Skip to content

fix(getter/groovy): gate the Super arm on its Wildcard parent, as Java does #1419

Closed

Description

@dekobon

Correction (2026-09-11): the premise below is wrong — Groovy::Super is emitted, in a wildcard type bound (? super T), where it correctly bills as an operator. Both proposed options would break something. See the correction comment for the measurement and the right fix (a Java-style parent gate).

Summary

big-code-analysis-ast/src/getter/groovy.rs:137 lists Super in the
operator arm of get_op_type. The Groovy grammar never emits
Groovy::Super (id 74) for a super reference — it emits a plain
identifier (id 1) — so the arm is dead, and super is classified as an
operand via the identifier path.

That happens to be the right answer since #1380, which made a
super-reference an operand in Java, C# and Kotlin to match the eleven
other languages. But it is right by accident: the only thing keeping
Groovy on the correct side is that its grammar does not produce the kind
the arm names. A grammar bump that starts emitting a dedicated super
node would silently flip Groovy to operator and reintroduce exactly the
inconsistency #1380 closed — with no test failing, because no fixture
exercises a kind that is currently unreachable.

Measured

At the pinned grammars, on the integration branch carrying the #1380 fix:

class B extends A {
  B() { super(1) }
  def h() { super.g() }
}
$ bca dump --no-config -p g.groovy | rg ': super'
      │     │     ├─ {identifier:1} from (2, 9) to (2, 14) : super
      │     │     │  ├─ {identifier:1} from (3, 13) to (3, 18) : super

$ bca ops --no-config -p g.groovy
   |- operators
   |  |- ()  |- .  |- def  |- extends  |- {}
   |- operands
   |  |- 1  |- A  |- B  |- g  |- h  `- super

Both the constructor-delegation spelling and the qualified-access
spelling reach identifier. A.super.h() and super::h were checked
too, with the same result.

The enum variant exists (language_groovy.rs:88 Super = 74, rendered
at :404), so the arm compiles and reads as live.

Why this is the §2 case

.claude/rules/grammar-dispatch.md §2 covers the shape: a variant that
exists in the enum but the parser never emits. Its prescription is to
keep the defensive arm and pin its unreachability with an assertion
naming the rule, "otherwise a future grammar that promotes the rule
changes behaviour invisibly". That is precisely the risk here, and there
is no such pin.

Options

  1. Pin it. Keep the arm and add
    !ast_has_kind_id(&parser, Groovy::Super as u16) asserting the kind is
    unreachable, so a grammar bump that promotes it fails loudly instead of
    silently reclassifying.
  2. Move it. If the arm is kept for the day the grammar does emit it,
    move Super to the operand arm so the defensive path agrees with the
    live path — the answer fix(getter): self- and super-references split three ways across languages #1380 settled for every other language.

Option 2 plus option 1's assertion is probably right: agreement between
the two paths, and a loud failure if the grammar changes.

Check This at the same time — Groovy's this is also a plain
identifier and the same question applies.

Found while fixing #1380, which moved This / Super / Base to the
operand arm in Java, C# and Kotlin. Not fixed there because Groovy needed
no behaviour change and a dead-arm cleanup is a different claim from a
reclassification.


Resolution

Fixed in 7f2a1feb (branch fix/issue-1419, integration branch
fix/batch-2026-09-14).

Super is pulled out of the ungated operator alternation in
big-code-analysis-ast/src/getter/groovy.rs and given Java's parent
gate:

Super if ancestors.parent_has_kind(node, Wildcard as u16) => TokenRole::Operator,
Super => TokenRole::Operand,

Groovy::Wildcard = 233 has no numeric-suffix alias. The gate uses
Ancestors::parent_has_kind — the same O(1) chain lookup Java's
ancestors.parent(node).map(...) performs, and the spelling the
SLASH slashy-string guard in the same match already uses.

No metric moves. The corrected premise in the comments above is
confirmed against the grammar source: dekobon-tree-sitter-groovy 0.2.2
spells 'super' only inside the wildcard production, so every
super-reference was already a plain identifier and already an operand.
The fix is forward compatibility — it removes the grammar accident that
a bump routing a reference to kind 74 would have turned into a
misclassification. The workspace suite is green with no
integration-snapshot drift.

Tests. groovy_wildcard_super_bound_stays_an_operator gains a
reference-direction assertion over all five super-reference spellings;
its bound half now exercises the gate's operator branch and was verified
by inverting the gate (perturbation, not a file-level revert). The
gate's operand branch is unreachable under the pin — only error recovery
on invalid Groovy (List<? super>) detaches the token from its
wildcard — so it is left untested per grammar-dispatch section 6,
with the existing node census as the drift marker.

Side effect. .rustfmt-bail-baseline.txt ratcheted for
getter/groovy.rs 5 → 7: two more arms in a match that already bails on
pre-existing in-pattern section comments, exactly as the same split did
for Java.

make pre-commit → BCA_GATE: pass.

Activity

  1. dekobon commented on Sep 11, 2026

    @dekobon
    OwnerAuthor

    Correction — the premise of this issue is wrong, and both proposed options would break something

    I filed this issue claiming Groovy's Super operator arm "never fires" because
    the grammar never emits Groovy::Super (id 74). That is false. I measured
    only receiver positions (super(1), super.h(), A.super.h(), super::h),
    where the grammar does emit a plain identifier, and wrongly generalised to
    every position.

    The grammar emits Groovy::Super in a wildcard type bound:

    $ printf 'class C {\n  void m(List<? super String> xs) { }\n}\n' > w.groovy
    $ bca dump --no-config -p w.groovy | rg 'wildcard|super'
          │     ├─ {wildcard:233} from (2, 15) to (2, 29) : ? super String
          │     │  ├─ {super:74} from (2, 17) to (2, 22) : super
    $ bca ops --no-config -p w.groovy
       |- operators
       |  |- ()  |- <  |- >  |- ?  |- super  |- {}

    So the arm is live, and it bills super as an operator in exactly the
    position where #1380 decided super should stay an operator: ? super T is
    the mirror of ? extends T, and Java keeps it an operator behind a parent gate
    (java_wildcard_super_bound_stays_an_operator). Groovy's current behaviour is
    therefore already correct for both positions — the wildcard super is an
    operator via this arm, and the receiver super is an operand via the
    identifier path.

    Why both options in the body are wrong

    What the real latent risk is, and the right fix

    The only remaining exposure is narrower than this issue described: if a grammar
    bump started emitting Groovy::Super in receiver position, the arm would bill
    that as an operator. The fix is the same parent gate Java uses — Super under
    a Wildcard parent is an operator, and an operand everywhere else — plus a
    groovy_wildcard_super_bound_stays_an_operator test mirroring Java's. That
    makes Groovy correct by construction rather than by grammar accident, in both
    positions.

    That gate is being applied on the fix/batch-2026-09-10 branch as part of the
    whole-branch /code-review remediation. The comment I added to
    getter/groovy.rs in 4c5af945 repeated this issue's false premise ("never
    fires") and is being corrected in the same pass.

    Found by the /code-review max pass over that branch.

  2. changed the title [-]fix(getter/groovy): the dead Super operator arm is unpinned and would flip on a grammar bump[/-] [+]fix(getter/groovy): gate the Super arm on its Wildcard parent, as Java does[/+] on Sep 11, 2026
  3. dekobon commented on Sep 11, 2026

    @dekobon
    OwnerAuthor

    Closing: the premise was wrong — the arm is live and correct

    I filed this, and I tested too narrowly. It says Groovy's Super
    operator arm "never fires" because the grammar spells a super-reference
    as a plain identifier. That holds for every reference form I checked
    (super(1), super.h(), A.super.h(), super::h). But I never
    checked a wildcard bound, and there the grammar does emit
    Groovy::Super:

    $ printf 'class C {\n  void m(List<? super Integer> xs) { }\n  void n() { super.hashCode() }\n}\n' > w.groovy
    $ bca dump --no-config -p w.groovy | rg ': super'
          │  │  │  │     │  ├─ {super:74} from (2, 17) to (2, 22) : super      <- wildcard bound: Groovy::Super (74)
          │     │     │  ├─ {identifier:1} from (3, 14) to (3, 19) : super    <- reference: identifier

    So the arm fires for every ? super T. There, billing super as an
    operator is right: it mirrors ? extends T's extends, which is also
    an operator, and it matches the decision #1380 gated Java to
    (java_wildcard_super_bound_stays_an_operator).

    Both proposed remedies were wrong

    • An unreachability pin (!ast_has_kind_id(…, Groovy::Super)) would
      fail on any file that uses a wildcard, because the kind is reachable.
    • Moving Super to the operand arm would bill a wildcard bound as an
      operand and split Groovy from Java's pinned wildcard decision.

    What was actually worth doing is done in #1424

    • The misleading comment I added in 4c5af945 is corrected in
      getter/groovy.rs.
    • A new groovy_wildcard_super_bound_stays_an_operator test pins the
      wildcard-bound direction.
    • The reference direction (a super-reference stays an operand) is
      pinned by tests/parity/self_reference_operand_parity.rs. That test
      already catches the grammar-bump flip this issue worried about.

    Groovy is on the correct side for both uses by construction, so there is
    no defect to fix. Closing as not planned. Credit to the max-effort review
    of #1424 for catching the wildcard case.

  4. dekobon commented on Sep 11, 2026

    @dekobon
    OwnerAuthor

    Reopening — I closed this too early, and the gate was not applied

    Correcting my own closing comment above, which was wrong on one point.

    The 18:28 comment reframed this issue: the premise was wrong, but one
    real item remained — gate Groovy's Super on its Wildcard parent, as
    Java does
    , so Groovy is correct by construction rather than by grammar
    accident. It said that gate "is being applied on the
    fix/batch-2026-09-10 branch". It was not. Only two of the three
    pieces landed in #1424:

    In #1424?
    Comment in getter/groovy.rs corrected yes
    groovy_wildcard_super_bound_stays_an_operator test yes
    Super gated on a Wildcard parent no

    Super still sits in the ungated operator arm
    (big-code-analysis-ast/src/getter/groovy.rs:152). I missed the
    reframing, closed the issue on its original premise, and wrongly said
    everything worth doing was done in #1424.

    Current state is correct, and guarded in both directions

    • A wildcard bound (? super T) emits Groovy::Super and is billed
      as an operator. That is right, and the new test pins it.
    • A super-reference emits identifier and is billed as an operand.
      That is right, and tests/parity/self_reference_operand_parity.rs pins
      it.

    So nothing is miscounted today. If a grammar bump started emitting
    Groovy::Super for a reference, the ungated arm would bill it as an
    operator. The parity test would then fail loudly rather than let it
    through silently.

    What remains

    Gate the arm like Java does (getter/java.rs, #1380): Super under a
    Wildcard parent is an operator; everywhere else it is an operand. That
    turns the parity test's future failure into a correct classification.
    It is a small forward-compatibility change. Moving Super out of the
    shared or-pattern into its own arm will raise Groovy's entry in
    .rustfmt-bail-baseline.txt, as the same move did for Java.

    Left open for that.

  5. dekobon commented on Sep 14, 2026

    @dekobon
    OwnerAuthor

    Fixed in 7f2a1feb on fix/issue-1419 (integration branch fix/batch-2026-09-14).

    Root cause

    GroovyCode::get_op_type listed Super in an ungated operator
    alternation. The corrected premise in the comments above holds: nothing
    is miscounted today, because the pinned dekobon-tree-sitter-groovy 0.2.2 grammar spells 'super' in exactly one production —

    wildcard: $ => seq('?', optional(seq(choice('extends', 'super'), $._type))),

    — so every super-reference (super(1), super.h(), A.super.h(),
    super::h, super?.h()) parses as a plain identifier (kind 1) and
    reaches the operand arm, while the one shape that does emit
    Groovy::Super (kind 74) is the wildcard bound, where operator is the
    right answer. The exposure was purely forward-looking: a grammar bump
    routing a reference to kind 74 would have billed it as an operator.

    Changes

    big-code-analysis-ast/src/getter/groovy.rs — Super pulled out of the
    big operator alternation and given Java's parent gate:

    Super if ancestors.parent_has_kind(node, Wildcard as u16) => TokenRole::Operator,
    Super => TokenRole::Operand,

    Groovy::Wildcard = 233 is the only wildcard variant in
    language_groovy.rs — no numeric-suffix alias, per grammar-dispatch
    section 1. The gate uses Ancestors::parent_has_kind rather than Java's
    ancestors.parent(node).map(|p| p.kind_id().into()) match: it is the
    same O(1) chain lookup, it is what the SLASH slashy-string guard three
    arms above already calls, and it compares a u16 instead of converting
    to the enum first. Java's arm is left alone — equivalent, and out of
    scope here.

    The stale rationale comments in getter/groovy.rs and
    tests/parity/self_reference_operand_parity.rs (both of which described
    the accident and pointed at this issue) are rewritten to describe the
    gate that now exists.

    No behaviour change

    The full workspace suite is green with no integration-snapshot drift
    (tests/repositories/big-code-analysis-output reports a clean tree),
    which is the expected result for a fix whose only reachable input is
    already classified correctly.

    Languages affected

    Groovy only. Java already carried the gate (#1380). No other language in
    the workspace spells a super-reference as a dedicated keyword token that
    this arm shape would apply to.

    Tests

    src/metrics/halstead.rs, groovy_wildcard_super_bound_stays_an_operator:

    • The bound half now genuinely exercises the gate's operator branch.
      Verified by perturbation rather than by file-level revert (the file
      carried the uncommitted fix): inverting the gate to
      Some(Wildcard) => TokenRole::Operand fails it with
      `? super String` must keep `super` an operator; operators were ["()", ",", "<", ">", "?", "extends", "{}"]
      The exact inverse edit restores it; git diff --stat confirmed
      nothing else moved.
    • The node census half is retained as the drift marker, with its
      message updated — it no longer describes an "ungated operator arm".
    • New reference direction:
      assert_keywords_are_operands_only::<GroovyParser> over a fixture
      carrying all five super-reference spellings plus this.x. The fixture
      parses with no ERROR/MISSING node (verified by bca dump). This
      pins valid-input behaviour the gate must not move; it is additive to
      the parity row, which covers only super.h() and this.x.

    tests/parity/self_reference_operand_parity.rs (24 tests) passes
    unchanged.

    The untested branch, deliberately

    The gate's operand branch is unreachable under the pin. The only input
    that reaches it is error recovery on invalid Groovy —
    class A { void m(List<? super> a) { } } puts {super:74} under an
    {ERROR:65535} parent. Per grammar-dispatch section 6, pinning that
    shape's numbers would make the grammar's present over-permissiveness the
    contract and invert into a bug-lock when it tightens, so no test was
    added for it and the arm's comment says why. The node census is what
    fails by name if a bump ever makes the branch live.

    Side effect

    .rustfmt-bail-baseline.txt: getter/groovy.rs 5 → 7. Splitting Super
    into its own arm adds two arms to a match that already bails — cause 1,
    pre-existing, from the in-pattern section comments in the big operator
    alternation. .claude/rules/formatting.md rules out clearing those, so
    the baseline is ratcheted with ./utils/check-rustfmt-bail.py --update.
    The same move produced the same effect for Java. Both new arms were
    checked by hand for width (93 and 40 columns) since cargo fmt cannot
    see into a bailing match.

    Validation

    make pre-commit → BCA_GATE: pass (gate=pre-commit).

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions