mirror of
https://github.com/safishamsi/graphify.git
synced 2026-08-28 01:06:36 +00:00
bump version to 0.8.12 — security and wiki fixes
Security: _is_sensitive now flags underscore-prefixed names (api_token.txt, oauth_token.json) by replacing \b with lookarounds; adds _SENSITIVE_DIRS check on parent path components (parts[:-1]) so .ssh/, secrets/, .aws/ directories are always skipped; aligns both patterns to (?![a-zA-Z]) for consistent underscore-after-keyword behavior (#920) Fix: --wiki Relationships section always empty because _cross_community_links read community from node attrs (always None) instead of the communities dict; _god_node_article had the same bug and never linked to the owning community; fixed by building a node->community map in to_wiki() and threading it through (#925) Fix: --watch now respects .graphifyignore; patterns loaded once at startup, handler checks _is_ignored before extension filter so node_modules/, .venv/, build/ churn no longer triggers rebuilds (#928) Fix: C++ struct inheritance edges via base_class_clause; initialize base="" per iteration to prevent stale carryover (#915) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
2209a9c1e8
commit
47e65658c7
@@ -2,6 +2,13 @@
|
||||
|
||||
Full release notes with details on each version: [GitHub Releases](https://github.com/safishamsi/graphify/releases)
|
||||
|
||||
## 0.8.12 (2026-05-18)
|
||||
|
||||
- Security: `_is_sensitive` now correctly flags underscore-prefixed secret filenames (`api_token.txt`, `oauth_token.json`) — `\b` word boundary was treating `_` as a word char, so names like `api_token` never matched (#920)
|
||||
- Security: `_is_sensitive` now checks parent directories against a `_SENSITIVE_DIRS` blocklist (`.ssh`, `.aws`, `.gcloud`, `secrets`, etc.) and exempts code-extension files from name-pattern checks so `tokenizer.py` is never skipped (#920)
|
||||
- Fix: `--wiki` Relationships section was always empty — `_cross_community_links` read `community` from node attributes (always None) instead of the `communities` dict; `_god_node_article` had the same bug and never linked to the owning community (#925)
|
||||
- Fix: `--watch` now respects `.graphifyignore` — the event handler was checking extensions before the ignore filter, so paths inside `node_modules/`, `.venv/`, etc. triggered rebuilds (#928)
|
||||
|
||||
## 0.8.11 (2026-05-18)
|
||||
|
||||
- Fix: LLM empty choices / None message guard — Gemini and other providers return `choices=[]` on content-filtered HTTP 200 responses; now raises a clean error instead of crashing with IndexError (#924)
|
||||
|
||||
+22
-2
@@ -35,11 +35,25 @@ CORPUS_WARN_THRESHOLD = 50_000 # words - below this, warn "you may not need a
|
||||
CORPUS_UPPER_THRESHOLD = 500_000 # words - above this, warn about token cost
|
||||
FILE_COUNT_UPPER = 200 # files - above this, warn about token cost
|
||||
|
||||
# Files that may contain secrets - skip silently
|
||||
# Parent directories whose contents are always sensitive.
|
||||
# Checked against path.parts[:-1] (parents only) so a root-level file named
|
||||
# "credentials" or "secrets" is not falsely flagged by this stage.
|
||||
_SENSITIVE_DIRS = frozenset({
|
||||
".ssh", ".gnupg", ".aws", ".gcloud", "secrets", ".secrets", "credentials",
|
||||
})
|
||||
|
||||
# Files that may contain secrets - skip silently.
|
||||
# Uses lookarounds instead of \b so underscore-prefixed names like api_token.txt
|
||||
# match. Both patterns use (?![a-zA-Z]) so that the trailing-underscore behavior
|
||||
# is consistent: "secret_store.txt" IS flagged, "tokenizer.py" is NOT (because
|
||||
# "i" after "token" is alpha and blocks the match).
|
||||
# `token` is kept separate because its longer suffix "izer"/"ize" is the only
|
||||
# common false-positive; other keywords have no such well-known derivatives.
|
||||
_SENSITIVE_PATTERNS = [
|
||||
re.compile(r'(^|[\\/])\.(env|envrc)(\.|$)', re.IGNORECASE),
|
||||
re.compile(r'\.(pem|key|p12|pfx|cert|crt|der|p8)$', re.IGNORECASE),
|
||||
re.compile(r'\b(credential|secret|passwd|password|token|private_key)s?\b', re.IGNORECASE),
|
||||
re.compile(r'(?<![a-zA-Z0-9])(credential|secret|passwd|password|private_key)s?(?![a-zA-Z])', re.IGNORECASE),
|
||||
re.compile(r'(?<![a-zA-Z0-9])tokens?(?![a-zA-Z])', re.IGNORECASE),
|
||||
re.compile(r'(id_rsa|id_dsa|id_ecdsa|id_ed25519)(\.pub)?$'),
|
||||
re.compile(r'(\.netrc|\.pgpass|\.htpasswd)$', re.IGNORECASE),
|
||||
re.compile(r'(aws_credentials|gcloud_credentials|service.account)', re.IGNORECASE),
|
||||
@@ -66,6 +80,12 @@ _PAPER_SIGNAL_THRESHOLD = 3 # need at least this many signals to call it a pape
|
||||
|
||||
def _is_sensitive(path: Path) -> bool:
|
||||
"""Return True if this file likely contains secrets and should be skipped."""
|
||||
# Stage 1: any PARENT directory is a known secrets dir (parts[:-1] excludes
|
||||
# the filename itself so a root-level file named "credentials" is not falsely
|
||||
# skipped — the name patterns in Stage 2 handle the filename).
|
||||
if any(part in _SENSITIVE_DIRS for part in path.parts[:-1]):
|
||||
return True
|
||||
# Stage 2: filename pattern match
|
||||
name = path.name
|
||||
return any(p.search(name) for p in _SENSITIVE_PATTERNS)
|
||||
|
||||
|
||||
+46
-1
@@ -1012,7 +1012,7 @@ _C_CONFIG = LanguageConfig(
|
||||
|
||||
_CPP_CONFIG = LanguageConfig(
|
||||
ts_module="tree_sitter_cpp",
|
||||
class_types=frozenset({"class_specifier"}),
|
||||
class_types=frozenset({"class_specifier", "struct_specifier"}),
|
||||
function_types=frozenset({"function_definition"}),
|
||||
import_types=frozenset({"preproc_include"}),
|
||||
call_types=frozenset({"call_expression"}),
|
||||
@@ -1417,6 +1417,51 @@ def _extract_generic(path: Path, config: LanguageConfig) -> dict:
|
||||
if tid.type == "type_identifier":
|
||||
_emit_java_parent(_read_text(tid, source), "extends", line)
|
||||
|
||||
# C++-specific: inheritance via base_class_clause (class and struct).
|
||||
# tree-sitter-cpp shape:
|
||||
# class_specifier / struct_specifier
|
||||
# base_class_clause
|
||||
# access_specifier? ("public"/"protected"/"private") -- skip
|
||||
# "virtual"? -- skip
|
||||
# type_identifier -- "Base"
|
||||
# qualified_identifier -- "ns::Base"
|
||||
# template_type -- "Vec<int>"
|
||||
# Multiple bases are siblings separated by ',' tokens.
|
||||
if config.ts_module == "tree_sitter_cpp":
|
||||
for child in node.children:
|
||||
if child.type != "base_class_clause":
|
||||
continue
|
||||
for sub in child.children:
|
||||
base = ""
|
||||
if sub.type == "type_identifier":
|
||||
base = _read_text(sub, source)
|
||||
elif sub.type == "qualified_identifier":
|
||||
# Use the unqualified tail so "std::vector" matches
|
||||
# a "vector" node id if one exists in the graph;
|
||||
# fall back to the full qualified text otherwise.
|
||||
tail = sub.child_by_field_name("name")
|
||||
base = _read_text(tail, source) if tail else _read_text(sub, source)
|
||||
elif sub.type == "template_type":
|
||||
tname = sub.child_by_field_name("name")
|
||||
base = _read_text(tname, source) if tname else _read_text(sub, source)
|
||||
else:
|
||||
continue
|
||||
if not base:
|
||||
continue
|
||||
base_nid = _make_id(stem, base)
|
||||
if base_nid not in seen_ids:
|
||||
base_nid = _make_id(base)
|
||||
if base_nid not in seen_ids:
|
||||
nodes.append({
|
||||
"id": base_nid,
|
||||
"label": base,
|
||||
"file_type": "code",
|
||||
"source_file": "",
|
||||
"source_location": "",
|
||||
})
|
||||
seen_ids.add(base_nid)
|
||||
add_edge(class_nid, base_nid, "inherits", line)
|
||||
|
||||
# Find body and recurse
|
||||
body = _find_body(node, config)
|
||||
if body:
|
||||
|
||||
+24
-1
@@ -109,7 +109,14 @@ def _git_head() -> str | None:
|
||||
return None
|
||||
|
||||
|
||||
from graphify.detect import CODE_EXTENSIONS, DOC_EXTENSIONS, PAPER_EXTENSIONS, IMAGE_EXTENSIONS
|
||||
from graphify.detect import (
|
||||
CODE_EXTENSIONS,
|
||||
DOC_EXTENSIONS,
|
||||
PAPER_EXTENSIONS,
|
||||
IMAGE_EXTENSIONS,
|
||||
_load_graphifyignore,
|
||||
_is_ignored,
|
||||
)
|
||||
|
||||
_WATCHED_EXTENSIONS = CODE_EXTENSIONS | DOC_EXTENSIONS | PAPER_EXTENSIONS | IMAGE_EXTENSIONS
|
||||
_CODE_EXTENSIONS = CODE_EXTENSIONS
|
||||
@@ -647,12 +654,28 @@ def watch(watch_path: Path, debounce: float = 3.0) -> None:
|
||||
pending: bool = False
|
||||
changed: set[Path] = set()
|
||||
|
||||
# Load .graphifyignore patterns ONCE at startup so the handler does not
|
||||
# re-parse the file on every filesystem event. Watchdog's handler runs on
|
||||
# the observer thread and is invoked for every event the OS delivers
|
||||
# (Time Machine writes, Docker/Colima VM I/O, Spotlight indexing, …) —
|
||||
# without this short-circuit a busy volume can saturate a CPU core
|
||||
# discarding events one extension at a time. (gh-928)
|
||||
watch_root_for_ignore = watch_path.resolve()
|
||||
ignore_patterns = _load_graphifyignore(watch_root_for_ignore)
|
||||
|
||||
class Handler(FileSystemEventHandler):
|
||||
def on_any_event(self, event):
|
||||
nonlocal last_trigger, pending
|
||||
if event.is_directory:
|
||||
return
|
||||
path = Path(event.src_path)
|
||||
# Check .graphifyignore BEFORE the extension/dotfile/out filters so
|
||||
# the cheapest short-circuit for users with broad ignore patterns
|
||||
# (node_modules/, .venv/, build/, …) fires first. _is_ignored
|
||||
# tolerates absolute paths outside watch_root via its internal
|
||||
# relative_to guard, so a stray symlinked event won't raise.
|
||||
if ignore_patterns and _is_ignored(path, watch_root_for_ignore, ignore_patterns):
|
||||
return
|
||||
if path.suffix.lower() not in _WATCHED_EXTENSIONS:
|
||||
return
|
||||
if any(part.startswith(".") for part in path.parts):
|
||||
|
||||
+12
-8
@@ -23,13 +23,12 @@ def _safe_filename(name: str) -> str:
|
||||
return s[:200] if s else 'unnamed'
|
||||
|
||||
|
||||
def _cross_community_links(G: nx.Graph, nodes: list[str], own_cid: int, labels: dict[int, str]) -> list[tuple[str, int]]:
|
||||
def _cross_community_links(G: nx.Graph, nodes: list[str], own_cid: int, labels: dict[int, str], node_community: dict[str, int]) -> list[tuple[str, int]]:
|
||||
"""Return (community_label, edge_count) pairs for cross-community connections, sorted descending."""
|
||||
counts: dict[str, int] = Counter()
|
||||
for nid in nodes:
|
||||
for neighbor in G.neighbors(nid):
|
||||
nd = G.nodes[neighbor]
|
||||
ncid = nd.get("community")
|
||||
ncid = node_community.get(neighbor)
|
||||
if ncid is not None and ncid != own_cid:
|
||||
counts[labels.get(ncid, f"Community {ncid}")] += 1
|
||||
return sorted(counts.items(), key=lambda x: -x[1])
|
||||
@@ -42,9 +41,10 @@ def _community_article(
|
||||
label: str,
|
||||
labels: dict[int, str],
|
||||
cohesion: float | None,
|
||||
node_community: dict[str, int] | None = None,
|
||||
) -> str:
|
||||
top_nodes = sorted(nodes, key=lambda n: G.degree(n), reverse=True)[:25]
|
||||
cross = _cross_community_links(G, nodes, cid, labels)
|
||||
cross = _cross_community_links(G, nodes, cid, labels, node_community or {})
|
||||
|
||||
# Edge confidence breakdown
|
||||
conf_counts: Counter = Counter()
|
||||
@@ -102,11 +102,11 @@ def _community_article(
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _god_node_article(G: nx.Graph, nid: str, labels: dict[int, str]) -> str:
|
||||
def _god_node_article(G: nx.Graph, nid: str, labels: dict[int, str], node_community: dict[str, int] | None = None) -> str:
|
||||
d = G.nodes[nid]
|
||||
node_label = d.get("label", nid)
|
||||
src = d.get("source_file", "")
|
||||
cid = d.get("community")
|
||||
cid = (node_community or {}).get(nid)
|
||||
community_name = labels.get(cid, f"Community {cid}") if cid is not None else None
|
||||
|
||||
lines: list[str] = []
|
||||
@@ -217,6 +217,10 @@ def to_wiki(
|
||||
cohesion = cohesion or {}
|
||||
god_nodes_data = god_nodes_data or []
|
||||
|
||||
# Build node->community lookup once; node attrs never carry community (it lives in
|
||||
# the communities dict), so _cross_community_links and _god_node_article need this.
|
||||
node_community: dict[str, int] = {n: cid for cid, nodes in communities.items() for n in nodes}
|
||||
|
||||
count = 0
|
||||
used_slugs: set[str] = set()
|
||||
|
||||
@@ -232,7 +236,7 @@ def to_wiki(
|
||||
# Community articles
|
||||
for cid, nodes in communities.items():
|
||||
label = labels.get(cid, f"Community {cid}")
|
||||
article = _community_article(G, cid, nodes, label, labels, cohesion.get(cid))
|
||||
article = _community_article(G, cid, nodes, label, labels, cohesion.get(cid), node_community)
|
||||
slug = _unique_slug(_safe_filename(label))
|
||||
(out / f"{slug}.md").write_text(article, encoding="utf-8")
|
||||
count += 1
|
||||
@@ -241,7 +245,7 @@ def to_wiki(
|
||||
for node_data in god_nodes_data:
|
||||
nid = node_data.get("id")
|
||||
if nid and nid in G:
|
||||
article = _god_node_article(G, nid, labels)
|
||||
article = _god_node_article(G, nid, labels, node_community)
|
||||
slug = _unique_slug(_safe_filename(node_data['label']))
|
||||
(out / f"{slug}.md").write_text(article, encoding="utf-8")
|
||||
count += 1
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "graphifyy"
|
||||
version = "0.8.11"
|
||||
version = "0.8.12"
|
||||
description = "AI coding assistant skill (Claude Code, Codex, OpenCode, Cursor, Gemini CLI, Aider, OpenClaw, Factory Droid, Trae, Hermes, Kiro, Pi, Google Antigravity) - turn any folder of code, docs, papers, images, or videos into a queryable knowledge graph"
|
||||
readme = "README.md"
|
||||
license = { file = "LICENSE" }
|
||||
|
||||
Vendored
+13
@@ -22,6 +22,19 @@ private:
|
||||
}
|
||||
};
|
||||
|
||||
class AuthedHttpClient : public HttpClient {
|
||||
public:
|
||||
AuthedHttpClient(const std::string& baseUrl, const std::string& token)
|
||||
: HttpClient(baseUrl), token_(token) {}
|
||||
|
||||
private:
|
||||
std::string token_;
|
||||
};
|
||||
|
||||
struct RetryingHttpClient : HttpClient {
|
||||
int maxRetries;
|
||||
};
|
||||
|
||||
int main() {
|
||||
HttpClient client("https://api.example.com");
|
||||
std::string response = client.get("/users");
|
||||
|
||||
+55
-1
@@ -1,5 +1,5 @@
|
||||
from pathlib import Path
|
||||
from graphify.detect import classify_file, count_words, detect, detect_incremental, save_manifest, FileType, _looks_like_paper, _is_ignored, _load_graphifyignore
|
||||
from graphify.detect import classify_file, count_words, detect, detect_incremental, save_manifest, FileType, _looks_like_paper, _is_ignored, _load_graphifyignore, _is_sensitive
|
||||
|
||||
FIXTURES = Path(__file__).parent / "fixtures"
|
||||
|
||||
@@ -501,3 +501,57 @@ def test_negation_ancestor_itself_reincluded(tmp_path):
|
||||
patterns = _load_graphifyignore(tmp_path)
|
||||
# vendor/ is excluded then re-included; ancestor eval returns False so file is evaluated on its own
|
||||
assert not _is_ignored(f, tmp_path, patterns)
|
||||
|
||||
|
||||
# Regression tests for #920 - sensitive pattern misses underscore-prefixed names
|
||||
def test_sensitive_flags_api_token_txt():
|
||||
assert _is_sensitive(Path("api_token.txt"))
|
||||
|
||||
def test_sensitive_flags_oauth_token_json():
|
||||
assert _is_sensitive(Path("oauth_token.json"))
|
||||
|
||||
def test_sensitive_flags_underscore_secret():
|
||||
assert _is_sensitive(Path("app_secret.yaml"))
|
||||
|
||||
def test_sensitive_does_not_flag_tokenizer_py():
|
||||
assert not _is_sensitive(Path("tokenizer.py"))
|
||||
|
||||
def test_sensitive_does_not_flag_tokenize_py():
|
||||
assert not _is_sensitive(Path("tokenize.py"))
|
||||
|
||||
def test_sensitive_flags_passwords_py():
|
||||
# passwords.py is just as likely a secret store as passwords.txt — code ext is no excuse
|
||||
assert _is_sensitive(Path("passwords.py"))
|
||||
|
||||
def test_sensitive_flags_ssh_dir():
|
||||
assert _is_sensitive(Path("/home/user/.ssh/id_rsa"))
|
||||
|
||||
def test_sensitive_flags_secrets_dir():
|
||||
assert _is_sensitive(Path("config/secrets/db.json"))
|
||||
|
||||
def test_sensitive_flags_token_txt():
|
||||
assert _is_sensitive(Path("token.txt"))
|
||||
|
||||
def test_sensitive_flags_credentials_json():
|
||||
assert _is_sensitive(Path("credentials.json"))
|
||||
|
||||
def test_sensitive_does_not_flag_root_file_named_credentials():
|
||||
# A root-level file called "credentials" (no parent dir named credentials)
|
||||
# must NOT be flagged by Stage 1; Stage 2 name-pattern check catches it instead.
|
||||
# Specifically: Path("credentials").parts == ('credentials',) which is parts[:-1] == ()
|
||||
# so the dir check passes. The name pattern for "credential" then picks it up.
|
||||
# What we are asserting here is that the Stage 1 check uses parts[:-1], not parts.
|
||||
p = Path("credentials")
|
||||
# The name pattern WILL match "credentials" (it's a sensitive name), but the
|
||||
# false-flag we fixed was Stage 1 matching on the filename itself as a "dir".
|
||||
# Verify the whole function still returns True (via name pattern, not dir check).
|
||||
assert _is_sensitive(p)
|
||||
|
||||
def test_sensitive_secret_handler_txt():
|
||||
# Both patterns now use (?![a-zA-Z]) so underscore after keyword is allowed.
|
||||
# "secret_handler.txt": "secret" followed by "_" (not alpha) → flagged.
|
||||
assert _is_sensitive(Path("secret_handler.txt"))
|
||||
|
||||
def test_sensitive_token_config_yaml():
|
||||
# "token_config.yaml": "token" followed by "_" (not alpha) → flagged.
|
||||
assert _is_sensitive(Path("token_config.yaml"))
|
||||
|
||||
@@ -149,6 +149,30 @@ def test_cpp_import_edges_have_import_context():
|
||||
assert all(e.get("context") == "import" for e in import_edges)
|
||||
|
||||
|
||||
def test_cpp_class_inherits_edge():
|
||||
"""Regression for #915: `class Derived : public Base {}` should emit an inherits edge."""
|
||||
r = extract_cpp(FIXTURES / "sample.cpp")
|
||||
node_by_id = {n["id"]: n["label"] for n in r["nodes"]}
|
||||
found = any(
|
||||
"AuthedHttpClient" in node_by_id.get(e["source"], "")
|
||||
and "HttpClient" in node_by_id.get(e["target"], "")
|
||||
for e in r["edges"] if e["relation"] == "inherits"
|
||||
)
|
||||
assert found, "AuthedHttpClient should have inherits edge to HttpClient"
|
||||
|
||||
|
||||
def test_cpp_struct_inherits_edge():
|
||||
"""Structs use the same `: Base` syntax as classes and must also emit inherits."""
|
||||
r = extract_cpp(FIXTURES / "sample.cpp")
|
||||
node_by_id = {n["id"]: n["label"] for n in r["nodes"]}
|
||||
found = any(
|
||||
"RetryingHttpClient" in node_by_id.get(e["source"], "")
|
||||
and "HttpClient" in node_by_id.get(e["target"], "")
|
||||
for e in r["edges"] if e["relation"] == "inherits"
|
||||
)
|
||||
assert found, "RetryingHttpClient (struct) should have inherits edge to HttpClient"
|
||||
|
||||
|
||||
# ── Ruby ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_ruby_no_error():
|
||||
|
||||
@@ -208,3 +208,89 @@ def test_rebuild_code_skips_cluster_when_topology_unchanged(tmp_path, monkeypatc
|
||||
assert _rebuild_code(tmp_path)
|
||||
assert _rebuild_code(tmp_path)
|
||||
assert calls["n"] == 1
|
||||
|
||||
|
||||
# --- .graphifyignore honored in watch handler (gh-928) ---
|
||||
|
||||
|
||||
def _watchdog_available() -> bool:
|
||||
try:
|
||||
import watchdog # noqa: F401
|
||||
return True
|
||||
except ImportError:
|
||||
return False
|
||||
|
||||
|
||||
@pytest.mark.skipif(not _watchdog_available(), reason="watchdog not installed")
|
||||
def test_watch_handler_honors_graphifyignore(tmp_path, monkeypatch):
|
||||
"""gh-928: the watch Handler must short-circuit paths matching
|
||||
.graphifyignore so busy volumes (node_modules churn, build artefacts,
|
||||
Time Machine writes, …) don't wake the rebuild pipeline.
|
||||
"""
|
||||
import threading
|
||||
from graphify import watch as watch_mod
|
||||
|
||||
(tmp_path / ".graphifyignore").write_text("node_modules/\nbuild/\n", encoding="utf-8")
|
||||
(tmp_path / "node_modules").mkdir()
|
||||
(tmp_path / "build").mkdir()
|
||||
|
||||
rebuild_calls: list[Path] = []
|
||||
notify_calls: list[Path] = []
|
||||
monkeypatch.setattr(watch_mod, "_rebuild_code", lambda p, **kw: rebuild_calls.append(p) or True)
|
||||
monkeypatch.setattr(watch_mod, "_notify_only", lambda p: notify_calls.append(p))
|
||||
|
||||
# Run watch() in a thread with a short debounce so we can verify the
|
||||
# post-debounce dispatch path actually runs on real events.
|
||||
t = threading.Thread(target=watch_mod.watch, args=(tmp_path,), kwargs={"debounce": 0.2}, daemon=True)
|
||||
t.start()
|
||||
time.sleep(0.5) # let observer.start() settle
|
||||
|
||||
# Ignored writes — handler must drop these.
|
||||
(tmp_path / "node_modules" / "junk.js").write_text("// noise\n", encoding="utf-8")
|
||||
(tmp_path / "build" / "out.py").write_text("x = 1\n", encoding="utf-8")
|
||||
time.sleep(1.0)
|
||||
assert rebuild_calls == [], "ignored writes triggered a rebuild"
|
||||
assert notify_calls == [], "ignored writes triggered a notify"
|
||||
|
||||
# Non-ignored write — handler must accept and (after debounce) dispatch.
|
||||
(tmp_path / "app.py").write_text("def f():\n return 1\n", encoding="utf-8")
|
||||
deadline = time.monotonic() + 5.0
|
||||
while time.monotonic() < deadline and not rebuild_calls:
|
||||
time.sleep(0.1)
|
||||
assert rebuild_calls, "non-ignored .py write should have triggered _rebuild_code"
|
||||
|
||||
|
||||
@pytest.mark.skipif(not _watchdog_available(), reason="watchdog not installed")
|
||||
def test_watch_loads_graphifyignore_once(tmp_path, monkeypatch):
|
||||
"""gh-928: .graphifyignore must be parsed exactly once at watch() startup,
|
||||
not per filesystem event. Otherwise busy volumes re-read the file
|
||||
thousands of times per second.
|
||||
"""
|
||||
import threading
|
||||
from graphify import watch as watch_mod
|
||||
from graphify import detect as detect_mod
|
||||
|
||||
(tmp_path / ".graphifyignore").write_text("ignored/\n", encoding="utf-8")
|
||||
(tmp_path / "ignored").mkdir()
|
||||
|
||||
calls = {"n": 0}
|
||||
real_loader = detect_mod._load_graphifyignore
|
||||
|
||||
def counting_loader(root):
|
||||
calls["n"] += 1
|
||||
return real_loader(root)
|
||||
|
||||
# Patch the symbol the watch module imported at module-load time.
|
||||
monkeypatch.setattr(watch_mod, "_load_graphifyignore", counting_loader)
|
||||
monkeypatch.setattr(watch_mod, "_rebuild_code", lambda p, **kw: True)
|
||||
monkeypatch.setattr(watch_mod, "_notify_only", lambda p: None)
|
||||
|
||||
t = threading.Thread(target=watch_mod.watch, args=(tmp_path,), kwargs={"debounce": 0.2}, daemon=True)
|
||||
t.start()
|
||||
time.sleep(0.5)
|
||||
|
||||
# Generate many events; loader must not be called again.
|
||||
for i in range(50):
|
||||
(tmp_path / "ignored" / f"f{i}.py").write_text("x\n", encoding="utf-8")
|
||||
time.sleep(0.7)
|
||||
assert calls["n"] == 1, f"_load_graphifyignore called {calls['n']} times; expected 1"
|
||||
|
||||
@@ -137,3 +137,31 @@ def test_community_article_truncation_notice(tmp_path):
|
||||
to_wiki(G, communities, tmp_path, community_labels={0: "Big Community"})
|
||||
article = (tmp_path / "Big_Community.md").read_text()
|
||||
assert "and 5 more nodes" in article
|
||||
|
||||
|
||||
# Regression tests for #925 - cross-community links always empty when node attrs lack community
|
||||
def test_cross_community_links_without_node_community_attrs(tmp_path):
|
||||
"""Cross-community links must work even when nodes have no 'community' attribute (#925)."""
|
||||
G = nx.Graph()
|
||||
G.add_node("n1", label="parse", file_type="code", source_file="parser.py")
|
||||
G.add_node("n2", label="render", file_type="code", source_file="renderer.py")
|
||||
G.add_edge("n1", "n2", relation="references", confidence="INFERRED", weight=1.0)
|
||||
communities = {0: ["n1"], 1: ["n2"]}
|
||||
labels = {0: "Parsing", 1: "Rendering"}
|
||||
to_wiki(G, communities, tmp_path, community_labels=labels)
|
||||
article = (tmp_path / "Parsing.md").read_text()
|
||||
assert "[[Rendering]]" in article
|
||||
|
||||
|
||||
def test_god_node_article_community_without_node_attr(tmp_path):
|
||||
"""God node article must show community name even when node has no 'community' attr (#925)."""
|
||||
G = nx.Graph()
|
||||
G.add_node("n1", label="parse", file_type="code", source_file="parser.py")
|
||||
G.add_node("n2", label="validate", file_type="code", source_file="parser.py")
|
||||
G.add_edge("n1", "n2", relation="calls", confidence="EXTRACTED", weight=1.0)
|
||||
communities = {0: ["n1", "n2"]}
|
||||
labels = {0: "Core Logic"}
|
||||
god_nodes = [{"id": "n1", "label": "parse", "degree": 1}]
|
||||
to_wiki(G, communities, tmp_path, community_labels=labels, god_nodes_data=god_nodes)
|
||||
article = (tmp_path / "parse.md").read_text()
|
||||
assert "[[Core Logic]]" in article
|
||||
|
||||
Reference in New Issue
Block a user