mirror of
https://github.com/safishamsi/graphify.git
synced 2026-10-14 07:59:40 +00:00
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 <noreply@anthropic.com>
This commit is contained in:
1 parent
0b60d47e6c
commit
2d61ef7e68
2 files changed
+154
-4
No files matched your search
@@ -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
|
||||
|
||||
|
||||
@@ -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<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
|
||||
Reference in new issue
Block a user