Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions graphify/affected.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
"uses",
"mixes_in",
"embeds",
"requires",
)


Expand Down
25 changes: 25 additions & 0 deletions graphify/extractors/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
141 changes: 141 additions & 0 deletions tests/test_scala_self_type.py
Original file line number Diff line number Diff line change
@@ -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
Loading