Skip to content

Node IDs are context-dependent (_file_stem uses immediate parent of the passed path) → split & cross-repo-collided nodes #1158

Description

@cheema-yahya

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

  1. 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
  2. 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.
  3. Add a validate.py collision check (canonical_id -> [ids]) wired into validate_extraction / build_from_json so a regression fails loudly.
  4. 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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions