fix(ids): salt residual separator-collision node IDs injectively (#1522)

0.9.0 made the node-ID stem the full repo-relative path, but normalize_id collapses
every non-word run to "_", so the path separator is indistinguishable from inner
punctuation: foo/bar_baz.py and foo_bar/baz.py both normalized to foo_bar_baz and
still silently merged (the residual of #1504). The existing _disambiguate_colliding
_node_ids salt didn't help — it salted with _make_id(source_key, old_id), which
re-normalizes the path with the same lossy recipe, so the two colliders produced an
identical salted id.

When two distinct source paths' naive salts still collide, append a short stable
sha1(source_key)[:6] — injective over distinct paths — so they separate. Computed in
code from source_file (never trusted from the LLM), so AST<->semantic parity holds.

Blast radius: minimal/non-breaking — only the actual residual colliders get a hash
suffix. Non-colliding ids (the 99%, incl. the common #1504 case like two README.md
in different dirs) are byte-identical to 0.9.0 (verified: src/auth/session.py ->
src_auth_session, docs/v1/api/README.md -> docs_v1_api_readme unchanged). This is a
0.9.1 patch, not another migration.

Reported by @sub4biz (#1522). Regression tests cover both the collider-separation
and the non-collider-unchanged cases.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
safishamsi
2026-06-28 19:54:48 +01:00
co-authored by Claude Opus 4.8
parent 0080d8ac43
commit 35fb437365
2 changed files with 58 additions and 1 deletions
+25 -1
View File
@@ -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
+33
View File
@@ -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"