Skip to content

refactor(roles): declare every paying construct as a role row in every language - #1708

Merged
dekobon merged 38 commits into
mainfrom
refactor/1694-1695-paying-roles
Oct 24, 2026
Merged

dekobon merged 38 commits into
mainfrom
refactor/1694-1695-paying-roles

Conversation

@dekobon

@dekobon dekobon commented Oct 24, 2026

Copy link
Copy Markdown
Owner

Closes #1694, closes #1695. Part of #1690.

What

Every paying construct — Comparison, ShortCircuit, EagerLogical, Ternary, CaseArm, Guard, NullSafe — is now a declared role_table! row in every language (big-code-analysis-ast/src/roles/), and every consumer of each (ABC, cyclomatic, cognitive, Halstead through Getter::get_paying_role) reads the row in place of its own kind list. No cell of the seven roles is pending anywhere.

Each row records the policy the consumers already had rather than moving it: Kotlin's infix and/or/xor and Perl/PHP xor are eager; ? pays the ternary only under its node where the grammar spells ? elsewhere; default / _ / *) arms pay nothing (lesson 11); guards are the node or the token their consumers pay; elvis and optional navigation are NullSafe, with compound ??= / //= excluded as the consumers' own. Four specification corrections found while writing the rows are recorded on them and on #1695.

DSL

role_table! gained: owner holds_payer (projection lands on a row payer), payer under P / under [P, Q], pays_itself_when f, payer text_gated_by f, project() on every row (field by cached grammar id, payer-child fallback), pays_at_token_kind (what Halstead reads), owns_via(payer, …), and empty OWNERS/PAYERS on absent rows. ~30 identical one-line gate fns and three shared gate macros went away. utils/role_tables.py reads the new forms; the dsl_gates proof table and the census self-tests pin them.

Zero drift

bca ops and bca metrics are byte-identical to main (base rebuilt at 44184ad7):

  • 6,638 outputs: the integration corpora plus a fixture per language spelling every row kind
  • 14,588 outputs: every fixture truncated at each line, sixteen offsets, and after every operator and every word

Ten drifts found by that sweep or by review were fixed before landing (listed in the CHANGELOG entry). Two pre-existing quadratics the rows became the reader of are linear — Elixir repeated guards (881 ms → 21 ms at 8k), Python cognitive and chains (204 ms → 10 ms) — plus the Tcl wide-switch arm list; make bench-scaling is 86/86 with probes for each.

Process

After the two issues closed, the branch went through simplify-rust, rust-optimize, audit-tests, four independent review agents, and an astra-review of the fixes; each pass's findings are their own commits with the evidence in the messages. Lesson 102 records why the first recovery sweep missed two keyword-operator drifts.

Rebased onto main; make pre-commit passes; check-test-lang-gates-compare against main reports no test lost a build.

…ays lists

The root crate's metric dispatchers become consumers of the Comparison
and ShortCircuit rows in #1694, so everything a table emits is now
`pub` (the AST crate is outside STABILITY.md). A `pays [..]` list walks
the same continuation-passing splice an `owns` list does, so a shared
payer fragment spells its gate inline (`LT gated_by applied,`) and the
gate resolves at the splicing row; `utils/role_tables.py` expands the
same fragments and the differential test holds the two readers
together.

`Getter::get_paying_role` is the Halstead hook for the paying-construct
rows, read between `get_keyword_role` and the byte-less `get_op_type`
on every path the walk takes (`paying_roles!` emits it from a
language's two rows). It defaults to `Unknown` while the rows land one
package at a time. The bypass gate learns that a macro body which only
hands `kind_id()` to a row restates no membership.

`cpp_operator_is_applied` moves to `roles/c_family.rs` as
`cpp_family_operator_is_applied`, where the C++ dialects' rows gate on
it; the root re-exports it for the consumers that have not migrated.

`impl_cyclomatic_java_like_rows!` reads a `ShortCircuit` row in place
of the `&&` / `||` kind list; the list form stays for Groovy until
#1695.

Refs #1694
…with every consumer

Java's `Comparison` row gains the `instanceof` keyword as its payer —
the grammar spells it in no other production, so one pays per test,
plain or pattern form — and `InstanceofExpression` becomes an ordinary
owner. `ShortCircuit` is declared: `binary_expression` gated on its
`operator` field, `&&` / `||` paying ungated, projected through the
field.

Every consumer reads the rows: ABC's token arm reads
`comparison::pays_at_leaf`, its `scores_itself` reads both rows'
`owns`, its chain walker reads `short_circuit::pays_at_leaf`;
cyclomatic reads the short-circuit leaf through
`impl_cyclomatic_java_like_rows!`; cognitive's boolean-sequence arm is
guarded by `short_circuit::owns` and keys on the row's `PAYERS`; the
getter bills the leaf projection through `paying_roles!(java)`, keeping
only the generic-bracket `<` / `>` — the row's gated-out spelling — in
the byte-less arm under a `role-bypass: allow(Comparison)`.

The C-family ABC `scores_itself` closures now capture `code`, so
`count_negated_operand` takes `impl Fn`.

Zero drift: `bca ops` and `bca metrics` JSON byte-identical on 12 Java
corpus files plus a fixture spelling every row kind, and on the Python
and C++ populations the change does not touch (73 / 1048 / 1048 for
Mozcpp). Bypass baseline −24 (every Java comparison / short-circuit
site), census −1, coverage −3 (`&&`, `||`, `instanceof`).

Perturbations, observed failures:
- drop `LT` from the payers: role_rows_tests, java_assignments_with_conditions,
  java_generic_angles_are_one_pair_1559, comparison_row_separates_the_owner_from_its_token
- `project` returns `None`: 22 tests across abc, cognitive, roles
- `BinaryExpression` listed as a payer: rejected at expansion by the
  owner/payer disjointness assertion
- drop `LTEQ`: java_comparison_and_short_circuit_spellings_bill_1694

Refs #1694
- `project` falls back to the owner's first declared-payer child when
  the grammar gives the owner no projection field: Java's
  `instanceof_expression` carries its keyword as a bare child, so the
  field-only projection answered `None` for a declared owner. The test
  now projects every owner of a fixture spelling both type-test forms.
- Java's byte-less `LT | GT` arm is unconditional again: a token error
  recovery reparents under `ERROR` (`return a < ;`) is under no
  `binary_expression`, so the row's gate leaves it out, and it billed
  before the row existed. The arm's exemption says which spellings it
  covers and that the Halstead load-bearing evidence is the other
  seven payers; a recovery fixture pins the classification.
- The bypass gate keeps reporting a macro that reads a row *and* still
  matches on the kind — a row mention no longer launders a `match`.
  Paired self-tests for the forwarding and the mixed shape.
- The census self-tests cover a payer fragment with inline gates, so
  reverting the Python reader's `pays` expansion fails by name.
- Two comments that described the C++ rows as already reading the moved
  gate, and the module doc's `pays_itself` example, now say what holds.

Refs #1694
…ry consumer through its rows

Python's `ShortCircuit` row is declared — `boolean_operator` owns the
chain, `and` / `or` pay ungated — beside the `Comparison` row R01
proved. The row is the owner-only-for-one-consumer case of the pilot:
ABC reads `comparison::pays_at_wrapper` (one condition per
`comparison_operator`, however many operators it chains) and reads
`short_circuit::pays_at_leaf` only to find the chain whose operand
slots pay, never adding for the token (#395); cyclomatic counts that
same token; cognitive's boolean-sequence arm and its outermost-chain
test read `short_circuit::owns`; the getter bills each leaf through
`paying_roles!(python)`, keeping the loop `in` — the row's gated-out
spelling — in the byte-less arm under a `role-bypass: allow`.

`Node::count_specific_ancestors` takes `impl Fn` for `check`, so the
cognitive ancestor test can capture `code`; the method is outside
STABILITY.md's covered set.

Zero drift: `bca ops` and `bca metrics` JSON byte-identical on 73
Python corpus files plus a fixture spelling every row kind (and still
on the 12 Java / 1048 Cpp / 1048 Mozcpp populations). Bypass baseline
−15 (every Python comparison / short-circuit site), census −1,
coverage −3 (`and`, `or`, `boolean_operator`).

Perturbations, observed failures:
- drop `In` from the payers: bare_in_and_is_pay_only_under_the_wrapper
- drop `LTEQ`: python_comparison_and_short_circuit_spellings_bill_1694
- drop `Or`: 13 tests across abc (role_rows, slot_pays, three
  behaviour tests), cognitive (seven), roles and halstead — the three
  root consumers and Halstead all read the one row

Refs #1694
…ortCircuit through shared fragments

Cpp and Mozcpp declare both rows by splicing the `cpp_family_*`
fragments of `roles/c_family.rs` (contract 5 of #1690): the owners
are the aliased `binary_expression` ids and the fold, each gated on
its `operator` field holding one of the row's payers; the payers are
the eight comparison tokens (`<=>` and `not_eq` included) and `&&` /
`||` / `and` / `or`, each gated by `applied` — the parent-only test
that was `cpp_operator_is_applied`, now `cpp_family_operator_is_applied`
beside the rows. The gates are emitted per dialect by
`cpp_family_gates!`, since each reads its dialect's ids. The fold and
the ISO alternative spellings are the fold and suppression cases of
the pilot; `ConstraintReach` is untouched and the rows are read under
it, with the comment at the call site saying why a parenthesised
constraint cannot be the row's business.

Every consumer reads the rows in both dialects: ABC's token arm and
chain walker, its `scores_itself` (the shared C-family slot helpers
now take a `ScoresItself` parameter — Cpp and Mozcpp pass row-keyed
closures, C and Objective-C the name-keyed one until #1695);
cyclomatic through `impl_cyclomatic_c_family_rows!`, which replaces
the `applied_if` escape; cognitive's keyed boolean sequence, guarded
by `short_circuit::owns` with the `and` → `&&` key beside the table;
Halstead through `paying_roles!`, the non-applying spellings staying
in the byte-less arm under a `role-bypass: allow`.

Zero drift: `bca ops` and `bca metrics` JSON byte-identical on 1048
C++ corpus files plus a fixture spelling every row kind, run as Cpp
and again as Mozcpp, and on the 12 Java and 73 Python populations.
Bypass baseline unchanged (the C++ sites were name-keyed and
invisible to it), census −4, coverage −30 (15 ids per dialect).
`tests/parity/cpp_mozcpp_parity.rs` gains a row fixture with its
expected values written out; `role_rows_tests` runs the same rows on
both dialects.

Perturbations (both dialects), observed failures:
- drop `LTEQGT` from the comparison payers: 7 (role_rows ×2, three
  abc tests, two parity tests)
- drop `And` from the short-circuit payers: 15 across abc,
  cognitive, cyclomatic, the alternative-token suite and parity
- `applied` answering true for every parent: 17 across abc,
  cyclomatic, halstead, ops and parity
- `FoldExpression` dropped from the ShortCircuit owners: 5
  (slot_pays ×2, cognitive fold ×2, parity)

Refs #1694
…agments

The TypeScript and TSX `Modifier` and `DeclKeyword` rows were byte-level
copies (contract 5 of #1690 assigned the fold to R04). Each now splices
`..ts_family_modifiers!()` / `..ts_family_decl_keywords!()` from
`roles/js_family.rs` beside the JS-family fragments; the `type` gate
inside the fragment resolves at each dialect's row, as the C++
`applied` gate does. The derivations stay on the TypeScript rows and
TSX's own `String3` exclusion stays on its `Primitive` row.

Zero drift: `bca ops` and `bca metrics` JSON byte-identical on the six
TS/TSX corpus files, each run as both dialects (24 outputs).

Refs #1694
- Java's `instanceof` payer is gated to its `instanceof_expression`.
  Error recovery keeps a keyword whose expression it lost (`return x
  instanceof` at end of input) as a bare child of an `ERROR`, where
  the ungated payer scored an ABC condition the expression arm it
  replaced never did — the one drift the corpus oracle could not see.
  Halstead keeps billing the stranded keyword through the byte-less
  arm, as it did before the row. A truncated-input row in
  `role_rows_tests` and a recovery line in the Halstead spelling test
  pin both; a sweep of every fixture truncated at each line boundary
  and sixteen byte offsets (780 outputs, both binaries) differs
  nowhere.
- The bypass gate's forwarding-macro exemption is positive: the body
  binds the kind once and every later mention is the first argument of
  a `…_kind(` call. A cached-kind comparison beside a row read is
  reported again, with a self-test.
- The Java and Python Halstead spelling tests say which entries pin the
  row and which pin the vocabulary the byte-less arm keeps.

Refs #1694
…d ShortCircuit rows

The two C-family dialects declare both rows through shared
`c_family_*` fragments (`roles/c_family.rs`): the owners are both
aliased `binary_expression` ids gated on their `operator` field, the
comparison payers `<=` `>=` `==` `!=` ungated (each grammar emits them
only from a `binary_expression` or the `preproc_binary_expression`
aliased onto it) and `<` / `>` gated to that parent — Objective-C spells
them bare in `generic_specifier`, `protocol_reference_list`,
`parameterized_arguments` and `argument_list` too — and `&&` / `||`
ungated. `c_family_gates!` emits the three gates per dialect.

Every consumer reads the rows: ABC's token arm, chain walker and a
row-keyed `scores_itself` (the name-keyed `c_family_condition_scores_itself`
is gone — all four C-family dialects now pass row-keyed closures to the
shared slot helpers); the hand-written cyclomatic impls; cognitive's
boolean-sequence arm, guarded by `short_circuit::owns`; Halstead through
`paying_roles!`, with `<` / `>` (and ObjC's `LT2`) kept in the byte-less
arm for the gated-out spellings under a `role-bypass: allow`.

Zero drift: `bca ops` and `bca metrics` JSON byte-identical on the C and
Objective-C fixtures spelling every row kind (no corpus file routes to
either), on a 100-output recovery-truncation sweep of both, and still on
the 1048 C++ corpus files run as both C++ dialects. Census −4, coverage
−20 (10 ids per dialect), bypass unchanged (the sites were name-keyed).
Dropping `GTEQ` from the shared comparison fragment fails four tests
(role_rows and the Halstead spelling test, per dialect).

Refs #1695
…nd ShortCircuit rows

Three grammars with one `binary_expression` kind told apart by its
`operator` field: each row gates the owner on the field and `<` / `>`
on the parent (Rust's generic lists and turbofish, Lua's `<const>`
attribute — #1297 — and every grammar's error recovery spell the same
tokens). Lua's inequality is `~=` and its chain operators the words
`and` / `or`. Rust's `ShortCircuit` row also owns the Rust 2024
let-chain (`LetChain`, and the hidden `_let_chain` supertype), whose
`&&` tokens are its direct children with no field to project through —
the row's payer fallback lands on the first, which is the one the ABC
walker reads the chain from (#396). Rust's `||` pays ungated: an empty
closure's bars are two `|` tokens, and the only other spelling is a
bare token in a macro's `token_tree`, which the counting consumers
always paid and Halstead never billed. That split lives in
`RustCode::get_paying_role`, a hand-written reading of the row that
keeps the Halstead policy where the macro cannot express it.

Every consumer reads the rows in all three languages: ABC's token arm
and chain walker (Go's walker is the old terminal-kind design and asks
the rows nothing else; Rust's and Lua's `scores_itself` read both
owners), the hand-written cyclomatic impls, cognitive's boolean-sequence
arm, and Halstead through `paying_roles!` (Go, Lua) with `<` / `>` kept
in each byte-less arm for the gated-out spellings.

One drift the first cut had and the Rust corpus caught: `&&` inside a
macro's `token_tree` reaches the chain walker with a parent that owns no
chain, and the walker now asks the row before reading operands (two
serde files moved; a `m!(a && b)` row pins it, failing 0 → 100 without
the guard). Zero drift after it: `bca ops` and `bca metrics` JSON
byte-identical on 173 Rust corpus files and the Go / Lua fixtures
spelling every row kind. Census −6, coverage −29. Dropping any payer
fails that language's Halstead spelling test and `role_rows_tests`.

Refs #1695
…ortCircuit rows

The Tcl family's `binop_expr` names no operator field: its operator is
one token among its children. So each row's owner is `BinopExpr` gated
on holding one of the row's payers, and the row's projection — the
first child whose kind is a declared payer — is the lookup, the shape
the `project` fallback was added for in R04. `roles/tcl_family.rs`
holds the shared payer fragments (the six symbols, `eq` / `ne`, `in` /
`ni`, `&&` / `||`) and the per-dialect gates; iRules adds its string
and glob comparators (`starts_with`, `ends_with`, `contains`, `equals`,
`matches`, `matches_regex`, `matches_glob`) and the word forms `and` /
`or` beside them.

Every consumer reads the rows in both dialects: ABC's token arm, chain
walker and `scores_itself`; the hand-written cyclomatic impls (iRules
ahead of its switch-arm count); cognitive's boolean-sequence arm, Tcl
keyed on the token and iRules folding `and` → `&&` beside the row; and
Halstead through `paying_roles!`, reached through the braced-word
revision both dialects route the walk through, so every arm the two
getters carried for these kinds is gone.

Zero drift: `bca ops` and `bca metrics` JSON byte-identical on the Tcl
and iRules fixtures spelling every row kind (no corpus file routes to
either). Census −4, coverage −35. Dropping `In` from the shared
comparison fragment fails the Halstead spelling test and the ABC row
test of each dialect. The baseline refresh records the one cognitive
dispatch that grew by its guard arm and drops the iRules getter effort
entry the row's arms took below its limit.

Refs #1695
…tCircuit rows

JavaScript, MozJS, TypeScript and TSX declare both rows through shared
`js_family_*` fragments (`roles/js_family.rs`): the owner is the one
`binary_expression` kind gated on its `operator` field; the comparison
payers are the four equalities, `<=` / `>=`, the `instanceof` keyword,
and `<` / `>` gated to that parent — the TypeScript grammars spell them
as generic brackets and every one of the four as JSX tag delimiters
(#1297); `&&` / `||` pay ungated. `in` is left unclaimed: no counting
consumer scores it (the slot holding one pays), and claiming it would
move output. `??` and the compound `&&=` / `||=` / `??=` keep their
consumer arms until the `NullSafe` row.

Every consumer reads the rows in all four grammars: the two ABC macros
through a per-grammar row-keyed `scores_itself` (the shared slot
helpers take it as a parameter, as the C family's do), `js_cognitive!`
on an owner guard, `impl_cyclomatic_js_family!` — now its own body,
since the kind-list `impl_cyclomatic_c_family!` has no user left and is
gone — and Halstead through `paying_roles!` inside
`impl_js_family_get_op_type!`, with `<` / `>` kept in the byte-less arm
for the gated-out spellings.

Zero drift: `bca ops` and `bca metrics` JSON byte-identical on 389
JavaScript corpus files run as both JS dialects, 8 TypeScript and the
TSX fixture, each spelling every row kind. Census −8, coverage −48.
Dropping `EQEQEQ` from the shared fragment fails the Halstead spelling
test and the three ABC row tests. rustfmt-bail −4 (the deleted macro).

Refs #1695
…t rows

The worked example of a node / token pairing. A `binary_expression`
owns a comparison through its `operator` field, `<` / `>` gated to that
parent (the grammar also spells them as generic brackets, in four
productions). The dekobon grammar gives Groovy's own comparisons nodes
of their own, each wrapping its token: `spaceship_expression` / `<=>`,
`identity_expression` / `===` `!==`, `regex_find_expression` / `=~`,
`regex_match_expression` / `==~`, `membership_expression` / `in` (gated
to that parent: the same keyword opens a `for (x in xs)` header) and
`instanceof_expression` / `instanceof`.

The negated `a !in b` and `o !instanceof T` emit no token at all, so
the row needs an owner that pays for itself in one spelling and
through its token in the other. `pays_itself_when f` is the one
representation extension this package adds to `role_table!`: the owner
pays at the leaf projection under its gate and `f`, which here is
"holds no keyword token" — `project(node).is_none()`. The Python reader
accepts the attribute; the differential test holds the two readers
together; a row test pins that `a in b` pays at `in`, `a !in b` at
the node, and a `for` header's `in` nowhere.

Every consumer reads the rows: ABC's token arm, chain walker and
`scores_itself`; cyclomatic through `impl_cyclomatic_java_like_rows!`
(the kind-list `impl_cyclomatic_java_like!` has no user left and is
gone); cognitive's boolean-sequence arm; Halstead through
`paying_roles!`, with `<` / `>` / `in` kept in the byte-less arm for
the gated-out spellings. The elvis `?:` and the safe-navigation forms
keep their consumer arms until the `NullSafe` row.

Zero drift: `bca ops` and `bca metrics` JSON byte-identical on the
Groovy fixture spelling every row kind (no corpus file routes to
Groovy). Census −2, coverage −22, rustfmt-bail −3 (the deleted macro).
Making the membership owner pay unconditionally fails six tests (the
row test, three ABC tests, two production-construct sweeps).

Refs #1695
…ortCircuit rows

Kotlin: a `binary_expression` owns a comparison through its `operator`
field, `<` / `>` gated to that parent (tree-sitter-kotlin-ng spells
them in `super<A>`, `type_arguments` and `type_parameters` too, and
misparses a generic call as nested comparisons — #1297, #1394); the
type test `is_expression` and membership test `in_expression` own
theirs through their keyword, each a token of its own in both the
positive and negated spelling (`is` / `!is`, `in` / `!in`) and gated
to the expression, since a `when` entry's `type_test` / `range_test`
and a `for` header spell the same keywords and the entry pays there.

C#: both `binary_expression` aliases own a comparison through the
field, every comparison token gated to that parent — the grammar
sweep (six other productions, from the generic brackets to an
`operator_declaration`, #1420, and a `relational_pattern`, #1383) and
the fail-closed polarity move onto the row's gate — and the two type
tests `is_expression` / `is_pattern_expression` own theirs through
the one `is` keyword each wraps.

Every consumer reads the rows in both languages: ABC's token arm,
chain walker and `scores_itself`; the hand-written cyclomatic impls;
cognitive's boolean-sequence arm (the elvis `?:` and `??` keep their
place in the guard and the key under an exemption naming the
`NullSafe` row that takes them over); Halstead through
`paying_roles!`, with the gated-out spellings kept in the byte-less
arms.

Zero drift: `bca ops` and `bca metrics` JSON byte-identical on 22 C#
corpus files and the Kotlin fixture spelling every row kind. Census
−4, coverage −30. Ungating Kotlin's `is` fails the row test (a `when`
entry's `is` would pay twice) and `kotlin_subjectful_when_arms_keep_the_per_entry_count`;
dropping C#'s `Is` payer fails ten tests across abc, slot_pays and
halstead. The baseline refresh drops Kotlin's ABC cyclomatic entry
(the dispatch now carries the standard suppression) and records the
C# cognitive dispatch's effort, 0.1 over its limit by the guard arm
every sibling now has.

Refs #1695
…rtCircuit rows

PHP: the one `binary_expression` kind carries every binary operator,
`instanceof` included; the row's owner is gated on its `operator`
field and the payers are the four equalities, `<>`, the orderings, the
spaceship and the `instanceof` keyword, `<` / `>` gated to the parent
for a recovered token. The short-circuit payers are the punctuation
`&&` / `||` and the low-precedence `and` / `or`; `xor` evaluates both
operands and stays with `EagerLogical` (#1536), `??` with `NullSafe`.

Ruby: the `binary` node — three aliased ids — owns through the field,
and each of the ten comparison tokens (`==` `!=` `===`, the orderings,
`<=>`, `=~` / `!~`) is gated to a `binary` parent, since every one
doubles as an operator method's name and `<` as a superclass clause
(#1280). `&&` / `||` / `and` / `or` pay ungated; the chain walker
still asks the row whether the token's parent owns a chain, since
error recovery can parent one under an `ERROR` (`f(&&)`).

Every consumer reads the rows in both languages: ABC's token arm,
chain walker and `scores_itself`; the hand-written cyclomatic impls;
cognitive's boolean-sequence arm (`??` keeps its place in PHP's guard
and key under an exemption naming the `NullSafe` row; Ruby's key fn
sits beside its row read); Halstead through `paying_roles!`, with
Ruby's gated-out spellings and PHP's `<` / `>` kept in the byte-less
arms.

Zero drift: `bca ops` and `bca metrics` JSON byte-identical on the
PHP and Ruby fixtures spelling every row kind (no corpus file routes to
either). Census −4, coverage −33. The baseline refresh records the
two getters' effort falling with their arms and the PHP cognitive
dispatch's guard arm.

Refs #1695
…son and ShortCircuit rows

The three text-gated grammars, which complete the two roles across
every language.

Elixir: the `binary_operator` node (three aliased ids) owns through
its `operator` field, and every payer is gated on *applying* its
operator under one, since a token also names the operator in a
capture (`&==/2`), an atom and a `Kernel.||(a, b)` call (#1531,
#1534). The remote call applies the macro too but has no
`binary_operator`, so it stays the counting consumers' text-keyed
extra (#1574, #1582), named beside each row read.

Perl: tree-sitter-perl names no `operator` field on a comparison, so
the row's projection — the field where there is one, the first payer
child otherwise — is the lookup. The fourteen comparison payers are
the numeric, string and spaceship / `cmp` forms plus the bindings
`=~` / `!~`; a binding that rewrites (`$x =~ s///`) is an edit the
consumer declines as its own extra (#1540). `<` / `>` are gated to a
`binary_expression`, a readline's brackets being the same tokens. The
chain row owns the `unary_expression` the grammar builds for `and`,
with `and` gated by `applied_and` so an auto-quoted key pays nothing
(#1539); `//` stays a consumer arm until `NullSafe`, `xor` is
`EagerLogical`'s.

Bash: the symbols inside `[[ … ]]` / `(( … ))` are owned by every
`binary_expression` alias, `<` / `>` gated to that parent (a
redirection spells them, #1280); inside `[ … ]` the comparison is a
`test_operator`, one kind the grammar shares with the `-a` / `-o`
connectives, so that payer is `text_gated_by` — a new payer keyword
that expands as `gated_by` and documents the byte read at the row,
accepted by the Python reader. The chain row owns the command `list`
and the test `binary_expression`s, with `-a` / `-o` as the text-gated
test spelling. ABC reads that row for nothing (Fitzpatrick Rule 9
does not map onto a command-list separator, #1536), and
`BashCode::get_paying_role` keeps a `test_operator` billing nothing,
as the getter always had.

Every consumer reads the rows in all three: ABC's token arm, chain
walker and `scores_itself`; the hand-written cyclomatic impls;
cognitive's boolean-sequence arm (Bash's comparison-run bookkeeping
still fires on every `binary_expression`, with the row read inside);
Halstead through `paying_roles!` (Elixir, Perl) or the hand-written
reading (Bash), with the gated-out spellings kept in the byte-less
arms. `cognitive/bash.rs` names `Bash::UnaryExpression` by id rather
than by string, since the bypass gate now resolves that string to a
claimed kind.

Zero drift: `bca ops` and `bca metrics` JSON byte-identical on 144
Bash corpus files and the Elixir / Perl fixtures spelling every row
kind. Census −6 (no `Comparison` / `ShortCircuit` cell is pending
anywhere), coverage −55. Dropping Elixir's `Notin` fails two tests;
ungating Perl's `and` fails eight (the row test and the keyword-key
suite); ungating Bash's `test_operator` fails three (the row test and
the connective suite). The baseline refresh records the Bash getter's
effort falling with its arms.

Refs #1695
Four findings from the fresh-context review of the twenty-language
rollout, each a drift the corpus oracle could not see, now measured
on every population (0 of 6,640 outputs differ) and on a recovery
sweep of every fixture truncated at each line and sixteen offsets
(1,004 outputs, 0 differing):

- `paying_roles!` billed a `pays_itself` owner as a Halstead operator:
  Groovy's `a !in b` and `o !instanceof T` have no token, so the
  counting consumers pay at the node while Halstead had nothing to
  bill, and the macro's leaf read put `membership_expression` into the
  vocabulary. The macro now bills only a kind the row lists as a
  payer, which is the rule for any future tokenless owner; the Groovy
  spelling test asserts the exact vocabulary.
- Perl's rewrite exception applied to every comparison owner: `$a ==
  s/x/y/` (valid Perl) made `scores_itself` false, so a slot holding
  it paid twice. `perl_binding_rewrites` now asks the row's projection
  whether the operator is `=~` / `!~` before reading the right
  operand, and the token arm reads the same predicate, so the two
  cannot part again. Two rows pin it, inside and outside a slot.
- C#'s `is` payer is gated to its two type-test expressions, as Java's
  `instanceof` was in the pilot review: error recovery strands the
  keyword under an `ERROR` (`var x = o is` at end of input), where the
  ungated payer scored a condition the node arm never did. The getter's
  byte-less arm keeps billing the stranded keyword; a truncated-input
  row and a spelling row pin both.
- Perl cognitive now reads the `ShortCircuit` row: the arm's guard asks
  `short_circuit::owns` (whose `applied_and` gate replaces the
  key-misparse check) or names the `//` / `op=` extras pending
  `NullSafe`, and the sequence key asks the row per child before
  folding the word forms onto their symbols. Dropping `Or` from the row
  now fails `perl_word_operators_continue_their_symbol_sequence`.

Refs #1695
The second role of #1695: a boolean operator that evaluates both
sides. Recognition only — no count and no Halstead classification
moves, which is what the role exists to record beside the chain
operators it must not be confused with.

Three languages have one. Perl's and PHP's `xor` is a `Xor` token the
grammar spells only as a `binary_expression`'s operator (a hash key or
member named `xor` is a bareword / `name`), so each row owns the
expression gated on its `operator` field and pays the token; the three
ABC / cyclomatic / cognitive exclusions that #1536 wrote now name the
row, and Halstead bills the token through `paying_roles!` — which
reads the row beside `Comparison` and `ShortCircuit` — instead of each
getter's byte-less arm. Kotlin's infix `and` / `or` / `xor` are
functions: tree-sitter-kotlin-ng gives an `infix_expression` no field
and no operator kind, its name being an `identifier` leaf, so the row
is owner-only and `text_gated_by eager_infix`, which reads the second
identifier child's bytes. Nothing pays: the name stays the operand it
was, and a slot holding the call pays once under the ordinary slot
rule, as for any call.

Five languages record a recognition boundary rather than an absence:
Java (JLS §15.22.2), C#, Rust, Groovy and Ruby spell a boolean `&` /
`|` / `^` with the integral operator's kind, which syntax alone cannot
tell apart, so each row is `owns []` with the three tokens `excludes`d
and the reason. Elixir's `and` / `or` short-circuit and its `&&&` /
`|||` are bitwise, so its cell is `absent`; Bash's `absent` reason now
says that `-a` / `-o` are the `ShortCircuit` row's text-gated payer,
not an eager form.

An `absent` row now emits empty `OWNERS` / `PAYERS` constants, so a
reader that asks several rows about one token can ask an absent one
alike — which is how `paying_roles!` reads `EagerLogical` in the
twenty-two languages without a payer.

Zero drift: `bca ops` and `bca metrics` JSON byte-identical on every
population (0 of 6,638 outputs). Census −9 (no `EagerLogical` cell is
pending), coverage −18. Emptying Perl's or PHP's payer list fails that
language's Halstead spelling test; the Kotlin row test pins which
infix calls the gate admits and that nothing pays; the ABC row tests
pin each eager form in and outside a slot. The baseline refresh
records the two getters' effort falling with their `Xor` arm.

Refs #1695
- Kotlin's `eager_infix` gate read the second *identifier* child, so a
  compound left operand (`(a && b) xor a`), a nested chain's outer node
  and a right operand *named* `and` were all misread. It reads the
  second non-comment child and asks that it is an identifier; the row
  test pins all four shapes, and says the name is all that is read — a
  user `infix fun and` spells the same call.
- Bash's `-a` / `-o` are arguments to the `test` builtin, which
  evaluates both sides (`[ 1 -eq 2 -a nope -eq 1 ]` still complains
  about `nope`), so calling them lazy was wrong. The row is now a
  recognition boundary — `owns []`, `TestOperator` excluded with the
  reason that every consumer scores them as the `&&` / `||` they spell
  (#1536) and the `ShortCircuit` row claims them.
- Python's `&` / `|` / `^` on two `bool`s are eager and return a `bool`,
  so its pre-existing `absent` claim was also wrong; it takes the same
  shared-kind exclusion the other five languages have.
- Perl's and PHP's rows gain a direct test of the owner gate and the
  projection, which no consumer observes: `xor` owns, `or` does not, a
  key named `xor` does not, and the projected token is `xor`.

Zero drift re-measured on the touched languages (Bash 144, Python 73,
and the Kotlin / Perl / PHP fixtures). Coverage −3.

Refs #1695
The third role of #1695. Eighteen languages have a conditional
expression; each row names the node that owns it and, where the
grammar has one, the `?` that pays for it, and every consumer reads
the row: ABC at the projection it always used (the token in the C
family, Java, Groovy, the JS family, C# and Ruby; the node in Perl,
PHP, Python, Tcl, iRules and Bash — the latter for the #1273 misparse
Perl routes around, for PHP's and Python's tokenless designs, and for
Tcl's marker-relative slot walk), cyclomatic and cognitive at the
node, and Halstead at the token through `paying_roles!` (or Bash's
hand-written reading).

Where the grammar spells a bare `?` elsewhere, the payer is gated to
its owner and the getter's byte-less arm keeps billing the gated-out
spelling under an exemption naming the row: Java's and Groovy's
wildcard bound (#1274), TypeScript's ten type productions (#1275),
C#'s nullable type and constraint — and its conditional access, the
`NullSafe` row's, which C# ABC still counts through its denylist arm
until that row lands — PHP's `?int`, and Bash's `${y?msg}`. The C
family, Ruby, Perl, Tcl and iRules spell the token nowhere else and
pay it ungated; Bash's `TernaryExpression2` is listed defensively and
its `QMARK2` excluded, with `bash_ternary_expression_alias_is_unreachable`
pinning both. Python's `a if c else b` has no operator token, so its
row is owner-only and its `if` / `else` keep billing as keywords. The
C-family and Java-like cyclomatic macros take `ternary = <row>`
beside `short_circuit = <row>`; the JS one reads both rows by name.

Zero drift: `bca ops` and `bca metrics` JSON byte-identical on every
population (0 of 6,638 outputs) and on the recovery sweep (1,004
outputs). Census −18 (no `Ternary` cell is pending), coverage −36.
`role_rows_tests` gains a ternary row per language — outside a slot,
inside one, with negated branches, and each gated-out spelling —
with every expected value taken from the base binary, and one
Halstead spelling test covers the `?` of sixteen grammars.
Perturbations: ungating Java's `?` fails 2 tests (the wildcard
scores), dropping C++'s payer fails 8+, ungating C#'s fails 6, and
ungating Bash's fails the new row test in `roles/bash.rs` (the
expansion's `?` pays). The baseline refresh drops three entries the
standard dispatch suppression now covers and records the Perl and
Ruby getters' effort falling with their `?` arm.

Refs #1695
Four drifts and misstatements, all on recovery trees or in prose:

- JavaScript's and MozJS's `?` payer was gated to the ternary, but the
  arm it replaced was not: those grammars spell the bare token nowhere
  else, and a `?` error recovery strands under an `ERROR` (`let c = a
  ?` at end of input) had always paid. The two rows pay it ungated
  again; the TypeScript rows keep their gate. A recovery row per JS
  grammar pins both readings.
- PHP's `<` / `>` were gated the same way, against an arm that was
  not: the pinned grammar spells the two only as a `binary_expression`
  operator, so a stranded one paid before. Both pay ungated now, and
  the ABC row test holds the recovery spelling. Found by widening the
  recovery sweep to cut one byte after every operator punctuation
  (4,310 outputs over the R04 and R05 fixtures, 0 differing — the
  R04-era sweep cut only at line ends and sixteen offsets, which is
  how both slipped past).
- Python's row said cyclomatic does not count the ternary; it does,
  through the `if` keyword the expression spells. Cyclomatic now reads
  the row at the node and declines the keyword under it, so the row's
  owner is what counts; dropping the owner fails the row test, and so
  does removing the keyword's decline (the ternary scores twice).
- Bash's row claimed excluding `QMARK2` would make a reachable spelling
  fail the coverage gate, which forgives excluded ids. The claim is
  gone; `bash_ternary_expression_alias_is_unreachable` now asserts the
  second `?` id is not emitted either, over the arithmetic sweep plus
  an expansion's `${y?msg}` and `$?`.

Zero drift re-measured on the touched languages.

Refs #1695
The fourth and fifth roles of #1695, together as the issue steps them:
which arm of a multi-way branch is a decision, and which guard on an
arm or a head is one more. Every language's rows and every consumer
of them; zero drift.

The arm row's shape follows the grammar. Where the arm has a keyword
the counting consumers pay at — the C family's, Java's, Groovy's, the
JS family's and C#'s `case` — the label node owns and the keyword pays,
gated where another production spells it (C#'s `goto case`, #1450);
`default` is excluded on every such row with lesson 11's reason, and
Halstead keeps billing it as the keyword it is. Where the arm has no
keyword, or the consumers pay at the node — Kotlin's `when_entry`,
Rust's `match_arm`, Python's `case_clause`, Go's three case kinds,
Ruby's `when` / `in_clause`, PHP's `case_statement` /
`match_conditional_expression`, Perl's `when`, Elixir's `stab_clause`,
Bash's `case_item`, iRules' `switch_arm`, and Tcl's `command` under a
brace-list `switch` — the node owns and pays, gated against the
fallback its grammar spells: an absent `condition` field, a bare `_`
(`discard` / `var _` in C#, an `identifier` spelled `_` in Ruby), a
sole `*`, a `default` name, or Elixir's catch-all and head rules.
C#'s `switch_expression_arm` is `pays_itself` beside the `case` payer,
the one row reading both projections. The guard rows own Java's
`guard`, C#'s `when_clause` / `catch_filter_clause`, Ruby's `if_guard`
/ `unless_guard` (and the hidden `_guard`), Python's `if_clause` under
a `case_clause` (a comprehension's filter shares the kind), Rust's
`match_pattern` with a `condition` field (its `if` pays, gated to the
pattern), and Elixir's `when` operator (its token pays, gated to a
guard position — a typespec's `when a: integer` spells the same).

Two spec corrections. Java's `Guard` was R01's `pays never` proof
row; cyclomatic pays the guard node, so the owner is the paying
projection and `pays never` now has no live user (the DSL keeps the
form). C#'s `when` is excluded rather than paid because it is also a
`_reserved_identifier` at this pin (`int when = 1;`), which the old
consumer comment recorded and the row now carries.

The gate predicates moved into the AST crate so the rows could read
them: `src/metrics/elixir_decisions.rs` is now
`lang_helpers/elixir_decisions.rs` (a move; `is_operand` inlined), and
`lang_helpers/case_arms.rs` holds the bare-default predicates that
were in `npa/shared.rs` and `cyclomatic.rs` plus the Tcl / iRules
switch-arm readers (the per-switch arm *counters* are gone: each arm
is read at its own node now, with the same total). The C-family and
Java-like cyclomatic macros take one `roles = <module>` argument in
place of three row paths; Java's `[Guard]` extra is the row.

Zero drift: `bca ops` and `bca metrics` JSON byte-identical on every
population (0 of 6,638 outputs) and on the recovery sweep (4,310
outputs). Census −30 (no `CaseArm` or `Guard` cell is pending),
coverage −80, bypass baseline 86 → 84 (Java's two guard sites). A
case-arm / guard row test per language with every expected value from
the base binary; a Halstead spelling test over twelve grammars; owner
tests in `roles/python.rs`. Perturbations: ungating C#'s expression
arm fails 6+, Rust's arm 6+, Tcl's arm 3, Elixir's guard 2, Python's
guard the new row test.

Refs #1695
Three findings, two of them drifts the corpus and recovery oracles
could not see and one a complexity regression:

- iRules' `switch_arm` owner accepted an arm with no `switch` parent,
  where the per-switch counter it replaced reached only a switch's
  direct children: an arm error recovery strands under an `ERROR` with
  its `switch` lost at end of input scored a decision it never had.
  The gate reads the parent now, and a row pins the truncated input.
- Tcl's cyclomatic read of the `CaseArm` row returned before the
  command-name dispatch, so an arm whose *pattern* spells `for` or
  `switch` — which the grammar cannot tell from the command — stopped
  paying as that command too. It falls through again, as it always
  did, and a row pins `for { … }` as an arm.
- Tcl's arm gate rescanned the switch's argument list per arm to find
  the sole braced word, `O(A × W)` for `A` arms and `W` options where
  the deleted per-switch counter scanned once. The scan is memoised
  per `word_list` on the walk's `FoldCache` under a new
  `MemoQuestion::SwitchArmList`; `cyclomatic/tcl-wide-switch` grows
  options and arms together and reads exponent 0.93 with the memo
  and 2.01 without.

The Rust `| _ =>` fixture's comment said the leading `|` leaves the
`_` bare; it does not — tree-sitter-rust nests it in an `or_pattern`
and the arm has always counted — and the comment now says so.

Zero drift re-measured: every population (0 of 6,638) and the
recovery sweep (4,310 outputs, 0 differing).

Refs #1695
The last role of #1695: optional navigation and null coalescing. Ten
languages have one; every consumer reads the row, and each keeps the
reading it had — the role records which consumer scores which payer,
not a new policy (#281, #452).

The rows. JS family: `binary_expression` gated on `??` owns, `??` and
the optional chain's `?.` pay — the `optional_chain` token itself in
the JavaScript grammars, the `?.` under an `optional_chain` wrapper
in the TypeScript ones, where the wrapper is excluded so a chain pays
once. C#: `??` pays at its token and the `conditional_access_expression`
pays for itself — its operator is the same bare `?` the ternary and
two type productions spell (#1275), so the node is the payer and the
token keeps billing through the byte-less arm. Kotlin: the elvis
`binary_expression` and the `navigation_expression` holding `?.`;
`!!` excluded (it throws, no branch). Groovy: the four `safe_*` /
`elvis_expression` owners, four tokens. PHP: `??` and the two
nullsafe kinds' `?->`. Perl: the defined-or `//`. Ruby: a `call`
(four aliased ids) holding `&.`. The compound `??=` / `//=` is
excluded on every row as the consumers' own: an assignment to ABC, a
sequence to cognitive, a decision to cyclomatic (#236, #248, #249).

The consumers. Cyclomatic pays every row payer (the JS macro's
`$opt_chain` argument and Groovy's four extras are gone; C#'s access
pays at the node as before). Cognitive folds `??` / elvis / `//` into
a boolean sequence through `null_safe::owns`, the navigations never
reaching its key. ABC scores the coalescing operator as a condition
where that language's ABC always did — gated to that one payer under
a `role-bypass: allow(NullSafe)` saying which, since the navigations
are no boolean test — walks Perl's `//` chain operands (Rule 9), and
pays C#'s access at the node, the C#-internal agreement with its own
cyclomatic no sibling shares; PHP's `??` stays a slot-only condition.
Halstead bills each payer through `paying_roles!`; the getters' `??`
/ `?.` / `?:` / `?->` / `//` / `&.` arms and the JS family's
`op_extras` entries are gone. The Ruby `call` claim is exempted at
`is_call`, the ABC branch, and the `attr_*` / visibility walks, none
of which is membership.

Zero drift: `bca ops` and `bca metrics` JSON byte-identical on every
population (0 of 6,638 outputs) and on the recovery sweep (4,372
outputs, the fixtures gaining Groovy's `??.` / `?[`, C#'s `a?[0]` and
Ruby's chained `&.`). Census −10 (no `NullSafe` cell is pending, and
no cell of any of the seven paying roles), coverage −42. A null-safe
row test per language with every expected value from the base
binary, and a Halstead spelling test over nine grammars (which records
that the JS family's `??=`, like its `&&=` / `||=`, never billed — a
pre-existing gap the exclusion neither opens nor closes).
Perturbations, one payer dropped per language: JS 5 failures, TS 5,
C# 4, Kotlin 3, Groovy 5, PHP 3, Perl 5, Ruby 3. The baseline
refresh records the Perl, PHP and Ruby getters' effort falling with
their arms.

Refs #1695
One drift the oracles could not see, and the stale prose around it:

- C#'s `scores_itself` read every `NullSafe` owner, so a negated
  conditional access — `!(a?.b)`, valid C# — stopped paying as the
  negated operand of its slot: 2 conditions became 1 while the access
  itself still paid once. Only the coalescing owner is what the slot
  predicate must decline (its `??` arm pays it); the access was never
  a `scores_itself` case. The predicate now asks the row's projection
  for `??`, as Kotlin's and Groovy's ask for the elvis, and a row pins
  `!(o?.ToString()?.Length)` at 3 — which reads 2 with the gate
  removed. The C# fixture gains the negated form, so the corpus
  oracle covers the class from here.
- Nine comments still described `??` / elvis / `//` as "pending
  `NullSafe`", two described billing paths that do not exist (the JS
  getter has no `??=` arm; C#'s getter named an
  `under_conditional_access` gate the row does not have), and the
  `count_boolean_slot` doc said `scores_itself` "must agree with those
  arms exactly" by hand — it now says the two read the same rows and
  agree by construction, and where a hand-listed arm or a multi-owner
  row still needs care.

Zero drift re-measured on C# (44 outputs) and the recovery sweep
(4,388 outputs, 0 differing).

Refs #1695
The final branch review found a scaling regression the zero-drift
oracles could not: `paying_roles!` asked the `Guard` row for every
`when` token, and that row's payer gate climbs the chain of nested
`when` operators a repeated guard (`when a when b when …`) builds —
once per token, quadratic in the chain. 8,000 guards went from 0.02 s
to 0.88 s on a Halstead-only walk, with byte-identical output: the
gate decides nothing for Halstead, since a `when` it leaves out (a
typespec's `when a: integer`) bills as an operator through the
byte-less arm all the same.

`ElixirCode::get_paying_role` is hand-written now, as Rust's and
Bash's are: it bills the `Guard` row's payer in every position without
asking the gate, and reads the other rows as the macro does. The
byte-less `E::When` arm is gone, since nothing reaches it. A
`halstead/elixir-repeated-guards` depth probe reads exponent 0.98 so
and 2.18 with the row asked. The counting consumers still pay the
climb once per guard, where it decides something.

Refs #1695
The predicates were gated per language, but the `role_table!`s that
import them are compiled unconditionally — `mk_langs!` generates every
language's enum and table whatever the feature set — so any build
without `bash`, `csharp`, `irules`, `python`, `ruby` and `tcl` all on
failed on the imports. The gates are gone: the enums they read exist
in every build, as the other `lang_helpers` modules already assume.
Found by the per-language isolation run (`-p big-code-analysis-ast
--no-default-features --features elixir`), which the workspace-wide
run cannot see since the CLI and web crates re-enable every feature.

Refs #1695
The `simplify-rust` pass over the branch, three reviewers (reuse,
clarity, efficiency), every finding applied:

Reuse. Two gates every field-projected owner and every
parent-gated payer restated as a one-line fn per language — thirty
copies of `project(node).is_some_and(|op| PAYERS.contains(…))` and
`parent_has_kind(node, K)` across seventeen files — are now the DSL's:
an owner `holds_payer` gates on its projection landing on one of the
row's payers, and a payer `under P` / `under [P, Q]` on its parent's
kind. The gate fns, the three shared gate macros they lived in
(`c_family_gates!`, `js_family_operator_gates!`, `tcl_family_gates!`)
and `cpp_family_operator_in` are gone; the per-language rationale they
carried moved onto the rows (the TypeScript `?` productions in
particular). `utils/role_tables.py` reads both forms (the fragment arm
regex now closes at `] $($rest)*`, since `under [..]` closes a bracket
inside the list), with a census self-test and a `dsl_gates` Rust table
pinning them. Beside them the DSL gains `pays_at_token_kind` — the leaf
projection restricted to declared payers, which `paying_roles!` and
the three hand-written `get_paying_role`s read instead of repeating
`PAYERS.contains && pays_at_leaf_kind` — and `owns_via(payer, …)`, the
owner test narrowed to one payer, which C#'s, Kotlin's and Groovy's
`scores_itself` read instead of `owns && project == K`. `rows_own!` in
`abc.rs` is every `scores_itself`'s leading clause and carries the
"asked with no chain" rationale once; `compute_booleans_from` is the
ten identical boolean-sequence closures; `ScoresItself` has one home;
the two #1694 Halstead tests use `assert_spellings_bill_as_operators`.

Clarity. `case_arms` is `pub(crate)` with `tcl_switch_arm_list`
re-exported (its one root reader), the three row-only Elixir
predicates and the C++ `applied` helper are `pub(crate)`, and
`canonical()` is `pub(crate)` again. Eighteen module docs that said
"every other role is pending", the `roles/mod.rs` doc and its
`dead_code` allow that said only the keyword rows had a consumer, and
a cyclomatic test doc naming the deleted `[Assert, QMARKCOLON]` extras
are rewritten; `pays never` is documented as a kept shape no row uses.
Python's cyclomatic asks the ternary owner of a keyword's parent with
no chain rather than the keyword's. Tcl's no-`return` row read says so
in its first line.

Efficiency. Every cognitive `compute` binds `kind` once and reads
`owns_kind`, as ABC and cyclomatic already did; Perl's boolean arm
drops a three-scan guard the body made redundant; the `scores_itself`
projection reads happen once through `owns_via`.

Zero drift (0 of 6,638 outputs; recovery sweep 4,388, 0 differing).
The rustfmt-bail baseline is ratcheted down for the arms the deleted
macros held and up by one in `cognitive/perl.rs`, whose match bails on
a pre-existing in-pattern comment and whose row arm is now short
enough to probe. The self-scan baseline gains `roles/mod.rs` at 562
PLOC: the DSL is one file by design and the role-name mapping is a
third of it.

Refs #1695
… gate fns

The `rust-optimize` pass over the branch. Every change preserves the
walk's output byte for byte (0 of 6,638 corpus and fixture outputs;
recovery sweep 4,388, 0 differing); the parity suite passes.

- The two JS ABC macros were token-identical once both read their
  rows (`QMARK` gated vs ungated had been the one difference, and the
  rows carry that now): `js_abc_compute!` is gone and the four grammars
  expand `js_family_abc_compute!`. The JS-vs-TS rationale moved above
  the invocations; the tests that named "separate macro bodies" say
  what they pin now.
- `MozcppCode`'s ABC and cognitive impls were `CppCode`'s with the ids
  swapped: `cpp_family_abc!` / `cpp_family_cognitive!` expand both,
  against each dialect's rows, with `tests/parity/cpp_mozcpp_parity.rs`
  pinning them equal as before. `abc/mozcpp.rs` is 28 lines,
  `cognitive/mozcpp.rs` 18.
- `impl_cyclomatic_c_family_rows!` takes `extra = [..]` for the
  dialect's own both-count kinds, so C (none) and ObjC (`CatchClause`)
  expand it instead of hand-writing the same body for want of a
  parameter.
- `project()` is emitted for every row, from the declared field where
  there is one and the first payer child otherwise — a `const PROJECT`
  records which — so `holds_payer` serves the last eight "holds its
  token" gate fns (`is_case_label` ×4 and its two shared macros,
  `is_case_section`, `safe_navigation` ×2) and the four single-payer
  projection gates (`null_coalescing_operator` ×2, `elvis_operator`,
  `defined_or_operator`); Bash's five-alias `under_binary_expression`
  is `under [..]`. The DSL doc's example rows spell the new forms.
- `Ancestors::memoised(question, node, || answer)` is the get-or-record
  read `argument_slots_are_readable` and the Tcl arm-list test both
  spelt out; `bash_case_item_is_bare_wildcard` reads the `value` fields
  through `children_by_field_name` instead of a hand-driven cursor.
- Getter comments that named the deleted gate fns name the rows'
  `under` kinds.

Clippy pedantic is already at `-D warnings` workspace-wide, so the
triage step found nothing. Net −580 lines over the branch's Rust.

Refs #1695
The `audit-tests` pass over the branch's ~90 new tests, two auditors
verifying each finding by perturbation. No production code moves;
every fix below is a test that now fails where it did not, confirmed
by re-running the auditor's perturbation against it.

In the DSL's proof tests (`roles/mod.rs`): the `dsl_gates` table
declares its gated `holds_payer` owner under `always`, so the
projection half of `@clause_payer … gated_by` decides (under `never`
it never ran); lists the real parent *second* in an `under [..]`, so
an expansion reading only the first alternative fails; carries a gated
`pays_itself` owner whose gate admits it, so "no clause emitted" and
"gate said no" part; and keeps a `pays never` row — the shape no
production table declares since Java's `Guard` moved — so
`pays_at_wrapper`'s `!PAYS_NEVER` and the `never` mode stay pinned.
The absent-row test asks `pays_at_token_kind` (the one projection
Halstead reads) and the empty `OWNERS` / `PAYERS`. The differential
test's doc says what `canonical()` compares — the kind lists — and
that gate attributes are the Python self-tests' to pin; one of those
now reads `pays_itself_when` as a self-payer, the one attribute no
check had read. A counter on the Tcl arm-list scan and a test beside
`tcl_scans_an_erroring_list_once_per_walk` pin the `SwitchArmList`
memo's effect in `make pre-commit`, which only the quarterly bench
probe saw before.

In the root crate: the C "recovered `<`" fixtures (`return a < ;`)
were not recovered at all — tree-sitter-c keeps `a <` as a
`binary_expression` with a zero-width operand, and the ABC assertion
passed by reading the unit's own count rather than the function's.
They spell `{ < a; }`, which strands, and the ABC row reads the sum
and pins both shapes. Ruby's `!~` and Elixir's `===` / `!=` / `!==` /
`<=` gain the outside-slot row that makes dropping the payer fail
(each was covered only by a slot row, which the slot itself
satisfies). The Halstead spelling helper's doc no longer claims every
dropped payer fails its entry — where the byte-less arm still lists
the kind the vocabulary is the union, and the row's reach is the ABC
row tests' — and the C / ObjC test docs say so. The two #1695 bench
probes' readings are asserted to grow with the shape, per probe,
since `probe_workload_is_exercised`'s floor-of-one would pass a
`tcl_wide_switch` that lost its arms.

Refs #1695
…found

Four review passes over the branch (algorithmic performance, the
`review` skill, a gap review of the non-code artefacts, and a
`code-review` run), every finding applied.

Two zero-drift breaks, both on error-recovery shapes a keyword-
boundary truncation reaches and the operator-boundary sweep did not:

- Elixir billed `when` as an operator inside an `operator_identifier`
  (`&when/2`), where `get_op_type`'s first test had returned `Unknown`
  before the `Guard` row's reader ran first. The reader now makes the
  same test. A Halstead row pins the name position beside `&==/2`.
- Groovy's `Comparison` row paid a stranded `instanceof` (`{ o
  instanceof }` at end of input, the keyword under `ERROR`) where the
  ABC arm it replaced paid the expression node; the payer is `under
  InstanceofExpression`, as Java's is, and the getter's byte-less arm
  keeps billing the stranded spelling. An ABC row and a Halstead row
  pin both readings.

The recovery oracle (`/tmp/opencode/1695/recovery_oracle.py`) now
cuts after every word as well as every operator: 14,588 outputs, 0
differing; corpus oracle 0 of 6,638.

Performance, measured against the pre-branch binary:

- `project()` resolved its field by name on every ask, a string scan
  over the grammar's field table, and a `binary_expression` is asked
  by up to four rows per consumer. `Node::child_by_field_id` /
  `field_id` resolve it once per row (a `OnceLock` in the emitted fn).
- Perl ABC's `scores_itself` scanned a `binary_expression`'s children
  once per row (four rows own the kind, none through a field); it
  dispatches on the kind and reads the shared projection once. Perl
  ABC on a 20k-line file: +10.5% → +5%; other languages ±1–3%.
- Two pre-existing quadratics the branch's rows became the reader of:
  Elixir's repeated-guard climb (`when a when b …`, per token) is
  memoised per `when` operator under `MemoQuestion::GuardOperator`
  (881 ms → 21 ms at 8,000 guards), and Python cognitive's
  `boolean_operator` stops at the first enclosing chain instead of
  counting them all (204 ms → 10 ms at 8,000 operands). Probes
  `cyclomatic/elixir-repeated-guards` and `cognitive/nested-and` pin
  both, and the benchmarking doc lists all four #1695 probes.

Hygiene: `canonical()` is `pub(crate)` (the earlier message claimed it
and the edit was lost); four comments naming the renamed
`cpp_family_operator_is_applied`; four inert `role-bypass:
allow(NullSafe)` markers on `??=`-only arms removed (an excluded kind
is not claimed, so the gate never read them); the `get_paying_role`
trait doc no longer says languages are "still pending"; the roles
module doc says what a row's comment may cross-reference, since ~100
row comments do name their consumers' readings; a 125-column doc line
reflowed. Baseline refreshed for `Node`'s two new methods and the DSL's
two new lines.

Refs #1695
The `code-review --effort max` pass over the branch, four findings
applied (a fifth, folding Elixir's hand-written `get_paying_role` into
`paying_roles!`, is declined: the three hand-written readers each
state their reason, and a macro exception would hide it).

- `perl_condition_scores_itself`'s comment named a helper that does
  not exist and counted four rows where the scan reads three; and its
  comparison branch re-ran the projection scan it had just done —
  a comparison payer *is* the row's projection, so only the binding
  question is left. Perl ABC on a 20k-line file: +5% → +3.5%.
- `project()` emits its field-id cache only for a row that declares a
  field, through an `@projected` arm, instead of a `OnceLock` every
  row consults through a nested `Option`.
- `Node::child_by_field_id` / `field_id` are `#[doc(hidden)]` with
  the reason they are `pub` (the exported macro) on the doc, and the
  CHANGELOG entry records them, the widened recovery sweep, the two
  drifts it found and the two walks made linear.

Zero drift holds (0 of 6,638; recovery sweep 0 of 14,588).

Refs #1695
The astra-review of the review fixes: the Groovy stranded-`instanceof`
assertion had been appended to the Kotlin row test as well as the
Groovy one (the two fixture tables end alike, and the edit matched
both), and the gate it earned — `all(groovy, kotlin)` — dropped the
whole Kotlin table from any build without Groovy. The duplicate and
the widened gate are gone; `--compare bdd70eb` reports no test lost a
build.

Refs #1695
…has found

Lesson 102: the #1695 truncation sweep cut after operator punctuation,
the class of the first drifts it surfaced, and so could not reach the
keyword-operator shapes a reviewer's independently chosen sweep found.

Refs #1695
…r it climbs

Four findings from the review of the review fixes:

- `elixir_when_is_guard` memoised only the asking token's operator, so
  "a chain is climbed once" held only when the walk visited the tokens
  outermost first. The climb now records its answer on every `when`
  operator it passes, and a counter test asks the tokens innermost
  first — the order that leaves nothing memoised above the first asker
  — and reads one position check; without the record it reads the
  chain's length.
- `perl_condition_scores_itself` asks the `Ternary` row of a
  `unary_expression` no longer, since only the chain rows own that
  kind; and Perl's `Comparison` row says the ABC consumer reads its
  projection directly, so a gate added there is read here too.
- The `cognitive/nested-and` rationale described the chain its render
  does not produce (left-nested) and cited that shape's timing; it now
  cites both.

Zero drift holds (0 of 6,638; recovery sweep 0 of 14,588).

Refs #1695
@codecov

codecov Bot commented Oct 24, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 98.42287% with 32 lines in your changes missing coverage. Please review.
✅ Project coverage is 98.29%. Comparing base (44184ad) to head (7425561).
⚠️ Report is 38 commits behind head on main.

Files with missing lines Patch % Lines
big-code-analysis-ast/src/getter.rs 73.07% 7 Missing ⚠️
...ig-code-analysis-ast/src/lang_helpers/case_arms.rs 96.55% 4 Missing and 2 partials ⚠️
src/metrics/abc/perl.rs 92.30% 5 Missing and 1 partial ⚠️
big-code-analysis-ast/src/roles/bash.rs 96.38% 1 Missing and 2 partials ⚠️
big-code-analysis-ast/src/roles/perl.rs 94.87% 1 Missing and 1 partial ⚠️
big-code-analysis-ast/src/roles/php.rs 90.90% 1 Missing and 1 partial ⚠️
...-analysis-ast/src/lang_helpers/elixir_decisions.rs 98.71% 1 Missing ⚠️
big-code-analysis-ast/src/roles/elixir.rs 97.22% 1 Missing ⚠️
src/metrics/abc/cpp.rs 98.87% 0 Missing and 1 partial ⚠️
src/metrics/abc/groovy.rs 98.57% 1 Missing ⚠️
... and 2 more
Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##             main    #1708      +/-   ##
==========================================
- Coverage   98.30%   98.29%   -0.01%     
==========================================
  Files         380      386       +6     
  Lines      110089   111166    +1077     
  Branches   109654   110731    +1077     
==========================================
+ Hits       108222   109273    +1051     
- Misses       1202     1239      +37     
+ Partials      665      654      -11     
Flag Coverage Δ
python 100.00% <ø> (ø)
rust 98.29% <98.42%> (-0.01%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
big-code-analysis-ast/src/checker/php.rs 94.93% <ø> (ø)
big-code-analysis-ast/src/checker/ruby.rs 98.46% <ø> (ø)
big-code-analysis-ast/src/checker/tcl.rs 72.72% <ø> (ø)
big-code-analysis-ast/src/getter/bash.rs 100.00% <100.00%> (ø)
big-code-analysis-ast/src/getter/c.rs 85.71% <100.00%> (ø)
big-code-analysis-ast/src/getter/cpp.rs 100.00% <ø> (ø)
big-code-analysis-ast/src/getter/csharp.rs 97.43% <ø> (ø)
big-code-analysis-ast/src/getter/elixir.rs 96.47% <100.00%> (+0.88%) ⬆️
big-code-analysis-ast/src/getter/go.rs 91.66% <ø> (ø)
big-code-analysis-ast/src/getter/groovy.rs 96.07% <100.00%> (ø)
... and 99 more

... and 1 file with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

The patch coverage report named two owner gates no test executed:
Bash's `comparison_operator`, which reads a `test_operator`'s bytes to
keep `-a` / `-o` out of the `Comparison` row, and Elixir's
`is_when_operator`, the `Guard` owner. Every consumer of both rows
reads the token projection, so `owns` ran only under the role-table
self-checks. Each row now has a test walking a fixture that spells
both readings and asserting the owners, the payers, and that a payer's
parent owns; dropping the byte read fails the first, and widening the
Elixir owner to every operator fails the second.

Refs #1695
The astra-review of the owner-gate tests: the Bash fixture spelt `-a`
inside `[[ ]]`, which tree-sitter parses and `bash -n` rejects, so it
pinned a grammar shape no script can have; it is `[ ]`'s now. And the
payer check asked whether *some* row owned a payer's parent, which an
ungated or swapped payer gate still satisfies; each row's payers are
now listed and held to that row's owner, asked with the chain above
it. Elixir's test asks `pays_at_leaf` of every node rather than only
the `when` tokens, so a payer the row gained would show. Verified by
perturbation: the connective check, each payer gate's text gate, the
two gates swapped, the Elixir owner widened and its payer ungated
each fail the test that names it.

Refs #1695
@dekobon
dekobon merged commit 7425561 into main Oct 24, 2026
54 checks passed
@dekobon
dekobon deleted the refactor/1694-1695-paying-roles branch October 24, 2026 22:43

This branch was successfully deployed

1 active deployment
github-pages — 7425561f Deployed Oct 24, 2026 by dekobon via deploy to GitHub Pages #859
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant