big-code-analysis is on the 2.x line (currently 2.2.0). This
document is the public-API stability contract: what is stable, what
counts as an additive change, what is reserved for a major bump, and
the narrow seams where the contract deliberately does not reach.
Within 2.x, callers can pin a minor version and expect a working
tree against any subsequent patch or minor bump, without code edits.
The crate follows Semantic Versioning 2.0.0:
| Bump | Shape (types, fn signatures) | Value (metric numbers) |
|---|---|---|
2.X.Y → 2.X.Y+1 (patch) |
Held stable | May change |
2.X.Y → 2.X+1.0 (minor) |
Additive only | May change |
2.X → 3.0.0 (major) |
Breaking changes allowed | Re-baselined |
"Additive only" means new items may be added to the public API and
existing items may gain #[non_exhaustive]-style fields / variants
where they already permit it, but no existing item is renamed,
removed, or has its signature changed within 2.x. Anything that
would qualify as a SemVer break is held for the next major bump
(3.0) and will be called out in the changelog under
(breaking).
Why patch and minor bumps can still move metric values: a grammar
pin bump (any tree-sitter-* crate in the root Cargo.toml) or a
regression fix in a metric definition is by definition a value
change but does not break the API surface. Callers who need
bit-for-bit reproducibility should pin to an exact version
(big-code-analysis = "= 2.2.0") and store that version alongside
their results; see What is stable in value
below.
Within 2.x, the following items are SemVer-protected. Their
signatures will not change until a 3.0 bump; any such break appears
in the changelog under (breaking) in the 3.0.0
section.
-
Language identification
LANGenum (generated by themk_langs!macro invoked fromsrc/langs.rs; the macro itself lives insrc/macros/mod.rs): variants are additive. Adding a new variant in a minor bump is allowed; renaming or removing one is a3.0break. DerivesHashand implementsDisplay(thenamestring) andFromStr(parsing that same canonical name; error typeParseLangError). Since #540 every variant has a distinct canonical lowercase slug (cpp,csharp,tsx,typescript,javascript,mozjs, …), soDisplayis injective andLANG::from_str(&lang.to_string()) == Ok(lang)for every variant. This slug is the single language identifier across every surface (the CLI JSONlanguagefield, the weblanguagefield on every analysis endpoint (/metrics,/comment,/function(#541)) and the Python bindings). The human-prettyc/c++/c#display forms were dropped at2.0, a break in the serializedlanguagevalue.get_language_for_file,guess_languageinsrc/tools.rs.
-
File readers (
src/tools.rs)read_file,read_file_with_eol,normalize_eol. Their signatures are fixed for2.x; in particularread_file_with_eolkeeps itsio::Result<Option<Vec<u8>>>shape, which names no reason for a skip.read_file_with_eol_classifiedandSkipReason(#1287) are the additive sibling that does name it:io::Result<Result<Vec<u8>, SkipReason>>, of whichread_file_with_eolis the.map(Result::ok)projection.SkipReasoncarries#[non_exhaustive], so a future gate adds a variant in a minor bump — match it with a wildcard arm or render it throughDisplay, whose per-variant wording is not SemVer-protected.
-
Top-level entry points
analyzeandSourceinsrc/spaces.rs: the recommended library entry point.Sourcecarries#[non_exhaustive]and its fields are private (pub(crate)): construct viaSource::newpluswith_*setters. Only that builder API is the contract: the field set, names, and types (e.g.code: &[u8],name: String) are free to change and are not SemVer-protected (#533).MetricsOptionscarries#[non_exhaustive]and, likeSource, has private (pub(crate)) fields as of2.0. Construct viaMetricsOptions::default()pluswith_*setters; the contract is the builder methods, not the field representation (#533). The parser-genericmetrics/metrics_with_optionsfunctions and theMetricsCfgbuilder were removed at2.0in favor ofanalyze/Ast.Metric,MetricSetinsrc/metric_set.rs:Metriccarries#[non_exhaustive], so adding variants is additive.MetricSetis the opaque bitfield consumed byMetricsOptions::with_only(&[Metric])and read back throughCodeMetrics::selected(). Its constructors (empty,all,with,union), the dependency-closure operations (from_slice_with_deps,resolved), and inspectors (contains) are stable; the underlying integer representation is not.MetricsOptions::with_metric_setcloses the supplied set underMetric::dependenciesbefore storing it (#743), so a derived metric always reaches the walker with its inputs.Metricis the single metric vocabulary for both selection and suppression: it now derivesOrd/PartialOrd(declaration order, so aBTreeSet<Metric>iterates deterministically) and implementsSerialize/Deserializeas its canonicalDisplayspelling (nargs,nexits,tokens, …). (breaking, 2.0) #555 unified the former parallelsuppression::MetricKindenum ontoMetricand removedMetricKindfrom the public API.SuppressionScopenow carriesBTreeSet<Metric>, so a serialized non-empty suppressed scope spells the exit-point metricnexits(the canonical name) rather than the oldexit; theexitparse alias was retired at2.0(#588), so onlynexitsnow deserializes.tokenshas no threshold and is rejected as non-suppressible (Metric::suppressible()enumerates the silenceable set). The threshold-name resolver moved to the free functionbig_code_analysis::threshold_metric_for_name(formerlyMetricKind::for_threshold_name). This also closes theMetricKind: FromStr<Err = ()>gap (#554): suppression now parses viaMetric::from_str, which returns the descriptiveParseMetricErrornaming the offending input.- The per-file function-listing, finding, counting, and
comment-stripping operations are reached through the [
Ast] seam (Ast::functions,Ast::find,Ast::count,Ast::strip_comments). As of2.0the underlying free functions (function,find,count,rm_comments) arepub(crate), not part of the public surface, and the parser-genericoperands_and_operatorsfunction was removed in favor ofAst::ops.Ops::operatorsandOps::operandsare sorted in byte-lexicographic order of the rendered strings as of the2.1line; before that they were documented as arbitrary and did in fact vary run to run. The distinction matters only for a key that is not valid UTF-8: rendering replaces those bytes with U+FFFD, which is not order-preserving, so the contract is over what a caller actually receives rather than over the source bytes behind it. The order is part of the contract now — callers may diff or hashopsoutput — and will not change before3.0. ConcurrentRunnerinsrc/concurrent_files.rs:new'snum_jobsis the number of consumer threads. Through the2.0line it was a budget shared with a dedicated producer thread andrunspawnedmax(2, num_jobs) - 1consumers; since #1114 dispatch happens on the calling thread and allnum_jobsgo to consumers. The signature did not change, so this is a behavioral change, not a source break: a caller passingngets one more worker than before.without_path_verification(added #1114) opts out of the per-pathis_file()check for a caller whose own traversal already classified every entry.ConcurrentErrors::Produceris no longer constructed — the enum is#[non_exhaustive], but removing a variant would still break a downstreammatch, so it stays until3.0.NumJobsinsrc/concurrent_files.rs(added in 1.x, #560): the shared<N|auto>worker-count selector forConcurrentRunner, used by both thebcaCLI and thebca-webserver. A clap-agnostic plainFromStr+resolve() -> usizetype;Autoresolves cgroup-/cpuset-aware viaavailable_parallelism, falling back to 1. Adding variants is a3.0break (it is not#[non_exhaustive]).<NumJobs as FromStr>::Erris the namedParseNumJobsError(Zero/NotAPositiveInteger, each carrying the rejectedinput(),Display + Error), matching the typedParseMetricError/ParseLangErrorconvention; it is deliberately exhaustive so callers can match both failure modes without a wildcard.MetricsErrorinsrc/error.rs: carries#[non_exhaustive], so adding variants is additive. Current variants areLanguageDisabled(LANG)(the only one produced today) and the reserved-for-futureEmptyRoot. The previously-reservedNonUtf8PathandParseHasErrorsvariants were removed in the pre-2.0 cleanup (#536);#[non_exhaustive]lets a future strict mode re-add them without a break. Thestd::error::ErrorandDisplayimpls are stable; the exact wording ofDisplayoutput is not.
-
Result shapes
-
FuncSpace,CodeMetrics,SpaceKind,Metricsinsrc/spaces.rs. These are the JSON / YAML / TOML / CBOR serialization roots and have downstream consumers.FuncSpaceandCodeMetricsderivePartialEq(#552) so callers can compare analyses structurally;Eqis omitted because the nestedStatscarry float fields (see the per-metricStatsnote below).SpaceKindadditionally derivesHash(#552) so it can key aHashMap/HashSet. These derive additions only widen the trait set and are additive.FuncSpaceimplementsDrop(as doOps,AstNode, and theirwirecounterparts), so its fields cannot be moved out of a value by name —let m = space.metrics;isE0509; clone or borrow instead. This is the one source-level shape break landed under a minor bump rather than held for3.0(#1056): the compiler-generatedDropglue recursed once per nesting level and aborted the process on a tree deep enough to reach throughbca-web's body cap, and an explicitDropthat flattens descendants into a work list is the only way to break that recursion. Treat theDropimpls themselves as an implementation detail — the contract is that these trees tear down in constant stack, not the presence of any particularDropbody. -
FunctionSpaninsrc/function.rs.
-
-
Offender / catalog enums
Severityinsrc/output/offenders.rs(re-exported from the crate root): an ordered severity scale carrying#[non_exhaustive]. As of #552 it derivesHash,PartialOrd/Ordkeyed on declaration order. Variants are declared least-severe-first (WarningthenError), so the derived ordering isError > Warning; callers may rely onseverities.iter().max()selecting the worst tier and on>= Severity::Errorgating. Any future tier (Info/Note) must be inserted in the correct severity position to preserve this contract.metric_catalog::Direction: derivesHash(#552) so it can key aHashMap/HashSet. Adding these derives is additive.
-
Per-metric
Stats: oneStatsstruct under eachsrc/metrics/<metric>.rs(abc,cognitive,cyclomatic,halstead,loc,mi,nargs,nexits,nom,npa,npm,wmc, plustokens). These structs carry#[non_exhaustive](as of2.0), so adding a public field is additive: every field is already private and read through accessors, and the marker makes the "no external struct-literal construction, no exhaustive match" guarantee explicit and future-proof against a field ever being made public. We still flag each field addition in the changelog. Removing or renaming a field is a3.0break.To stay forward-compatible within
2.x, callers should treatStatsas the typed projection of the serialized JSON / YAML output: read fields through accessors or by name, do not construct values with struct-literal syntax, and do not write exhaustivematch/ destructuring patterns against the struct. Code that follows those rules will keep compiling across every minor bump in2.x.Every per-metric
StatsderivesPartialEq(#552), so callers can compare two compute-sideStatsdirectly without round-tripping throughto_wire().Eqis intentionally not derived: the float fields (ratios, averages, ABCmagnitude, the derived Halstead scores) preclude it. ComparingStatsfrom two analyses of the same deterministic source is exact-equal-safe (identical code path, identicalf64bits); do not rely on cross-input float equality. AddingPartialEqis additive: it only widens the derived-trait set. -
Prelude:
big_code_analysis::preludere-exports the recommended entry points (analyze,Ast,Source,MetricsOptions,MetricsError,LANG,FuncSpace,CodeMetrics,SpaceKind,Metric). Themetrics_from_treeentry was removed at2.0along with the free function it named; tree adoption now goes throughAst::from_tree_sitter. New items may be added; nothing in the set is removed before3.0. -
Per-language Cargo features: the feature set (
all-languages, plus per-language featuresrust,typescript,python,cpp, …) is itself part of the contract. Adding a new language feature is additive. Removing one is a3.0break. Theall-languagesdefault is permanent within2.x.
Change-history (VCS) metrics (#328) are an opt-in, additive
surface gated behind the vcs = ["vcs-git"] Cargo feature (off by
default for the library; on by default for the bca / bca-web /
Python builds). When enabled, the following join the shape contract:
the big_code_analysis::vcs module (build_history_index, Options,
Stats, HistoryIndex, RiskFormula, FileTypeScope, parse_window,
parse_timestamp, workdir_root), wire::Vcs, CodeMetrics::vcs, the bca vcs
subcommand (including its --file-types {metrics|all|EXT,…} scope flag
and the matching bca.toml [vcs] file_types key) and bca metrics --vcs flag, the POST /vcs REST endpoint, and the Python
vcs_metrics() function plus the analyze(vcs=True) keyword. bca vcs --format markdown|html and the bca report markdown|html --vcs "Change-history risk" section (#573) are one-way
rendered projections (like the AST bca report output): the page
structure is stable within 2.x, but the exact bytes are not a
round-trip format; do not parse them. The 2.0 line restructured the AST
report's presentation deliberately: hotspot section titles now follow one
<Concept> hotspots (top N by <column>) template (#677), the WMC table is
labeled "Types" (#687), and the HTML section anchors derive from a stable
<Concept> hotspots slug independent of --top, so a deep link minted
against a 1.x report may need re-fragmenting. The VCS report's (total)
column headers became (long) (#592). These are presentation changes only;
no round-trip / wire shape moved. bca vcs --output names a single
file (a whole-repo report is one document); as of 2.0 bca metrics/bca ops --output also names a single aggregate file, with the per-file
directory tree now written by --output-dir (#669). The composite
risk_score is ordinal, not cardinal (only
relative ranks are meaningful) and is formula-versioned
(risk_score_version): the formula may change within 2.x, but any
change bumps that field, and the serialized field set is versioned by
vcs_schema_version. Per-file score magnitudes therefore carry the
same "not byte-stable across bumps" caveat as every other metric value.
In 2.0 the per-file VCS block became a nested vcs object under
each ranked file (and each /vcs/trend point), replacing the former
#[serde(flatten)]-beside-path layout, so it now reads like every
other metric group (#684). The block is also always-slim (#635):
the four constant stamps that hold across an entire response
(vcs_schema_version, risk_score_version, long_window_days,
recent_window_days) are carried exactly once on the enclosing
envelope (bca vcs's report, POST /vcs, vcs_metrics(), and the
/vcs/trend document), never repeated per row or per trend point. The
CSV projection stays flat with dotted column names.
Per-function change-history attribution (#329) extends the same opt-in
surface: big_code_analysis::vcs::{PerFunctionBlame, BlameSession, LineSpan}, the
vcs::Error::Blame variant, the bca metrics --vcs-per-function flag,
and the CodeMetrics::vcs field now being populated on nested function
spaces (not just the file space). The per-function block shares
wire::Vcs's shape and the same risk_score / *_version contract
above, but its numbers are a git blame current snapshot (churn is
surviving-line count, not the file-level added+deleted churn), so values
are not comparable across the file and function levels by construction.
Just-in-time (commit-level) risk scoring (#331) adds a further opt-in
slice of the same surface: big_code_analysis::vcs::{score_commit, JitReport, JitFeatures, JitContributions, JitCommit, JitSize, JitDiffusion, JitHistory, JitExperience, JitPurpose} plus the
JIT_SCORE_VERSION / JIT_SCHEMA_VERSION constants, and the bca vcs commit <commit> subcommand with its --fail-above CI-gate exit code
(2, the check metric-gate convention). (In 2.0 the subcommand was
renamed from bca vcs jit and the flag from --fail-over; both old
spellings remain as hidden aliases for one release cycle, #603.) The JitReport JSON shape is
stable within 2.x and versioned by jit_schema_version; the composite
risk_score (per-diff partial_risk_score) is ordinal, not cardinal
and formula-versioned by jit_score_version (separate from the file-level
risk_score_version), so the same "magnitudes are not byte-stable across
bumps" caveat applies. Both shapes carry a source discriminator
("commit" / "diff", the JitSource enum) so a serialized report
self-identifies; commit-mode reports gained the field in
jit_schema_version 2 (#642), and jit_schema_version 3 renamed the
score keys score → risk_score / partial_score → partial_risk_score
for cross-surface consistency (#591, 2.0). The JIT
serialized types intentionally derive Serialize directly (they are
pure output DTOs) rather than mirroring through a wire::* type the way
the per-file Stats does.
Directory- / repo-level bus factor (#332) adds the last opt-in slice:
big_code_analysis::vcs::{BusFactor, GroupBusFactor, DirectoryBusFactor, VcsAggregate} plus the BUS_FACTOR_SCHEMA_VERSION constant, the
HistoryIndex::bus_factor() accessor and with_bus_factor builder, the
Options::{compute_bus_factor, bus_factor_threshold} fields, the
vcs::options::validate_bus_factor_threshold helper, and the
vcs::Error::InvalidBusFactorThreshold variant. The aggregate is
surfaced as a top-level vcs_aggregate object by bca vcs,
bca report --vcs, POST /vcs, and vcs_metrics(), and is gated on the
front end opting in (compute_bus_factor), so it is purely additive: no
existing field moved and vcs_schema_version is unchanged. Like the JIT
report, the bus-factor types derive Serialize directly; their shape is
stable within 2.x and versioned by BUS_FACTOR_SCHEMA_VERSION. The
public field and serialized key were renamed schema_version →
bus_factor_schema_version (BUS_FACTOR_SCHEMA_VERSION bumped 1 → 2) for
cross-surface consistency (#591, 2.0). The
bus_factor count is a small integer with a direct reading (key
departures that abandon a subsystem), but it inherits the Avelino
heuristic's caveats (a single-author or very young repository skews it
downward), so treat it as a planning signal, not a guarantee.
Historical metric trend (#333) adds one more opt-in slice:
big_code_analysis::vcs::{build_trend, Trend, TrendDelta, TrendDeltas}
plus the TREND_SCHEMA_VERSION constant, the
wire::{VcsTrend, VcsTrendPoint, VcsTrendDelta, VcsTrendDeltas}
projection, and the vcs::Error::InvalidTrend variant. The trend is
surfaced by bca vcs trend, POST /vcs/trend, and vcs_trend(). Each
sampled point's metric block is the same wire::Vcs shape (so its fields
and risk_score carry the same ordinal/versioned contract as bca vcs);
the container shape (as_of_points, the per-file point arrays (null
where a file did not exist), and the deltas summary) is stable within
2.x and versioned by trend_schema_version. The per-point timestamps
and the delta magnitudes are derived from the same ordinal risk_score,
so the "magnitudes are not byte-stable across bumps" caveat applies. The
new module is additive: no existing field moved.
The persistent change-history cache (#334) is an additive, opt-out
optimization: big_code_analysis::vcs::{build_history_index_cached, CacheConfig, CACHE_SCHEMA_VERSION}, the vcs::cache module, the
vcs::Error::Cache variant, and AuthorId::from_digest. It is surfaced
by bca vcs --no-cache / --clear-cache / --cache-dir (and reused
transparently by bca metrics --vcs / bca report --vcs), the
POST /vcs no_cache / cache_dir fields, and the
vcs_metrics(no_cache=…, cache_dir=…) parameters. The contract is purely
behavioral (a cache hit produces output bit-identical to an uncached
build_history_index at the same reference time), so the cache never
changes the wire::Vcs shape or any metric value. The on-disk file
format (the HistoryCache / CommitEvent JSON, versioned by
CACHE_SCHEMA_VERSION) is deliberately not a stability surface: it is
private machine-local state under the user's cache directory, may change
between releases (a stale or unreadable entry is silently recomputed), and
must not be parsed or relied on by downstream code.
The VCS front-end input config structs, vcs::Options and
vcs::CacheConfig, are both #[non_exhaustive]. Downstream crates
construct them by starting from Options::default() / CacheConfig::default()
and assigning the pub fields they need, not by struct literal or
..Default::default() functional update (both of which #[non_exhaustive]
forbids across crates). The fields stay pub so this default-then-assign
form is the supported path, and any field added in a later 2.x is therefore
additive and non-breaking for external constructors. This was landed early
(part of the 2.0 #[non_exhaustive] sweep, #505) because the VCS surface
is still unreleased, so sealing it now breaks no existing downstream user.
Opt-in keyed author-identity hashing (#956) is an additive hardening of
--emit-author-details: big_code_analysis::vcs::AuthorHashKey, the
additive Options::author_hash_key field (admissible precisely because
Options is #[non_exhaustive]), and AuthorId::emit_hashed. It is
surfaced by bca vcs --author-hash-key (and the BCA_AUTHOR_HASH_KEY
environment variable), the POST /vcs author_hash_key field, and the
vcs.Options(author_hash_key=…) parameter. The default (no key) emits the
same bare SHA-256 digest as before, so output is unchanged; the key is a
finalization-time transform that leaves the cache-replay invariant intact
(the on-disk cache stores the unkeyed inner digest, so a cached walk
re-finalizes under any key without a re-walk).
The following are explicitly not part of the shape contract:
- Anything marked
#[doc(hidden)](seesrc/traits.rsfor current examples). These exist for macro plumbing and may move at any time, including in patch bumps. - The per-language
*Code/*Parsertypes (RustCode,PythonCode, …). They are public because themk_langs!macro emits them, but they are intended to be reached throughLANGrather than referenced by name. Parserand the per-languageChecker/Getter/Alteratortrait impls: these are internal plumbing. As of2.0they arepub(crate), not a public extension surface:ParserTrait,Parser<T>,Filter,Cursor, and the per-metric compute traits (Cognitive,Cyclomatic,Halstead,Loc,Mi,Nom,NArgs,Exit,Abc,Npa,Npm,Tokens,Wmc) were all demoted from their former#[doc(hidden)]-but-pubstate topub(crate).LanguageInfowas likewise demoted topub(crate), and theCallbacktrait /AstCallbackdispatch were removed at2.0. None of these are reachable from the public API or appear in the curated rustdoc; treat them as internal plumbing.
Metric values are not guaranteed to be byte-stable across
versions, even within 2.x. Concretely:
- A grammar bump (e.g.
tree-sitter-python =0.25.0→tree-sitter-python =0.26.0) can shift any metric on any file in any direction. Such bumps land under patch or minor versions and are noted in the changelog. - A bug fix in a metric definition (cyclomatic, cognitive, ABC, Halstead operator classification, …) is by definition a value change for the files it touches. We do not hold these for major bumps: fixing a wrong number sooner is more valuable than freezing it.
- Operator / operand classification for Halstead, branch detection for cyclomatic, and exit-point counting continue to be refined per-language. Expect numbers to drift across minor bumps as per-language coverage improves; every such drift is called out in the changelog.
This is the one deliberate carve-out in the 2.x contract. The
shape of the data is stable; the numbers in the cells are not.
If you need to compare metric runs across time, pin to an exact
version (big-code-analysis = "= 2.2.0") and store the version
alongside the results.
The structured serializers (JSON, YAML, TOML, CBOR) emit full
f64 precision for the float-valued fields (ratios, averages,
ABC magnitude, the derived Halstead scores, and the MI scores);
they are not rounded. The exact decimal expansion of a float
magnitude is therefore not byte-stable across versions or
platforms: a metric-definition fix or a grammar bump can move the
last digits, and the same value may render with a different number
of significant digits on a different target. This is the same
"numbers in the cells are not stable" carve-out applied to floats,
and it is the reason float magnitudes are excluded from exact
snapshot anchoring. Do not diff structured output byte-for-byte to
detect change; compare the parsed numbers with a tolerance, or
compare the integer-valued fields (which are exact).
The human-readable diagnostic path (bca check warning lines and
the Checkstyle / SARIF message strings) is intentionally
different: it rounds float magnitudes to six decimal places for
legibility. Machine output favors fidelity (full precision); human
output favors readability (rounded). The two are not expected to
agree digit-for-digit, by design.
Non-finite float values (NaN, ±Infinity) serialize as a
null, uniformly across every structured format. A non-finite
metric means "not applicable" (an average or ratio over an empty
subtree, an undefined log/division in a Halstead or MI formula).
Such values render as a native null in JSON, YAML, and CBOR, and
as an omitted key in TOML (which has no null literal). This is
enforced once at the serialize boundary rather than relying on each
accessor staying finite, so a future metric cannot silently
reintroduce the old per-format split (JSON null vs TOML nan vs
CBOR raw IEEE-754 bits). The metric accessors are additionally
guarded to return finite values today (#428, #438, and the
Halstead/MI log/division guards), so this policy is a structural
backstop rather than a behavior you should observe in practice.
The serialized metric tree can be read back into typed structs via the
public big_code_analysis::wire module: wire::FuncSpace,
wire::CodeMetrics, wire::Ops, wire::FunctionSpan, and one
struct per metric (wire::Abc, wire::Cognitive, wire::Cyclomatic,
wire::Nexits, wire::Halstead, wire::Loc, wire::Mi,
wire::Nargs, wire::Nom, wire::Npa, wire::Npm, wire::Tokens,
wire::Wmc) plus the wire::CyclomaticModified sub-record, the
modified projection nested inside wire::Cyclomatic. Each derives
Serialize + Deserialize. These are the single definition of the
serialized shape (the compute types' own Serialize impls delegate
to them), so the document a compute type emits is exactly the document
the matching wire type parses.
Round-trip contract. For a value produced by this library and read back with this library's serde stack:
serde_json::from_str::<wire::FuncSpace>(&serde_json::to_string(&fs))reconstructs the tree, and the result re-serializes byte-for-byte. The same holds for YAML, TOML, and CBOR. Float magnitudes round-trip bit-exactly because the crate enables serde_json'sfloat_roundtripfeature; CBOR carries raw IEEE-754 bits; YAML/TOML emit full precision.- Integer-valued fields are exact in every format.
- Non-finite floats round-trip as
null↔NaN(omitted-key↔NaNin TOML); see the non-finite policy above. wire::CodeMetricsround-trips the selected metric set: a metric absent from the document stays absent, andwire::CodeMetrics::selected()rebuilds theMetricSetfrom the present keys.
Nesting is bounded on the way out, too. serde cannot emit a tree
without one native stack frame per level, and overflowing that stack
aborts the process instead of raising a catchable panic. Serialization
therefore stops at wire::MAX_SPACE_SERIALIZE_DEPTH (128) nested
FuncSpace / Ops levels and MAX_AST_SERIALIZE_DEPTH (512) nested
AstNode levels, failing with an ordinary serializer error naming the
type and the limit (#1056). The space limit mirrors the 128-level
recursion limit serde_json's Deserializer already applies on the
way in — which, at two JSON levels per space, caps reading a document
back near 61 levels, so the emit limit is the more generous of the two.
For scale, the deepest space nesting across the 14 450-file corpus
under tests/repositories is 10 levels, and the deepest AST is 188.
Both limits may be raised in a minor bump; lowering one is a 3.0
break.
The bit-exactness of float magnitudes is a property of this library's
parser configuration, not of JSON text in general: a downstream consumer
parsing the same document with a stock serde_json (no float_roundtrip)
may differ by up to 1 ULP. Per the value-stability carve-out above, do
not treat float magnitudes as a stable cross-version identity; compare
them with a tolerance.
Note that bca --version prints the CLI
binary's own version, not the library version. To record the
library version from a CLI run, capture it from Cargo.lock or
the resolved cargo metadata output for the
big-code-analysis package.
The next major re-baseline at 3.0 (whenever it happens) is the
point at which the value drifts accumulated across 2.x will be
folded in; consumers who want a "frozen at major" comparison should
pin to the last patch of 2.x before moving to 3.0.
These public items are intentionally lower-level and follow their
underlying dependency rather than our own SemVer. They are the
narrow seams where the 2.x shape contract gives way to the
upstream grammar contract; depend on them only when you need to
reach the raw tree-sitter surface.
Nodeexposes itstree_sitter::Nodethrough an accessor.Node<'a>insrc/node.rswraps the tree-sitter node; the inner field is private and the node is reached throughNode::as_tree_sitter(&self) -> tree_sitter::Node<'a>(the node isCopy, so it is returned by value). Anything you do through that node follows the pinnedtree-sittercrate version and will move whenever we bump that pin (typically under a minor release). This mirrors the higher-levelAst::as_tree_sitterseam below; reach forNode::as_tree_sitteronly when you already hold aNodefrom this surface. (breaking, 2.0) The pre-2.0 shape waspub struct Node<'a>(pub OtherNode<'a>), a public tuple field welding the value-not-stabletree_sitter::Nodeinto the stable struct's layout. #556 demoted the field to private and added the accessor; #534 had already narrowed the siblingCursor/Callback/LanguageInfosurface but did not touch this field. Field-to-method is a source break, so it landed at2.0.tree_sitteris re-exported asbig_code_analysis::tree_sitter. Consumers who construct trees themselves should depend on the re-export rather than adding a siblingtree-sitterdependency; that guarantees thetree_sitter::Treethey pass intoAst::from_tree_sitteragrees with the version the metric walker was compiled against. The re-export follows our pin: bumpingtree-sitterto a new version is a minor bump on our side and will move every type in this module. Treat the re-exported API as value-not-stable in the same sense the rest of this document means it.LANG::tree_sitter_languagereturns thetree_sitter::Languagepaired with each enum variant, wrapped inResult<…, MetricsError>so feature-gated builds can reportErr(MetricsError::LanguageDisabled(_))when the matching per-language Cargo feature is off. The language identity follows the grammar pin (tree-sitter-rust = "=0.24.2", …) and will change whenever those pins move, typically under a minor bump.Ast::from_tree_sitteris the public entry point of the parse seam. It accepts a caller-builttree_sitter::Treedirectly; the internalTreewrapper used by the metric walker stays crate-private, so the only type a consumer ever sees on this seam istree_sitter::Treeitself. It follows thetree-sitterpin in the same value-not-stable sense as the re-export above. The formerParser::from_tree/metrics_from_treeentry points were removed at2.0(Parser::from_treedemoted topub(crate)along withParser; the parser-genericmetrics_from_treedeleted) in favor of this single explicit-name seam.Astis the parse-once seam and, as of2.0, the single public analysis entry point.Ast::as_tree_sitterexposes the heldtree_sitter::Tree; that single method follows thetree-sitterpin in the same value-not-stable sense as thetree_sitterre-export. The rest ofAst's API surface (parse,from_tree_sitter,metrics,ops,strip_comments,functions,dump,count,find,root_node,suppressions,language,source,name) is shape-stable.Ast::root_node/Ast::findreturn the publicNodewrapper (#728), whose ownpreorder()/descendants_by_kind()traversal helpers andas_tree_sitter()escape hatch are shape-stable; the raw node kinds andkind_ids it surfaces follow thetree-sitterpin in the same value-not-stable sense as the re-export. The language-dispatchedAstInnerenum and the matchingast_*_dispatchhelpers staypub(crate); onlyAstis exposed. At2.0the path-positionalget_function_spaces*/metrics_from_tree/get_opsshims, the parser-genericmetrics/metrics_with_options/operands_and_operatorsfunctions, and theaction/Callbackdispatch were all removed in favor of these explicit-nameSource/Astseams (the old path-positional forms derived identity from a lossy path).#[doc(hidden)]items are part of the macro / internal plumbing surface. They are not covered by any stability promise and may be removed or renamed in a patch bump.
The bca command-line grammar (subcommand names, flag spellings, and
flag placement) is a CLI surface, not a library API, and is not
covered by the library SemVer contract above; it evolves with the
big-code-analysis-cli crate version and breaking grammar changes are
reserved for major bumps and called out in CHANGELOG.md.
As of 2.0, flags are scoped to the subcommand that consumes them
(#597). Only -w/--warnings and --report-skipped are universal and
accepted in any position; every walk-, tuning-, preprocessor-, and
output-specific flag (--paths/-p, --include/-I, --exclude/-X,
--language/-l, --jobs/-j, --no-ignore, --exclude-tests,
--cyclomatic-count-try, --no-config, --preproc-data, --color,
--no-skip-generated, --paths-from, --exclude-from) must follow the
subcommand (bca metrics --paths src, never bca --paths src metrics).
A flag passed to a subcommand that never consumed it is a hard usage
error (exit 1), not a silent no-op: bca vcs commit --exclude-tests
and bca list-metrics --paths both error. Input paths are also accepted
positionally on the walking subcommands (bca metrics src/), unioned
with --paths (#651); bca find / bca count take node kinds via a
repeatable -t/--type flag. --help output is sectioned by flag group
(Input selection / Walker tuning / Preprocessor / Output).
The .bca-baseline.toml schema is a CLI artifact, not a library
API. It carries its own version field and evolves independently
of the library SemVer contract above. Schema bumps land under
patch or minor releases of the big-code-analysis-cli crate.
Loaders accept the current version plus a documented legacy
window for in-place migration; older versions surface a
"regenerate with --write-baseline" hint instead of silently
mis-matching. Recent transitions:
- v5 → v6 (#1170):
start_linebecame optional and is written only for an entry whose(path, qualified, metric)identity is shared with another — the sole case matching consults it. Elsewhere the field re-rendered on every edit above the function, churning diffs and conflicting on every merge. v2–v5 baselines read unchanged: a recorded line is still honored exactly as before. A v6 file read by a pre-v6 bca fails to parse (a required field is missing) rather than mis-matching; upgrade bca or regenerate with--write-baseline. The optionality reaches four rendered surfaces, so an entry with no recorded line changes shape in each:bca exemptions --format jsonomits thelinekey andbca diff-baseline --format jsonomitsstart_line(omitted, nevernull),bca exemptions --format markdownrenders-in the Line column, and the text format drops the:linesuffix entirely. - v3 → v4 (#377):
entries key on the qualified symbol (
functionrenamed toqualified) plus astart_linetolerance rather than the exact line, so editing code above a function no longer re-keys it. An optionalbody_hashfield backs--baseline-fuzzy-match. v2/v3 baselines are still read: their barefunctionname maps toqualifiedvia a serde alias and matching degrades to bare-name + tolerance (equivalent to the old behavior, now line-drift-tolerant) until the file is refreshed with--write-baseline. No re-analysis is performed at load time. - v2 → v3 (#376):
path keys are canonicalized relative to the baseline file's own
directory. ASCII-clean v2 baselines migrate transparently with
a one-time stderr notice; v2 baselines with pre-encoded
non-ASCII paths may need a
--write-baselinerefresh. - v1 → v2:
%percent-encoding made total in the UTF-8 fast path so a literal%FFin a filename cannot collide with the%FFescape for byte0xFFin a non-UTF-8 path. v1 is no longer accepted; regenerate the file.
bca (and the library's output module, re-exported at the crate
root) emits source metrics in two families of format. The split
between them is itself part of the contract:
- Round-trip formats: JSON, YAML, TOML, CBOR. These project the
metric tree through the
wiremodule and read back into the matchingwiretypes (see Reading serialized output back). A document this library writes, parsed with this library's serde stack, reconstructs the tree and re-serializes byte-for-byte. - One-way projections: CSV, SARIF, Checkstyle, code-climate,
the Clang/MSVC warning lines, and the AST /
dump_*output. These are lossy, write-only views with no reader in this crate. They drop or reshape data the round-trip formats keep: e.g. the CSV row carries no per-FuncSpacesuppressedscope (it is present inwire::FuncSpacebut absent from the flattened CSV columns). Do not expect to parse a projection back into aFuncSpace; pin to a structured format if you need round-trip fidelity.
The writer free functions are SemVer-protected in signature within
2.x (additive only; a break is a 3.0 event). They are re-exported
at the crate root from src/output/:
| Writer | Signature (eliding <W: Write>) |
|---|---|
write_csv |
(space: &FuncSpace, source_path: &Path, writer: W) -> io::Result<()> |
write_csv_aggregate |
(spaces: impl IntoIterator<Item = (&FuncSpace, &Path)>, writer: W) -> io::Result<()>; one shared header, then every tree's rows |
write_sarif |
(offenders: &[OffenderRecord], writer: W) -> io::Result<()> |
write_sarif_with_suppressed |
(active, in_source, baseline: &[OffenderRecord], writer: W) -> io::Result<()>. The two suppressed slices stay separate because they render different suppressions[].kind values: in_source gives "inSource", baseline gives "external" |
write_checkstyle |
(offenders: &[OffenderRecord], writer: W) -> io::Result<()> |
write_clang_warning |
(offenders: &[OffenderRecord], …, writer: W) -> io::Result<()> |
write_msvc_warning |
(offenders: &[OffenderRecord], writer: W) -> io::Result<()> |
write_code_climate |
(offenders: &[OffenderRecord], writer: W) -> io::Result<()> |
The shared types they consume (OffenderRecord, Severity (see
Offender / catalog enums), TOOL_ID (the "big-code-analysis"
tool name), CSV_HEADER, and CSV_EXTENSION (".csv")) are part of
the same surface. The AST dump entry points (dump_node, dump_root,
and dump_ops, which take no config) are likewise re-exported and
shape-stable, as is the AstCfg config type that Ast::dump
consumes. Each terminal dump entry point also has a
*_with_color sibling (dump_node_with_color, dump_root_with_color,
dump_ops_with_color, dump_function_spans_with_color) that takes a
ColorMode (Auto / Always / Never); the bare forms retain their
historical always-colored behavior. These were added in a minor bump
(issue #605) and are part of the stable surface going forward.
CSV_HEADER is the frozen, positional column contract: downstream
tools (Pandas, Excel, awk) address columns by index, so the order and
names below will not change within 2.x (a column may be appended
in a minor bump; reordering or renaming is a 3.0 break). The first
five are identity columns; the rest are dotted JSON-style metric paths
so one name addresses the metric in both JSON and CSV:
path, space_name, space_kind, start_line, end_line,
cognitive.{sum,average,min,max},
cyclomatic.{sum,average,min,max},
cyclomatic.modified.{sum,average,min,max},
halstead.{unique_operators,total_operators,unique_operands,total_operands,
length,estimated_program_length,purity_ratio,vocabulary,volume,
difficulty,level,effort,time,bugs},
loc.{sloc,ploc,lloc,cloc,blank,
sloc_average,ploc_average,lloc_average,cloc_average,blank_average,
sloc_min,sloc_max,cloc_min,cloc_max,ploc_min,ploc_max,
lloc_min,lloc_max,blank_min,blank_max},
nom.{functions,closures,functions_average,closures_average,total,average,
functions_min,functions_max,closures_min,closures_max},
nargs.{function_args,closure_args,function_args_average,closure_args_average,
total,average,function_args_min,function_args_max,
closure_args_min,closure_args_max},
nexits.{sum,average,min,max},
tokens.{sum,average,min,max},
abc.{assignments,branches,conditions,magnitude,
assignments_average,branches_average,conditions_average,
assignments_min,assignments_max,branches_min,branches_max,
conditions_min,conditions_max},
wmc.{class_wmc_sum,interface_wmc_sum,total},
npm.{class_npm_sum,interface_npm_sum,class_methods,interface_methods,
class_coa,interface_coa,total,total_methods,coa},
npa.{class_npa_sum,interface_npa_sum,class_attributes,interface_attributes,
class_cda,interface_cda,total,total_attributes,cda},
mi.{original,sei,visual_studio}
The exact list is pinned in code by CSV_HEADER and asserted against
the rendered header row by output::csv::tests::header_constant_matches_first_row;
the documented list above is pinned to CSV_HEADER by
output::csv::tests::header_constant_matches_documented_columns, so
this table and the code cannot drift apart.
The CSV value matrix is uniformly f64 (see funcspace_row::metric_values),
routed through the non-finite→empty-cell adapter (numfmt::CellMetric):
a cell renders empty only when its value is NaN/±Infinity. (CSV
keeps this f64 matrix even though #530 made the structured formats'
integer metrics exact u64; CellMetric renders integer-valued
finites without a decimal point.) The integer-named columns (every
*.sum, *.min, *.max,
the counts, halstead.length/vocabulary, the WMC/NOM/NARGS/ABC/NPM/NPA
count columns) are always populated: they are never non-finite, so the
empty-cell branch fires only for the float-derived columns (*_average,
ratios, abc.magnitude, the derived Halstead scores, the mi.* family).
write_sarif emits a SARIF 2.1.0 document. The $schema is pinned
to the schemastore mirror
https://json.schemastore.org/sarif-2.1.0.json (the URI the GitHub
Code Scanning validator resolves), and the top-level version field is
"2.1.0". The schemastore mirror is what this tool emits; the OASIS
canonical URI for the standard is
https://docs.oasis-open.org/sarif/sarif/v2.1.0/cs01/schemas/sarif-schema-2.1.0.json.
The pinned schema URI and version are stable within 2.x. The
tool.driver.name is TOOL_ID ("big-code-analysis") and
tool.driver.version is the big-code-analysis library crate version
(currently in lockstep with the CLI's). The writer is also reached from
the Python bindings' to_sarif, where no CLI is involved.
write_code_climate emits the strict GitLab Code Quality subset of
the upstream Code Climate spec. The emitted field set is stable within
2.x: description, check_name ("big-code-analysis/<metric>"),
fingerprint, severity, location.path, location.lines.{begin,end},
and location.positions.begin.{line,column} (the last two emitted only
when present). The spec's type, categories, remediation_points,
and content are deliberately omitted (additive to re-add later).
The fingerprint is contractually stable: GitLab persists it
across pipeline runs to deduplicate and to compute the base-vs-head
diff. It is the SHA-256 of path \0 function \0 metric (NUL-separated;
function is the empty string when the offender is file-scoped),
truncated to the leading 16 bytes and hex-encoded lowercase
(32 hex characters, leading zero bytes preserved). The line number and
metric value are deliberately excluded from the hash so a cosmetic
edit that shifts a function's line does not re-surface a known
violation.
When two distinct findings in one file would otherwise share the same
path \0 function \0 metric triple (same-named functions breaching the
same metric, a collision that previously made one finding vanish under
GitLab's fingerprint dedup), a \0 <ordinal> disambiguator (the
occurrence index as a little-endian u32) is appended before hashing.
This is backward-compatible: the first occurrence carries ordinal 0
and so keeps the byte-identical historical fingerprint; only the
second-and-later colliding findings, which previously had no stable
fingerprint of their own, receive a new, distinct value.
dump_* and the AstNode / Span types (re-exported with
AstPayload / AstResponse / AstCfg) serialize a
one-way projection of the parse tree: Serialize only, by
design. There is intentionally no Deserialize: the AST output is
a debugging / inspection view like CSV and SARIF, not a wire format you
round-trip through. The JSON shape uses snake_case keys and is pinned
by ast::tests::serialized_json_uses_snake_case_keys:
AstNode→{type, value, span, field_name, children}.field_nameis the tree-sitter grammar field through which the parent reaches the node (Nonefor the root, anonymous tokens, and unnamed children);childrenis a nested array ofAstNode.Span→{start_row, start_col, end_row, end_col}.
The snake_case key scheme is stable within 2.x. The former
PascalCase keys (Type/TextValue/Span/Children) were dropped at
the #535 snake_case landing change.
CLI artifact schemas (the version field on .bca-baseline.toml,
the shape of the [thresholds] config (the bca.toml manifest table
or a --config file), the on-disk report formats) are
read by whichever bca binary the user has installed. A schema bump
in the binary that goes out without a coordinated update to the
install-bca surfaces leaves downstream users in a state where
their checked-in artifact file is unreadable by their pinned bca,
and the gate fails with version N is not supported by this bca
on a clean checkout.
Treat a schema bump as a release-coupled change. The same PR (or
release-prep batch) that bumps BASELINE_VERSION or the equivalent
constant must also:
- Update
.github/workflows/pages.ymlif it pins abcarelease (today it builds from checkout, so this is automatic, but a future revert to a pinned install would re-introduce the gap). - Update every install-bca example in the
CI integration recipe:
the GitHub Actions section (
BCA_VERSION/BCA_SHA256block, thetaiki-e/install-action@v2cache snippet) and the GitLab CI section (variables.BCA_VERSION/BCA_SHA256). - Land a
bcarelease that supports the new schema before the next downstream user pulls. The release-train ordering is: merge the schema bump → cut a release on the new commit → bump the documentedBCA_VERSIONto that release. Skipping step 2 ships a documented install path that cannot read the artifactmainnow writes, exactly the failure mode that motivated the pages.yml workflow's switch to build-from-checkout.
The schema author owns this checklist. The release-prep section of
RELEASING.md references back to this contract.
bca check has a stable process-exit contract, separate from the
library SemVer surface:
| Exit | Meaning |
|---|---|
0 |
clean: no threshold violations |
1 |
tool error (bad config, unknown metric, unreadable path) |
2 |
one or more threshold violations |
Reserving 1 for tool errors lets CI distinguish "a function got too
complex" from "the analyzer crashed". This 0/1/2 contract is the
default and will not change within 2.x.
--exit-codes=tiered (or [check] exit_codes = "tiered" in
bca.toml, #385,
#666)
opts into a finer split of the violation case. --exit-codes is
value-taking (<default|tiered>); the CLI value overrides the manifest
key in either direction. The default codes above are unaffected:
| Exit | Meaning (tiered mode only) |
|---|---|
0 |
clean |
1 |
tool error |
2 |
new offenders only (no baseline entry matched) |
3 |
baseline regressions only (a baselined offender worsened) |
4 |
both new offenders and regressions |
5 |
a --tier=soft violation that also breaches the hard limit |
Every fail-state stays non-zero, so existing exit != 0 → fail
tooling is unaffected. Only consumers that test $? -eq 2
explicitly need to widen to 2-5 when they opt in. --no-fail
still forces exit 0 in both modes. Code 5 is emitted only at the
soft tier; at the hard tier every violation is a hard breach by
definition, so the 2/3/4 split applies instead.
The deprecated --strict-exit-codes flag remains a hidden one-cycle
alias for --exit-codes=tiered (warns; removed at the next major, 3.0).
bca check gates against one of two tiers, selected by the
value-taking --tier <hard|soft|soft=RATIO>
(#688):
hard(default): flag a function only at/over its[thresholds]limit.soft: early-warning tier flagging a function atRATIOof any limit (default0.95), before the hard gate trips. With a[thresholds.soft]table present, its per-metric limits take precedence over the blanket ratio.soft=0.90: soft tier scaling every limit by0.90;soft=1.0disables the blanket scale.
A bare --tier means soft. The manifest [check] headroom key
supplies the soft ratio for a bare --tier=soft. The retired
--headroom <R> flag is a hidden one-cycle alias for --tier=soft=<R>
(warns; removed at the next major, 3.0); it now promotes a hard run to the
soft tier rather than being ignored at the hard tier.
When both an explicit CLI flag and a bca.toml manifest key supply a
list, how the two are combined depends on the meaning of the list
(#539):
--xand--x-fromalways union with each other.--excludepatterns and any--exclude-fromfile patterns combine into one deny-set; likewise--check-excludeand--check-exclude-from. Order does not matter.- Positive scope keys (
paths,include) are REPLACED by any explicit CLI value. A CLI--paths/--includediscards the manifest's list entirely (bca check one.rswith manifestpaths = ["src"]checks justone.rs). The manifest fills these only when the CLI passed nothing. - Negative filter keys (
exclude,[check] exclude) UNION CLI values with the manifest list. A CLI--exclude/--check-excludeis added to the manifest's exemptions, never a replacement, so a command-line filter can never silently un-exclude a directory the project config deliberately skipped (e.g.vendor/). Duplicates across the two sources collapse; CLI patterns sort first. This mirrors ruff/ESLint'sexclude(replace) vsextend-exclude(add), generalized: targets replace, filters add. --no-configignores the manifest entirely. With it set the manifest contributes nothing, so excludes come only from the CLI.
The negative-filter union is a behavior change from the pre-2.0 contract, where CLI excludes replaced the manifest list. It landed as part of the 2.0 line.
big-code-analysis-py is the PyO3 wrapper published from this
workspace to PyPI. The names below are locked: none of them can
change without breaking every consumer's imports.
- Distribution name
big-code-analysis(thepip installtarget / PyPI project name). - Import name
big_code_analysis(the top-level package). - Compiled extension
big_code_analysis._native: a private,#[doc(hidden)]-equivalent module. It is an implementation detail: import from the package (big_code_analysis.analyze), never from_nativedirectly. Its layout may change between releases; the package facade (__init__.py) is the stable seam.
The bound surface tracks the library: analyze, analyze_source,
analyze_batch, analyze_paths (the directory-walk entry point, #658),
language_for_file (with the filesystem-free read=False option, #682),
language_for_extension (#682), language_extensions,
supported_languages, to_sarif, flatten_spaces, the Ast parse-once
handle (#727) and the lazy Node traversal handle it hands out via
Ast.root_node / Ast.find (#728), the AnalysisFailure
value type (a per-file batch failure, returned not raised; renamed
from AnalysisError at 2.0, #614), the ParseError /
UnsupportedLanguageError exception types, the change-history exception
taxonomy (VcsError and its NotARepositoryError / InvalidRevisionError
/ InvalidDiffError / VcsEnvironmentError subclasses), __version__,
and the METRIC_NAMES constant. The change-history surface lives in the
big_code_analysis.vcs submodule (#612): vcs.rank / vcs.trend /
vcs.commit / vcs.score_diff plus the shared vcs.Options object,
names mirroring the bca vcs CLI subcommands.
supported_languages() returns list[Lang], language_for_file()
returns Lang | None, and METRIC_NAMES is a
tuple[MetricName, ...]. Lang and MetricName are
enum.StrEnums (the floor is Python 3.12), so each member
is a str: Lang.CPP == "cpp", MetricName.HALSTEAD == "halstead", and the members work anywhere a string slug does
(metrics=[MetricName.LOC], language_extensions(Lang.RUST)). The
enums are generated from the same upstream tables the CLI and
JSON output use, LANG::name() (the canonical slugs, see What is
stable in shape above) and Metric::NAMES, by a checked-in
generator with a drift-gate test, so the Python values can never
diverge from the slugs emitted elsewhere. The contract is: every
Lang / MetricName value equals the corresponding CLI / JSON
slug. Adding a language or metric adds a member (additive, minor);
the existing values are frozen.
A batch slot is not always a result or a failure. analyze_batch /
analyze_paths emit a None element, under skip_generated=False
only, for a file the shared read gate declines to parse — three bytes
or fewer, a UTF-16 BOM, a leading window that is not valid UTF-8, or
(rarely) a file that shrank between the size probe and the read.
That gate is unconditional, so before #1238 those files produced no
element at all and the documented zip(inputs, results) mis-paired
every later entry; the placeholder is what makes skip_generated=False
genuinely one-element-per-input. None is the same value single-file
analyze returns for those files, and to_sarif skips it. The
skip_generated=True default is unchanged: a skipped file, generated
or unreadable, still yields no element.
The placeholder is a behaviour break for existing
skip_generated=False callers, in both directions. A caller that
looped over the results untyped — for r in results: r["metrics"] —
previously ran to completion on a batch containing such a file (having
silently mis-paired every later entry) and now raises TypeError: 'NoneType' object is not subscriptable at the placeholder. A caller
that counted len(results) sees a larger number. Both are the intended
direction: the failure is now loud and local instead of silent and
downstream, and the count is now the truth. analyze_paths shares the
change, so a directory walk under skip_generated=False gains one
element per discovered file the gate declined — on a tree with binary
assets that can be a substantial fraction of the list.
Per-file failures from analyze_batch / analyze_paths are
returned, not raised, as AnalysisFailure values (not Exception
subclasses: the class is deliberately not raisable; it was renamed from
AnalysisError at 2.0 because the …Error suffix mislead readers into
except clauses, #614). The error_kind field is a closed set
(Literal["UnsupportedLanguage", "ParseError", "IoError"]) and is
part of the contract: callers may branch on it. The raising entry
points (analyze, analyze_source) map upstream failures to
UnsupportedLanguageError (a ValueError subclass), ParseError,
or the appropriate OSError subclass. The change-history surface
(vcs.rank / vcs.trend / vcs.commit / vcs.score_diff) maps
vcs::Error variants to VcsError (a ValueError subclass) and its
named subclasses:
NotARepositoryError, InvalidRevisionError, InvalidDiffError,
VcsEnvironmentError. The subclasses are pinned additively: a caller
may catch the specific class or the VcsError / ValueError base.
The client-input vs environment split mirrors the web crate's
400/500 mapping (vcs::Error::is_client_input).
The package ships PEP 561 type information: a py.typed
marker and a hand-written _native.pyi stub, with the public facade
typed directly. mypy --strict and pyright are gated in CI. The
stub shape (function signatures, the error_kind Literal, the
Lang / MetricName enums) is covered by the same shape contract
as the library: additive in minor bumps, breaking only at a major.
The analysis-result wire shape is also expressed as exported
TypedDicts (#623): analyze / analyze_source return
FuncSpaceDict | None / FuncSpaceDict, analyze_batch /
analyze_paths return list[FuncSpaceDict | AnalysisFailure | None]
(the None slot is the read-gate placeholder described under Error
mapping above, #1238), and the
nested metric blocks (CodeMetricsDict, LocDict, HalsteadDict,
VcsDict, …) are re-exported from the package. Like the enums, these are
generated from the big_code_analysis::wire structs (src/wire.rs,
the single source of the serialized shape) by a checked-in generator with a
drift-gate test, so the Python types cannot diverge from the JSON the
CLI emits. The change is stub-only (the runtime values are plain
dicts, byte-identical to the CLI output), so it only narrows the
static type. Every metric block is NotRequired because a metrics=
selection can elide blocks, and because the object-oriented blocks are
scope-gated: wmc, npm and npa are emitted on container spaces and
on the file root, and never on a function space (#1197, #1203). For
npm and npa the space's own kind has been the sole input for every
language since the latter, with no grammar deviating in either
direction. wmc is narrower in two language-level ways: Go emits no
wmc block on any space, including the file root, and a namespace
space carries npm and npa but no wmc. A language
with no class-like construct emits them nowhere. Which spaces carry
which block is not part of the shape contract — the wire struct
definitions are. The VCS report dicts are now single-sourced and typed too
(#664): vcs.rank returns VcsReportDict, vcs.trend returns
VcsTrendDict, vcs.commit returns JitCommitReportDict, and
vcs.score_diff returns JitDiffReportDict. The report / trend envelope
structs moved into big_code_analysis::wire and the jit shapes are
mirrored from src/vcs/jit.rs, so the former dict[str, Any] returns are
gone. The same additive / major-only shape contract applies.
One documented exception to that contract, on the 2.x line: #1238
widened the analyze_batch / analyze_paths return element to
FuncSpaceDict | AnalysisFailure | None. Widening a return union is
not additive — a mypy --strict consumer that indexed a slot without a
None check newly fails to type-check, which is the point: the runtime
values it would have received were already wrong. It landed in a minor
rather than waiting for the next major because the alternative was
leaving a silent data-corruption defect in the released line, and
because the runtime contract the widening makes true is the one both
entry points already documented.
big-code-analysis-web (bca-web) is the HTTP wrapper. Its
request/response schema is a published surface governed by the same
SemVer discipline as the CLI artifact formats: additive endpoints
and fields in minor bumps, shape breaks reserved for major bumps.
The full route-by-route reference lives in the book
(REST API); the
contract points are:
- Analysis envelope.
/ast,/metrics,/function, and/commentreturn a JSON object carrying the requestid(echoed; empty when the request carried none), alanguagefield, and the endpoint's result. Thelanguagevalue is the #540 canonical slug (routed throughguess_language→LANG::name()), the same identifier used by the CLI JSON output and the Python bindings; the comment endpoint reports the guessed language, not its internalccommentgrammar swap./astjoined the language echo at2.0(#654), bringing the publishedAstResponselibrary type to{id, language, root}. /metricsresult shape. The single root metric space is returned under therootkey (a single object, not the misleading pluralspaces); its own nestedspaceslist holds the child spaces. The request flag selecting file-level-only output isscope:"full"(default, the full nested tree) or"file"(the root only), both as a JSON-body field and as a query parameter on the octet-stream variant. The former pluralspacesenvelope key and the booleanunitflag were renamed as a2.0-line break (#638); the oldunitkey now fails as an unknown field (see Unknown fields below).- Span vocabulary.
/ast,/function, and/metricsall report node/space spans withstart_line/end_line(1-based) and, where columns are emitted,start_col/end_col./ast's formerstart_row/end_rowkeys were renamed to*_lineas a2.0-line break (#638) so a client correlating spans across endpoints no longer special-cases the field names per endpoint. This shape-changes the publishedSpanlibrary type. - Unknown fields. Every request body and query string rejects an
unrecognized field with a
400whoseerrornames the offending key and whoseerror_kindisunknown_field(#633). A typo can no longer silently change analysis semantics; clients probing for feature support use theGET /v1route index (#643) instead. This is a2.0-line break: payloads with extra/typo'd fields that200'd before now400. /commentresult shape. The JSON variant returnscodeas a string holding the stripped source; the requestcodearrived as a JSON string, so the output is valid UTF-8 and is handed back as a string matching the request and every other JSON endpoint. The octet-stream variant returns the raw stripped bytes as the response body. The former JSONcode: number[](a serdeVec<u8>artifact) was changed tostringas a2.0-line break (#629).- Empty
/commentresult. When the source has no removable comments, both content-type variants signal it as200with an empty payload: the JSON variant returns{… "code": ""}(empty string) and the octet-stream variant returns an empty body. The former204 No Contenton the octet-stream variant was removed as a2.0-line break (#558) so the empty outcome shares one status code and one envelope shape acrossAccepttypes. - Uniform errors. Every error path returns the JSON body
{error, error_kind, id}with an appropriate status; no endpoint emits a baretext/plainerror.errorcarries the specific human-readable cause;error_kindcarries a stablesnake_casemachine token clients branch on without string-matching the prose (#631);idis always present. The token vocabulary is closed and governed by this contract: adding a token is additive, renaming or removing one is a break. The current tokens are:unsupported_language,unknown_field,bad_request,bad_query,invalid_scope_flag,vcs_mode_conflict,payload_too_large,read_error,internal_error,parse_timeout,parse_pool_saturated,ast_build_failed,metrics_failed,not_found,method_not_allowed,unsupported_media_type,not_acceptable,serialize_failed, and the per-cause vcs tokensvcs_not_a_repository,vcs_invalid_revision,vcs_invalid_bot_pattern,vcs_invalid_window,vcs_invalid_timestamp,vcs_invalid_formula,vcs_invalid_file_type_scope,vcs_invalid_bus_factor_threshold,vcs_invalid_author_hash_key,vcs_invalid_trend,vcs_invalid_diff, and the catch-allvcs_internal_error. Afile_namethat maps to no supported language is a422 Unprocessable Entitycarryingerror_kind: "unsupported_language"(the route matched and the body parsed); only an unknown URL is a404. The former404for the unsupported-language case was a2.0-line break (#634). The per-vcs-cause tokens replace the former single kitchen-sink/vcs400 message at2.0(#631): a bad window now answersvcs_invalid_windowwith the specific cause, not a sentence listing every possible parameter.vcs_invalid_author_hash_keyis new after2.1.0, which does not carry it: a misusedauthor_hash_keyalready answered400there, but reported the catch-allvcs_internal_error, so a client branching on the token saw its own mistake as a server fault (#1245). Adding a token is additive, and correcting one that named the wrong cause is a bug fix rather than a rename — but a client that has to interoperate with both must treatvcs_internal_erroron a400as the older spelling of this case. /vcs-family defaults. The web defaults match the CLI's bounded defaults so the same logical invocation returns the same-sized result on either surface (#636):topdefaults to 50,top_deltasto 10, andpointsto 12 (formerly hard-required). An explicittop: 0/top_deltas: 0still returns all (the #6020 = allescape). Aligning these from the former unbounded "all" default is a behavioral2.0-line break: payloads omitting these fields get a smaller result.- Introspection.
GET /v1/versionreports the server and library versions;GET /v1/languagesreports the supported languages and their extensions, sourced from theLANGtable (not hardcoded), mirroring the Python__version__/supported_languages()/language_extensions()surface. All routes are served under the/v1prefix. - Unprefixed aliases removed at 2.0. The original unprefixed paths
(
/metrics,/comment,/function,/ast,/ping,/version,/languages, the bare/index, and the/vcs*routes) were removed at the 2.0 release cut (#517 / #637), a deliberate, already-planned break. For one cycle each carriedDeprecation: true, aSunsetHTTP-date, and aLink rel="successor-version"naming the canonical/v1twin; they now404like any other unknown URL. Clients must use the/v1prefix.
The {id, language} envelope on /function and /comment (and
/ast at #654), the uniform error body (now {error, error_kind, id}, #631), the /metrics root key + scope flag and the *_line
span vocabulary (#638), unknown-field rejection (#633), the
CLI-aligned /vcs-family defaults (#636), and the removal of the
unprefixed aliases (#637) all landed as 2.0-line breaks. The
original {id, language} envelope and uniform {error, id} body
first landed under #541.
The workspace pins rust-version = "1.94" (see the
[workspace.package] table in Cargo.toml). All three shipping
crates (big-code-analysis, big-code-analysis-cli,
big-code-analysis-web) inherit this.
- Minimum supported Rust version (MSRV) is
1.94. - A bump to the MSRV is a minor-version event (not patch). It appears in the changelog under (breaking) because some consumers treat a toolchain bump as a build break, even though it is not a source-level shape break under SemVer.
- We do not commit to supporting a specific N-back window of Rust releases; the policy is simply "we bump when we want a 2024 edition feature or a meaningfully better stdlib API, and we call it out in the changelog".
- The crate uses edition 2024 (
let-else, let-chains, etc.).
Every release MUST update CHANGELOG.md:
- Major bumps list every source-level shape change under (breaking) and every known value change. A major bump is the only place a source-level shape break may appear.
- Minor bumps list every additive shape change (a new public
item, a new
LANGvariant, a newMetricsErrorvariant, a new language feature) and every known value change. A minor bump may also carry an (breaking) entry only for an MSRV bump (see MSRV policy), which is a toolchain break, not a source-level shape break. Treat that as the single permitted routine exception; any other(breaking)entry in a minor section needs the justification spelled out in the entry itself. Exactly one such entry exists: theDropimpls on the result trees in2.1.0(#1056), landed early because holding them for3.0would have left a remotely-reachable process abort open. - Patch bumps list every known value change and any internal-only refactors that have user-visible effects (e.g. a default that flips). A patch bump never carries (breaking).
If you find a value change that was not flagged in the changelog, or a shape break that landed in a patch or minor bump, that is a bug; please open an issue.
The breaking changes that were once on the 2.0 horizon have now
shipped in 2.0.0. See the [2.0.0] entry in
CHANGELOG.md for the full list (the
#[non_exhaustive] markers on the open public enums and the
per-metric Stats structs, the
serialized-key normalization, the
integer-metric u64 shift, the language-dispatch and grammar
defaults, the Python and REST surface changes) and for the
consolidated metric-value re-baseline that folds in the drift
accumulated since 1.0.
No 3.0 is scheduled. Future breaking changes will be collected
here when one is on the horizon. The #[non_exhaustive] markers
added at 2.0 keep most future additions (new enum variants, new
fields) non-breaking, so the next major may be a long way off.