fix(build): ghost-merge keys on full source_file, not bare basename (#2068)

build_from_json's #1145 ghost-duplicate merge keyed on (Path(source_file).name,
label), discarding the directory, so unrelated nodes from different files sharing
a common basename (index.md, README.md, ...) and a generic label were silently
merged onto one survivor with their edges rewired — corrupting multi-corpus doc
graphs. The AST/LLM ghost twins the merge legitimately targets always share the
same source_file, so keying on the full normalized source_file preserves #1145
while making cross-directory false merges impossible. This subsumes the
#1753/#1257 cross-file ambiguity guard (now removed as dead code). Independent of
the #2032 label pass. Updated the #1257 test to the now-correct precise merge.
This commit is contained in:
safishamsi
2026-07-21 13:03:34 +01:00
parent 87c870495f
commit cdeba1dcb7
2 changed files with 55 additions and 36 deletions
+20 -26
View File
@@ -625,9 +625,9 @@ def build_from_json(extraction: dict, *, directed: bool = False, root: str | Pat
# populates source_location, so those ghosts survived. Extended fix: use
# _origin=="ast" as the canonical signal. AST nodes always win; any non-AST
# node sharing (basename, label) with an AST node is a ghost.
_loc_nodes: dict[tuple[str, str], str] = {} # (basename, label) -> canonical node id
_loc_nodes: dict[tuple[str, str], str] = {} # (source_file, label) -> canonical node id
_loc_collisions: set[tuple[str, str]] = set() # keys shared by 2+ AST nodes
_noloc_nodes: dict[tuple[str, str], str] = {} # (basename, label) -> ghost node id
_noloc_nodes: dict[tuple[str, str], str] = {} # (source_file, label) -> ghost node id
# Pass 1: collect canonical nodes — AST-origin nodes take precedence over LLM nodes.
# When 2+ AST nodes share a key (same-named symbols in same-named files across
@@ -643,36 +643,31 @@ def build_from_json(extraction: dict, *, directed: bool = False, root: str | Pat
attrs = G.nodes[nid]
label = str(attrs.get("label", "")).strip()
sf = str(attrs.get("source_file", ""))
basename = Path(sf).name if sf else ""
if not label or not basename:
if not label or not sf:
continue
is_ast = attrs.get("_origin") == "ast"
if attrs.get("source_location") or is_ast:
key = (basename, label)
# Key on the FULL normalized source_file, not the bare basename
# (#2068): the AST/LLM ghost twins of #1145 always share the same
# source_file (different ids, same file), so full-path keying still
# collapses them, while unrelated same-basename nodes in DIFFERENT
# directories (docs/a/index.md vs docs/b/index.md) now get distinct
# keys and are never falsely merged. This subsumes the #1753/#1257
# cross-file ambiguity guard, which is why the non-AST branch below
# no longer needs it.
key = (sf, label)
if is_ast:
# Two AST nodes on the same key is an ambiguous collision.
# Two AST nodes on the same key (same file, same label) is an
# ambiguous collision.
if key in _loc_nodes and G.nodes[_loc_nodes[key]].get("_origin") == "ast":
_loc_collisions.add(key)
# AST-origin nodes always overwrite a prior non-AST entry.
_loc_nodes[key] = nid
else:
existing = _loc_nodes.get(key)
if existing is None:
_loc_nodes[key] = nid
elif (
G.nodes[existing].get("_origin") != "ast"
and str(G.nodes[existing].get("source_file", "")) != sf
):
# Two NON-AST nodes sharing (basename, label) but coming from
# DIFFERENT files are distinct concepts (e.g. a same-named
# concept in dir_a/update.md and dir_b/update.md), not an AST
# ghost/canonical twin. Merging them would drop a real node
# and pick the survivor arbitrarily via iteration order
# (#1753). Mark the key ambiguous so Pass 2 leaves both, the
# same conservatism the AST/AST case uses (#1257). A genuine
# same-file duplicate (identical source_file) is not flagged
# and still collapses.
_loc_collisions.add(key)
# First non-AST node for this (file, label) wins as canonical; a
# later same-key node is a genuine same-file duplicate and still
# collapses in Pass 2.
_loc_nodes.setdefault(key, nid)
# Pass 2: find ghosts — non-AST nodes that have an AST canonical twin.
for nid in sorted(node_set):
@@ -681,10 +676,9 @@ def build_from_json(extraction: dict, *, directed: bool = False, root: str | Pat
continue # AST nodes are never ghosts
label = str(attrs.get("label", "")).strip()
sf = str(attrs.get("source_file", ""))
basename = Path(sf).name if sf else ""
if not label or not basename:
if not label or not sf:
continue
key = (basename, label)
key = (sf, label)
if key in _loc_collisions:
continue # ambiguous key: no safe canonical winner, leave ghost intact
if key in _loc_nodes and _loc_nodes[key] != nid: