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:
co-authored by
Claude Sonnet 4.6
parent
740382af51
commit
9f73400cbc
@@ -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
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user