Repository navigation
fix: batch 2026-10-09 — 36 metric, getter and gate fixes - #1577
Merged
Merged
Conversation
#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 Report❌ Patch coverage is Additional details and impacted files@@ 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
Flags with carried forward coverage won't be shown. Click here to find out more.
🚀 New features to boost your workflow:
|
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
force-pushed
the
fix/batch-2026-10-09
branch
from
October 10, 2026 19:36
528ad56 to
8948e9c
Compare
This branch was successfully deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
A batch of 36 issue fixes, one commit per issue (48 commits, all signed). Most of the work falls into four groups:
src/metrics/abc.rs, plus C# negated call arguments.FIXME(#N upstream):and,notand-barewordhash keys;=~ s///treated as a test;xoris a value, not a decision.-a/-o/=score like their&&/==twins.with <-clause is a decision.let-elseis a cyclomatic decision and a nested cognitive increment.<…>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.pywrong verdicts, and Perl separator and pattern-role cases. Each carriesRefsto the issue it corrects.Metric drift
Values move by design. The
[Unreleased]CHANGELOG section has the full account.<>pair lowers effort for most code that uses generics.halstead.effortormi.*. Thebca initdefault forhalstead.effortand the book's calibration table predate these changes. chore(thresholds): re-derive halstead.effort defaults after the vocabulary changes #1569 tracks re-deriving them.operator truename is an operator or an operand #1296, fix(halstead): a zero-width MISSING identifier is billed as an operand #1546, fix(getter): the class keyword is unbilled in five getters #1552, fix(getter): bill C++ and TypeScript declaration keywords and names #1557, design(halstead): bill generic <> as a bracket pair? #1559;cyclomatic.sum.API
The only addition is
Getter::get_operator_spelling, a defaulted trait method inbig-code-analysis-ast. That crate is outside the stability contract (STABILITY.md), and nothing new reaches the public surface ofbig-code-analysis.Verification
make pre-commiton the tip givesBCA_GATE: pass (gate=pre-commit).bench-scalingprobes cover the Perl dash-key, Kotlin parameter and Perl pattern-list shapes.dekobon/big-code-analysis-outputmain, so a rebase or fast-forward merge works.Follow-ups (filed, not in this PR)
=>: fix(perl): a bareword key before => scores an ABC branch #1572.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