Summary
graphify.extract._file_stem() derives a node-ID prefix from path.parent.name of whatever Path it is handed, so node identity is not a pure function of the file's repo-relative location. The same entity can get two IDs (split nodes), and different entities can get one ID (collisions). Both corrupt degree/betweenness and community structure.
Two failure modes
1. Split nodes (when IDs come from more than one extractor)
The AST extractor receives absolute paths, so a repo-root file foo.py gets the repo folder name baked in: <repofolder>_foo_<entity>. Any other ID source that records a relative source_file / bare stem (an LLM/agent extraction step, a hand-authored fragment) produces foo_<entity>. They never join → the entity becomes two nodes with split degree. (_pick_winner in dedup.py already prefers the shorter, un-prefixed ID — i.e. the repo-prefixed form is effectively non-canonical.)
2. Cross-repo collisions (multi-repo workspace)
_file_stem qualifies with only the immediate parent dir. Running graphify extract . over a workspace of several repos collides same-named files: every service's app/models.py → one app_models_* node; tests/conftest.py from N services → shared tests_conftest_* nodes. Distinct entities silently merge, fabricating cross-service edges.
Real-world data point: a single extract . over a 7-repo workspace produced 29 such collisions (e.g. tests/conftest.py merged across 6 services, app/app.py across 5). merge-graphs/prefix_graph_for_global do namespace correctly via <repo>::<id> + the repo attribute — but a combined extract . does not, and that's a common usage.
Root cause
def _file_stem(path: Path) -> str:
parent = path.parent.name # immediate parent of the *passed* path
if parent and parent not in (".", ""):
return f"{parent}.{path.stem}"
return path.stem
Identity depends on CWD / absolute-vs-relative path / repo folder name, and disambiguates only one directory level (partial #550 — still collides for same-named files 2+ dirs deep).
Invariant
A node's identity should be a pure function of (repo-relative path, entity) — stable across CWD, absolute-vs-relative paths, and the repo folder's name. The "which repo" dimension belongs in the repo attribute (which deduplicate_entities and prefix_graph_for_global already use), never in the ID.
Proposed fix
- Thread the repo/scan root into
_file_stem and qualify by the full repo-relative path (also fixes deep same-name collisions):
def _file_stem(path, repo_root=None):
if repo_root is not None:
try:
rel = path.resolve().relative_to(Path(repo_root).resolve())
return ".".join(rel.with_suffix("").parts)
except ValueError:
pass
parent = path.parent.name # legacy fallback (unchanged)
return f"{parent}.{path.stem}" if parent not in (".", "") else path.stem
- Extract identity into one
graphify/ids.py (node_id(repo_root, path, entity)) called by every ID-producing path, so non-AST sources can't drift.
- Add a
validate.py collision check (canonical_id -> [ids]) wired into validate_extraction / build_from_json so a regression fails loudly.
- Backward-compat: this changes existing IDs → ship a one-time
graphify migrate-ids. I have a working reference implementation (canonicalization + alias-assertion + selftest) and am happy to contribute it.
Tests to add
canon idempotency; deep-nesting (a/b/c.py vs x/b/c.py distinct); rename-invariance (same repo under two folder names → identical IDs); collision-hook flags a split and passes a clean graph.
Filing as an issue first since it's a backward-incompatible change to node IDs — happy to open a PR if this direction looks right.
🤖 Generated with Claude Code
Summary
graphify.extract._file_stem()derives a node-ID prefix frompath.parent.nameof whateverPathit is handed, so node identity is not a pure function of the file's repo-relative location. The same entity can get two IDs (split nodes), and different entities can get one ID (collisions). Both corrupt degree/betweenness and community structure.Two failure modes
1. Split nodes (when IDs come from more than one extractor)
The AST extractor receives absolute paths, so a repo-root file
foo.pygets the repo folder name baked in:<repofolder>_foo_<entity>. Any other ID source that records a relativesource_file/ bare stem (an LLM/agent extraction step, a hand-authored fragment) producesfoo_<entity>. They never join → the entity becomes two nodes with split degree. (_pick_winnerindedup.pyalready prefers the shorter, un-prefixed ID — i.e. the repo-prefixed form is effectively non-canonical.)2. Cross-repo collisions (multi-repo workspace)
_file_stemqualifies with only the immediate parent dir. Runninggraphify extract .over a workspace of several repos collides same-named files: every service'sapp/models.py→ oneapp_models_*node;tests/conftest.pyfrom N services → sharedtests_conftest_*nodes. Distinct entities silently merge, fabricating cross-service edges.Real-world data point: a single
extract .over a 7-repo workspace produced 29 such collisions (e.g.tests/conftest.pymerged across 6 services,app/app.pyacross 5).merge-graphs/prefix_graph_for_globaldo namespace correctly via<repo>::<id>+ therepoattribute — but a combinedextract .does not, and that's a common usage.Root cause
Identity depends on CWD / absolute-vs-relative path / repo folder name, and disambiguates only one directory level (partial #550 — still collides for same-named files 2+ dirs deep).
Invariant
A node's identity should be a pure function of (repo-relative path, entity) — stable across CWD, absolute-vs-relative paths, and the repo folder's name. The "which repo" dimension belongs in the
repoattribute (whichdeduplicate_entitiesandprefix_graph_for_globalalready use), never in the ID.Proposed fix
_file_stemand qualify by the full repo-relative path (also fixes deep same-name collisions):graphify/ids.py(node_id(repo_root, path, entity)) called by every ID-producing path, so non-AST sources can't drift.validate.pycollision check (canonical_id -> [ids]) wired intovalidate_extraction/build_from_jsonso a regression fails loudly.graphify migrate-ids. I have a working reference implementation (canonicalization + alias-assertion + selftest) and am happy to contribute it.Tests to add
canonidempotency; deep-nesting (a/b/c.pyvsx/b/c.pydistinct); rename-invariance (same repo under two folder names → identical IDs); collision-hook flags a split and passes a clean graph.Filing as an issue first since it's a backward-incompatible change to node IDs — happy to open a PR if this direction looks right.
🤖 Generated with Claude Code