diff --git a/graphify/extract.py b/graphify/extract.py index 8064e9150..dca1a66ec 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -1536,7 +1536,10 @@ _SCALA_CONFIG = LanguageConfig( # Scala 3 `enum` is a class-like container too: it owns methods and a set of # 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"}), + # A Scala 3 `given ... with` instance is a class-like container: its `def`s + # belong to the given instance, not to file scope (where they leak and collide + # with same-named trait methods, #4130). Its body is a `with_template_body`. + class_types=frozenset({"class_definition", "object_definition", "trait_definition", "enum_definition", "given_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 @@ -1551,8 +1554,9 @@ _SCALA_CONFIG = LanguageConfig( call_accessor_field="field", name_fallback_child_types=("identifier",), # an enum wraps its members in `enum_body` rather than a `template_body`, - # so the body walk needs it to reach the enum's methods and cases. - body_fallback_child_types=("template_body", "enum_body"), + # so the body walk needs it to reach the enum's methods and cases; a + # `given ... with` instance wraps its members in a `with_template_body`. + body_fallback_child_types=("template_body", "enum_body", "with_template_body"), function_boundary_types=frozenset({"function_definition"}), import_handler=_import_scala, ) diff --git a/graphify/extractors/engine.py b/graphify/extractors/engine.py index ddb177f6d..6f1185ed7 100644 --- a/graphify/extractors/engine.py +++ b/graphify/extractors/engine.py @@ -1272,6 +1272,32 @@ def _cpp_collect_type_refs(node, source: bytes, generic: bool, out: list[tuple[s if c.is_named: _cpp_collect_type_refs(c, source, generic, out) +def _scala_given_synthetic_name(node, source: bytes) -> str | None: + """A stable label for an anonymous Scala 3 given (`given Show[Int] with`). + + Derived from the implemented type's base name so the given instance gets its + own scope (`given Show` -> id `..._given_show`), distinct from the trait type + node `Show` (`..._show`); otherwise its members would be dropped or leak to + file scope (#4130). Deterministic (no line numbers): two anonymous givens for + the same type in one file are a Scala ambiguity error, so no real collision. + """ + for c in node.children: + base = None + if c.type == "generic_type": + base = next( + (cc for cc in c.children + if cc.type in ("type_identifier", "stable_type_identifier", "identifier")), + None, + ) + elif c.type in ("type_identifier", "stable_type_identifier"): + base = c + if base is not None: + text = _read_text(base, source).strip() + if text: + return f"given {text}" + return "given" + + def _scala_collect_type_refs(node, source: bytes, generic: bool, out: list[tuple[str, str]]) -> None: """Walk a Scala type expression; append (name, role) tuples. Handles type_identifier, generic_type (List[T]), and common type wrappers.""" @@ -4924,9 +4950,17 @@ def _extract_generic( if child.type in config.name_fallback_child_types: name_node = child break + synthetic_name = None if not name_node: - return - class_name = _read_text(name_node, source) + # A Scala 3 anonymous given (`given Show[Int] with`) has no + # identifier child. Synthesize a stable name from the implemented + # type so its members stay scoped to the given instead of being + # dropped (or leaking to file scope) (#4130). + if config.ts_module == "tree_sitter_scala" and t == "given_definition": + synthetic_name = _scala_given_synthetic_name(node, source) + if not synthetic_name: + return + class_name = synthetic_name if synthetic_name else _read_text(name_node, source) ruby_absolute_declaration = ( config.ts_module == "tree_sitter_ruby" and class_name.startswith("::") ) diff --git a/tests/test_scala_given_instances.py b/tests/test_scala_given_instances.py new file mode 100644 index 000000000..7496aaf16 --- /dev/null +++ b/tests/test_scala_given_instances.py @@ -0,0 +1,87 @@ +"""Scala 3 `given ... with` members are scoped to the given, not file scope (#4130). + +A `given intShow: Show[Int] with` instance owns its `def`s. They used to leak to +file scope, where `show` (cased to `_show`) collided with the trait TYPE node +`Show` — so the given's call/reference edges landed on the trait. The given is now +a class-like container; an anonymous `given Show[String] with` gets a synthesized +name so its members are kept (scoped) instead of dropped. +""" +from __future__ import annotations + +from pathlib import Path + +from graphify.extract import extract_scala + +SRC = ( + "trait Show[A]:\n" + " def show(a: A): String\n" + "\n" + "given intShow: Show[Int] with\n" + " def show(a: Int): String = helper(a)\n" + " def helper(a: Int): String = a.toString\n" + "\n" + "given Show[String] with\n" + " def show(a: String): String = a\n" +) + + +def _graph(tmp_path): + f = tmp_path / "given.scala" + f.write_text(SRC, encoding="utf-8") + r = extract_scala(f) + label = {n["id"]: str(n.get("label", "")) for n in r["nodes"]} + return r, label + + +def _method(r, label, owner, method): + return any( + e.get("relation") == "method" + and label.get(e.get("source")) == owner + and label.get(e.get("target")) == method + for e in r["edges"] + ) + + +def test_named_given_owns_its_methods(tmp_path): + r, label = _graph(tmp_path) + assert _method(r, label, "intShow", ".show()"), "given's show not scoped to the given" + assert _method(r, label, "intShow", ".helper()"), "given's helper not scoped to the given" + + +def test_intra_given_call_resolves(tmp_path): + r, label = _graph(tmp_path) + calls = { + (label.get(e["source"]), label.get(e["target"])) + for e in r["edges"] if e.get("relation") == "calls" + } + assert (".show()", ".helper()") in calls + + +def test_trait_method_is_untouched_by_the_given(tmp_path): + r, label = _graph(tmp_path) + assert _method(r, label, "Show", ".show()"), "trait lost its own method" + # The trait TYPE node must carry no call/method edge stolen from the given. + show_trait_id = next( + n["id"] for n in r["nodes"] + if n.get("label") == "Show" and n.get("source_file") + ) + for e in r["edges"]: + if e.get("source") == show_trait_id and e.get("relation") == "calls": + raise AssertionError(f"given's call leaked onto the trait node: {e}") + + +def test_given_members_do_not_leak_to_file_scope(tmp_path): + r, _ = _graph(tmp_path) + labels = [n.get("label") for n in r["nodes"]] + # a leaked member would be a bare, file-scoped `helper()` (no leading dot) + assert "helper()" not in labels, "given member leaked to file scope" + + +def test_anonymous_given_members_are_kept(tmp_path): + r, label = _graph(tmp_path) + # the anonymous `given Show[String] with` is scoped under a synthesized owner, + # distinct from the trait node, and still owns its show(). + assert _method(r, label, "given Show", ".show()"), "anonymous given's members dropped" + trait_id = next(n["id"] for n in r["nodes"] if n.get("label") == "Show" and n.get("source_file")) + anon_ids = [n["id"] for n in r["nodes"] if n.get("label") == "given Show"] + assert anon_ids and trait_id not in anon_ids, "anonymous given collided with the trait node"