diff --git a/graphify/extract.py b/graphify/extract.py index 07f5accf3..cbed8879c 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -1454,7 +1454,13 @@ _SCALA_CONFIG = LanguageConfig( # cases, so it needs a node and a body walk like the others (its cases are # emitted by _scala_extra_walk, the parity of Java #1719 / Kotlin #1738). class_types=frozenset({"class_definition", "object_definition", "trait_definition", "enum_definition"}), - function_types=frozenset({"function_definition"}), + # `function_declaration` is a bodyless `def area: Double` — a deferred + # (abstract) method. In a `trait` or `abstract class` it is the contract a + # subclass must implement, exactly like Java/C#/TS abstract methods. Only + # `function_definition` (a `def` WITH a body) was a function type, so every + # abstract member was dropped: a pure-interface trait had no method nodes at + # all, and a concrete method calling a deferred one had no target to link to. + function_types=frozenset({"function_definition", "function_declaration"}), import_types=frozenset({"import_declaration"}), call_types=frozenset({"call_expression"}), call_function_field="", diff --git a/tests/test_scala_abstract_methods.py b/tests/test_scala_abstract_methods.py new file mode 100644 index 000000000..eeffc7f75 --- /dev/null +++ b/tests/test_scala_abstract_methods.py @@ -0,0 +1,96 @@ +"""A deferred (abstract) Scala `def` is a method node, like a concrete one. + +A bodyless `def area: Double` parses as `function_declaration`; a `def` WITH a +body is `function_definition`. Only the latter was a Scala function type, so +every abstract member was dropped. In Scala that is the common case, not an edge +case: a `trait` used as an interface is *all* deferred defs, so the whole +contract vanished from the graph — the trait had no method nodes, and a concrete +method calling a deferred one (`describe` → `name()`) had no target to link to. + +These specs pin that the deferred method becomes a node in both a `trait` and an +`abstract class`, and that a call to it now resolves. +""" +from __future__ import annotations + +import os +import tempfile +from pathlib import Path + +from graphify.extract import extract + + +def _extract(tmp_path, files: dict[str, str]) -> dict: + for name, body in files.items(): + p = tmp_path / name + p.parent.mkdir(parents=True, exist_ok=True) + p.write_text(body) + old = os.getcwd() + try: + os.chdir(tmp_path) + return extract([Path(n) for n in files], cache_root=Path(tempfile.mkdtemp())) + finally: + os.chdir(old) + + +def _method_edge(r: dict, owner: str, method: str) -> bool: + labels = {n["id"]: str(n.get("label", "")) for n in r["nodes"]} + return any( + e.get("relation") == "method" + and labels.get(e.get("source")) == owner + and labels.get(e.get("target")) == method + for e in r["edges"] + ) + + +def _calls(r: dict, src: str, tgt: str) -> bool: + labels = {n["id"]: str(n.get("label", "")) for n in r["nodes"]} + return any( + e.get("relation") == "calls" + and labels.get(e.get("source")) == src + and labels.get(e.get("target")) == tgt + for e in r["edges"] + ) + + +def test_deferred_method_without_parens_is_a_node(tmp_path): + r = _extract(tmp_path, {"s.scala": ( + "trait Shape {\n" + " def area: Double\n" + "}\n" + )}) + assert _method_edge(r, "Shape", ".area()") + + +def test_deferred_method_with_params_is_a_node(tmp_path): + r = _extract(tmp_path, {"s.scala": ( + "trait Shape {\n" + " def scale(f: Double): Shape\n" + "}\n" + )}) + assert _method_edge(r, "Shape", ".scale()") + + +def test_pure_interface_trait_keeps_every_contract_method(tmp_path): + r = _extract(tmp_path, {"s.scala": ( + "trait Shape {\n" + " def area: Double\n" + " def name(): String\n" + " def describe: String = name()\n" + "}\n" + )}) + assert _method_edge(r, "Shape", ".area()") + assert _method_edge(r, "Shape", ".name()") + assert _method_edge(r, "Shape", ".describe()") + # The concrete method's call now has a target to resolve to. + assert _calls(r, ".describe()", ".name()") + + +def test_abstract_class_deferred_method_is_a_node(tmp_path): + r = _extract(tmp_path, {"s.scala": ( + "abstract class Base {\n" + " def compute(x: Int): Int\n" + " def run(): Int = compute(1)\n" + "}\n" + )}) + assert _method_edge(r, "Base", ".compute()") + assert _calls(r, ".run()", ".compute()")