From c8969edd3c945f6a54b09531358fbcc2568fd2df Mon Sep 17 00:00:00 2001 From: Safi Date: Sat, 2 May 2026 08:53:17 +0100 Subject: [PATCH 01/18] Fix semantic node preservation on incremental rebuild and detach git hooks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit watch.py: filter preserved nodes by ID membership in new AST output instead of file_type — INFERRED/AMBIGUOUS nodes on code files also carry file_type=code and were being wrongly dropped, triggering the to_json safety check refusal. hooks.py: detach post-commit and post-checkout rebuilds with nohup + disown so git commit returns immediately instead of blocking for the full rebuild duration. Rebuild log written to ~/.cache/graphify-rebuild.log. Co-Authored-By: Claude Sonnet 4.6 --- graphify/hooks.py | 20 +++++++++++++++----- graphify/watch.py | 21 +++++++++++++-------- 2 files changed, 28 insertions(+), 13 deletions(-) diff --git a/graphify/hooks.py b/graphify/hooks.py index e921155b..9341b854 100644 --- a/graphify/hooks.py +++ b/graphify/hooks.py @@ -61,7 +61,13 @@ fi """ + _PYTHON_DETECT + """ export GRAPHIFY_CHANGED="$CHANGED" -$GRAPHIFY_PYTHON -c " + +# Run rebuild detached so git commit returns immediately. +# Full repo rebuilds can take hours; blocking the post-commit hook stalls the shell. +_GRAPHIFY_LOG="${HOME}/.cache/graphify-rebuild.log" +mkdir -p "$(dirname "$_GRAPHIFY_LOG")" +echo "[graphify hook] launching background rebuild (log: $_GRAPHIFY_LOG)" +nohup $GRAPHIFY_PYTHON -c " import os, sys from pathlib import Path @@ -79,7 +85,8 @@ try: except Exception as exc: print(f'[graphify hook] Rebuild failed: {exc}') sys.exit(1) -" +" > "$_GRAPHIFY_LOG" 2>&1 < /dev/null & +disown 2>/dev/null || true # graphify-hook-end """ @@ -111,8 +118,10 @@ GIT_DIR=$(git rev-parse --git-dir 2>/dev/null) [ -f "$GIT_DIR/CHERRY_PICK_HEAD" ] && exit 0 """ + _PYTHON_DETECT + """ -echo "[graphify] Branch switched - rebuilding knowledge graph (code files)..." -$GRAPHIFY_PYTHON -c " +_GRAPHIFY_LOG="${HOME}/.cache/graphify-rebuild.log" +mkdir -p "$(dirname "$_GRAPHIFY_LOG")" +echo "[graphify] Branch switched - launching background rebuild (log: $_GRAPHIFY_LOG)" +nohup $GRAPHIFY_PYTHON -c " from graphify.watch import _rebuild_code from pathlib import Path import sys @@ -121,7 +130,8 @@ try: except Exception as exc: print(f'[graphify] Rebuild failed: {exc}') sys.exit(1) -" +" > "$_GRAPHIFY_LOG" 2>&1 < /dev/null & +disown 2>/dev/null || true # graphify-checkout-hook-end """ diff --git a/graphify/watch.py b/graphify/watch.py index 1e07bd39..c77572a4 100644 --- a/graphify/watch.py +++ b/graphify/watch.py @@ -60,20 +60,25 @@ def _rebuild_code(watch_path: Path, *, follow_symlinks: bool = False) -> bool: 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. + # AST-only rebuild replaces nodes for changed files; everything else is kept. + # Filter by node ID membership in the new AST output, not by file_type — + # INFERRED/AMBIGUOUS nodes extracted from code files also carry file_type="code" + # and would be wrongly dropped by a file_type-based filter. out = watch_path / "graphify-out" existing_graph = out / "graph.json" if existing_graph.exists(): try: existing = json.loads(existing_graph.read_text(encoding="utf-8")) - code_ids = {n["id"] for n in existing.get("nodes", []) if n.get("file_type") == "code"} - sem_nodes = [n for n in existing.get("nodes", []) if n.get("file_type") != "code"] - sem_edges = [e for e in existing.get("links", existing.get("edges", [])) - if e.get("confidence") in ("INFERRED", "AMBIGUOUS") - or (e.get("source") not in code_ids and e.get("target") not in code_ids)] + new_ast_ids = {n["id"] for n in result["nodes"]} + preserved_nodes = [n for n in existing.get("nodes", []) if n["id"] not in new_ast_ids] + all_ids = new_ast_ids | {n["id"] for n in preserved_nodes} + preserved_edges = [ + e for e in existing.get("links", existing.get("edges", [])) + if e.get("source") in all_ids and e.get("target") in all_ids + ] result = { - "nodes": result["nodes"] + sem_nodes, - "edges": result["edges"] + sem_edges, + "nodes": result["nodes"] + preserved_nodes, + "edges": result["edges"] + preserved_edges, "hyperedges": existing.get("hyperedges", []), "input_tokens": 0, "output_tokens": 0, From 028f5853564b4759effcb872b63b26aff3563986 Mon Sep 17 00:00:00 2001 From: Safi Date: Sat, 2 May 2026 08:56:20 +0100 Subject: [PATCH 02/18] Fix ambiguous cross-file call resolution inflating god_nodes and guard cluster-only to_html MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit extract.py: build name -> all candidates map instead of last-write-wins dict. Skip cross-file INFERRED calls where the callee name resolves to 2+ nodes (common names like log/execute/find with no import evidence to pick the right target) — prevents spurious edges from polluting god_nodes degree ranking. __main__.py: wrap cluster-only to_html in try/except ValueError so large graphs (>5000 nodes) don't crash the cluster command. Co-Authored-By: Claude Sonnet 4.6 --- graphify/__main__.py | 5 ++++- graphify/extract.py | 18 ++++++++++++++---- 2 files changed, 18 insertions(+), 5 deletions(-) diff --git a/graphify/__main__.py b/graphify/__main__.py index 2520d6b0..3df4467d 100644 --- a/graphify/__main__.py +++ b/graphify/__main__.py @@ -1412,7 +1412,10 @@ def main() -> None: out = watch_path / "graphify-out" (out / "GRAPH_REPORT.md").write_text(report, encoding="utf-8") to_json(G, communities, str(out / "graph.json")) - to_html(G, communities, str(out / "graph.html"), community_labels=labels or None) + try: + to_html(G, communities, str(out / "graph.html"), community_labels=labels or None) + except ValueError as _viz_err: + print(f"[graphify] Skipped graph.html: {_viz_err}") print(f"Done — {len(communities)} communities. GRAPH_REPORT.md, graph.json and graph.html updated.") elif cmd == "update": diff --git a/graphify/extract.py b/graphify/extract.py index a6951644..9380e259 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -3567,12 +3567,16 @@ def extract(paths: list[Path], cache_root: Path | None = None) -> dict: # Cross-file call resolution for all languages # Each extractor saved unresolved calls in raw_calls. Now that we have all # nodes from all files, resolve any callee that exists in another file. - global_label_to_nid: dict[str, str] = {} + # Build name → ALL matching node IDs so we can skip ambiguous common names + # (e.g. "log", "execute", "find") that appear in multiple files — resolving + # those inflates god_nodes ranking with spurious cross-file edges. + global_label_to_nids: dict[str, list[str]] = {} for n in all_nodes: raw = n.get("label", "") normalised = raw.strip("()").lstrip(".") if normalised: - global_label_to_nid[normalised.lower()] = n["id"] + key = normalised.lower() + global_label_to_nids.setdefault(key, []).append(n["id"]) existing_pairs = {(e["source"], e["target"]) for e in all_edges} for result in per_file: @@ -3584,9 +3588,15 @@ def extract(paths: list[Path], cache_root: Path | None = None) -> dict: # and collides with any top-level function named "log" in the corpus. if rc.get("is_member_call"): continue - tgt = global_label_to_nid.get(callee.lower()) + candidates = global_label_to_nids.get(callee.lower(), []) + # Skip ambiguous names that resolve to multiple nodes — these are + # common short names (log, execute, find) with no import evidence + # to pick the right target; emitting all edges inflates god_nodes. + if len(candidates) != 1: + continue + tgt = candidates[0] caller = rc["caller_nid"] - if tgt and tgt != caller and (caller, tgt) not in existing_pairs: + if tgt != caller and (caller, tgt) not in existing_pairs: existing_pairs.add((caller, tgt)) all_edges.append({ "source": caller, From a4149dffcdcad0bf4a5a84ad5a9d6a59b80cfe32 Mon Sep 17 00:00:00 2001 From: Safi Date: Sat, 2 May 2026 08:57:57 +0100 Subject: [PATCH 03/18] Bump version to 0.6.3 Co-Authored-By: Claude Sonnet 4.6 --- CHANGELOG.md | 7 +++++++ pyproject.toml | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 074e469a..c4c35742 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ Full release notes with details on each version: [GitHub Releases](https://github.com/safishamsi/graphify/releases) +## 0.6.3 (2026-05-02) + +- Fix: incremental rebuild (`graphify update`, post-commit hook) dropped INFERRED/AMBIGUOUS semantic nodes extracted from code files — node preservation now filters by ID membership in the new AST output instead of `file_type`, so LLM-extracted call/data-flow edges survive code-only rebuilds (#653) +- Fix: post-commit and post-checkout hooks blocked `git commit` for the full rebuild duration (hours on large repos) — rebuilds now detach via `nohup & disown`, git returns in ~100ms, log written to `~/.cache/graphify-rebuild.log` (#650) +- Fix: cross-file INFERRED `calls` resolution used a last-write-wins name map, causing common short names (`log`, `execute`, `find`) to accumulate hundreds of spurious edges and dominate god_nodes ranking — resolution now skips any callee name that matches 2+ candidates (ambiguous, no import evidence to pick the right target) (#543) +- Fix: `cluster-only` command crashed on graphs with >5000 nodes due to unguarded `to_html` call — now wrapped in try/except ValueError matching the watch/hook path (#541) + ## 0.6.2 (2026-05-01) - Fix: Kimi K2.6 reasoning mode consumed entire token budget leaving `content` empty — thinking now disabled on Moonshot calls so graphs actually populate (#623) diff --git a/pyproject.toml b/pyproject.toml index ee2b7596..6b247b30 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "graphifyy" -version = "0.6.2" +version = "0.6.3" description = "AI coding assistant skill (Claude Code, Codex, OpenCode, Cursor, Gemini CLI, Aider, OpenClaw, Factory Droid, Trae, Hermes, Kiro, Google Antigravity) - turn any folder of code, docs, papers, images, or videos into a queryable knowledge graph" readme = "README.md" license = { file = "LICENSE" } From 8c9ee1818bc3e2ade968806b3b256d181d56137f Mon Sep 17 00:00:00 2001 From: Safi Date: Sat, 2 May 2026 09:35:28 +0100 Subject: [PATCH 04/18] Fix Codex PreToolUse hook failing on Windows Replace bash-only [ -f ] file check with a cross-platform Python one-liner so the hook works on Windows where cmd.exe has no [ builtin. Co-Authored-By: Claude Sonnet 4.6 --- graphify/__main__.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/graphify/__main__.py b/graphify/__main__.py index 3df4467d..e2f6024e 100644 --- a/graphify/__main__.py +++ b/graphify/__main__.py @@ -718,10 +718,14 @@ _CODEX_HOOK = { "hooks": [ { "type": "command", + # Use Python for the file check so the hook works on Windows + # (cmd.exe has no [ -f ] builtin; Python is always available). "command": ( - "[ -f graphify-out/graph.json ] && " - r"""echo '{"hookSpecificOutput":{"hookEventName":"PreToolUse","additionalContext":"graphify: Knowledge graph exists. Read graphify-out/GRAPH_REPORT.md for god nodes and community structure before searching raw files."}}' """ - "|| true" + "python3 -c \"" + "import pathlib,json,sys; " + "p=pathlib.Path('graphify-out/graph.json'); " + r"print(json.dumps({'hookSpecificOutput':{'hookEventName':'PreToolUse','additionalContext':'graphify: Knowledge graph exists. Read graphify-out/GRAPH_REPORT.md for god nodes and community structure before searching raw files.'}})) if p.exists() else None" + "\"" ), } ], From a61b25ce5f5ffcfe9d83a2f538b5573427d97253 Mon Sep 17 00:00:00 2001 From: Safi Date: Sat, 2 May 2026 09:36:20 +0100 Subject: [PATCH 05/18] Bump version to 0.6.4 Co-Authored-By: Claude Sonnet 4.6 --- CHANGELOG.md | 4 ++++ pyproject.toml | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c4c35742..d2275cca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ Full release notes with details on each version: [GitHub Releases](https://github.com/safishamsi/graphify/releases) +## 0.6.4 (2026-05-02) + +- Fix: Codex PreToolUse hook failed on Windows — `[ -f ]` is bash-only and crashes on `cmd.exe`; replaced with a cross-platform Python one-liner (`pathlib.Path.exists()`) (#651) + ## 0.6.3 (2026-05-02) - Fix: incremental rebuild (`graphify update`, post-commit hook) dropped INFERRED/AMBIGUOUS semantic nodes extracted from code files — node preservation now filters by ID membership in the new AST output instead of `file_type`, so LLM-extracted call/data-flow edges survive code-only rebuilds (#653) diff --git a/pyproject.toml b/pyproject.toml index 6b247b30..9d82a03a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "graphifyy" -version = "0.6.3" +version = "0.6.4" description = "AI coding assistant skill (Claude Code, Codex, OpenCode, Cursor, Gemini CLI, Aider, OpenClaw, Factory Droid, Trae, Hermes, Kiro, Google Antigravity) - turn any folder of code, docs, papers, images, or videos into a queryable knowledge graph" readme = "README.md" license = { file = "LICENSE" } From c3ddace69083ee8a47deea3a11edc9e7de23f65e Mon Sep 17 00:00:00 2001 From: Safi Date: Sat, 2 May 2026 09:37:46 +0100 Subject: [PATCH 06/18] Update README git hooks description to reflect detached background rebuild Co-Authored-By: Claude Sonnet 4.6 --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index fbc238bc..153cc803 100644 --- a/README.md +++ b/README.md @@ -408,7 +408,7 @@ Audio never leaves your machine. All transcription runs locally. **Auto-sync** (`--watch`) - run in a background terminal and the graph updates itself as your codebase changes. Code file saves trigger an instant rebuild (AST only, no LLM). Doc/image changes notify you to run `--update` for the LLM re-pass. -**Git hooks** (`graphify hook install`) - installs post-commit and post-checkout hooks. Graph rebuilds automatically after every commit and every branch switch. If a rebuild fails, the hook exits with a non-zero code so git surfaces the error instead of silently continuing. No background process needed. +**Git hooks** (`graphify hook install`) - installs post-commit and post-checkout hooks. Graph rebuilds automatically after every commit and every branch switch. Rebuilds run detached in the background so `git commit` returns instantly — log written to `~/.cache/graphify-rebuild.log` (`tail -f` for status). **Wiki** (`--wiki`) - Wikipedia-style markdown articles per community and god node, with an `index.md` entry point. Point any agent at `index.md` and it can navigate the knowledge base by reading files instead of parsing JSON. From 71423a1efb6de95276cc48994156a5daec9a232e Mon Sep 17 00:00:00 2001 From: Michal Harakal Date: Sat, 2 May 2026 14:49:42 +0200 Subject: [PATCH 07/18] Kotlin call-walker: accept both simple_identifier and identifier (#659) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `extract_kotlin` previously emitted zero `calls` edges (and zero `raw_calls` entries) on the current PyPI grammar. The Kotlin branch of `walk_calls` only matched node type `simple_identifier`, but PyPI's `tree_sitter_kotlin` produces `identifier` for the equivalent plain-identifier node. The `simple_identifier` ↔ `identifier` rename is a generation gap between tree-sitter-kotlin grammar versions — older forks (and the JVM `io.github.bonede:tree-sitter-kotlin` binding) still use `simple_identifier`. Accept both names so the extractor works across grammar generations. Also widens `_KOTLIN_CONFIG.name_fallback_child_types` for the same reason (defensive — currently the `name` field path covers class/function name resolution, but if that field is dropped in a future grammar update the fallback would face the same rename). Tested against `tests/fixtures/sample.kt`: edges go from 6 (file-contains + class-method only) to 10 (adds 4 in-file `calls` edges resolved by the walker: - .get() → .buildRequest() @ L8 - .post() → .buildRequest() @ L12 - createClient() → Config @ L21 - createClient() → HttpClient @ L22). A new regression test `test_kotlin_emits_in_file_calls` asserts the four edges so this exact bug can't recur. Found via graphify-kmp (Kotlin Multiplatform port of graphify) — its `PythonParityTest` flagged 4 KMP-only edges that Python missed. Co-authored-by: Claude Opus 4.7 (1M context) --- graphify/extract.py | 16 ++++++++++++---- tests/test_languages.py | 12 ++++++++++++ 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/graphify/extract.py b/graphify/extract.py index 9380e259..d45a97a7 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -590,7 +590,11 @@ _KOTLIN_CONFIG = LanguageConfig( call_function_field="", call_accessor_node_types=frozenset({"navigation_expression"}), call_accessor_field="", - name_fallback_child_types=("simple_identifier",), + # Different tree-sitter-kotlin grammar versions name plain identifier + # nodes differently: PyPI's `tree_sitter_kotlin` uses `identifier`, + # older forks use `simple_identifier`. Accept both so the extractor + # works across grammar generations. + name_fallback_child_types=("simple_identifier", "identifier"), body_fallback_child_types=("function_body", "class_body"), function_boundary_types=frozenset({"function_declaration"}), import_handler=_import_kotlin, @@ -1069,15 +1073,19 @@ def _extract_generic(path: Path, config: LanguageConfig) -> dict: if sc.type == "simple_identifier": callee_name = _read_text(sc, source) elif config.ts_module == "tree_sitter_kotlin": - # Kotlin: first child may be simple_identifier or navigation_expression + # Kotlin: first child may be simple_identifier/identifier or + # navigation_expression. PyPI's `tree_sitter_kotlin` produces + # `identifier` for plain identifier nodes; older grammar + # versions (including the JVM `io.github.bonede:tree-sitter-kotlin` + # binding) produce `simple_identifier`. Accept both. first = node.children[0] if node.children else None if first: - if first.type == "simple_identifier": + if first.type in ("simple_identifier", "identifier"): callee_name = _read_text(first, source) elif first.type == "navigation_expression": is_member_call = True for child in reversed(first.children): - if child.type == "simple_identifier": + if child.type in ("simple_identifier", "identifier"): callee_name = _read_text(child, source) break elif config.ts_module == "tree_sitter_scala": diff --git a/tests/test_languages.py b/tests/test_languages.py index 680bb4e2..c9150f78 100644 --- a/tests/test_languages.py +++ b/tests/test_languages.py @@ -188,6 +188,18 @@ def test_kotlin_finds_function(): r = extract_kotlin(FIXTURES / "sample.kt") assert any("createClient" in l for l in _labels(r)) +def test_kotlin_emits_in_file_calls(): + """Regression test for the call-walker `simple_identifier` / + `identifier` rename — see graphify-kmp's PythonParityTest.""" + r = extract_kotlin(FIXTURES / "sample.kt") + calls = _calls(r) + # In sample.kt: get() and post() both call buildRequest(), and + # createClient() invokes Config and HttpClient (constructor calls). + assert (".get()", ".buildRequest()") in calls + assert (".post()", ".buildRequest()") in calls + assert ("createClient()", "Config") in calls + assert ("createClient()", "HttpClient") in calls + # ── Scala ───────────────────────────────────────────────────────────────────── From 8e01d686f73ea363aecd0e0cdd631fdbbd49b758 Mon Sep 17 00:00:00 2001 From: Hanzala Sohrab Date: Sat, 2 May 2026 18:19:45 +0530 Subject: [PATCH 08/18] feat: replace show/hide buttons with checkbox-based multi-select controls (#647) --- graphify/export.py | 51 ++++++++++++++++++++++++++++++++++++---------- 1 file changed, 40 insertions(+), 11 deletions(-) diff --git a/graphify/export.py b/graphify/export.py index b14812fe..1d45e53a 100644 --- a/graphify/export.py +++ b/graphify/export.py @@ -55,9 +55,14 @@ def _html_styles() -> str: .legend-label { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .legend-count { color: #666; font-size: 11px; } #stats { padding: 10px 14px; border-top: 1px solid #2a2a4e; font-size: 11px; color: #555; } - #legend-controls { display: flex; gap: 6px; margin-bottom: 8px; } - #legend-controls button { flex: 1; background: #0f0f1a; border: 1px solid #3a3a5e; color: #aaa; padding: 4px 0; border-radius: 4px; font-size: 11px; cursor: pointer; } - #legend-controls button:hover { border-color: #4E79A7; color: #e0e0e0; } + #legend-controls { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; padding: 4px 0; } + #legend-controls label { display: flex; align-items: center; gap: 6px; cursor: pointer; font-size: 12px; color: #aaa; user-select: none; } + #legend-controls label:hover { color: #e0e0e0; } + .legend-cb, #select-all-cb { appearance: none; -webkit-appearance: none; width: 14px; height: 14px; border: 1.5px solid #3a3a5e; border-radius: 3px; background: #0f0f1a; cursor: pointer; position: relative; flex-shrink: 0; } + .legend-cb:checked, #select-all-cb:checked { background: #4E79A7; border-color: #4E79A7; } + .legend-cb:checked::after, #select-all-cb:checked::after { content: ''; position: absolute; left: 3.5px; top: 1px; width: 4px; height: 7px; border: solid #fff; border-width: 0 2px 2px 0; transform: rotate(45deg); } + #select-all-cb:indeterminate { background: #4E79A7; border-color: #4E79A7; } + #select-all-cb:indeterminate::after { content: ''; position: absolute; left: 2px; top: 5px; width: 8px; height: 2px; background: #fff; border: none; transform: none; } """ @@ -244,26 +249,41 @@ document.addEventListener('click', e => {{ const hiddenCommunities = new Set(); +const selectAllCb = document.getElementById('select-all-cb'); + +function updateSelectAllState() {{ + const total = LEGEND.length; + const hidden = hiddenCommunities.size; + selectAllCb.checked = hidden === 0; + selectAllCb.indeterminate = hidden > 0 && hidden < total; +}} + function toggleAllCommunities(hide) {{ document.querySelectorAll('.legend-item').forEach(item => {{ hide ? item.classList.add('dimmed') : item.classList.remove('dimmed'); }}); + document.querySelectorAll('.legend-cb').forEach(cb => {{ + cb.checked = !hide; + }}); LEGEND.forEach(c => {{ if (hide) hiddenCommunities.add(c.cid); else hiddenCommunities.delete(c.cid); }}); const updates = RAW_NODES.map(n => ({{ id: n.id, hidden: hide }})); nodesDS.update(updates); + updateSelectAllState(); }} const legendEl = document.getElementById('legend'); LEGEND.forEach(c => {{ const item = document.createElement('div'); item.className = 'legend-item'; - item.innerHTML = `
- ${{c.label}} - ${{c.count}}`; - item.onclick = () => {{ - if (hiddenCommunities.has(c.cid)) {{ + const cb = document.createElement('input'); + cb.type = 'checkbox'; + cb.className = 'legend-cb'; + cb.checked = true; + cb.addEventListener('change', (e) => {{ + e.stopPropagation(); + if (cb.checked) {{ hiddenCommunities.delete(c.cid); item.classList.remove('dimmed'); }} else {{ @@ -272,8 +292,18 @@ LEGEND.forEach(c => {{ }} const updates = RAW_NODES .filter(n => n.community === c.cid) - .map(n => ({{ id: n.id, hidden: hiddenCommunities.has(c.cid) }})); + .map(n => ({{ id: n.id, hidden: !cb.checked }})); nodesDS.update(updates); + updateSelectAllState(); + }}); + item.innerHTML = `
+ ${{c.label}} + ${{c.count}}`; + item.prepend(cb); + item.onclick = (e) => {{ + if (e.target === cb) return; + cb.checked = !cb.checked; + cb.dispatchEvent(new Event('change')); }}; legendEl.appendChild(item); }}); @@ -488,8 +518,7 @@ def to_html(

Communities

- - +
From ca480924134f6135adc162e56b3e77ea7c6460f7 Mon Sep 17 00:00:00 2001 From: Safi Date: Sat, 2 May 2026 13:52:09 +0100 Subject: [PATCH 09/18] Add --force flag and GRAPHIFY_FORCE env var to graphify update Bypasses the node-count safety check in to_json for refactors that legitimately shrink the graph (renames, package deletions). Also honored by post-commit and post-checkout hooks via GRAPHIFY_FORCE=1. Implements the approach from #639 (targeted at v5) adapted for v6. Co-Authored-By: Claude Sonnet 4.6 --- graphify/__main__.py | 13 ++++++++++--- graphify/hooks.py | 9 ++++++--- graphify/watch.py | 8 ++++++-- 3 files changed, 22 insertions(+), 8 deletions(-) diff --git a/graphify/__main__.py b/graphify/__main__.py index e2f6024e..1277bff0 100644 --- a/graphify/__main__.py +++ b/graphify/__main__.py @@ -1009,6 +1009,8 @@ def main() -> None: print(" --dir target directory (default: ./raw)") print(" watch watch a folder and rebuild the graph on code changes") print(" update re-extract code files and update the graph (no LLM needed)") + print(" --force overwrite graph.json even if the rebuild has fewer nodes") + print(" (also: GRAPHIFY_FORCE=1 env var; use after refactors that delete code)") print(" cluster-only rerun clustering on an existing graph.json and regenerate report") print(" query \"\" BFS traversal of graph.json for a question") print(" --dfs use depth-first instead of breadth-first") @@ -1423,8 +1425,13 @@ def main() -> None: print(f"Done — {len(communities)} communities. GRAPH_REPORT.md, graph.json and graph.html updated.") elif cmd == "update": - if len(sys.argv) > 2: - watch_path = Path(sys.argv[2]) + force = os.environ.get("GRAPHIFY_FORCE", "").lower() in ("1", "true", "yes") + argv = list(sys.argv) + if "--force" in argv[2:]: + force = True + argv = [a for a in argv if a != "--force"] + if len(argv) > 2: + watch_path = Path(argv[2]) else: # Try to recover the scan root saved by the last full build saved = Path("graphify-out/.graphify_root") @@ -1437,7 +1444,7 @@ def main() -> None: sys.exit(1) from graphify.watch import _rebuild_code print(f"Re-extracting code files in {watch_path} (no LLM needed)...") - ok = _rebuild_code(watch_path) + ok = _rebuild_code(watch_path, force=force) if ok: print("Code graph updated. For doc/paper/image changes run /graphify --update in your AI assistant.") if not os.environ.get("MOONSHOT_API_KEY") and not os.environ.get("GRAPHIFY_NO_TIPS"): diff --git a/graphify/hooks.py b/graphify/hooks.py index 9341b854..eebd92ae 100644 --- a/graphify/hooks.py +++ b/graphify/hooks.py @@ -80,8 +80,10 @@ if not changed: print(f'[graphify hook] {len(changed)} file(s) changed - rebuilding graph...') try: + import os as _os from graphify.watch import _rebuild_code - _rebuild_code(Path('.')) + _force = _os.environ.get('GRAPHIFY_FORCE', '').lower() in ('1', 'true', 'yes') + _rebuild_code(Path('.'), force=_force) except Exception as exc: print(f'[graphify hook] Rebuild failed: {exc}') sys.exit(1) @@ -124,9 +126,10 @@ echo "[graphify] Branch switched - launching background rebuild (log: $_GRAPHIFY nohup $GRAPHIFY_PYTHON -c " from graphify.watch import _rebuild_code from pathlib import Path -import sys +import os, sys try: - _rebuild_code(Path('.')) + _force = os.environ.get('GRAPHIFY_FORCE', '').lower() in ('1', 'true', 'yes') + _rebuild_code(Path('.'), force=_force) except Exception as exc: print(f'[graphify] Rebuild failed: {exc}') sys.exit(1) diff --git a/graphify/watch.py b/graphify/watch.py index c77572a4..12496851 100644 --- a/graphify/watch.py +++ b/graphify/watch.py @@ -33,9 +33,13 @@ def _relativize_source_files(payload: dict, root: Path) -> None: continue -def _rebuild_code(watch_path: Path, *, follow_symlinks: bool = False) -> bool: +def _rebuild_code(watch_path: Path, *, follow_symlinks: bool = False, force: bool = False) -> bool: """Re-run AST extraction + build + cluster + report for code files. No LLM needed. + When ``force`` is True the node-count safety check in ``to_json`` is bypassed + so the rebuilt graph overwrites graph.json even if it has fewer nodes. + Use this after refactors that legitimately delete code. + Returns True on success, False on error. """ watch_root = watch_path.resolve() @@ -105,7 +109,7 @@ def _rebuild_code(watch_path: Path, *, follow_symlinks: bool = False) -> bool: out.mkdir(exist_ok=True) (out / ".graphify_root").write_text(str(watch_root), encoding="utf-8") - json_written = to_json(G, communities, str(out / "graph.json")) + json_written = to_json(G, communities, str(out / "graph.json"), force=force) if not json_written: return False From e02c7cc60c40310b487a72b489d6c7014dde4442 Mon Sep 17 00:00:00 2001 From: Safi Date: Sat, 2 May 2026 13:57:19 +0100 Subject: [PATCH 10/18] Fix Codex PreToolUse hook on Windows by delegating to graphify hook-check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The python3 -c "..." approach still failed on Windows Conda (no python3 shim) and PowerShell (JSON curly brace/quote parsing). Replace the inline command with 'graphify hook-check' — a new shell-agnostic subcommand that prints the hookSpecificOutput JSON if graph.json exists and exits 0 silently if not. Works on PowerShell, cmd.exe, macOS, and Linux with no quoting or interpreter-name issues. Users must re-run 'graphify codex install' to regenerate the hook. Co-Authored-By: Claude Sonnet 4.6 --- graphify/__main__.py | 33 ++++++++++++++++++++++++--------- 1 file changed, 24 insertions(+), 9 deletions(-) diff --git a/graphify/__main__.py b/graphify/__main__.py index 1277bff0..94d1f69c 100644 --- a/graphify/__main__.py +++ b/graphify/__main__.py @@ -718,15 +718,11 @@ _CODEX_HOOK = { "hooks": [ { "type": "command", - # Use Python for the file check so the hook works on Windows - # (cmd.exe has no [ -f ] builtin; Python is always available). - "command": ( - "python3 -c \"" - "import pathlib,json,sys; " - "p=pathlib.Path('graphify-out/graph.json'); " - r"print(json.dumps({'hookSpecificOutput':{'hookEventName':'PreToolUse','additionalContext':'graphify: Knowledge graph exists. Read graphify-out/GRAPH_REPORT.md for god nodes and community structure before searching raw files.'}})) if p.exists() else None" - "\"" - ), + # Use the graphify CLI itself so the hook is shell-agnostic: + # no [ -f ] bash syntax, no python3 vs python Conda issue, + # no JSON escaping inside PowerShell strings. Works on + # Windows (PowerShell/cmd.exe), macOS, and Linux. + "command": "graphify hook-check", } ], } @@ -1453,6 +1449,25 @@ def main() -> None: print("Nothing to update or rebuild failed — check output above.", file=sys.stderr) sys.exit(1) + elif cmd == "hook-check": + # Shell-agnostic PreToolUse hook entry point for Codex (and any platform + # where embedding Python/bash inline in a JSON hook command is fragile). + # Prints the hookSpecificOutput JSON if graph.json exists, exits 0 silently + # if not. Works on Windows PowerShell, cmd.exe, macOS, and Linux. + graph = Path("graphify-out") / "graph.json" + if graph.exists(): + import json as _json + print(_json.dumps({ + "hookSpecificOutput": { + "hookEventName": "PreToolUse", + "additionalContext": ( + "graphify: Knowledge graph exists. " + "Read graphify-out/GRAPH_REPORT.md for god nodes and " + "community structure before searching raw files." + ), + } + })) + sys.exit(0) elif cmd == "check-update": if len(sys.argv) < 3: print("Usage: graphify check-update ", file=sys.stderr) From d40e1c0cefb477faffc558c6dcc8a75b509af6a3 Mon Sep 17 00:00:00 2001 From: Safi Date: Sat, 2 May 2026 14:00:32 +0100 Subject: [PATCH 11/18] Bump version to 0.6.5 Co-Authored-By: Claude Sonnet 4.6 --- CHANGELOG.md | 7 +++++++ pyproject.toml | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d2275cca..2fb5f9d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ Full release notes with details on each version: [GitHub Releases](https://github.com/safishamsi/graphify/releases) +## 0.6.5 (2026-05-02) + +- Fix: Kotlin call-walker now accepts both `simple_identifier` and `identifier` node types — PyPI's `tree_sitter_kotlin` grammar uses `identifier` while older forks use `simple_identifier`, causing zero `calls` edges to be emitted (#659) +- Feat: community sidebar now uses checkbox-based multi-select instead of show/hide buttons — supports indeterminate "select all" state (#647) +- Feat: `graphify update --force` and `GRAPHIFY_FORCE=1` env var — bypass the node-count safety check after refactors that legitimately shrink the graph (#639) +- Fix: Codex PreToolUse hook on Windows — replaced `python3 -c "..."` inline command (fails on Conda where only `python` exists, and breaks PowerShell JSON parsing) with `graphify hook-check`, a new shell-agnostic subcommand. Re-run `graphify codex install` to regenerate the hook (#651, #522) + ## 0.6.4 (2026-05-02) - Fix: Codex PreToolUse hook failed on Windows — `[ -f ]` is bash-only and crashes on `cmd.exe`; replaced with a cross-platform Python one-liner (`pathlib.Path.exists()`) (#651) diff --git a/pyproject.toml b/pyproject.toml index 9d82a03a..2ffd2859 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "graphifyy" -version = "0.6.4" +version = "0.6.5" description = "AI coding assistant skill (Claude Code, Codex, OpenCode, Cursor, Gemini CLI, Aider, OpenClaw, Factory Droid, Trae, Hermes, Kiro, Google Antigravity) - turn any folder of code, docs, papers, images, or videos into a queryable knowledge graph" readme = "README.md" license = { file = "LICENSE" } From 36e894aa628ca569e171f6f76ebc5ef04cb6bf10 Mon Sep 17 00:00:00 2001 From: Safi Date: Sat, 2 May 2026 14:25:26 +0100 Subject: [PATCH 12/18] v0.6.6: Windows skill bash rewrite, wiki fixes, rationale-node fix, hidden allowlist, --no-viz cluster-only Co-Authored-By: Claude Sonnet 4.6 --- CHANGELOG.md | 13 ++++ graphify/__main__.py | 26 ++++++-- graphify/detect.py | 121 ++++++++++++++++++++++++++++++++++++-- graphify/export.py | 44 ++++++++++++-- graphify/extract.py | 23 +++++++- graphify/skill-trae.md | 2 +- graphify/skill-windows.md | 33 +++++++++-- graphify/skill.md | 27 +++++++-- graphify/wiki.py | 22 ++++++- pyproject.toml | 2 +- 10 files changed, 283 insertions(+), 30 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2fb5f9d5..4939e65d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,19 @@ Full release notes with details on each version: [GitHub Releases](https://github.com/safishamsi/graphify/releases) +## 0.6.6 (2026-05-02) + +- Fix: `skill-windows.md` rewritten from PowerShell to bash — Claude Code on Windows uses git-bash so PowerShell syntax (`$null`, `$LASTEXITCODE`, `Select-Object`, `& (Get-Content ...)`, `Remove-Item`) caused exit code 49 failures; now mirrors `skill.md` structure with `python` added as fallback after `python3` for Windows Conda (#39) +- Fix: wiki `to_wiki()` now clears stale articles before regenerating, preventing orphan .md accumulation (#558) +- Fix: `_safe_filename()` in wiki.py now strips Windows-reserved characters (`< > : " / \ | ? *`) and caps length at 200 chars (#594) +- Fix: rationale-node leakage in cross-file INFERRED call resolution — rationale nodes now excluded from name lookup; edge direction (`calls`, `rationale_for`) preserved correctly at JSON export (#576) +- Feat: `.graphifyinclude` hidden path allowlist — opt specific hidden dirs into traversal (e.g. `.hermes/plans/**/*.md`) (#583) +- Feat: `--no-viz` flag wired in `cluster-only`; `GRAPHIFY_VIZ_NODE_LIMIT` env var overrides 5000-node HTML threshold (#565) +- Fix: stray colon SyntaxError in `skill-trae.md` `--cluster-only` block (#603) +- Docs: skill INFERRED confidence score guidance changed to discrete rubric (0.55/0.65/0.75/0.85/0.95) backed by calibration data (#546) +- Docs: skill `--update` prune output clarified — splits no-drift vs drift cases (#544) +- Docs: skill `--update` merge step now calls `save_manifest` to prevent deleted files reappearing (#545) + ## 0.6.5 (2026-05-02) - Fix: Kotlin call-walker now accepts both `simple_identifier` and `identifier` node types — PyPI's `tree_sitter_kotlin` grammar uses `identifier` while older forks use `simple_identifier`, causing zero `calls` edges to be emitted (#659) diff --git a/graphify/__main__.py b/graphify/__main__.py index 94d1f69c..42d4429a 100644 --- a/graphify/__main__.py +++ b/graphify/__main__.py @@ -1008,6 +1008,7 @@ def main() -> None: print(" --force overwrite graph.json even if the rebuild has fewer nodes") print(" (also: GRAPHIFY_FORCE=1 env var; use after refactors that delete code)") print(" cluster-only rerun clustering on an existing graph.json and regenerate report") + print(" --no-viz skip graph.html generation (useful for >5000 node graphs / CI)") print(" query \"\" BFS traversal of graph.json for a question") print(" --dfs use depth-first instead of breadth-first") print(" --budget N cap output at N tokens (default 2000)") @@ -1385,6 +1386,7 @@ def main() -> None: elif cmd == "cluster-only": watch_path = Path(sys.argv[2]) if len(sys.argv) > 2 else Path(".") + no_viz = "--no-viz" in sys.argv graph_json = watch_path / "graphify-out" / "graph.json" if not graph_json.exists(): print(f"error: no graph found at {graph_json} — run /graphify first", file=sys.stderr) @@ -1414,11 +1416,25 @@ def main() -> None: out = watch_path / "graphify-out" (out / "GRAPH_REPORT.md").write_text(report, encoding="utf-8") to_json(G, communities, str(out / "graph.json")) - try: - to_html(G, communities, str(out / "graph.html"), community_labels=labels or None) - except ValueError as _viz_err: - print(f"[graphify] Skipped graph.html: {_viz_err}") - print(f"Done — {len(communities)} communities. GRAPH_REPORT.md, graph.json and graph.html updated.") + + # Mirror watch.py pattern: gate to_html so core outputs (graph.json + + # GRAPH_REPORT.md) always land. Honor --no-viz explicitly; otherwise + # fall back to ValueError handling so an oversized graph doesn't crash + # the CLI mid-write and leave a stale graph.html on disk. + html_target = out / "graph.html" + if no_viz: + if html_target.exists(): + html_target.unlink() + print(f"Done — {len(communities)} communities. GRAPH_REPORT.md and graph.json updated (--no-viz; graph.html removed).") + else: + try: + to_html(G, communities, str(html_target), community_labels=labels or None) + print(f"Done — {len(communities)} communities. GRAPH_REPORT.md, graph.json and graph.html updated.") + except ValueError as viz_err: + if html_target.exists(): + html_target.unlink() + print(f"Skipped graph.html: {viz_err}") + print(f"Done — {len(communities)} communities. GRAPH_REPORT.md and graph.json updated.") elif cmd == "update": force = os.environ.get("GRAPHIFY_FORCE", "").lower() in ("1", "true", "yes") diff --git a/graphify/detect.py b/graphify/detect.py index ba1595e6..419fc5c9 100644 --- a/graphify/detect.py +++ b/graphify/detect.py @@ -509,6 +509,115 @@ def _is_ignored(path: Path, root: Path, patterns: list[tuple[Path, str]]) -> boo return result +def _load_graphifyinclude(root: Path) -> list[tuple[Path, str]]: + """Read .graphifyinclude allowlist patterns from root and ancestors. + + Include patterns opt matching hidden files/dirs into traversal. Sensitive + files and hard-skipped noise directories are still excluded later. + Uses the same VCS-root ceiling logic as _load_graphifyignore. + """ + root = root.resolve() + ceiling = _find_vcs_root(root) or root + + dirs: list[Path] = [] + current = root + while True: + dirs.append(current) + if current == ceiling: + break + current = current.parent + dirs.reverse() + + patterns: list[tuple[Path, str]] = [] + for d in dirs: + include_file = d / ".graphifyinclude" + if include_file.exists(): + for raw in include_file.read_text(encoding="utf-8", errors="ignore").splitlines(): + line = _parse_gitignore_line(raw) + if line: + patterns.append((d, line)) + return patterns + + +def _is_included(path: Path, root: Path, patterns: list[tuple[Path, str]]) -> bool: + """Return True if path matches any .graphifyinclude allowlist pattern.""" + if not patterns: + return False + + def _matches(rel: str, p: str) -> bool: + parts = rel.split("/") + if fnmatch.fnmatch(rel, p): + return True + if fnmatch.fnmatch(path.name, p): + return True + for i, part in enumerate(parts): + if fnmatch.fnmatch(part, p): + return True + if fnmatch.fnmatch("/".join(parts[:i + 1]), p): + return True + return False + + for anchor, pattern in patterns: + anchored = pattern.startswith("/") + p = pattern.strip("/") + if not p: + continue + if anchored: + try: + rel_anchor = str(path.relative_to(anchor)).replace(os.sep, "/") + if _matches(rel_anchor, p): + return True + except ValueError: + pass + else: + try: + rel = str(path.relative_to(root)).replace(os.sep, "/") + if _matches(rel, p): + return True + except ValueError: + pass + if anchor != root: + try: + rel_anchor = str(path.relative_to(anchor)).replace(os.sep, "/") + if _matches(rel_anchor, p): + return True + except ValueError: + pass + return False + + +def _could_contain_included_path(path: Path, root: Path, patterns: list[tuple[Path, str]]) -> bool: + """Return True if a directory may contain files matched by .graphifyinclude.""" + if not patterns: + return False + + rels: list[str] = [] + try: + rels.append(str(path.relative_to(root)).replace(os.sep, "/")) + except ValueError: + pass + for anchor, _ in patterns: + if anchor != root: + try: + rels.append(str(path.relative_to(anchor)).replace(os.sep, "/")) + except ValueError: + pass + + for rel in rels: + rel = rel.strip("/") + if not rel: + return True + for _, pattern in patterns: + p = pattern.strip("/") + if not p: + continue + if p == rel or p.startswith(rel + "/"): + return True + if fnmatch.fnmatch(rel, p): + return True + return False + + def detect(root: Path, *, follow_symlinks: bool = False) -> dict: root = root.resolve() files: dict[FileType, list[str]] = { @@ -522,6 +631,7 @@ def detect(root: Path, *, follow_symlinks: bool = False) -> dict: skipped_sensitive: list[str] = [] ignore_patterns = _load_graphifyignore(root) + include_patterns = _load_graphifyinclude(root) # Always include graphify-out/memory/ - query results filed back into the graph memory_dir = root / "graphify-out" / "memory" @@ -543,10 +653,12 @@ def detect(root: Path, *, follow_symlinks: bool = False) -> dict: dirnames.clear() continue if not in_memory_tree: - # Prune noise dirs in-place so os.walk never descends into them + # Prune noise dirs in-place so os.walk never descends into them. + # Hidden dirs are allowed through if they could contain an + # explicitly included path (.graphifyinclude allowlist). dirnames[:] = [ d for d in dirnames - if not d.startswith(".") + if (not d.startswith(".") or _could_contain_included_path(dp / d, root, include_patterns)) and not _is_noise_dir(d) and not _is_ignored(dp / d, root, ignore_patterns) ] @@ -565,8 +677,9 @@ def detect(root: Path, *, follow_symlinks: bool = False) -> dict: in_memory = memory_dir.exists() and str(p).startswith(str(memory_dir)) if not in_memory: # Hidden files are already excluded via dir pruning above, - # but catch hidden files at the root level - if p.name.startswith("."): + # but catch hidden files at the root level. A .graphifyinclude + # entry can opt a specific hidden file back in. + if p.name.startswith(".") and not _is_included(p, root, include_patterns): continue # Skip files inside our own converted/ dir (avoid re-processing sidecars) if str(p).startswith(str(converted_dir)): diff --git a/graphify/export.py b/graphify/export.py index 1d45e53a..14587add 100644 --- a/graphify/export.py +++ b/graphify/export.py @@ -25,6 +25,22 @@ COMMUNITY_COLORS = [ MAX_NODES_FOR_VIZ = 5_000 +def _viz_node_limit() -> int: + """Return the effective viz node limit, honoring GRAPHIFY_VIZ_NODE_LIMIT env var. + + Falls back to MAX_NODES_FOR_VIZ when the env var is unset, empty, or non-integer. + Set to 0 to disable HTML viz unconditionally (useful for CI runners). + """ + import os + raw = os.environ.get("GRAPHIFY_VIZ_NODE_LIMIT") + if raw is None or not raw.strip(): + return MAX_NODES_FOR_VIZ + try: + return int(raw) + except ValueError: + return MAX_NODES_FOR_VIZ + + def _html_styles() -> str: return """