Skip to content

Scala self-type annotations (self: Logging with Database =>) are completely invisible to structural extraction — a dispatch-only fix, reusing the existing type-reference walker unchanged #2052

Description

@sub4biz

Summary

A trait's self-type annotation — self: Logging with Database =>, the cake-pattern idiom for declaring
"this trait can only be mixed into something that also provides Logging/Database" — produces a dedicated
self_type node that is never handled anywhere in the Scala extractor: not a single references, inherits,
or mixes_in edge is produced for the types it names, in any context. Unlike the match type finding in
graphify#2049, this needs no change to the
type-reference walker itself — _scala_collect_type_refs already handles every shape a self-type's type
position can take.
The fix is a single new dispatch branch.

The gap

trait Logging
trait Database

trait UserService:
  self: Logging with Database =>

  def findUser(id: Int): Unit = ()
trait_definition
  identifier "UserService"
  template_body
    self_type
      identifier "self"
      compound_type
        type_identifier "Logging"
        with
        type_identifier "Database"
      =>
    function_definition
      identifier "findUser"

self_type sits at the same sibling level inside template_body as val_definition/type_definition would.
Grepping the whole extractor for self_type turns up zero matches — this node type is never dispatched on
anywhere. It's also not reachable by the existing extends_clause/class_parameters scan (the code that
produces inherits/mixes_in edges), because that scan walks the direct children of the
trait_definition/class_definition node itself — self_type is one level deeper, inside template_body,
so it was never in that scan's reach even incidentally:

# existing extends_clause scan — iterates node.children where `node` IS the
# trait_definition/class_definition, never descends into template_body:
if config.ts_module == "tree_sitter_scala":
    extend = node.child_by_field_name("extend")
    if extend is None:
        for c in node.children:
            if c.type == "extends_clause":
                extend = c
                break
    ...

The practical effect: Logging with Database here is a real, declared structural dependency of
UserService — exactly the kind of typeclass/mixin wiring information cats-effect-style code relies on the
graph to surface — and it produces nothing at all.

Isolating the type node — no field names, but .is_named filtering is exact

self_type's children carry no field names (field_name_for_child returns None for every child, checked
directly), so the type has to be found positionally. Filtering by .is_named isolates exactly two entries in
the common case — the binder identifier first, the type node second:

trait Repository[A]:
  this: HasConfig =>

  def load(id: Int): A
self_type
  identifier "this"
  type_identifier "HasConfig"

Named children (punctuation :/=> are unnamed, confirmed via .is_named): ['identifier', 'type_identifier']
for this shape, ['identifier', 'compound_type'] for the Logging with Database shape above — the type node
is reliably named[1] whenever a type is present.

Legal self-type shape with no type at all, checked so this isn't left as an assumption — Scala allows
naming this for use in nested classes without imposing any type constraint:

class Outer:
  self =>

  def name: String = "outer"
self_type
  identifier "self"

Only one named child here (no :, no type) — the "type node is named[1]" rule needs len(named) >= 2 as a
guard, which correctly yields "no type node" for this shape rather than misreading the binder identifier as
a type.

The walker already handles every real type shape — confirmed by direct invocation, no changes needed

Running the installed _scala_collect_type_refs (imported from the installed package, not reimplemented)
directly against each self-type's isolated type node:

Logging with Database (compound_type)  -> refs = [('Logging', 'type'), ('Database', 'type')]
HasConfig (type_identifier)            -> refs = [('HasConfig', 'type')]

Both resolve correctly with zero modification to the walker — compound_type is already in its recursion
whitelist (added independently of this proposal), and a bare type_identifier is the walker's base case.
Unlike the match_type situation in
graphify#2049, there is no second, independent bug
layer here — this is purely a missing dispatch branch.

Structural refinement in a self-type (self: Logging { def extra: Int } =>, another idiomatic
cake-pattern shape) checked too, not left as an untested assumption:

self: Logging { def extra: Int } =>
compound_type
  type_identifier "Logging"
  refinement
    function_declaration "extra"

Still a compound_type at the top level, so the same isolation logic applies unchanged —
_scala_collect_type_refs on it returns [('Logging', 'type')]. The refinement body's own member signature
(extra: Int) isn't picked up as a reference — checked this is consistent with the rest of the extractor, not
just assumed: a val x: Logging { def extra: Int } = ... produces the exact same refs = [('Logging', 'type')]
through the existing, unrelated val_definition branch. The refinement body is unscanned everywhere in this
extractor, not a limitation specific to self-type support.

Proposed diff

if (config.ts_module == "tree_sitter_scala"
        and t == "self_type"
        and parent_class_nid):
    named = [c for c in node.children if c.is_named]
    type_node = named[1] if len(named) >= 2 else None
    if type_node is not None:
        line = node.start_point[0] + 1
        refs: list[tuple[str, str]] = []
        _scala_collect_type_refs(type_node, source, False, refs)
        for ref_name, role in refs:
            target_nid = ensure_named_node(ref_name, line)
            if target_nid != parent_class_nid:
                add_edge(parent_class_nid, target_nid, "requires", line)

Verified against a real parsed tree (not just reasoned through) — running this exact logic against all
four shapes above:

UserService  -> requires Logging, requires Database
Repository   -> requires HasConfig
Outer        -> (no self_type type node — self =>, correctly produces nothing)
UserService2 -> requires Logging   (the structural-refinement shape)

Also checked that this doesn't interact with the existing extends_clause handling when both are present on
the same trait — trait UserService extends Base: self: Logging => ... resolves extends_clause to Base
(via the existing, unmodified inherits edge) and self_type to Logging (via this proposal's new
requires edge) independently, with no conflict between the two code paths.

Open question: relation label

Proposing "requires" as a new dedicated label rather than reusing generic "references" — a self-type is a
structural precondition ("this can only be mixed into something providing X"), a different relationship than
inherits/mixes_in (is-a) or the generic "references" used for val/type-alias RHS mentions. Precedent in
this extractor is split both ways (inherits/mixes_in/derives are dedicated labels; most type mentions
reuse "references" with a context tag) — same open question already left to the maintainer for export
in graphify#2048; the maintainer's call here too.

What this deliberately does not propose

  • Any node/container for the self-type annotation itself — there is no meaningful "self-type node" to create;
    this is reference-edge extraction only, same scoping as the val/type_definition proposals.
  • Scanning a self-type's structural refinement body ({ def extra: Int }) for its own member references —
    checked above that the base type still resolves correctly through it, but the refinement body's members
    aren't walked, consistent with every other refinement-type context in this extractor, not a new limitation.

Environment

  • graphifyy 0.9.20, tree_sitter_scala 0.26.0
  • All four shapes (multi-type via with, single-type, no-type, structural refinement) were dumped directly
    against the installed grammar, checked for absence of ERROR/is_missing nodes. Field-name absence was
    confirmed via field_name_for_child, not assumed. The walker output was produced by running the installed
    _scala_collect_type_refs directly against real parsed nodes, not inferred from reading the source.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions