Skip to content

graphify update / post-commit hook drops code-file semantic nodes and is then blocked by to_json safety check #653

Closed

Description

@rvilius

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

  1. Build a graph with the full pipeline so it contains semantic edges on code files: /graphify . (this dispatches LLM subagents that produce nodes with file_type=\"code\" plus INFERRED/AMBIGUOUS edges).
  2. Touch any code file (or git pull a code change) so the AST output is slightly different from before.
  3. 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

  1. safishamsi commented on May 2, 2026

    @safishamsi
    Member

    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 the to_json safety check no longer fires.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions