Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
5f3ad18
fix(lint): lex char literals in the snapshot-anchor gate
dekobon Aug 3, 2026
ebf3713
fix(py): assert what the VCS thread test can actually observe
dekobon Aug 3, 2026
898db4b
fix(spaces): anchor the file-level unit span at line 1
dekobon Aug 3, 2026
2b95389
fix(abc): address ternary slots by grammar field, not by index
dekobon Aug 3, 2026
2062a38
fix(abc): count the `not` keyword like `!` in Ruby and Perl
dekobon Aug 3, 2026
9039894
fix(abc): wire Phase 2B slot routing for Tcl and iRules
dekobon Aug 3, 2026
89f86be
fix(walk): anchor manifest excludes under a directory seed
dekobon Aug 3, 2026
4a814d6
feat(check): report which exclude globs are manifest-anchored
dekobon Aug 3, 2026
a52fea7
refactor(report): hoist hotspot selection out of both renderers
dekobon Aug 3, 2026
6eab5b7
fix(nargs): count a lambda's bare parameter in Java and C#
dekobon Aug 3, 2026
a7c296e
fix(spaces): open a function space for five nameless constructs
dekobon Aug 3, 2026
7fa9143
fix(js): classify generator functions as functions, not closures
dekobon Aug 3, 2026
9aa6bcf
fix(cognitive): reset the lambda surcharge at every function boundary
dekobon Aug 3, 2026
db5e9e6
fix(js): classify an anonymous function by one rule in both spellings
dekobon Aug 3, 2026
d87db12
fix(cognitive): treat the #1184 constructs as function boundaries
dekobon Aug 3, 2026
bb9a0d1
docs(self-scan): record why nargs stays at 7
dekobon Aug 3, 2026
a66365e
refactor: absorb the threshold regressions this batch introduced
dekobon Aug 3, 2026
085b79f
fix: resolve the findings from the batch's own review
dekobon Aug 3, 2026
2aa4f8c
chore(self-scan): refresh the baseline for this batch
dekobon Aug 3, 2026
176e904
fix(check): gate nargs on a callable's own parameters
dekobon Aug 3, 2026
d64935b
fix: resolve the code-review findings on #1180-#1196
dekobon Aug 3, 2026
4890c81
chore(self-scan): converge nargs from 7 to the shipped default of 5
dekobon Aug 3, 2026
bfbcd4b
refactor(elixir): drop duplicated func-space rule
dekobon Aug 3, 2026
9647f8a
test(preproc): pin PreprocDiagnostic Display output
dekobon Aug 3, 2026
2571b4d
test(vcs): pin the blame commit-lookup retry gate
dekobon Aug 3, 2026
c04fff8
test(preproc): name the Display test for what it covers
dekobon Aug 3, 2026
490e048
fix(preproc): lowercase every diagnostic warning prefix
dekobon Aug 3, 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
780 changes: 582 additions & 198 deletions .bca-baseline.toml

Large diffs are not rendered by default.

5 changes: 5 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -552,6 +552,11 @@ jobs:
# this check.
- name: snapshot anchors (explicit)
run: python3 utils/check-snapshot-anchors.py
# Run the gate's own unittests as their own explicit step, for the
# same reason as the rustfmt-bail twin below: a refactor that
# breaks the script must not be able to disable the gate silently.
- name: snapshot-anchors self-tests (explicit)
run: python3 -m unittest -q utils/check-snapshot-anchors-test.py
# Defensive twin for the rustfmt-bail gate (#1136): a comment
# inside a match pattern makes rustfmt emit the whole match
# verbatim while `cargo fmt --check` exits 0, so the fmt job
Expand Down
10 changes: 10 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,16 @@ repos:
entry: python3 utils/check-snapshot-anchors.py
pass_filenames: false

# Self-tests for the snapshot-anchors gate itself. Kept as a
# separate hook so the gate hook above remains a one-line
# invariant check, matching the rustfmt-bail pair below.
- id: snapshot-anchors-test
name: snapshot-anchors-test
language: system
files: '^utils/check-snapshot-anchors(-test)?\.py$'
entry: python3 -m unittest -q utils/check-snapshot-anchors-test.py
pass_filenames: false

# Block new match arms rustfmt refuses to format (#1136). A
# comment inside a match *pattern* makes rustfmt emit the whole
# match verbatim while `cargo fmt --check` still exits 0, so the
Expand Down
14 changes: 6 additions & 8 deletions .snapshot-anchor-baseline.txt
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,16 @@
# Maintained by check-snapshot-anchors.py; see AGENTS.md.
# Lower-or-equal current counts pass; any increase fails CI.
# Regenerate with: ./utils/check-snapshot-anchors.py --update
#
# Only files with outstanding bare calls are listed. An unlisted
# file is allowed zero, which `load_baseline`'s `.get(rel, 0)`
# already encodes — so omitting them keeps this a list of debt
# rather than a census of every scanned file (#1192 made the
# scan recursive, which would otherwise have added 126 zeroes).

src/metrics/abc.rs 27
src/metrics/cognitive.rs 0
src/metrics/cyclomatic.rs 1
src/metrics/halstead.rs 3
src/metrics/loc.rs 0
src/metrics/mi.rs 0
src/metrics/mod.rs 0
src/metrics/nargs.rs 0
src/metrics/nexits.rs 0
src/metrics/nom.rs 0
src/metrics/npa.rs 12
src/metrics/npm.rs 23
src/metrics/tokens.rs 0
src/metrics/wmc.rs 13
21 changes: 18 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -436,12 +436,27 @@ as part of `make pre-commit` and `make ci`, the
`.github/workflows/ci.yml`) invokes
`./utils/check-snapshot-anchors.py`, which scans every
`insta::assert_json_snapshot!(metric.…)` call under `src/metrics/`
and counts the unanchored ones per file. The current per-file
counts are checked in at `.snapshot-anchor-baseline.txt`; CI fails
on any *increase*. Decreases are silent and may be locked in with
— **subdirectories included since #1192**; before that the glob was
non-recursive and the 126 files under `abc/`, `cognitive/`,
`cyclomatic/`, `loc/`, `npa/` and `npm/` were invisible to it — and
counts the unanchored ones per file. Outstanding counts are checked
in at `.snapshot-anchor-baseline.txt`; CI fails on any *increase*.
A file with no bare calls is **not** listed, and an unlisted file is
allowed zero, so the baseline reads as a list of debt rather than a
census. Decreases are silent and may be locked in with
`./utils/check-snapshot-anchors.py --update`, which regenerates the
baseline from the working tree.

The gate lexes Rust literals to decide what is live code, and that
lexer is itself gated: `make snapshot-anchors-test` runs
`utils/check-snapshot-anchors-test.py`, which pins both directions of
the char-literal rule (#1192). A `b'"'` read as an unpaired quote
opens a string span that hides every later snapshot call, and a
lifetime (`'a`, `'outer:`) read as a literal swallows the rest of the
file the other way. Both failure modes make the gate report a clean
file — the outcome it exists to prevent — so neither may be left to
inspection.

**`cargo fmt --check` is not the whole formatting gate.** A comment
inside a match *pattern* makes rustfmt emit the enclosing match
verbatim, silently, with `cargo fmt --check` still exiting `0` — so
Expand Down
139 changes: 139 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,32 @@ for historical reference.

### Added

- `bca check --print-effective-config` reports which exclude globs are
manifest-anchored (#1194). After #1164 a glob's meaning depends on its
origin — a `--check-exclude` pattern resolves against the caller's
working directory, a `bca.toml` one against the manifest's directory —
and a single flattened array cannot express that. `manifest_exclude`,
`manifest_check_exclude`, `manifest_exclude_from` and
`manifest_check_exclude_from` name the manifest-origin subset
alongside the resolved lists, which stay where they were so the TOML
form keeps round-tripping through `--config`. The anchor is the
reported `manifest` file's directory. Each key is omitted when the
manifest contributed nothing; the `*_from` pair is present only when
the manifest's file is the one actually in effect, since a CLI
`--exclude-from` *replaces* rather than unions with it.
- Kotlin property accessors (`get()` / `set()`) and `init { … }`, Java
and Groovy `static { … }`, and JavaScript class static blocks now open
a function space of their own (#1184). Each carries executable code but
was referenced nowhere outside the generated language enum, so its
control flow was charged to the enclosing class and `bca check` could
never flag one however complex it got. They are reported under
synthesised names — `<get>`, `<set>`, `<init>`, `<static-init>` —
following the existing `<anonymous>` convention. **They are
deliberately absent from `nom.functions`, `nargs` and `bca functions`**:
none is a callable named at a call site, and counting an accessor as a
method would make `npm` bill the same property once as an attribute and
again as a method. See the NOM section of the metrics guide.

- `bca check --explain-threshold <metric>=<limit>`: preview what a
candidate threshold would cost at **both** tiers without editing
`bca.toml` or running a gate (#1169). Reports hard-tier offenders, the
Expand Down Expand Up @@ -157,6 +183,64 @@ for historical reference.

### Changed

- `PreprocDiagnostic`'s `Display` output now uses a lowercase
`warning:` prefix for all five variants. `SelfInclusion`,
`IncludeCycle` and `NotPreprocessed` previously capitalised it while
the two non-UTF-8 variants did not, so `bca preproc` emitted both
spellings — and the capitalised three matched no other CLI
diagnostic, since `warn()` has printed lowercase `warning:` since
#609. Per `STABILITY.md`, `Display` impls are stable but their exact
wording is not, so this is not a breaking change. It is also
deliberately byte-compatible with routing these through `warn()`
later: `warn(msg)` renders `warning: {msg}`, so moving the prefix out
of `Display` will produce identical output rather than a second
user-visible change.

- This project's own `nargs` limit converges from 7 to the shipped
default of 5 (#1183). Repository configuration only — no library or CLI
behaviour changes. The convergence was declined twice before, both
times correctly: #1143 measured it against a hard-tier count that
missed the soft tier, and #1183 found the offenders were mostly
artifacts of the gate summing closure parameters into the enclosing
function. #1196 removed that, and with it the reason to stay at 7 —
which had become a limit catching nothing in the current tree.

- **`bca check --threshold nargs=N` now gates each callable on its own
parameter list** rather than on `nargs.total()`, which summed a
function's parameters with every nested closure's (#1196). This changes
gate outcomes on existing configurations — read it before upgrading a
pinned CI.

A three-parameter function containing a two-parameter sort comparator
was reported at 5, and the remediation the number implied — fewer
parameters — was not the one that would clear it. Measured on this
repository, of the 76 functions a limit of 5 would have newly gated,
only 17 had six or more parameters of their own; one had a single
parameter plus five contributed by closures in its body. Refreshing
this project's own baseline under the new rule retired 45 of its 61
recorded `nargs` entries.

Every comparable tool measures the same quantity the gate now does —
RuboCop `Metrics/ParameterLists`, ESLint `max-params`, Clippy
`too_many_arguments`, lizard, SonarQube S107, Pylint `R0913` — and two
of those are the anchors the shipped default of 5 is derived from, so
the default and the gate were previously calibrated against different
quantities.

Nothing escapes the narrower rule. Where a closure opens its own space
(Rust, the JavaScript family, C#, Go, PHP, Perl, Ruby, Lua, Elixir) it
is gated on its own offender row. Where a lambda opens none (Python,
Java, Kotlin, C++) its arguments still fold into the enclosing
function, and the offender row now shows the split —
`nargs = 8 (1 own + 7 lambda)` — so the reader can tell whether the
lever is the signature or the lambda.

Unchanged: the serialized `function_args` / `closure_args` / `total`
keys, which remain subtree sums. Only the gate's reading of them moved.
If you have a `nargs` limit tuned against the old behaviour, expect
fewer offenders and consider whether the limit is now looser than you
intended.

- This repository's own `bca.toml` gates `cognitive` at 15, the shipped
default, instead of the pre-#1140 folklore value of 25 (#1143). This
is self-scan configuration only — no public API, no metric
Expand Down Expand Up @@ -682,6 +766,61 @@ for historical reference.

### Fixed

- **Metric drift.** ABC `conditions` moves wherever a comment sits inside
a ternary (#1181). Slots are now addressed by grammar field rather than
by neighbouring token or fixed index, which fixes two opposite errors
from one cause: C, C++, Objective-C, Mozcpp, PHP, Perl, JavaScript,
TypeScript, TSX and MozJS **over**-counted (`a ? /*n*/ (b) : c` scored
3 against `a ? (b) : c`'s 2), while Java, C# and Groovy **under**-counted
(`a ? /*n*/ !b : c` scored 2 against `a ? !b : c`'s 3).
- **Metric drift.** ABC `conditions` counts Ruby's and Perl's `not`
keyword like `!` (#1182). `if not b` scored 0 against `if !b`'s 1, and a
`not` ternary scored 2 against the `!` form's 4. Lua and Elixir were
already correct.
- **Metric drift.** Tcl and iRules gained the Phase 2B slot routing every
other language already had (#1180): `if {$a}` and `while {$a}` move 0 →
1, `if`/`elseif`/`else` 2 → 4, and `expr {$a ? !$b : !$c}` 1 → 4,
matching the value the other languages report for the same expression.
The argument and `return` slots remain unrouted.
- **Metric drift.** A lambda written without its optional parentheses
reports its parameter in Java and C# (#1185). `x -> x + 1` scored
`nargs` 0 where `(x) -> x + 1` scored 1; the parameters are billed to
`closure_args` as before.
- **Metric drift.** JavaScript-family generator functions are classified
as functions rather than closures (#1186), so `nom`'s function/closure
split, `nargs`' `fn_args`/`closure_args` split and cognitive nesting all
move for `function*`. `bca functions` and `bca find --type function`
now report a named generator, which they previously omitted.
- **Metric drift.** An immediately-invoked function expression is a
closure whether or not its result is bound (#1188). `nom` and `nargs`
previously classified `(function(){…})()` and
`const v = (function(){…})()` differently. A class field initialiser and
a non-identifier-keyed object property are now classified the same way
whether written as a function expression or an arrow.
- **Metric drift.** Cognitive complexity resets the lambda surcharge at
every function boundary, not only in the JavaScript family (#1187). A
function *declared inside* a closure scored 3 where the same body
outside one scored 2, in Rust, Java, C++, PHP and C#. Separately,
`(function(){ function g(){…} })()` and `(() => { function g(){…} })()`
now charge `g` the same function depth.
- **Metric drift.** The file-level unit's line span is anchored at line 1
(#1195). A whitespace-only file reported `0..0`, and any file opening
with blank lines reported a span that omitted them — `"\n\n\nfn a(){}\n"`
gave `4..4` of a 4-line file. An empty file still reports `0..0`, having
no lines.
- **Metric drift.** Kotlin `init { … }` complexity now contributes to its
class's WMC, which follows from the new function space (#1184).
- A `bca.toml` `exclude` glob keeps applying under a directory seed
(#1189). `bca metrics -p sub` moved the walk root, and manifest globs —
written against the manifest's directory — silently stopped matching.
The rule is now stated once and shared by the walker and the `bca check`
gate.
- `utils/check-snapshot-anchors.py` lexes char literals, byte-raw strings
and the whole of `src/metrics/` (#1192). A `b'"'` opened a string span
that hid every later snapshot call, and the scan was non-recursive so
the 126 files under the per-language subdirectories were never checked.
Latent — no live count changed.

- **Metric values move.** A Java record's compact constructor
(`record R(int a) { R { … } }`) now opens its own function space
instead of charging its body to the enclosing class (#1160).
Expand Down
Loading