Skip to content

fix: batch 2026-08-30 — Halstead, ABC, LOC, NPM/NPA, nargs, preproc - #1403

Merged
dekobon merged 20 commits into
mainfrom
fix/batch-2026-08-30
Sep 9, 2026
Merged

dekobon merged 20 commits into
mainfrom
fix/batch-2026-08-30

Conversation

@dekobon

@dekobon dekobon commented Sep 9, 2026

Copy link
Copy Markdown
Owner

Batch fix of ten issues on one integration branch, followed by a
whole-branch review pass and its corrections.

Fixes

Issue Area Change
#1358 Halstead / Bash $"…" billed once: the string child is the keeper, the translated_string wrapper is not classified
#1359 Halstead / Ruby 1r, 1i, 1ri billed once as the rational / complex wrapper, whose text is the constant's identity
#1360 Halstead / Ruby subshell backtick delimiters no longer counted as operators; guard is parent-scoped so a backtick method still counts
#1361 Halstead / C++, mozcpp this is an operand; cpp/mozcpp parity test pins the clone
#1318 Halstead / Tcl, iRules a braced word is a block only where its command takes a script (SCRIPT_TAKING_COMMANDS); one-line switch patterns stay literal
#1297 ABC < / > score a condition only as a binary_expression operator, in six grammars (JSX tags, generics, C# operator <, Perl readlines, Lua attributes no longer count)
#1260 LOC multi-row string literals in Bash, Elixir, Tcl and iRules credit every spanned row to PLOC, as #778 did for eighteen languages
#1255 NPM / NPA, Ruby private / protected / public / module_function / private_class_method modelled as calls with arguments and retroactive scope, not a bare flag
#1236 nargs new nargs.value wire field (additive, #[serde(default)]); CLI extractor, report hotspot and wire projection share Stats::own_args(); the Python to_sarif binding reads it, restoring parity with bca check
#1304 preproc preproc documents emitted in sorted order, sorted at the serialize seam so public field types are unchanged

Fixes #1236
Fixes #1255
Fixes #1260
Fixes #1297
Fixes #1304
Fixes #1318
Fixes #1358
Fixes #1359
Fixes #1360
Fixes #1361

Review passes

Two whole-branch reviews ran after the per-issue pipelines:

  • The first found four real defects the per-issue reviews and
    audit-tests had missed (0c987134), plus a Tcl switch layout
    case where a one-line arm list reached the recogniser as a single
    command (de043cdd).
  • The second (fresh context) found no bugs; its three documentation
    findings are in 54a32232.

That experience is recorded as batch-fix Step 6c, lesson 93 (the
#1196#1236 gate/wire divergence), a new testing.md section on
perturbing the fixture, and a grammar-dispatch §6 tiebreak.

Validation

  • make pre-commit: BCA_GATE: pass on the branch tip before the two
    docs-only commits; those touched comments, Markdown and
    .rustfmt-bail-baseline.txt only (cargo fmt --check,
    rustfmt-bail and rumdl re-run clean).
  • Integration snapshots: big-code-analysis-output at 3cd242f2,
    already on its main.
  • .bca-baseline.toml and .rustfmt-bail-baseline.txt refreshed in
    the commits that moved them; the src/getter/ruby.rs 3 → 4 bail
    increase is explained in the baseline's notes.

dekobon added 19 commits August 30, 2026 21:18
`bca preproc` serialized `PreprocResults.files` and the three
`HashSet<String>` fields of `PreprocFile` straight off hash order, so
the document differed byte for byte between runs over an unchanged
tree: eight runs over a five-file fixture gave eight distinct hashes.
This was the last file destination in the #1244 / #1303 family still
nondeterministic, and unlike those two it moves identically at
`--jobs 1` — the variation is at the serialization seam, not in worker
arrival order.

Sort there rather than storing ordered containers. The four fields are
public on published structs and `get_macros` publishes
`HashSet<String>` as its return type, so a `BTreeMap` / `BTreeSet` swap
would be a source-level break reserved for the next major; four
`serialize_with` hooks leave the in-memory types, the public field
types, and the wire shape untouched and make only the byte order
deterministic. Both destinations benefit — one `to_string` feeds
`--output` and stdout alike.

The document uses two comparators, which is now stated rather than
incidental: `files` sorts its `PathBuf` keys component-wise, matching
what `metrics --output` and `ops --output` sort their aggregates on,
while the `String` name sets sort by bytes. The two disagree for paths
such as `a/x.h` and `a-b/x.h`, so the unit test carries that trio in
both positions and fails if either hook reaches for the other
comparator.

Fixes #1304
`translated_string: $ => seq('$', $.string)` gives the wrapper one
required `string` child and no text of its own beyond the `$`, but
`BashCode::get_op_type` classified both as operands. Every `$"…"`
therefore scored twice in N2 and planted a second n2 entry beside its
child: `a=$"x"` reported N2 3 for two operands.

The wrapper also defeated the #180 expansion guard.
`bash_string_has_expansion` inspects a node's own children, and a
`translated_string` has only `$` and `string`, so it never saw the
`simple_expansion` one level down and `b=$"$y"` counted both `$"$y"`
and `$y` — exactly the double count #180 removed, for the `$"…"`
spelling alone.

Drop the wrapper arm, as #1351 did for `command_name` in the same
match. The `string` is the keeper under grammar-dispatch §6 because it
is the node present for every spelling: in ordinary argument position
the grammar emits no wrapper at all, just a `$` token and a `string`.
Dropping it therefore makes each wrapper-bearing position agree with
argument position rather than newly disagree. The wrapper is never
childless, so nothing regresses to zero that a plain string did not
already — `$"${#}"` and `"${#}"` both score zero, and the new parity
test pins that.

The duplicated row-driver in the two Bash wrapper tables is extracted
into `assert_bash_wrapper_sheds_one_operand`, which re-derives both
`_before` columns from the current parse instead of trusting them.

Fixes #1358
An audit of the #1358 tests measured that the two `_before`-column
identities in `assert_bash_wrapper_sheds_one_operand` hold whatever
`get_op_type` answers: neutralising the final `assert_halstead_counts`
under the pre-fix arm leaves both wrapper tables green. They catch a
mistyped column, not a production regression, so the doc no longer
claims they are "re-derived from the current parse" as if that were
coverage.

Also record that only the command-name half of the parity test moves
under the pre-fix arm — dropping the argument-position assertions still
fails, dropping the command-name ones passes — so the reference half is
not mistaken for redundancy later.
tree-sitter-ruby parses a suffixed numeric literal as a wrapper over the
numeral it suffixes -- `rational: seq($._int_or_float, immediate('r'))`,
and `complex` over an `_int_or_float` or over an aliased `rational` for
the `ri` form. `RubyCode::get_op_type` listed `Integer`, `Float`,
`Complex` and `Rational` side by side in one plain operand arm, so every
level of the nest was billed: `a = 1r; b = 2i; c = 3ri; d = 4` reported
n2 11 / N2 12 for its eight operands, with `3ri` counted three times.

Suppress a numeral whose parent is a `complex` or a `rational`, the
compound-leaf guard of grammar-dispatch section 5.

The wrapper is the keeper rather than the leaf, which is the opposite of
the resolution #1351 and #1358 took for their Bash wrappers. Section 6's
keeper rule -- take the node present for every spelling -- exists to
stop a childless variant scoring zero, and neither choice can do that
here: both wrappers' numeral child is required, and an unsuffixed `4`
carries no wrapper at all. With that hazard absent the tiebreak is
operand identity. `1`, `1r`, `1i` and `1ri` are four distinct Ruby
constants and only the wrapper's span carries the suffix that says
which, so billing the leaf would file all four under the operand text
`1` -- n2 5 / N2 8 for eight operands. Every other language here spells
the suffix inside a single token, so keeping the wrapper is also what
makes Ruby agree with its siblings.

The regression test walks the nesting depths separately, because the
half-fix that guards the two leaf kinds and not the intermediate
`rational` still passes `1r` and `2i` and fails only the `ri` rows.

Fixes #1359
tree-sitter-ruby aliases both ends of a subshell literal to one backtick
token: `bca dump` at the pinned =0.23.1 shows all seven spellings -- the
backtick pair plus %x with {}, (), [], <>, || and !! -- emitting BQUOTE
for the opener and the closer alike. `RubyCode::get_op_type` lists
BQUOTE in its operator arm, so `u = `echo hi`` reported n1 2 / N1 3 for
a line whose only operation is the assignment. This is the delimiter
fabrication #1312 removed for regexes and #1256 for Elixir; `subshell`
was in neither sweep.

Suppress a BQUOTE whose parent is a `subshell`, the compound-leaf guard
of grammar-dispatch section 5. The `subshell` node itself remains the
operand, so the literal is not lost -- including the childless `` ``
spelling, which still bills its wrapper.

Gated rather than deleted, per section 6: a backtick is also a legal
Ruby method name, and at this grammar `def `(cmd)` and `s.`("ls")` wrap
their BQUOTE in a named `operator` node. Deleting the kind would score
that marker zero, which the positive-control test pins.

Parent, not ancestor, and here the difference is observable: a backtick
method inside a subshell's interpolation keeps `operator` as its parent
but gains the `subshell` as an ancestor, so an ancestor scan would
swallow it. The three mutants -- no guard, ancestor scan, and deletion
from the operator arm -- each fail a different subset of the five new
tests.

Ruby Halstead operator counts drop for code containing subshell
literals. The sweep the issue asked for found no sibling: no other
language fabricates a paired-delimiter operator through this shape.

Both baselines are refreshed in this commit. The new arm raises
`get_op_type`'s halstead.effort past its recorded value, and it is the
fourth stuck arm in a match that has been outside `cargo fmt` since
before this change.

Fixes #1360
`Cpp::This` / `Mozcpp::This` were in neither arm of
`CppCode::get_op_type` or `MozcppCode::get_op_type`, so a C++ `this`
contributed nothing at all: not an operator (defensible) and not an
operand (wrong). `struct S { int f() { return this->x; } }` billed `S`,
`f` and `x` and dropped the receiver. Same shape as #1316's character
literals, and the inverse of the #1351-#1355 wrapper/leaf over-counts,
so the fix adds an arm rather than deleting one.

Operand, not operator, on three grounds. Structure: `field_expression`
is `<receiver> -> <field>`, and `p->x` already bills operands `p` / `x`
around the `->` operator, so calling `this` an operator makes `this->x`
a binary operator with one operand and scores the identical AST
differently for the two spellings of the same receiver. Role: `this` is
a pointer rvalue standing exactly where a variable stands — `return
this;`, `*this`, `f(this)` — while `->` / `.` / `*` are the operators
already counted acting on it. Precedent: ten of the thirteen languages
here bill their self-reference as an operand, and the JS family's is the
only reasoned one among them, its `MetaProperty` note calling `this`
"one atomic operand". Keeping C++ an operand also keeps it agreeing with
ObjC, whose `self` is an operand and whose `.mm` files route to this
same impl.

Java, C# and Kotlin disagree, each having swept `This` in as one entry
in a run of keywords grouped by lexical class. That three-way split is
filed as #1380 rather than settled here.

Listing the leaf is safe against a grammar-dispatch section 5 double
count: `this` is childless in every position the pinned grammar admits
it — `this->x`, `(*this)`, `return this`, `f(this)`, `[this]`, `[=,
this]`, `decltype(this->x)`, and a constructor body after a
member-initialiser list — and none of `field_expression`,
`pointer_expression`, `lambda_capture_specifier` or `argument_list` is
classified. Neither generated enum aliases the rule, so there is no
`This2` for a single-variant arm to miss. C++23's explicit object
parameter does not reach the arm at all: the pinned grammar cannot parse
`this S&& self` and emits `type_identifier` plus an ERROR, a spelling
already operand-classed through `TypeIdentifier`.

Halstead `n2` / `N2` rise by one per `this`, moving the derived volume /
difficulty / effort / time / bugs and the three maintainability-index
variants. 157 DeepSpeech corpus snapshots refresh; all 1000 changed
operand values increased, `unique_operands` by exactly one per space,
and no operator count or space moved.

Fixes #1361
`cpp_this_is_an_operand_in_every_position` promised a completeness
property its fixture does not have: `delete this;`, `throw this;`,
`[&, this]`, a default member initialiser and `this->template f<int>()`
all parse cleanly and are absent from `CPP_THIS_POSITIONS`. None of
them is a distinct path — every one is the same childless `this:215`
leaf under an unclassified wrapper, which is exactly why the arm needs
no guard — so the fix is to name the property the walk actually
establishes, not to enumerate six more rows.

The wrapper check also now covers every ancestor rather than only
`chain.last()`, matching the claim both it and the `cpp.rs` arm comment
make. `(*this)` is the case that separates them: its
`parenthesized_expression` grandparent sits two levels above the leaf,
and classifying it (as the aliased `ParenthesizedExpression2`, id 363 —
not the `ParenthesizedExpression` 248 a reader would reach for) fails
the widened loop where the parent-only form passed.
The Tcl and iRules grammars spell a script body and a plain literal
with one kind, `braced_word`. #1314 guarded the `{` of the literal form
the grammars special-case (`braced_word_simple`), which left every
other value position fabricating a block: `lappend x {a b}` and
`puts {c d}` each reported a `{}` operator for a block the line does
not contain, where the synonym `set x {a b}` reported none.

No kind-scoped arm can separate the two roles, so recognise them
out-of-band by the enclosing command's leading word (grammar-dispatch
section 9). A braced word filling a slot of a construct the grammar
models is a script; one passed to a command in
`SCRIPT_TAKING_COMMANDS` (`after`, `eval`, `for`, `on`, `switch`,
`time`, `trap`, `uplevel`) is a script; a defaulted `proc` parameter's
value and a braced word in the command-name position are data.
Everything else is a value and its `{` is a quote, not an operator.

An unrecognised command therefore defaults to *value*. The
script-taking set is closed and small — Tcl has no user-extensible
control structures — while the value-taking set is open, holding every
user proc and package command. Defaulting the other way fabricates;
this way can only omit one `{}` occurrence whose vocabulary entry any
real block in the file already carries.

The rule revises the operator and nothing else. Suppressing a value
word's contents as well would make `lappend x {a b}` score like
`set x {a b}`, which is right for that line and wrong in general: a
braced argument to an unrecognised command is as often real code as a
list. Built and measured on a file holding an `oo::class` body, a
`tcltest -body` and an `apply` lambda, it took n1 5 -> 2, N1 8 -> 2,
n2 25 -> 15 and N2 32 -> 15, each body collapsing into a single
operand. So the classifier withdraws a claim it cannot support and
never discards code the walk has read. The residual asymmetry is filed
as #1382; keeping to the brace also keeps the test O(1), where an
ancestor scan measured quadratic on a deeply nested `expr`.

Tcl's `switch` needs one extra step: the grammar models it with no node
of its own, so the arm list parses as a command named after the first
pattern and its bodies would read as that command's literals. The
rescue is scoped to the arm's arguments, so a braced *pattern* stays a
literal. iRules models `switch` with `switch_arm` children and never
reaches it.

The recognition needs the source bytes, so it lands in a
`get_op_type_with_code` override in both getters -- the spelling the
walk calls. `get_op_type` keeps answering the byte-less question
unchanged.

Fixes #1318
The comparison operator and at least one non-comparison construct are
the same bare anonymous token in every grammar named here, so six ABC
dispatch arms scored markup, type syntax and declarations as decisions.
Re-measured at the pinned grammars, `abc.conditions` before -> after:

  TSX             JSX tag delimiters                     6 -> 0
  JavaScript      JSX tag delimiters                     6 -> 0
  Mozjs           JSX tag delimiters                     6 -> 0
  Lua             `local x <const> = 1` attributes       4 -> 0
  C#              `operator <` / `operator >` overloads  4 -> 2
  Kotlin          `super<A>.g()` qualified super call    2 -> 0
  Perl            `<FH>` / `<$fh>` readlines             2 -> 0

Take the allowlist polarity #1274 chose for Java and Groovy, and that
C, C++, Objective-C, mozcpp, Rust and Go already used: count the token
only when its parent is the construct that applies it. A denylist is a
coverage claim a grammar bump can silently invalidate
(`.claude/rules/grammar-dispatch.md` §1), and each of these four was
already incomplete. TypeScript and TSX denied `type_arguments` and
`type_parameters` and left three JSX productions counting; Kotlin
denied the same two and left `super_expression`; C# denied
`type_argument_list`, `type_parameter_list` and
`function_pointer_type` and left `operator_declaration`; JavaScript,
Mozjs, Lua and Perl had no gate at all, with `LT` / `GT` sitting in the
unconditional condition arm.

C# takes a two-sided gate rather than the one-entry allowlist, because
its `relational_pattern` (`x is > 0`, and a `> 5 =>` switch-expression
arm) is a genuine comparison living outside `binary_expression`; a
one-entry allowlist would have under-counted it, the inverse error and
the harder one to notice. Preserving that count also preserves a
pre-existing double charge against the arm that owns the pattern,
filed as #1383 rather than settled here as an unmeasured behaviour
change riding along.

Each gate rests on a `grammar.json` sweep of its pinned crate,
enumerating every production that emits a bare `<` or `>` with hidden
`_`-rules resolved to their visible parents: six productions in
tree-sitter-typescript 0.23.2, four in tree-sitter-javascript 0.25.0
and in the vendored mozjs fork, two in tree-sitter-lua 0.5.0 (the
second being `_attrib`, aliased to `attribute`), six in
tree-sitter-c-sharp 0.23.5 and four in tree-sitter-kotlin-ng 1.1.0.

Two rows of the issue no longer reproduce and are left alone: #1280
(f9b475b, 01c5953) already gated Bash `file_redirect` and Ruby
`superclass`, both with regression tests, and both fixtures measure 0
on this branch before any change of mine. #1275 (8246f50) already
settled the issue's `?` section for C# and TS/TSX.

Perl is the row the issue got wrong in the other direction. It was
cleared on the grounds that `<STDIN>` lexes as one token, which is
true — but `<FH>` and `<$fh>` are `standard_input_to_identifier` and
`standard_input_to_variable`, plain three-token sequences whose
brackets scored two phantom conditions each. A sweep of
tree-sitter-perl 1.1.2 finds bare angle brackets in exactly those two
productions plus `binary_expression`, so the same gate applies.

TypeScript gets a positive-direction parity test rather than a JSX
fixture: the `.ts` dialect does not parse JSX, so the construct is
unreachable there. On that malformed input the flip does move the
number (3 -> 2), because a `<` / `>` under an `ERROR` node no longer
counts — the same trade #1274 recorded, in the direction that
under-counts unparsable text rather than inflating it.

One regression test per affected language, each pairing the excluded
construct with a genuine condition and asserting a non-zero total, so
an unparsable fixture (which also scores 0) stays distinguishable from
a working gate. `csharp_relational_pattern_still_counts_as_a_condition`
exists because the `operator <` fixture cannot prove the
`RelationalPattern` entry alone (grammar-dispatch §11): its `is > 0`
sits beside a `binary_expression` comparison, so a one-entry allowlist
would still leave a plausible non-zero total.

Verified by eight perturbations of the six gates against the whole
499-test `metrics::abc` suite; every new test fails under at least one,
and the JS, Lua, Perl and C# gates fail nothing else. Real comparison
shapes were measured rather than assumed: `for` headers, `while`
predicates, parenthesised chains, a comparison inside a JSX expression
container, C# LINQ `where` beside a generic method call, and Kotlin
lambda comparisons all still count.

Also corrects prose the change falsified or that contradicts it: the
lessons-learned entry whose conclusion was "C#, Kotlin and the JS
family still carry the denylist form"; a C# test comment naming an
"LT/GT exclusion list" that no longer exists; the note claiming
`BinaryExpression2` is reachable here, when C#'s preprocessor admits
no relational operator; and the Objective-C arm's claim that its gate
is kept only "for parity", when that grammar emits bare angle brackets
from four non-comparison productions.

Kotlin keeps a residual over-count the gate cannot reach:
tree-sitter-kotlin-ng resolves `id<Int>(a)` into nested
`binary_expression` nodes, so both brackets satisfy any comparison
gate. Recorded on the arm and filed as #1394, alongside #1383 (C#
relational-pattern double count) and #1395 (Halstead still bills every
excluded bracket as an operator).

`PerlCode::compute` reaches cyclomatic 15 with the new arm. It joins
the `halstead` marker it already carried rather than being split: it
is the same exhaustive one-arm-per-grammar-kind dispatch table that
`CppCode`, `CCode`, `ObjcCode` and `MozcppCode` suppress on identical
grounds, and removing `cyclomatic` from the marker re-trips the gate,
so the marker is load-bearing rather than decoration.

No integration-snapshot churn: the C# corpus carries no operator
overload, pdf.js no JSX, and no Lua, Kotlin or Perl file is
snapshotted.

Fixes #1297
Every #1297 test asserted a condition total that its own fixture could
still produce after the excluded construct was deleted from it. The
production revert test proves the construct reaches the arm today, but
nothing failed if a later edit dropped it, so each test could quietly
decay into one that asserts the surviving comparison and nothing else.

Anchor the three JSX fixtures on `assignments_sum()`: the
`className="x"` attribute `=` is each fixture's only assignment, so
removing the JSX now fails. Anchor the Perl fixture the same way — the
three readlines are three of the sub's four assignments.

Assert the C# claim per space instead of through the file total, which
is 3 both with the two operator overloads and without them. The
overloads open function spaces, so the test can now say what it means:
each overload scores no condition and `m` scores three.

Lua and TypeScript get a comment rather than an anchor. Once excluded,
a Lua attribute and a TypeScript type argument contribute to no ABC
axis and open no space — `local x <const> = 1` and `local x = 1` score
identically — so there is nothing to assert, and the revert test is the
only coverage available. Saying so stops the next reader from taking
the missing anchor for an oversight.

Verified by perturbing the fixtures rather than the production code:
deleting the JSX from each of the three tests, the readlines from the
Perl test, and the overloads from the C# test each fails exactly its
own test and nothing else.
#778 established that the interior rows of a multi-line string are code,
not blank, mirroring Python's #415 decision, and routed thirteen
languages through `add_multiline_string_ploc`. Bash, Elixir, Tcl and
iRules never got an arm, so in each of them a literal spanning rows
reached neither PLOC nor CLOC and `blank = sloc - ploc - cloc` claimed
its interior. The issue's four reproductions measured `blank` 2, 2, 1
and 1 on files with no blank rows at all.

Route each language's multi-row-capable literals:

- Bash: `string`, `raw_string`, `ansi_c_string` and both `heredoc_body`
  ids. `string` needed an arm despite emitting a `string_content` child
  per row, because it emits none for a row that is empty *inside* the
  literal — the shape every other language already counts as code.
- Elixir: `quoted_content`, the literal text every string form wraps
  (`"…"`, `"""` heredoc, `'''` charlist, `~s`/`~S` sigil). Matched by
  name: the grammar aliases it to twenty ids, and a name comparison
  survives a twenty-first at a measured +1.3% of walk time.
- Tcl and iRules: `quoted_word`. `braced_word` stays out — both
  grammars parse a braced literal as a script, so routing it would turn
  every blank line inside every procedure body into code.

Elixir's `@doc` / `@moduledoc` heredoc rows are PLOC, not CLOC. Python's
carve-out to CLOC is a *bare* string expression statement whose value is
discarded — a docstring by position. A module attribute is not that
shape: `@doc "…"` is an assignment whose value the compiler stores and
`Code.fetch_docs/1` reads back, so its Python analogue is
`x = """…"""`, which Python already counts as PLOC.

Two fixes to the shared helper fall out of the four new callers:

- It no longer takes `end`, deriving the literal's last row from
  `Node::end_line` instead. A node whose end column is 0 finished at the
  start of the row below its last content row, so the raw end row is one
  the literal does not span. No current grammar makes the difference
  observable — every such row carries a delimiter token that credits it
  anyway — so this is correct-by-construction, not a behaviour change.
- Its parent gate, which skips the opening row on the premise that the
  enclosing statement owns it, is only safe where the caller's `_` arm
  credits every node's start row. Bash's is leaf-gated, so a container
  parent contributes nothing and a childless `raw_string` is the sole
  node covering its own row: `'ls'` alone in a file reported `ploc 0,
  blank 1` until Bash's arm inserted the row itself.

`bash_heredoc_loc` and `tcl_no_string_lloc` had pinned the defect
(`blank 1` on all-code fixtures); both now assert the corrected values,
and their snapshots move with them. No integration snapshot moves — the
output submodule holds no `.sh`, `.tcl`, `.ex` or `.irule` files.

Fixes #1260
Ruby's `private` / `public` / `protected` were modelled as a single
body-wide flag flipped by a bare identifier. That covers one of the
four shapes the language actually has, and two of the other three
corrupt `class_nm` / `class_na` rather than only the public split.

`private def x` and `private attr_accessor :b` nest the declaration in
the keyword's argument list, so a walk over the body's direct children
never sees it: `class A; def pub1; private def hidden; def pub2; end`
reported nm 2 against `nom.functions` 3, and `attr_accessor :a` plus
`private attr_accessor :b` reported na 1. Both walkers now read what a
visibility call's arguments declare, taking visibility from the keyword
rather than the flag.

`private :foo` demotes a method already defined, which a running flag
cannot express at all. `Npm` records each declaration during the pass —
name, method family, visibility — and tallies once the body is read, so
a symbol argument can re-file an earlier entry. The name comes from the
grammar's `name` field, which is what makes `def val=` (a `setter`) and
`def ==` (an `operator`) resolvable; `%i[a b]` names several.

A bare `private` governs instance methods only, so `def self.factory`
stayed public where the flag had demoted it. Only `private_class_method`
reaches a singleton, in both its symbol and its wrapping form, and
`private def self.x` correspondingly leaves one public. Inside
`class << self` the declarations are plain `method` nodes, so the flag
applies there — the exemption keys on the node kind, not the container.

The rules live once, in `npa/shared.rs`, and both metrics consult them,
so the two cannot drift on the same Ruby question. Two guards fall out
of putting them there: a receiver other than `self` means the call
names another object (`Other.private :a` declared and demoted nothing
before this change only because the receiver happened to be a
constant), and a partially interpolated `:"get_#{s}"` resolves to no
name rather than to `get_`.

Deferred with issues rather than folded in: retroactive demotion of an
attribute and the runtime-resolved argument forms (#1399), `initialize`
being automatically private (#1400), and a `def` wrapped in a call that
is not a visibility keyword (#1401).

No integration snapshots move; the output submodule holds no Ruby.

Fixes #1255
The fixture put its `hash_key_symbol` in an unrelated hash literal, so
the test asserted the arm was unreached without ever offering it the
chance to fire. `attr_accessor :x, foo: 1` puts one inside the very
argument list `ruby_symbol_argument_count` walks -- wrapped in a
`pair`, which is the claim -- so the na = 1 result now fails if the
defensive arm is ever reachable. Perturbing its `_ => 0` to `_ => 1`
fails this test; against the old fixture it did not.
`bca.to_sarif(thresholds={"nargs": N})` compared `metrics.nargs.total`,
the subtree sum, while `bca check --threshold nargs=N` has gated on the
callable's own parameter list since #1196. The two front-ends therefore
disagreed on which spaces breach, against `to_sarif`'s documented parity
with `bca check --report-format sarif`: a two-argument function holding
a three- and a two-argument closure scored 7 in the binding and 2 in the
CLI, so closure-heavy code drew SARIF findings no CLI run produces.

The binding walks JSON and no serialized key carried the gate's number —
#1196 moved the extractor without one. Follow the #958 precedent and
serialize it as `nargs.value`, additive and `#[serde(default)]`,
alongside the unchanged `nargs.total`. The sum now has one definition,
`nargs::Stats::own_args()`, which the CLI extractor, the report hotspot
table and the wire projection call instead of spelling
`function_args() + closure_args()` three times.

`metric_catalog`'s documentary `skip_at_unit` flag was stale for the
same reason and is corrected to `true`; its pinned set had asserted a
JSON-vs-accessor parity that stopped holding at #1196. STABILITY.md now
states what #958 and this change both practise — a `wire` struct field
addition is additive within 2.x — and `_flatten.py`'s CSV-parity
docstring names the five `*.value` columns CSV_HEADER does not carry.

The baseline refresh also records two decreases from earlier commits on
this branch, and `extract_summaries_inner`'s abc drops from 40.1 to 39.2
because `own_args()` is one call where the open-coded sum was two.

Fixes #1236
Record the ten issue fixes merged onto this branch: one additive wire
field (nargs.value) and nine corrections spanning preproc output
ordering, ABC condition counting, LOC blank-line classification, Ruby
visibility modelling, and four Halstead classifier defects.
Tcl/iRules braced_word_op_type withdrew every operator child of a
value-role braced word rather than only its opening brace, so a `;`
command separator was silently dropped and halstead.effort collapsed
to 0.0 for an unmodelled command.

The Bash lloc arm listed only the unsuffixed VariableAssignment id,
which the parser never emits, so every Bash assignment scored zero
logical lines. Added the alias, gated so a declaration_command or a
command environment prefix does not double-count.

The #1297 sweep skipped Elixir, whose denylist named only sigil
delimiters, so an operator named rather than applied (`&</2`,
`Kernel.<`) scored a phantom ABC condition.

Tcl `::eval` fell to the value default and lost its block; a leading
global qualifier is now stripped, while `ns::eval` still defaults.
A `switch` arm list is `pattern body pattern body …`, and the Tcl
grammar breaks it into commands at newlines. The #1318 rescue kept
the *first* braced pattern a literal — it is the arm command's name —
but every later one on the same line is an argument, and the rescue
read all of an arm command's arguments as bodies. So a one-line
`switch -regexp $v { {^a} {…} {^b} {…} }` billed a `{}` around `{^b}`
(N1 4) that the same arms written one per line did not (N1 3): the
score moved with layout, which is the thing the rule exists to stop.

Gate the rescue on argument position. Tcl pairs the list up by index
with no marker on either half, so even-indexed arguments are bodies
and odd-indexed ones patterns; a `-` fall-through body and a `default`
pattern are `simple_word`s that take one slot each, so the parity
survives them. Two rows pin it, and perturbing the gate to "always a
body" fails exactly the new one-line row across the halstead suite.

Also corrects the cost bound #1318 recorded. Its doc-comment, the
book and the changelog claimed the value default "can only omit one
`N1` occurrence of a `{}` whose vocabulary entry any real block in the
file already carries". Measured against `main`: a mixed file omitted
seven occurrences, and a top-level `dict for {k v} $d { puts $k }`
with nothing around it loses the vocabulary entry too, so `n1` is 0
and `halstead.effort` reads `0.0` (13.5 on `main`, with the `{k v}`
list fabricated as a block). Inside a `proc` the keyword and body
brace keep the space non-zero (48.4 -> 41.5), which is the scope the
metric is gated at, and `set x {a b}` already produced the same `0.0`
before #1318 — Halstead's shape on an operator-free space, not a new
failure mode. The prose now says so instead of understating it.

Lesson 89's #1297 paragraph is pared back to what that fix showed.
The rewrite in 80c0b2b closed on "the allowlist needs to know only
what a comparison is, and that set does not grow", which the same
commit contradicts: C# needed a second allowlist entry for
`relational_pattern`, a comparison outside `binary_expression`, and
a one-entry allowlist would have under-counted it silently. It also
listed JavaScript and Mozjs among the denylist languages when they had
no gate at all, and dropped Perl, the row the issue had declared
immune. The paragraph now records both directions.
Lesson 93 records the #1196/#1236 shape: a threshold gate moved to a
typed per-space accessor while the wire kept serializing only subtree
sums, so the JSON-walking SARIF binding diverged from `bca check` with
no failing test. The extractor table had noted the disagreement and
named no consumer, which is the part of the mechanism worth keeping.

The other batch findings had better homes than a lesson:

- lesson 66 gains the #1318 layout sub-example (Tcl splits `switch`
  arm lists at newlines, so the same source reaches the recogniser as
  one command or several);
- grammar-dispatch §6 gets the identity tiebreak for a wrapper/leaf
  pair with no childless-zero hazard (#1359 vs #1358);
- testing.md gets "perturb the fixture as well as the production
  line" from the #1297 anchors;
- batch-fix gains Step 6c, a whole-branch review in a fresh context,
  after ten per-issue pipelines left four bugs that one pass found.
#778 wired `add_multiline_string_ploc` into eighteen languages, not
thirteen; the #1260 comments and changelog entry all copied the wrong
number. The `this` regression test's doc said "ten of the thirteen"
where the getter comment it mirrors says "eleven of the fourteen".

Also record in `.rustfmt-bail-baseline.txt` why `src/getter/ruby.rs`
went 3 -> 4 in #1360 (a new arm in an already-bailing match, nothing
new to hoist) and lock in the silent `abc/elixir.rs` decrease.
@codecov

codecov Bot commented Sep 9, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 99.15254% with 13 lines in your changes missing coverage. Please review.
✅ Project coverage is 98.49%. Comparing base (9c93e05) to head (4d7ec3e).
⚠️ Report is 20 commits behind head on main.

Files with missing lines Patch % Lines
src/metrics/halstead.rs 98.79% 5 Missing ⚠️
src/getter.rs 95.55% 0 Missing and 4 partials ⚠️
src/metrics/npa/shared.rs 97.02% 0 Missing and 3 partials ⚠️
src/metrics/loc.rs 99.56% 0 Missing and 1 partial ⚠️
Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##             main    #1403      +/-   ##
==========================================
+ Coverage   98.46%   98.49%   +0.02%     
==========================================
  Files         278      278              
  Lines       75772    77200    +1428     
  Branches    75342    76769    +1427     
==========================================
+ Hits        74610    76037    +1427     
+ Misses        756      752       -4     
- Partials      406      411       +5     
Flag Coverage Δ
python 100.00% <100.00%> (ø)
rust 98.48% <99.15%> (+0.02%) ⬆️

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

Files with missing lines Coverage Δ
...e-analysis-py/python/big_code_analysis/_flatten.py 100.00% <ø> (ø)
...ode-analysis-py/python/big_code_analysis/_types.py 100.00% <100.00%> (ø)
src/getter/bash.rs 95.83% <ø> (ø)
src/getter/cpp.rs 100.00% <ø> (ø)
src/getter/irules.rs 96.66% <100.00%> (+1.01%) ⬆️
src/getter/mozcpp.rs 94.73% <ø> (ø)
src/getter/ruby.rs 96.66% <100.00%> (+0.83%) ⬆️
src/getter/tcl.rs 96.66% <100.00%> (+1.01%) ⬆️
src/metric_catalog.rs 100.00% <ø> (ø)
src/metrics/abc.rs 99.62% <100.00%> (+<0.01%) ⬆️
... and 40 more
🚀 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.

Codecov on PR #1403 reported two production arms no test executed:
the C `string_literal` and mozjs `template_string` arms that credit a
multi-row string to PLOC. Only `.jsm` routes to the mozjs copy, and C
has no raw string, so neither was reached by the sibling fixtures. Add
both to the cross-language parity test; each arm perturbed alone now
fails it.

Two Ruby `?` fall-throughs were reachable but unexercised: the empty
delimited symbol `:""` (no `string_content` child) and `self.(...)`
(a `call` with a `self` receiver and no `method` field). Extend the
#1399 unresolvable-arguments fixture and the another-object npa fixture
with one each; perturbing either line fails exactly its own test.

The remaining lines are non-UTF-8 or grammar-unreachable `?` branches
and assertion failure messages inside tests, none of which a passing
test can execute.
@dekobon
dekobon merged commit 4d7ec3e into main Sep 9, 2026
56 checks passed
@dekobon
dekobon deleted the fix/batch-2026-08-30 branch September 9, 2026 20:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment