fix(build): deduplicate spec-conformant method nodes and aliases (#3705)

(cherry picked from commit fefdb5f074)
This commit is contained in:
shobhitagnihotri69 authored and safishamsi committed 2026-09-21 19:19:56 +01:00
1 parent 89c27c7b80
commit 3097e9d2fa
2 files changed
+64 -1

No files matched your search

+17 -1
View File
@@ -1069,6 +1069,7 @@ def build_from_json(extraction: dict, *, directed: bool = False, root: str | Pat
_loc_collisions: set[tuple[str, str]] = set() # keys shared by 2+ AST nodes
_noloc_nodes: dict[tuple[str, str], str] = {} # (source_file, label) -> ghost node id
_ast_file_nodes: list[tuple[str, str]] = [] # (node_id, source_file) for AST file-self nodes (#3344)
_ast_method_nodes: dict[tuple[str, str], list[str]] = {} # (source_file, norm_method) -> [ast_nid, ...]
# 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
@@ -1125,6 +1126,9 @@ def build_from_json(extraction: dict, *, directed: bool = False, root: str | Pat
# Pass 2b below can catch these by label alone.
if _is_file_node_label(label, sf):
_ast_file_nodes.append((nid, sf))
if label.startswith("."):
m_name = label.removeprefix(".").removesuffix("()")
_ast_method_nodes.setdefault((sf, m_name), []).append(nid)
else:
# First non-AST node for this (file, label) wins as canonical; a
# later same-key node is a genuine same-file duplicate and still
@@ -1132,6 +1136,7 @@ def build_from_json(extraction: dict, *, directed: bool = False, root: str | Pat
_loc_nodes.setdefault(key, nid)
# Pass 2: find ghosts — non-AST nodes that have an AST canonical twin.
_ghost_remap: dict[str, str] = {} # ghost_id -> canonical_id
for nid in sorted(node_set):
attrs = G.nodes[nid]
if attrs.get("_origin") == "ast":
@@ -1145,8 +1150,13 @@ def build_from_json(extraction: dict, *, directed: bool = False, root: str | Pat
continue # ambiguous key: no safe canonical winner, leave ghost intact
if key in _loc_nodes and _loc_nodes[key] != nid:
_noloc_nodes[key] = nid
elif key not in _loc_nodes:
# Spec-conformant method ghost omitting class segment / leading dot (#3705)
m_name = label.removeprefix(".").removesuffix("()")
m_candidates = _ast_method_nodes.get((sf, m_name), [])
if len(m_candidates) == 1:
_ghost_remap[nid] = m_candidates[0]
# For every ghost that has an AST counterpart, record a remap.
_ghost_remap: dict[str, str] = {} # ghost_id -> canonical_id
for key, sem_id in _noloc_nodes.items():
ast_id = _loc_nodes.get(key)
if ast_id is not None:
@@ -1247,6 +1257,12 @@ def build_from_json(extraction: dict, *, directed: bool = False, root: str | Pat
alias = old_stem + suffix
_alias_candidates.setdefault(_normalize_id(alias), set()).add(nid)
_alias_candidates.setdefault(alias, set()).add(nid)
if attrs.get("_origin") == "ast" and str(attrs.get("label", "")).startswith("."):
m_name = make_id(str(attrs.get("label", "")).strip().removeprefix(".").removesuffix("()"))
m_alias = f"{new_stem}_{m_name}"
if _normalize_id(nid) != _normalize_id(m_alias):
_alias_candidates.setdefault(_normalize_id(m_alias), set()).add(nid)
_alias_candidates.setdefault(m_alias, set()).add(nid)
for alias_key, candidates in _alias_candidates.items():
if len(candidates) == 1:
norm_to_id.setdefault(alias_key, next(iter(candidates)))
+47
View File
@@ -2001,3 +2001,50 @@ def test_build_annotations_all_resolve():
except NameError as exc:
unresolvable.append(f"{name}: {exc}")
assert not unresolvable, "\n".join(unresolvable)
def test_method_ghost_dedupe_and_alias_resolution_3705():
from graphify.build import build_from_json
extraction = {
"nodes": [
# AST node with class segment and leading dot
{
"id": "broker_mock_tda_mocktdaaccount_save_state",
"label": ".save_state()",
"file_type": "code",
"source_file": "broker/mock_tda.py",
"source_location": "L220",
"_origin": "ast",
},
# Spec-conformant semantic ghost node (no class segment, no leading dot)
{
"id": "broker_mock_tda_save_state",
"label": "save_state()",
"file_type": "code",
"source_file": "broker/mock_tda.py",
},
{
"id": "doc_summary",
"label": "Doc Summary",
"file_type": "document",
"source_file": "docs/summary.md",
},
],
"edges": [
{
"source": "doc_summary",
"target": "broker_mock_tda_save_state",
"relation": "references",
"confidence": "INFERRED",
"confidence_score": 0.85,
"source_file": "docs/summary.md",
}
],
}
G = build_from_json(extraction)
# The ghost node should be removed/remapped into the canonical AST node
assert "broker_mock_tda_save_state" not in G.nodes
assert "broker_mock_tda_mocktdaaccount_save_state" in G.nodes
assert G.has_edge("doc_summary", "broker_mock_tda_mocktdaaccount_save_state")