Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
c6aad16
fix(preproc): emit preproc documents in sorted order
dekobon Aug 31, 2026
69a7f78
fix(getter/bash): count $"…" once in Halstead N2
dekobon Aug 31, 2026
6853f8c
test(halstead): say what the Bash _before columns prove
dekobon Aug 31, 2026
15ac604
fix(getter/ruby): bill a suffixed numeral once
dekobon Aug 31, 2026
9108f0a
fix(getter/ruby): stop billing subshell backticks
dekobon Aug 31, 2026
e1fd4f4
fix(getter/cpp): count `this` as an operand
dekobon Aug 31, 2026
fb13011
test(halstead): name the #1361 walk for its property
dekobon Aug 31, 2026
cd152c9
fix(getter/tcl): stop a braced literal opening a block
dekobon Aug 31, 2026
80c0b2b
fix(abc): stop scoring non-comparison < and >
dekobon Sep 9, 2026
c8c8d8c
test(abc): pin the #1297 fixtures against future edits
dekobon Sep 9, 2026
35b203e
fix(metrics/loc): bill multi-row literals as code
dekobon Sep 9, 2026
b32e594
fix(npm/ruby): model visibility calls, not just the flag
dekobon Sep 9, 2026
a4601cd
test(npa/ruby): make the hash-key-symbol pin adversarial
dekobon Sep 9, 2026
811387d
fix(py): threshold nargs on the per-space own count
dekobon Sep 9, 2026
e0d6495
docs(changelog): consolidate entries from batch fix
dekobon Sep 9, 2026
0c98713
fix(metrics): correct four defects found reviewing the batch
dekobon Sep 9, 2026
de043cd
fix(getter/tcl): keep one-line switch patterns literal
dekobon Sep 9, 2026
a862616
docs(lessons): add lesson 93 and route three batch findings to rules
dekobon Sep 9, 2026
54a3223
docs: correct two language counts from the branch review
dekobon Sep 9, 2026
4d7ec3e
test(metrics): cover four uncovered patch lines
dekobon Sep 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 7 additions & 13 deletions .bca-baseline.toml
Original file line number Diff line number Diff line change
Expand Up @@ -285,7 +285,7 @@ value = 5.0
path = "big-code-analysis-cli/src/markdown_report.rs"
qualified = "extract_summaries_inner"
metric = "abc"
value = 40.124805295477756
value = 39.153543900903784

[[entry]]
path = "big-code-analysis-cli/src/markdown_report.rs"
Expand Down Expand Up @@ -531,7 +531,7 @@ value = 6.0
path = "big-code-analysis-py/src/types_codegen.rs"
qualified = "<file>"
metric = "loc.ploc"
value = 745.0
value = 748.0

[[entry]]
path = "big-code-analysis-py/src/vcs.rs"
Expand Down Expand Up @@ -807,7 +807,7 @@ value = 5.0
path = "src/getter/bash.rs"
qualified = "BashCode::get_op_type"
metric = "halstead.effort"
value = 57989.391134957754
value = 57421.05047463073

[[entry]]
path = "src/getter/elixir.rs"
Expand Down Expand Up @@ -843,7 +843,7 @@ value = 48346.66496041094
path = "src/getter/ruby.rs"
qualified = "RubyCode::get_op_type"
metric = "halstead.effort"
value = 97661.51169055807
value = 120865.54836941602

[[entry]]
path = "src/metrics/abc/csharp.rs"
Expand Down Expand Up @@ -1029,13 +1029,7 @@ value = 5.0
path = "src/metrics/loc/perl.rs"
qualified = "PerlCode::compute"
metric = "halstead.effort"
value = 52134.594881912846

[[entry]]
path = "src/metrics/loc/shared.rs"
qualified = "add_multiline_string_ploc"
metric = "nargs"
value = 5.0
value = 51486.205050501565

[[entry]]
path = "src/metrics/npa/python.rs"
Expand All @@ -1047,7 +1041,7 @@ value = 6.0
path = "src/node.rs"
qualified = "Node<'a>"
metric = "nom"
value = 34.0
value = 33.0

[[entry]]
path = "src/ops.rs"
Expand Down Expand Up @@ -1203,7 +1197,7 @@ value = 7.0
path = "src/spaces/compute.rs"
qualified = "metrics_inner"
metric = "halstead.effort"
value = 120010.12175375846
value = 119147.7514530567

[[entry]]
path = "src/spaces/compute.rs"
Expand Down
10 changes: 10 additions & 0 deletions .claude/rules/grammar-dispatch.md
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,16 @@ than assumed. PHP's `primitive_type` is childless for `callable`,
leaf — the "obvious" innermost choice — would have scored six types
zero (#1293).

When neither choice can strand a childless node — every wrapper requires
its leaf and the unwrapped spelling carries no wrapper at all — the
hazard is absent and the tiebreak is identity: keep the node whose span
*is* the operand. Ruby's `1r`, `1i` and `1ri` are three constants
distinct from `1`, and only the `rational` / `complex` wrapper's text
says which; billing the leaf files all four under `1` (#1359). Bash's
`$"…"` went the other way for the first reason, not this one — its
`string` child is the node present in argument position, where the
grammar emits no wrapper (#1358).

## 7. Walk the sibling predicates for parity

`Checker::is_string`, `Getter::get_op_type`, `Checker::is_call`,
Expand Down
26 changes: 26 additions & 0 deletions .claude/rules/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,32 @@ confirm it by perturbing the exact production line the assertion names —
per the sections above, a test that cannot fail is worse than no test,
because it reads as coverage.

## Perturb the fixture as well as the production line

A revert test proves the construct reaches the arm *today*. It says
nothing about whether the fixture still contains the construct tomorrow.
A test that pairs an excluded construct with a genuine condition and
asserts the non-zero total keeps passing after someone trims the
construct out of the fixture, because the surviving comparison supplies
the total on its own — and every #1297 test had exactly that shape, so
each could decay into an assertion about the comparison alone with no
signal.

Anchor the fixture on a second axis that only the construct contributes,
then verify by deleting the construct from the fixture and watching
exactly that test fail:

- The three JSX fixtures anchor on `assignments_sum()`: the
`className="x"` attribute `=` is each fixture's only assignment.
- The C# operator overloads are asserted per space rather than through
the file total, which is 3 with the overloads and without them.
- The Perl readlines are three of the sub's four assignments.

Where the construct contributes to no axis once excluded — a Lua
`<const>` attribute, a TypeScript type argument — there is nothing to
anchor on, and the revert test is the only coverage available. Say so in
a comment, so the missing anchor is not read as an oversight.

## Coverage measures execution, not discrimination

A coverage report answers "did any test run this line?" It never answers
Expand Down
18 changes: 18 additions & 0 deletions .claude/skills/batch-fix/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -670,6 +670,24 @@ git checkout <INTEGRATION_BRANCH>
git stash pop # if stash was used
```

### 6c: Review the whole branch as one diff

Once 6a passes, review `main...HEAD` as a single diff in a context that
has not seen the fixes: spawn an agent that runs `review branch` (the
worktree-agent rule against invoking skills does not apply to the
orchestrator), or ask the user to run `/code-review` on the branch.
Phase 3 reviewed each fix on its own; the defects that survive it are
the ones visible only across fixes — a sibling one sweep skipped, a
helper two fixes now share under different rules, an alias one
language's arm lists and another's does not, a rule whose stated cost
bound the next fix falsifies. On the 2026-08-30 batch, ten per-issue
pipelines plus `audit-tests` and a green gate left four real bugs that
the user's whole-branch `/code-review` found on its first reading.

Fix the findings as a further commit on the integration branch, re-run
6a, and only then proceed to Step 7. Do not re-run the review over the
fix commit — that is the pass Phase 3 already says finds nothing.

---

## Step 7: Summary
Expand Down
12 changes: 10 additions & 2 deletions .rustfmt-bail-baseline.txt
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,14 @@
# tests/common/mod.rs an `insta::with_settings!` invocation,
# whose `key => value` token tree is not a
# match at all
#
# Known cause-1 increases that added no comment: the arm joined a match
# that already bailed on section comments inside its or-patterns, the
# kind #1136 decided to keep, so the count grew by one with nothing new
# to hoist. Hand-check the arm's formatting instead.
#
# src/getter/ruby.rs 3 -> 4, the `BQUOTE` subshell guard
# (#1360) in `get_op_type`
# src/vcs/error.rs `classify_error_variants!` (#1245): its
# matcher, plus the 11 `$pat => $sample`
# entries at the invocation, which are
Expand Down Expand Up @@ -122,10 +130,10 @@ src/getter/mozcpp.rs 5
src/getter/perl.rs 3
src/getter/php.rs 11
src/getter/python.rs 7
src/getter/ruby.rs 3
src/getter/ruby.rs 4
src/getter/tcl.rs 6
src/macros/mod.rs 38
src/metrics/abc/elixir.rs 6
src/metrics/abc/elixir.rs 5
src/metrics/cognitive/perl.rs 8
src/metrics/cyclomatic.rs 7
src/metrics/cyclomatic/irules.rs 2
Expand Down
169 changes: 169 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,29 @@ for historical reference.

## [Unreleased]

### Added

- Per-space *own* value for `nargs` in the serialized wire shape:
`nargs.value` (#1236). `nargs.total` remains the subtree sum; the new
field is the per-space scalar `bca check --threshold nargs=N` has
gated on since #1196, exposed so a JSON-walking front-end can
reproduce the gate. SemVer-additive, with `#[serde(default)]` so
older documents still parse; it appears in every output format
(JSON / YAML / TOML / CBOR), in `bca dump`'s metric tree, as a new
column in `flatten_spaces` records, and in the Python `NargsDict`
TypedDict. The library accessor is `nargs::Stats::own_args()`.
`bca metrics -O csv` is the one output that does not carry it —
`CSV_HEADER` is a frozen positional contract, as it already is for
the four `.value` fields #958 added. `bca diff` walks the raw
document rather than the `wire` types, so diffing a pre-#1236 file
against a current one reports `nargs.value` as a change wherever the
older side's absent field differs from the newer side's value.
`metric_catalog::METRICS` now records `nargs` with
`skip_at_unit: true`, matching the four metrics whose serialized
aggregate diverges from the CLI accessor; nothing in the workspace
reads the flag, so this is a correction to a published description
rather than a behaviour change.

### Performance

- The metric walk's cognitive nesting map no longer grows to one entry
Expand Down Expand Up @@ -59,6 +82,152 @@ for historical reference.
`MISSING` match and a corrected alternative-step skip) that nothing
here uses.

### Fixed

- **`bca preproc` documents are byte-identical across runs** (#1304).
`PreprocResults.files` and `PreprocFile`'s three `HashSet<String>`
fields serialized straight off hash order, so an unchanged tree
produced a different document on nearly every run — eight distinct
hashes over eight runs. They now emit sorted, on stdout as well as
through `--output`, closing the last destination in the #1244 /
#1303 family. The public field types are unchanged: the ordering is
imposed at the serialization seam, so no `BTreeMap` / `BTreeSet`
break is needed. Note the document uses two comparators — `files`
sorts its `PathBuf` keys component-wise, matching `metrics --output`
and `ops --output`, while the `String` name sets sort
byte-lexicographically — so `a-b/x.h` precedes `a/x.h` in an include
array and follows it as a key. `STABILITY.md` records the emitted
order as contract through `3.0`.

- ABC no longer scores a non-comparison `<` or `>` as a condition
(#1297). JSX tag delimiters (TypeScript, TSX, JavaScript, Mozjs),
Lua 5.4 variable attributes, a C# comparison-operator overload's
declared name, a Kotlin qualified super call (`super<A>.g()`), and
Perl's `<FH>` / `<$fh>` readlines each scored phantom conditions.
Perl was listed as immune by the original survey and was not. C#
additionally keeps counting a relational pattern's operator
(`x is > 0`), a genuine comparison outside `binary_expression`.
Elixir was swept the same way in the same release: an operator
*named* rather than applied — the capture `&</2`, the qualified call
`Kernel.<(a, b)` — puts a bare `<` under an `operator_identifier`,
which its sigil-only denylist did not exclude. **Metric drift:**
`abc.magnitude` falls for every file carrying one of these shapes,
and `abc` is a gated threshold metric.

- Multi-line strings and heredocs no longer read as blank lines in
Bash, Elixir, Tcl and iRules (#1260). Each language's
multi-row-capable literals now credit every spanned row to PLOC,
matching the eighteen languages #778 covered and Python's #415
decision. Elixir `@doc` / `@moduledoc` rows are PLOC — a module
attribute is an assignment whose value the compiler stores, so its
Python analogue is `x = """…"""`, not a discarded docstring —
and Tcl / iRules `braced_word` is excluded because both grammars
parse a braced literal as a script. **Metric drift:** `loc.ploc`
rises and `loc.blank` falls for any such file; both are gated at the
file scope.

- **Ruby `npm` / `npa`: visibility calls no longer hide methods or
mis-scope singletons** (#1255). `private def x`,
`private attr_accessor :b`, `private :foo`, `%i[…]` argument arrays
and the `private_class_method` / `public_class_method` pair are now
modelled, and a bare `private` no longer demotes a `def self.x`. The
first two corrupted the `class_nm` / `class_na` totals, not just the
public split.

- Tcl and iRules braced literals no longer report a `{}` operator for
a block the source does not contain (#1318). Both grammars spell a
script body and a quoted value with one kind, `braced_word`, so
#1314's guard on the literal `braced_word_simple` form reached only
the value slots the grammars special-case; everywhere else —
`lappend x {a b}`, `puts {c d}`, and every user-defined proc — the
brace still opened a block. The role is now recognised by the
enclosing command's leading word: `after`, `eval`, `for`, `on`,
`switch`, `time`, `trap` and `uplevel` take scripts, as does every
construct the grammar models with a node of its own, except a
defaulted `proc` parameter and a braced word in the command-name
position. An unrecognised command is taken to receive a value.
The rule reaches the brace and only the brace: a `;` separating two
commands is a direct child of the same `braced_word` — `_terminator`
is a hidden rule, so it is inlined rather than wrapped — and both
dialects classify it as an operator, so a revision keyed on the
parent kind alone withdrew the separators too and took
`lappend x {puts a ; puts b}` to `halstead.effort` `0.0`. A leading
`::` is stripped before the command lookup, so `::eval {…}` — the
spelling a `namespace eval` body uses to reach the core command past
a local proc — keeps its block like the unqualified form; a
`ns::eval` prefix is a different command and does not. Inside a
Tcl `switch` arm list, only even-positioned arguments of an arm
command are bodies, so a one-line `switch -regexp $v { {^a} {…}
{^b} {…} }` no longer wraps `{^b}` in a block that the same arms
written one per line did not.
**Metric drift:** Tcl and iRules `halstead.unique_operators` /
`total_operators`, the derived values, and hence `mi` fall for any
file passing a braced literal to a command the grammar does not
model. A script handed to an unlisted command (`dict for {k v} $d
{…}`, a Tk `-command {…}`) loses its `{}` too, and a top-level
fragment with no other block then has no operator at all, so its
`halstead.effort` reads `0.0`; a `proc` keeps its own keyword and
body brace and is unaffected. Operand counts are deliberately
unchanged. Supersedes the note in 2.2.0 that a value-position
braced literal still reports a `{}` operator.

- A Bash assignment counts as a logical line. `variable_assignment`
reaches the walk under the alias id the enum spells
`VariableAssignment2`, and the `Loc` dispatch listed only the
unsuffixed one, so `a=1` scored `loc.lloc` 0 — a file of nothing but
assignments reported no logical lines at all. The alias is gated on
the assignment not hanging off a `declaration_command` (`local n=5`)
or a `command`'s environment prefix (`X=1 cmd`), both of which the
same arm already counts as one line. **Metric drift:** Bash
`loc.lloc` rises by one per statement-position assignment, and `mi`
moves with it.

- Bash translated strings (`$"…"`) are no longer counted twice in
Halstead `N2` (#1358). `BashCode::get_op_type` classified both the
`translated_string` wrapper and the single required `string` child
the grammar gives it, so `a=$"x"` reported `N2` 3 for two operands.
The wrapper also defeated the #180 expansion guard, which inspects a
node's own children: a `translated_string` has only `$` and
`string`, so `b=$"$y"` counted both `$"$y"` and `$y`. **Metric
drift:** Bash `N2` falls by one per `$"…"` literal, moving the
derived values with it.

- Ruby suffixed numeric literals (`1r`, `2i`, `3ri`) are billed once
in Halstead rather than once per level of the grammar's `complex` /
`rational` nest (#1359). The wrapper is kept rather than the leaf,
because the suffix that distinguishes `1`, `1r`, `1i` and `1ri` as
four constants lives on the wrapper's span. **Metric drift:**
`halstead.*` and every derived value change for Ruby sources
containing a rational or complex literal; the operand vocabulary now
records the wrapper's full text, so `1` and `1r` are distinct
operands where they previously collapsed.

- Ruby subshell literals no longer fabricate two `` ` `` operators
each (#1360) — the delimiter class #1256 and #1312 removed for
Elixir and for regexes. tree-sitter-ruby aliases both ends of a
subshell to the same backtick token, the one the `` def ` ``
method-name marker also uses, so `` u = `echo hi` `` reported `n1` 2
/ `N1` 3 for a line whose only operation is the assignment, in all
seven spellings (`` `…` `` and the six `%x` forms). The delimiters
are now parent-guarded under the `subshell`, so a real backtick
method, defined or called, still counts. Operand counts do not move.

- **Metric drift:** C++ and mozcpp `this` is now a Halstead operand
(#1361). It had been classified as neither operator nor operand, so
every `this` was dropped from the vocabulary — the same class of
silent drop as #1316's character literals. `n2` rises by one per
space and `N2` by one per occurrence; the derived volume /
difficulty / effort / time / bugs and the three maintainability-index
variants move. Note `difficulty` is `(n1/2)·(N2/n2)`, so a larger
`n2` pushes it down as often as up.

- Python `to_sarif`: `thresholds={"nargs": N}` now gates each space on
its own argument count, matching `bca check --report-format sarif`
exactly (#1236). It compared the `nargs.total` subtree sum, so a
two-argument function containing a three- and a two-argument closure
was reported at 7 — a finding the CLI never emits — and
closure-heavy code drew spurious SARIF findings.

## [2.2.0] - 2026-08-29

One entry below is marked **(breaking)**: the Python bindings'
Expand Down
Loading