mirror of
https://github.com/safishamsi/graphify.git
synced 2026-10-13 07:29:33 +00:00
fix(build): deduplicate spec-conformant method nodes and aliases (#3705)
(cherry picked from commit fefdb5f074)
This commit is contained in:
1 parent
89c27c7b80
commit
3097e9d2fa
2 files changed
+64
-1
No files matched your search
+17
-1
@@ -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)))
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in new issue
Block a user