fix: memory-dir gitignore leak, Pass 2 dedup cross-file identical merge, decorated method node ID mismatch (#1047, #1046, #1050)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Safi
2026-05-27 22:30:08 +01:00
co-authored by Claude Sonnet 4.6
parent 740382af51
commit 9f73400cbc
4 changed files with 113 additions and 1 deletions
+9
View File
@@ -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)
+1 -1
View File
@@ -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))
+14
View File
@@ -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)
+89
View File
@@ -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"
)