From dbde78ca03df43b3a69dcfe5d75c287ed3f7e522 Mon Sep 17 00:00:00 2001 From: Safi Date: Tue, 21 Apr 2026 22:26:17 +0100 Subject: [PATCH] Fix four bugs from community PRs - cache: skip directory source_file in save_cached to prevent IsADirectoryError (#444) - report: skip structural-only communities with no real nodes (#443) - hooks: allow @ in python path allowlist for Homebrew paths (#474) - watch: keep source_file paths project-relative after rebuild (#434) Co-Authored-By: Claude Sonnet 4.6 --- graphify/cache.py | 9 ++++++++- graphify/hooks.py | 2 +- graphify/report.py | 2 ++ graphify/watch.py | 30 ++++++++++++++++++++++++++++-- 4 files changed, 39 insertions(+), 4 deletions(-) diff --git a/graphify/cache.py b/graphify/cache.py index 992e3d1b..af153d93 100644 --- a/graphify/cache.py +++ b/graphify/cache.py @@ -75,8 +75,15 @@ def save_cached(path: Path, result: dict, root: Path = Path(".")) -> None: Stores as graphify-out/cache/{hash}.json where hash = SHA256 of current file contents. result should be a dict with 'nodes' and 'edges' lists. + + No-ops if `path` is not a regular file. Subagent-produced semantic fragments + occasionally carry a directory path in `source_file`; skipping them prevents + IsADirectoryError from aborting the whole batch. """ - h = file_hash(path, root) + p = Path(path) + if not p.is_file(): + return + h = file_hash(p, root) entry = cache_dir(root) / f"{h}.json" tmp = entry.with_suffix(".tmp") try: diff --git a/graphify/hooks.py b/graphify/hooks.py index a76ed7c5..2a79ec89 100644 --- a/graphify/hooks.py +++ b/graphify/hooks.py @@ -21,7 +21,7 @@ if [ -n "$GRAPHIFY_BIN" ]; then # Allowlist: only keep characters valid in a filesystem path to prevent # injection if the shebang contains shell metacharacters case "$GRAPHIFY_PYTHON" in - *[!a-zA-Z0-9/_.-]*) GRAPHIFY_PYTHON="" ;; + *[!a-zA-Z0-9/_.@-]*) GRAPHIFY_PYTHON="" ;; esac if [ -n "$GRAPHIFY_PYTHON" ] && ! "$GRAPHIFY_PYTHON" -c "import graphify" 2>/dev/null; then GRAPHIFY_PYTHON="" diff --git a/graphify/report.py b/graphify/report.py index 5ebd2ea6..82657e1b 100644 --- a/graphify/report.py +++ b/graphify/report.py @@ -111,6 +111,8 @@ def generate( score = cohesion_scores.get(cid, 0.0) # Filter method/function stubs from display - they're structural noise real_nodes = [n for n in nodes if not _ifn(G, n)] + if not real_nodes: + continue display = [G.nodes[n].get("label", n) for n in real_nodes[:8]] suffix = f" (+{len(real_nodes)-8} more)" if len(real_nodes) > 8 else "" lines += [ diff --git a/graphify/watch.py b/graphify/watch.py index 56ebbe81..5babd89f 100644 --- a/graphify/watch.py +++ b/graphify/watch.py @@ -12,11 +12,35 @@ _WATCHED_EXTENSIONS = CODE_EXTENSIONS | DOC_EXTENSIONS | PAPER_EXTENSIONS | IMAG _CODE_EXTENSIONS = CODE_EXTENSIONS +def _report_root_label(watch_path: Path) -> str: + if watch_path.is_absolute(): + return watch_path.name or str(watch_path) + return Path.cwd().name if watch_path == Path(".") else str(watch_path) + + +def _relativize_source_files(payload: dict, root: Path) -> None: + for bucket in ("nodes", "edges", "hyperedges"): + for item in payload.get(bucket, []): + source = item.get("source_file") + if not source: + continue + source_path = Path(source) + if not source_path.is_absolute(): + continue + try: + item["source_file"] = str(source_path.resolve().relative_to(root)) + except ValueError: + continue + + def _rebuild_code(watch_path: Path, *, follow_symlinks: bool = False) -> bool: """Re-run AST extraction + build + cluster + report for code files. No LLM needed. Returns True on success, False on error. """ + watch_root = watch_path.resolve() + project_root = Path.cwd().resolve() if not watch_path.is_absolute() else watch_root + report_root = _report_root_label(watch_path) try: from graphify.extract import extract from graphify.detect import detect @@ -33,7 +57,7 @@ def _rebuild_code(watch_path: Path, *, follow_symlinks: bool = False) -> bool: print("[graphify watch] No code files found - nothing to rebuild.") return False - result = extract(code_files, cache_root=watch_path) + result = extract(code_files, cache_root=watch_root) # Preserve semantic nodes/edges from a previous full run. # AST-only rebuild replaces code nodes; doc/paper/image nodes are kept. @@ -57,6 +81,8 @@ def _rebuild_code(watch_path: Path, *, follow_symlinks: bool = False) -> bool: except Exception: pass # corrupt graph.json - proceed with AST-only + _relativize_source_files(result, project_root) + detection = { "files": {"code": [str(f) for f in code_files], "document": [], "paper": [], "image": []}, "total_files": len(code_files), @@ -74,7 +100,7 @@ def _rebuild_code(watch_path: Path, *, follow_symlinks: bool = False) -> bool: out.mkdir(exist_ok=True) report = generate(G, communities, cohesion, labels, gods, surprises, detection, - {"input": 0, "output": 0}, str(watch_path), suggested_questions=questions) + {"input": 0, "output": 0}, report_root, suggested_questions=questions) (out / "GRAPH_REPORT.md").write_text(report, encoding="utf-8") to_json(G, communities, str(out / "graph.json"))