diff --git a/graphify/dedup.py b/graphify/dedup.py index dc177fb..d5b8276 100644 --- a/graphify/dedup.py +++ b/graphify/dedup.py @@ -247,6 +247,15 @@ def deduplicate_entities( score += _COMMUNITY_BOOST if score >= _MERGE_THRESHOLD: + # Identical labels across different source files almost always + # means same-named-but-different symbols (trait impls, wrapper + # methods, common type names). Mirror Pass 1's source_file + # partition for this sub-case. (#1046, leaks #895's fix) + if norm_label == neighbor_norm: + sf_a = node.get("source_file") or "" + sf_b = neighbor.get("source_file") or "" + if sf_a != sf_b: + continue all_group = norm_to_nodes.get(norm_label, [node]) + \ norm_to_nodes.get(neighbor_norm, [neighbor]) winner = _pick_winner(all_group) diff --git a/graphify/detect.py b/graphify/detect.py index aff69ea..8592ec7 100644 --- a/graphify/detect.py +++ b/graphify/detect.py @@ -935,7 +935,7 @@ def detect(root: Path, *, follow_symlinks: bool | None = None, google_workspace: # Skip files inside our own converted/ dir (avoid re-processing sidecars) if str(p).startswith(str(converted_dir)): continue - if _is_ignored(p, root, ignore_patterns): + if not in_memory and _is_ignored(p, root, ignore_patterns): continue if _is_sensitive(p): skipped_sensitive.append(str(p)) diff --git a/graphify/extract.py b/graphify/extract.py index 3173dee..413dd0e 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -2156,6 +2156,20 @@ def _extract_generic(path: Path, config: LanguageConfig) -> dict: parent_class_nid, add_node, add_edge): return + # Python's `@property` / `@staticmethod` / `@classmethod` wrap the + # inner function_definition in a `decorated_definition` node. The + # default recurse below clears parent_class_nid, which would cause the + # inner method to be emitted with a class-unqualified node id (e.g. + # `file_baz` instead of `file_bar_baz`). That diverges from the + # class-qualified id the rationale walker uses for the same method's + # docstring, leaving the rationale edge dangling and the docstring + # node orphaned (#1050). Treat decorated_definition as a transparent + # wrapper so parent_class_nid propagates to the real function node. + if t == "decorated_definition": + for child in node.children: + walk(child, parent_class_nid=parent_class_nid) + return + # Default: recurse for child in node.children: walk(child, parent_class_nid=None) diff --git a/tests/test_rationale.py b/tests/test_rationale.py index 67bd3df..b52aa39 100644 --- a/tests/test_rationale.py +++ b/tests/test_rationale.py @@ -3,6 +3,7 @@ import textwrap from pathlib import Path import pytest from graphify.extract import extract_python +from graphify.build import build_from_json def _write_py(tmp_path: Path, code: str) -> Path: @@ -172,3 +173,91 @@ def test_generated_file_module_docstring_suppressed(tmp_path): result = extract_python(path) rationale = [n for n in result["nodes"] if n.get("file_type") == "rationale"] assert not any("protocol buffer" in n["label"].lower() for n in rationale) + + +def test_decorated_method_node_id_is_class_qualified(tmp_path): + """Regression for #1050: @property / @staticmethod / @classmethod methods + were emitted with a class-unqualified node id (e.g. ``file_baz``) while the + rationale walker emitted the class-qualified id (``file_bar_baz``) as the + docstring's edge target. The mismatch caused ``build_from_json`` to drop + the rationale_for edge as dangling, orphaning the docstring node. + """ + path = _write_py(tmp_path, ''' + class Bar: + @property + def baz(self) -> int: + """Return the baz value because callers expect a cached integer.""" + return 1 + + @staticmethod + def helper() -> int: + """A static helper documented for downstream callers.""" + return 2 + + @classmethod + def factory(cls) -> "Bar": + """Construct a Bar via the canonical classmethod entry point.""" + return cls() + + def normal(self) -> int: + """A normal instance method documented for comparison.""" + return 3 + ''') + result = extract_python(path) + nodes_by_id = {n["id"]: n for n in result["nodes"]} + + # The plain method's id is the baseline: stem + class + name. + normal_ids = [nid for nid, n in nodes_by_id.items() + if n.get("label") == ".normal()"] + assert len(normal_ids) == 1, "expected exactly one ``.normal()`` method node" + normal_id = normal_ids[0] + assert normal_id.endswith("_bar_normal"), normal_id + + # Each decorated method must share the same class-qualified id shape so the + # rationale_for edge target matches the method node id. + for decorated_name in ("baz", "helper", "factory"): + matches = [nid for nid, n in nodes_by_id.items() + if n.get("label") == f".{decorated_name}()"] + assert len(matches) == 1, ( + f"expected exactly one ``.{decorated_name}()`` method node, got {matches}" + ) + method_id = matches[0] + assert method_id.endswith(f"_bar_{decorated_name}"), method_id + # Unqualified id (the buggy form) must NOT also be present. + unqualified_buggy_id = method_id.replace(f"_bar_{decorated_name}", + f"_{decorated_name}") + assert unqualified_buggy_id not in nodes_by_id, ( + f"buggy unqualified id {unqualified_buggy_id} should not exist alongside " + f"the class-qualified id" + ) + + # Every rationale_for edge's target must resolve to an actual node in the + # extraction (no dangling edges into phantom unqualified ids). + node_ids = set(nodes_by_id.keys()) + rationale_edges = [e for e in result["edges"] if e.get("relation") == "rationale_for"] + for edge in rationale_edges: + assert edge["target"] in node_ids, ( + f"rationale_for edge targets missing node id {edge['target']!r}" + ) + + # After build_from_json, each decorated-method docstring node must be + # connected (degree > 0), not an orphan dropped from the graph. + g = build_from_json(result) + for decorated_name in ("baz", "helper", "factory", "normal"): + method_id = next( + nid for nid, n in nodes_by_id.items() + if n.get("label") == f".{decorated_name}()" + ) + # Find rationale node attached to this method. + attached_rationale = [ + e["source"] for e in rationale_edges if e["target"] == method_id + ] + assert attached_rationale, ( + f"no rationale_for edge found for ``.{decorated_name}()`` method" + ) + for r_id in attached_rationale: + assert r_id in g.nodes, f"rationale node {r_id} missing from graph" + assert g.degree(r_id) > 0, ( + f"rationale node {r_id} for ``.{decorated_name}()`` is orphaned " + f"(degree 0) after build_from_json" + )