Skip to content

fix: batch 2026-08-26 — ten metric, CLI and gate fixes - #1357

Merged
dekobon merged 17 commits into
mainfrom
fix/batch-2026-08-26
Aug 29, 2026
Merged

fix: batch 2026-08-26 — ten metric, CLI and gate fixes#1357
dekobon merged 17 commits into
mainfrom
fix/batch-2026-08-26

Conversation

@dekobon

@dekobon dekobon commented Aug 29, 2026

Copy link
Copy Markdown
Owner

Summary

Batch fix of ten open issues on one integration branch, each fix taken through simplify / optimize / review / test-audit, followed by a /code-review max --fix pass over the whole branch whose confirmed findings landed as four follow-up commits.

Issue Fix
#1345 check-safety-doc-pin scans every //! line; a multi-line #![allow(…)] can no longer truncate the scan
#1306 bca check --paths-from - materializes the seed list once, so [check.exclude] globs anchor correctly with stdin input
#1303 bca metrics / bca ops stdout emits documents in walk order via a reorder buffer — byte-identical at --jobs > 1
#1287 Additive SkipReason + read_file_with_eol_classified; the CLI names the gate that skipped a file instead of calling everything "empty"
#1270 nexits counts Ruby raise/exit, Perl die/exit, Tcl/iRules error (+ Tcl throw); parity guard revived
#1293 PHP type and qualified-name wrappers no longer double-count their leaves in Halstead N2
#1263 JS-family / C# / Groovy member-expression and qualified-name containers count leaves only; #x private fields gain their operand
#1283 JS-family var / let / const (and JS using) declarations count as LLOC, with for-header and export carve-outs
#1247 The unit's sloc / blank count leading blank rows; line_span is the single owner of the unit's row span (retires the #1087 carve-out)
#1301 An inline C++ friend no longer contributes to the class WMC (Checker::is_non_member_function)

Metric values move for JS/TS/TSX (lloc, Halstead, MI), PHP (Halstead, MI), Ruby/Perl/Tcl/iRules (nexits), C++ classes with inline friends (wmc), and any file opening with blank lines (sloc/blank/MI). Each shift is called out in its CHANGELOG entry.

Review follow-ups (last four fix/refactor commits)

  • JS-family LLOC: the for carve-out also swallowed a brace-less for body; keyed on the initializer field now. TS/TSX stop counting bare declare const.
  • import.meta / new.target lost their only operand when the containers were dropped — MetaProperty added.
  • nexits: Ruby Kernel.raise / Kernel.exit / exit!, Perl CORE::die / CORE::exit, Tcl exit.
  • Ordered-stdout plumbing: error-path write failures tallied; constructor instead of two-phase init; one stdout lock per drain; --paths-from materialization shared between the walk and bca check.

Reported, not fixed here: drain-batch write-failure under-tally (needs a release signature change), inline friend mis-parse upstream in tree-sitter-cpp, the Perl fat-comma (die => 1) artifact, and the stdout-vs---output ordering difference.

Follow-up issues filed by the sibling sweeps

#1351 bash, #1352 groovy, #1353 ruby, #1354 irules/tcl, #1355 perl (Halstead container/leaf double-counts); #1356 objc @implementation C helpers weighted into class WMC (anchored by FIXME(#1356)).

Validation

Fixes #1345
Fixes #1306
Fixes #1303
Fixes #1287
Fixes #1270
Fixes #1293
Fixes #1263
Fixes #1283
Fixes #1247
Fixes #1301

https://claude.ai/code/session_01SNCAwAj8i8J66tyWa9xZSR

dekobon added 16 commits August 26, 2026 15:22
`module_doc_lines` collected the leading `//!` block and skipped blank
lines and lines starting `#![`, so a *multi-line* inner attribute ended
the scan: its continuation and `)]` lines are ordinary non-doc code.
Every `=X.Y.Z` citation below the cut became invisible. Both directions
were live — a stale citation below it passed with the gate printing OK
(the false-clean the gate exists to prevent), and a file whose only
citation sat below it failed the "no longer cites" branch while being
correct.

Drop the stop condition rather than lexing bracket depth: `//!` is only
legal as a module doc, so scanning the whole file yields the rule this
one-file gate wants anyway — any `=X.Y.Z` in a module doc in `node.rs`
is a claim about the pin — with nothing left to truncate.

`test_code_ends_the_block` becomes `test_code_does_not_end_the_block`
and now pins the widened contract via a nested `mod` doc. Four cases
cover the fix, all four verified failing against the old scanner: the
multi-line attribute at the extractor level, the widened contract, and
both gate-level directions. The stale-citation one asserts on the
message, not the exit code, since a truncating scan also exits 1 there
via the other branch.

Fixes #1345

Claude-Session: https://claude.ai/code/session_01SYCdx8bWc2c1ZzpZYvJTRP
`apply_check_exclude` rebuilt the gate's seed list by re-reading
`--paths-from`. `read_paths_from` resolves `-` to stdin, which the
walk had already drained, so the second read returned an empty list:
every violation anchored against the `--paths` set alone and a
`[check.exclude]` / `--check-exclude` glob silently failed to match.
A `git diff --name-only | bca check --paths-from -` gate therefore
failed on files the project had exempted.

`run_check_walk` now resolves `--paths-from` into `globals.paths`
once, before the walk, and carries the materialized list out in
`CheckWalk::seeds` for `apply_check_exclude`. The second read is gone
rather than special-cased. `CheckExcludes::resolve` still
short-circuits first, so the no-exclude run does no glob-set build and
no `--check-exclude-from` read; the remediation footer still renders
from the pre-materialization `GlobalOpts` clone and keeps printing the
caller's `--paths-from -` spelling.

Fixes #1306
`bca metrics -O json` / `bca ops -O json` wrote each per-file document
from the worker that finished it, so redirecting stdout to a file gave
a different byte sequence on every run at `--jobs > 1`: forty trivial
Python files measured six distinct hashes over six runs, and twenty-four
Rust files ten over ten. #1244 could sort the `--output <FILE>`
aggregate because it holds every result before serializing; stdout has
already written each document by then.

Rendering is now split from emission. `GenericFormat::render` (and
`render_csv`) hand the document's bytes back instead of writing them,
and a reorder buffer keyed on each file's index in the walk's resolved
list releases them in that order. The buffer is installed by
`run_walk_tallying` for the walks `Config::streams_documents_to_stdout`
identifies, rather than by each command runner, so `metrics` and `ops`
cannot drift apart on it.

Every dispatched path releases its slot, document or not — a skipped,
unreadable, or unparseable file releases an empty marker — so the drain
never stalls behind a file that emitted nothing, which is the one way
this deadlocks in production and never in a test. `flush_remaining`
backs that up after the walk, ahead of the runner's own failure report,
so a slot a panicked worker never released costs its successors their
position rather than their existence.

The emitted bytes are unchanged: `-j 8` output is now byte-identical to
the pre-fix `-j 1` output on pdf.js, and content-identical on
DeepSpeech. Throughput over DeepSpeech (13,583 documents) is unchanged
within noise; peak RSS rises from ~280 MB to ~350 MB there, the
documents that complete while an earlier file is still being analyzed.

Fixes #1303
`bca` printed `skipping empty file: <path>` for every file the
library declined to read, but emptiness is only one of four gates:
a 1-3 byte source, a multi-kilobyte binary and a UTF-16-BOM file
were all announced as empty.

The cause is library-side. `read_file_with_eol` collapses every
skip into `Ok(None)`, so no front-end can name the real cause.
Its `io::Result<Option<Vec<u8>>>` shape is public 2.x API, so the
classification arrives additively:

- `SkipReason` (`#[non_exhaustive]`, `Display`) with one variant
  per skip branch: `Empty`, `TooSmall`, `Utf16Bom`, `NotUtf8`.
- `read_file_with_eol_classified`, returning
  `io::Result<Result<Vec<u8>, SkipReason>>`.
- `read_file_with_eol` is now that call's `.map(Result::ok)`, so
  the two agree branch for branch by construction.

The read body moves to a private `read_gated` whose two failure
modes ride one `ReadStop` enum, letting every gate be spelled `?`
rather than hand-wrapped in the public nested-Result shape. That
lands its halstead.effort below the pre-change function's; the
baseline entry is renamed, not raised.

The CLI renders the reason through `warn`, which keeps owning the
severity prefix. The Python and web front-ends are untouched: the
web server never reads files, and the py batch path's `Ok(None)`
contract (#1238) would need a widening this issue does not want.

Fixes #1287
Ruby `raise`/`exit`, Perl `die`/`exit` and Tcl/iRules `error` leave a
function exactly the way Python's `raise`, Go's `panic` and Lua's
`error` do, but none of them has a dedicated grammar node and only the
latter three had a callee-text arm. A Ruby guard clause that raised
scored nexits 1 where the byte-equivalent Python scored 3.

Each is matched at the seam its siblings already used, re-derived
against the target grammar with `bca dump`:

- Ruby: a receiver-less `call` whose `method` identifier spells the
  builtin. `Checker::is_call` supplies the four visible `call` aliases.
- Perl: `call_expression_with_bareword`, which every call form nests
  exactly once, so no wrapper double-counts.
- Tcl and iRules: the leading word of a generic `command`. Tcl adds the
  8.6 `throw`; iRules does not, because TMOS runs a Tcl 8.4-derived
  interpreter with no such builtin.

Receiver'd calls (`obj.raise`, `$obj->die`), package-qualified callees
(`Carp::croak`) and the same words in argument position (`puts error`)
stay uncounted, each with a negative test. A bare argument-less Ruby
`raise` also stays uncounted: it parses as a plain identifier,
indistinguishable from a variable read.

`tests/parity/exit_cross_language_parity.rs` documented the gap as a
language property — four fixtures using two `return`s each, which would
have kept passing after this change without exercising it. They now use
`return` + the abrupt-exit builtin like the other 18 languages, so the
cross-language guard is live for all four; each new arm was verified by
perturbation to fail its unit test and the parity test.

Metric values shift for Ruby, Perl, Tcl and iRules functions using
these builtins.

Fixes #1270
`PhpCode::get_op_type` listed both the wrapper nodes of a PHP type
annotation and a qualified name AND the leaves those wrappers contain
as Halstead operands, so one occurrence billed the same bytes two to
five times: `int` scored 2, `?int` 3, `Foo\Bar\Baz` 5, and `?A\B` 6
for two identifiers. #1259 fixed the `$variable` half of the same
shape; this is the rest of it.

Two design calls, recorded in the arm comments because the code cannot
carry them:

* A type's operand is its innermost concrete form. `?int` is the
  operand `int` — `?` is already an operator, so folding it into the
  operand text bills nullability across both Halstead halves and
  splits one type into `int` and `?int` in the vocabulary.
* A qualified name's operands are its components. `Foo\Bar\Baz` is
  `Foo`, `Bar`, `Baz` around two `\` operators, which is exactly how
  PHP's own `::` and `->` already read here and how Rust's
  `foo::bar::baz` reads. The whole-path reading would plant a
  vocabulary entry per prefix.

The direction differs per construct, and deliberately. `named_type`,
`optional_type`, `union_type`, `intersection_type`,
`disjunctive_normal_form_type`, `qualified_name`, `relative_name` and
`namespace_name` drop out of the operand arm entirely, because the
grammar requires each to contain the node that now carries the
operand. `primitive_type` cannot: `bca dump` shows it is childless for
`callable`, `iterable`, `mixed`, `void`, `false` and `true`, so
dropping it would score those six types zero — grammar-dispatch §6.
There the wrapper keeps the operand and the keyword child is
suppressed under it, parent-scoped so the `array` heading an
`array(...)` literal still counts.

Metric values shift for PHP: lower Halstead N2/n2 and therefore lower
volume, difficulty, effort and bugs, and a higher maintainability
index. The two reproducers go 23 -> 15 and 14 -> 9 operands. The six
PHP corpus snapshots are refreshed in the submodule bump, the
`get_op_type` self-scan entry and the php.rs rustfmt-bail count are
rebaselined for the added arms.

Each guard is pinned by test-via-perturbation: re-adding the wrappers
to the operand arm fails three tests, un-suppressing the keyword leaf
fails four, making the keyword suppression unscoped fails only
`php_primitive_type_keyword_guard_is_parent_scoped`, and dropping
`PrimitiveType` instead of gating its leaf fails
`php_childless_primitive_types_still_count`.

Fixes #1293
The JS family, C# and Groovy classified *container* expression nodes as
Halstead operands while the walker independently reached and classified
every leaf inside them, so one member access billed three vocabulary
entries where the rest of the workspace bills two plus an operator:

    var r = a.b;   ->  operands a, b, a.b   (should be a, b)

Removed from the operand arms: `MemberExpression`/`2`/`3` in the shared
`impl_js_family_get_op_type!` body and `MemberExpression4` /
`NestedIdentifier` from the TS and TSX extras; `QualifiedName`,
`GenericName` and `AliasQualifiedName` in C#; `QualifiedName` and
`QualifiedType` in Groovy. Classification never stopped descent, so the
inner leaves were already counted and the removal deletes only the
composite entry.

Two things the containers were masking, both closed here:

* `PrivatePropertyIdentifier` (`#x`) was in no operand list, so the
  composite had been the only count for `this.#x` and the declaration
  `#x = 1` counted nothing at all. It joins the operand arm, or the
  bare deletion would have regressed private fields to zero.
* Groovy's `QualifiedType` never fired, because the runtime emits the
  alias `QualifiedType2` the arm did not name. That lesson-2 miss is
  why the issue recorded Groovy as already compliant; its
  `QualifiedName` half really did double-count `package com.example`.

C, C++, Java, Rust, Python, Go, Kotlin, Ruby, Lua and PHP were probed
with `bca ops` and are all already leaves-only, so this is convergence
on the existing convention rather than a new one. It is recorded in the
macro's doc comment.

Metric values shift: `n2` and `N2` fall (-29% / -18% over the pdf.js
and C# corpora), volume falls and `mi` rises; `difficulty` and
`effort` rise, because `n2` falls faster than `N2`. 331 integration
snapshots refreshed, value-only.

Fixes #1263
`variable_declaration` (`var`) and `lexical_declaration` (`let` /
`const`) had no LLOC arm in any of the four JS-family modules, so a
declarations-only file reported `lloc 0` and every real JS/TS file
under-reported LLOC by one per declaration. Java's
`LocalVariableDeclaration`, Rust's `let` and Python's assignments all
count the equivalent construct.

The two JavaScript modules also count `using_declaration`, the third
member of the grammar's `declaration` supertype that runs an
initializer; the pinned TypeScript and TSX grammars emit no such node.

Two enclosing constructs already count the row, so the declaration is
carved out under either — mirroring Java's for-header rule:

  * a classic `for (let i = 0; ...)` header, whose `ForStatement` has
    its own arm;
  * an `ExportStatement` (`export const a = 1;`), reached by an
    ancestor walk rather than a parent check because TypeScript
    interposes an `ambient_declaration` for `export declare const`.

`for (const x of ...)` and `for (var k in ...)` need no carve-out: the
grammar inlines the keyword into `for_in_statement` and emits no
declaration node. `StatementBlock` stops the walk, so a declaration in
a function or loop body still counts.

Metric values move: `loc.lloc` and its aggregates rise for JS, MozJS,
TypeScript and TSX input. 351 pdf.js corpus snapshots refreshed; the
churn is LLOC-only.

Fixes #1283
The file-level unit anchors its reported span at line 1 because the unit
is the file (#1195), but its `sloc` row span was still measured from the
root node, which tree-sitter starts at the first token. Blank rows above
that token therefore counted in neither `sloc` nor `blank`, while
identical rows one line lower counted in both: `"\n\n\nfn a() {}\n"`
reported `sloc 1, blank 0` over a reported span of 1..4, and the same
file shifted down by a leading comment reported `sloc 4, blank 2`.

Anchor the unit's `sloc` span to the span the unit reports, at space
finalization. Three places held a copy of where the unit's rows start —
`line_span`, `loc::shared::init`, and the synthetic-Unit seed — and
#1195 updated only the first. Deriving the span from the finished
`FuncSpace` leaves `line_span` the sole owner, so the synthetic seed is
deleted rather than corrected. Doing it in the walker also keeps the
twenty-odd per-language `Loc` impls out of it: none knows the space
kind, and re-widening the trait with a unit flag is what #1067 removed
for perf.

This also retires the #1087 carve-out. Whitespace-only files reported
`sloc 0` when newline-terminated because most grammars collapse the root
to a zero-width point at end-of-input; with the span anchored, where the
root starts is no longer observable in LoC and all twenty-five grammars
agree. `whitespace_only_input_is_the_documented_carve_out` is renamed
and now pins the absence of the split it used to enumerate.

Metric values move for files whose first token is not on line 1: `sloc`
and `blank` rise by the leading blank rows, their averages and maxima
follow, and `mi` falls through its `ln(sloc)` term. One corpus snapshot
and three CSV snapshots move; nested spaces and `exclude_tests` pruning
are unaffected, both pinned.

Fixes #1247
A C++ `friend` with an inline body is written inside the class braces
but is a free function the class merely grants access to. `npm` never
counted it as a method, yet the space tree nests its `Function` space
inside the class space and `wmc` weighted every such space, so the two
metrics disagreed about the same class — the #1258 divergence surviving
for `friend`.

Reparenting the friend's space out of the class was considered and
rejected: the walk is a single-pass stack, so a space is a child of
whichever space was open when the walk reached it, and
`tests/parity/space_span_containment.rs` requires a child's span to sit
inside its parent's. A friend's body is physically inside the class
braces, so no placement outside the class can satisfy both.

Instead the walk records membership at the one point that still holds
the node: `Checker::is_non_member_function` (default `false`, overridden
by Cpp and Mozcpp for a `function_definition` under a
`friend_declaration`) is consulted in `open_func_space`, and
`wmc::Stats::merge` declines a marked child's contribution to the
enclosing container. Only WMC consults it; the friend keeps its own
`Function` space and its own metrics, `nom` still counts it, and the
file-level cyclomatic sum is unchanged.

One predicate rather than a byte-less / `_with_code` pair, since the
pair's silent forwarding is what makes a wrong-spelling call site read
as correct (`.claude/rules/grammar-dispatch.md` section 7).

The sibling sweep found the same shape in Objective-C — a `static` C
function inside `@implementation` — filed as #1356 with a FIXME at the
ObjC `Wmc` impl. Java/Groovy `static {}`, Kotlin `init`, TypeScript
`static {}`, C# and Rust local functions, and every nested-container
case were verified fine.

Metric values move: `wmc.class_wmc` / `class_wmc_sum` / `total` fall by
each inline friend's cyclomatic. Five DeepSpeech snapshots refreshed in
the submodule.

Fixes #1301

Claude-Session: https://claude.ai/code/session_01SYCdx8bWc2c1ZzpZYvJTRP
Add the Unreleased entries for the four fixes whose agents reported
entry text instead of editing the file (#1287 API + diagnostic,
#1306 check-exclude stdin, #1303 stdout ordering) and fold the #1345
scanner clause into the existing safety-doc-pin gate entry.
New lesson #90 (single-consumption source re-read, #1306) and
sub-examples merged into #2 (#1263), #63 (#1301) and #59 (#1247).
Two standing obligations go to the rule files instead:
--no-fail-fast for multi-target perturbation sweeps (#1270) in
testing.md and the keeper-node heuristic (#1293) in
grammar-dispatch.md.

Claude-Session: https://claude.ai/code/session_01SNCAwAj8i8J66tyWa9xZSR
The #1283 declaration arm carved out any declaration under a
for_statement, so a brace-less for body (`for (...) var s = i;`)
was mistaken for the header and never counted. The carve-out now
recognises the header as the initializer field. TS/TSX also stop
counting bare ambient declarations (`declare const x: T;`), which
execute nothing, matching the other `declare` forms.

Claude-Session: https://claude.ai/code/session_01SNCAwAj8i8J66tyWa9xZSR
Dropping MemberExpression* in #1263 removed the only operand that
covered `import.meta` / `new.target`; their leaves are anonymous
tokens no arm classifies, so the meta-object contributed nothing.
Refreshes the two pdf.js snapshots that carry `import.meta`.

Claude-Session: https://claude.ai/code/session_01SNCAwAj8i8J66tyWa9xZSR
Ruby `Kernel.raise` / `Kernel.exit` / `exit!`, Perl `CORE::die` /
`CORE::exit`, and Tcl `exit` are the builtins spelled explicitly and
were left out of the #1270 arms. CHANGELOG entries for #1283, #1263
and #1270 updated for the corrected behaviour.

Claude-Session: https://claude.ai/code/session_01SNCAwAj8i8J66tyWa9xZSR
Tally a stdout write failure raised by the dispatch-error slot
release instead of dropping it. Construct the reorder buffer through
`OrderedStdout::new` (no two-phase OnceLock init), hold an Option
rather than an empty-Vec sentinel, and take the stdout lock once per
drain rather than per document. Materialize `--paths-from` through
one `path_io` helper shared by the walk and `bca check`, and pass
the seed list to `apply_check_exclude` by value.

Claude-Session: https://claude.ai/code/session_01SNCAwAj8i8J66tyWa9xZSR
@codecov

codecov Bot commented Aug 29, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 99.75639% with 2 lines in your changes missing coverage. Please review.
✅ Project coverage is 98.44%. Comparing base (f6c144e) to head (1d9cf5c).

Files with missing lines Patch % Lines
src/tools.rs 95.23% 1 Missing and 1 partial ⚠️
Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##             main    #1357      +/-   ##
==========================================
+ Coverage   98.42%   98.44%   +0.01%     
==========================================
  Files         278      278              
  Lines       73736    74392     +656     
  Branches    73306    73962     +656     
==========================================
+ Hits        72574    73233     +659     
+ Misses        756      753       -3     
  Partials      406      406              
Flag Coverage Δ
python 100.00% <ø> (ø)
rust 98.43% <99.75%> (+0.01%) ⬆️

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

Files with missing lines Coverage Δ
src/checker.rs 96.72% <100.00%> (+0.02%) ⬆️
src/checker/cpp.rs 100.00% <100.00%> (ø)
src/checker/mozcpp.rs 100.00% <100.00%> (ø)
src/getter.rs 94.66% <ø> (ø)
src/getter/csharp.rs 91.66% <ø> (ø)
src/getter/groovy.rs 95.00% <100.00%> (ø)
src/getter/php.rs 97.05% <100.00%> (+0.28%) ⬆️
src/getter/tsx.rs 96.42% <ø> (ø)
src/getter/typescript.rs 96.42% <ø> (ø)
src/metrics/halstead.rs 99.88% <100.00%> (+<0.01%) ⬆️
... and 13 more

... and 1 file with indirect coverage changes

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

Every published version of `bisync`, a transitive dependency of
`gix-protocol` 0.64, was yanked on 2026-08-24, so the cargo-deny
advisories check fails on any lockfile still holding it and
`cargo update -p bisync` cannot satisfy `^0.3.0`. `gix-protocol` 0.65
dropped the crate, so advance `gix` 0.86 -> 0.87.1 and let the
gitoxide family follow (36 lock entries, `bisync` and `bisync_macros`
removed).

`gix-date` 0.16 takes its reference time as a `jiff::Zoned`; the
`--as-of` parser passes `gix::date::Zoned::now()` instead of
`SystemTime::now()`. No behaviour or public-API change.

Claude-Session: https://claude.ai/code/session_01SNCAwAj8i8J66tyWa9xZSR
@dekobon
dekobon merged commit 6e2cf18 into main Aug 29, 2026
51 checks passed
@dekobon
dekobon deleted the fix/batch-2026-08-26 branch August 29, 2026 01:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment