Repository navigation
fix(getter/groovy): a var the grammar reads as a type_identifier bills as an operand #1706
Description
Summary
#1704 made Groovy's var bill as a declaration keyword where the dekobon grammar emits it as the keyword leaf ({var:60} under local_variable_declaration). The grammar spells the same keyword as a type_identifier in two other positions, and there it still bills as an operand named var — a type name that names no type:
| source | var node |
bills as |
|---|---|---|
def f() { var x = 1 } |
var:60 |
operator var (#1704) |
class C { var field = 1 } |
type_identifier:308 |
operand var |
class C { static var s = 2 } |
type_identifier:308 |
operand var |
def f() { final var q = 5 } |
type_identifier:308 |
operand var |
Measured with bca ops --no-config on fix/batch-2026-10-23 after #1704. So one construct has two readings depending on whether a modifier or a class body precedes it, and the operand reading is the one Java had before #1560's text gate (Java's get_op_type_with_code returns Unknown for a type_identifier spelled var; var y; bills nothing — checked on the same build).
What the fix needs
The right answer is the #1704 one: operator var, keyed as the keyword. Two ways, neither free:
- Key it by text.
Getter::get_operator_spelling(node, ancestors)is byte-less, so it cannot say "thistype_identifierspellsvar". Widening it to takecodeis abig-code-analysis-asttrait change (outsideSTABILITY.md's shape contract, so allowed in a minor), after which the Groovy getter can answerSome("var")for atype_identifierwhose bytes arevarandget_op_type_with_codecan returnOperator. Groovy'sPrimitiverow already does the text-gated half (spells_primitive_type) and the walk keys primitives by text throughChecker::is_primitive, so the plumbing for "operator keyed by its spelling" exists for that one role and would need to grow one more case. - Java parity, cheaper: return
Unknownfor thetype_identifiervaras Java does, so it bills nothing. Stops the bogus operand but leavesvar x(operator) andvar field(nothing) disagreeing.
Option 1 is the one that makes the table above read one line. Either way it moves Groovy's n2 on every field or modified local declared with var.
Related
#1704 (the keyword leaf), #1560 (Java's var gate), #1568 (Groovy's text-gated primitives), #1693 (the DeclKeyword row).
Status after #1599 (PR #1717)
Option 1 is unblocked: Checker::keys_operator_by_text(node, code, ancestors) is the byte-aware hook Halstead reads, and Groovy already overrides it for the Primitive row's text gate — see the comment of that date.
Resolution
Status: Fixed (pending merge)
Commit: 89ad67e on branch fix/issue-1706 (integration branch fix/batch-2026-10-25)
Root cause: the DeclKeyword row owned only the keyword leaf. Fixed with a text-gated TypeIdentifier owner — gated on the parent too, since var is a legal package segment and import alias — and keys_operator_by_text keying both spellings by text so Halstead counts one var. The var m() {} row in the table above is invalid Groovy and is not pinned; the parameter spelling m(var x) is covered.
Activity
dekobon commentedon Oct 26, 2026
The byte-aware hook now exists (#1696, PR #1717)
Option 1's blocker — "
Getter::get_operator_spelling(node, ancestors)is byte-less, so it cannot say thistype_identifierspellsvar" — is gone. #1599 landedChecker::keys_operator_by_text(node, code, ancestors), which Halstead reads for the operator key, and Groovy already overrides it to read thePrimitiverow's text gate (spells_primitive_typeonidentifier/type_identifier).So the fix is the shape the row table already has: give the
DeclKeywordrow ofroles/groovy.rsaTypeIdentifier(text_gated_by spells_var)owner beside the keyword leaf, with the gate reading the bytes and (as theFIXME(#1706)atroles/groovy.rs:249notes) declining thevarget_op_type_with_coderejects for its own reasons;get_keyword_rolethen bills it as an operator andkeys_operator_by_textkeys it by text, so the table in the summary reads one line. Then2move on everyvarfield or modified local is the expected zero-drift exception and wants anopsfixture per row of that table.dekobon commentedon Oct 26, 2026
Fix Summary
Root cause: the DeclKeyword row of roles/groovy.rs owned var as the keyword leaf only; the type_identifier spelling fell to the getter's operand arm.
Changes:
varthe grammar reads as atype_identifierbills as an operand #1706) is gone.varas a declaration keyword #1704 entry corrected.Two corrections to the issue's table:
Languages affected: Groovy only (Java gates its var separately, #1560; Kotlin's is a keyword leaf).
Tests: operator rows for field / static field / private field / two fields / final var / parameter / constructor parameter / mixed spellings ([7, 10], the one row pinning the single text key — kind-keyed it reads 8) / var var = 6; operand rows for the same plus qualified, cast, instanceof, type-argument, array and variant prefix controls. Nine perturbations each fail exactly the tests that claim them (~/bca-evidence/1706/perturb.txt): dropping the owner, keying by kind, text-keying every keyword, admitting every type_identifier, bytes-only gate, dropping each parent, prefix match. Patch coverage 100% (29/29; project 98.78%). No integration-snapshot movement (the corpora carry no Groovy var fields).
Known gap: a script-level final var s = 9 followed by another statement recovers as a method_declaration head rather than an ERROR, so its var still bills nothing — documented at the gate, not pinned.
Evidence: ~/bca-evidence/1706/ (context dumps, fixtures, perturbation driver and log, coverage-llvm.json).
Commit: 89ad67e on branch fix/issue-1706 (integration branch fix/batch-2026-10-25).
sharmis-tp commentedon Oct 27, 2026
Option 1 seems right to me, and the FIXME(#1706) already sits beside the
DeclKeywordrow inroles/groovy.rs. I'd follow thePrimitiverow: addTypeIdentifier(text_gated_by ...)toDeclKeyword, with a gate checking that the bytes spellvarin a declaration's type position. Then I'd widenGetter::get_operator_spellingto takecode, so the Groovy getter answersSome("var")and all four rows of your table key as one operator. Tests would pin those four, plusdef var = 3and[var: 1]staying operands asidentifiers.What gives me pause is #1690: R05 rewrote every language's rows on
refactor/1694-1695-paying-roles, which isn't pushed yet. Should this PR targetmainnow, or wait and rebase onto that branch once it lands?If the plan suits you, please assign me; I'd have a PR up within 1-2 days of knowing the base.
dekobon commentedon Oct 31, 2026
Evidence supplement (2026-10-31)
The fix comment above cites a local evidence directory, which is a path on one machine. The one thing in the tree that rested on it is the rule in
.claude/rules/grammar-dispatch.md("Every parent in the dump gets a row or a stated exclusion"), which cited the first investigation fixture by path.That fixture packed every
varspelling into one 22-line Groovy class and method. Nothing in it is needed any more: each spelling it held is now a single-construct row in the#1706tests insrc/metrics/halstead.rs, including the one the rule is about, where the keyword sits inside a catch clause and the gate first shipped without that parent. The packing is itself the rule's second point: in that file the clause recovered as an error node, so the real parent appeared only once the construct was dumped alone.The perturbation driver and coverage capture were retained locally but their anchors are tied to the pre-merge text; the nine perturbations and their results are in the fix comment.