Skip to content

v2.1.0

Choose a tag to compare

@github-actions github-actions released this 07 Aug 01:00
· 299 commits to main since this release
fa5cc1f

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-config reports which exclude globs are
    manifest-anchored (#1194). After #1164 a glob's meaning depends on its
    origin — a --check-exclude pattern resolves against the caller's
    working directory, a bca.toml one against the manifest's directory —
    and a single flattened array cannot express that. manifest_exclude,
    manifest_check_exclude, manifest_exclude_from and
    manifest_check_exclude_from name the manifest-origin subset
    alongside the resolved lists, which stay where they were so the TOML
    form keeps round-tripping through --config. The anchor is the
    reported manifest file's directory. Each key is omitted when the
    manifest contributed nothing; the *_from pair is present only when
    the manifest's file is the one actually in effect, since a CLI
    --exclude-from replaces rather than unions with it.

  • Kotlin property accessors (get() / set()) and init { … }, Java
    and Groovy static { … }, and JavaScript class static blocks now open
    a function space of their own (#1184). Each carries executable code but
    was referenced nowhere outside the generated language enum, so its
    control flow was charged to the enclosing class and bca check could
    never flag one however complex it got. They are reported under
    synthesised names — <get>, <set>, <init>, <static-init>
    following the existing <anonymous> convention. They are
    deliberately absent from nom.functions, nargs and bca functions
    :
    none is a callable named at a call site, and counting an accessor as a
    method would make npm bill the same property once as an attribute and
    again as a method. See the NOM section of the metrics guide.

  • bca check --explain-threshold <metric>=<limit>: preview what a
    candidate threshold would cost at both tiers without editing
    bca.toml or running a gate (#1169). Reports hard-tier offenders, the
    resolved soft limit and its offenders, how many of each already match
    a --baseline entry — 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;
    honours exclude_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
    --threshold limits 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.py plus
    .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 --check still 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, --update to ratchet, wired into make lint and 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 to git submodule update --force for 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 --language accepts, 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-config renders one fully resolved table per
    overridden language. Additive manifest surface; not a STABILITY.md
    event.

  • big_code_analysis::vcs::BlameSession, a per-thread handle obtained
    from the new PerFunctionBlame::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_function keeps its one-shot semantics by
    building and discarding a session. Additive — see STABILITY.md.

  • ConcurrentRunner::without_path_verification, which skips the
    per-path is_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 — the bca CLI walk — was paying one
    redundant stat per 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 (or make 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_tractable and
    tokens_deep_nesting_is_tractable moved into the gate and the
    BCA_ASSERT_SCALING escape hatch is gone; both tests keep their
    value assertions and are renamed
    cognitive_nesting_is_inherited_at_depth and
    tokens_count_holds_at_depth. The harness is out-of-band by design —
    .github/workflows/benchmark.yml runs 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_ancestors from loc,
    and elixir_is_inside_quote_block from nom), all through
    Node::parent, which tree_sitter resolves 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 from mdbook-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, with README.ja.md as a hand-maintained sibling
    of README.md. Fragment-linked headings carry explicit {#anchor}
    ids so intra-book links survive heading translation. New Makefile
    targets book-pot, book-po-update, and book-ja drive 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 the v2.0.0 cut
    (#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
    asserts cyclomatic.sum serializes as a JSON integer (is_u64()),
    which the existing as_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 via make smoke. A new
    path-filtered smoke-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) and MAX_AST_SERIALIZE_DEPTH
    (512): the nesting depths past which FuncSpace / Ops and AstNode
    refuse to serialize (#1056). Both are set far clear of real source:
    across the 14 450-file corpus under tests/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-level Deserializer limit charges two levels per space.

Changed

  • Dependencies advanced past the semver-major line Dependabot is
    configured to ignore: gix 0.83 → 0.86, sha2 0.10.9 → 0.11.0,
    hmac 0.12.1 → 0.13.0, num-derive 0.4 → 0.5, clap_mangen 0.2 →
    0.3, and jsonschema 0.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
    gix or RustCrypto type appears in a public signature, so
    STABILITY.md is unaffected. Two consequences are worth recording.
    sha2 and hmac now sit on the RustCrypto digest 0.11 trait
    family, which actix-http already pulled in via sha1 0.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. And clap_mangen 0.3 renders a required option after
    the optional ones in the SYNOPSIS, which moves <-t|--type> in
    man/bca-count.1 and man/bca-find.1.

  • AuthorId::hashed and AuthorHashKey::apply are now pinned to
    absolute digest values, derived independently from Python's
    hashlib / hmac so 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.rs writes unkeyed digests to disk and honours them
    across version boundaries (CACHE_SCHEMA_VERSION tracks the on-disk
    format, not the hash implementation), and AuthorId::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's Display now
    renders the bare message, with no severity prefix at all: bca preproc prints it through the CLI's warn helper instead of a bare
    eprintln!, so warning: 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, IncludeCycle and
    NotPreprocessed capitalised Warning: while the two non-UTF-8
    variants did not, and neither spelling matched any other CLI
    diagnostic, since warn() has printed lowercase warning: since
    #609.) Per STABILITY.md, Display impls are stable but their exact
    wording is not, so this is not a breaking change. Two further
    user-visible consequences, both intended: the IncludeCycle block 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 redundant bca: warning:
    double prefix #609 removed elsewhere. make check-diagnostic-prefix
    (utils/check-diagnostic-prefix.py, wired into make lint,
    make pre-commit, make ci and pre-commit) blocks a further site
    from reappearing.

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

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

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

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

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

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

  • This repository's own bca.toml gates cognitive at 15, the shipped
    default, instead of the pre-#1140 folklore value of 25 (#1143). This
    is self-scan configuration only — no public API, no metric
    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 shape AGENTS.md warns 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 the nargs case that was rejected is that
    the population extends past the limit (cognitive runs 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. nargs stays at 7; nargs 6 → 5 is tracked
    separately in #1183.

  • (behaviour change) bca check writes its offender rows to
    stdout instead of stderr (#1167). The rows are the command's
    product, so bca check | wc -l, | head, | rg -c and
    bca check 2>/dev/null now 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-format without --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-file digest, and the
    aggregated document are unchanged. Migration: a pipeline reading the
    rows through 2>&1 needs no change; one that captured them with
    2>file should now use >file.

  • An unrecognized or non-suppressible metric name inside
    bca: suppress(...) is now reported and skipped rather than voiding
    the entire marker, so suppress(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.toml records start_line only
    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_line moves 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-v6 bca still surfaces the raw error and must be
    regenerated with --write-baseline. An entry that pins no line drops
    it from every rendering: bca exemptions omits the :line suffix
    (text), renders - in the Line column (markdown), and omits the
    line key (JSON); bca diff-baseline --format json omits
    start_line.

  • .bca-baseline.toml is marked -merge in .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. -merge rather than a
    merge=ours driver, which would need per-clone git config and
    silently falls back to a normal merge where unconfigured.

  • make pre-commit and make ci now 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-only Pre-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 -j stops
    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 of bca'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 : !c scored 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. nargs counts formal parameters for Elixir
    (#1142) and for Perl subroutine signatures (#1147), both of which
    reported 0 unconditionally. Elixir's def/defp/defmacro are
    Call nodes whose parameter list sits two arguments levels down, so
    the shared parameters-field heuristic found nothing; Perl's
    signature is an unnamed function_signature child. A def inside
    quote do … end still 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; a def two conditionals deep now scores 2 where it scored 4.

  • Documented Python's per-enclosing-lambda surcharge on boolean
    operators in the book's Cognitive Complexity → Per-language
    deviations
    list (#1150). No behaviour change.

  • Documented an upstream tree-sitter-c limitation 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 likewise char **, struct S * or a static pointer
    return — opens no function space under C or Objective-C, so it is
    absent from nom.functions, nargs and bca functions while the
    orphaned body's decisions are charged to the file's unit space. The
    parse produces no ERROR node, so nothing downstream can detect it.
    C/C++ supports no K&R form at all. No behaviour change; the paired
    fixture in tests/grammars/c_grammar_metrics.rs is a drift marker, so
    a grammar bump that fixes the parse fails the test rather than
    shifting metrics silently.

  • bca ops opens the same function spaces as bca metrics, through the
    same source-aware promote-and-classify predicate (#1130). The two
    walks each carried their own copy of the decision and the ops copy
    was byte-less, so every Elixir input came back as a bare file-level
    space while bca metrics returned the full module/function tree.
    tests/parity/ops_metrics_space_parity.rs pins the agreement per
    language. bca functions and bca find --type function carried the
    same byte-less predicate and were fixed in the same release (#1162,
    below).

  • Every walking subcommand exits 1 when 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 check was the worst case,
    being indistinguishable from a clean gate. bca diff --since reports
    it as UnwalkableInputs and bca vcs gates its ranking the same way.
    An ignore file still prunes such a directory before the walker
    descends; --exclude does not, being a post-walk filter.

  • A path named directly on the command line still overrides the walker's
    --exclude / --exclude-from / .bcaignore / manifest exclude
    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] exclude is 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] exclude globs are applied, so a
    ./-prefixed glob matches every spelling of the same file.

  • bca strip-comments terminates non-UTF-8 output on stdout with a
    newline, matching the UTF-8 branch, and flushes both (#1132).

  • ConcurrentRunner::new's num_jobs is now the consumer-thread count
    rather than a budget shared with a dedicated producer thread, which
    spawned max(2, num_jobs) - 1 consumers and left one slot idle
    (#1114). Dispatch happens on the calling thread instead. The signature
    is unchanged; a caller passing n now gets n consumers rather than
    n - 1. ConcurrentErrors::Producer is consequently never
    constructed — retained so a downstream match still 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 redundant is_file() re-check, so such a path is
    no longer silently skipped during dispatch; it fails at the read and
    is counted by the same read_failures guard 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] table bca init scaffolds, deriving each
    limit from published thresholds plus a 20-language corpus measurement
    (#1140). cognitive 25 to 15 (SonarSource's own default for the
    metric), nargs 7 to 5 (RuboCop's value; 7 fired on under 1% of
    functions and so could not catch anything), abc 50 to 40, and file
    size split into a loc.ploc working limit of 600 with loc.sloc
    demoted to a 1200-line bloat backstop (#1138). cyclomatic,
    nexits, halstead.effort, nom, and wmc are unchanged. Existing
    bca.toml files are unaffected; this changes what a fresh bca init
    writes. The derivation, a per-language override table, and
    per-use-case profiles are in the book's new Choosing thresholds
    recipe.

  • make test now runs the suite through cargo-nextest when it is
    available, matching CI, and falls back to cargo test otherwise
    (#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, as cargo test did.

  • 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::Ops now implement Drop (#1056), so fields can no longer be
    moved out of one by value: let m = space.metrics; becomes
    let m = space.metrics.clone(); or let m = &space.metrics;
    (E0509). The compiler-generated Drop glue recursed once per
    nesting level and aborted the process on a deep tree — reachable
    through bca-web's 4 MiB body cap — and an explicit Drop that
    hoists descendants into a flat work list is the only way to break
    that chain. STABILITY.md reserves 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 until 3.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 into utils/,
    joining check-tools.sh and deploy-book-to-gh-pages.sh. This
    covers every gate run by make 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.sh and each gate's
    *-test.py self-tests), and verify-name-only-churn.py. Each script
    now resolves the repository root as parents[1] of its own location
    rather than parent, 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 the entry: commands and
    the ^-anchored files: triggers), .github/workflows/ci.yml, and
    .taskcluster.yml now invoke them as utils/<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 of src/tools.rs into a new test-only
    src/test_support.rs, retiring that file's loc.sloc baseline entry
    (#1066); python_comprehension_clause_nesting takes the Nesting
    struct rather than three positional usize parameters, completing
    the threading started in #1062 (#1070); increase_nesting does the
    same at its 43 call sites across the 19 per-language cognitive
    modules and the shared js_cognitive! macro (23 language impls),
    which now hold the Nesting struct end to end instead of
    destructuring it into three same-typed locals and rebuilding it, with
    the conditional + function_depth + lambda sum folded into a new
    Nesting::total() (#1086); the two statements that make up the
    function-boundary rule moved behind a shared enter_function_boundary
    helper, replacing eighteen longhand copies and leaving Elixir and the
    js_cognitive! macro visibly opted out at their call sites (#1103);
    and node_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 the Getter trait (#1059). bca.toml's
    exclude_tests comment, which claimed the option does not lower
    loc.sloc, was corrected — #722 made it do exactly that (#1066).

Performance

  • finalize no longer re-derives a parent space's Halstead Stats and
    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::merge dispatches on the parent's
    recorded space_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::default caps 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-test metrics:: tranche
    alone 1.16 s to 0.95 s. Values are unchanged, pinned by a new
    metric_selection_parity test 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] sets debug = "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-line src/lib.rs
    edit from ~90 to ~28 CPU-seconds. No test bodies changed; the
    before/after cargo nextest list sets 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 300 git spawns and roughly halves the
    vcs_per_function binary. 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 check computes 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 --since builds 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. On tests/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
    MetricSet for 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 full check over 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 crossbeam senders rather
    than Mutex<std::sync::mpsc::Sender<_>>, so workers no longer take a
    lock per file (#1119). No measurable throughput change at --jobs 32
    or --jobs 64 on 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::checked verified chain.last() == node.parent()
    per node on all five walks that thread a chain, and Node::parent
    costs O(depth) — which made every cargo test walk O(nodes × depth) and hit the deep-nesting regression tests hardest. The exact
    assertion moved behind --cfg chain_audit (make chain-audit, plus a
    chain-audit CI lane); a plain debug build keeps an O(1)
    consequence of the same invariant, which catches a push moved ahead
    of the per-node computes and a dropped truncate but 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_depth from ~1.6 s to
    ~0.02 s. No shipped behaviour changes.

  • Traversals that enumerate every node's children reuse one
    TreeCursor instead of building and freeing one per node, through
    the new internal Node::children_with (#1112). The six per-node
    consumers are Node::preorder, the suppression-marker DFS, the two
    Search walks behind bca find and 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 reach children on 3-6 % of nodes
    (16 % for C#), where the effect is under 1 %. The Python bindings'
    mirror of that walk — Node.walk() and Node.descendants_by_kind()
    hoists a cursor the same way. Every one of the six is pinned by the
    child_scan_cursors counter, 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 raw File and
    LineWriter handles the incremental serializers wrote through one
    structural token at a time (#1115). A 165-file metrics --format json --output-dir run falls from 4,757,028 write(2) calls to 303 (1.55 s
    → 0.13 s); the 12 MB --output aggregate from 4,757,194 to 197
    (3.36 s → 0.20 s); the metrics text tree from 1,524,444 to 165;
    check --output-format sarif from 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_tests prune no longer resolves siblings from the
    node to find the #[…] run before an item (#1100). That cost
    O(attributes × depth), because tree_sitter resolves 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).

  • Loc stores 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 new loc/nested-fn-rows scaling 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,
    and Parser::new borrows a file's visible macro names out of
    PreprocResults instead of deep-cloning them (#1107). Measured over a
    10,918-file tree: record_indirect_includes 126.6 ms → 83.0 ms, and
    2.37M fewer allocations per metrics pass, with identical output.

  • Ops serializes through a borrowed projection instead of cloning an
    owned wire::Ops first (#1110). serde_json::to_string on 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 their Strings are rendered, making Ast::ops ~22% faster on
    vocabulary-heavy input. Output is byte-identical in every format.

  • HalsteadMaps::operators hashes its kind_id keys with the crate's
    integer hasher instead of SipHash-1-3, closing the gap left by #1069
    (#1108). Output is bit-identical. The text-keyed primitive_operators
    and operands maps deliberately stay on SipHash: their keys come from
    the analysed source, so hash-flooding resistance is load-bearing.

  • guess_language evaluates 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::new reuses one tree_sitter::Parser per 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 than O(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 own bca.toml enables — 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 = 1 under the dev and test
    profiles (#1121). The tree-sitter runtime and every grammar are C/C++
    libraries compiled by cc-rs, which forwards Cargo's OPT_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-node Node::parent calls in the walk (#1096).
    Getter::get_op_type (and its _with_code variant), Abc::compute,
    Npm::compute, Npa::compute, Cyclomatic::compute /
    compute_with_options, and Checker::is_useful_comment gained an
    Ancestors parameter; the ABC condition walkers take the slot's
    parent from the caller that descended from it; and the
    comment-removal walk behind bca remove-comments maintains a chain of
    its own. All are pub(crate), so the published API is unchanged, and
    metric values are unchanged for every language. Python's Cyclomatic
    else arm 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-not fits time ~ depth^k at 1.99
    before and 0.99 after, abc/nested-if at 2.00 and 1.14,
    cyclomatic/nested-ternary at 2.06 and 1.27, and
    loc/nested-quote at 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; their halstead/nested-paren,
    abc/nested-block, and cyclomatic/nested-and shape controls hold at
    0.97-1.31 either side. Per #1088's lesson, the primitives were checked
    too: the ABC walkers' Node::previous_sibling carried the same
    O(depth) (ts_node__prev_sibling opens with ts_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
    the ops, bca function, and suppression-marker walks maintain one of
    their own (#1088). The JS-family Checker::is_func / is_closure
    name-binding walk, Ruby's Checker::is_closure block-versus-lambda
    test, Elixir's Getter::get_func_space_name, and the
    suppression scan each resolved ancestors with Node::parent, which
    tree_sitter answers by descending from the root. Checker::is_func,
    is_closure, Getter::get_func_name, get_func_space_name, and
    NArgs::compute gained an Ancestors parameter to carry it; all are
    pub(crate), so the published API is unchanged.

  • Elixir's Npm / Npa::compute no 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, for def-shaped calls, an ancestor walk asking whether the
    call sat inside a quote template. That walk's answer was always
    discarded: the defmodule keyword 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 a defmodule nested inside a quote.

  • Node::wraps_any — reached from is_child and has_sibling, and so
    from every language's checkers and getters — scans a node's children
    with a cursor instead of chaining next_sibling() (#1088). The chain
    #217 introduced was premised on a sibling step being O(1); it is
    not, because ts_node_next_sibling resolves the parent first, so the
    scan cost O(children × depth). This was the dominant term behind the
    JS closure classifier: the new nom/nested-arrow probe fits
    time ~ depth^k at 1.97 before and 1.03 after, and at depth
    4000 nom over nested arrow functions drops from ~17.6 s to ~6.3 ms,
    while its nom/nested-declared-function shape control — the same
    nesting written with function declarations, which need no ancestor
    walk — holds at 1.08 either side. Ordinary input benefits too: a
    metric walk over the 384-file pdf.js corpus drops from ~443 ms to
    ~370 ms. Metric values are unchanged for every language.

  • The ops walk builds the file-level vocabulary once instead of once
    per closing space. finalize rebuilt 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, in ops_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 with Node::parent, which tree_sitter resolves
    by descending from the root (#1084). Checker::is_else_if (13
    languages), Loc's declaration gate (8 languages), Python's
    cognitive boolean-operator walk, and Elixir's quote-template
    lookup in Nom each cost O(depth) per node and were therefore
    quadratic in nesting depth. The three depth-scaling probes covering
    them fit time ~ depth^k at 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, nom on nested Elixir
    quote blocks drops from ~260 ms to ~2 ms and loc on 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/loc measured 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 one function_declarator
    directly inside another, and since #1200 nargs read the innermost,
    so the macro's (allocate) was the answer and the function's own
    arguments were discarded. TensorFlow's four run_stats_jni.cc shims
    all reported 1 while declaring 2, 3, 4 and 3; bca check --threshold nargs=1 found 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_declarator and 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 single operator_name with 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 the if
    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 declarator RUN_STATS_METHOD(allocate)(JNIEnv *env)
    that JNI shims use. Both put a function_declarator in the slot
    get_func_space_name expected an identifier in, so the name resolved
    to nothing. The four getters now take the name from the same
    declarator walk nargs has 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: name in the metric output, bca functions, which had been rendering these as a red error: line, and
    the bca check / .bca-baseline.toml offender 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
    an if statement'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 over DeepSpeech and
    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_declarator wrapped by the pointer_declarator the return
    type contributed; nargs read 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 pointerint (*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_declarator is 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 the function_declarator instead 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 nargs rises wherever such a function
    appears — 3,336 recorded values across 276 files of the DeepSpeech
    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=N and 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 real parameter_declaration
    for the void and every nargs filter counted it, so f(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
    shared count_args helper, 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
    Checker gained an is_empty_param_marker hook that defaults to
    false and reads them.

    Metric drift. 24 recorded values fall to 0 in the DeepSpeech
    corpus, all in pywrapfst.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 every nargs filter 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 nargs falls wherever a signature
    carries a comment — across the DeepSpeech corpus, 854 recorded
    values, including kenlm's DontBhiksha::DontBhiksha (7 → 4) and
    ReadBackoff (3 → 2). A signature that previously tripped
    bca check --threshold nargs=N on its comments alone now passes.

  • Serialized shape. npm and npa emission is now decided by the
    space's kind alone, for every language (#1203). #1197 declared the rule
    — containers and the file unit root 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 struct declared inside a function body put the block
      on that function space. Across the serde corpus that was 39
      function spaces in 10 files, most of them #[test] functions
      declaring a local struct.
    • 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 _sum fields,
      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 check reads
    metrics.npm directly through MetricScope, which never consulted the
    emission gate. STABILITY.md already 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 whose npm / npa
    appear only on the root, because its space tree has no container kind;
    since bca check gates both on container spaces, no npm or npa
    limit can fire on Go source. Both are documented in the metrics guide.

    One consequence reaches a front-end. The Python to_sarif binding
    walks serialized JSON and skips a metric whose key is absent, so it
    silently dropped every npm / npa offender on a C++ namespace
    a kind MetricScope::Container admits 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.

  • Serialized shape. npm and npa no 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 Kotlin get() / set() /
    init { … }, Java and Groovy static { … } and the JS-family
    class_static_block — which is the inconsistency the issue reports: a
    <get> space carrying OOP metrics while the m beside 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, which wmc already
    followed and which the three metrics share as a single definition:
    containers and the whole-file unit root 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-file class_npm_sum (7,530 fields across the
    integration corpus, 400 of them non-zero) and left npm / npa
    disagreeing with wmc about the same root.

    Consumers reading metrics.npm off 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 PHP new 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, and wmc has 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 the npm.* / npa.* columns on every row regardless of space
    kind, carrying the real accessor values.

  • Metric drift. ABC conditions moves wherever a comment sits inside
    a ternary (#1181). Slots are now addressed by grammar field rather than
    by neighbouring token or fixed index, which fixes two opposite errors
    from one cause: C, C++, Objective-C, Mozcpp, PHP, Perl, JavaScript,
    TypeScript, TSX and MozJS over-counted (a ? /*n*/ (b) : c scored
    3 against a ? (b) : c's 2), while Java, C# and Groovy under-counted
    (a ? /*n*/ !b : c scored 2 against a ? !b : c's 3).

  • Metric drift. ABC conditions counts Ruby's and Perl's not
    keyword like ! (#1182). if not b scored 0 against if !b's 1, and a
    not ternary scored 2 against the ! form's 4. Lua and Elixir were
    already correct.

  • Metric drift. Tcl and iRules gained the Phase 2B slot routing every
    other language already had (#1180): if {$a} and while {$a} move 0 →
    1, if/elseif/else 2 → 4, and expr {$a ? !$b : !$c} 1 → 4,
    matching the value the other languages report for the same expression.
    The argument and return slots remain unrouted.

  • Metric drift. A lambda written without its optional parentheses
    reports its parameter in Java and C# (#1185). x -> x + 1 scored
    nargs 0 where (x) -> x + 1 scored 1; the parameters are billed to
    closure_args as before.

  • Metric drift. JavaScript-family generator functions are classified
    as functions rather than closures (#1186), so nom's function/closure
    split, nargs' fn_args/closure_args split and cognitive nesting all
    move for function*. bca functions and bca find --type function
    now report a named generator, which they previously omitted.

  • Metric drift. An immediately-invoked function expression is a
    closure whether or not its result is bound (#1188). nom and nargs
    previously classified (function(){…})() and
    const v = (function(){…})() differently. A class field initialiser and
    a non-identifier-keyed object property are now classified the same way
    whether written as a function expression or an arrow.

  • Metric drift. Cognitive complexity resets the lambda surcharge at
    every function boundary, not only in the JavaScript family (#1187). A
    function declared inside a closure scored 3 where the same body
    outside one scored 2, in Rust, Java, C++, PHP and C#. Separately,
    (function(){ function g(){…} })() and (() => { function g(){…} })()
    now charge g the same function depth.

  • Metric drift. The file-level unit's line span is anchored at line 1
    (#1195). A whitespace-only file reported 0..0, and any file opening
    with blank lines reported a span that omitted them — "\n\n\nfn a(){}\n"
    gave 4..4 of a 4-line file. An empty file still reports 0..0, having
    no lines.

  • Metric drift. Kotlin init { … } complexity now contributes to its
    class's WMC, which follows from the new function space (#1184).

  • A bca.toml exclude glob keeps applying under a directory seed
    (#1189). bca metrics -p sub moved the walk root, and manifest globs —
    written against the manifest's directory — silently stopped matching.
    The rule is now stated once and shared by the walker and the bca check
    gate.

  • utils/check-snapshot-anchors.py lexes char literals, byte-raw strings
    and the whole of src/metrics/ (#1192). A b'"' opened a string span
    that hid every later snapshot call, and the scan was non-recursive so
    the 126 files under the per-language subdirectories were never checked.
    Latent — no live count changed.

  • utils/check-diagnostic-prefix.py decides 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 follows r ("dir/r"), and an unterminated r" 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
    from check-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.md scope the
    object-oriented emission rule to npm and npa (#1220). Both said
    all three blocks follow the space's kind with no grammar deviating in
    either direction; that holds for npm / npa, which are gated
    centrally by kind, and not for wmc, which is decided per language.
    Go emits no wmc block on any space including the file root while its
    npa / npm do appear there, and a namespace space — a C++ or
    Mozcpp namespace, or a Ruby module — carries npm / npa but no
    wmc because 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 only npm and
    npa — nothing pinned wmc'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, cyclomatic and nexits move from the record's class
    space onto the constructor's; nom, npm.class_methods /
    class_npm_sum and wmc each count it; and nargs reports the
    record's component count, so the compact and canonical spellings of
    one constructor score identically. bca check can 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 to method_definition and to function_expression
    nodes the Checker calls functions, not to function_declaration
    alone, and the function-depth stops list carries the same kinds
    (#1159). A method or bound function expression defined inside
    conditionals no longer inherits the enclosing nesting, and a
    function declared 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's a ? !b : !c went from 1 to 4; Python's a 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_line is keyed on the node's end column rather than on
    its SpaceKind (#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
    usize underflow in #1051bca check offender lines, the SARIF
    region and any editor integration slice source by these spans. The
    SpaceKind::Unit arm's missing + 1 was never a statement about
    units; it was a statement about nodes that end at column 0, which the
    root always does. bca functions computed 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 function and bca count --type function reported nothing at all for Elixir sources (#1162). Elixir's
    def / defp / defmacro are not distinct grammar productions but
    Call nodes whose target identifier spells the keyword, so a
    byte-less predicate cannot see them; both seams now read the source
    bytes, as bca metrics and bca ops already did. The Ast::functions
    and Ast::find library seams and the web /function endpoint were
    affected identically. A cross-language parity test now pins that every
    named Function-kind space in the metrics() tree appears in
    functions(), which is the invariant that would have caught this seam
    and #1130's together.

  • [check] exclude globs from a bca.toml are resolved
    against the manifest's directory rather than the caller's working
    directory (#1164), so an exemption written for the project root holds
    when bca check <file> is invoked from a subdirectory. [check] exclude is 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 --exclude override 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 manifest exclude glob 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.toml sets paths to something other
    than ["."].
    [check] exclude globs were previously matched
    against each file's path relative to the walk root; they are now
    matched relative to the manifest's directory. Where paths = ["."]
    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-config and confirm the resolved
    check_exclude list still means what you intended.

  • A threshold written with the bare bca diff --metric alias (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-config prints the limit
    that actually fires; its output now round-trips through --config to
    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 --headroom alias, 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" = 20 with --tier=soft=0.5 resolved 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
    with soft=0.9 resolves 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 to mi.* 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.md and 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 #123 reaches 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 when count_specific_ancestors's
    (ancestors, check, stop) order makes it do the reverse (#1090). Adds
    a test discriminating the previously-untested ExpressionList stop
    arm through both routes that reach one under a lambda — a parenthesised
    yield and an f-string interpolation. No metric values change.

  • make bench-scaling now measures two axes (#1133). Probe carries an
    Axis (Depth or Width), and the new nom/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, and bca vcs trend exit 0 again when
    their consumer closes the pipe (bca vcs … | head). The
    write_text flush below made the resulting EPIPE visible, and those
    emitters died on every I/O error, so a routine pipeline became
    error: writing vcs output: Broken pipe and exit 1 while dump,
    metrics, and ops piped into the same consumer exited 0. The
    BrokenPipe exemption the rest of the CLI applies is now shared by
    the vcs family; a genuine write failure still exits 1.

  • bca vcs, bca vcs commit, and bca vcs trend exit 1 when their
    report cannot be written to stdout. All three emit compact JSON, and
    std::io::Stdout is a LineWriter over 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 exit 0
    with no output at all. #1132 fixed the walk's stdout paths and missed
    these three, because every other vcs format (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 and enums — now roots its own
    workspace (#1145). exclude denies 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 metadata errored and took cargo fmt --all and every make pre-commit stage chained behind it with it.
    .claude/worktrees is excluded from the root workspace for the
    mirror-image reason.

  • The tree-sitter runtime is =0.26.11, up from the =0.26.9 that
    v2.0.0 shipped — two upstream patch releases, taken via Dependabot
    and pinned in lockstep across the root manifest and every excluded
    crate. tree_sitter is re-exported from the library root, so the
    resolved version is visible to consumers; per STABILITY.md, a
    runtime bump rides a minor release. No grammar content moved: the
    vendored parser.c sources and every external grammar pin are
    byte-identical to v2.0.0.

  • The vendored grammar manifests pin their tree-sitter dependencies with
    =X.Y.Z requirements rather than caret ranges (#1151):
    tree-sitter-cpp in bca-tree-sitter-mozcpp and
    tree-sitter-javascript in bca-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 plain cargo update move one silently.
    tree-sitter-language deliberately stays caret-ranged — it is the
    ecosystem's shared LanguageFn shim, 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 into make lint, make pre-commit, make ci, the
    pre-commit hooks, and the lint CI job). It parses manifests with
    tomllib and checks the root manifest's [workspace.dependencies]
    block alongside each excluded crate's own tables.

  • utils/check-grammar-marker-sync.py compares the vendored grammar
    marker against its baseline with the requirement operator stripped, so
    =0.23.4, = 0.23.4 and 0.23.4 all name the same upstream version.
    The literal comparison reported drift for #1151's pin tightening,
    which touched no generated byte.

  • make book-pot writes messages.pot to the book's po/ 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
    and make book-po-update then 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 following msgmerge marks every code-block entry fuzzy and
    rewrites po/ja.po against msgids the pinned toolchain never
    produces. docs/development/translations.md now 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 ends stats.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 the comments node,
    which additionally reclassified the row from comment-only to
    code-and-comment. A realistic fourteen-row Tcl file with six comment
    rows reported ploc 13 instead of 7. The Tcl family also counted
    whitespace-only rows — trailing whitespace on an otherwise blank
    line — as code, so blank moves there too; a wholly empty row was
    unaffected either way. PLOC, ploc_average, ploc_min /
    ploc_max, and (for the Tcl family) blank move for Tcl, iRules,
    and Perl sources
    ; no other language is affected.
    a_comment_row_is_never_counted_as_code now 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, so b" " reports sloc 1 and
    b" \n" reports sloc 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.

  • bca no longer exits 0 when an input file cannot be read (#1098).
    #1060 fixed this for check only; metrics, ops, report,
    functions, find, count, dump, exemptions, preproc,
    strip-comments, and diff --since all exited 0 after printing
    error processing <path>: … to stderr. The guard now lives in the
    shared walk layer, so any read failure is a tool error (exit 1) for
    every walking subcommand. diff --since reports 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 --exclude to skip them deliberately.

  • Every walking subcommand exits 1 when 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 exits 0.

  • bca dump and bca find no 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 reads error: N input files could not be read … rather than error: bca: N input files could not be read …; the bca: prefix was duplicated by error: (#1098).

  • bca check no longer exits 0 when 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) reported error processing <path>: … on
    stderr and then exited 0 — 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 exits 1 (tool error, distinct from 2 = 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
    prefixed bca: rather than bca check:, which misattributed an
    init failure to a subcommand the user never ran. Other subcommands
    are unchanged for now; extending the same contract past check is
    tracked separately.

    read_file_with_eol is fixed on the same issue: its "≤ 3 bytes is
    not worth parsing" shortcut returned Ok(None) from a bare stat,
    and stat succeeds 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-file error processing line. 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::operators and Ops::operands are now sorted in
    byte-lexicographic order, so bca ops produces identical bytes for
    identical input (#1091). Both vectors were collected from HashMap
    keys, and RandomState reseeds per map instance, so the listings
    were reordered on every run — and even between two parses within one
    process. That made bca ops output 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's n1 / n2 are set
    cardinalities. Sorting costs O(n log n) per space over the space's
    vocabulary and is paid only on the ops seam — the metric walk does
    not run it.

  • The dump AST 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 dump goes 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 mirrored metrics and ops text 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.sloc no longer drops the final line of source that is not
    newline-terminated (#1067). Sloc derived 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 to 0.0 through mi::inputs_are_empty, and
    cloc + ploc > sloc for input such as b"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 see sloc (and blank, sloc_max, the
    *_average values, and all three MI formulas) increase by one
    line's worth; whitespace-only unterminated files move from sloc 0
    to sloc 1 / blank 1. This reaches every entry point that does
    not normalise its input: the Rust Source / Ast::parse API and
    the Python Ast.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(), and Ast.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-perl does this to the last sub of a
    file, whose function_definition swallows the newline after the
    closing brace; that sub's sloc could exceed the whole file's, and
    now drops by one along with the file's sloc_max / blank_max
    where it was the maximum. tree-sitter-bash does the same to some
    function_definitions (one file in the in-tree corpus:
    parse_valgrind_suppressions.sh, whose function drops from sloc 9
    to 8 and moves mi.sei from 37.4 to 49.1). This drift does reach
    the CLI, web server, and Python bindings.

  • wire::FuncSpace::from and wire::Ops::from no 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, or AstNode deeper than its limit
    now fails with an ordinary serializer error naming the type and the
    limit, instead of overflowing the stack (#1056). serde cannot emit a
    tree without one native frame per level — serialize_field must run
    the child's Serialize to completion before returning — so the depth
    is bounded rather than de-recursed, mirroring the 128-level recursion
    limit serde_json's Deserializer already applies to the same
    documents.

  • The cognitive metric's nesting lookup is no longer quadratic in
    nesting depth (#1062). It recovered each node's inherited nesting via
    node.parent(), which is O(depth) — tree-sitter stores no parent
    pointer — making the lookup O(nodes × depth). The walker now hands
    each node its inherited nesting directly, so the lookup is O(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 whenever cognitive is deselected, so a
    metric-subset run (--metrics loc, bca check with a threshold
    subset) allocated a hash table per file for one unread entry. The two
    grammars whose cognitive impl is a no-op, preproc and ccomment, now
    build no map at all rather than one entry per AST node.

    cognitive's remaining Node::parent sites are gone with it.
    increment_function_depth asked every function node whether a
    function encloses it by climbing with node.parent(), which kept the
    metric O(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-fn depth-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's when-default check and
    Ruby's case-default check, one per else node — read the same
    chain now. Cognitive values are unchanged; the arithmetic is pinned at
    depth 1000 by cognitive_function_depth_is_inherited_at_depth and
    across languages by
    function_depth_surcharge_holds_across_languages.

    Node::parent climbs remain elsewhere in the crate — the five
    Ancestors::unknown() call sites, the Halstead get_op_type
    getters, and per-node lookups in several loc, npa / npm, and
    checker arms — all outside cognitive and 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). Tokens decided whether a leaf sat inside a comment by
    walking that leaf's ancestor chain — and Node::parent is itself
    O(depth), so the metric ran in O(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 against bca-web and a way to stall bca check in
    CI. The walker now propagates comment membership down the traversal in
    O(1) per node, so tokens is 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::parent is O(depth) and is used per-node in several other
    places: cognitive's nesting-map lookup dominates the nested-paren
    shape measured above, while Loc's count_specific_ancestors
    (C-family, Java, C#, Go, Groovy, Objective-C) and Elixir's
    is_inside_quote_block are 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). Loc discounts 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 (/// x as 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 reported cloc one too low, which also shifted
      blank and 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 more cloc (and one
    fewer blank) than a 2.0.0 release build did; two such comments
    shift by two. Re-check .bca-baseline.toml entries and thresholds for
    affected files.

    Reachable from analyze / Source (the documented library entry
    point) and from the Python Ast.parse(...).metrics() fast path, which
    — unlike analyze_source and Ast.from_path — does not normalize
    line endings. The bca CLI and every bca-web endpoint that computes
    metrics normalize their input and were unaffected.

  • docs.rs now publishes the complete API reference. The published
    build previously used default features only, silently dropping the
    entire feature-gated vcs module (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 via doc(cfg). A new
    make doc-check-docsrs target 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
    Serialize and Drop paths (#1056). bca metrics -O json on ~1 000
    nested functions (11 KB of source) overflowed the thread stack, and a
    stack overflow is a SIGABRT, not a catchable panic: bca-web's
    spawn_blocking wrapper 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
    nested fns for the serialization overflow, ~80 KB of nested
    parentheses for the /ast one, 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
    unauthenticated bca-web endpoints, whose parse deadline frees the
    client but cannot cancel the blocking task. Two of the quadratic paths
    are gone — the tokens ancestor walk (#1052) and every one of
    cognitive's parent lookups (#1062) — as are the three Node::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/TS is_func / is_closure walk, Elixir's Npa / Npm /
    get_func_space_name / suppression-marker lookups, the Halstead
    get_op_type getters, and per-node Node::parent calls in several
    loc and checker arms. Operators analysing untrusted input must
    still bound request concurrency and input size.

  • Cleared the two RUSTSEC advisories behind the OpenSSF Scorecard
    Vulnerabilities alert: anyhow 1.0.1021.0.103 (unsound
    Error::downcast_mut(), RUSTSEC-2026-0190) and memmap2 0.9.10
    0.9.11 (unchecked pointer offset in the advise_range /
    flush_range family, RUSTSEC-2026-0186). Both are transitive
    dependencies (via wit-parser and gix respectively); 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.lock regenerated by make py-relock — with pip install --require-hashes, replacing the floor-range pip installs; this
    also closes the long-tracked "CI does not consume uv.lock" gap.
    The wheel smoke jobs install the just-built wheel by explicit
    dist/*.whl path with --no-deps (the build jobs already verify
    exactly one wheel per artifact), and the unpinned
    pip install --upgrade pip steps are gone. pytest-cov joins the
    dev extra and maturin the examples extra 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 their package-lock.json (previously
    gitignored — the lockfiles were never actually in git) and
    generate-grammars/generate-grammar.sh installs with
    npm ci --include=dev, which fails loudly on a missing or drifted
    lockfile. generate-mozcpp.sh uses npm ci inside the upstream
    tree-sitter-cpp checkout (upstream commits a lockfile at the pinned
    revision) and replaces the npm 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.sh gains the set -euo pipefail fail-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-deny gate.
    crossbeam-epoch (a shipped transitive dependency via crossbeam)
    moves 0.9.180.9.20 for the invalid-pointer-dereference in its
    fmt::Pointer impl (RUSTSEC-2026-0204). The dev-only quick-xml
    test dependency moves 0.390.41 for the quadratic
    duplicate-attribute check (RUSTSEC-2026-0194) and the unbounded
    namespace-declaration allocation in NsReader (RUSTSEC-2026-0195);
    the XML-validation test helpers migrate from the now-deprecated
    Attribute::unescape_value() to normalized_value(XmlVersion::Implicit1_0),
    which is its exact behavioral equivalent.