mirror of
https://github.com/safishamsi/graphify.git
synced 2026-09-22 21:45:58 +00:00
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:
co-authored by
Claude Opus 4.8
parent
0080d8ac43
commit
35fb437365
+25
-1
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user