From 585e90953595df89708cdec4fd2fc339becc1eb2 Mon Sep 17 00:00:00 2001 From: rajatnagda45 Date: Tue, 13 Oct 2026 13:41:40 +0530 Subject: [PATCH] fix(extract): drop Scala generic type parameters from references MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A `class Box[T]` / `def map[R](...)` binds `T`/`R` as local names, not corpus symbols, but `_scala_collect_type_refs` appended the bare identifiers used in a signature as type references and ran them through the sourceless-stub path. Every generic class/method in the corpus then collapsed its `T` onto one bare `t` node — a god node that pollutes god-node rankings, since single-letter type parameters are not in the analyzer's builtin-noise filter. C#, Java, TypeScript, Kotlin, and Go already exclude theirs. Mirrors that pattern: a new `_scala_type_parameters_in_scope` helper walks up to every enclosing declaration's `type_parameters` list (the use site is `type_arguments`, a distinct node) and collects parameter names from direct identifiers and variance-annotated params, skipping `context_bound`/ `upper_bound`/`lower_bound` nodes (those name real types). `_scala_collect_type_refs` auto-computes and threads that skip set. Context bounds (#2046), genuine user types, and the generic container head still reference. --- graphify/extractors/engine.py | 54 +++++++++++++++++++++---- tests/test_scala_generic_type_params.py | 51 +++++++++++++++++++++++ 2 files changed, 98 insertions(+), 7 deletions(-) create mode 100644 tests/test_scala_generic_type_params.py diff --git a/graphify/extractors/engine.py b/graphify/extractors/engine.py index 443af97ce..473329d9e 100644 --- a/graphify/extractors/engine.py +++ b/graphify/extractors/engine.py @@ -1407,15 +1407,55 @@ def _scala_given_synthetic_name(node, source: bytes) -> str | None: return "given" -def _scala_collect_type_refs(node, source: bytes, generic: bool, out: list[tuple[str, str]]) -> None: +def _scala_type_parameters_in_scope(node, source: bytes) -> frozenset[str]: + """Return Scala type-parameter names visible from ``node``. + + A `class Box[T]` / `def map[R](...)` binds `T`/`R` as local names, not + corpus symbols. Walking up to every enclosing declaration's `type_parameters` + list (the use site is `type_arguments`, a distinct node, so an instantiation + argument is never collected) collects them for the scope skip set. A + variance-annotated parameter (`[+A]`/`[-B]`) wraps its name one level + deeper. A bound (`[A: Ordering]`, `[T <: Base]`) names a real type, not a + parameter, so its `context_bound`/`upper_bound`/`lower_bound` node is skipped. + """ + names: set[str] = set() + scope = node + while scope is not None: + for child in scope.children: + if child.type != "type_parameters": + continue + for param in child.children: + if param.type in ("identifier", "type_identifier"): + name = _read_text(param, source) + if name: + names.add(name) + elif param.type in ("covariant_type_parameter", "contravariant_type_parameter"): + for sub in param.children: + if sub.type in ("identifier", "type_identifier"): + name = _read_text(sub, source) + if name: + names.add(name) + break + scope = scope.parent + return frozenset(names) + + +def _scala_collect_type_refs(node, source: bytes, generic: bool, out: list[tuple[str, str]], + skip: frozenset[str] | None = None) -> None: """Walk a Scala type expression; append (name, role) tuples. - Handles type_identifier, generic_type (List[T]), and common type wrappers.""" + Handles type_identifier, generic_type (List[T]), and common type wrappers. + + Generic type parameters in scope (``skip``) are local bindings, not + references; emitting one mints a sourceless stub (the bare `t` god node a + `[T]` class or method would otherwise share corpus-wide).""" if node is None: return + if skip is None: + skip = _scala_type_parameters_in_scope(node, source) t = node.type if t == "type_identifier": text = _read_text(node, source) - if text: + if text and text not in skip: out.append((text, "generic_arg" if generic else "type")) return if t == "generic_type": @@ -1427,19 +1467,19 @@ def _scala_collect_type_refs(node, source: bytes, generic: bool, out: list[tuple break if base is not None and base.type == "type_identifier": text = _read_text(base, source) - if text: + if text and text not in skip: out.append((text, "generic_arg" if generic else "type")) for c in node.children: if c.type == "type_arguments": for arg in c.children: if arg.is_named: - _scala_collect_type_refs(arg, source, True, out) + _scala_collect_type_refs(arg, source, True, out, skip) return if t in ("compound_type", "infix_type", "function_type", "tuple_type", "annotated_type", "projected_type"): for c in node.children: if c.is_named: - _scala_collect_type_refs(c, source, generic, out) + _scala_collect_type_refs(c, source, generic, out, skip) # A `match type` (`type Elem[X] = X match { case String => Char }`) names a # real type in every case clause -- both the type it discriminates on and # the type it yields. Without these two the walker returned nothing for a @@ -1448,7 +1488,7 @@ def _scala_collect_type_refs(node, source: bytes, generic: bool, out: list[tuple if t in ("match_type", "type_case_clause"): for c in node.children: if c.is_named: - _scala_collect_type_refs(c, source, generic, out) + _scala_collect_type_refs(c, source, generic, out, skip) def _scala_collect_context_bounds( diff --git a/tests/test_scala_generic_type_params.py b/tests/test_scala_generic_type_params.py new file mode 100644 index 000000000..97e8dcdda --- /dev/null +++ b/tests/test_scala_generic_type_params.py @@ -0,0 +1,51 @@ +"""Scala generic type parameters must not mint sourceless god nodes. + +A `class Box[T]` / `def map[R](...)` binds `T`/`R` as local names, not corpus +symbols. The Scala type-ref pass appended the bare `T`/`R` identifiers used in +a signature as type references and ran them through the sourceless-stub path, +so every generic class/method in the corpus collapsed its `T` onto one bare `t` +node — a god node (single-letter type params are not in the analyzer's +builtin-noise filter). C#, Java, TypeScript, Kotlin, and Go already exclude +their type parameters; Scala now matches. +""" +from pathlib import Path + +from graphify.extract import extract + + +def _sourceless_labels(r): + return sorted({n.get("label") for n in r["nodes"] if not n.get("source_file")}) + + +def test_generic_class_and_method_type_params_mint_no_stub(tmp_path): + (tmp_path / "S.scala").write_text( + "class Box[T](v: T) {\n" + " def get(): T = v\n" + " def map[R](f: T => R): R = ???\n" + "}\n" + ) + r = extract([tmp_path / "S.scala"], cache_root=tmp_path, parallel=False) + assert not any(e.get("target") in ("t", "r") for e in r["edges"]) + assert "T" not in _sourceless_labels(r) + assert "R" not in _sourceless_labels(r) + + +def test_bounds_param_dropped_but_context_bound_and_real_types_kept(tmp_path): + # `A` is a parameter (dropped); the `Ordering` context bound is a real + # dependency (kept, #2046), and a genuine user type in the signature still + # references. (An upper bound `<: User` is deliberately not emitted, #2046.) + (tmp_path / "S.scala").write_text( + "class User(name: String)\n" + "class Processor[A: Ordering] {\n" + " def run(u: User): User = u\n" + "}\n" + ) + r = extract([tmp_path / "S.scala"], cache_root=tmp_path, parallel=False) + refs = { + rn["label"] + for e in r["edges"] if e.get("relation") == "references" + for rn in r["nodes"] if rn["id"] == e["target"] + } + assert "Ordering" in refs + assert "User" in refs + assert "A" not in refs