v2.1.0
A feature and correctness release on the 2.x line, and the first to
carry a deliberate exception to the stability contract. FuncSpace,
Ops, AstNode, wire::FuncSpace and wire::Ops gained an explicit
Drop impl (#1056): a source-level break (E0509 on a by-value field
move, fixed at each call site with a .clone() or a borrow) landed
under a minor because the compiler-generated drop glue recursed once
per nesting level and aborted the process on a deep tree — reachable
remotely through bca-web's 4 MiB body cap. Its (breaking) entry
under Changed below carries the full rationale. Everything else in
this release is additive or a fix.
Added
-
bca check --print-effective-configreports which exclude globs are
manifest-anchored (#1194). After #1164 a glob's meaning depends on its
origin — a--check-excludepattern resolves against the caller's
working directory, abca.tomlone against the manifest's directory —
and a single flattened array cannot express that.manifest_exclude,
manifest_check_exclude,manifest_exclude_fromand
manifest_check_exclude_fromname 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
reportedmanifestfile's directory. Each key is omitted when the
manifest contributed nothing; the*_frompair is present only when
the manifest's file is the one actually in effect, since a CLI
--exclude-fromreplaces rather than unions with it. -
Kotlin property accessors (
get()/set()) andinit { … }, Java
and Groovystatic { … }, 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 andbca checkcould
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 fromnom.functions,nargsandbca functions:
none is a callable named at a call site, and counting an accessor as a
method would makenpmbill 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.tomlor running a gate (#1169). Reports hard-tier offenders, the
resolved soft limit and its offenders, how many of each already match
a--baselineentry — so the new-entry count a reviewer actually
weighs is on screen — and names a cluster when the candidate lands on
top of an existing population. Repeatable, one candidate per metric;
honoursexclude_tests,[check] exclude, suppression markers,
[thresholds.lang.<slug>]overrides and the baseline exactly as the
run it predicts, and always exits 0 on success (1 on a tool error,
such as a candidate naming a metric this build does not gate). This
closes the gap that
--thresholdlimits are absolute and never scaled, which made the
one-command way to trial a candidate limit the one way that could not
show its soft-tier cost. -
make rustfmt-bail(utils/check-rustfmt-bail.pyplus
.rustfmt-bail-baseline.txt), a gate that blocks new match arms
rustfmt silently refuses to format (#1136). A comment inside a match
pattern makes rustfmt emit the enclosing match verbatim while
cargo fmt --checkstill exits 0, so those matches sat outside the
formatting gate entirely — 36 of them, concentrated in exactly the
per-language modules where this project's most common change shape, a
bulk edit mirrored across sibling modules, lands. The gate mirrors the
snapshot-anchor pattern: per-file counts, fail on increase, silent on
decrease,--updateto ratchet, wired intomake lintand therefore
make pre-commit/make ci. It reports the two distinct causes it
cannot tell apart — a hoistable in-pattern comment, and a
macro_rules!body rustfmt cannot parse, which is permanent — so the
baseline does not send the next reader hunting for a comment that
does not exist. -
make worktree-setup: one idempotent bootstrap for a fresh clone or
git worktree(#1171). Checks out the integration corpora under
tests/repositories/and the Python-bindings venv, classifying each
submodule first so it is a ~100 ms no-op once the tree is set up. It
escalates togit submodule update --forcefor the interrupted-
checkout state that a plain re-run cannot repair — a plain re-run is a
silent no-op there, because the recorded SHA already matches HEAD —
and refuses to force a submodule that also carries local
modifications. -
Per-language threshold overrides in
bca.toml(#1141). A
[thresholds.lang.<slug>]table layers over the global[thresholds]
per metric, keyed by the same language slugs--languageaccepts, so
a polyglot repository can apply the per-language recommendations
#1140 published instead of picking one number and baselining the
difference. An unknown slug is a hard error with a did-you-mean hint;
a file whose language is not detected falls through to the global
table. The soft tier is derived from each language's resolved hard
limit rather than the global one — otherwise a loosened language's
soft threshold would sit below its hard threshold and exit code 5
would become silently reachable for every function between them — and
a soft limit looser than its hard limit is now rejected outright.
--print-effective-configrenders one fully resolved table per
overridden language. Additive manifest surface; not aSTABILITY.md
event. -
big_code_analysis::vcs::BlameSession, a per-thread handle obtained
from the newPerFunctionBlame::session(#1117). It carries the
thread-local repository handle, its object cache, the parsed
.mailmap, and the resolved-commit memo, so a caller blaming many
files in one repository pays each once instead of once per file;
PerFunctionBlame::per_functionkeeps its one-shot semantics by
building and discarding a session. Additive — seeSTABILITY.md. -
ConcurrentRunner::without_path_verification, which skips the
per-pathis_file()check during dispatch (#1114).FilesData::paths
is documented as a terminal file list, so the check is a safety net for
a caller that hands in something else; a caller whose own traversal
already read each entry's kind — thebcaCLI walk — was paying one
redundantstatper file. Additive: the default is unchanged. -
Benchmark harness for the metric walk, in the new workspace member
big-code-analysis-bench(#1068).cargo bench -p big-code-analysis-bench --bench scaling(ormake bench-scaling)
measures eighteen probes at three doubling nesting depths and fits
time ~ depth^k, failing when a probe's exponent leaves its declared
complexity class;--bench metric_walk(make bench-walk) runs
criterion benchmarks per metric over a deterministic, self-reporting
slice of the corpus submodules. The wall-clock assertions in
cognitive_deep_nesting_is_tractableand
tokens_deep_nesting_is_tractablemoved into the gate and the
BCA_ASSERT_SCALINGescape hatch is gone; both tests keep their
value assertions and are renamed
cognitive_nesting_is_inherited_at_depthand
tokens_count_holds_at_depth. The harness is out-of-band by design —
.github/workflows/benchmark.ymlruns it quarterly, not per-PR.
Documented in
docs/development/benchmarking.md.
The first run recorded three walks as quadratic in nesting depth
(Checker::is_else_if,Node::count_specific_ancestorsfromloc,
andelixir_is_inside_quote_blockfromnom), all through
Node::parent, whichtree_sitterresolves by descending from the
root; all three are fixed in this release (#1084) and their probes
now sit at the harness's linear bound. -
Japanese localization of the documentation. The mdBook is now
translated through the gettext workflow frommdbook-i18n-helpers
(big-code-analysis-book/po/ja.po; untranslated or stale entries
fall back to English) and deployed at
/ja/alongside
the English site, withREADME.ja.mdas a hand-maintained sibling
ofREADME.md. Fragment-linked headings carry explicit{#anchor}
ids so intra-book links survive heading translation. New Makefile
targetsbook-pot,book-po-update, andbook-jadrive the
refresh workflow, documented in
docs/development/translations.md. -
Per-PR coverage for the release/wheel smoke harnesses, closing the
drift gap that let three stale assertions block thev2.0.0cut
(#995). The integer-valued-metric JSON serialization (#530) is now
pinned by a CLI integration test
(cli_metrics_json_serializes_integer_metrics_as_integers) that
assertscyclomatic.sumserializes as a JSON integer (is_u64()),
which the existingas_f64()-coercing round-trip tests could not
catch. The previously-inline library and CLI wheel smokes were
extracted into checked-in, lint-gated scripts under
scripts/smoke/(mypy--strict/ ruff for the
Python script, shellcheck for the shell script), referenced by both
wheel workflows and runnable locally viamake smoke. A new
path-filteredsmoke-dryrun.yml
workflow runs those scripts against a cheap dev build on any PR that
touches the release/wheel plumbing, so a future metric rename or
serialization change reds a PR check instead of a release. -
wire::MAX_SPACE_SERIALIZE_DEPTH(128) andMAX_AST_SERIALIZE_DEPTH
(512): the nesting depths past whichFuncSpace/OpsandAstNode
refuse to serialize (#1056). Both are set far clear of real source:
across the 14 450-file corpus undertests/repositories(TensorFlow,
DeepSpeech, serde, …) the deepest AST is 188 levels and the deepest
space nesting is 10. The space limit is also more permissive than the
read side, where a document caps out near 61 levels —serde_json's
own 128-levelDeserializerlimit charges two levels per space.
Changed
-
Dependencies advanced past the semver-major line Dependabot is
configured to ignore:gix0.83 → 0.86,sha20.10.9 → 0.11.0,
hmac0.12.1 → 0.13.0,num-derive0.4 → 0.5,clap_mangen0.2 →
0.3, andjsonschema0.46 → 0.49 (dev only), alongside a full
lockfile refresh in the root workspace and in each of the six
excluded crates. No behaviour change and no public-API change — no
gixor RustCrypto type appears in a public signature, so
STABILITY.mdis unaffected. Two consequences are worth recording.
sha2andhmacnow sit on the RustCryptodigest0.11 trait
family, whichactix-httpalready pulled in viasha10.11. Only
the trait plumbing moved, so every emitted digest is byte-identical:
the Code Climate fingerprints were already pinned to literal values,
and the author-identity digests are pinned for the first time by the
entry below. Andclap_mangen0.3 renders a required option after
the optional ones in the SYNOPSIS, which moves<-t|--type>in
man/bca-count.1andman/bca-find.1. -
AuthorId::hashedandAuthorHashKey::applyare now pinned to
absolute digest values, derived independently from Python's
hashlib/hmacso the assertions check conformance to SHA-256 and
RFC 2104 rather than agreement with our own implementation. Every
prior assertion on these two was relational — comparing one digest
against another produced by the same build — and so held whatever the
hash library emitted. That is the wrong shape for a stored value:
src/vcs/cache.rswrites unkeyed digests to disk and honours them
across version boundaries (CACHE_SCHEMA_VERSIONtracks the on-disk
format, not the hash implementation), andAuthorId::hashed
documents the emitted digests as stable cross-report pseudonyms.
Perturbing either pre-image fails the two new tests and nothing
else, which is what the gap looked like from the inside. -
Severity prefixes moved off the message producers and onto the layer
that presents them (#609, #1199).PreprocDiagnostic'sDisplaynow
renders the bare message, with no severity prefix at all:bca preprocprints it through the CLI'swarnhelper instead of a bare
eprintln!, sowarning:is written in one place rather than baked
into five variants. Embedders that captured these and printed them
verbatim must add their own prefix. (Previously the five variants
disagreed among themselves:SelfInclusion,IncludeCycleand
NotPreprocessedcapitalisedWarning:while the two non-UTF-8
variants did not, and neither spelling matched any other CLI
diagnostic, sincewarn()has printed lowercasewarning:since
#609.) PerSTABILITY.md,Displayimpls are stable but their exact
wording is not, so this is not a breaking change. Two further
user-visible consequences, both intended: theIncludeCycleblock no
longer ends in a newline, so it is no longer followed by a blank
line; and the CSV writer's non-UTF-8 path warning now reads
warning: skipping non-UTF-8 path in CSV output: …, having moved
onto the shared helper that already words the other five formats'
(dropping its capitalised prefix and its lone use of "source path").
The remaining capitalised warnings in the library — the code-climate
empty-path skip and the walker's non-regular-file skip — are
lowercase for the same reason, as are the walk seam's two
explicit-path notices, which shed the redundantbca: warning:
double prefix #609 removed elsewhere.make check-diagnostic-prefix
(utils/check-diagnostic-prefix.py, wired intomake lint,
make pre-commit,make ciand pre-commit) blocks a further site
from reappearing. -
This project's own
nargslimit 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=Nnow gates each callable on its own
parameter list rather than onnargs.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
recordednargsentries.Every comparable tool measures the same quantity the gate now does —
RuboCopMetrics/ParameterLists, ESLintmax-params, Clippy
too_many_arguments, lizard, SonarQube S107, PylintR0913— 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 anargslimit tuned against the old behaviour, expect
fewer offenders and consider whether the limit is now looser than you
intended. -
This repository's own
bca.tomlgatescognitiveat 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
computation, nothing a consumer sees. The old limit was inert: the
measured maximum here is 20, so 25 could never fire. Re-deriving the
statistic #1140 used against this tree gives p97.5 = 15 exactly, so
the shipped default and a local re-derivation agree. Of the 18
offenders convergence surfaced, 10 were genuinely simplified and 8
suppressed with a reason.The rest of the ledger, stated plainly because it is a cost and not a
benefit: 14 further entries were added to.bca-baseline.toml,
all sitting at 15.bca check --explain-threshold cognitive=15
reports them as a cluster — "14 of 14 soft-band offenders sit at
exactly 15, the candidate limit itself … none of them can clear it
without real work" — which is the same shapeAGENTS.mdwarns about
under Price a candidate limit at both tiers. The "0 new offenders"
reading is circular: it is 0 because those 14 are baselined. What
distinguishes this from thenargscase that was rejected is that
the population extends past the limit (cognitiveruns to 20, so the
hard tier gains 18 real offenders) rather than stopping at it, and
that a baseline entry at the limit keeps a growth alarm a suppression
marker would discard.nargsstays at 7;nargs6 → 5 is tracked
separately in #1183. -
(behaviour change)
bca checkwrites its offender rows to
stdout instead of stderr (#1167). The rows are the command's
product, sobca check | wc -l,| head,| rg -cand
bca check 2>/dev/nullnow reach them; previously all four reported
an empty offender list, which reads as "this tree is clean" rather
than as an error. Everything that is commentary about the run stays on
stderr: the--- summary ---footer, the--- next steps ---
remediation block, GitHub Actions annotations, and the
bca: skipped N …/bca: filtered N …/warning:/error:
diagnostics. One exception —--report-formatwithout--output
gives the aggregated SARIF / Checkstyle / Code Climate document
stdout, so the human rows fall back to stderr rather than corrupting
a payload that parses today;--output <file>moves the document off
stdout and the rows return to it. Exit codes (including
--exit-codes=tiered), the--summary-filedigest, and the
aggregated document are unchanged. Migration: a pipeline reading the
rows through2>&1needs no change; one that captured them with
2>fileshould now use>file. -
An unrecognized or non-suppressible metric name inside
bca: suppress(...)is now reported and skipped rather than voiding
the entire marker, sosuppress(cognitive, exit)still silences
cognitive(#1168, reversing the contract pinned by #896). Skipping
can only narrow a marker's coverage, so a typo still cannot widen
scope — whereas voiding left the author believing an exemption was
active when it was not. -
Baseline schema v6.
.bca-baseline.tomlrecordsstart_lineonly
for an entry whose(path, qualified, metric)identity is shared with
another — the sole case matching consults it (#1170). Elsewhere the
field re-rendered on every unrelated edit above a baselined function,
churning diffs, hiding real value changes in review, and conflicting
on every merge between branches. Entry order keeps its line-number
tiebreak:start_linemoves from third to last in the sort key, so it
now decides only between entries sharing one identity. v2–v5 baselines
read unchanged. A baseline written by a newer schema now reports the
version mismatch by name instead of a bare serde field error; the
reverse direction cannot be fixed from here, so a v6 file handed to an
already-released pre-v6bcastill surfaces the raw error and must be
regenerated with--write-baseline. An entry that pins no line drops
it from every rendering:bca exemptionsomits the:linesuffix
(text), renders-in the Line column (markdown), and omits the
linekey (JSON);bca diff-baseline --format jsonomits
start_line. -
.bca-baseline.tomlis marked-mergein.gitattributes(#1170).
The file is generated wholesale, so a textual merge of two branches
produces hunks that are wrong on both sides; git now leaves it
conflicted as a whole and the resolution is to regenerate with
make self-scan-write-baseline-headroom.-mergerather than a
merge=oursdriver, which would need per-clonegit configand
silently falls back to a normal merge where unconfigured. -
make pre-commitandmake cinow end with a single
machine-readable verdict line —BCA_GATE: pass (gate=pre-commit)or
BCA_GATE: fail (gate=pre-commit, exit=2, stage=_pc-fmt)— replacing
the success-onlyPre-commit checks passed/CI checks passed
(#1172). Grep it anchored (^BCA_GATE:); absence of the line is a
third state (crash, kill, interrupt), not a pass.stage=is a
comma-separated list in make's report order, because-jstops
scheduling but lets running jobs finish and fail. Both gates' exit
statuses are unchanged. -
The 24 tests that depend on the integration corpora now fail with a
diagnostic naming the cause and the remedy — including that by-hand
recovery needs--force— instead ofbca's generic "path does not
exist" or a corpus-count mismatch that conflated an absent corpus with
a drifted one (#1171). -
Metric values move. A ternary's condition and its two branch
operands now each count as a Fitzpatrick Rule 9 unary condition in
abc.conditions, matching what Java, Groovy, and C# already did
(#1102).a ? !b : !cscored 1 — the?alone — and now scores 4.
Affects C, C++, Mozcpp, Objective-C, JavaScript, TypeScript, TSX,
Mozjs, PHP, and Perl. Ruby and Python are not covered by this pass
but caught up later in this same release (#1161), as did Tcl and
iRules (#1180), so the cross-language comparison ships even. -
Metric values move.
nargscounts formal parameters for Elixir
(#1142) and for Perl subroutine signatures (#1147), both of which
reported 0 unconditionally. Elixir'sdef/defp/defmacroare
Callnodes whose parameter list sits twoargumentslevels down, so
the sharedparameters-field heuristic found nothing; Perl's
signature is an unnamedfunction_signaturechild. Adefinside
quote do … endstill contributes nothing (#310), an@_-style Perl
sub still reads 0 correctly, and Bash still reports 0 (the shell has
no formal parameter list). Anonymous Perl subs read 0 pending an
upstream grammar fix. -
Metric values move. Python resets structural nesting at a
def
boundary, so a function defined inside a conditional is scored against
its own depth rather than the enclosing function's (#1149). Python was
the only family with a syntactic function-definition node that did
not, charging an inherited-conditional surcharge no sibling language
charges; adeftwo conditionals deep now scores 2 where it scored 4. -
Documented Python's per-enclosing-
lambdasurcharge on boolean
operators in the book's Cognitive Complexity → Per-language
deviations list (#1150). No behaviour change. -
Documented an upstream
tree-sitter-climitation on the book's
Supported Languages page (#1209). A pre-ANSI (K&R) function
definition whose return type wraps the declarator —int *f(a) int a; { … }, and likewisechar **,struct S *or astaticpointer
return — opens no function space underCorObjective-C, so it is
absent fromnom.functions,nargsandbca functionswhile the
orphaned body's decisions are charged to the file's unit space. The
parse produces noERRORnode, so nothing downstream can detect it.
C/C++supports no K&R form at all. No behaviour change; the paired
fixture intests/grammars/c_grammar_metrics.rsis a drift marker, so
a grammar bump that fixes the parse fails the test rather than
shifting metrics silently. -
bca opsopens the same function spaces asbca metrics, through the
same source-aware promote-and-classify predicate (#1130). The two
walks each carried their own copy of the decision and theopscopy
was byte-less, so every Elixir input came back as a bare file-level
space whilebca metricsreturned the full module/function tree.
tests/parity/ops_metrics_space_parity.rspins the agreement per
language.bca functionsandbca find --type functioncarried the
same byte-less predicate and were fixed in the same release (#1162,
below). -
Every walking subcommand exits
1when the traversal could not read
an entry — typically a directory the process cannot list (#1131). A
whole subtree drops out of the resolved set before any file is
selected, so the per-file read tally stayed zero and the run reported
success over a tree it had not read;bca checkwas the worst case,
being indistinguishable from a clean gate.bca diff --sincereports
it asUnwalkableInputsandbca vcsgates its ranking the same way.
An ignore file still prunes such a directory before the walker
descends;--excludedoes not, being a post-walk filter. -
A path named directly on the command line still overrides the walker's
--exclude/--exclude-from/.bcaignore/ manifestexclude
deny-set, but now says so on stderr, naming the glob it overrode
(#1146). Silent for a seed no language claims, so a
git diff --name-only | bca … --paths-from -pipeline does not warn
about lockfiles and Markdown.[check] excludeis unchanged: it is
gate scope, survives an explicit path, and is where a
"never gate this" entry belongs. An absolute explicit path now anchors
against the CWD before the[check] excludeglobs are applied, so a
./-prefixed glob matches every spelling of the same file. -
bca strip-commentsterminates non-UTF-8 output on stdout with a
newline, matching the UTF-8 branch, and flushes both (#1132). -
ConcurrentRunner::new'snum_jobsis now the consumer-thread count
rather than a budget shared with a dedicated producer thread, which
spawnedmax(2, num_jobs) - 1consumers and left one slot idle
(#1114). Dispatch happens on the calling thread instead. The signature
is unchanged; a caller passingnnow getsnconsumers rather than
n - 1.ConcurrentErrors::Produceris consequently never
constructed — retained so a downstreammatchstill compiles, and
scheduled for removal in the next major. -
bca's per-file output order for a directory walk is now sorted
rather than readdir order (#1114). It was never specified, and the
parallel walker would otherwise vary it run to run; sorting also makes
it independent of the filesystem and the machine. Any consumer that
pinned the previous order sees a one-time reshuffle. -
A file that disappears between the walk and its analysis is now a tool
error (exit 1) rather than a warning-and-exit-0 (#1114). The CLI opts
out of the runner's redundantis_file()re-check, so such a path is
no longer silently skipped during dispatch; it fails at the read and
is counted by the sameread_failuresguard that already refuses to
report a result derived from a partially analysed input set (#1098).
The previous silent skip was the inconsistency. -
Retuned the
[thresholds]tablebca initscaffolds, deriving each
limit from published thresholds plus a 20-language corpus measurement
(#1140).cognitive25 to 15 (SonarSource's own default for the
metric),nargs7 to 5 (RuboCop's value; 7 fired on under 1% of
functions and so could not catch anything),abc50 to 40, and file
size split into aloc.plocworking limit of 600 withloc.sloc
demoted to a 1200-line bloat backstop (#1138).cyclomatic,
nexits,halstead.effort,nom, andwmcare unchanged. Existing
bca.tomlfiles are unaffected; this changes what a freshbca init
writes. The derivation, a per-language override table, and
per-use-case profiles are in the book's new Choosing thresholds
recipe. -
make testnow runs the suite throughcargo-nextestwhen it is
available, matching CI, and falls back tocargo testotherwise
(#1120). nextest schedules every binary's tests into one global pool
rather than finishing each test binary before starting the next. Set
NEXTEST=to force the fallback, or point it at a specific binary.
nextest's default profile disables fail-fast so a local run still
reports the whole failure set, ascargo testdid. -
Corpus snapshot tests now assert an exact per-corpus file count, and
separately that each resolved file reached its snapshot assertion
(#1123). A traversal or glob change that silently analyzed fewer files
previously still passed while verifying less than it claimed. A
deliberate corpus bump must update the expected count alongside the
snapshots. -
(breaking)
FuncSpace,Ops,AstNode,wire::FuncSpace, and
wire::Opsnow implementDrop(#1056), so fields can no longer be
moved out of one by value:let m = space.metrics;becomes
let m = space.metrics.clone();orlet m = &space.metrics;
(E0509). The compiler-generatedDropglue recursed once per
nesting level and aborted the process on a deep tree — reachable
throughbca-web's 4 MiB body cap — and an explicitDropthat
hoists descendants into a flat work list is the only way to break
that chain.STABILITY.mdreserves source-level
shape breaks for a major bump; this one is landed under a minor as a
deliberate, documented exception, because the alternative was leaving
a reachable remote process abort open until3.0. The mechanical fix
at each call site is a.clone()or a borrow; 13 sites inside this
repository needed it. -
Repository layout, no shipped-library change: the twelve helper
scripts that used to sit in the repository root moved intoutils/,
joiningcheck-tools.shanddeploy-book-to-gh-pages.sh. This
covers every gate run bymake pre-commit/make ci
(check-versions.py,check-snapshot-anchors.py,
check-manpage-assets.py,check-grammar-marker-sync.py,
check-enums-codegen-drift.sh,check-grammar-crate.py), the
scripts coupled to them (check-grammars-crates.shand each gate's
*-test.pyself-tests), andverify-name-only-churn.py. Each script
now resolves the repository root asparents[1]of its own location
rather thanparent, so it still runs correctly from any cwd, and
the two self-tests that stage a copy of their gate into a tempdir
stage it under<tmpdir>/utils/so the tempdir keeps standing in for
the repository root. Callers were updated in lockstep: the
Makefile,.pre-commit-config.yaml(both theentry:commands and
the^-anchoredfiles:triggers),.github/workflows/ci.yml, and
.taskcluster.ymlnow invoke them asutils/<name>. Contributors
invoking a gate by hand need the new prefix, e.g.
./utils/check-snapshot-anchors.py --update. -
Internal, no behaviour change: the crate's shared
#[cfg(test)]
helpers moved out ofsrc/tools.rsinto a new test-only
src/test_support.rs, retiring that file'sloc.slocbaseline entry
(#1066);python_comprehension_clause_nestingtakes theNesting
struct rather than three positionalusizeparameters, completing
the threading started in #1062 (#1070);increase_nestingdoes the
same at its 43 call sites across the 19 per-language cognitive
modules and the sharedjs_cognitive!macro (23 language impls),
which now hold theNestingstruct end to end instead of
destructuring it into three same-typed locals and rebuilding it, with
theconditional + function_depth + lambdasum folded into a new
Nesting::total()(#1086); the two statements that make up the
function-boundary rule moved behind a sharedenter_function_boundary
helper, replacing eighteen longhand copies and leaving Elixir and the
js_cognitive!macro visibly opted out at their call sites (#1103);
andnode_text's safety documentation no longer describes a UTF-8
char-boundary panic that cannot occur for a&[u8]parameter, with
the same-parse precondition now stated on theGettertrait (#1059).bca.toml's
exclude_testscomment, which claimed the option does not lower
loc.sloc, was corrected — #722 made it do exactly that (#1066).
Performance
-
finalizeno longer re-derives a parent space's HalsteadStatsand
MI after every child merges into it (#1106). The per-child pass was
three map traversals over the parent's accumulated vocabulary for a
result the parent's own finalize overwrites —O(children x vocabulary), quadratic in a file's function count. Only the WMC third
is load-bearing there (wmc::Stats::mergedispatches on the parent's
recordedspace_kind), so only it survives in the pop arm. Metric
values are unchanged. On the widest corpus file (1,808 top-level
spaces) this is ~10% of the walk;Limits::defaultcaps files at
64 KiB, so the corpus average does not move. -
The per-metric unit-test modules compute only the metric family they
assert plus its declared dependencies, instead of all thirteen
(#1127). Single-threaded per-run minima of the all-features lib test
binary: CPU 4.64 s to 4.20 s, and the 2,317-testmetrics::tranche
alone 1.16 s to 0.95 s. Values are unchanged, pinned by a new
metric_selection_paritytest asserting a restricted walk reproduces
the full walk's per-space values for every metric in the selection's
resolved closure. -
The workspace's 68 integration test files are now 12 directory test
targets, and[profile.dev]setsdebug = "line-tables-only"
(#1124). Test binaries drop from 13.03 GB to 1.74 GB,target/debug
from 18.8 GB to 4.9 GB, and a relink after a one-linesrc/lib.rs
edit from ~90 to ~28 CPU-seconds. No test bodies changed; the
before/aftercargo nextest listsets were compared to prove nothing
was dropped. -
The VCS per-function perf fixture builds 50 commits rather than 200,
with its wall-clock budget re-derived from 30 s to 8 s at the same
57x headroom (#1125). Cuts 300gitspawns and roughly halves the
vcs_per_functionbinary. Its work-product assertion was tightened
from "some function has history" to exact per-function commit counts,
so a shrunk fixture cannot pass while covering less. -
CLI integration fixtures are served from one shared, content-addressed
directory instead of being rewritten per test (#1126). -
bca checkcomputes only the metric families its resolved thresholds
read, instead of the whole suite (#1113). Over
tests/repositories/DeepSpeech(12.7k files), median user CPU of five
runs: a one- or two-metric gate falls from 28.6–29.2 s to 22.4–23.3 s
(1.24–1.28×). A gate naming nine families — this repository's own
bca.toml— is unchanged, since it already selects nearly
everything; the ~22 s parse-and-walk floor bounds the saving. -
bca diff --sincebuilds both sides' metric sets in memory rather
than writing one JSON document per source file to a temp tree and
immediately re-walking, re-reading and re-parsing it (#1116). Each
tree is reduced to its metric values by a collector running alongside
the walk, over a bounded channel, so the trees are dropped as they
arrive rather than all held at once. Ontests/repositories/DeepSpeech
(12,732 files), median of three: wall 9.42 s → 7.62 s, system time
3.59 s → 2.34 s. Output is byte-identical.Peak memory rises: 437 MB → 599 MB (+37%) on that tree. The
MetricSetfor a side is now accumulated during its walk instead of
in a separate pass afterwards, so the two overlap — that overlap is
what buys the speed. Draining after the walk instead of during it
would take the same tree to 831 MB, which is what the bounded channel
and the concurrent collector exist to avoid. Size CI containers
accordingly for very large trees. -
The CLI's directory walk runs on
ignore's parallel walker instead of
its single-threaded iterator, and the worker pool no longer reserves a
slot for a producer thread that finished almost immediately (#1114).
Walking DeepSpeech in isolation falls from 100.8 ms to 33.6 ms (3.0×);
a fullcheckover it improves ~1.12×. The resolved file list is now
sorted, so per-file output order is deterministic and independent of
readdir order — previously it followed the filesystem's own ordering. -
The walk's five result channels are plain
crossbeamsenders rather
thanMutex<std::sync::mpsc::Sender<_>>, so workers no longer take a
lock per file (#1119). No measurable throughput change at--jobs 32
or--jobs 64on a 16-core host; the lock was taken once per file,
never per record. The change removes a global serialization point and
four unreachable poisoned-lock branches. -
The debug-build ancestor-chain check no longer re-derives every
parent, so an unoptimised metric walk is linear like the shipped one
(#1122).Ancestors::checkedverifiedchain.last() == node.parent()
per node on all five walks that thread a chain, andNode::parent
costsO(depth)— which made everycargo testwalkO(nodes × depth)and hit the deep-nesting regression tests hardest. The exact
assertion moved behind--cfg chain_audit(make chain-audit, plus a
chain-auditCI lane); a plain debug build keeps anO(1)
consequence of the same invariant, which catches apushmoved ahead
of the per-node computes and a droppedtruncatebut not a chain
short by exactly one. The library test suite falls from ~5.0 s to
~1.7 s,cognitive_nesting_is_inherited_at_depthfrom ~1.6 s to
~0.02 s. No shipped behaviour changes. -
Traversals that enumerate every node's children reuse one
TreeCursorinstead of building and freeing one per node, through
the new internalNode::children_with(#1112). The six per-node
consumers areNode::preorder, the suppression-marker DFS, the two
Searchwalks behindbca findand the function-space name lookup,
bca dump's tree renderer, and Python's instance-attribute scan in
metrics::npa::python— the last being 92 % of the Python metric
walk's child scans. Over 400 Python corpus
files that is 414,620 cursor allocations down to 33,328 (−92 %) and
−2.4 % walk time. Other languages reachchildrenon 3-6 % of nodes
(16 % for C#), where the effect is under 1 %. The Python bindings'
mirror of that walk —Node.walk()andNode.descendants_by_kind()—
hoists a cursor the same way. Every one of the six is pinned by the
child_scan_cursorscounter, so reverting one is a test failure
rather than a silent allocation per node. Metric values are
unchanged. -
Every file destination and terminal dump writes through an
explicitly-flushed 64 KiB buffer, replacing the rawFileand
LineWriterhandles the incremental serializers wrote through one
structural token at a time (#1115). A 165-filemetrics --format json --output-dirrun falls from 4,757,028write(2)calls to 303 (1.55 s
→ 0.13 s); the 12 MB--outputaggregate from 4,757,194 to 197
(3.36 s → 0.20 s); themetricstext tree from 1,524,444 to 165;
check --output-format sariffrom 5,622 to 16. The terminal dumps
emit in bounded chunks rather than one whole-document buffer, so peak
resident memory for a deeply nested file is 21 MB rather than 545 MB.
Output is byte-identical in every format. -
The Rust
exclude_testsprune no longer resolves siblings from the
node to find the#[…]run before an item (#1100). That cost
O(attributes × depth), becausetree_sitterresolves a parent by
descending from the root. The run is now read forward from the parent
the walker already carries, under a budget that grows with depth;
a parent too wide for that budget keeps the backward walk, so the
shallow-and-wide shape is unaffected. A Rust shape with an attributed
item at each of 4,000 nesting levels drops from 2.05 s to 8.1 ms
(fitted exponent 2.00 → 1.21). -
Locstores its per-space physical- and comment-line sets as a
word-array bitset instead of a hash set, making the space-stack merge
a word-wise OR (#1109). This fixes a quadratic in function-space
nesting depth — a newloc/nested-fn-rowsscaling probe fits 2.11
before and 1.10 after — removes ~7.5M hash probes over the corpus
repositories, and cuts the retained set payload roughly 29×. LOC
values are unchanged. -
The C/C++ indirect-include closure is computed once per include-graph
node in reverse topological order rather than by a fresh DFS per file,
andParser::newborrows a file's visible macro names out of
PreprocResultsinstead of deep-cloning them (#1107). Measured over a
10,918-file tree:record_indirect_includes126.6 ms → 83.0 ms, and
2.37M fewer allocations per metrics pass, with identical output. -
Opsserializes through a borrowed projection instead of cloning an
ownedwire::Opsfirst (#1110).serde_json::to_stringon a
hundred-level space nest is 5.4× faster, and a tree past the
serialization depth limit is refused without cloning it at all
(109 ms → 0.007 ms at 2,000 levels). Per-space vocabularies are sorted
before theirStrings are rendered, makingAst::ops~22% faster on
vocabulary-heavy input. Output is byte-identical in every format. -
HalsteadMaps::operatorshashes itskind_idkeys with the crate's
integer hasher instead of SipHash-1-3, closing the gap left by #1069
(#1108). Output is bit-identical. The text-keyedprimitive_operators
andoperandsmaps deliberately stay on SipHash: their keys come from
the analysed source, so hash-flooding resistance is load-bearing. -
guess_languageevaluates its extension → modeline → shebang
precedence lazily, so a file with a recognised extension no longer
runs the Emacs/Vim modeline regex scan, and an already-lowercase
extension is borrowed rather than reallocated (#1111). Detection
results are unchanged. -
Tree::newreuses onetree_sitter::Parserper thread instead of
constructing one per file, saving ~2.5% of parse time on trees of
small files (#1118). Internal only — no public API change. -
#[cfg(...)]predicate classification is now linear in the attribute
body rather thanO(len²)on deeply nested predicates such as
all(all(all(…test…)))(#1105). Every operand previously rescanned the
whole remaining tail probing for a top-level comma; commas are now
bucketed by paren depth in one forward pass and each region queries its
own bucket. This path is reached from every Rust attribute under
--exclude-tests— which this repository's ownbca.tomlenables — so
a machine-generated or adversarial source file was a denial-of-service
vector. Classification behaviour is unchanged, verified against the
previous implementation over millions of generated predicates. The
depth-50 000 regression test drops from ~73 s to ~0.04 s. -
Dependencies now build at
opt-level = 1under the dev and test
profiles (#1121). The tree-sitter runtime and every grammar are C/C++
libraries compiled bycc-rs, which forwards Cargo'sOPT_LEVEL, so
they were previously parsing at roughly half speed in test builds.
Workspace members are unaffected and stay fully debuggable; the cost is
a one-time dependency rebuild and a slower cold build. Deliberately 1
rather than 2, which measured slower on the unit suite. -
Corpus snapshot tests size their worker pool from
available_parallelism()instead of a hardcoded 4 jobs, which had left
three consumer threads analyzing DeepSpeech's 1042 files (#1123). -
The ancestor chain now reaches the per-language metric bodies, retiring
the last per-nodeNode::parentcalls in the walk (#1096).
Getter::get_op_type(and its_with_codevariant),Abc::compute,
Npm::compute,Npa::compute,Cyclomatic::compute/
compute_with_options, andChecker::is_useful_commentgained an
Ancestorsparameter; the ABC condition walkers take the slot's
parent from the caller that descended from it; and the
comment-removal walk behindbca remove-commentsmaintains a chain of
its own. All arepub(crate), so the published API is unchanged, and
metric values are unchanged for every language. Python'sCyclomatic
elsearm went the same way: it climbed two links through
Node::parent_grandparent_match, which no search for.parent()
finds at the call site. Four new probes and three new controls guard
the classes:halstead/nested-notfitstime ~ depth^kat 1.99
before and 0.99 after,abc/nested-ifat 2.00 and 1.14,
cyclomatic/nested-ternaryat 2.06 and 1.27, and
loc/nested-quoteat 2.00 and 1.03. At depth 4000 those four
drop from ~478 ms to ~0.57 ms, from ~808 ms to ~3.3 ms, from ~1.13 s
to ~3.4 ms, and from ~9.6 s to ~9.2 ms; theirhalstead/nested-paren,
abc/nested-block, andcyclomatic/nested-andshape controls hold at
0.97-1.31 either side. Per #1088's lesson, the primitives were checked
too: the ABC walkers'Node::previous_siblingcarried the same
O(depth)(ts_node__prev_siblingopens withts_node_parent) and
now scans the known parent's children instead, through the new
Node::previous_sibling_under. -
The ancestor chain now reaches the predicates #1084 left climbing, and
theops,bca function, and suppression-marker walks maintain one of
their own (#1088). The JS-familyChecker::is_func/is_closure
name-binding walk, Ruby'sChecker::is_closureblock-versus-lambda
test, Elixir'sGetter::get_func_space_name, and the
suppression scan each resolved ancestors withNode::parent, which
tree_sitteranswers by descending from the root.Checker::is_func,
is_closure,Getter::get_func_name,get_func_space_name, and
NArgs::computegained anAncestorsparameter to carry it; all are
pub(crate), so the published API is unchanged. -
Elixir's
Npm/Npa::computeno longer run the class-space
classifier at all (#1088). Both opened with
is_func_space_with_code, which cost a source-text keyword scan per
node and, fordef-shaped calls, an ancestor walk asking whether the
call sat inside aquotetemplate. That walk's answer was always
discarded: thedefmodulekeyword check immediately below admits
exactly the nodes the classifier would have, and rejects every node
the walk was consulted for. Deleting the call removes the work rather
than making it cheaper. Counts are unchanged, pinned by two new tests
over adefmodulenested inside aquote. -
Node::wraps_any— reached fromis_childandhas_sibling, and so
from every language's checkers and getters — scans a node's children
with a cursor instead of chainingnext_sibling()(#1088). The chain
#217 introduced was premised on a sibling step beingO(1); it is
not, becausets_node_next_siblingresolves the parent first, so the
scan costO(children × depth). This was the dominant term behind the
JS closure classifier: the newnom/nested-arrowprobe fits
time ~ depth^kat 1.97 before and 1.03 after, and at depth
4000nomover nested arrow functions drops from ~17.6 s to ~6.3 ms,
while itsnom/nested-declared-functionshape control — the same
nesting written withfunctiondeclarations, which need no ancestor
walk — holds at 1.08 either side. Ordinary input benefits too: a
metric walk over the 384-filepdf.jscorpus drops from ~443 ms to
~370 ms. Metric values are unchanged for every language. -
The
opswalk builds the file-level vocabulary once instead of once
per closing space.finalizerebuilt the innermost still-open space's
operator and operand lists on every call — that is, every time the
walk left a function — and every result but the last was immediately
overwritten, so a file with F function spaces rebuilt the root's whole
vocabulary F times. Only the root needs the trailing rebuild, and it
now happens once, inops_inner, after the walk drains. On
hlo_instruction.cc(4 057 lines, ~180 spaces)bca ops -O json
drops from ~39 ms to ~27 ms per run — faster than before the #1091
sort was added, which the redundant rebuilds would otherwise have run
F times over. Output is byte-identical. -
The metric walk carries its ancestor chain down the traversal instead
of rediscovering it withNode::parent, whichtree_sitterresolves
by descending from the root (#1084).Checker::is_else_if(13
languages),Loc's declaration gate (8 languages), Python's
cognitiveboolean-operator walk, and Elixir'squote-template
lookup inNomeach costO(depth)per node and were therefore
quadratic in nesting depth. The three depth-scaling probes covering
them fittime ~ depth^kat 1.97 / 1.95 / 2.01 before and
1.14 / 1.12 / 1.02 after, and moved from the harness's quadratic
bound to its linear one; at depth 1000,nomon nested Elixir
quoteblocks drops from ~260 ms to ~2 ms andlocon nested C
declarations from ~62 ms to ~1 ms. Metric values are unchanged for
every language. -
Loc's per-line sets and the cognitive nesting map no longer pay for
SipHash and incremental rehashing (#1069). The line-number sets and
the node-id keyed nesting map are keyed by integers this crate
produces itself, so hash-flooding resistance buys nothing; both now
use the crate's fast integer hasher, and the nesting map is sized up
front from the subtree's node count.corpus/walk/locmeasured 7%
faster and the depth-1000 cognitive shape 12% faster on an
interleaved paired benchmark; output is bit-identical.
Fixed
-
C, C++, Mozcpp and Objective-C functions whose declarator is obscured
by an unexpanded function-like macro now report their own arity rather
than the macro's (#1213).RUN_STATS_METHOD(allocate)(JNIEnv *env, jclass clazz)— the JNI shim idiom — nests onefunction_declarator
directly inside another, and since #1200nargsread the innermost,
so the macro's(allocate)was the answer and the function's own
arguments were discarded. TensorFlow's fourrun_stats_jni.ccshims
all reported 1 while declaring 2, 3, 4 and 3;bca check --threshold nargs=1found one violation in that file and now finds five. The
multi-argument spelling moved the other way,void MACRO(a, b)(int x)
having reported the macro's 2 rather than the function's 1.Neither language permits a function to return a function type (C11
6.7.6.3p1, C++[dcl.fct]), so the direct nesting is not a declarator
chain and the rule is structural rather than a guess about macros: a
legitimate function returning a function pointer,
int (*fp(int a, int b))(int c), interposes a
parenthesized_declaratorand does not move, nor does C++
operator()— the one construct whose source text resembles the
shape, at 1,546 function spaces across the corpora, none of which
nests: the grammar emits a singleoperator_namewith the parameter
list as its sibling.The space keeps the macro's name, so the 44 names #1208 recovered
are unaffected: after##pasting the real symbol is not in the
source at all, and the macro is the token a reader greps for. Arity
now comes off the outer declarator and the name off the invocation it
wraps, which retires #1208's same-node pairing in favour of one
function, one walk.46 function spaces change across the corpora, all in files with no
snapshot coverage. 27 are macro shims, fixed. The other 19 are
TF_ASSIGN_OR_RETURN(…); if (…)statements that tree-sitter recovers
into this same shape, where the outer "parameter list" is theif
condition; recovery trees have never been inside the walk's contract
and those numbers were not arities before the change either. -
C, C++, Mozcpp and Objective-C function spaces whose declared name
sits under an extra declarator layer now carry that name instead of
null(#1208). Two spellings were affected: a function returning a
function pointer,int (*fp(int a, int b))(int c), and the
macro-obscured declaratorRUN_STATS_METHOD(allocate)(JNIEnv *env)
that JNI shims use. Both put afunction_declaratorin the slot
get_func_space_nameexpected an identifier in, so the name resolved
to nothing. The four getters now take the name from the same
declarator walknargshas taken the arity from since #1200, so the
two answers about one function can no longer disagree. (#1213, above,
then moved the macro spelling's arity to the outer declarator while
leaving its name where it is, so for that one shape the two answers
come off two nodes of the same walk rather than one.)Three surfaces change with it:
namein the metric output,bca functions, which had been rendering these as a rederror:line, and
thebca check/.bca-baseline.tomloffender key, which had been
the line-dependent<anon@L…>. A C-family baseline holding such an
entry needs one refresh, after which the key is stable across line
drift like any other named function.Six spaces move the other way, all of them inside
ERROR-recovery
subtrees, where no declarator rule holds and any strategy's answer is
arbitrary: two lose a name they had (one of which had been reporting
anifstatement's callee as a function name) and four are renamed.
One of the renames is a regression to weigh when refreshing a
baseline: an annotation macro carrying an argument —
T *f() TF_LOCKS_EXCLUDED(mu_), the TensorFlow / Abseil idiom — now
names the space after the macro, so two members of one class sharing
an annotation share one offender key. Measured overDeepSpeechand
pdf.js(14,269 files): 46 spaces named, 2 un-named, 4 renamed, for a
net 44 fewer nameless spaces. -
C, C++, Mozcpp and Objective-C functions whose return type is a
pointer or a reference now report their real arity instead of 0
(#1200). C declarator syntax nests outward from the declared name, so
FILE *f(int a, int b, int c)puts the parameter list on a
function_declaratorwrapped by thepointer_declaratorthe return
type contributed;nargsread the wrapper, found no parameters and
reported nothing.int **,int &,Foo &&,static int *and a
member function returning a reference were all affected. A function
returning a function pointer —int (*fp(int a, int b))(int c)—
is fixed in the same walk: it had been reporting(int c), the
return type's list, rather than its own.A C++11
[[…]]attribute on a function no longer hides its
parameters either.attributed_declaratoris the one declarator rule
that puts its declarator first, so
int f(int a, int b) [[deprecated]]had always reported 0. The GNU
__attribute__((…))spelling was never affected — every one of the
four grammars absorbs it into thefunction_declaratorinstead of
wrapping it.A C++ conversion operator is explicitly excluded from the walk:
operator int (*)(int x)takes no arguments however many its target
type has.Metric drift. Serialized
nargsrises wherever such a function
appears — 3,336 recorded values across 276 files of theDeepSpeech
corpus, and nothing falls. Since #1196 made the gate read a
callable's own parameter count, these functions were invisible to
bca check --threshold nargs=Nand can now trip it. -
C's
(void)marker is no longer counted as a parameter.int f(void)
declares nothing, but the grammar emits a realparameter_declaration
for thevoidand everynargsfilter counted it, sof(void)and
f(int)both reported 1. Fixed for C, C++, Mozcpp and Objective-C,
in both the function and the closure channel — an Objective-C block
literal^(void){ … }counted the marker too, because its arm
matched parameter kinds positively instead of routing through the
sharedcount_argshelper, and so never consulted the hook (#1218).
The distinction needs the source bytes rather than the tree — an
unnamed parameter is the same shape and really is one argument — so
Checkergained anis_empty_param_markerhook that defaults to
falseand reads them.Metric drift. 24 recorded values fall to 0 in the
DeepSpeech
corpus, all inpywrapfst.cc, its only(void)definitions. The
block-literal half moves nothing recorded:^(void)appears in two
corpus files, both.mm, which route to C++ — where blocks are not a
construct. -
A comment written inside a parameter list is no longer counted as a
parameter (#1201). tree-sitter attaches such a comment as a direct
child of the parameter-list node rather than inside the parameter it
documents, and everynargsfilter listed punctuation only, so
int h(int a /* one */, int b /* two */)reported 4 and the C++ idiom
for a deliberately unused parameter,void f(int /*unused*/),
reported 2. Fixed for C, C++, Mozcpp, Objective-C, JavaScript, MozJS,
TypeScript, TSX, Python, Rust, Java, C#, PHP, Ruby, Groovy, Elixir and
Kotlin lambdas in one shared predicate. Go, Lua, Tcl, iRules,
Objective-C methods and blocks, Kotlin functions and Groovy closures
already reported the right count here and needed no fix; Perl had
carried the exclusion privately since its signature support landed.
Objective-C blocks were correct only incidentally — their arm listed
the parameter kinds it wanted rather than excluding comments, and
nothing asserted it — so #1218 routed them through the shared
predicate and added the fixture.Metric drift. Serialized
nargsfalls wherever a signature
carries a comment — across theDeepSpeechcorpus, 854 recorded
values, including kenlm'sDontBhiksha::DontBhiksha(7 → 4) and
ReadBackoff(3 → 2). A signature that previously tripped
bca check --threshold nargs=Non its comments alone now passes. -
Serialized shape.
npmandnpaemission is now decided by the
space's kind alone, for every language (#1203). #1197 declared the rule
— containers and the fileunitroot carry the block, a function space
never does — but enforced it only for the ten languages it routed
through a shared predicate. The other seven kept enabling from their
own grammar node kinds and disagreed with it in both directions:- A Go or Rust
structdeclared inside a function body put the block
on that function space. Across theserdecorpus that was 39
function spaces in 10 files, most of them#[test]functions
declaring a localstruct. - A C++
namespace, and any file root whose only container sat inside
a function, carried no block. In the last case the counts were
serialized nowhere at all — they reached the root's_sumfields,
which nothing emitted — so merely suppressing the function-space
block would have deleted them from output rather than relocating
them.
The space kind is now the only input, recorded once per space by the
walker, so there is no per-language surface left to deviate on.
Practically: every container space and every file root of a language
with class-shaped constructs carries both blocks, and no function space
does. In this repository's integration corpora that adds a block to
1,214 file roots and 1,332 C++ namespaces, and removes one from 39 Rust
function spaces.No metric value changed. The counts always rolled up through every
enclosing space regardless of which one serialized them; this moves
which space reports them. Thresholds are unaffected —bca checkreads
metrics.npmdirectly throughMetricScope, which never consulted the
emission gate.STABILITY.mdalready places which space carries which
block outside the shape contract.Languages with no class-shaped construct at all — Bash, C, Lua, Perl,
Tcl, iRules — still emit neither block, rather than gaining an all-zero
one on every file root. Go remains the one language whosenpm/npa
appear only on the root, because its space tree has no container kind;
sincebca checkgates both on container spaces, nonpmornpa
limit can fire on Go source. Both are documented in the metrics guide.One consequence reaches a front-end. The Python
to_sarifbinding
walks serialized JSON and skips a metric whose key is absent, so it
silently dropped everynpm/npaoffender on a C++namespace—
a kindMetricScope::Containeradmits and the CLI has always gated,
reading the struct rather than the JSON. The two front-ends now agree,
and SARIF output may gain namespace-scoped findings it was missing. - A Go or Rust
-
Serialized shape.
npmandnpano longer emit an all-zero block
on function spaces (#1197). They enabled themselves from
Checker::is_func_space, which answers "does this node open a space",
not "is this a scope that owns methods and attributes". C#, JavaScript,
MozJS, TypeScript, TSX, PHP and Ruby therefore carried a block on every
ordinary method, and #1184 extended that to Kotlinget()/set()/
init { … }, Java and Groovystatic { … }and the JS-family
class_static_block— which is the inconsistency the issue reports: a
<get>space carrying OOP metrics while thembeside it did not.
Kotlin, Java, Groovy, JavaScript, MozJS, TypeScript, TSX, C#, PHP and
Ruby are affected; Python, Rust, C, C++, Mozcpp, Go, Objective-C and
Elixir gate on their own node kinds and do not move.The rule is now
SpaceKind::is_member_scope, whichwmcalready
followed and which the three metrics share as a single definition:
containers and the whole-fileunitroot carry the block, a function
space never does. The file-root roll-up is retained — an earlier
draft of this fix narrowed to containers alone, which would have
deleted the whole-fileclass_npm_sum(7,530 fields across the
integration corpus, 400 of them non-zero) and leftnpm/npa
disagreeing withwmcabout the same root.Consumers reading
metrics.npmoff a function space now find the key
absent. Across the integration corpus that removes 6,974 blocks, of
which 6,969 were entirely zero. The other five belong to a function
that lexically contains a class — a PHPnew class { … }or a
JavaScript class inside a callback — where the block was that nested
class's roll-up; it remains available on the class's own space and in
the file-root total, andwmchas always omitted it in the same
position. No metric value changed anywhere.The CSV projection is a fixed-column format and is unaffected: it
writes thenpm.*/npa.*columns on every row regardless of space
kind, carrying the real accessor values. -
Metric drift. ABC
conditionsmoves 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) : cscored
3 againsta ? (b) : c's 2), while Java, C# and Groovy under-counted
(a ? /*n*/ !b : cscored 2 againsta ? !b : c's 3). -
Metric drift. ABC
conditionscounts Ruby's and Perl'snot
keyword like!(#1182).if not bscored 0 againstif !b's 1, and a
notternary 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}andwhile {$a}move 0 →
1,if/elseif/else2 → 4, andexpr {$a ? !$b : !$c}1 → 4,
matching the value the other languages report for the same expression.
The argument andreturnslots remain unrouted. -
Metric drift. A lambda written without its optional parentheses
reports its parameter in Java and C# (#1185).x -> x + 1scored
nargs0 where(x) -> x + 1scored 1; the parameters are billed to
closure_argsas before. -
Metric drift. JavaScript-family generator functions are classified
as functions rather than closures (#1186), sonom's function/closure
split,nargs'fn_args/closure_argssplit and cognitive nesting all
move forfunction*.bca functionsandbca 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).nomandnargs
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 chargegthe same function depth. -
Metric drift. The file-level unit's line span is anchored at line 1
(#1195). A whitespace-only file reported0..0, and any file opening
with blank lines reported a span that omitted them —"\n\n\nfn a(){}\n"
gave4..4of a 4-line file. An empty file still reports0..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.tomlexcludeglob keeps applying under a directory seed
(#1189).bca metrics -p submoved 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 thebca check
gate. -
utils/check-snapshot-anchors.pylexes char literals, byte-raw strings
and the whole ofsrc/metrics/(#1192). Ab'"'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. -
utils/check-diagnostic-prefix.pydecides string and comment state
with a lexer rather than a per-line regex (#1219). Three inputs made
it read a raw-string open where there was none — a plain string whose
closing quote followsr("dir/r"), and an unterminatedr"in a
trailing//or a/* … */comment — after which every line to the
next quote was skipped and any severity literal in between was a
false clean. Neither cheap fix works: no lookbehind can express the
first, since what distinguishes it is that the quote closes a
literal, and stripping comments by regex would truncate"http://x"
mid-literal and open a phantom span of its own. The walk is ported
fromcheck-snapshot-anchors.py, which needed the identical machine;
both sides now name the other. One deliberate widening: a severity
quoted inside any comment is skipped, where before only a whole-line
comment was. Latent — no live count changed, verified by diffing both
scanners over all 559 tracked Rust files. -
The book and
STABILITY.mdscope the
object-oriented emission rule tonpmandnpa(#1220). Both said
all three blocks follow the space's kind with no grammar deviating in
either direction; that holds fornpm/npa, which are gated
centrally by kind, and not forwmc, which is decided per language.
Go emits nowmcblock on any space including the file root while its
npa/npmdo appear there, and anamespacespace — a C++ or
Mozcppnamespace, or a Rubymodule— carriesnpm/npabut no
wmcbecause its member functions are free functions rather than
methods of a class. Both narrowings are now asserted in
container_scope_tests.rs, which previously tracked onlynpmand
npa— nothing pinnedwmc's scope, which is how one rule came to
describe three blocks. -
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).
cognitive,cyclomaticandnexitsmove from the record's class
space onto the constructor's;nom,npm.class_methods/
class_npm_sumandwmceach count it; andnargsreports the
record's component count, so the compact and canonical spellings of
one constructor score identically.bca checkcan flag a compact
constructor for the first time — previously it could never be flagged
however complex it got. -
Metric values move. The JS-family cognitive function-boundary
rule now applies tomethod_definitionand tofunction_expression
nodes theCheckercalls functions, not tofunction_declaration
alone, and the function-depthstopslist carries the same kinds
(#1159). A method or bound function expression defined inside
conditionals no longer inherits the enclosing nesting, and a
functiondeclared inside a method now takes the depth surcharge.
JavaScript / TypeScript / TSX / MozJS values move in both
directions — 544 corpus values up and 14 down, the increases
dominated by declarations inside IIFE module wrappers, which now take
the surcharge they always should have. -
Metric values move. ABC now counts the condition and both branch
operands of a Ruby ternary, and the condition of a Python conditional
expression, bringing both level with Java and the C family (#1161).
Ruby'sa ? !b : !cwent from 1 to 4; Python'sa if c() else b
from 1 to 2. Tcl and iRules got the same coverage through their
broader Phase 2B slot routing, which also ships in this release
(#1180). Bash's ABC is keyword-driven by design rather than
expression-driven, so its arithmetic ternary is not a gap — the
deviation table now says so, since "scores 0" and "not applicable"
read the same to a reader. -
A space's
end_lineis keyed on the node's end column rather than on
itsSpaceKind(#1163). A Perl function that is the last item in a
file reported a span one row past its parent unit and past EOF, which
is not a representable tree and is the shape that produced the
usizeunderflow in #1051 —bca checkoffender lines, the SARIF
regionand any editor integration slice source by these spans. The
SpaceKind::Unitarm's missing+ 1was never a statement about
units; it was a statement about nodes that end at column 0, which the
root always does.bca functionscomputed the same quantity a third
way with no unit case at all, and is fixed with them; sources with no
trailing newline reported a unit span one row short in every
language, observable only through the verbatim library API. -
bca functions,bca find --type functionandbca count --type functionreported nothing at all for Elixir sources (#1162). Elixir's
def/defp/defmacroare not distinct grammar productions but
Callnodes whose target identifier spells the keyword, so a
byte-less predicate cannot see them; both seams now read the source
bytes, asbca metricsandbca opsalready did. TheAst::functions
andAst::findlibrary seams and the web/functionendpoint were
affected identically. A cross-language parity test now pins that every
namedFunction-kind space in themetrics()tree appears in
functions(), which is the invariant that would have caught this seam
and #1130's together. -
[check] excludeglobs from abca.tomlare resolved
against the manifest's directory rather than the caller's working
directory (#1164), so an exemption written for the project root holds
whenbca check <file>is invoked from a subdirectory.[check] excludeis the one exclude surface documented as surviving an
explicit path — #1146 steered the agent-feedback hooks and this
repository's own dev-tooling exemptions at it for exactly that
reason — and the guarantee silently did not hold whenever the
caller's cwd was not the manifest root, which for a per-file editor
hook is unpredictable. The--excludeoverride warning added in
#1146 was anchored by the same helper and went silent under the same
conditions; it is fixed with them. CLI--check-exclude/--exclude
globs stay relative to the working directory, because that is where
the user typed them. A manifestexcludeglob under a directory
walk keeps its previous walk-root anchoring, which differs from the
manifest root only when the walk does not start there; that remaining
gap predates this change and is tracked in #1189.Migration — only if your
bca.tomlsetspathsto something other
than["."].[check] excludeglobs were previously matched
against each file's path relative to the walk root; they are now
matched relative to the manifest's directory. Wherepaths = ["."]
those are the same directory and nothing moves — which is the common
case, and why this is a fix rather than a break. Where they differ,
both directions are live, and the second is the one to check for:paths = ["sub"] [check] exclude = ["vendor/**"] # walk-root-relative: exempted before, reports now exclude = ["sub/vendor/**"] # manifest-relative: matched nothing before, exempts now
The first direction fails loudly — an offender you had exempted
starts being reported. The second is silent and is the dangerous one:
a glob that never matched anything, and that nobody would have
noticed was inert, now exempts real violations. Run
bca check --print-effective-configand confirm the resolved
check_excludelist still means what you intended. -
A threshold written with the bare
bca diff --metricalias (sloc,
ploc,lloc,cloc,blank) now overrides the same metric's
dotted spelling instead of adding a second, independent threshold
(#1165). Aliases are resolved where each layer is parsed — the
manifest and--config[thresholds]table,[thresholds.soft],
[thresholds.lang.<slug>], and--threshold— so the layers merge by
metric rather than by spelling, one(function, metric)pair emits
one offender line, and--print-effective-configprints the limit
that actually fires; its output now round-trips through--configto
an identical gate result. A single table that sets one metric under
both spellings is rejected rather than silently keeping whichever key
sorts last. -
bca check --tier=soft=RATIO(and its--headroomalias, and a
"<ratio>x"string in[thresholds.soft]) scaled the lower-is-worse
mi.*family the wrong way (#1166). A limit there is a floor, so
multiplying it by the ratio lowered it:[thresholds] "mi.original" = 20with--tier=soft=0.5resolved to a soft floor of 10, below the
hard floor it was meant to warn ahead of. The early-warning band could
never fire first, making the soft tier a silent no-op for the whole
family. The ratio now tightens each limit in its own direction — 20
withsoft=0.9resolves to 22.2223, rounded up so the band never
resolves below the exact quotient. The[thresholds.soft]
soft-looser-than-hard check, previously restricted to higher-is-worse
metrics because of this defect, now applies tomi.*too. -
A suppression marker carrying a rationale on the same line
(// bca: suppress(nargs) — threaded context) is no longer rejected
as malformed and silently inert (#1168). Anything after the metric
list is free text, with no separator required: the parentheses are the
positive signal that the comment is a marker.AGENTS.mdand the book
prescribed writing the rationale there, which is the spelling that
voided the marker. A bare verb (// bca: suppress, no list) still
takes no trailing text and warns when it carries any — no separator
set can distinguish a rationale from prose about the marker, since
-,:,//,#and the dashes are exactly what someone writing
// bca: suppress - we removed this marker, see #123reaches for, and
reading that as a marker silences every metric on its function with no
diagnostic at all. The warning now names the way out: list the metrics
you mean, or move the reason to the line above. -
Corrected the inverted doc comment on
python_apply_boolean_operator,
which described its ancestor walk as counting control constructs and
stopping at lambdas whencount_specific_ancestors's
(ancestors, check, stop)order makes it do the reverse (#1090). Adds
a test discriminating the previously-untestedExpressionListstop
arm through both routes that reach one under a lambda — a parenthesised
yieldand an f-string interpolation. No metric values change. -
make bench-scalingnow measures two axes (#1133).Probecarries an
Axis(DepthorWidth), and the newnom/wide-attributed-fn
probe sweeps one parent's child count so a walk that is linear in
nesting depth but quadratic in a parent's child count fails the gate —
the class #1100's rejected fix belonged to, which every existing probe
passed. Falsified against that fix: exponent 0.97 clean, 1.99 with it
reinstated, while all depth probes stayed green. -
bca vcs,bca vcs commit, andbca vcs trendexit0again when
their consumer closes the pipe (bca vcs … | head). The
write_textflush below made the resultingEPIPEvisible, and those
emittersdied on every I/O error, so a routine pipeline became
error: writing vcs output: Broken pipeand exit1whiledump,
metrics, andopspiped into the same consumer exited0. The
BrokenPipeexemption the rest of the CLI applies is now shared by
thevcsfamily; a genuine write failure still exits1. -
bca vcs,bca vcs commit, andbca vcs trendexit1when their
report cannot be written to stdout. All three emit compact JSON, and
std::io::Stdoutis aLineWriterover a 1 KiB buffer: a document
containing no newline and shorter than that was accepted into the
buffer and only written by the exit-time cleanup flush, whose error is
discarded — so a full disk or a closed>target produced exit0
with no output at all. #1132 fixed the walk's stdout paths and missed
these three, because every othervcsformat (yaml,toml,
markdown,html,csv, the default table) contains newlines and
was already surfacing the failure.path_io::write_stdout_parts_or_die
carried the same missing flush; no shipped subcommand can reach it
with a newline-free document, so that half was latent. -
Every crate the root manifest
excludes — the five vendored
bca-tree-sitter-*grammars andenums— now roots its own
workspace (#1145).excludedenies membership without terminating
cargo's upward search for a workspace root, so inside a git worktree
under.claude/worktrees/that search escaped the worktree and
resolved against the main checkout, where the crate's path is neither
a member nor excluded;cargo metadataerrored and tookcargo fmt --alland everymake pre-commitstage chained behind it with it.
.claude/worktreesis excluded from the root workspace for the
mirror-image reason. -
The
tree-sitterruntime is=0.26.11, up from the=0.26.9that
v2.0.0shipped — two upstream patch releases, taken via Dependabot
and pinned in lockstep across the root manifest and every excluded
crate.tree_sitteris re-exported from the library root, so the
resolved version is visible to consumers; perSTABILITY.md, a
runtime bump rides a minor release. No grammar content moved: the
vendoredparser.csources and every external grammar pin are
byte-identical tov2.0.0. -
The vendored grammar manifests pin their tree-sitter dependencies with
=X.Y.Zrequirements rather than caret ranges (#1151):
tree-sitter-cppinbca-tree-sitter-mozcppand
tree-sitter-javascriptinbca-tree-sitter-mozjs. Both are
build-dependencies of published crates, so the loose requirement let a
downstream consumer resolve a different grammar than this workspace
builds against, and let a plaincargo updatemove one silently.
tree-sitter-languagedeliberately stays caret-ranged — it is the
ecosystem's sharedLanguageFnshim, not a grammar, and=-pinning
it makes both this workspace and downstream consumers unresolvable. -
A new gate,
utils/check-excluded-manifests.py, holds both of the
above (wired intomake lint,make pre-commit,make ci, the
pre-commit hooks, and thelintCI job). It parses manifests with
tomlliband checks the root manifest's[workspace.dependencies]
block alongside each excluded crate's own tables. -
utils/check-grammar-marker-sync.pycompares the vendored grammar
marker against its baseline with the requirement operator stripped, so
=0.23.4,= 0.23.4and0.23.4all name the same upstream version.
The literal comparison reported drift for #1151's pin tightening,
which touched no generated byte. -
make book-potwritesmessages.potto the book'spo/directory
again, and now refuses to run against an unsupported mdBook. The
target passed a relative-d po, which mdBook 0.4 resolved against
the book root but 0.5 resolves against the working directory, so
under 0.5 the pot silently landed in./po/at the repository root
andmake book-po-updatethen failed on a missing file. The
destination is now absolute. A version guard was added alongside it:
mdbook-i18n-helpers 0.3.x pairs only with mdBook 0.4.x, and the
mismatched pair that does not error — helpers 0.4.x — extracts
fenced code blocks one entry per line instead of one per block, so
the followingmsgmergemarks every code-block entry fuzzy and
rewritespo/ja.poagainst msgids the pinned toolchain never
produces.docs/development/translations.mdnow pins both halves of
the toolchain and describes both failure modes. -
Comment-only rows are no longer counted as physical lines of code in
Tcl, iRules (#1135), and Perl (#1137). Both defects let a token reach
the_catch-all that endsstats.ploc.lines.insert(start): in the
Tcl family it was the row terminator, which those two grammars alone
surface as a token child of the root and whose start row is the row it
terminates; in Perl it was the#inside thecommentsnode,
which additionally reclassified the row from comment-only to
code-and-comment. A realistic fourteen-row Tcl file with six comment
rows reportedploc 13instead of7. The Tcl family also counted
whitespace-only rows — trailing whitespace on an otherwise blank
line — as code, soblankmoves there too; a wholly empty row was
unaffected either way. PLOC,ploc_average,ploc_min/
ploc_max, and (for the Tcl family)blankmove for Tcl, iRules,
and Perl sources; no other language is affected.
a_comment_row_is_never_counted_as_codenow sweeps every language
and comment spelling, comment-before-code and comment-after-code, so
a third instance of this shape fails a test rather than shipping. -
Documented the one input class where a trailing newline does change a
LOC value (#1087). #1067 established that a trailing newline is a
formatting detail no LOC sub-metric may depend on, but whitespace-only
source violates that: most grammars collapse tree-sitter's root to a
zero-width node at end-of-input, sob" "reportssloc 1and
b" \n"reportssloc 0. This is upstream grammar behaviour and is
now stated as an explicit carve-out rather than left as an unspoken
exception. Measuring it across the tree (it had only been checked for
Rust) found the split is 20 grammars collapsing and 5 — Elixir, Tcl,
iRules,preproc,ccomment— keeping the span; both halves are
pinned per language, so a grammar bump that moves a language across
fails a test rather than silently changing a metric. No behaviour
change. See
developers/loc.md. -
bcano longer exits0when an input file cannot be read (#1098).
#1060 fixed this forcheckonly;metrics,ops,report,
functions,find,count,dump,exemptions,preproc,
strip-comments, anddiff --sinceall exited0after printing
error processing <path>: …to stderr. The guard now lives in the
shared walk layer, so any read failure is a tool error (exit1) for
every walking subcommand.diff --sincereports which side failed,
and a partial aggregate, report, tally, or preproc document is no
longer emitted — output already streamed during the walk is kept.
This can turn a previously-green CI job red: a run that tolerated
unreadable files now fails. Use--excludeto skip them deliberately. -
Every walking subcommand exits
1when an output document could not
be written — an unwritable--output-dir, a full disk — mirroring the
unreadable-input contract above (#1115). Previously the per-file error
went to stderr and the process reported success, leaving a truncated
document behind. A broken pipe still exits0. -
bca dumpandbca findno longer interleave one file's== path ==
banner with another worker's tree under--jobs N(#1115). The stdout
lock is held across banner and tree. -
bca check's unreadable-input summary readserror: N input files could not be read …rather thanerror: bca: N input files could not be read …; thebca:prefix was duplicated byerror:(#1098). -
bca checkno longer exits0when input files could not be read
(#1060). The counter backing the "no input files matched" guard was
bumped before the read was attempted, so a tree whose every file was
unreadable (permission denied, a broken symlink, a container
volume-mount mismatch) reportederror processing <path>: …on
stderr and then exited0— the worst failure mode a CI gate has.
The counter now moves only for files that were actually read, and
read failures are tallied separately: any input file that failed to
read exits1(tool error, distinct from2= gate breach) with a
summary line, because a partially analysed gate is not a passing
gate. Like the pre-existing empty-input guard, the check runs before
the gate is evaluated and is not suppressed by--no-fail, which
suppresses threshold failures rather than broken input.bca init
scaffolds its baseline through the same walk, so it inherits the
guard and refuses to pin a baseline that would silently under-record
the debt in a file it could not read. Both guards' messages are now
prefixedbca:rather thanbca check:, which misattributed an
initfailure to a subcommand the user never ran. Other subcommands
are unchanged for now; extending the same contract pastcheckis
tracked separately.read_file_with_eolis fixed on the same issue: its "≤ 3 bytes is
not worth parsing" shortcut returnedOk(None)from a barestat,
andstatsucceeds on a file the process cannot open — so a tiny
unreadable file was indistinguishable from an empty one and the
permission error the function documents never surfaced.bca check
on a 3-byte unreadable file therefore exited 0 with no diagnostic at
all, not even the per-fileerror processingline. The shortcut now
confirms readability by opening the file first; the open is skipped
for anything that is not a regular file, so the function still never
blocks on a FIFO. Behaviour is unchanged for readable files of any
size. -
Ops::operatorsandOps::operandsare now sorted in
byte-lexicographic order, sobca opsproduces identical bytes for
identical input (#1091). Both vectors were collected fromHashMap
keys, andRandomStatereseeds per map instance, so the listings
were reordered on every run — and even between two parses within one
process. That madebca opsoutput impossible to diff between runs,
check into a repository, or use as a cache key, in the tree renderer
and in every serialized format alike. The fields were documented as
"arbitrary order", so pinning them down is additive for callers; no
metric value moves, since Halstead'sn1/n2are set
cardinalities. Sorting costsO(n log n)per space over the space's
vocabulary and is paid only on theopsseam — the metric walk does
not run it. -
The
dumpAST walk no longer rebuilds its indentation prefix per
node, and no longer resolves a node's parent per node (#1054). Each
queued node carried an owned copy of its ancestors' box-drawing
prefix — a string that grows ~3 bytes per nesting level — so a
wide-and-deep tree held O(depth²) resident bytes and copied O(depth)
per node; separately, the flush-left check called
tree_sitter::Node::parent, which resolves by descending from the
root, once per node. The walk now keeps one shared prefix buffer that
is extended on descent and truncated on the next visit, and carries
each node's connector glyph on the work stack so only the node the
walk starts from needs a parent lookup. On the issue's fixture
(int main(){return ((((…1…))));})bca dumpgoes from 1.18 s to
0.05 s at nesting depth 4000 with peak RSS falling from 66 MB to
13 MB; on a wide-and-deep JavaScript fixture (1500 nested functions,
two siblings each) it goes from 4.00 s to 0.05 s. The rendered text
is byte-identical — verified across 300 files spanning the corpus
submodules — and the emitted size stays O(nodes × depth), which is
inherent to a tree drawing where every line carries its own
indentation. The mirroredmetricsandopstext dumps
(dump_metrics,dump_ops) carried the same per-entry owned prefix
and got the same shared-buffer treatment; their measured cost is
unchanged on the fixtures tried — a nested-closure chain keeps only
one stack entry alive at a time, so the quadratic term needs a tree
that is wide and deep — but the O(depth) copy per rendered line is
gone and the three walks no longer differ in shape. -
loc.slocno longer drops the final line of source that is not
newline-terminated (#1067).Slocderived its row count from an
"is this the unit span?" flag; the unit branch was correct only
because a trailing newline pushes tree-sitter's root node onto a
phantom extra row, so a one-line unterminated file reported
sloc == 0,mi.original/mi.sei/mi.visual_studio
short-circuited to0.0throughmi::inputs_are_empty, and
cloc + ploc > slocfor input such asb"fn f(){}\n/// x". The row
count now comes from the span's end column, which is correct in both
directions.
Metric drift, all languages: callers passing bytes with no
trailing newline now seesloc(andblank,sloc_max, the
*_averagevalues, and all three MI formulas) increase by one
line's worth; whitespace-only unterminated files move fromsloc 0
tosloc 1/blank 1. This reaches every entry point that does
not normalise its input: the RustSource/Ast::parseAPI and
the PythonAst.parse(code, language)staticmethod, which passes
its bytes through verbatim. Entry points that read through
read_file_with_eol/normalize_eol— the CLI, the web server's
metrics endpoints,analyze(), andAst.from_path— append a
trailing newline and are unaffected on this axis.
Metric drift, per-function spans: the same rule corrects the
opposite error wherever a grammar ends a func-space node at column 0
of a row it does not occupy — a span that used to be credited one
row too many.tree-sitter-perldoes this to the lastsubof a
file, whosefunction_definitionswallows the newline after the
closing brace; that sub'ssloccould exceed the whole file's, and
now drops by one along with the file'ssloc_max/blank_max
where it was the maximum.tree-sitter-bashdoes the same to some
function_definitions (one file in the in-tree corpus:
parse_valgrind_suppressions.sh, whose function drops fromsloc 9
to8and movesmi.seifrom 37.4 to 49.1). This drift does reach
the CLI, web server, and Python bindings. -
wire::FuncSpace::fromandwire::Ops::fromno longer recurse
(#1056). Both projected a nested tree with
spaces.iter().map(Self::from).collect(), one stack frame per nesting
level at roughly 2.3 KB each, which aborted the process at ~900 levels
on a default 2 MiB thread. They now walk an explicit work stack and
convert a 1 000 000-level chain on a 512 KiB thread. -
Serializing a
FuncSpace,Ops, orAstNodedeeper than its limit
now fails with an ordinary serializer error naming the type and the
limit, instead of overflowing the stack (#1056).serdecannot emit a
tree without one native frame per level —serialize_fieldmust run
the child'sSerializeto completion before returning — so the depth
is bounded rather than de-recursed, mirroring the 128-level recursion
limitserde_json'sDeserializeralready applies to the same
documents. -
The
cognitivemetric's nesting lookup is no longer quadratic in
nesting depth (#1062). It recovered each node's inherited nesting via
node.parent(), which isO(depth)— tree-sitter stores no parent
pointer — making the lookupO(nodes × depth). The walker now hands
each node its inherited nesting directly, so the lookup isO(1).
Cognitive values are unchanged. On shapes that exercise only this path,
whole-file analysis is now linear: nested parentheses at depths
8000 / 16000 / 32000 / 64000 take 13 / 23 / 45 / 88 ms, so a 128 KB
file completes in under a tenth of a second.The walker also no longer pre-seeds that map with the root. Nothing
read the seed — the lookup already falls back to a default — but it was
the map's only entry whenevercognitiveis deselected, so a
metric-subset run (--metrics loc,bca checkwith a threshold
subset) allocated a hash table per file for one unread entry. The two
grammars whose cognitive impl is a no-op,preprocandccomment, now
build no map at all rather than one entry per AST node.cognitive's remainingNode::parentsites are gone with it.
increment_function_depthasked every function node whether a
function encloses it by climbing withnode.parent(), which kept the
metricO(depth²)on nested definitions across its 19 call sites (22
languages, counting the four the JS-family macro expands to). It now
reads the ancestor chain the walker hands down (the #1084
mechanism, deferred out of that change), and a new
cognitive/nested-fndepth-scaling probe covers it:time ~ depth^k
fits 2.04 against the climb and 1.21 against the chain, and at depth
4000 the walk drops from ~150 ms to ~16 ms. The two remaining
per-node climbs inside the metric — Kotlin'swhen-default check and
Ruby'scase-default check, one perelsenode — read the same
chain now. Cognitive values are unchanged; the arithmetic is pinned at
depth 1000 bycognitive_function_depth_is_inherited_at_depthand
across languages by
function_depth_surcharge_holds_across_languages.Node::parentclimbs remain elsewhere in the crate — the five
Ancestors::unknown()call sites, the Halsteadget_op_type
getters, and per-node lookups in severalloc,npa/npm, and
checkerarms — all outsidecognitiveand outside the probed
walks, and tracked in #1088. Operators analysing untrusted input
should still bound request concurrency and input size. -
Deeply nested source no longer costs quadratic time in the
tokens
metric (#1052).Tokensdecided whether a leaf sat inside a comment by
walking that leaf's ancestor chain — andNode::parentis itself
O(depth), so the metric ran inO(leaves × depth²). A 2 KB file of
nested parentheses took ~19 s and a 4 KB one over two minutes, with
parsing itself staying flat, which made it an unauthenticated CPU
exhaustion vector againstbca-weband a way to stallbca checkin
CI. The walker now propagates comment membership down the traversal in
O(1)per node, sotokensis linear: measured at nesting depths
1000 / 2000 / 4000, the metric now costs 4 / 5 / 6 ms, and the same
files analyse end-to-end in 74 ms / 285 ms / 1.1 s. Token counts are
unchanged — comment-internal leaves (Rust doc-comment markers and
content) are still excluded, now by an inherited flag rather than a
rediscovered one.Nesting-heavy input is faster but still superlinear overall, so
untrusted deeply-nested source is not yet safe to analyse unbounded.
Node::parentisO(depth)and is used per-node in several other
places:cognitive's nesting-map lookup dominates the nested-paren
shape measured above, whileLoc'scount_specific_ancestors
(C-family, Java, C#, Go, Groovy, Objective-C) and Elixir's
is_inside_quote_blockare superlinear on other shapes — the latter
behind no metric selection, so it cannot be deselected. Tracked in
#1062. -
A Rust doc comment ending at EOF without a trailing newline no longer
crashes or miscounts (#1051).Locdiscounts the row that a
DocComment's scanner consumes along with its newline, but at EOF
there is no newline left to consume, so the node ends on its own start
row and the row was discounted anyway. The symptom split by where the
comment sat:- On the first row (
/// xas the whole file): the analyzer
panicked — in debug at the subtraction, in release as a hash-table
capacity overflow while inserting comment rows. - On any later row (
fn f() {}\n/// x): release builds did not
crash. They silently reportedclocone too low, which also shifted
blankand the Maintainability Index's comment percentage.
This changes metric values. Any Rust file whose last line is a doc
comment with no trailing newline now reports one morecloc(and one
fewerblank) than a2.0.0release build did; two such comments
shift by two. Re-check.bca-baseline.tomlentries and thresholds for
affected files.Reachable from
analyze/Source(the documented library entry
point) and from the PythonAst.parse(...).metrics()fast path, which
— unlikeanalyze_sourceandAst.from_path— does not normalize
line endings. ThebcaCLI and everybca-webendpoint that computes
metrics normalize their input and were unaffected. - On the first row (
-
docs.rs now publishes the complete API reference. The published
build previously used default features only, silently dropping the
entire feature-gatedvcsmodule (change-history metrics, #328) from
the reference. A[package.metadata.docs.rs]section
(all-features = true,--cfg docsrs) restores it and enables
per-item "Available on crate feature …" badges viadoc(cfg). A new
make doc-check-docsrstarget reproduces the docs.rs build locally on
nightly (--cfg docsrs), so the published rendering can be verified
before a release rather than discovered broken after publish.
Security
-
Closed a remotely-triggerable process abort in the recursive
SerializeandDroppaths (#1056).bca metrics -O jsonon ~1 000
nested functions (11 KB of source) overflowed the thread stack, and a
stack overflow is aSIGABRT, not a catchable panic:bca-web's
spawn_blockingwrapper turns a panic into one failed request, but an
abort takes the whole process down with every request in flight. Three
recursions were involved, all now bounded — see the Fixed and
Changed entries below. Reachable payloads were small: ~11 KB of
nestedfns for the serialization overflow, ~80 KB of nested
parentheses for the/astone, both far inside the 4 MiB body cap.Issues #700 / #709 had converted every AST traversal to an explicit
work stack; this was the same hazard in the recursive types, which
those tests did not reach. -
Narrowed a remotely-triggerable CPU-exhaustion vector: a few kilobytes
of deeply nested source could pin a core for minutes against the
unauthenticatedbca-webendpoints, whose parse deadline frees the
client but cannot cancel the blocking task. Two of the quadratic paths
are gone — thetokensancestor walk (#1052) and every one of
cognitive's parent lookups (#1062) — as are the threeNode::parent
predicates the benchmark harness measured as quadratic (#1084); see
those entries under Fixed. Every walk the depth-scaling gate
probes now fits an exponent near 1.0.This is not closed. The climbs tracked in #1088 are unprobed and
still resolve a parent by descending from the root, among them the
JS/TSis_func/is_closurewalk, Elixir'sNpa/Npm/
get_func_space_name/ suppression-marker lookups, the Halstead
get_op_typegetters, and per-nodeNode::parentcalls in several
locandcheckerarms. Operators analysing untrusted input must
still bound request concurrency and input size. -
Cleared the two RUSTSEC advisories behind the OpenSSF Scorecard
Vulnerabilities alert:anyhow1.0.102→1.0.103(unsound
Error::downcast_mut(), RUSTSEC-2026-0190) andmemmap20.9.10
→0.9.11(unchecked pointer offset in theadvise_range/
flush_rangefamily, RUSTSEC-2026-0186). Both are transitive
dependencies (viawit-parserandgixrespectively); neither
affected API is called directly by this workspace. -
Removed
test_ext, a 4.3 MB compiled debug binary accidentally
committed at the repository root (flagged by the OpenSSF Scorecard
Binary-Artifacts check). Nothing referenced it. -
CI Python tooling is now hash-pinned (OpenSSF Scorecard
Pinned-Dependencies). Workflows install from
big-code-analysis-py/requirements/{dev,examples}.txt— exports of
uv.lockregenerated bymake py-relock— withpip install --require-hashes, replacing the floor-rangepip installs; this
also closes the long-tracked "CI does not consumeuv.lock" gap.
The wheel smoke jobs install the just-built wheel by explicit
dist/*.whlpath with--no-deps(the build jobs already verify
exactly one wheel per artifact), and the unpinned
pip install --upgrade pipsteps are gone.pytest-covjoins the
devextra andmaturintheexamplesextra so the exports cover
exactly what each CI job needs. -
The grammar-regeneration scripts now install npm dependencies
hash-verified (OpenSSF Scorecard Pinned-Dependencies, code-scanning
alerts #759–#761, issue #1012). The four internal grammar crates
(tree-sitter-ccomment,tree-sitter-preproc,tree-sitter-mozcpp,
tree-sitter-mozjs) now commit theirpackage-lock.json(previously
gitignored — the lockfiles were never actually in git) and
generate-grammars/generate-grammar.shinstalls with
npm ci --include=dev, which fails loudly on a missing or drifted
lockfile.generate-mozcpp.shusesnpm ciinside the upstream
tree-sitter-cppcheckout (upstream commits a lockfile at the pinned
revision) and replaces thenpm install --no-save tree-sitter-c@0.23.1
override with a registry tarball fetched by exact version and verified
against a recorded sha512 before extraction — no npm version
resolution at all, and the package's install scripts are never run.
generate-mozjs.shgains theset -euo pipefailfail-loud guard its
mozcpp sibling already had, so an aborted regen can no longer fall
through to cleanup and report success. Both regens were verified
byte-reproducible from a clean checkout. -
Cleared three RUSTSEC advisories flagged by the
cargo-denygate.
crossbeam-epoch(a shipped transitive dependency viacrossbeam)
moves0.9.18→0.9.20for the invalid-pointer-dereference in its
fmt::Pointerimpl (RUSTSEC-2026-0204). The dev-onlyquick-xml
test dependency moves0.39→0.41for the quadratic
duplicate-attribute check (RUSTSEC-2026-0194) and the unbounded
namespace-declaration allocation inNsReader(RUSTSEC-2026-0195);
the XML-validation test helpers migrate from the now-deprecated
Attribute::unescape_value()tonormalized_value(XmlVersion::Implicit1_0),
which is its exact behavioral equivalent.