Skip to content

fix: batch 2026-10-09 — 36 metric, getter and gate fixes - #1577

Merged
dekobon merged 48 commits into
mainfrom
fix/batch-2026-10-09
Oct 10, 2026
Merged

dekobon merged 48 commits into
mainfrom
fix/batch-2026-10-09

Conversation

@dekobon

@dekobon dekobon commented Oct 10, 2026

Copy link
Copy Markdown
Owner

Summary

A batch of 36 issue fixes, one commit per issue (48 commits, all signed). Most of the work falls into four groups:

  • ABC consolidation. One shared chain-operand and argument walk in src/metrics/abc.rs, plus C# negated call arguments.
  • Perl grammar workarounds for tree-sitter-perl 1.1.2 misparses, each marked FIXME(#N upstream):
    • comparison and ternary precedence;
    • and, not and -bareword hash keys;
    • =~ s/// treated as a test;
    • comment text billed as an operator.
  • Decision-metric policy:
    • xor is a value, not a decision.
    • Bash -a / -o / = score like their && / == twins.
    • Each Elixir with <- clause is a decision.
    • Rust let-else is a cyclomatic decision and a nested cognitive increment.
  • Halstead vocabulary sweep. Punctuation and declaration keywords count as operators in every language: Python and Rust punctuation, declaration keywords and modifiers, Java and TypeScript type names, and generic <…> billed as one <> pair. Plus JSX text, C# lambda modifiers, C++ operator names, zero-width recovery tokens, and C++ bodiless specifiers no longer opening spaces.

The last ten commits collect fixes from four whole-branch reviews: two quadratic walks (Perl ABC, Bash), Kotlin soft-keyword names, check-test-lang-gates.py wrong verdicts, and Perl separator and pattern-role cases. Each carries Refs to the issue it corrects.

Metric drift

Values move by design. The [Unreleased] CHANGELOG section has the full account.

API

The only addition is Getter::get_operator_spelling, a defaulted trait method in big-code-analysis-ast. That crate is outside the stability contract (STABILITY.md), and nothing new reaches the public surface of big-code-analysis.

Verification

  • Final gate: make pre-commit on the tip gives BCA_GATE: pass (gate=pre-commit).
  • Tests: each issue's regression tests were checked by reverting the fix. After the rebase, each commit's tree is a state that passed the gate.
  • Patch coverage: 99.53% against 96.71% project coverage, measured the same way. This was measured locally before the review-fix commits, so Codecov's figure for the final tip may differ.
  • Performance: the quadratic walks the reviews found are linear now. For example, 8,000 Perl dash keys take 0.06 s (was 37.7 s), and 16,000 Kotlin parameters take 0.10 s (was 13.4 s). New bench-scaling probes cover the Perl dash-key, Kotlin parameter and Perl pattern-list shapes.
  • Snapshot submodule: every commit's pointer is on dekobon/big-code-analysis-output main, so a rebase or fast-forward merge works.

Follow-ups (filed, not in this PR)

Closes

Fixes #1537
Fixes #1469
Fixes #1475
Fixes #1273
Fixes #1539
Fixes #1540
Fixes #1541
Fixes #1536
Fixes #1534
Fixes #1535
Fixes #1482
Fixes #1296
Fixes #1483
Fixes #1544
Fixes #1543
Fixes #1542
Fixes #1545
Fixes #1547
Fixes #1546
Fixes #1548
Fixes #1549
Fixes #1519
Fixes #1486
Fixes #1550
Fixes #1551
Fixes #1552
Fixes #1554
Fixes #1555
Fixes #1556
Fixes #1557
Fixes #1562
Fixes #1558
Fixes #1560
Fixes #1561
Fixes #1559
Fixes #1553

dekobon added 16 commits October 9, 2026 09:34
#1526 gave the boolean-slot rule one home, but every language still
carried its own `<lang>_count_chain_operands` reading `left`/`right`
and its own negated-argument loop, so a change to the operand rule
had to be made in about a dozen places (lesson 59).

Add `count_field_operands` (Rule 9 over a `left`/`right` chain) and
`count_each_operand` (every operand child of a list) to abc.rs, each
taking the language's slot scorer. The nine field-reading copies and
the argument/return-list loops now call them; per-language rationale
moved to the call sites. Each caller keeps its own parent gate, so
the change is behaviour-neutral: no snapshot moved.

Refs #1537
tree-sitter-c-sharp wraps every call argument in an `argument` node,
and the argument walk handed that wrapper straight to the negation
peel, which declines it. So `G(!a)` scored 0 conditions where Java's
`g(!a)` and every other C-family language score 1.

Read each argument's value as the wrapper's last operand, which skips
a named-argument label, a `ref`/`out`/`in` keyword and comments. The
primary-constructor base call `class S(bool a) : Base(!a)` returns
early as a branch before the walk, while its record spelling reaches
it, so score its arguments on the branch path too and keep the two
spellings level. Indexer arguments stay unscored, as Java's index is.

The unit snapshot for `F(x == y, x < y, !x.Equals(y))` moves from 2
to 3 conditions and is now anchored. No corpus snapshot moves.

Fixes #1537
#1526 closed the C-family non-numeric literal gap without naming a
kind: every boolean slot now pays unless its predicate already scores.
Nothing pinned that answer for the literal kinds, so a kind wrongly
added to cpp_condition_scores_itself would drop a slot silently.

Add C, C++, Mozcpp and Objective-C rows to literal_bool_operands, one
spelling per non-numeric literal kind, each anchored to its kind id and
measured against the identifier twin in both the `&&` chain slot and
the `if` slot: string_literal, concatenated_string, true, false and
null; C++ raw_string_literal and user_defined_literal; Objective-C
@"s", array/dictionary literals, at_expression, @selector and @encode.
A negated literal joins the shared C-family slot rows.

Fixes #1469
#1475 recorded constructs that measured one condition short of an
identifier in a boolean slot. #1526 made every slot pay unless its
predicate already scores, which fixed all of them, but no test pinned
the result.

Pin each against the identifier control in both an `if` slot and an
`&&` chain-operand slot: C# default/checked/unchecked, an assignment of
a call and an object creation; Groovy `new Foo()` and `a*.b`; Perl
s///, tr///, y///, an anonymous sub, @A, @$b, %h and %$h; Ruby both
lambda spellings and a range; Elixir `fn` and `&f/1`. JS `!this` gains
a call-argument row.

Pin Groovy::Identifier2 as unreachable: it is the hidden
`_gstring_dollar_path` token, emitted under the public `identifier`
symbol, so the Halstead operand arm needs no second alias.

Whether Perl s/// and tr/// count outside a slot stays undecided; the
bound `$x =~ s///` and bare `s///` disagree there, tracked in #1540.

Fixes #1475
tree-sitter-perl 1.1.2 lets a chaining comparison (< > <= >= lt gt
le ge == != eq ne) swallow every lower-precedence operator to its
right, so `$a > $b ? 1 : 2` parses as `$a > ($b ? 1 : 2)` and
`$a > $b && $c` as `$a > ($b && $c)`. The ternary condition slot and
the chain walker then paid for `$b`, scoring one condition above the
JavaScript twin per misparsed comparison.

Detect the shape by climbing the ternary-condition / chain-first-
operand spine to a node that directly follows a chaining comparison
token, and skip that leading slot: the real occupant is the
comparison, which scores itself. Marked FIXME(#1273 upstream).
Cyclomatic and cognitive are unaffected.

Fixes #1273
tree-sitter-perl 1.1.2 lexes an auto-quoted bareword `and` as the
low-precedence operator. `$h{and}` recovers into an ERROR holding the
token, and `(and => 1)` becomes a unary_expression led by it, so
cyclomatic, cognitive, ABC and Halstead each scored an operator that
is not there.

A real `and` always has a left operand. The new
lang_helpers::perl::perl_and_is_operator checks that the token's
parent is an expression and that the token is not its first child.
Each site that keys on `and` now asks it. A rejected `and` is billed
as an operand, which is what `$h{or}` already bills. The workaround
is marked FIXME(#1539 upstream).

Fixes #1539
`=~` / `!~` counted as a condition whatever they bound, so
`$x =~ s/a/b/;` scored 1 while the bare `s/a/b/;` on `$_` scored 0.
A substitution or transliteration edits its string and yields a
count, so by use it is no test in either spelling, as #1467 already
decided for the bare form.

`perl_binding_rewrites` reads the binding's right operand and gates
both the operator-token arm and `perl_condition_scores_itself` on it,
so a slot still pays for a rewrite binding exactly once and every
`if (...)` reading is unchanged. Matches (`/re/`, `m{}`, `qr//`, a
pattern variable) keep their 1. `!~ s///r` / `!~ tr///r` are Perl
compile errors and are documented, not pinned.

Fixes #1540
tree-sitter-perl 1.1.2 lexes an auto-quoted bareword `not` as the
keyword. `$h{not}` recovers into an ERROR holding the token, and
`(not => 1)` into the unary_expression a real `not 1` gets, with the
`=>` wrapped in an ERROR beside it. The getter billed both as the
`not` operator where the `or` twin bills an identifier operand, and
ABC's value peel read a `=>` key as a negation, so `f(not => 1)` and
`return (not => 1)` scored a condition.

`not` is a prefix operator, so #1539's left-operand test cannot tell
the two apart; perl_not_is_key reads the recovery node instead. The
skipped `=>` sits in an ERROR the parser marks as an extra, so the
sibling scan keeps it while still passing over comments.

The #1539 test module now covers both keywords and is renamed to
perl_keyword_key_tests.rs.

Fixes #1541
Perl and PHP `xor` evaluates both operands, so like Java `^`, C++
`xor` and Kotlin's infix `xor` it is a value. Perl ABC walked it as
a chain (`if ($a xor $b)` scored 2 against cyclomatic 2), and PHP
counted it in all three metrics (3 / 2 / 2). Both now score as their
`^` twin: 2 / 1 / 1 in an `if`, 1 / 0 / 0 by use. Halstead still
bills `xor` as an operator.

Refs #1536
`[ X -a Y ]`, `[ X ] && [ Y ]` and `[[ X && Y ]]` disagreed. ABC
counted the `-a` / `-o` connective as a comparison and never counted
a `=` comparison, cyclomatic and cognitive ignored `-a` / `-o`, so
`[ "$a" = 1 -a "$b" = 2 ]` scored 2 / 2 / 1 where `[[ "$a" == 1 &&
"$b" == 2 ]]` scores 3 / 3 / 2.

lang_helpers::bash names the two cases: `bash_test_connective` reads
a binary `test_operator`'s text (the unary `-a FILE` stays a file
test) and `bash_eq_is_comparison` climbs out of the expression tree
to a `test_command`, so `(( x = 1 ))` stays an assignment. Cognitive
keys `-a` / `-o` to `&&` / `||`, and widens the sequence span over the
comparison the grammar wraps around a connective (`"$a" = (1 -o "$b")
= …`), which also fixes `[[ "$a" = 1 || "$b" = 2 || "$c" = 3 ]]`
scoring two sequences. A bare `test X -a Y` stays out of reach.

Fixes #1536
`&==/2`, `&and/2` and `Kernel.||(a, b)` name an operator without
applying it, and the grammar wraps the token in an
`operator_identifier`. Halstead billed the wrapper as an operand and
the token inside it as an operator, so `a = &==/2` scored n1 4 where
`a = &foo/2` scores 3. Cyclomatic counted a named `&&` / `||` /
`and` / `or` as a decision in both tiers.

The getter now returns Unknown for any token whose parent is an
`operator_identifier`, keeping the wrapper (the only node a childless
`..` has). Cyclomatic gates its short-circuit arm on an applying
`binary_operator`, through `lang_helpers::elixir::
elixir_applying_operator`, which ABC now shares.

Fixes #1534
Each `pattern <- expr` clause of a `with` either matches or falls
through to the `else`, so it is a decision: +1 standard and modified
cyclomatic and one ABC condition, like a Rust `if let` or `?`. A
`when` guard on that pattern is now a guard, as on a clause. A `for`
generator's `<-` and its guard stay non-decisions; the `for` already
pays its loop decision.

Modified cyclomatic no longer charges `with` a flat +1. Its `else`
clauses collapse into one modified decision only when one of them is
a decision, so `else _ -> 0` is free in both tiers, as Rust's
`if let … else` is.

A `for … reduce:` block's accumulator clauses now take the
multi-clause `fn` head skip (#776): a single `acc -> …` clause scores
nothing, and n clauses score n-1, a trailing `_ ->` included.

Fixes #1535
tree-sitter-c-sharp aliases a lambda's or anonymous method's bare
`static` / `async` onto a childless `modifier` node, which no Halstead
half billed, while the declaration spelling of both keywords is an
operator through its leaf.

Bill both texts as operators in the aliased-modifier arm, and list
`Static` / `Async` in `CsharpCode::is_primitive` so the two spellings
share the lexeme-keyed map instead of splitting one keyword across both
operator maps and counting it twice in `n1`. The comment there claimed
widening the getter needed no change to `is_primitive`; it did.

Adds a `parameters.cs` corpus fixture carrying every aliased modifier
spelling, which also gives #1418 its first snapshot coverage.

Fixes #1482
An overloaded operator's name was an operator only when the grammar
spelled it with punctuation (`operator +`). C#'s `operator true` /
`operator false` emit the keyword tokens, which were billed as operands,
and C++'s `operator[]`, `operator()`, `operator""_x` and
`operator co_await` emit whole tokens of their own that no arm listed,
so they were billed nowhere.

C#: the `True | False` arm gains an `OperatorDeclaration` parent guard
beside the #1253 `BooleanLiteral` one. C++ and Mozcpp: `LPARENRPAREN`,
`LBRACKRBRACK`, `DQUOTEDQUOTE`, `CoAwait` and the `operator` keyword
are operators. `co_await` is also the keyword of a `co_await`
expression. C and ObjC have none of these kinds.

254 DeepSpeech C++ snapshots move, all in Halstead operator counts and
the fields derived from them (and mi); no operand or structural change.

Fixes #1296
`jsx_text` and `html_character_reference` were in no arm of the
JavaScript, MozJS or TSX getters, so the text content of a JSX element
reached neither Halstead half.

A new `jsx:` argument to `impl_js_family_get_op_type!` bills element
text as an operand, and a character reference as one when its parent is
not a `string` (inside an attribute string it is already part of that
operand). It also emits two byte-aware overrides: whitespace-only text
(the same-line space between two tags) is `Unknown`, and the operand is
keyed on the trimmed text so indentation cannot split identical text.

`Checker::is_string` deliberately stays without `jsx_text`: `find
string` reports delimited literals, the set the alterator flattens.
TypeScript gets no `jsx:` argument; a test pins that its grammar never
emits `jsx_text`. No corpus contains JSX.

Fixes #1483
`x => x` puts its parameter in a childless `implicit_parameter` node,
where `(x) => x` puts an `identifier` in a `parameter_list`. The C#
getter listed only the latter, so a bare lambda parameter was billed in
neither Halstead half: `_ => 0` lost `_` from the vocabulary entirely.

Add `ImplicitParameter` to the operand arm. `nargs` already counts both
spellings alike through `is_bare_param` (#1185), and Java's bare lambda
parameter is a plain `identifier` that was already an operand.

The csharp corpus snapshots anonymous.cs and linq.cs move in
total_operands and its derived fields only.

Fixes #1544
@codecov

codecov Bot commented Oct 10, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 99.84202% with 3 lines in your changes missing coverage. Please review.
✅ Project coverage is 98.18%. Comparing base (50e990e) to head (8948e9c).
⚠️ Report is 48 commits behind head on main.

Files with missing lines Patch % Lines
big-code-analysis-ast/src/lang_helpers/bash.rs 95.83% 0 Missing and 1 partial ⚠️
src/metrics/abc.rs 99.69% 0 Missing and 1 partial ⚠️
src/metrics/elixir_decisions.rs 99.43% 0 Missing and 1 partial ⚠️
Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##             main    #1577      +/-   ##
==========================================
+ Coverage   98.14%   98.18%   +0.04%     
==========================================
  Files         359      359              
  Lines       97893    99449    +1556     
  Branches    97462    99449    +1987     
==========================================
+ Hits        96073    97645    +1572     
+ Misses       1187     1174      -13     
+ Partials      633      630       -3     
Flag Coverage Δ
python ?
rust 98.18% <99.84%> (+0.05%) ⬆️

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.rs 97.07% <100.00%> (+<0.01%) ⬆️
big-code-analysis-ast/src/checker/cpp.rs 100.00% <100.00%> (ø)
big-code-analysis-ast/src/checker/csharp.rs 100.00% <ø> (ø)
big-code-analysis-ast/src/checker/kotlin.rs 88.46% <100.00%> (+1.50%) ⬆️
big-code-analysis-ast/src/checker/mozcpp.rs 100.00% <100.00%> (ø)
big-code-analysis-ast/src/getter.rs 96.06% <100.00%> (-0.09%) ⬇️
big-code-analysis-ast/src/getter/c.rs 85.71% <100.00%> (ø)
big-code-analysis-ast/src/getter/cpp.rs 100.00% <100.00%> (ø)
big-code-analysis-ast/src/getter/csharp.rs 95.52% <100.00%> (+0.52%) ⬆️
big-code-analysis-ast/src/getter/elixir.rs 94.11% <100.00%> (+0.27%) ⬆️
... and 54 more

... and 6 files 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 Elixir clause, guard and `with` predicates that `Cyclomatic` and
`Abc` share lived in `npa::shared`, which they have nothing to do
with, and pushed that file past the `loc.ploc` limit. Move them
verbatim into `metrics::elixir_decisions` and import them by name at
the two call sites. No behaviour change; the `npa/shared.rs` file
baseline entry is gone.

Fixes #1543
`let PAT = e else { … };` binds or diverges, the same decision its
`if let` and `match` twins pay, but no Rust cyclomatic arm matched it,
so it scored nothing and broke the `abc.conditions == cyclomatic - 1`
parity ABC's `else` slot already held. Count a `let_declaration` that
carries an `alternative` block, +1 in standard and modified. Keying on
the field rather than the `else` token leaves `if … else` alone.

Cognitive's existing 1 comes from the shared `else` token arm, not a
let-else rule, and takes no nesting; that is filed as #1548.

The serde corpus moves in one file (three let-elses). The self-scan
baseline is unchanged: 151 functions gain +175 in total, and none
crosses the soft or hard cyclomatic limit.

Fixes #1542
tree-sitter-perl 1.1.2 lexes the `-X` of an auto-quoted `-bareword`
key as a file test. `$h{-foo}` and `(-text => 1)` billed the operand
`oo` / `ext` plus an ABC branch for "calling" it, `(-x => 1)` billed
the whole `-x => 1` span, and `-not` / `-and` keys billed a `-`
operator.

The Perl getter now bills a glued `-bareword` file test in key
position (whole hash subscript key, or left of `=>`) as one operand
spelling the key, drops the inner word, and drops the `-` of a
`-and` / `-not` key while spelling that key with its sign. The ABC
walker no longer counts the inner word as a call. Real file tests,
and `-bareword` outside a key, keep their reading.

The subscript form of an exact file-test key (`$h{-x}`) drops the
token in error recovery and stays unbilled.

Fixes #1545
C++'s coroutine keywords were in neither Halstead arm, so `co_return`
and `co_yield` billed nothing while `return` and, since #1296,
`co_await` are operators. Both are now operators in Cpp and Mozcpp.
Each is the keyword leaf of an unclassified statement node, so nothing
bills one twice.

NExits counted neither statement either. `co_return_statement` is a
coroutine's `return`, and `co_yield_statement` suspends to the caller
as the Python / JS `yield` NExits already counts, so both are exits.

No DeepSpeech snapshot moves: the corpus has no coroutines.

Fixes #1547
Error recovery inserts zero-width tokens for what the source lacks: a
MISSING `;`, `)` or identifier, and a few non-MISSING identifiers (C#,
Elixir). Every getter that classifies the kind billed them, so an
inserted identifier became an operand spelled "", in all 23 languages,
and an inserted `;` a second `;` operator. Halstead counts written
tokens, so compute_halstead now skips any zero-width node, which covers
`bca metrics` and `bca ops` alike.

34 DeepSpeech C/C++ snapshots move in Halstead and mi only. The
openfst dfs-visit.h `Destroy` (`dfs_state->~DfsState<FST>()`) drops
the "" operand: n2 8 -> 7, N2 16 -> 15.

Fixes #1546
A `let PAT = e else { … };` paid cognitive only through the shared
`else` token arm, a flat +1 with no nesting, so inside a `for` it
scored one below its `if let` twin and fell further behind with each
level. Score it as the `if let` it replaces: a structural increment
plus the current nesting, keyed on the `alternative` field as
cyclomatic is (#1542), with everything under it, the `else` block
included, one level deeper. The `else` token arm now skips a token
whose parent is a `let_declaration`, so nothing pays twice.

Across the repository 50 spaces move, +62 in total. One function
newly reaches the soft tier: `greedy_truck_factor` in
src/vcs/bus_factor.rs goes 14 -> 15, the cognitive limit, and is
baselined. No corpus snapshot moves; serde's three let-elses are at
function top level, where both rules give 1.

Fixes #1548
tree-sitter-perl spells a comment's leading `#` as a `#` token under
`comments`, and the getter listed `HASH` as an operator, so every
comment billed one `#`. The grammar has one other position for that
token: the pattern text of a bare `/…/`, whose `regex_pattern_content`
also spells `[`, `]` and `\` as anonymous tokens. Three of the four
were operators, so `/a#b[c]\#/` billed `#`, `[]` and `\` where its
`m//` and `qr//` synonyms bill nothing.

Drop `HASH` from the operator arm, since neither position is code, and
classify every child of `regex_pattern_content` as Unknown, the
compound-leaf guard the bare form's `/` delimiters already use. `$#a`
and the `#` delimiters of `qw#…#`, `s#a#b#` and `q#…#` are distinct
token kinds and keep their reading.

A sweep of all 21 languages' commented and comment-free twins found no
other comment-token leak. No corpus snapshot moves: the two Perl
corpus files are not snapshotted. The getter's halstead.effort
baseline entry is refreshed for the new arm.

Fixes #1549
`num_jobs_is_the_consumer_count_not_a_budget_shared_with_a_producer`
counted the distinct threads that had run the callback, a read taken
at one point in time. Under scheduler pressure the second consumer can
start only after the first has drained all 200 files, so it existed
but was never seen, and the test failed with "got 1". Production is
not under-provisioned: `run` spawns exactly `num_jobs` consumers on
one shared receiver.

Each consumer now waits on its first file until both have arrived.
With the first parked, the second is guaranteed a file whenever it
starts. The wait is bounded at 10 s, so the pre-#1114 single consumer
still fails the "more than one" assertion rather than hanging. The
50 us sleep that only made the race less likely is gone.

The flake did not reproduce naturally: 600 isolated runs under CPU
load and 55 full-suite runs all passed. A 50 ms start delay injected
into consumer 1 makes it deterministic: the old test failed 184/200
under load (200/200 pinned to one CPU), the new one passed 200/200 in
both, and it passes 200/200 under load without the delay.

Fixes #1519
#1395 made punctuation and delimiters Halstead vocabulary, but two
getters still left glyphs out: Python billed only `,` and `.`, and
Rust billed `::` but not `:`.

Python now bills `(`, `[`, `{` (once per pair, rendered `()` / `[]` /
`{}` like its siblings), `;` and every syntactic `:`. An f-string's
replacement-field `{` and format-spec `:` stay unbilled, matching the
#1314 interpolation-opener rule. Rust now bills its single `:`, a
separate entry from `::`.

Every Python and Rust Halstead score, and the MI derived from it,
rises. 116 of 172 serde corpus snapshots move (Halstead and MI only);
the self-scan baseline is refreshed for the repo's own Rust code.

Fixes #1486
Python billed `def` as a Halstead operator but not `class`, so
`class C:\n    pass` reported only `:` and `pass`. Ruby, Kotlin, C#
and PHP bill their `class` keyword, and Rust bills `struct` / `trait`.

The `class` leaf joins the operator arm beside `def`. Its
`ClassDefinition` wrapper stays unlisted, so a definition bills one
`class`, not two. Every Python file with a class gains one operator
entry and one occurrence per definition; the CSV greeter snapshot moves
by exactly that (n1 8 -> 9, N1 16 -> 17).

The getter's own effort grew, so its self-scan baseline is refreshed.

Fixes #1550
A self-documenting f-string field (`f"{z=}"`, Python 3.8+) billed its
`=` as a Halstead operator, so `bca ops` reported `["="]` for a line
that assigns nothing.

The `=` is a directive of the replacement field: it asks for the
expression's source text to be printed before its value. It belongs
with the `{` opener and the format-spec `:` that #1486 left unbilled
under the #1314 interpolation-opener rule, and with the `!r`
conversion, which was never billed. So it is withdrawn under exactly
the parents the `{` is: `Interpolation`, and `FormatExpression` for
the nested `f"{a:{w=}}"` form Python 3.12 accepts. The helper is
renamed from `brace_or_colon_role` to `replacement_field_role`, and
`EQ` moves out of the main operator arm, where it would be dead.

The guard reads the parent, so a real `=` inside a field is still
billed: a keyword argument (`f"{g(a=1)}"`) keeps its `=`, and a walrus
(`f"{(y := 1)}"`) keeps its `:=`, a different token.

ABC's A axis never counted this `=`: Python ABC counts `Assignment`
nodes that carry an `EQ` child, and the directive's parent is the
interpolation. `f"{z=}"` measures A = 0 before and after this change,
so only the getter changes.

Fixes #1551
Java, the JS family, Groovy and C++ billed no Halstead operator for
`class`, while C#, Kotlin, PHP, Ruby, Python and Rust (`struct`) bill
their declaration keyword. JavaScript billed `function` but not
`class`.

Each getter now bills the keyword leaf and leaves the declaration
wrapper unlisted, so a declaration bills its keyword once. In the JS
family `Class2` is the leaf; the unsuffixed `Class` is the class
expression node and stays unbilled.

Keywords beyond `class`, each to close the same gap within one
getter or between grammars that share an alphabet:
- Java: `interface`, `enum`, `record`, `@interface`, as C# bills its
  `interface` / `enum` / `record`.
- TypeScript / TSX: `interface`, `enum`, for the same reason.
- Groovy: `interface`, `trait`, `enum`, `record`, mirroring Java.
- C++ / Mozcpp: `struct`, `union`, `enum`. `struct S {}` and
  `class S {}` are the same declaration and must score alike.
- C: `struct`, `union`, `enum`. c.rs defines C's set as C++'s minus
  the C++-only forms, so a header must not score by which grammar
  parsed it.
- ObjC: C's three, plus `@interface`, `@implementation` and
  `@protocol`, its class and protocol declarations. `@class` already
  billed its `@` and is unchanged.

Two positions where `class` declares no class stay unbilled: Java's
`C.class` is already one operand (grammar-dispatch section 5), and
C++'s `template <class T>` is the synonym of `typename T`, which is
unbilled. TypeScript `type` / `namespace` / `module`, C++
`namespace` / `typedef` and Kotlin `interface` are left alone.

The corpus snapshots move in halstead.* and mi.* only: 449 C++ and
178 JavaScript files. No Java, Groovy, TypeScript, C or ObjC corpus
is snapshotted. The self-scan baseline does not move.

Fixes #1552
The JS-family operator arm listed both the `function` keyword leaf and
the `function_expression` wrapper that contains it, so
`const f = function () {}` billed `function` twice: n1 and N1 each one
too high (grammar-dispatch section 5).

The wrapper is dropped and the leaf kept. The leaf exists in every
spelling: plain, `async`, and an ERROR-recovery parse, where the
wrapper is absent. A generator expression is its own
`generator_function` node and was never billed. JavaScript, Mozjs,
TypeScript and TSX share the macro.

The pdf.js corpus moves in halstead.* and mi.* only: 157 files, each
changed space losing exactly one unique operator.

Fixes #1554
C++ and Mozcpp opened a struct / class space for every
`struct_specifier` / `class_specifier`, including those that only
name a type: forward declarations (`struct S;`), elaborated type
specifiers (`struct S *p;`, casts, parameter types) and explicit
template instantiations. Each spurious space sat at unit or function
level, inflated cyclomatic.sum by its base of 1, and skewed every
per-space min and average.

A new shared predicate, `cpp_specifier_has_body`, gates both
`is_func_space` and `get_space_kind` on the `body` field, so the
space tree cannot disagree with itself (grammar-dispatch sections 6
and 7). Neither grammar overrides the `_with_code` forms, which
forward to these. C and ObjC open no struct spaces and are unchanged.

105 DeepSpeech snapshots lose 4452 bodiless spaces (4207 struct,
245 class); no space is added, none removed had children, and every
retained struct/class span has a body.

Fixes #1555
Kotlin billed `class` and `object` but not `interface`, while Java, C#,
TypeScript and Groovy bill theirs (#1552). The `interface` leaf is now
an operator; its `class_declaration` wrapper stays unlisted, so a
declaration bills its keyword once. The grammar has one `interface`
kind, so there is no alias to cover.

`enum` in `enum class` stays unbilled. Kotlin's grammar parses it as a
`class_modifier`, the same node that holds `data`, `sealed` and
`annotation`, and the getter bills none of those, nor `abstract` or any
visibility modifier. The declaration keyword is the `class` that
follows, which already bills, so `enum class E` scores one keyword just
as Java's `enum E` does. Billing `enum` alone would single out one
member of a modifier class the getter otherwise ignores; whether Kotlin
should bill modifiers at all, as Java and C# do, is a separate
question.

No Kotlin corpus is snapshotted, so no integration snapshot moves.

Fixes #1556
TypeScript billed no operand for a type name. `type_identifier` is the
one leaf kind for every type name: a class, interface or alias name, a
type parameter, an annotation (`x: Foo`), a generic (`Map<K, V>`), an
`implements` entry, the tail of `ns.T`. It was in neither arm, so
`interface I {}` and `type T = number` lost their names, and TS read
`class C {}` with one operand fewer than JavaScript. C#, Rust, C, C++,
ObjC, Go and Groovy bill type names as operands; `TypeIdentifier` is
now a TS and TSX operand extra. No classified node wraps it
(`generic_type` and `nested_type_identifier` are unlisted), so each
name bills once. Java has the same gap, filed as #1560.

The declaration keywords #1552 left out are now operators, each as its
keyword leaf under an unclassified wrapper:
- C++ / Mozcpp: `namespace`, `template`, `typename`, `typedef`,
  `using`. They also bill in their non-declaring positions
  (`using namespace`, `typename T::x`, `x.template f`), where they are
  the same leaf.
- C and ObjC: `typedef`, so a header scores alike whichever grammar
  parses it.
- TypeScript / TSX: `type`, `namespace`, `module`, `declare`, and
  `global` (the `declare global {}` sibling of `declare module`).
  `Module2` is the keyword leaf; the `Module` declaration node stays
  unlisted. Each word is contextual, but used as a name the grammar
  emits an `identifier`, so no parent gate is needed.

#1552 left C++'s template-parameter `class` unbilled because its
synonym `typename` was. With `typename` billed the guard is gone, and
`template <class T>` and `template <typename T>` still score alike.

Template `<` `>` stay two operators: every language with generics
bills them that way, so the pair question is filed as #1559.

Snapshots: 929 DeepSpeech C++ files move in halstead.* and mi.* only
(field-level diff); pdf.js and serde do not move. Submodule 58a78b26.

Fixes #1557
A call made inside an inner `#[cfg(feature = ...)]` dropped the
languages it named, so a test whose every parser was guarded derived
no needs. The gate then took it for an always-compiled caller and
cleared the needs of each helper it called. `assert_operator_rows`
had three such callers, so its gate was checked against nothing and
the #1556 version lacking `kotlin` printed OK over an E0425.

The turbofish was never the gap: `::<KotlinParser>` already counted.

A guarded call now hands its gate's languages to the callee, and only
unguarded calls mark a callee always-compiled. The fixed gate found
one more helper of the same shape, `braced_word_string_verdicts` in
checker.rs, ungated while every call sits behind `tcl` / `irules`;
it now carries their `any(...)`.

Fixes #1562
Java, Groovy and C# bill their declaration modifiers as Halstead
operators; Kotlin billed none. Each modifier keyword leaf is now an
operator: `enum`, `data`, `sealed`, `annotation`, `inner`, `value`,
`companion`, the four visibilities, `abstract`, `final`, `open`,
`override`, `lateinit`, `const`, the function, parameter and platform
modifiers, `reified` and the variance `out` (`in` already billed). The
`modifiers` / `*_modifier` wrappers stay unlisted, so each counts once.

Every keyword has one kind, so `kind_id` keys it without splitting one
word across the two `n1` maps. `reified` is spelled only as the named
`reification_modifier` kind, so `KotlinCode::is_primitive` keys it by
text and `bca ops` lists the word written. The same words used as
names (`val open = 1`) parse as identifiers and stay operands.

The #1556 rows for `enum class`, `data class` and `sealed interface`
now score the modifier, and the bracket parity row bills `override`.
No Kotlin corpus is snapshotted, so no integration snapshot moves.

Fixes #1558
Java classified no `type_identifier`, so a type name used as a type
contributed no Halstead operand: `class C implements I { Foo f; }`
billed `C`, `I` and `f` but not `Foo`, and the `implements I`
reference billed nothing. C#, Rust, the C family, Go, Groovy and
TypeScript (#1557) bill type names as operands; Java now does too.

The grammar has one `type_identifier` kind and no listed node wraps
it, so each name bills once: a field or parameter type, an
`implements` / `extends` entry, a type parameter, a generic argument,
and each segment of `java.util.List` (the `.` already bills). Two
positions stay unbilled:
- a type inside a class literal, directly or under an array or scoped
  type (`Foo.class`, `Foo[].class`, `java.util.List.class`), since the
  literal is already one operand keyed by its whole text;
- `var` in `var x = 1`, which the grammar spells as a type name. It
  names no type, and C# bills its `var` as nothing, so
  `get_op_type_with_code` keeps it unbilled. A variable named `var`
  is an `identifier` and stays an operand.

`java_operators_and_operands` and `java_ops` move by the one
`string` parameter type (n2 12 -> 13, N2 22 -> 23). No Java corpus is
snapshotted, so no integration snapshot moves.

Fixes #1560
TypeScript billed `as`, `typeof` and `extends` but not their
type-level siblings. `satisfies`, `keyof`, `infer`, and the `is` and
`asserts` of a type predicate are now operators in both TypeScript
grammars, each as its keyword leaf. The `satisfies_expression`,
`index_type_query`, `infer_type`, `type_predicate` and `asserts`
wrappers stay unlisted, so a keyword bills once. `Asserts2` is the
keyword token under the `Asserts` node; billing either is
indistinguishable, and billing both is what the new rows catch.

Used as names (`const keyof = 1`, `o.is`, a `satisfies` parameter)
the words parse as identifiers and stay operands. The pinned grammar
misreads one valid spelling, `let satisfies = 1`, as a
`satisfies_expression` around an ERROR; that statement was already
mis-scored and is noted in the getter rather than pinned.

The keywords live in the TS/TSX extras only, so JavaScript, Mozjs and
the pdf.js corpus do not move, and no snapshot moves.

Fixes #1561
A type-argument, type-parameter or template list billed its `<` and
`>` as two operators under the comparison operators' kind ids, so
`Vec<u8>` and `a < b` shared a vocabulary entry. Every other bracket
pair bills once on its opener (#1395).

The list's `<` now bills as one `<>` operator and its `>` as nothing,
gated on the parent being the list node. The grammars give the opener
the comparison's kind id, so a new `Getter::get_operator_spelling`
hook keys it by the fixed text `<>` instead. Comparisons and shifts
are unchanged.

Covered: Rust (type arguments and parameters, turbofish, `for<'a>`,
`use<T>`), Java, Groovy, C#, TypeScript, TSX, Kotlin, C++ and Mozcpp.
Go's generics use `[]` and already billed a pair. Rust's `<T as Tr>`,
C#'s `delegate*<…>` and Kotlin's `super<A>` keep `<` and `>`.

N1 falls wherever generics appear; n1 rises by one where a comparison
survives beside the new `<>`. Halstead and MI move in 858 C/C++, 64
Rust and 4 C# corpus snapshots, and the self-scan baseline is
refreshed.

Fixes #1559
`impl_js_family_get_op_type!` and its per-language rationale are
shared by exactly the four JS-family getter modules, yet sat in the
middle of getter.rs between the trait and its helpers. #1483 and #1559
grew it (JSX text, generic angles), pushing getter.rs to 1,264 sloc,
inside the 0.95 soft band of the 1,300 file limit.

Move it verbatim to getter/js_family.rs, beside generic_angles.rs, and
re-export it so the `use super::*` call sites resolve unchanged.
getter.rs drops to ~970 sloc and leaves the baseline. The macro's
three rustfmt-bail arms move with it, so the bail baseline relocates
them rather than growing. No behaviour change.

Fixes #1553
Record the behaviour changes from the 2026-10-09 batch under
[Unreleased]: the Halstead vocabulary policy changes under Changed,
the new defaulted Getter::get_operator_spelling under Added, and the
per-language metric fixes under Fixed.
#1559 paired the generic angle brackets in every getter with generics
but Objective-C, whose lists still billed `<` and `>` under the
comparison operators' entries. The ObjC getter now carries a
GENERIC_ANGLES table over its four generic lists: generic_specifier
(`NSArray<NSString *>`), protocol_reference_list (`id<NSCopying>`),
parameterized_arguments (`@interface A : NSObject <NSCopying>`), and
the call argument_list's `Type<...>` form (`f(NSArray<NSString *>)`).
The call form is the ArgumentList2 alias; ArgumentList is the
preprocessor call's, which holds no angle brackets. A regression test
covers all four lists, a nested generic and surviving comparisons; the
book and changelog now list Objective-C.

Two doc comments still described `xor` as a boolean-sequence operator
after #1536 made it a value: the compute_booleans_keyed doc and the
PHP word-form test comment in cognitive.rs. The PHP keyword-walker
regression note in abc.rs is corrected the same way.

Refs #1559
Refs #1536
Add lesson 95: error recovery inserts zero-width tokens that
is_missing() does not flag, on valid code the grammar cannot parse as
well as invalid code (#1546). Extend lesson 35 with the #1540 token
arm narrowed without its slot predicate.

Route the standing obligations to rules: skip invented tokens by
width (grammar-dispatch section 6), re-run perturbations after merging
predicates and clear __pycache__ before a Python sweep (testing.md),
and the three insta traps in the bulk snapshot refresh (AGENTS.md).
Two reviews of main...fix/batch-2026-10-09 found defects that the
per-issue pipelines could not see. Each behavioural fix carries a
regression test, verified by reverting the fix.

- Perl ABC and Bash cognitive/ABC walks were quadratic in chain length
  or statement count; climb only where the slot pays, and track Bash
  comparison runs top-down.
- Kotlin soft keywords used as names billed as modifiers (#1558).
- C++ operator() / operator[] rendered as () / [], duplicating bca ops
  entries (#1296).
- Generic <> gated against comparisons the ObjC and C# grammars
  misparse as generic lists (#1559).
- check-test-lang-gates: three wrong-verdict paths for guarded calls
  and const tables (#1562).
- Perl and / -and / -not keys, a signed key, and infix minus (#1539,
  #1541, #1545).
- Elixir Kernel.|| keeps its cyclomatic decision; Foo.|| does not
  (#1534).
- TS export { type }, C++ co_return ABC, Bash =~ sequences.
- CHANGELOG and book corrections, including the halstead.effort
  default drift tracked in #1569.
The #1545 dash-key workaround found the `=>` after each `-foo` key by
scanning the enclosing list from its start, and #1558's Kotlin
recovery guard did the same for the token after each parameter
modifier. Both are O(width) per node, so a wide argument or
parameter list made the walk quadratic: 4 000 Perl keys took 7.45 s
and 8 000 Kotlin parameters 2.81 s in a release build.

Add Node::children_after and Node::children_before, which seat a
cursor on the child with goto_first_child_for_byte (O(log n), as the
Tcl slot lookups already do) and fall back to a scan only for a
zero-width child. Route the Perl key helpers, the Kotlin guard, and
the ERROR-parent operator lookup in Perl ABC's misparse check (the
same shape, 4.78 s for 8 000 chains in an unclosed list) through
them. All three now scale linearly.

Add width probes for the Perl and Kotlin shapes to the scaling gate.

Refs #1545
Refs #1558

Also rebaseline `impl Node`'s nom, which the two new methods
raise from 36 to 42.
#1558's guard bills a modifier keyword only when the token after it
is no zero-width name that recovery inserted. A comment between the
two is a sibling extra, and the guard took it for that token, so
`fun f(vararg /*note*/: Int)` billed a phantom `vararg` operator.

Step over extras when finding the next token. A real `vararg` before
a commented parameter keeps its operator.

Refs #1558
check-test-lang-gates handed a call behind an inner #[cfg] the
parsers named in its block, not the builds its guard admits. A call
in an any(python, rust) block naming only PythonParser handed its
helper `python` alone, so --fix gated the helper out of the rust
build, exited 0, and rejected the correct any(python, rust) gate.
Calls were also matched one line at a time, so a turbofish spread
over several lines was in neither the guarded nor the unguarded set.

Attribute a statement-level guard by evaluating its predicates: the
call stays unconditional if they hold with no language enabled, and
otherwise hands over every single-language build they admit. A nested
item's own #[cfg] is its gate rather than a guard on its container,
so those extents keep the parser-named reading. Match calls and
references over the whole body, placing each by its start line.

The real tree still reports OK with no new markers, and a scratch
crate confirms each verdict with rustc (E0425 exactly where the gate
reports a stranded build).

Refs #1562
Three ways the Perl ABC walk misread an operand's role:

- tree-sitter-perl names `,` and `=>` (`normal_comma`, `fat_comma`),
  so a trailing separator was taken for a list's last element:
  `if ($a > 1,)` and `if ($a > 1 =>)` scored 2 conditions against 1
  for `if ($a > 1)`, which Deparse reduces them to. No other grammar
  here names its separator, so the fix is Perl's own operand test.
- A pattern's bound/delimiter role was read on its immediate parent,
  so `$x =~ (/foo/)` and `split((/foo/), $x)` paid the match their
  bare twins do not, and `$x =~ (s/a/b/)` paid the `=~` that
  `$x =~ s/a/b/` does not. The role is now read on the outermost
  `( … )`, stopping at a call's own argument list. Only the binding's
  right operand is bound: `/foo/ =~ $x` matches `$_` first and now
  scores 2, as `my $m = /foo/; $m =~ $x` does.
- The `split` delimiter exemption ignored the package, so
  `My::split(/foo/, $x)` scored 0. Only bare `split` and
  `CORE::split` are the builtin.

Refs #1526
Refs #1467
Refs #1540
#1545 reads a file test that swallowed a `=>` (`(-x => 1)`) as the
auto-quoted key `-x`. Perl quotes `-x` there only across spaces and
tabs: across a newline, or a comment, which runs to one, it stays the
file test on `$_`. `(-f # c\n=> 7)` and `(-f\n=> 7)` both run as
`(-f, 7)`, but the grammar recovers the same ERROR around the `=>`,
so both billed a fabricated `-f` operand.

Require the recovered `=>` to start on the test's own row. The `-foo`
and keyword keys keep their reading across a comment or newline,
where Perl's result is the same string either way.

Refs #1545
@dekobon
dekobon force-pushed the fix/batch-2026-10-09 branch from 528ad56 to 8948e9c Compare October 10, 2026 19:36
@dekobon
dekobon merged commit 8948e9c into main Oct 10, 2026
54 checks passed
@dekobon
dekobon deleted the fix/batch-2026-10-09 branch October 10, 2026 19:55

This branch was successfully deployed

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