diff --git a/graphify/build.py b/graphify/build.py index e8aac5ec3..dc774f2d2 100644 --- a/graphify/build.py +++ b/graphify/build.py @@ -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))) diff --git a/tests/test_build.py b/tests/test_build.py index c496873cb..92ab15732 100644 --- a/tests/test_build.py +++ b/tests/test_build.py @@ -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") +