Skip to content

fix(scala): recognize trait as a class-like node - #2247

Open
Yyunozor wants to merge 1 commit into
Graphify-Labs:v8from
Yyunozor:fix/scala-trait-classlike
Open

fix(scala): recognize trait as a class-like node#2247
Yyunozor wants to merge 1 commit into
Graphify-Labs:v8from
Yyunozor:fix/scala-trait-classlike

Conversation

@Yyunozor

@Yyunozor Yyunozor commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Bug

Scala trait declarations are invisible to structural extraction: no node
for the trait, and its extends/with heritage is never evaluated.

Root cause: _SCALA_CONFIG.class_types (graphify/extract.py) only listed
class_definition/object_definition. tree-sitter-scala parses trait Foo
as its own kind, trait_definition, so it never entered walk()'s
class-node branch: no node was created, and members fell to the generic
recurse fallback, which resets parent_class_nid to None (misattributed
to file scope). trait_definition reuses the template_body shape already
in body_fallback_child_types -- confirmed against the grammar's own parse
tree -- 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.py
is now byte-identical to v8; the diff is the one-line class_types
addition plus tests.

The cross-file regression you traced (_rewire_unique_stub_nodes bailing
once 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_node
pins this directly: a case class Point/object Point pair referenced from
a second file resolves to the real node, byte-for-byte the same as on v8
before 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. Verified
against current v8 (0.9.29): trait Cake { self: Loggable => } emits no
edge before this diff, and requires Cake -> Loggable after it. Since the
cake 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 way
a same-named class and companion object already do on v8 today (single
node, 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 affected traversal through a trait, and the
cross-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.

@safishamsi safishamsi left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.
@Yyunozor
Yyunozor force-pushed the fix/scala-trait-classlike branch from c45ec73 to 3016c3e Compare July 28, 2026 11:55
@Yyunozor Yyunozor changed the title fix(scala): recognize trait as a class-like node, guard same-name kind collisions fix(scala): recognize trait as a class-like node Jul 28, 2026

@graphify-labs graphify-labs Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 --prove to 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):

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Health regressiontest_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.

@Yyunozor

Copy link
Copy Markdown
Contributor Author

Thanks for the detailed review — took the split option.

The collision guard is gone entirely: graphify/extractors/engine.py is byte-identical to v8, and the diff is now the one-line class_types addition plus tests.

Added the two-file class+companion reference test you asked for — it fails on the previous commit (reference lands on the ('Point','') stub) and passes here. Rebased onto 0.9.29: full suite 3847 → 3854 passed, 0 failed, on Python 3.10 and 3.12.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants