One entry below is marked (breaking): the Python bindings'
analyze_batch / analyze_paths now return one element per input
under skip_generated=False, as both docstrings always promised. The
previous shape silently misattributed results to the wrong path, so
the fix ships in a minor release as a correctness exception to the
stability contract rather than waiting for 3.0.
Added
-
bca checknow reports what it declined to look at (#1055). When the
gate skipped files — a generated-code marker (@generated,
DO NOT EDIT,GENERATED CODE) or a VCS ignore file committed in
the tree under test — a one-line stderr summary names the counts:
bca: 2 files not checked (1 generated, 1 ignored) — pass --report-skipped to list them. Either input could previously remove
a file from a pull-request gate with nothing on stderr. The counts
are measured at the walk's prune points and cover only files a
parser owns (a generatedCargo.lockor an ignored log file was
never a gate bypass), never a file named explicitly on the command
line; a whole ignored directory is reported — under
--report-skippedonly, since ignored build trees exist in
essentially every checkout — as its own
N ignored directories not walkedclause and is never entered, so
the summary stays cheap and small on trees with large ignored build
output. Clean runs stay silent, exit codes are unchanged, and
--report-skippedalso lists each ignore-dropped entry
(note: skipped (ignored): …,
note: skipped (ignored directory): …). -
bca check --strict, plus a matching[check] strictmanifest key
(#1055): the untrusted-input gate profile. Flips both skip defaults
in one flag (equivalent to--no-skip-generated --no-ignore), so a
PR gate opts out of content sniffing and in-tree ignore files without
remembering two flags. The manifest key is presence-only (it can turn
the profile on, never off) so a project opts in once rather than per
workflow. -
A
cargo-fuzzcrate (fuzz/, workspace-excluded) with eleven libFuzzer
targets over the parse-and-walk layer, committed seed corpora, and an
out-of-bandfuzzworkflow (make fuzz-check/fuzz-smoke/
fuzz-run; see
docs/development/fuzzing.md). The
target set is scoped to what the static lints adopted in #1152 cannot
reach — chiefly the nine per-functionindexing_slicingcarve-outs in
the C-family macro lexer, which only thepreproc_macrotarget
exercises — rather than to blanket byte fuzzing, which a 112-file
adversarial corpus already showed the encoding/IO layer survives.
Targets callSource::from_bytesso the bytes reach the parser
unnormalised; the file-reading path appends a trailing newline, and a
harness that inherited it would pass vacuously against the very class
of bug (#1051) that motivated this. The tree-sitter grammars are
compiled with-fsanitize=addresstoo, so the C scanners are covered
and not just the Rust. Its first bounded run found a bounded leak in
tree-sitter-perl1.1.2's heredoc scanner, whose
external_scanner_destroyhas its queue frees commented out — upstream
in the grammar, recorded infuzz/lsan-suppressions.txtwith the
measurement showing it is per-thread rather than per-parse. No library
behaviour changes (#1154). Apreproc_includestarget covers the
include-resolution half of the preprocessor —fix_includesand the
graph building, SCC collapse and candidate scoring behind it, none of
whichpreproc_macroreaches (#1288). -
A
check-ruff-lockstepgate (make check-ruff-lockstep, wired into
make lint/pre-commit/ciand the pre-commit hooks) holds the one
adopted ruff version together across the four files that declare it.
big-code-analysis-py/uv.lockis the anchor, because the hash-pinned
requirements/dev.txtCI installs from is generated from it — gating on
the export would bless a stale export rather than catch one. The
ruff-pre-commitrev:in.pre-commit-config.yamlmust bev+ the
locked version, the export must pin it, andpyproject.toml's bound must
match the one uv recorded resolving against. Therev:was previously held
by a comment alone and had already drifted silently once (v0.15.14
against a lockfile resolving 0.15.22), which stays invisible until the two
versions disagree and then presents as "works locally, red in CI" (#1230). -
A
check-safety-doc-pingate (make check-safety-doc-pin, wired into
make lint/pre-commit/ci, the pre-commit hooks, and its own CI
step) holds thetree-sitterversion cited by theunsafesoundness
argument inbig-code-analysis-py/src/node.rsequal to the version
[workspace.dependencies]pins. That module doc is the canonical
justification for the workspace's only sanctionedunsafeblock and
reasons about a named release — theNode<'tree>layout,Tree::edit
taking&mut self,Send + Sync— so a pin that moves while the literal
does not leaves an argument reading as verified against a crate nobody
compiles. The gate also fails when the literal is dropped altogether,
since a version-free phrasing hides the staleness rather than fixing it.
The scanner reads every//!line in the file rather than stopping at
the first non-doc line, so a multi-line#![allow(…)]between doc
paragraphs cannot silently truncate the scan in either direction
(#1345). No library behaviour changes (#1057). -
SkipReason(#[non_exhaustive], with aDisplaythat renders a
noun phrase) andread_file_with_eol_classified, the reason-naming
sibling ofread_file_with_eol—io::Result<Result<Vec<u8>, SkipReason>>, of which the old function is now the.map(Result::ok)
projection (#1287). The four variants map 1:1 onto the reader's skip
gates:Empty,TooSmall(1–3 bytes, or a file that shrank below the
probe window),Utf16Bom, andNotUtf8. Prefer the classified
variant wherever a skip is reported to a user; the shape is recorded
inSTABILITY.md, whose per-variantDisplaywording is not
SemVer-protected. -
web:
error_kindtokenvcs_invalid_author_hash_key. The
STABILITY.mdvocabulary list also gainsnot_acceptableand
serialize_failed, added by #657 and never documented (#1245).
Fixed
-
Bash command names are no longer counted twice in Halstead
N2
(#1351).BashCode::get_op_typeclassified both thecommand_name
wrapper and theword/string/expansionit wraps as operands,
sols barreportedN23 for two operands, and the spellings whose
wrapper text differs from the child's ("$cmd",${cmd}) also planted
a spurious secondn2entry. The wrapper arm is gone; the child
carries the count for all fourteen spellings the pinned grammar
admits. The deadBash::Concatarm — the hidden_concatexternal
token, never emitted — was removed with it and pinned by a drift
marker. Metric drift: BashN2falls by one per command name,
movinglength,volume,difficulty,level,effort,time
andbugs. -
Ruby
%w[…]/%i[…]arrays and adjacent-string concatenation no
longer count an extra Halstead operand for the wrapper (#1353).
RubyCode::get_op_typesuppressed a string-like literal only when it
carried anInterpolationchild, butchained_string,string_array
andsymbol_arrayhold classified operand children instead, so
%w[x y]scoredn24 /N24 for three operands — a vocabulary
that depended on how the literals were grouped. The three kinds are
gated rather than dropped, because%w[]parses to a wrapper holding
nothing but its delimiters.bare_symboljoinsbare_stringunder
the same guard, fixing an asymmetry where%I[a#{n}b]billed both the
element and the interpolatedn. Metric drift: Rubyhalstead.*
and the derived values fall. -
Perl no longer counts a qualified name once per part and once as a
whole (#1355).package_name,package_variableandtypeglobwere
operands alongside the operand kinds they contain, and they nest:use strict;scoredN22 for one name,our $Foo::count = 3;scored
n25 /N26 for two, and the vocabulary carried a bare::entry.
A parent-keyed guard now subsumes the contained operands.
Metric drift: Perlhalstead.*and the derived values fall. -
Tcl and iRules no longer bill a braced word twice (#1354, #1317). A
braced literal (set x {literal here}) was a Halstead operand and so
was every word inside it, soset a {a b}scored three operands
against itsset a "a b"synonym's one. Tcl evaluates nothing between
braces, so the literal is now the single operand its parts belong to.
A braced script — aproc,iforwhenbody — was additionally
billed as one operand spanning its entire text beside the commands the
walk already counts, son2grew with the size and uniqueness of the
code rather than with its vocabulary; it is now billed through its
contents alone, except when it holds no command (lappend l {}is an
empty list whose brace pair is its only carrier, and a comment-only
body scores like an empty one). Metric drift:
Tcl and iRuleshalstead.*, the derived values, and hencemifall
for any file containing a braced word or block. A value-position
braced literal still reports a{}operator (#1318). -
C, C++, Mozcpp and Objective-C character literals are Halstead
operands (#1316).char_literalwas in neither the operator nor the
operand arm of those four getters, so a character literal contributed
nothing at all andchar b = 'x';billedbalone — while Rust,
Java, Kotlin, C#, Go and Elixir each counted theirs. All prefixed
spellings (L'x',u'x',U'x',u8'x') count as distinct
operands, and a multi-character constant such as'ab'counts once.
Checker::is_stringis deliberately unchanged: a character is not a
string, the same split Rust and Go apply. Metric drift:n2/
N2rise by one per character literal, moving the derived values and
the three maintainability-index variants. -
A plain C function written inside an Objective-C
@implementation,
@interfaceor@protocolno longer contributes to that container's
wmc(#1356). Such a function is a file-static helper, not a method —
no receiver, absent from the method table, unsendable — sonpmnever
counted it, but the space tree nests itsFunctionspace inside the
container's andwmcweighted every such space. The three metrics
disagreed about one container:npm.class_methods1 against
wmc.class_wmc_sum3. This is the C++frienddivergence #1301
removed, surviving in a sibling language. Metric drift: ObjC
wmc.class_wmc/class_wmc_sum/interface_wmc_sum/totalfall
by the cyclomatic complexity of each such helper. The helper keeps its
ownFunctionspace and metrics,nomstill counts it, and the
file-levelcyclomaticsum is unchanged. -
The JS-family ABC assignment count no longer depends on the previous
statement's terminator (#1277). The counter cleared its declaration
sentinel only on aSEMItoken, so aconstwritten without a
semicolon (ASI) left a stale sentinel that suppressed every subsequent
=until the next;—const a = 1thenx = 2scored zero
assignments.constinitializers are now identified structurally from
the=token's ancestor chain, matching the Kotlin fix in #455; the
same change closes TypeScript'sx as const, which promoted a live
letslot and leaked even in fully semicolon-terminated code. A
destructuring default under aconstdeclarator (const {a = 1} = o)
stays part of the declaration, as it was under the sentinel. Affects
Javascript, Mozjs, Typescript and Tsx. Metric drift: an assignment
nested inside aconstinitializer's value (const x = (o.p = v),
const x = a || (b = 1)) was blanket-suppressed by the sentinel and now
counts, soabc.assignmentsrises in semicolon-terminated code that
uses that shape — the change behind the pdf.js corpus refresh. Java,
Groovy and C# now use the same structural rule forfinal/const
initializers instead of a sentinel stack of their own; that stack
stayed live from the keyword to the next;, so in Java and Groovy
every=inside afinalinitializer — a lambda or closure body's
x = 1, an array initializer's — was suppressed with the declarator's
own (final Runnable r = () -> { x = 1; };scored 0). Metric
drift: Java and Groovyabc.assignmentsrise by one per assignment
nested in afinalinitializer; C# is unchanged, since aconst
initializer cannot contain one. -
A
?used as type syntax is no longer counted as an ABC ternary
condition in C#, TypeScript and TSX (#1275). The ternary?and the
type-syntax?are the same anonymous token, so C# scoredint? x
andwhere T : class?, and TS/TSX scored every optional parameter,
property, method, class field and tuple element, plus conditional
types (T extends U ? X : Y), as decisions. C# safe navigation
(a?.b,a?[0]) shares the same token and deliberately still counts,
keeping ABC in step with C# cyclomatic — so C# gates by denylist and
TS/TSX by allowlist, a polarity difference the arm comments record.
Completes the type-syntax work #1274 began for Java and Groovy. -
abc.conditionsnow counts theforheader's condition slot
(#1276). A bare, negated or parenthesised loop condition
(for (; a; ),for (; !a; ),for (; (a); )) scored zero in C,
C++, Mozcpp, Objective-C, JavaScript, Mozjs, TypeScript, TSX, PHP and
Perl's C-stylefor (my $i = 0; $ok; $i++)while the same predicate in
anifscored one; comparison-shaped conditions (i < n) were
unaffected. Go's three-clausefor init; cond; posthad the same gap,
and its header slot was additionally mis-read for a barefor {}and
shifted by a header comment. Metric drift:abc.conditions— and
thereforeabc.magnitude/abc.value— rises for any function with
such a loop in those eleven languages. An empty condition (for (;;),
for (init; ; update)) now counts zero in every language: Java
and Groovy previously scored one vacuous condition and drop by one per
such loop. Java's and Groovy'sforcondition also no longer goes
unread when a comment appears in the loop header. -
Objective-C message sends are boolean terminals in ABC condition
slots.cpp_bool_terminal_kinds!listedcall_expressionbut not
ObjC'smessage_expression, soif ([a ok]),while (![a ok]),
for (; [a ok]; ),[a ok] ? x : yand each operand of a&&/
||chain scored zero conditions where the C-call twinif (ok())
scored one. Metric drift: ObjCabc.conditionsrises for any
function with a message send in a decision slot. -
Groovy's elvis operator
a ?: cis an ABC condition. Groovy
cyclomatic already counted the token and Kotlin's ABC arm counts its
identical one, so a method whose only branching was elvis chains
reported cyclomatic above one with zero conditions; one condition per
?:, soabc.conditionsstays equal tocyclomatic() - 1on the
chain. Metric drift: Groovyabc.conditionsrises by one per
elvis. -
Perl
qw()lists are Halstead operands. Neither the elements, the
wrapper nor theqwkeyword had a classification, somy @a = qw(a b c)billed@aalone where("a", "b", "c")billed four operands.
Each element is one operand and the emptyqw()is one, the rule
#1353 set for Ruby's%w[]; the delimiter choice cannot move the
score. Metric drift: Perln2/N2rise by one perqw
element. -
iRules array references are no longer billed three times. The
array_indexwrapper of$arr(k)was an operand beside the reference
and the index it wraps, so one reference contributed three operands
where the Tcl twin contributed two. Metric drift: iRulesN2falls
by one per array reference, andn2by one per distinct index
spelling. -
The
@of an Objective-C string literal is no longer a Halstead
operator.@"…"is onestring_literalholding its@as a child,
so the marker was billed as an operator while the operand key already
carried it;NSString *s = @"str";scored an@inn1that@42
and@[…]legitimately keep. Metric drift: ObjCn1/N1fall
by one per string literal. -
bcano longer announces every file the reader declines as
skipping empty file(#1287). Emptiness is only one of four gates: a
1–3-byte valid source, a multi-kilobyte binary, and a UTF-16-BOM file
were all reported as empty, which sent anyone triaging a skipped file
in the wrong direction. The-wdiagnostic now names the gate that
fired —skipping empty file,skipping file too small to analyze (3 bytes or fewer),skipping file with non-UTF-8 contents,
skipping UTF-16 file (unsupported encoding)— rendered from the new
SkipReason(see Added). Exit codes and which files are skipped are
unchanged. -
bca check --paths-from -no longer silently defeats[check.exclude]
/--check-excludeglobs (#1306). The gate re-read--paths-fromto
re-anchor exclude globs against the seed list, but-resolves to
stdin and the walk had already drained it, so the second read returned
an empty list and every violation anchored against the bare--paths
set — agit diff --name-only | bca check --paths-from -gate failed
on files the project had exempted, with nothing on stderr. The list is
now materialized once, before the walk, and carried to the exclude
stage; the remediation footer still echoes the caller's original
--paths-from -spelling, and the no-exclude fast path still does no
glob-set build. -
bca metrics -O json/bca ops -O jsonto stdout now emit their
per-file documents in the walk's file order rather than
worker-completion order (#1303), so redirecting stdout to a file
yields byte-identical output on every run at--jobs > 1— the
sibling of #1244's--output <FILE>fix, on the destination pipelines
reach for most. A reorder buffer keyed on each file's index in the
resolved list holds out-of-order completions; skipped, unreadable, and
unparseable files release their slot with an empty marker so the drain
never stalls, and a post-join flush turns a missed release into a late
document rather than a lost one. Throughput is unchanged within noise
(measured on a 13,583-file tree); peak memory rises by the documents
buffered while an earlier file is still being analyzed. -
A C++
frienddefined inline no longer contributes to the enclosing
class'swmc(#1301). A friend is a free function the class grants
access to, not a member of it, sonpmnever counted it as a method
— but the space tree nests itsFunctionspace inside the class
space, andwmcweighted every such space it found. The three
metrics disagreed about the same class: forclass R { friend void amigo() { if (1) { } } void mine() { } };,npm.class_methodswas
1 whilewmc.class_wmc_sumwas 3. This is the divergence #1258
removed for templated member bodies, surviving forfriend. Both
friend_declaration > function_definitionand its templated
template_declaration > friend_declaration > function_definition
form are covered, inLANG::CppandLANG::Mozcppalike; a friend
declared without a body opens no space and was never affected.
Metric values move:wmc.class_wmc/class_wmc_sum/total
fall by the cyclomatic complexity of each inline friend, for classes
that have one. Nothing else changes — the friend keeps its own
Functionspace and its own metrics,nomstill counts it (it
counts free functions wherever they appear), and the file-level
cyclomaticsum is unchanged. -
The file-level unit's
loc.slocandloc.blanknow count blank lines
above the first token (#1247). The unit anchors its reported span at
line 1 because the unit is the file (#1195), but its row span was
still measured from the root node, which tree-sitter starts at the
first token — so"\n\n\nfn a() {}\n"reportedsloc 1, blank 0
against a reported span of1..4, while the same file shifted down by
a leading comment reportedsloc 4, blank 2, comments being in the
tree where blank rows are not. The unit'sslocis now derived from
the span it reports, so the two can no longer disagree. The anchor is
applied once at space finalization rather than in the twenty-odd
per-languageLocimplementations, none of which knows the space kind.
Metric values move, for files whose first token is not on line 1:
loc.slocandloc.blankrise by the number of leading blank rows,
as do thesloc_average/sloc_max/blank_average/blank_max
aggregates, andmifalls slightly through itsln(sloc)term. Files
opening with code or a comment are unaffected, as are all nested
spaces, which keep their measured spans. -
Whitespace-only files now report their real row count regardless of a
trailing newline (#1087, #1247). Most grammars collapse the root node
to a zero-width point at end-of-input, so" \n \n"measured no rows
at all and reportedsloc 0while its unterminated twin reported
sloc 1— accepted in #1087 as an upstream-owned carve-out, with the
five grammars that behave otherwise (Elixir, Tcl, iRules,preproc,
ccomment) pinned as the exception. Anchoring the unit's row span
removed the premise: where the root node starts is no longer
observable in LoC, and all twenty-five grammars now answer alike.
Metric values move: a newline-terminated whitespace-only file goes
fromsloc 0, blank 0to its realsloc n, blank n. -
JavaScript, MozJS, TypeScript and TSX now count a
var/let/
constdeclaration as a logical line (#1283). Neither
variable_declarationnorlexical_declarationhad an LLOC arm, so a
file of nothing but declarations reportedlloc 0and every real
JS/TS file under-reportedloc.llocby one per declaration statement
— while Java'sLocalVariableDeclaration, Rust'sletand Python's
assignments all counted the equivalent construct. The two JavaScript
modules also countusing_declaration(using r = open();), the
third member of the grammar'sdeclarationsupertype that runs an
initializer; the TypeScript and TSX grammars pinned here emit no such
node. Two enclosing constructs that already count the row carve the
declaration out, mirroring Java's for-header rule: a classic
for (let i = 0; …)header — recognised as thefor_statement's
initializerfield, so a brace-less body (for (…) var s = i;)
still counts as its own line — and anexport const a = 1;
(including TypeScript'sexport declare const y: string;, where an
ambient_declarationsits between the export and the declaration).
In TypeScript and TSX a declaration under anambient_declaration
(declare const x: number;, the body of adeclare namespaceor
declare module) counts nothing: it has no initializer to run.
for (const x of …)andfor (var k in …)need no carve-out — the
grammar inlines the keyword and emits no declaration node.
Metric values move:loc.llocrises for JS/TS/TSX/JSM input, and
with it thelloc_average/lloc_min/lloc_maxaggregates. No
other metric is affected —midoes not consumelloc. -
A member access or qualified name in JavaScript, MozJS, TypeScript,
TSX, C# and Groovy no longer counts as a Halstead operand on top of
the identifier leaves it contains (#1263).var r = a.b;reported
the operandsa,banda.b, because themember_expression
wrapper was classified while the walker independently counted both
children — so a chain likea.b.cbilled two composites on top of
its three leaves. The same shape covered TS/TSXnested_identifier
(namespace N.M), C#qualified_name(using System.Text;),
generic_name(List<int>) andalias_qualified_name
(global::Foo), and Groovyqualified_name(package com.example).
The convention is now the one C, C++, Java, Rust, Python, Go, Kotlin,
Ruby, Lua and PHP already followed and is recorded in the getter
macro's doc comment: a member access is its leaves plus the./
::operator, never the composite text as well. In the same change
JavaScript'sprivate_property_identifier(#x) becomes an operand,
closing the gap the composite had been masking — a private field's
declaration counted nothing at all, andthis.#xcounted only the
composite.meta_property(import.meta,new.target) is the one
composite kept, as a single operand likethis: itsmeta/
targetleaves are anonymous tokens no arm classifies, so dropping
it would have left the meta-object with no operand at all.
Metric values shift: affected files report lower
halsteadn2/N2(−29% and −18% across the pdf.js and C#
integration corpora) and therefore lower volume and a highermi.
Notedifficultyandeffortmove up, becausen2falls faster
thanN2and difficulty is(n1/2)·(N2/n2). Refresh affected
baselines. Follows #1293, which fixed the same wrapper/leaf shape in
PHP. -
PHP's Halstead operand count no longer bills a type or qualified name
once per wrapper node (#1293).primitive_type,optional_type,
named_type,union_type,intersection_type,
disjunctive_normal_form_type,qualified_name,relative_nameand
namespace_nameall nest around the leaves they contain, and every
level was listed as an operand — sointscored 2,?intscored 3,
Foo\Bar\Bazscored 5, and?A\Bscored 6 for two identifiers. Two
design calls settle which node keeps the operand: a type is counted
at its innermost concrete form (?intis the operandint, since
?is already an operator), and a qualified name is counted by its
components (Foo\Bar\BazisFoo,Bar,Bazaround two\
operators — the same reading PHP's own::and->already get).
Theprimitive_typewrapper keeps the operand and its keyword child
is suppressed under it, because the grammar emits no child token for
callable,iterable,mixed,void,falseortrueand
dropping the wrapper would score those six types zero; the
suppression is parent-scoped, so thearrayheading anarray(…)
literal still counts. Metric values shift: PHP files report lower
halsteadN2/n2— 23 → 15 and 14 → 9 on the issue's two
reproducers — and therefore lower volume, difficulty, effort and
bugs, and a highermi(maintainability index). Refresh affected
PHP baselines. Follows #1259, which fixed the$variablehalf of the
same wrapper/leaf shape. -
nexitsnow counts the abrupt-exit builtins of Ruby, Perl, Tcl and
iRules (#1270). Rubyraise/exit/exit!, Perldie/exit,
Tclerror/throw/exitand iRuleserrorleave a function
exactly the way Python'sraise, Go'spanicand Lua'serrordo,
but none of them has a dedicated grammar node, and only the latter
three had a callee-text arm — so a Ruby guard clause that raised
scored1where the byte-equivalent Python scored3. Each is
matched at the same seam its siblings already used: acallwhose
method identifier spells the builtin and whose receiver is absent or
the explicitKernelconstant (Ruby), acall_expression_with_bareword
naming the builtin bare or throughCORE::(Perl), the leading word
of a generic command (Tcl/iRules). Calls with any other receiver
(obj.raise,$obj->die), other package-qualified callees
(Carp::croak), and the same words in argument position (puts error)
are not counted. Metric values shift: any Ruby, Perl, Tcl or iRules
function using these builtins reports a highernexitssum, average
and max than in 2.1.0, which can newly breach annexitsthreshold —
refresh affected baselines. Tcl 8.6'sthrowis deliberately absent
from the iRules set (TMOS runs a Tcl 8.4-derived interpreter with no
such builtin), and a bare argument-less Rubyraisestays uncounted
because it parses as a plain identifier, indistinguishable from a
variable read. -
(breaking) The Python bindings'
analyze_batch/analyze_pathsdropped a
result slot for any file the read gate declines to parse — three
bytes or fewer, a UTF-16 BOM, or a leading window that is not valid
UTF-8 — even underskip_generated=False, which both entry points
documented as guaranteeing one element per input. That gate is
unconditional, so a batch containing one such file returned a shorter
list and thezip(inputs, results)pattern the docstrings endorse
silently attributed every later result to the wrong path: a
data-corruption class defect with no error and noAnalysisFailureto
observe. Withskip_generated=Falsesuch a file now holds its
position as aNoneelement — the same value single-fileanalyze
returns for it, and oneto_sarifalready skips — so the documented
zipis finally safe (#1238). Theskip_generated=Truedefault is
unchanged: a skipped file, generated or unreadable, still yields no
element. The typed surface widens to match:analyze_batchand
analyze_pathsare now annotated
list[FuncSpaceDict | AnalysisFailure | None]in_native.pyi, so a
mypy --strictconsumer indexing a slot without aNonecheck is
told to add one rather than discovering it at runtime. An untyped
skip_generated=Falseconsumer sees the change at runtime instead: a
loop that indexed every slot used to run to completion on such a batch
(silently mis-paired) and now raisesTypeError: 'NoneType' object is not subscriptableat the placeholder, andlen(results)grows.
That is the intended direction — loud and local beats silent and
downstream — but it is a behaviour break, andanalyze_pathsshares
it, so a directory walk underskip_generated=Falsegains one element
per discovered file the gate declined. Widening a return union is not
an additive change under STABILITY.md; the exception
and its reasoning are recorded there under Python bindings →
Typing. -
Ruby regex literals and Perl bare match literals fabricated division
operators: the delimiter tokens under the literal wrapper were
classified through the generic/operator arm, sox = /abc/
reported a division with none in the source. Both are now
parent-guarded toUnknownunderregex/pattern_matcher, the
same compound-leaf shape #1256 applied to Elixir sigils. Ruby's
%r{…}spellings alias onto the same token and are covered; a
standalonea / bdivision still counts. Ruby and Perl Halstead
operator counts drop accordingly (#1312). The remaining eight
languages with this defect are tracked in #1314. -
Literal delimiters fabricated operators in eight more languages, the
rest of the class #1256 and #1312 fixed. A JavaScript, TypeScript,
TSX or MozJS regex spelled both delimiters/; a Groovy slashy
string spelled its closer/; a C++ or MozC++ raw string spelled its
R"(opener(; and a Tcl or iRules braced word spelled its{
the way a script block does. Each reported an operation absent from
the source, and each moved with the author's choice of delimiter. All
are now parent-guarded toUnknownunder the literal wrapper, so a
real division, call or block still counts (#1314). -
JavaScript-family regex literals contributed no operand either —
Regexwas in neither the operator nor the operand arm, so/abc/g
reached the Halstead vocabulary from neither side. It now counts as
one operand, matching Ruby'sregexand Elixir's sigils.n2/N2
rise for JS-family code containing regex literals (#1314). -
Perl's five pattern wrappers all scored zero, and are now split by
what they are:/abc/,m/abc/andqr/abc/are pattern values
and count as one operand each, whiles///andtr///(with its
y///synonym) are operations applied to a target and count as
operators, rendered inbca opsass///andtr///. Perl
n1/N1/n2/N2all move for code using patterns (#1314). -
PHP string-interpolation openers fabricated a block operator.
{is
both the compound-statement brace and the complex-interpolation
opener, so"{$x}"inflated the same{}vocabulary entry a real
block uses — the worst case of the five interpolating languages, and
the only one where the two share a token. Guarded in all four
positions the grammar puts it (encapsed_string,heredoc_body,
shell_command_expression, and the deprecated"${x}"form's
dynamic_variable_name); a real block still counts (#1314). -
Every
bcasubcommand man page omitted the CLI's global options
(-w/--warnings,--report-skipped), andbca-vcs-commit.1/
bca-vcs-trend.1additionally omitted the whole vcs history-tuning
family.xtaskrendered pages from a never-builtclap::Command, and
clap propagates global args into subcommands only during
Command::build(). The drift gate could not catch it: the committed
pages faithfully matched the wrong generator output. The regenerated
pages gain the missing options and keep their existing
bca-metrics-style synopsis spelling (#1248). The same pass removes
two dangling cross-references:man/bca.1pointed atbca-help(1)
andman/bca-vcs.1atbca-vcs-help(1), pages the renderer listed
in SUBCOMMANDS but deliberately never wrote. clap's auto-inserted
helpsubcommand is now suppressed in the rendering tree, so it is
neither listed nor referenced;bca help <cmd>is unaffected at
runtime. -
C++ function-pointer data members (
int (*fp)(int);) were counted as
methods and skipped as attributes — both backwards. The
function_declaratorarm is now gated on the declarator child not
being an indirection inside parentheses, and the attribute counter
recurses throughfunction_declarator/parenthesized_declarator
so the field is reachable. Parenthesised method names
(void (f)();) andFoo* operator->();still count as methods
(#1300). -
C++ conversion operators declared without a body (
operator float();
andtemplate<typename T> operator T();) were counted as neither a
method nor an attribute — their declarator is anoperator_cast, not
afunction_declarator, so bothnpmandnpaskipped them. They
now count as methods, in both the Cpp and Mozcpp grammars (#1298). -
In TypeScript and TSX, a
: stringtype annotation counted as both a
Halstead operator (thepredefined_typewrapper) and an operand (its
inner anonymous"string"keyword token, added by #313 for parity
withChecker::is_string) — one source token, two tallies, while
: number/: booleancounted once. The keyword now counts exactly
once, as the text-keyedstringoperator, symmetric with every other
predefined type; Halstead length and vocabulary drop accordingly for
annotation-dense TS/TSX code.Checker::is_stringwas narrowed in
the same direction, sobca find --type string/bca count --type stringreport string literals and templates only, no longer the
: stringannotation keyword; string literals whose contents spell
"string"are unaffected (#1261). -
A Ruby stabby lambda (
->(z) { … }/->(z) do … end) opened two
nested anonymous Function spaces — one for theLambdanode and a
phantom zero-metric one for theBlock/DoBlockthat is the
lambda's own body. #465 had fixed the closure count half; the
space-promotion walk still promoted the body block a second time.
TheBlock | DoBlockpromotion is now gated on the same
parent-is-not-Lambdapredicateis_closureuses, so each stabby
lambda opens exactly one space; keyword forms (lambda { },
proc { }) and iterator blocks are unchanged. Serialized space
trees for Ruby files with stabby lambdas lose the phantom entries,
which also removes their zero contributions fromfunction_spaces
averages and per-file minimums (#1257). -
Ruby cognitive complexity charged a stabby lambda's body twice for
lambda nesting: both theLambdawrapper and its own bodyBlock/
DoBlockincremented the surcharge, sof = ->(a) { if a then 1 end }scored 3 where the equivalent keyword formf = lambda { |a| if a then 1 end }scored 2. The body block is now excluded via the
same shared stabby-lambda-body predicate as the closure count and
the space tree (the sweep #1257's rationale mandated), and both
forms score 2. Cognitive values drop by 1 per nesting-sensitive
construct inside each stabby lambda; keyword lambdas,proc, and
iterator blocks are unchanged. -
Elixir sigil delimiter tokens (
/,(,{,[,<,>,|)
counted as ordinary Halstead operators, so the delimiter choice
changedn1/N1and~r/abc/fabricated two division operators.
The delimiters are now suppressed when their parent is the sigil
node (which is already the operand);~remains the single
per-sigil operator, and standalone/,<,|, … in ordinary
expressions — including inside a sigil's interpolation — still
count. The ABC condition count had the same delimiter sensitivity
through</>(x = ~s<hi>scored 2 conditions,x = ~s(hi)
zero) and now applies the same parent-is-sigil guard (#1256). -
Elixir's bare
_ ->catch-all arm (case/receive/rescue)
and any unguardedtrue ->directly under acond(shape-based,
whatever the arm's position — matching Rust's position-blind
bare-_rule) counted toward standard cyclomatic complexity, while
every sibling language excludes its default arm (Rust_ =>,
Pythoncase _:, Kotlinelse ->, C-familydefault:, …). Both
are now excluded; guarded wildcards (_ when …), named discards
(_x ->),true ->under acase, and a multi-clausefn's
trailing_ ->(its free base path is the #776 head-clause skip,
so its 2nd+ clauses all count, in parity with the identicalcase)
still count (#1272). -
A Tcl
try { … } on error { … }construct contributed zero to
cyclomatic and cognitive complexity. Eachon/traphandler now
counts +1 in both metrics (finallystays free), matchingcatch
and every exception-bearing sibling language, and the iRulesTry
sibling counts the same way — itson_handler/trap_handler
nodes previously opened spurious anonymous function spaces,
inflatingnom, instead of counting as decisions (#1266). -
A Tcl
forloop contributed zero to cyclomatic and cognitive
complexity because the grammar has noforrule — the loop parses
as a genericcommandnode. It is now recognised by command name
at the same out-of-band slot asswitch(#467) and counts like
while/foreach, nesting increments included (#1264). -
Every variable a Tcl script assigned was absent from Halstead
operands: the parser emits the anonymousidkind in both of its
positions, and the getter excluded it wholesale on the false
premise that it only appears inside$varsubstitutions (whose
wrapper is already the operand). The exclusion is now scoped to
the substitution-leaf position, sosettargets count in
n2/N2, matching the iRules parent guard (#1294). -
The
enumscode generator minted collision-breaking names
(Foo→Foo2) without registering them, so a minted suffix could
silently duplicate a node kind whose own sanitized name is literally
base+ digits.tree-sitter-phpis one aliased rule away from
exactly that — it already emitscast_type_token1through
cast_type_token12— and the generator would have exited0, leaving
the duplicate to surface as a rustc duplicate-variant error in the
parent crate during a grammar bump, where it reads as upstream
breakage. Minted names are now probed against, and registered in, the
taken-name set. No shipped artifact changes: no current grammar
reaches the collision, so every generated file is byte-identical
(#1237). -
bca check --baselinerendered a malformed, double-signed
[regr +-29%]tag for everymi.*regression.Baseline::classify
has been direction-aware since #827 — for the lower-is-worsemi.*
family a drop below the recorded value is the regression — but the
tag formatter still computed(value - recorded) / recordedand
prefixed a literal+, so the percentage carried its own minus sign.
Anything grepping the documented[regr +N%]shape mis-parsed it. The
magnitude is now measured in the metric's own direction, keeping one
tag shape across every metric; the higher-is-worse rendering is
unchanged (#1242). -
The VCS bot-author filter was case-sensitive despite
DEFAULT_BOT_PATTERN's doc promising the opposite since the option
shipped.BotFilter::newcompiled with a plainRegex, and
push_if_humanmatches the raw signature bytes before any
lowercasing, soDependabot[bot]was counted as a human author while
its lowercase twin was excluded — diluting ownership and raising file
risk on the same repository depending only on how a bot capitalised
itself. A user pattern behaves as written now too:
--bot-pattern renovatematchesRenovate Bot. Prefix(?-i)to
restore exact matching.CACHE_SCHEMA_VERSIONmoves 1 → 2, since the
cache fingerprint hashes only the pattern string and an event log
walked under the old matcher would otherwise replay with its excluded
set intact; the bump costs one cold walk (#1265). -
The VCS window-boundary computation was non-saturating in two of its
six sites —build_cached, the default path forbca vcs, and
BlameWalk's constructor. Both operands are attacker-adjacent: the
reference time is an--as-ofvalue or a committer timestamp read out
of an object header, and the window length is user-supplied up to
roughlyi64::MAXthrough--long-window. A plain-therefore
panicked in a debug build and, in release, wrapped to a boundary in
the far future, silently reversing the pure-hit check, the candidate
filter,prune_and_dedup, and thewalk_long_boundarypersisted for
future runs. All six sites now route through one
vcs::options::window_boundaryhelper that documents and pins the
saturation invariant once.days_betweencarried the same hazard
under a.max(0)clamp that a wrapped (positive) delta defeats, and
saturates too (#1271). -
A Bash arithmetic ternary — the only ternary form Bash has —
contributed nothing to cyclomatic or cognitive complexity, so
local m=$(( a > b ? a : b ))reported cyclomatic 1 (base only) and
cognitive 0, where the same construct scores 2 / 1 in every sibling
that has a ternary. It is now a decision point in both cyclomatic
tiers and a nesting construct for cognitive, matching the C-family
ConditionalExpression. Both arithmetic contexts are covered — the
$(( … ))expansion and the bare(( … ))statement. Bash
cyclomatic,cognitiveand themifamily derived from them shift
for files using arithmetic ternaries (#1268). -
Go ABC scored zero assignments for a
vardeclaration with an
initializer.var x = 5andx := 5are the same binding spelled two
ways, but only the latter is ashort_var_declaration; the former is a
var_spec, which no arm matched. An initializedvar_spec— typed or
not, standalone or inside a groupedvar ( … )block — is now one
assignment, matching what Rust, Java, C, C++, C#, JS, Lua and PHP
already count for the same construct (each measured).conststays
excluded, as does an uninitializedvar z int. Goabcvalues rise
by one per initializedvarspec (#1278). -
Constructor delegation scored zero ABC branches in Java, C# and
Kotlin.super(…)/this(…)parses asexplicit_constructor_invocation
in Java,: base(…)/: this(…)asconstructor_initializerin C#,
and: super(…)asconstructor_delegation_callin Kotlin — none of
which is the call kind each language's branch counter matched, so a
delegation contributed nothing where Groovy scored one for identical
source. Each is now one branch, per Fitzpatrick's "one per function
call". Calls in a delegation's argument list are separate nodes and
still count on their own, sosuper(f())is two branches, not three.
Java, C# and Kotlinabcvalues rise by one per delegating
constructor (#1279). -
Ruby ABC counted the
<of a superclass clause as a comparison
condition, so every subclass declaration scored a phantom condition —
class Foo < Barwith a single assignment inside reported
conditions = 1for a file with no conditional in it.LT/GTare
now counted only under abinaryparent, the positive polarity Rust,
Go, C, C++, Java, Groovy, C#, Kotlin and the JS family already use.
The same gate stops counting the<that names an operator method
(def <(other)). Real comparisons,<=>, the<<shovel and heredoc
openers are unaffected. Rubyabcvalues drop by one per subclass
declaration and per operator-method definition (#1280). The gate now
covers the sibling comparison and equality tokens too —==,!=,
===,<=,>=,<=>,=~,!~are each equally definable as an
operator method, sodef ==(other)scored the same phantom condition
def <(other)did. -
Bash ABC counted every I/O redirection as a condition.
>and<
spell a redirect as well as a comparison, and the grammar parents the
redirect underfile_redirect, soecho hi > out.txtreported
conditions = 1for a line with no test in it — the Bash instance of
the #1280 polarity, missed by that issue's cross-language sweep. Both
tokens now require abinary_expressionparent; comparisons inside
[[ … ]]and(( … ))are unaffected. Bashabcvalues drop by one
per redirection (#1280). -
Bash ABC scored no condition for the arithmetic ternary, so
local m=$(( a > b ? a : b ))reported 1 where the equivalent C
int m = a > b ? a : b;reports 2. #1268 brought Bash's only ternary
form into cyclomatic and cognitive; ABC now counts it too, matching
the C-familyConditionalExpression. Bashabcvalues rise by one
per arithmetic ternary (#1268). -
fix_includesreturned its diagnostics in a different order on every
run for identical input, because both producers push while iterating a
HashMap—filesinbuild_include_graph,nodesin
record_indirect_includes.bca preprocprints thatVecstraight to
stderr, so the same source tree emitted the same warnings in a
different order each time. Measured at 40 distinct orders across 40
runs of one 8-file input, against a single distinct set — the
content was already deterministic, only the sequence was not. The
sequence is now sorted, which neededOrdon the public
PreprocDiagnostic(additive; seeSTABILITY.md). The inner half of
this had already been fixed:IncludeCyclesorts its own member list
"so the emitted diagnostic is deterministic across runs", and only the
outer sequence was left. Found while establishing whether
fix_includeswas safe to fuzz (#1288). -
The
LANG::Carm of the preprocessor macro-replacement pass had no
test that could observe it.parse_then_metrics_c_with_preproc_…was
added by #721 to give that arm "direct coverage", but its only
assertion is acyclomatic_sum, andDBG ? x : 0and$$$ ? x : 0
have the same complexity — so it passed unchanged with the whole
LANG::C | LANG::Cpp | LANG::Mozcpparm ofget_fake_codedisabled,
which is how this was measured. It now asserts the rewritten bytes.
TheCppsibling has the same blind spot but is separately covered by
cpp_ast_source_reflects_preproc_expansion; theCarm had nothing.
Found while verifying that the newpreproc_macrofuzz target is not
vacuous (#1154). -
Halstead, PHP: variable references no longer count twice.
$xparses
as avariable_namewrapping anameleaf and both were classified as
operands, soN2roughly doubled on variable-dense PHP andn2carried a
sigil-less twin per variable; variable-variable syntax compounded it ($$a
scored 3,$$$bscored 4).Nameis now suppressed under a
variable_nameordynamic_variable_nameparent, and those wrappers under
adynamic_variable_nameparent, so every reference counts once at any
nesting depth. Volume, difficulty, effort and MI change for every PHP file
(#1259). -
Halstead, Elixir and C#:
true/false/nil/nullliterals no
longer count twice inN2. The grammar wraps the keyword leaf in a named
literal node andget_op_typeclassified both as operands; because
operands are keyed by source text,n2hid the duplication whileN2—
and the length, volume, difficulty and effort derived from it — inflated by
one per literal occurrence. Elixir drops the leaves outright; C# parent-
guards them, becauseoperator trueemits a bare leaf with no
boolean_literalwrapper (#1253). -
ABC, Java and Groovy: generic type syntax is no longer counted as
conditions. A generic declaration (class Gen<T>,<T> void m(), Groovy's
def <U> U m(U x)) scored two conditions per bracket pair, and a wildcard
bound (List<? extends T>) scored one as a phantom ternary. Both tokens
are now gated on their parent node kind, matching the polarity C, C++,
Objective-C, mozcpp, Rust and Go already use (#1274). -
npm, C++ and Mozcpp: a templated member function with an inline body
was not counted. Thetemplate_declarationguard resolves through a shared
helper that hunted afunction_declarator, but a templated member with a
body parses astemplate_declaration > function_definitionand has no
such node at that level — so the method scored zero whilenomopened a
function space for it andwmcweighted its cyclomatic, leaving three
metrics disagreeing about one class. The helper now accepts a
function_definitionchild outright rather than recursing into it, which
also fixes a templated conversion operator (whose declarator is an
operator_cast, so nofunction_declaratorexists at any depth). C++
npmvalues rise on classes with templated inline-bodied members (#1258). -
bca count -t callandbca find callreported 0 on all C, C++ and Mozcpp
source.Checker::is_callmatched only the unsuffixedCallExpression
kind_id, which is the grammar's always-aliasedpreproc_call_expression
and never reacheskind_id(); every real call carries the aliased variant.
No metric values are affected (#1254). -
web: a misused
author_hash_keyonPOST /v1/vcsand/v1/vcs/trend
reportederror_kind: "vcs_internal_error", the token reserved for backend
faults, so a client branching on the token saw its own mistake as a server
failure. It now carriesvcs_invalid_author_hash_key. The status was
already correct (400) (#1245). -
bca metrics --output <FILE>andbca ops --output <FILE>wrote the
aggregate document's per-file elements in worker-completion order, so two
runs over an unchanged tree produced differently-ordered files at
--jobs > 1. The elements are now sorted by emitted path in every format
(JSON, YAML, TOML, CBOR, CSV), making the artifact diffable and usable as a
cache key. Multi-seed runs change too: the walk sorts per seed and
concatenates, sobca metrics -p b -p a --output x.jsonpreviously emitted
b's files first and now emits one globally sorted document (#1244). -
bca check's remediation block printed a baseline-refresh command in the
pre-#597 flag order (bca --paths … check …), which exits 1 with a clap
usage error. The command now names thechecksubcommand first and mirrors
every flag that decides what--write-baselinerecords — including
--threshold,--no-config,--check-excludeand--check-exclude-from,
without which the suggested refresh wrote a different baseline than the
gate measured, or failed outright (#1243). -
The man-page drift gate now fails on a newly generated page.
git diff --exit-code -- man/reports tracked content only, so the page
cargo xtaskwrites for a brand-new subcommand passed both the CI
manpagejob andmake manpages-checkgreen and silently never shipped.
The check moved out of the two hand-mirrored shell blocks into
utils/check-manpage-drift.py, which both sites now call, and covers
modified, deleted, and added pages (#1249). -
cargo xtaskdeleted the man page it had just written when a command was
renamed case-only, on macOS/APFS and Windows/NTFS, while still exiting 0.
render_man_page's collision guard folded ASCII case;sweep_orphans
compared byte-for-byte. Because case-insensitive filesystems are also
case-preserving, the write forBCA.1landed in the existingbca.1
directory entry and the case-sensitive sweep then unlinked it as an orphan.
The relation now has a single definition that both sites call, and the
sweep classifies three ways rather than two: byte-equal keeps, no match
removes, and a case-only match is refused with an error naming every
conflicting spelling (#1250). -
The pre-tag
cargo publish --dry-runforbig-code-analysiswas skipped
on every release, not only the first. It was gated on a crates.io sparse
index probe for a leaf version that the Lockstep policy guarantees is the
version being released — and therefore never published yet — so the branch
that runs the dry-run was unreachable, whilerelease.ymland
RELEASING.mdboth told a maintainer it became a hard gate from the second
tag onwards.big-code-analysis-cliandbig-code-analysis-webhad no
such check at all: both pinbig-code-analysis = "=<version>", so neither
could have been dry-run either. Replaced withmake check-publish-metadata
(utils/check-publish-metadata.py), a registry-independent gate over every
publishable crate's crates.io-required metadata,[package].include
whitelist, and packaged size, wired intorelease-check,lint,
pre-commit,ci, and the release workflow's preflight. It reads resolved
fields fromcargo metadataso[workspace.package]inheritance is
honoured, and resolvesinclude.workspace = trueseparately because
cargo metadatadoes not emitinclude. Itscargo package --listruns
--locked, carrying over the flag from thecargo publish --dry-run --lockedit replaced: without it a staleCargo.lockis silently
re-resolved and rewritten and the gate still reports a pass, so a tag
could be cut without the committed lockfile ever being verified (#1224). -
make release-checknow probescargo-denyandcargo-aboutbefore
running them, naming the missing tool and its exact install command instead
of surfacing cargo's genericno such commandpartway through the gate.
Both are listed bymake check-toolsand documented inRELEASING.md. The
cargo-abouthint spells--features cli: the binary sits behind a
non-default feature, so a barecargo install cargo-aboutcompiles the
library, installs no binary, reports the miss as a warning, and exits 0
(#1226). -
make release-checkrejects uncommitted changes in the vendored grammar
leaves before its slow stages, with the commit → gate → tag ordering as the
remedy rather than cargo's misleading--allow-dirtyhint — passing that
flag would dry-run content differing from the tag, defeating the gate. The
check is scoped to whatcargo publish --dry-runactually rejects (tracked
changes inside the five leaf directories), measured rather than assumed, so
it cannot fail on trees cargo would accept.RELEASING.md's pre-release
checklist now carries the gate and that ordering explicitly, and notes the
push is separable (#1225). -
The CLI and web crates no longer terminate on a library parse error.
All fifteen.expect(FEATURES_PINNED)call sites — eight inbca's
dispatch helpers, seven inbca-web's handlers, plus the constant
itself in each crate — now propagate onto the error channel each
caller already had: anio::Errorof kindInvalidDatathat the
concurrent runner reports per file and continues past, and the
existing sanitized500that logs the cause server-side. The pinned
all-languagesfeature does makeMetricsError::LanguageDisabled
unreachable, butMetricsErroris#[non_exhaustive]and documents
that variants may be added in a minor release, so theexpectwas a
panic scheduled against a routine dependency bump rather than an
invariant. No behaviour changes today: the only reachable outcome is
still success (#1152).
Changed
-
Groovy's qualified-type operand classification is now pinned by a
regression test (#1352). #1263 removed bothQualifiedNameand
QualifiedTypefromGroovyCode::get_op_type's operand arm, but only
theQualifiedNamehalf was covered; re-adding the emitted alias
QualifiedType2failed no test, leaving the "complete the alias list"
reading — which is the double count #1263 removed — unobstructed. No
metric values change. -
gix0.86 → 0.87.1, with the rest of the gitoxide family advancing
in step (36 lockfile entries). Forced rather than routine: every
published version ofbisync, a transitive dependency of
gix-protocol0.64, was yanked on 2026-08-24, socargo deny's
advisoriescheck failed on any lockfile still holding it and no
cargo update -p bisynccould satisfy the^0.3.0requirement.
gix-protocol0.65 dropped the crate. One source change:gix-date
now takes its "now" reference as ajiff::Zoned, so--as-of
parsing passesgix::date::Zoned::now()instead ofSystemTime.
No behaviour change and no public-API change — nogixtype appears
in a public signature. The six excluded crates lock nogixand are
untouched. -
The pdf.js corpus test now covers all 384 files: the 118-entry
exclude list frozen in the mozjs-default era (#84) was retired
(#1282), since every entry parses without ERROR nodes under the
post-#507 upstreamtree-sitter-javascriptgrammar. The issue's own
probe had reported 3 residual failures, but those wererg ERROR
matching source text (MAX_ERROR, a regex literal) rather than
parse-error nodes. The 118 orphaned mozjs-era snapshots were
refreshed to current metric output, and the 5 DeepSpeech orphan
snapshots (files still excluded under #86) were deleted, so on-disk
snapshot counts match the asserted counts for every corpus. The
corpus harness now asserts that inverse direction — a.snapwith no
corresponding corpus file fails the test naming the orphan — so a
future exclude or corpus change cannot strand snapshots silently. -
The
tree-sitterruntime is=0.26.12, up one upstream patch
release, pinned in lockstep across the root manifest,enums, and
the five vendoredbca-tree-sitter-*crates.tree_sitteris
re-exported from the library root, so the resolved version is visible
to consumers. Nothing in the release reaches this workspace's
behaviour:include/tree_sitter/api.hand the Rust bindings are
byte-identical to0.26.11, soTREE_SITTER_LANGUAGE_VERSIONstays
at 15 and the vendoredparser.csources are unaffected, as is the
Send + Syncargument the PyO3 bindings rest on. The three C fixes
are an error-recovery restart when the parser is already in
ERROR_STATE, ahas_later_named_siblingscorrection in the tree
cursor, and query-anchor semantics for skipped quantifiers — the
last inert here, since this workspace uses no tree-sitter query API.
No metric value moves. -
Ruby's and Elixir's
#{interpolation opener no longer counts as a
Halstead operator. Unlike PHP's{, which aliases the
compound-statement brace and was fabricating a block (see Fixed),
#{is a token of its own and nothing was miscounted — this is a
deliberate change of rule, so that the six interpolating languages
agree. Kotlin, C# and Groovy already declined to count theirs; an
interpolation opener is spelling rather than an operation, and the
interpolated expression's own operators are counted either way. Ruby
and Elixir spell the marker with the same token, so leaving either
counted would have made the two disagree on one construct. Halstead
operator counts drop for every literal that interpolates: Ruby
strings, symbols, regexes, heredocs and subshells, and Elixir
strings, charlists and sigils (#1314). -
The
enumsgenerator'ssanitize_string/get_token_nameslost their
escape: boolparameter. No production caller passedtrue: #862
established that the JSON generator, the last one, was double-escaping
by mistake. Removing the flag deletes the dead double-backslash branch,
its test, and theJSON_TOKEN_ESCAPEconstant that existed only to
document why the answer must befalse— making the #862 bug
unrepresentable rather than one flipped boolean away. Internal build
tool only; generated output is byte-identical (#1241). -
The workspace-excluded
enumscrate declares the workspace lint posture
(clippy::pedantic,missing_docs) in its own manifest.
[workspace.lints]reaches members only, somake enums-checkhad been
gating the crate at-D warningsagainst the compiler defaults while
reading as a full lint gate. Clearing the table cost 38 findings, not the
23 pedantic ones alone —missing_docsaccounted for the other 15 — and
none was silenced with a blanketallow.
utils/check-excluded-manifests.pygained a third invariant so this cannot
recur: every workspace-excluded crate must declare its own[lints]table
or be named in the gate's exempt set. It is an exempt list rather than a
required list, so the next excluded crate has to make the decision
explicitly instead of inheriting the silence. The five vendored
tree-sitter-*grammar crates are exempt — their Rust is generated binding
boilerplate a regeneration replaces wholesale (#1228). -
make py-fmt,py-fmt-checkandpy-lintresolve
big-code-analysis-py/.venv/bin/ruffbefore PATH, the waypy-typecheck
already resolved mypy and pyright, so the local gate runs the
uv.lock-resolved ruff rather than whichever unpinned copymise.toml,
theDockerfile, or a barepipx install ruffleft on PATH. Those three
provisioning paths stay unpinned deliberately: an exact version in any of
them would be a fifth ungated copy to keep in lockstep, which is the
failure this change exists to prevent (#1230). -
clippy::arithmetic_side_effectsis enforced on thelocmetric
module, and the span arithmetic there is now explicitly saturating.
Locis the one metric computing on tree-sitter row coordinates, and
#1051 was ausizeunderflow of exactly that shape — a Rust doc
comment at EOF droveend - 1below zero from an input as small as
/// x, panicking in debug and wrapping tousize::MAXin release.
Validated by replaying the lint against the pre-#1051 tree, where it
flags both reported panic sites. Metric values are unchanged: a
saturating operation is identical to the plain one unless it would
have overflowed, and none does (#1152). -
clippy::indexing_slicingis enforced onsrc/c_macro.rs, the C/C++
macro-masking byte lexer, with nine per-function carve-outs each
naming the bound that makes its indexing safe. Validated by replaying
it against the pre-#126 tree, where it flags the&DOLLARS[..]slices
that panicked on macro identifiers longer than 2048 bytes. The one
slice whose bound is established in a different function —
step_raw_string's delimiter comparison, carried through
LexState::RawString— is hardened withgetrather than allowed
(#1152). -
clippy::unwrap_usedis enforced on production code across every
crate, as#![cfg_attr(not(test), warn(...))]at each of the eight
lib/bin roots rather than a[workspace.lints]entry: a Cargo lint
applies to every target of its package, and the ban is a production
rule — this workspace has 0 productionunwrap()calls against
1,023 legitimate ones in test targets.cfg(test)is set for
integration-test crates as well as the unit-test target, so the gate
needs no per-file carve-out and carries zero#[allow]s. Adopting it
costs nothing today and fails CI on the first productionunwrap()
added.clippy::expect_usedis deliberately not enabled — all 37
productionexpectsites already name their invariant in the message,
the formAGENTS.mdsanctions. The workspace-excludedenumscodegen
crate carries the same gate: it is CI-linted bymake enums-checkbut
invisible tocargo clippy --workspace, so its 7 production
unwrap()calls were outside the original count. They now propagate
onto theio::Resulteach generator already returned, except the Go
generator'smax()width, which becomesunwrap_or(0). None was a
reachable crash: every one rests on an invariant as solid as the 37
expectsites left alone. The difference is that anunwrap()states
no invariant, which is the whole basis for gating it (#1227). -
The Python bindings' ruff config states its rule set absolutely
(select) instead of relative to ruff's defaults (extend-select),
and the dev-extra bound moves toruff>=0.13,<0.17withuv.lock
and therequirements/exports resolving 0.16.2. ruff 0.16.0 grew its
default rule set from 59 rules to 413, which underextend-select
took this config from 265 enabled rules to 501 and pulled in 28
families it never selected —PLC/PLE/PLR/PLWamong
them, silently overriding the deliberate omission of Pylint's design
rules that the config comment states. The same release dropped 18
opinionatedE/Frules from the defaults, which a
defaults-relative config would have lost just as quietly. Under
selectthe count is 265 on 0.15.22 (unchanged, so nothing was lost
by dropping the implicit defaults) and 268 on 0.16.2, the three
additions being newRUFrules in an already-selected family.
ruff checkpasses on 0.15.22 and 0.16.2 with nonoqa, no
suppressions, and no source changes; the three diagnostics 0.16
reported (DTZ001,BLE001,PYI044) were all in families this
config never asked for. Theruff-pre-commitrev:in
.pre-commit-config.yamlmoves tov0.16.2to match whatuv.lock
resolves (it had drifted tov0.15.14against a locked 0.15.22).
The floor moves off0.6in the same breath because it was already
fiction:UP038left ruff's stable set in 0.13, and every release
from 0.6.0 through 0.12.12 flags the same five
isinstance(x, (A, B))sites here — under the oldextend-select
config identically, so this states what was always true rather than
changing anything (#1222).
Security
- Cleared the OpenSSF Scorecard Vulnerabilities alert for
RUSTSEC-2026-0258 (h2queues empty HTTP/2 DATA frames without
limit). The flagged crate wash2 0.3.27, reached only through
actix-web's defaulthttp2feature, and no patched 0.3 release
exists:actix-http3 is pinned toh2 0.3and the fix ships only
inh2 0.4.16.big-code-analysis-webnow buildsactix-web
withouthttp2— the daemon binds plaintext, and actix negotiates
HTTP/2 only over TLS ALPN, so the feature was never reachable — which
dropsh2 0.3fromCargo.lockaltogether. A web-crate test pins the
lockfile against anyh2older than the fix, because cargo-deny's
graph builder drops that crate before its advisory and ban checks run
and so cannot guard it.