fix(extract): drop internal origin_file so it stops leaking into graph.json (#1516)

The origin_file disambiguation field (#1462) is internal — it's consumed by the
colliding-id pass and must not be persisted. In 0.9.0 it shipped into graph.json
as an absolute, machine-specific path (the same portability bug class as #555,
#932), breaking on clone/move. It's now popped at the single extract() output
chokepoint, AFTER _disambiguate_colliding_node_ids has consumed it, so all persist
paths (clustered, --no-cluster, cluster-only) emit clean output. _origin is kept
(the incremental watcher relies on it, #1116).

Ported from PR #1516 by @TPAteeq. Verified: fresh extract (clustered and
--no-cluster) produces graph.json with zero origin_file fields; the #1462 same-
label disambiguation still works (it runs before the strip).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Mohammed Ateeq
2026-06-28 18:54:58 +01:00
committed by safishamsi
co-authored by Claude Opus 4.8
parent 94e5bafc89
commit afa4aded2e
3 changed files with 43 additions and 0 deletions
+4
View File
@@ -2,6 +2,10 @@
Full release notes with details on each version: [GitHub Releases](https://github.com/safishamsi/graphify/releases)
## Unreleased
- Fix: the internal `origin_file` disambiguation field (#1462) is no longer serialized into graph.json, where it had shipped (in 0.9.0) as an absolute, machine-specific path — it is dropped once the colliding-id pass consumes it, keeping output portable (#1516, thanks @TPAteeq; cf. #555, #932). `_origin` stays (the incremental watcher needs it, #1116).
## 0.9.0 (2026-06-28)
- **Breaking — node IDs now include the full repo-relative path** (#1504, #1509). The node-ID stem was the immediate parent dir + filename, so same-named files in different directories collided into one last-writer-wins node and silently dropped graph content (`docs/v1/api/README.md` and `docs/v2/api/README.md` both → `api_readme`). The stem is now the full repo-relative path (`docs_v1_api_readme` vs `docs_v2_api_readme`); top-level files are unchanged (`setup.py``setup`). The AST extractor, the LLM system prompt, the extraction-spec, and the two hand-copied stem helpers are all aligned to this one rule (fixing the #1509 AST↔LLM divergence that produced ghost duplicates), and `build_from_json` deterministically re-keys any cached/older semantic fragment onto the new IDs from its `source_file` so the unversioned semantic cache survives without ghosts or a re-bill. **Existing graphs migrate to the new ID format automatically on the next `build`/`update`** (no re-bill). Note: same-named files in different directories that previously collided into one node are only *recovered as distinct nodes* by a fresh extraction — run `graphify extract --force` to rebuild and gain them (migrating an already-collided graph/cache can't resurrect the nodes that were already dropped). If you push to a persisted **Neo4j** store, re-import after upgrading (re-exported IDs change); saved Gephi/yEd (GraphML) layouts go stale; MCP/cypher consumers should query by label rather than persisting node IDs across rebuilds.
+9
View File
@@ -13466,6 +13466,15 @@ def extract(
except ValueError:
pass
# origin_file is an internal disambiguation hint (#1462): the colliding-id pass
# above reads it to keep same-named cross-file stubs distinct, after which nothing
# consumes it. Drop it from the returned nodes so it never ships into graph.json as
# an absolute, machine-specific path — the same "no absolute paths in output"
# contract that relativizes source_file just above (#555, #932). The per-file AST
# cache keeps its own copy, which is what the colliding-id pass reads on a cache hit.
for n in all_nodes:
n.pop("origin_file", None)
# Tag AST provenance so the incremental watch rebuild can distinguish
# AST-extracted nodes from semantic/LLM nodes. On a full re-extraction
# the watcher drops any AST-marked node missing from the fresh output
+30
View File
@@ -171,6 +171,36 @@ def test_imported_type_stubs_do_not_collide_across_source_files(tmp_path):
assert all(not node.get("source_file") for node in path_nodes)
def test_origin_file_is_not_serialized_into_extract_output(tmp_path):
"""origin_file is an internal disambiguation hint (#1462) consumed only by the
colliding-id pass during extraction. It must not survive into the returned nodes
(and thus graph.json), where it would ship as an absolute, machine-specific path
the "no absolute paths in output" contract (#555, #932). Disambiguation still keys
on it first, so the two same-label cross-file stubs stay distinct."""
first = tmp_path / "pkg/a.py"
second = tmp_path / "pkg/b.py"
first.parent.mkdir(parents=True)
first.write_text("from pathlib import Path\ndef use_a(p: Path):\n return p\n", encoding="utf-8")
second.write_text("from pathlib import Path\ndef use_b(p: Path):\n return p\n", encoding="utf-8")
result = extract([first, second], cache_root=tmp_path)
# The internal field is gone from every node...
assert all("origin_file" not in node for node in result["nodes"])
# ...so no node leaks the absolute sandbox path that origin_file used to carry.
leaked = [
(node.get("id"), key, value)
for node in result["nodes"]
for key, value in node.items()
if isinstance(value, str) and str(tmp_path) in value
]
assert not leaked, f"absolute paths leaked into nodes: {leaked}"
# ...yet the colliding-id pass still kept the two cross-file stubs distinct.
path_nodes = [node for node in result["nodes"] if node["label"] == "Path"]
assert len(path_nodes) == 2
assert len({node["id"] for node in path_nodes}) == 2
def test_extract_updates_raw_call_callers_after_duplicate_id_disambiguation(tmp_path):
first = tmp_path / "apps/api/Program.cs"
second = tmp_path / "tools/api/Program.cs"