Skip to content

fix(abc): ABC condition-counting batch - #1524

Merged
dekobon merged 17 commits into
mainfrom
fix/batch-2026-10-07
Oct 8, 2026
Merged

dekobon merged 17 commits into
mainfrom
fix/batch-2026-10-07

Conversation

@dekobon

@dekobon dekobon commented Oct 8, 2026

Copy link
Copy Markdown
Owner

Five ABC conditions fixes in src/metrics/abc/, batched because they share the per-language count_condition / peel helpers. Only abc.* values move; cyclomatic, cognitive and Halstead are unchanged. The CHANGELOG.md [Unreleased] section has the user-facing wording.

Issues

#1468 was already fixed by #1476. I re-measured it and closed it separately.

Fixes #1448
Fixes #1453
Fixes #1455
Fixes #1467
Fixes #1470

Corpus snapshot drift

tests/repositories/big-code-analysis-output moves 062ae343 → 2e83ae93:

Review

Each fix went through simplify-rust, rust-optimize, review and audit-tests. Every fixed arm was perturbation-verified, meaning each perturbation fails the intended tests. A fresh-context whole-branch review and /code-review high followed; their findings are fixed in 278880fa, f4b63250 and 09283fc1.

Follow-ups filed

Test plan

  • make pre-commit: BCA_GATE: pass (gate=pre-commit).
  • Full workspace suite with --all-features: 5,801 tests pass.
  • Patch line coverage vs main is 99% (1,476 / 1,482), against a 97.31% project figure. Every uncovered line is either a break on a grammar-required child or a disabled-language continue.
  • .bca-baseline.toml refreshed where a value moved.

dekobon added 15 commits October 7, 2026 21:51
C++ and Mozcpp counted `<=` `>=` `==` `!=` `<=>` in an ungated arm,
so declaring `operator<=`, `operator>=`, `operator==`, `operator!=`
or `operator<=>` scored one spurious ABC condition per overload,
while `<` / `>` were already gated on a `binary_expression` parent.
This is the C++ instance of the C# defect fixed in #1420.

All seven tokens now share the `binary_expression` allowlist. A
grammar.json sweep of tree-sitter-cpp 0.23.4 and the vendored
mozcpp grammar is recorded at the arm: `#if A <= B` is aliased onto
`binary_expression` and keeps counting; `operator_name` and fold
expressions do not count, consistent with `<` / `>`. C and ObjC
emit these tokens only from comparisons and have no overloading,
so their arms stay as they are, with a note saying why.

Corpus snapshots move only in openfst operator==/operator!=
definitions and declarations, all downward.

Fixes #1448
The #1448 gate dropped comparison folds to zero. A fold applies its
comparison, so `(a <= ...)` and `(a < ...)` now score one condition,
and the binary fold `(0 == ... == a)` also scores one: the token must
be the `fold_expression`'s `operator` field, which names only one of
its two spellings.

The applied-comparison test moves into `cpp_comparison_is_applied`,
shared with Mozcpp and keyed on kind names like the other shared C++
helpers, which also keeps `compute` under its halstead.effort soft
limit. The overload fixture gains a defaulted C++20 `operator==`.
A subject-less `case` lists ordinary boolean expressions, evaluated as
`if` predicates, so a comparison in a `when` clause is already an ABC
condition through the token arm. The `When` arm added a blanket one on
top: `case; when x > 5 then 1; else 0; end` scored 3 against its `if`
analogue's 2. Parenthesised and negated comparisons, `&&` / `||`
chains, `(x in T)` pattern tests and ternaries double-counted the same
way.

A subject-less clause now pays for its decision only when no other arm
already did, decided by `ruby_condition_scores_itself`, which peels
`(...)` and `!` / `not` and asks whether a comparison, chain, pattern
test or ternary remains. A subject-ful clause keeps its per-clause
count, since its `pattern === subject` comparison is written nowhere.

The #1421 template routed the condition through the `if` slot instead.
Measured here, that slot scores 0 for `Foo::Bar`, `self`, `-x`,
`defined?(x)`, `(y = x)`, `*xs` and `x + 1`, so delegating would have
dropped each of those clauses from 1 to 0. That gap in the `if` slot
is filed as #1520.

A clause listing several patterns pays unless any pattern scored
itself, so `when a, x > 1` and `when x > 1, a` agree. `when x > 1,
x < -1` reads 2 against cyclomatic's single decision per clause, as
Kotlin's multi-alternative entries do.

The comparison token list moves into `ruby_comparison_kinds!` so the
token arm and the new predicate cannot disagree.

Fixes #1453
A subject-less `when a, b` is an implicit `||`, but the clause paid
one condition unless any pattern had already scored itself, so
`when a, b` and `when a, x > 1` read 1 against their `if a || b` /
`if a || x > 1` analogues' 2, while `when x > 1, x < -1` matched its
analogue at 2. Each pattern now scores once unless its own arm
already counted it, which matches all three analogues and stays
independent of pattern order. Subject-ful clauses are unchanged.
A bare `/re/` or `m{re}` matches the implicit `$_`, a relational
operator with no operator token. It reached ABC only through
`perl_bool_terminal_kinds!()`, which walkers consult inside a boolean
slot, so `my $r = /^#/` scored 0 against `my $r = ($x =~ /^#/)`'s 1.

Move both kinds out of the terminal set onto a gated arm that counts
the pattern wherever it is written, except where it is bound by
`=~` / `!~` (that token already counts) or is `split`'s first
argument, a delimiter rather than a match. In-slot shapes such as
`if (/x/)` and `/x/ && $y` keep their scores. `s///` and `tr///`
stay out of scope (#1475).

Fixes #1467
Six languages spelled their wrapper set twice: once in the
`<lang>_inspect_container` peel loop and again in
`<lang>_count_condition`, which decides what to hand that peel. Kotlin,
Groovy and C# each had the two lists drift apart and score a valid
predicate zero (#1459, #1466, #1463).

Ruby, Go, Rust, Python, Lua and Java now carry a
`<lang>_wrapper_operand` that returns one peel step, and both the loop
and the condition slot ask it. Python's returns a bare node: it peels
parentheses only, so it has no proves-boolean flag to report. Step
semantics are unchanged, including the positional `child(1)` reads.

PHP, Perl and C++ restated nothing (their slot already falls through to
the peel) and are left alone. A 337-cell wrapper matrix (`b`, `(b)`,
`!b`, `!!b`, `(!b)`, `!(b)`, `((b))`, `-b`, `(-b)`, plus `not` forms,
across each language's if, loop, chain, ternary and guard slots)
measured identical before and after.

The new tests pin routed, declined and proves-boolean answers per
language; a perturbation sweep of each half fails them. The Java
`java_inspect_container` cognitive baseline entry is dropped, as the
function no longer breaches.

Fixes #1470
C++ spells `!`, `&&`, `||` and `!=` equally as `not`, `and`, `or` and
`not_eq` ([lex.digraph]), and tree-sitter-cpp and the vendored
tree-sitter-mozcpp give each alternative its own token kind. ABC
matched only the symbols, so `if (not b)` scored 0 against 1 for
`if (!b)`, `b and c` scored 0 against 2 for `b && c`, and
`b not_eq c` scored 0 against 1 for `b != c`.

The shared peel now accepts `not` beside `!`, and the Cpp and Mozcpp
dispatchers add `And` / `Or` to the Rule 9 chain arm and `NotEq` to the
comparison arm. Each alternative occurs in a subset of its symbol's
grammar productions, so the existing parent gates decide it unchanged.
C and Objective-C have no such tokens (`not` is an <iso646.h> macro
the parser reads as an identifier), so their scores do not move.

Cyclomatic, cognitive and Halstead have the same gap; that is left
to a follow-up.
`ruby_condition_scores_itself` restated the `(...)` / `!` / `not`
peel that `ruby_wrapper_operand` now owns. It asks the peel instead,
so the two cannot drift apart. Behaviour is unchanged.
tree-sitter counts a comment among a node's children and marks it
named, so every ABC slot read by fixed child index or as "the first
named child" could land on a comment and score the condition zero:
`if (/*c*/ b)`, `if /*c*/ (b)`, `do {} while /*c*/ (b);`,
`return /*c*/ !b;`, `(( /*c*/ b))`, `! /*c*/ b`. Two read sites were
affected in nearly every language.

Slot reads now go through the grammar's field (`condition`, `value`,
`right`, `body`, Elixir's `operator`), or, where the grammar names
none, the new `wrapped_operand` helper: the first named child that is
not an `extra`. That covers C, C++, Mozcpp, ObjC, C#, Java, Groovy,
the JS family, PHP, Python, Rust, Go, Lua and Perl. The field reads
also fix two preambles: a C++ init-statement (`if (int y = g(); b)`)
and a Rust loop label (`'a: while b`) both scored zero.

Peel steps through `(...)` and negations take the operand the same
way in every language, which also repairs Ruby's subject-less `when`
(`when ( # c⏎ x > 1)` counted twice), Perl's `split( # sep⏎ /,/, $s)`
delimiter test, Perl's last-operand read of a trailing comment, and
Elixir's `=` read behind a comment (an assignment).

Two tests that pinned the old zero for C# and Groovy now assert the
fixed value. Corpus snapshots move only `abc.conditions`, upward: two
pdf.js `if (/* … */ x)` functions, and serde's `while try!(…)`, whose
2015-edition macro parses to an ERROR at child(1) so the field read
now finds the real condition. The self-scan baseline is refreshed:
Go's and ObjC's `compute` effort fell.

Fixes #1455
Record the metric changes from #1448, #1453, #1455, #1467 and #1470
under [Unreleased] Fixed.
Ruby's paren peel read the first statement of `(x; y)`, but the
sequence evaluates to its last; it now reads the last operand, as
Perl's list peel does, so `if (x; x > 1)` scores 1 and
`if (x > 1; x)` 2.

Docs: the book gains a Ruby subject-less `when` row and notes the
C++ overload, fold and alternative-token rules; a test-module doc
no longer lists Perl's bare matches in the terminal set; the
CHANGELOG names the kenlm snapshots and the Elixir assignment.
C#'s declarator arm, Ruby's statement-sequence peel and Perl's list
peel each spelled "last child that is named and not an extra". They
now share `last_operand` beside `wrapped_operand`. Also separates the
Lua and Python peel comments from the module overview and fixes a
member count in a Perl test comment.
`ruby_condition_scores_itself` treated every `&&` / `||` chain as
already counted, but the Rule 9 walker counts plain operands only, so
`case; when -a && -b` and `when (a = 1) && (b = 2)` scored 0 against
1 on main and against `when -a`'s 1. A chain now counts as paid only
if some operand, at any depth, is a comparison, pattern test, ternary
or a plain operand the walker counts. The walk is a worklist so a long
left-nested chain cannot recurse deeply.
@codecov

codecov Bot commented Oct 8, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 98.08%. Comparing base (c5a847d) to head (775582e).
⚠️ Report is 17 commits behind head on main.

Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##             main    #1524      +/-   ##
==========================================
+ Coverage   98.01%   98.08%   +0.06%     
==========================================
  Files         359      359              
  Lines       96210    97148     +938     
  Branches    95779    96717     +938     
==========================================
+ Hits        94303    95283     +980     
+ Misses       1222     1210      -12     
+ Partials      685      655      -30     
Flag Coverage Δ
rust 98.07% <100.00%> (+0.06%) ⬆️

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

Files with missing lines Coverage Δ
src/metrics/abc.rs 99.71% <100.00%> (+0.02%) ⬆️
src/metrics/abc/c.rs 95.91% <100.00%> (+2.16%) ⬆️
src/metrics/abc/cpp.rs 97.42% <100.00%> (+1.81%) ⬆️
src/metrics/abc/csharp.rs 95.78% <100.00%> (+1.26%) ⬆️
src/metrics/abc/elixir.rs 90.75% <100.00%> (+1.86%) ⬆️
src/metrics/abc/go.rs 97.11% <100.00%> (+1.92%) ⬆️
src/metrics/abc/groovy.rs 95.48% <100.00%> (+1.12%) ⬆️
src/metrics/abc/java.rs 97.14% <100.00%> (+0.57%) ⬆️
src/metrics/abc/js_family.rs 94.73% <100.00%> (+2.04%) ⬆️
src/metrics/abc/kotlin.rs 90.83% <100.00%> (+0.76%) ⬆️
... and 8 more

... and 3 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 wrapper peels spelled a grammar-required child as `child?`, and
the C++, JS and PHP peel loops had a second `let ... else { break }`
after the non-wrapper exit, so each carried a `None` branch no valid
tree reaches, reported as uncovered patch lines. The peels now map
the optional child (`wrapped_operand(node).map(...)`, guards via
`is_some_and`), the three loops end through one exit, and two
table-driven tests filter disabled languages instead of
`continue`-ing past them. Behaviour is unchanged.
@dekobon
dekobon merged commit 775582e into main Oct 8, 2026
49 checks passed
@dekobon
dekobon deleted the fix/batch-2026-10-07 branch October 8, 2026 11:40

This branch was successfully deployed

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