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.
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 dedicatedself_typenode that is never handled anywhere in the Scala extractor: not a singlereferences,inherits,or
mixes_inedge is produced for the types it names, in any context. Unlike thematch typefinding ingraphify#2049, this needs no change to the
type-reference walker itself —
_scala_collect_type_refsalready handles every shape a self-type's typeposition can take.
The fix is a single new dispatch branch.
The gap
self_typesits at the same sibling level insidetemplate_bodyasval_definition/type_definitionwould.Grepping the whole extractor for
self_typeturns up zero matches — this node type is never dispatched onanywhere. It's also not reachable by the existing
extends_clause/class_parametersscan (the code thatproduces
inherits/mixes_inedges), because that scan walks the direct children of thetrait_definition/class_definitionnode itself —self_typeis one level deeper, insidetemplate_body,so it was never in that scan's reach even incidentally:
The practical effect:
Logging with Databasehere is a real, declared structural dependency ofUserService— exactly the kind of typeclass/mixin wiring information cats-effect-style code relies on thegraph to surface — and it produces nothing at all.
Isolating the type node — no field names, but
.is_namedfiltering is exactself_type's children carry no field names (field_name_for_childreturnsNonefor every child, checkeddirectly), so the type has to be found positionally. Filtering by
.is_namedisolates exactly two entries inthe common case — the binder identifier first, the type node second:
Named children (punctuation
:/=>are unnamed, confirmed via.is_named):['identifier', 'type_identifier']for this shape,
['identifier', 'compound_type']for theLogging with Databaseshape above — the type nodeis 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
thisfor use in nested classes without imposing any type constraint:Only one named child here (no
:, no type) — the "type node isnamed[1]" rule needslen(named) >= 2as aguard, 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:
Both resolve correctly with zero modification to the walker —
compound_typeis already in its recursionwhitelist (added independently of this proposal), and a bare
type_identifieris the walker's base case.Unlike the
match_typesituation ingraphify#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 idiomaticcake-pattern shape) checked too, not left as an untested assumption:
Still a
compound_typeat the top level, so the same isolation logic applies unchanged —_scala_collect_type_refson 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, notjust assumed: a
val x: Logging { def extra: Int } = ...produces the exact samerefs = [('Logging', 'type')]through the existing, unrelated
val_definitionbranch. The refinement body is unscanned everywhere in thisextractor, not a limitation specific to self-type support.
Proposed diff
Verified against a real parsed tree (not just reasoned through) — running this exact logic against all
four shapes above:
Also checked that this doesn't interact with the existing
extends_clausehandling when both are present onthe same trait —
trait UserService extends Base: self: Logging => ...resolvesextends_clausetoBase(via the existing, unmodified
inheritsedge) andself_typetoLogging(via this proposal's newrequiresedge) 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 astructural 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 inthis extractor is split both ways (
inherits/mixes_in/derivesare dedicated labels; most type mentionsreuse
"references"with acontexttag) — same open question already left to the maintainer forexportin graphify#2048; the maintainer's call here too.
What this deliberately does not propose
this is reference-edge extraction only, same scoping as the
val/type_definitionproposals.{ 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
with, single-type, no-type, structural refinement) were dumped directlyagainst the installed grammar, checked for absence of
ERROR/is_missingnodes. Field-name absence wasconfirmed via
field_name_for_child, not assumed. The walker output was produced by running the installed_scala_collect_type_refsdirectly against real parsed nodes, not inferred from reading the source.