feat(ruby/affected): mixes_in edges for include/extend/prepend + method-bound callers in affected (#1668, #1669)
#1668: Ruby `include`/`extend`/`prepend <Const>` in a class/module body now emits a `mixes_in` edge to the module. The mixin is captured during the node walk and resolved cross-file by resolve_ruby_member_calls (single-owner guard, reusing the #1640 module nodes as targets). The shared call pass skips these markers so they are not mislabeled as `calls`. `extend self` and non-constant args are skipped; ambiguous/undefined modules produce no edge. Rails concern composition is now visible to affected/explain. #1669: affected <Class> seeds the reverse walk with the root's own member nodes (one method/contains hop) so callers that bind at method granularity (e.g. Service.call -> the def self.call node, #1634) are reachable from the class. method/contains stay out of the general relation-filtered walk (no forward noise), and the seeded member nodes are not reported as hits. Full suite: 2924 passed, 3 skipped. Verified end-to-end (Rails-shaped repros) plus edge cases: extend self / undefined / ambiguous mixins emit nothing, mixins are not emitted as calls, member methods aren't reported, class-level callers still resolve, and one-hop seeding does not pull in downstream classes' methods. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
d9f97b9c01
commit
6631af7936
@@ -4,6 +4,8 @@ Full release notes with details on each version: [GitHub Releases](https://githu
|
||||
|
||||
## Unreleased
|
||||
|
||||
- Feat: Ruby `include`/`extend`/`prepend <Module>` now emits a `mixes_in` edge to the module (#1668, thanks @krishnateja7). Concerns/mixins are the composition mechanism in Rails, but they produced no edges, so the blast radius of editing a shared concern was invisible to `affected`. A constant-argument mixin inside a class or module body now resolves to the module node (reusing the #1634 candidate logic and the #1640 module nodes, under the single-owner guard) and emits `Class --mixes_in--> Module`, which `affected` already traverses. `extend self` and non-constant arguments are skipped; an ambiguous or undefined module produces no edge.
|
||||
- Feat: `affected <Class>` now reaches callers that bind to the class's method nodes (#1669, thanks @krishnateja7). Since #1634 binds `Service.call` precisely to the `def self.call` method node, a class-level `affected` query missed those callers because `method`/`contains` are (correctly) not general-traversal relations. The reverse walk now seeds from the root's own member nodes (one `method`/`contains` hop outward) so method-bound callers are reachable from the class, with no change to the general traversal (no forward noise) and the member nodes themselves are not reported as hits.
|
||||
- Fix: capitalized/mixed-case file extensions are no longer silently skipped (#1671, thanks @raman118). `collect_files` and `_get_extractor` matched suffixes case-sensitively, so `App.PY`, `script.JS`, `Lib.Ts`, etc. fell through and were never extracted. Suffix matching now falls back to the lowercased form for both file discovery and extractor dispatch (including `.blade.php`); an unsupported extension like `.xyz` is still skipped.
|
||||
- Fix: the virtual PostgreSQL `source_file` URI no longer gets backslash-mangled on Windows (#1672, thanks @raman118). `introspect_postgres` built the synthetic `postgresql://host/db` path with `Path`, which rewrites `/` to `\` on Windows; it now uses `PurePosixPath` so the URI stays forward-slashed on every platform.
|
||||
- Fix: a deferred `import(...)` no longer manufactures a phantom file cycle (#1241, thanks @Synvoya). Dynamic imports are real dependencies but not static ones, so two files that reference each other via one static import plus one dynamic import were reported as a circular dependency. The dynamic-import edge stays in the graph (marked `deferred`) but is excluded from `find_import_cycles`.
|
||||
|
||||
@@ -149,6 +149,27 @@ def affected_nodes(
|
||||
queue: deque[tuple[str, int]] = deque([(seed, 0)])
|
||||
hits: list[AffectedHit] = []
|
||||
|
||||
# #1669: seed the reverse walk with the root's own member nodes (one outward
|
||||
# `method`/`contains` hop). A caller can bind to a class's method node rather
|
||||
# than the class node itself (e.g. `Service.call` resolves to the `def
|
||||
# self.call` node, #1634), so those callers are unreachable from the class
|
||||
# otherwise. The member nodes are seeds only (not reported as hits), and
|
||||
# `method`/`contains` stay out of the general relation-filtered walk, so this
|
||||
# adds no forward noise anywhere else.
|
||||
if hasattr(graph, "out_edges"):
|
||||
member_edges = graph.out_edges(seed, data=True)
|
||||
else:
|
||||
member_edges = (
|
||||
(s, t, d) for s, t, d in graph.edges(data=True) if s == seed
|
||||
)
|
||||
for _s, member, data in member_edges:
|
||||
if str(data.get("relation", "")) not in ("method", "contains"):
|
||||
continue
|
||||
member = str(member)
|
||||
if member not in seen:
|
||||
seen.add(member)
|
||||
queue.append((member, 0))
|
||||
|
||||
while queue:
|
||||
current, current_depth = queue.popleft()
|
||||
if current_depth >= depth:
|
||||
|
||||
@@ -3450,6 +3450,10 @@ def _extract_generic(
|
||||
# `let vm = VM()`) live outside function bodies, so the call-walk never
|
||||
# reaches them. Collect (owner_nid, call_node) here and walk them too.
|
||||
initializer_nodes: list[tuple[str, object]] = []
|
||||
# Ruby include/extend/prepend mixins collected during the node walk (#1668),
|
||||
# merged into raw_calls after the call-walk populates it (raw_calls does not
|
||||
# exist yet while walk() runs). Resolved cross-file by the Ruby resolver.
|
||||
_ruby_mixin_calls: list[dict] = []
|
||||
# #1356: per-file map of local name -> declared type (properties + params),
|
||||
# threaded out as `swift_type_table` so member calls (`vm.update()`) can be
|
||||
# resolved to the receiver's real definition in _resolve_swift_member_calls.
|
||||
@@ -3819,6 +3823,36 @@ def _extract_generic(
|
||||
seen_ids.add(base_nid)
|
||||
add_edge(class_nid, base_nid, "inherits", line)
|
||||
|
||||
# `include`/`extend`/`prepend <Const>` in the class/module body ->
|
||||
# a `mixes_in` edge to the module (#1668). The module usually lives
|
||||
# in another file, so defer resolution to the cross-file Ruby
|
||||
# resolver (reusing the #1634 candidate logic and the #1640 module
|
||||
# nodes as targets). Only bare/namespaced constant arguments count;
|
||||
# `extend self`, `include some_var`, etc. are skipped.
|
||||
_rb_body = _find_body(node, config)
|
||||
if _rb_body is not None:
|
||||
for _stmt in _rb_body.children:
|
||||
if _stmt.type != "call" or _stmt.child_by_field_name("receiver") is not None:
|
||||
continue
|
||||
_m = _stmt.child_by_field_name("method")
|
||||
if _m is None or _read_text(_m, source) not in ("include", "extend", "prepend"):
|
||||
continue
|
||||
_args = _stmt.child_by_field_name("arguments")
|
||||
if _args is None:
|
||||
continue
|
||||
for _arg in _args.children:
|
||||
if _arg.type not in ("constant", "scope_resolution"):
|
||||
continue
|
||||
_mod = _ruby_const_last_name(_arg, source)
|
||||
if _mod:
|
||||
_ruby_mixin_calls.append({
|
||||
"caller_nid": class_nid,
|
||||
"callee": _mod,
|
||||
"is_mixin": True,
|
||||
"source_file": str_path,
|
||||
"source_location": f"L{_stmt.start_point[0] + 1}",
|
||||
})
|
||||
|
||||
# C#-specific: inheritance / interface implementation via base_list
|
||||
if config.ts_module == "tree_sitter_c_sharp":
|
||||
csharp_type_params = _csharp_type_parameters_in_scope(node, source)
|
||||
@@ -5576,6 +5610,10 @@ def _extract_generic(
|
||||
if src in valid_ids and (tgt in valid_ids or edge["relation"] in ("imports", "imports_from", "re_exports")):
|
||||
clean_edges.append(edge)
|
||||
|
||||
# Ruby mixins were collected during the node walk (before raw_calls existed);
|
||||
# fold them in so the cross-file resolver sees them (#1668).
|
||||
if _ruby_mixin_calls:
|
||||
raw_calls.extend(_ruby_mixin_calls)
|
||||
result = {"nodes": nodes, "edges": clean_edges, "raw_calls": raw_calls}
|
||||
if callable_def_nids:
|
||||
# Mark function / method / class defs with a `_callable` attribute so the
|
||||
@@ -16438,6 +16476,12 @@ def extract(
|
||||
# and collides with any top-level function named "log" in the corpus.
|
||||
if rc.get("is_member_call"):
|
||||
continue
|
||||
# Skip Ruby include/extend/prepend mixin markers: they carry a module
|
||||
# name as `callee` but are not calls — the Ruby resolver turns them into
|
||||
# `mixes_in` edges. Letting the shared pass emit a `calls` edge here would
|
||||
# both mislabel the relation and block the mixes_in emit as a dup (#1668).
|
||||
if rc.get("is_mixin"):
|
||||
continue
|
||||
# Exact-case match first (case is semantic). Fold only when the CALLING
|
||||
# file's language is case-insensitive, and only against the folded index of
|
||||
# case-insensitive-language definitions — so a Python `Path()` call can never
|
||||
|
||||
@@ -94,7 +94,8 @@ def resolve_ruby_member_calls(
|
||||
nids = class_def_nids.get(_key(name), [])
|
||||
return nids[0] if len(nids) == 1 else None
|
||||
|
||||
def _emit(caller: str, target: str, rc: dict[str, Any]) -> None:
|
||||
def _emit(caller: str, target: str, rc: dict[str, Any],
|
||||
relation: str = "calls", context: str = "call") -> None:
|
||||
if not caller or not target or caller == target:
|
||||
return
|
||||
if (caller, target) in existing_pairs:
|
||||
@@ -103,8 +104,8 @@ def resolve_ruby_member_calls(
|
||||
all_edges.append({
|
||||
"source": caller,
|
||||
"target": target,
|
||||
"relation": "calls",
|
||||
"context": "call",
|
||||
"relation": relation,
|
||||
"context": context,
|
||||
"confidence": "EXTRACTED",
|
||||
"confidence_score": 1.0,
|
||||
"source_file": rc.get("source_file", ""),
|
||||
@@ -112,6 +113,21 @@ def resolve_ruby_member_calls(
|
||||
"weight": 1.0,
|
||||
})
|
||||
|
||||
# `include`/`extend`/`prepend <Const>` mixins (#1668): resolve the module by
|
||||
# its constant name to the single owning module/class node and emit a
|
||||
# `mixes_in` edge, under the same single-definition god-node guard. An
|
||||
# ambiguous or unresolved constant produces no edge.
|
||||
for rc in _ruby_raw_calls(per_file):
|
||||
if not rc.get("is_mixin"):
|
||||
continue
|
||||
caller = str(rc.get("caller_nid", ""))
|
||||
module_name = rc.get("callee")
|
||||
if not caller or not module_name:
|
||||
continue
|
||||
target = _unique_class(str(module_name))
|
||||
if target is not None:
|
||||
_emit(caller, target, rc, relation="mixes_in", context="mixin")
|
||||
|
||||
for rc in _ruby_raw_calls(per_file):
|
||||
if not rc.get("is_member_call"):
|
||||
continue
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
"""#1669 — affected <Class> must reach callers that bind to the class's method
|
||||
nodes (post-#1634 method-granularity resolution), by seeding the reverse walk
|
||||
with the root's member nodes (one method/contains hop). method/contains stay out
|
||||
of the general relation-filtered walk, so no forward noise is added elsewhere.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import networkx as nx
|
||||
|
||||
from graphify.affected import affected_nodes
|
||||
|
||||
|
||||
def _g():
|
||||
g = nx.DiGraph()
|
||||
for nid, label in [
|
||||
("proc", "Processor"), ("proc_call", ".call()"),
|
||||
("runner", "Runner"), ("runner_run", ".run()"),
|
||||
]:
|
||||
g.add_node(nid, label=label)
|
||||
g.add_edge("proc", "proc_call", relation="method") # class owns method
|
||||
g.add_edge("runner", "runner_run", relation="method")
|
||||
g.add_edge("runner_run", "proc_call", relation="calls") # caller binds to method node (#1634)
|
||||
return g
|
||||
|
||||
|
||||
def test_class_affected_reaches_method_bound_caller():
|
||||
g = _g()
|
||||
hits = {h.node_id for h in affected_nodes(g, "proc", depth=2)}
|
||||
assert "runner_run" in hits, "caller of Processor.call must be reachable from Processor"
|
||||
|
||||
|
||||
def test_member_method_node_not_reported_as_hit():
|
||||
g = _g()
|
||||
hits = {h.node_id for h in affected_nodes(g, "proc", depth=2)}
|
||||
# the class's own method node is a seed, not an affected node
|
||||
assert "proc_call" not in hits
|
||||
|
||||
|
||||
def test_method_contains_still_excluded_from_general_walk():
|
||||
# A node two method-hops away (method of a DIFFERENT class discovered during
|
||||
# the walk) must NOT be pulled in: only the root's own members are seeded.
|
||||
g = nx.DiGraph()
|
||||
for nid, label in [("a", "A"), ("a_m", ".m()"), ("b", "B"), ("b_m", ".n()")]:
|
||||
g.add_node(nid, label=label)
|
||||
g.add_edge("a", "a_m", relation="method")
|
||||
g.add_edge("a_m", "b", relation="calls") # A.m calls class B
|
||||
g.add_edge("b", "b_m", relation="method") # B's own method
|
||||
hits = {h.node_id for h in affected_nodes(g, "a", depth=3)}
|
||||
# We seeded A's members and walk reverse; B and B's method are downstream of A
|
||||
# (A.m -> B), not reverse-callers of A, so they must not appear.
|
||||
assert hits == set() or "b_m" not in hits
|
||||
|
||||
|
||||
def test_class_level_caller_still_works():
|
||||
# A caller bound to the class node itself (not a method) is unaffected.
|
||||
g = nx.DiGraph()
|
||||
g.add_node("svc", label="Svc")
|
||||
g.add_node("caller", label=".use()")
|
||||
g.add_edge("caller", "svc", relation="references")
|
||||
hits = {h.node_id for h in affected_nodes(g, "svc", depth=2)}
|
||||
assert "caller" in hits
|
||||
@@ -290,3 +290,61 @@ def test_ambiguous_constant_receiver_emits_no_edge(tmp_path: Path) -> None:
|
||||
"class Runner\n def run\n Processor.call\n end\nend\n")
|
||||
graph = extract([caller, tmp_path / "a.rb", tmp_path / "b.rb"], cache_root=tmp_path, parallel=False)
|
||||
assert _has_call_edge(graph, "run", "call") is None
|
||||
|
||||
|
||||
# ── #1668 include/extend/prepend -> mixes_in ─────────────────────────────────
|
||||
|
||||
|
||||
def _mixes_in(graph: dict) -> set[tuple[str, str]]:
|
||||
labels = _labels(graph["nodes"])
|
||||
return {
|
||||
(labels.get(e["source"], ""), labels.get(e["target"], ""))
|
||||
for e in graph["edges"] if e.get("relation") == "mixes_in"
|
||||
}
|
||||
|
||||
|
||||
def test_include_emits_mixes_in_edge(tmp_path: Path) -> None:
|
||||
_write(tmp_path, "concern.rb", "module SealedProtection\n def sealed?; true; end\nend\n")
|
||||
_write(tmp_path, "model.rb",
|
||||
"class Roster < ApplicationRecord\n include SealedProtection\nend\n")
|
||||
g = extract([tmp_path / "model.rb", tmp_path / "concern.rb"], cache_root=tmp_path, parallel=False)
|
||||
assert ("Roster", "SealedProtection") in _mixes_in(g)
|
||||
|
||||
|
||||
def test_extend_and_prepend_emit_mixes_in(tmp_path: Path) -> None:
|
||||
_write(tmp_path, "helpers.rb", "module Helpers\n def h; end\nend\n")
|
||||
_write(tmp_path, "audit.rb", "module Audit\n def a; end\nend\n")
|
||||
_write(tmp_path, "svc.rb",
|
||||
"class Svc\n extend Helpers\n prepend Audit\nend\n")
|
||||
mix = _mixes_in(extract(sorted(tmp_path.glob("*.rb")), cache_root=tmp_path, parallel=False))
|
||||
assert ("Svc", "Helpers") in mix
|
||||
assert ("Svc", "Audit") in mix
|
||||
|
||||
|
||||
def test_extend_self_and_nonconstant_args_emit_no_mixin(tmp_path: Path) -> None:
|
||||
# `extend self` and `include some_var` are not constant module references.
|
||||
_write(tmp_path, "m.rb",
|
||||
"module M\n extend self\n def go; end\nend\n")
|
||||
mix = _mixes_in(extract([tmp_path / "m.rb"], cache_root=tmp_path, parallel=False))
|
||||
assert not any(t == "self" for _s, t in mix)
|
||||
assert not mix
|
||||
|
||||
|
||||
def test_include_of_undefined_or_ambiguous_module_emits_no_edge(tmp_path: Path) -> None:
|
||||
# Undefined module (no node) -> no edge, under the single-owner guard.
|
||||
_write(tmp_path, "x.rb", "class X\n include NotDefinedAnywhere\nend\n")
|
||||
mix = _mixes_in(extract([tmp_path / "x.rb"], cache_root=tmp_path, parallel=False))
|
||||
assert not any(t == "NotDefinedAnywhere" for _s, t in mix)
|
||||
|
||||
|
||||
def test_mixin_is_not_emitted_as_calls_edge(tmp_path: Path) -> None:
|
||||
# Regression: the shared cross-file call pass must not turn a mixin into a
|
||||
# `calls` edge (which would mislabel it and block the mixes_in emit).
|
||||
_write(tmp_path, "concern.rb", "module C\n def m; end\nend\n")
|
||||
_write(tmp_path, "k.rb", "class K\n include C\nend\n")
|
||||
g = extract([tmp_path / "k.rb", tmp_path / "concern.rb"], cache_root=tmp_path, parallel=False)
|
||||
labels = _labels(g["nodes"])
|
||||
calls = {(labels.get(e["source"], ""), labels.get(e["target"], ""))
|
||||
for e in g["edges"] if e.get("relation") == "calls"}
|
||||
assert ("K", "C") not in calls
|
||||
assert ("K", "C") in _mixes_in(g)
|
||||
|
||||
Reference in New Issue
Block a user