diff --git a/graphify/extract.py b/graphify/extract.py index 72045663..df616932 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -1,6 +1,7 @@ """Deterministic structural extraction from source code using tree-sitter. Outputs nodes+edges dicts.""" from __future__ import annotations +import hashlib import importlib import json import os @@ -7658,11 +7659,34 @@ def _disambiguate_colliding_node_ids( if len(group) < 2 or len(source_keys) < 2: continue ambiguous_ids.add(old_id) + # Salt the colliding id with the *path* it came from. The naive salt is + # ``_make_id(source_key, old_id)`` — source_key is the raw repo-relative + # path. But _make_id collapses every separator, so two DISTINCT paths + # whose only difference is a separator-vs-inner-punctuation swap + # (``a/b/c.md`` vs ``a.b/c.md``, ``foo/bar_baz.md`` vs ``foo_bar/baz.md``) + # normalize to the SAME salted id and still collide (#1522 — the residual + # of #1504 the 0.9.0 full-path stem didn't reach). When that happens, + # append a short stable hash of the *raw* source_key, which IS injective + # over distinct paths, so the colliders separate. Computed in code from + # source_file (never trusted from the LLM), so AST↔semantic parity holds. + naive: dict[str, str] = {} # source_key -> _make_id(source_key, old_id) + for source_key in source_keys: + if source_key: + naive[source_key] = _make_id(source_key, old_id) + # source_keys that, after normalization, are not unique among themselves. + seen: dict[str, int] = {} + for nid in naive.values(): + seen[nid] = seen.get(nid, 0) + 1 + needs_hash = {sk for sk, nid in naive.items() if seen.get(nid, 0) > 1} for node in group: source_key = _node_disambiguation_source_key(node, root) if not source_key: continue - new_id = _make_id(source_key, old_id) + if source_key in needs_hash: + salt = hashlib.sha1(source_key.encode("utf-8")).hexdigest()[:6] + new_id = _make_id(source_key, old_id, salt) + else: + new_id = naive.get(source_key) or _make_id(source_key, old_id) remap[(old_id, source_key)] = new_id if new_id != old_id: node["id"] = new_id diff --git a/tests/test_extract.py b/tests/test_extract.py index eac89709..2f01bc0f 100644 --- a/tests/test_extract.py +++ b/tests/test_extract.py @@ -1548,3 +1548,36 @@ def test_dart_child_node_ids_are_stem_based(tmp_path): ) + + +def test_separator_collision_paths_get_distinct_ids(tmp_path): + """#1522: two distinct paths whose only difference is a separator-vs-punctuation + swap (foo/bar_baz.py vs foo_bar/baz.py) normalize to the same stem; the + disambiguation pass now salts the colliders with a stable path hash so they + stay distinct instead of silently merging.""" + a = tmp_path / "foo/bar_baz.py" + b = tmp_path / "foo_bar/baz.py" + a.parent.mkdir(parents=True) + b.parent.mkdir(parents=True) + a.write_text("class Widget:\n pass\n") + b.write_text("class Gadget:\n pass\n") + + result = extract([a, b], cache_root=tmp_path) + # file-level nodes are labeled with the filename; both files must survive as + # distinct nodes (no silent separator-collision merge) + file_nodes = [n for n in result["nodes"] if str(n.get("label", "")).endswith(".py")] + assert len(file_nodes) == 2 + assert len({n["id"] for n in file_nodes}) == 2, [n["id"] for n in file_nodes] + + +def test_non_colliding_path_id_is_not_salted(tmp_path): + """The collision hash must touch only actual colliders — a path with no collision + keeps its plain full-path stem id (no hash suffix).""" + from graphify.extractors.base import _file_stem + from graphify.ids import make_id + p = tmp_path / "src/auth/session.py" + p.parent.mkdir(parents=True) + p.write_text("class Session:\n pass\n") + result = extract([p], cache_root=tmp_path) + file_id = next(n["id"] for n in result["nodes"] if n.get("source_location") == "L1") + assert file_id == make_id(_file_stem(Path("src/auth/session.py"))) == "src_auth_session"