Repository navigation
graphify update / post-commit hook drops code-file semantic nodes and is then blocked by to_json safety check #653
Description
Summary
After a full /graphify . run, subsequent invocations of graphify update <path>, the post-commit hook, and the post-checkout hook all silently no-op with:
[graphify] WARNING: new graph has X nodes but existing graph.json has Y. Refusing to overwrite — you may be missing chunk files from a previous session. Pass force=True to override.
Nothing to update or rebuild failed — check output above.
Repro
- Build a graph with the full pipeline so it contains semantic edges on code files:
/graphify .(this dispatches LLM subagents that produce nodes withfile_type=\"code\"plus INFERRED/AMBIGUOUS edges). - Touch any code file (or
git pulla code change) so the AST output is slightly different from before. - Run
graphify update ., or commit so the post-commit hook fires.
Observed in v0.6.2 on a Python + TypeScript + Swift project:
- Full run: 4,121 nodes (2,618 AST + 1,503 semantic, ~1,400 of which had
file_type=\"code\"because subagents extracted call/data-flow edges that AST cannot see). - After
graphify update .: 2,981 nodes — the ~1,400 code-typed semantic nodes are gone. Safety check fires, write refused, hook reports failure.
Root cause
graphify/watch.py:_rebuild_code lines 65–82 try to preserve semantic data:
```python
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)]
```
The filter `n.get("file_type") != "code"` drops every semantic node whose source happens to be a code file. In practice that is most of the value of the LLM pass — INFERRED call/data-flow edges and the nodes they connect live exclusively on code files.
Then at `graphify/export.py:300-314`, `to_json`'s post-#479 safety check correctly notices the new graph is smaller and refuses to overwrite. The hook returns failure. The user sees no obvious indication that the graph is now stale; subsequent `graphify query` answers from the old graph until they re-run the full pipeline.
Suggested fix
Filter by `source_file`, not `file_type`. Keep semantic nodes/edges from files that were not re-extracted in this rebuild. Roughly:
```python
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]
preserved_node_ids = {n["id"] for n in preserved_nodes}
all_node_ids = new_ast_ids | preserved_node_ids
preserved_edges = [
e for e in existing.get("links", existing.get("edges", []))
if e.get("source") in all_node_ids and e.get("target") in all_node_ids
]
```
Or, more surgically: use `detect_incremental` to identify only changed files, AST-extract just those, and replace nodes whose `source_file` matches — keeping every other node and edge intact. This is what the `--update` flow documented in `skill.md` actually does, but `_rebuild_code` does not. The post-commit/post-checkout hooks would benefit most from this surgical path.
Workaround
Manual incremental update — read `graph.json`, AST-extract only changed files via `detect_incremental`, remove old nodes by `source_file`, add new ones, write with `to_json(..., force=True)`. Working code:
```python
from graphify.detect import detect_incremental
from graphify.extract import collect_files, extract
from networkx.readwrite import json_graph
import json
from pathlib import Path
result = detect_incremental(Path("."))
ROOT = Path(".").resolve()
ast_files = []
for f in result.get("new_files", {}).get("code", []):
p = Path(f)
ast_files.extend(collect_files(p) if p.is_dir() else [p])
ast = extract(ast_files, cache_root=Path("."))
data = json.loads(Path("graphify-out/graph.json").read_text())
G = json_graph.node_link_graph(data, edges="links")
changed_rel = {str(Path(f).resolve().relative_to(ROOT)) for f in ast_files}
to_remove = [n for n, d in G.nodes(data=True) if d.get("source_file") in changed_rel]
G.remove_nodes_from(to_remove)
for n in ast["nodes"]:
G.add_node(n["id"], **{k: v for k, v in n.items() if k != "id"})
for e in ast["edges"]:
if e["source"] in G and e["target"] in G:
G.add_edge(e["source"], e["target"], **{k: v for k, v in e.items() if k not in ("source","target")})
write back; preserved semantic nodes/edges remain intact
```
After this, `G` has all original semantic nodes plus refreshed AST for the changed files, and the safety check passes because node count holds steady or grows.
Environment
- graphifyy 0.6.2 (`uv tool install graphifyy`)
- Python 3.13, macOS 14 (Darwin 25.4.0)
Activity
safishamsi commentedon May 2, 2026
Fixed in v0.6.3. Semantic node preservation now filters by ID membership in the new AST output instead of
file_type— INFERRED/AMBIGUOUS nodes on code files are no longer dropped on incremental rebuild, and theto_jsonsafety check no longer fires.