fix(scala): recognize trait as a class-like node - #2247
Conversation
safishamsi
left a comment
There was a problem hiding this comment.
Thanks @Yyunozor. The trait-as-class-like recognition is exactly right (one-line config addition; traits reuse template_body, existing class/object handling untouched), and the tests are thorough.
The blocker is the same-name kind-collision guard. Salting the second same-named definition's id (class_nid = _make_id(class_nid, t)) is fine within a file, but it regresses cross-file resolution on Scala's most common idiom (a class/trait plus its companion object). _rewire_unique_stub_nodes only collapses a sourceless stub onto a real definition when the label is unique; with the salted id there are now two real candidates for the label, so the rewire bails and every cross-file reference to a class+companion (or trait+companion) pair lands on a dangling sourceless stub instead of the real node. Reproduced with a two-file corpus (case class Point + object Point, referenced from another file): on this PR the reference target is a ('Point','') stub, where v8 resolves to the real node. For a tool whose core value is affected, that's a functional regression, and the PR's tests only pin the same-file case.
Requested change: keep the trait recognition; fix the rewire side so a kind-split companion pair still resolves — when all same-label real candidates come from one source file and one holds the plain (unsalted) id, rewire the stub to that plain-id holder instead of bailing on non-uniqueness. Alternatively, split the trait recognition (which I'd merge as-is) from the collision guard into separate PRs. Happy to re-review once the cross-file rewire is covered (a two-file class+companion reference test would pin it).
`trait_definition` is tree-sitter-scala's own node kind for `trait Foo
{ ... }`, distinct from `class_definition`/`object_definition`, so it
never entered `walk()`'s class-node branch: no node for the trait
itself, its `extends_clause` heritage never evaluated, and every
member under it fell to the generic recurse fallback, which resets
`parent_class_nid` to `None` (misattributed to file scope). Traits use
the same `template_body` shape already declared in
`body_fallback_child_types`, confirmed against the grammar's own parse
tree, so the existing heritage/field-reference handling for
class/object picks a trait up unchanged once dispatched -- one-line
`class_types` addition, no other config or extractor change.
Narrowed from the original submission per review: recognizing traits
as class-like means a same-named trait and companion object
(`trait Foo` + `object Foo`) now collide onto one node -- confirmed
empirically to be the same shape as the pre-existing `class`/`object`
companion collision on v8 already (one node, source-order-first
definition keeps the id, both definitions' members attach to it), not
a new failure mode. The id-collision guard this PR originally carried
regressed cross-file resolution instead (`_rewire_unique_stub_nodes`
bails once a salted id makes a label non-unique, so a class+companion
reference from another file lands on a dangling stub -- reproduced
directly). Dropped rather than repaired here; trait recognition and
any id-collision fix are independent concerns.
Tests: 6 pin the positive path (node creation, heritage, member
ownership, distinct-name coexistence, an `affected` traversal through
a trait) plus 1 pinning that cross-file class+companion resolution is
byte-for-byte v8's existing behavior, unchanged by this diff.
c45ec73 to
3016c3e
Compare
There was a problem hiding this comment.
Looks safe to merge — no coupling regressions and no blocking issues, checked against the code graph (not a self-assessment).
Graphify reviewed this change.
Looks safe to merge — no coupling regressions and no blocking issues, checked against the code graph (not a self-assessment).
Graphify review panel
Reviewed by claude, gpt.
This PR adds trait_definition to the Scala language configuration's class_types set in graphify/extract.py, so that Scala traits are treated as class-like container nodes alongside classes and objects. The accompanying comment explains the intent: to route traits through the existing class-node/heritage/member-attribution handling rather than the generic recurse fallback. It also adds a new test file (tests/test_scala_trait_classlike.py) with regression tests covering scenarios such as bare traits becoming nodes, trait heritage producing inherits/mixes_in edges, trait members attaching to the trait, trait/object independence, affected_nodes traversal through trait edges, and a cross-file companion-object resolution case. The surface area is limited to one config line and a new standalone test module; no id-computation or node-rewiring logic is modified.
No blocking issues surfaced.
Analysis details — impact, health, verification
Impact & health
graphify review
Impact — 1191 function(s) in the blast radius of 205 changed node(s).
Health — regressions introduced by this change:
- worsened (Ca·Ce 9288→9504):
extract()(Ca=352 Ce=27) — fans out to 27 callees (efferent coupling); 352 callers depend on it (afferent coupling) - new offender:
test_affected_traverses_mixes_in_edge_through_a_real_trait_node()(Ca=0 Ce=7) — fans out to 7 callees (efferent coupling)
Verification — 1191 function(s) need re-proving (callee-first):
graphify_extract_xaml_inferred_viewmodel_names_add → graphify_extract_safe_extract → graphify_extract_safe_extract_with_xaml_root → graphify_extract_node_label_key → graphify_extract_lang_is_case_insensitive → graphify_extract_lang_family → graphify_extract_is_top_level_function_definition → graphify_extract_rewire_unique_stub_nodes → graphify_extract_file_node_id → graphify_extract_repoint_python_package_imports …
Gate & verification
graphify gate
PASS — objectively clean (no health regressions, tests not run — proofs not enforced this run (re-run with --prove to verify the blast radius)). Grounded, not self-assessed.
Advisory (not blocking):
- verification_scope: 1071 function(s) in blast radius — re-run with
--proveto formally verify them
· 1 grounded finding(s) anchored inline below; 1 more finding(s) on lines outside this diff (see the check run).
|
|
||
| # ── Consumer secondary path: graphify affected traverses through a trait ─── | ||
|
|
||
| def test_affected_traverses_mixes_in_edge_through_a_real_trait_node(tmp_path): |
There was a problem hiding this comment.
test_affected_traverses_mixes_in_edge_through_a_real_trait_node()
fans out to 7 callees (efferent coupling).
Grounded coupling-delta finding (deterministic), not an LLM guess.
|
Thanks for the detailed review — took the split option. The collision guard is gone entirely: Added the two-file |
Bug
Scala
traitdeclarations are invisible to structural extraction: no nodefor the trait, and its
extends/withheritage is never evaluated.Root cause:
_SCALA_CONFIG.class_types(graphify/extract.py) only listedclass_definition/object_definition. tree-sitter-scala parsestrait Fooas its own kind,
trait_definition, so it never enteredwalk()'sclass-node branch: no node was created, and members fell to the generic
recurse fallback, which resets
parent_class_nidtoNone(misattributedto file scope).
trait_definitionreuses thetemplate_bodyshape alreadyin
body_fallback_child_types-- confirmed against the grammar's own parsetree -- so the existing heritage/field-reference code picks it up unchanged
once dispatched, no other config or extractor change needed.
Changed since the last review
Thanks for the detailed review. Taking the split option: this version drops
the id-collision guard entirely rather than repairing it.
graphify/extractors/engine.pyis now byte-identical to
v8; the diff is the one-lineclass_typesaddition plus tests.
The cross-file regression you traced (
_rewire_unique_stub_nodesbailingonce a salted id makes a label non-unique, so a class+companion reference
from another file lands on a dangling stub) no longer applies -- there's no
salting left to trigger it.
test_cross_file_reference_to_a_companion_pair_still_resolves_to_the_real_nodepins this directly: a
case class Point/object Pointpair referenced froma second file resolves to the real node, byte-for-byte the same as on
v8before this diff.
Completes the self-type case from #2052
Following your note when closing #2245: the requires-edge emission is gated
on
parent_class_nid, so a self-type on a trait never reaches it. Verifiedagainst current
v8(0.9.29):trait Cake { self: Loggable => }emits noedge before this diff, and
requires Cake -> Loggableafter it. Since thecake pattern puts self-types on traits, this is the half of #2052 that
dispatch alone could not reach.
Known limitation
Recognizing traits as class-like means a same-named trait and companion
object (
trait Foo+object Foo) now collide onto one node the same waya same-named class and companion object already do on
v8today (singlenode, source-order-first definition keeps the id, both definitions' members
attach to it) -- confirmed by direct comparison, not assumed: this is the
same pre-existing shape extended to a third node kind, not a new failure
mode. Left untouched here; a project-wide kind-aware id scheme would be a
natural follow-up, out of scope for this diff.
Testing
tests/test_scala_trait_classlike.py: node creation, heritage onto real(non-stub) nodes, member ownership, a distinct-name trait/object
coexistence check, an
affectedtraversal through a trait, and thecross-file companion non-regression pin above. Rebased onto current
v8(0.9.29): full suite 3847 -> 3854 passed, 3 skipped, 0 failed on Python
3.10 and 3.12 -- delta exactly these tests, no pre-existing failures on
either side. Ruff clean.