mirror of
https://github.com/safishamsi/graphify.git
synced 2026-10-14 16:09:26 +00:00
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 <noreply@anthropic.com>
(cherry picked from commit 2d61ef7e68)
140 lines
5.5 KiB
Python
140 lines
5.5 KiB
Python
"""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<line>".
|
|
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
|