diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d61221..70170db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ Full release notes with details on each version: [GitHub Releases](https://githu ## Unreleased +- Feat: Ruby `include`/`extend`/`prepend ` 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 ` 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`. diff --git a/graphify/affected.py b/graphify/affected.py index dbb532b..deacc39 100644 --- a/graphify/affected.py +++ b/graphify/affected.py @@ -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: diff --git a/graphify/extract.py b/graphify/extract.py index 08edd32..f14f463 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -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 ` 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 diff --git a/graphify/ruby_resolution.py b/graphify/ruby_resolution.py index 5823ecf..e344175 100644 --- a/graphify/ruby_resolution.py +++ b/graphify/ruby_resolution.py @@ -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 ` 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 diff --git a/tests/test_affected_member_seed.py b/tests/test_affected_member_seed.py new file mode 100644 index 0000000..d1f6b3a --- /dev/null +++ b/tests/test_affected_member_seed.py @@ -0,0 +1,61 @@ +"""#1669 — affected 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 diff --git a/tests/test_ruby_resolution.py b/tests/test_ruby_resolution.py index adc7757..5cd1b11 100644 --- a/tests/test_ruby_resolution.py +++ b/tests/test_ruby_resolution.py @@ -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)