Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ Full release notes with details on each version: [GitHub Releases](https://githu
- Fix: `uv tool install graphifyy` / `pip install graphifyy` no longer fails to build on Linux/macOS — `tree-sitter-dm` (BYOND DreamMaker) ships only a Windows wheel, so on other platforms it compiled from source and aborted the entire install when a C toolchain or `python3-dev` headers were missing. It is now an optional extra (`graphifyy[dm]`, also in `[all]`) instead of a core dependency, so the default install needs no compiler (#1104).
- **Upgrade note:** DreamMaker `.dm`/`.dme` users must reinstall with `graphifyy[dm]` (or `[all]`) to keep AST extraction — on `uv tool upgrade` the now-optional grammar is removed. `.dmi`/`.dmm`/`.dmf` parsing is unaffected (no tree-sitter dependency).
- Fix: community IDs are now assigned by a total order (`(-size, sorted node IDs)`) so an identical grouping always gets identical IDs across runs — previously the equal-sized small communities that dominate a sparse graph were numbered by the partitioner's (not seed-stable) enumeration order, making a per-node community diff report large spurious "churn" even though the actual grouping was reproducible (#1090 follow-up)
- Fix: `graphify update` (full re-extraction) now prunes a symbol removed from a file that still exists — and its dangling inbound edge — instead of keeping the stale node until the next clean rebuild. AST nodes are tagged `_origin="ast"` at extraction so the watcher drops AST nodes missing from the fresh output by identity, never blanket-by-source-file, so genuine semantic/LLM nodes on the same file are preserved (#1116). **Backward-compat note:** a `graph.json` built before this change has no `_origin` markers, so on the *first* update after upgrading a removed symbol is not pruned that one cycle (no data loss); it self-heals once the node is re-stamped on a full re-extraction.
- Fix: `graphify amp install` now writes the skill where Amp actually looks for it. It was landing in `.amp/skills/graphify` (project) and `~/.amp/skills/graphify` (user), neither of which Amp searches, so the skill never loaded. User-scope installs now go to `~/.config/agents/skills/graphify` and project installs to `.agents/skills/graphify`, and a stale `~/.amp/skills/graphify` from an older install is cleaned up on the next run.

## 0.8.27 (2026-05-31)
Expand Down
19 changes: 17 additions & 2 deletions graphify/export.py
Original file line number Diff line number Diff line change
Expand Up @@ -1274,7 +1274,10 @@ def _safe_label(label: str) -> str:

with driver.session() as session:
for node_id, data in G.nodes(data=True):
props = {k: v for k, v in data.items() if isinstance(v, (str, int, float, bool))}
props = {
k: v for k, v in data.items()
if isinstance(v, (str, int, float, bool)) and not k.startswith("_")
}
props["id"] = node_id
cid = node_community.get(node_id)
if cid is not None:
Expand All @@ -1289,7 +1292,10 @@ def _safe_label(label: str) -> str:

for u, v, data in G.edges(data=True):
rel = _safe_rel(data.get("relation", "RELATED_TO"))
props = {k: v for k, v in data.items() if isinstance(v, (str, int, float, bool))}
props = {
k: v for k, v in data.items()
if isinstance(v, (str, int, float, bool)) and not k.startswith("_")
}
session.run(
f"MATCH (a {{id: $src}}), (b {{id: $tgt}}) "
f"MERGE (a)-[r:{rel}]->(b) SET r += $props",
Expand Down Expand Up @@ -1317,6 +1323,15 @@ def to_graphml(
node_community = _node_community_map(communities)
for node_id in H.nodes():
H.nodes[node_id]["community"] = node_community.get(node_id, -1)
# Drop internal markers (e.g. the AST-provenance "_origin" tag, #1116, and
# the "_src"/"_tgt" direction markers) — they are persistence/runtime details,
# not graph data, and should not leak into the exported file.
for _, attrs in H.nodes(data=True):
for k in [k for k in attrs if k.startswith("_")]:
del attrs[k]
for _, _, attrs in H.edges(data=True):
for k in [k for k in attrs if k.startswith("_")]:
del attrs[k]
nx.write_graphml(H, output_path)


Expand Down
8 changes: 8 additions & 0 deletions graphify/extract.py
Original file line number Diff line number Diff line change
Expand Up @@ -11078,6 +11078,14 @@ def extract(
except ValueError:
pass

# Tag AST provenance so the incremental watch rebuild can distinguish
# AST-extracted nodes from semantic/LLM nodes (which are added by a separate
# pass and never flow through here). On a full re-extraction the watcher
# drops any AST-marked node missing from the fresh output — even when its
# source file still exists — without touching genuine semantic nodes (#1116).
for n in all_nodes:
n["_origin"] = "ast"

return {
"nodes": all_nodes,
"edges": all_edges,
Expand Down
10 changes: 10 additions & 0 deletions graphify/watch.py
Original file line number Diff line number Diff line change
Expand Up @@ -543,9 +543,19 @@ def _rebuild_code(
evict_sources.add(sf)
evict_sources.add(norm)
deleted_paths.add(norm)
# On a full re-extraction every code file is re-extracted, so
# new_ast_ids is the complete current AST set. Any AST-marked node
# missing from it is stale and must be dropped even if its source
# file still exists (a symbol removed from a surviving file, #1116).
# Gate on full_rebuild: in incremental mode an AST node from an
# unchanged file is legitimately absent from new_ast_ids. Semantic
# nodes lack the "_origin" marker, so they are never dropped here —
# only by the deleted-file eviction in evict_sources above.
full_rebuild = changed_paths is None
preserved_nodes = [
n for n in existing.get("nodes", [])
if n["id"] not in new_ast_ids
and not (full_rebuild and n.get("_origin") == "ast")
and (not evict_sources or n.get("source_file") not in evict_sources)
]
all_ids = new_ast_ids | {n["id"] for n in preserved_nodes}
Expand Down
126 changes: 126 additions & 0 deletions tests/test_watch.py
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,132 @@ def test_rebuild_code_evicts_nodes_from_deleted_files(tmp_path):
assert "login()" in node_labels_after, "nodes from surviving file must be kept"


def test_rebuild_code_evicts_removed_symbol_from_surviving_file(tmp_path):
"""#1116: graphify update (_rebuild_code with no changed_paths) must prune a
symbol removed from a file that still exists — and its inbound call edge —
without dropping genuine semantic nodes that share the surviving file."""
import json
from graphify.watch import _rebuild_code

corpus = tmp_path / "corpus"
corpus.mkdir()

(corpus / "a.py").write_text(
"def foo(): pass\ndef bar(): pass\n", encoding="utf-8"
)
(corpus / "b.py").write_text(
"from a import foo\n\ndef caller():\n foo()\n", encoding="utf-8"
)

assert _rebuild_code(corpus, acquire_lock=False) is True
graph_path = corpus / "graphify-out" / "graph.json"
data = json.loads(graph_path.read_text(encoding="utf-8"))

def labels(d):
return {n["label"] for n in d.get("nodes", [])}

def id_for(d, label):
return next(n["id"] for n in d.get("nodes", []) if n["label"] == label)

def edges(d):
return d.get("links", d.get("edges", []))

before = labels(data)
assert {"foo()", "bar()", "caller()"} <= before
foo_id = id_for(data, "foo()")
caller_id = id_for(data, "caller()")
assert any(
{e.get("source"), e.get("target")} == {caller_id, foo_id}
for e in edges(data)
), "cross-file caller->foo call edge must exist before removal"

# Pre-seed a semantic node on the surviving a.py (no AST id, no _origin
# marker). A naive "evict every re-extracted file's nodes by source_file"
# fix would wrongly delete this; the identity-based fix must keep it.
data["nodes"].append({
"id": "a_authconcept",
"label": "AuthConcept",
"file_type": "concept",
"source_file": "a.py",
})
graph_path.write_text(json.dumps(data), encoding="utf-8")

# Remove foo() from a.py (keep bar); leave b.py untouched.
(corpus / "a.py").write_text("def bar(): pass\n", encoding="utf-8")

assert _rebuild_code(corpus, acquire_lock=False, force=True) is True
after_data = json.loads(graph_path.read_text(encoding="utf-8"))
after = labels(after_data)

assert "foo()" not in after, "removed symbol must be pruned from surviving file"
assert not any(
e.get("source") == foo_id or e.get("target") == foo_id
for e in edges(after_data)
), "dangling edge to the removed symbol must be dropped"
assert "bar()" in after, "surviving symbol in the same file must be kept"
assert "caller()" in after, "unchanged file's nodes must be kept"
assert "AuthConcept" in after, "semantic node on a surviving file must not be evicted"


def test_rebuild_code_preupgrade_marker_less_node_one_cycle_lag(tmp_path):
"""#1118 backward-compat: a graph.json built before #1116 has no `_origin`
markers. On the first `graphify update` after upgrading, a symbol removed
from a surviving file is NOT pruned that cycle — its old node carries no
marker, so the new drop-rule skips it. This is a deliberate one-cycle lag
(no data loss); it self-heals once the node has been stamped `_origin="ast"`
(which a full re-extraction does for every surviving symbol)."""
import json
from graphify.watch import _rebuild_code

corpus = tmp_path / "corpus"
corpus.mkdir()
(corpus / "a.py").write_text("def bar(): pass\n", encoding="utf-8")

assert _rebuild_code(corpus, acquire_lock=False) is True
graph_path = corpus / "graphify-out" / "graph.json"
data = json.loads(graph_path.read_text(encoding="utf-8"))

def labels(d):
return {n["label"] for n in d.get("nodes", [])}

# Simulate a pre-#1116 graph: strip every `_origin` marker, then inject a
# stale AST node for a symbol no longer present in a.py's source — also
# marker-less, exactly as a pre-upgrade graph would carry it.
for n in data["nodes"]:
n.pop("_origin", None)
data["nodes"].append({
"id": "a_foo",
"label": "foo()",
"file_type": "function",
"source_file": "a.py",
})
graph_path.write_text(json.dumps(data), encoding="utf-8")

# First update after "upgrade" (full rebuild, no changed_paths): the stale
# node has no marker, so the drop-rule skips it and it survives this cycle.
assert _rebuild_code(corpus, acquire_lock=False, force=True) is True
after = json.loads(graph_path.read_text(encoding="utf-8"))
assert "foo()" in labels(after), (
"pre-upgrade marker-less stale node must survive the first update — "
"documented one-cycle backward-compat lag (#1118)"
)

# Once stamped (a full re-extraction stamps every surviving symbol), the
# drop-rule applies on the next update and the stale node self-heals away.
for n in after["nodes"]:
if n["label"] == "foo()":
n["_origin"] = "ast"
graph_path.write_text(json.dumps(after), encoding="utf-8")

assert _rebuild_code(corpus, acquire_lock=False, force=True) is True
healed = json.loads(graph_path.read_text(encoding="utf-8"))
assert "foo()" not in labels(healed), (
"once carrying _origin=ast, the stale node is pruned on the next "
"update (self-heal)"
)
assert "bar()" in labels(healed), "surviving symbol must be kept throughout"


@pytest.mark.skipif(sys.platform == "win32", reason="fcntl-only (POSIX)")
def test_rebuild_lock_non_blocking_does_not_clobber_holder(tmp_path):
"""GH-858: a non-blocking caller that fails to acquire the lock must not
Expand Down