From 2620af2a6a30b98be59e5e496bf3805295264028 Mon Sep 17 00:00:00 2001 From: Yyunozor Date: Mon, 27 Jul 2026 22:58:09 +0200 Subject: [PATCH] fix(scala): dispatch self-type annotations to requires edges (#2052) self_type (`self: Logging with Database =>`, `this: T =>`) was never dispatched on anywhere in the Scala extractor, so a trait/class's structural precondition on its enclosing type produced zero edges, in any context. The type node sits at a fixed position among self_type's unnamed-field children (binder identifier first, type second when present), and _scala_collect_type_refs already handles every shape that position can take (type_identifier, compound_type for `with`, refinement bodies) -- reused unchanged, one new dispatch branch. Also add the new `requires` relation to DEFAULT_AFFECTED_RELATIONS, mirroring how `indirect_call` was wired into blast-radius traversal when it was introduced, so `graphify affected` follows it like the existing inherits/mixes_in/embeds structural relations. Covers: single type, `with`-compound, structural refinement (base type only, matching how refinement bodies are already unscanned elsewhere), the binder-only `self =>` shape (no requires edge), coexistence with an unrelated `extends`, and a plain class without a self-type (no spurious edge). --- graphify/affected.py | 1 + graphify/extractors/engine.py | 25 ++++++ tests/test_scala_self_type.py | 141 ++++++++++++++++++++++++++++++++++ 3 files changed, 167 insertions(+) create mode 100644 tests/test_scala_self_type.py diff --git a/graphify/affected.py b/graphify/affected.py index 4fc7b8fbf..ce1415223 100644 --- a/graphify/affected.py +++ b/graphify/affected.py @@ -22,6 +22,7 @@ "uses", "mixes_in", "embeds", + "requires", ) diff --git a/graphify/extractors/engine.py b/graphify/extractors/engine.py index 136085e23..2501092a8 100644 --- a/graphify/extractors/engine.py +++ b/graphify/extractors/engine.py @@ -3135,6 +3135,31 @@ def _emit_java_parent_type(type_node, rel: str, at_line: int) -> None: line, context=ctx) # fall through so any call expressions in the initializer get walked + # Scala: `self: Logging with Database =>` (or `this: T =>`) declares a + # structural precondition on the enclosing type, not a mixin/reference. + # self_type carries no field names, so the type node is found + # positionally: the binder identifier is named[0], the type (when + # present) is named[1]. `self =>` binds a name with no type at all, so + # len(named) < 2 correctly yields no type node rather than misreading + # the binder as a type. _scala_collect_type_refs already handles every + # shape a self-type's type position can take (type_identifier, + # compound_type for `with`, refinement bodies via compound_type) -- + # reused unchanged. + if (config.ts_module == "tree_sitter_scala" + and t == "self_type" + and parent_class_nid): + named = [c for c in node.children if c.is_named] + type_node = named[1] if len(named) >= 2 else None + if type_node is not None: + line = node.start_point[0] + 1 + refs: list[tuple[str, str]] = [] + _scala_collect_type_refs(type_node, source, False, refs) + for ref_name, role in refs: + target_nid = ensure_named_node(ref_name, line) + if target_nid != parent_class_nid: + add_edge(parent_class_nid, target_nid, "requires", line) + return + if (config.ts_module == "tree_sitter_cpp" and t == "field_declaration" and parent_class_nid): diff --git a/tests/test_scala_self_type.py b/tests/test_scala_self_type.py new file mode 100644 index 000000000..b9b40bcf4 --- /dev/null +++ b/tests/test_scala_self_type.py @@ -0,0 +1,141 @@ +"""Scala self-type annotations (`self: Logging with Database =>`, `this: T =>`). + +A trait/class's self-type declares a structural precondition on the enclosing +type ("this can only be mixed into something also providing X") -- the +cake-pattern idiom. `self_type` was never dispatched on anywhere in the +extractor, so it produced no edges at all, in any context (#2052). These +tests pin the new dispatch branch: every real type shape a self-type can +carry, the negative case where no type is present, that an ordinary class +without a self-type never gains a spurious edge, that the new `requires` +relation coexists cleanly with an unrelated `extends` on the same class, and +that `affected` (blast radius) now traverses it. +""" +from pathlib import Path + +import networkx as nx + +from graphify.affected import affected_nodes +from graphify.extract import extract_scala + +SRC = '''\ +trait Loggable +trait Database +trait HasConfig +trait BaseTrait + +class SimpleSelfType { + self: HasConfig => + + def configure(): Unit = () +} + +class CompoundSelfType { + self: Loggable with Database => + + def run(): Unit = () +} + +class RefinedSelfType { + self: Loggable { def extra: Int } => + + def refined(): Unit = () +} + +class BinderOnlySelfType { + self => + + def noop(): Unit = () +} + +class NoSelfType { + def plain(): Unit = () +} + +class SelfTypeWithExtends extends BaseTrait { + self: Loggable => + + def combined(): Unit = () +} +''' + + +def _build(tmp_path): + (tmp_path / "SelfTypes.scala").write_text(SRC) + r = extract_scala(tmp_path / "SelfTypes.scala") + nid = {n["label"]: n["id"] for n in r["nodes"]} + return r, nid + + +def _rels(r, relation): + return {(e["source"], e["target"]) for e in r["edges"] if e["relation"] == relation} + + +def test_self_type_simple_type_emits_requires(tmp_path): + r, nid = _build(tmp_path) + requires = _rels(r, "requires") + assert (nid["SimpleSelfType"], nid["HasConfig"]) in requires + + +def test_self_type_compound_with_emits_requires_for_each_member(tmp_path): + r, nid = _build(tmp_path) + requires = _rels(r, "requires") + assert (nid["CompoundSelfType"], nid["Loggable"]) in requires + assert (nid["CompoundSelfType"], nid["Database"]) in requires + + +def test_self_type_structural_refinement_emits_requires_for_base_only(tmp_path): + r, nid = _build(tmp_path) + requires = _rels(r, "requires") + assert (nid["RefinedSelfType"], nid["Loggable"]) in requires + # The refinement body's own member signature (`def extra: Int`) is not + # walked -- consistent with every other refinement-type context in this + # extractor (a plain `val x: Loggable { def extra: Int }` behaves the + # same way), not a new limitation introduced here. + assert "extra" not in nid + + +def test_self_type_binder_only_emits_no_requires_edge(tmp_path): + r, nid = _build(tmp_path) + requires = _rels(r, "requires") + binder_only = nid["BinderOnlySelfType"] + assert not any(src == binder_only for src, _tgt in requires) + + +def test_class_without_self_type_emits_no_requires_edge(tmp_path): + r, nid = _build(tmp_path) + requires = _rels(r, "requires") + no_self_type = nid["NoSelfType"] + assert not any(src == no_self_type for src, _tgt in requires) + + +def test_self_type_coexists_with_unrelated_extends(tmp_path): + r, nid = _build(tmp_path) + inherits = _rels(r, "inherits") + requires = _rels(r, "requires") + combined = nid["SelfTypeWithExtends"] + # extends_clause and self_type are independent code paths -- both fire + # on the same class with no conflict. + assert (combined, nid["BaseTrait"]) in inherits + assert (combined, nid["Loggable"]) in requires + + +def test_requires_edges_carry_no_context(tmp_path): + r, _nid = _build(tmp_path) + for e in r["edges"]: + if e["relation"] == "requires": + assert "context" not in e + + +def test_affected_includes_self_type_dependents(tmp_path): + r, nid = _build(tmp_path) + g = nx.DiGraph() + for n in r["nodes"]: + g.add_node(n["id"], **n) + for e in r["edges"]: + g.add_edge(e["source"], e["target"], **e) + + # HasConfig's blast radius now includes SimpleSelfType through `requires` + # (DEFAULT_AFFECTED_RELATIONS), mirroring how `inherits`/`mixes_in` are + # already traversed. + affected = {h.node_id for h in affected_nodes(g, nid["HasConfig"])} + assert nid["SimpleSelfType"] in affected