From afa4aded2e77af6c3591094132a7f29dedb411dc Mon Sep 17 00:00:00 2001 From: Mohammed Ateeq <88542309+TPAteeq@users.noreply.github.com> Date: Sun, 28 Jun 2026 18:54:58 +0100 Subject: [PATCH] fix(extract): drop internal origin_file so it stops leaking into graph.json (#1516) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- CHANGELOG.md | 4 ++++ graphify/extract.py | 9 +++++++++ tests/test_extract.py | 30 ++++++++++++++++++++++++++++++ 3 files changed, 43 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index dc8e14d0..925c4c52 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) +## 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. diff --git a/graphify/extract.py b/graphify/extract.py index 863ae480..39c4bfa8 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -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 diff --git a/tests/test_extract.py b/tests/test_extract.py index 43f15b04..54a186b0 100644 --- a/tests/test_extract.py +++ b/tests/test_extract.py @@ -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"