From 2d61ef7e688bfa05ce07c3aae06e714f2e49eb5d Mon Sep 17 00:00:00 2001 From: Faisal01011 Date: Sat, 3 Oct 2026 19:21:55 +0530 Subject: [PATCH] fix(extract): keep Scala file-scope val/var type references (#2054) Scala 3 allows `val`/`var` to be written directly at file scope with no enclosing object/class/trait. The `val_definition`/`var_definition` type-reference dispatch was gated on `and parent_class_nid`, so a file-scope binding produced no node and no edge of any kind, while the byte-identical definition nested one level inside any container resolved correctly. The missing container was the only difference: both positions parse to the same node type carrying the same `type` field. This is the Scala instance of a generic defect in the walker's default recursion path that had already caused and been fixed once for Python (#1050) -- a position that legitimately has no container must fall back to the file node rather than be skipped. The fix reuses the `owner_nid = parent_class_nid or file_nid` idiom already present in the property branch above, and moves the self-reference guard onto the same anchor so a file-scope binding cannot mint an edge from the file node to itself. Behaviour is unchanged for class-nested bindings; a regression test pins that case explicitly. Verified: - uv run pytest tests/test_scala_top_level_binding.py -q (8 passed) - 6 of the 8 new tests fail on the pre-fix tree, 2 pass either way (the in-class control, and the self-edge guard which is trivially empty before the fix and meaningful after it) - uv run pytest tests/ -q -> 28 failed, 6144 passed, 101 skipped; the 28 failures are byte-identical to the clean-tree baseline (missing tree-sitter grammars for erlang/R/solidity/vbnet, plus ollama retry tests) and none touch Scala - uv run ruff check . -> clean - uv run pyright -> 635 errors, identical to the clean-tree baseline (pre-existing debt; this change adds none) Co-Authored-By: Claude --- graphify/extractors/engine.py | 19 +++- tests/test_scala_top_level_binding.py | 139 ++++++++++++++++++++++++++ 2 files changed, 154 insertions(+), 4 deletions(-) create mode 100644 tests/test_scala_top_level_binding.py diff --git a/graphify/extractors/engine.py b/graphify/extractors/engine.py index 0914fe124..4e86ab523 100644 --- a/graphify/extractors/engine.py +++ b/graphify/extractors/engine.py @@ -5237,8 +5237,19 @@ def _extract_generic( return if (config.ts_module == "tree_sitter_scala" - and t in ("val_definition", "var_definition") - and parent_class_nid): + and t in ("val_definition", "var_definition")): + # Scala 3 drops the boilerplate wrapper: `val`/`var` can be written + # directly at file scope with no enclosing object/class/trait. Gating + # on `parent_class_nid` therefore dropped every type reference a + # file-scope binding makes -- the parsed shape is identical to the + # in-class case (both carry a `type` field), so the missing container + # was the only difference, and the binding vanished from the graph + # entirely rather than degrading (#2054). This is the Scala instance + # of the same generic defect already fixed for Python (#1050): a + # position that legitimately has no container must fall back to the + # file node rather than be skipped. Reuses the owner idiom from the + # property branch above. + owner_nid = parent_class_nid or file_nid type_node = node.child_by_field_name("type") if type_node is not None: line = node.start_point[0] + 1 @@ -5247,8 +5258,8 @@ def _extract_generic( for ref_name, role in refs: ctx = "generic_arg" if role == "generic_arg" else "field" target_nid = ensure_named_node(ref_name, line) - if target_nid != parent_class_nid: - add_edge(parent_class_nid, target_nid, "references", + if target_nid != owner_nid: + add_edge(owner_nid, target_nid, "references", line, context=ctx) # fall through so any call expressions in the initializer get walked diff --git a/tests/test_scala_top_level_binding.py b/tests/test_scala_top_level_binding.py new file mode 100644 index 000000000..a77aad150 --- /dev/null +++ b/tests/test_scala_top_level_binding.py @@ -0,0 +1,139 @@ +"""Scala top-level (file-scope) `val`/`var` bindings lose every type reference. + +Scala 3's headline feature is dropping the boilerplate wrapper: `val`, `var`, +`given` and `def` can be written directly at file scope with no enclosing +`object`/`class`/`trait`. The `val_definition`/`var_definition` type-reference +dispatch was gated on `and parent_class_nid`, so a file-scope binding silently +produced no node and no edge of any kind -- the exact same definition nested one +level inside any container resolved correctly. The gate was the only difference: +both positions parse to an identical `val_definition` with a `type` field. + +The same generic mechanism in the walker's default recursion path had already +caused and been fixed once for a Python-specific case (#1050), so this is the +Scala instance of a known defect rather than a new one. + +These tests pin the anchor fallback: a file-scope binding attributes its type +reference to the file node, a class-nested binding is unchanged, and the +self-reference guard follows the anchor so a file-scope binding whose type +resolves to the file's own stem does not mint an edge to itself. +""" +from pathlib import Path + +from graphify.extract import extract_scala + +SRC = '''\ +class Inner { + val nested: String = "x" + var nestedCounter: Int = 0 +} + +val greeting: String = "World" +var counter: Int = 0 + +trait Config +val config: Config = null + +val names: List[String] = Nil +''' + + +def _build(tmp_path): + (tmp_path / "TopLevel.scala").write_text(SRC) + r = extract_scala(tmp_path / "TopLevel.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"] == "references"} + + +def test_class_nested_binding_keeps_its_type_reference(tmp_path): + # Control: the pre-existing in-class behaviour must not regress. The edge is + # owned by the class, not the file. + r, nid = _build(tmp_path) + refs = _rels(r, "references") + assert (nid["Inner"], nid["String"]) in refs + + +def test_top_level_val_emits_type_reference_owned_by_file(tmp_path): + r, nid = _build(tmp_path) + refs = _rels(r, "references") + file_nid = nid["TopLevel.scala"] + assert (file_nid, nid["String"]) in refs + + +def test_top_level_var_emits_type_reference_owned_by_file(tmp_path): + r, nid = _build(tmp_path) + refs = _rels(r, "references") + file_nid = nid["TopLevel.scala"] + assert (file_nid, nid["Int"]) in refs + + +def test_top_level_binding_type_resolves_to_another_declaration(tmp_path): + # The type is a real declaration elsewhere in the corpus, so the reference + # must land on that node rather than on a bare stub. + r, nid = _build(tmp_path) + refs = _rels(r, "references") + file_nid = nid["TopLevel.scala"] + assert (file_nid, nid["Config"]) in refs + + +def test_top_level_binding_emits_no_self_edge(tmp_path): + # The self-reference guard compared against parent_class_nid, which is None at + # file scope. Anchoring on the file node must keep the guard working there, + # otherwise a binding typed with the file's own stem mints file -> file. + r, nid = _build(tmp_path) + file_nid = nid["TopLevel.scala"] + refs = _rels(r, "references") + assert not any(src == file_nid and tgt == file_nid for src, tgt in refs) + + +def test_top_level_type_reference_is_tagged_extracted(tmp_path): + # A type annotation written in the source is an extracted fact, not an + # inference: the confidence tag must say so, or a consumer cannot tell a + # real annotation from a guess (#2054). + r, nid = _build(tmp_path) + file_nid = nid["TopLevel.scala"] + edges = [e for e in r["edges"] + if e["relation"] == "references" and e["source"] == file_nid] + assert edges, "expected the file node to own the top-level type references" + for e in edges: + assert e.get("confidence") == "EXTRACTED", e + + +def test_top_level_type_reference_carries_source_location(tmp_path): + # The edge must point at the line the annotation is written on, so a query + # can cite it without a second file read. Locations serialise as "L". + r, nid = _build(tmp_path) + file_nid = nid["TopLevel.scala"] + edges = [e for e in r["edges"] + if e["relation"] == "references" and e["source"] == file_nid] + assert edges + locs = set() + for e in edges: + loc = e.get("source_location") + assert isinstance(loc, str) and loc.startswith("L"), e + locs.add(int(loc[1:])) + # `val greeting` L6, `var counter` L7, `val config` L10, `val names` L12 -- + # all strictly inside the file, never the default line 1. + assert locs == {6, 7, 10, 12}, locs + + +def test_top_level_generic_annotation_keeps_its_own_context(tmp_path): + # `List[String]` at file scope must resolve BOTH the base type and the type + # argument, and the argument keeps its own `generic_arg` context rather than + # being flattened into the plain `field` shape -- so a consumer can tell a + # type argument from a plain annotation. The type parameter `String` is + # shadowed by the real `String` node, which is exactly the resolution the + # in-class path already performs. + r, nid = _build(tmp_path) + refs = _rels(r, "references") + file_nid = nid["TopLevel.scala"] + assert (file_nid, nid["List"]) in refs + assert (file_nid, nid["String"]) in refs + ctxs = {(tgt, e.get("context")) for e in r["edges"] + if e["relation"] == "references" and e["source"] == file_nid + for tgt in (e["target"],)} + assert (nid["List"], "field") in ctxs + assert (nid["String"], "generic_arg") in ctxs