Skip to content

Latest commit

 

History

History
1336 lines (1215 loc) · 74.1 KB

File metadata and controls

1336 lines (1215 loc) · 74.1 KB

Stability policy

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.

Versioning scheme

The crate follows Semantic Versioning 2.0.0:

Bump Shape (types, fn signatures) Value (metric numbers)
2.X.Y2.X.Y+1 (patch) Held stable May change
2.X.Y2.X+1.0 (minor) Additive only May change
2.X3.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.

What is stable in shape

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

    • LANG enum (generated by the mk_langs! macro invoked from src/langs.rs; the macro itself lives in src/macros/mod.rs): variants are additive. Adding a new variant in a minor bump is allowed; renaming or removing one is a 3.0 break. Derives Hash and implements Display (the name string) and FromStr (parsing that same canonical name; error type ParseLangError). Since #540 every variant has a distinct canonical lowercase slug (cpp, csharp, tsx, typescript, javascript, mozjs, …), so Display is injective and LANG::from_str(&lang.to_string()) == Ok(lang) for every variant. This slug is the single language identifier across every surface (the CLI JSON language field, the web language field on every analysis endpoint (/metrics, /comment, /function (#541)) and the Python bindings). The human-pretty c/c++ / c# display forms were dropped at 2.0, a break in the serialized language value.
    • get_language_for_file, guess_language in src/tools.rs.
  • File readers (src/tools.rs)

    • read_file, read_file_with_eol, normalize_eol. Their signatures are fixed for 2.x; in particular read_file_with_eol keeps its io::Result<Option<Vec<u8>>> shape, which names no reason for a skip.
    • read_file_with_eol_classified and SkipReason (#1287) are the additive sibling that does name it: io::Result<Result<Vec<u8>, SkipReason>>, of which read_file_with_eol is the .map(Result::ok) projection. SkipReason carries #[non_exhaustive], so a future gate adds a variant in a minor bump — match it with a wildcard arm or render it through Display, whose per-variant wording is not SemVer-protected.
  • Top-level entry points

    • analyze and Source in src/spaces.rs: the recommended library entry point. Source carries #[non_exhaustive] and its fields are private (pub(crate)): construct via Source::new plus with_* 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).
    • MetricsOptions carries #[non_exhaustive] and, like Source, has private (pub(crate)) fields as of 2.0. Construct via MetricsOptions::default() plus with_* setters; the contract is the builder methods, not the field representation (#533). The parser-generic metrics / metrics_with_options functions and the MetricsCfg builder were removed at 2.0 in favor of analyze / Ast.
    • Metric, MetricSet in src/metric_set.rs: Metric carries #[non_exhaustive], so adding variants is additive. MetricSet is the opaque bitfield consumed by MetricsOptions::with_only(&[Metric]) and read back through CodeMetrics::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_set closes the supplied set under Metric::dependencies before storing it (#743), so a derived metric always reaches the walker with its inputs. Metric is the single metric vocabulary for both selection and suppression: it now derives Ord/PartialOrd (declaration order, so a BTreeSet<Metric> iterates deterministically) and implements Serialize/Deserialize as its canonical Display spelling (nargs, nexits, tokens, …). (breaking, 2.0) #555 unified the former parallel suppression::MetricKind enum onto Metric and removed MetricKind from the public API. SuppressionScope now carries BTreeSet<Metric>, so a serialized non-empty suppressed scope spells the exit-point metric nexits (the canonical name) rather than the old exit; the exit parse alias was retired at 2.0 (#588), so only nexits now deserializes. tokens has no threshold and is rejected as non-suppressible (Metric::suppressible() enumerates the silenceable set). The threshold-name resolver moved to the free function big_code_analysis::threshold_metric_for_name (formerly MetricKind::for_threshold_name). This also closes the MetricKind: FromStr<Err = ()> gap (#554): suppression now parses via Metric::from_str, which returns the descriptive ParseMetricError naming 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 of 2.0 the underlying free functions (function, find, count, rm_comments) are pub(crate), not part of the public surface, and the parser-generic operands_and_operators function was removed in favor of Ast::ops. Ops::operators and Ops::operands are sorted in byte-lexicographic order of the rendered strings as of the 2.1 line; 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 hash ops output — and will not change before 3.0.
    • ConcurrentRunner in src/concurrent_files.rs: new's num_jobs is the number of consumer threads. Through the 2.0 line it was a budget shared with a dedicated producer thread and run spawned max(2, num_jobs) - 1 consumers; since #1114 dispatch happens on the calling thread and all num_jobs go to consumers. The signature did not change, so this is a behavioral change, not a source break: a caller passing n gets one more worker than before. without_path_verification (added #1114) opts out of the per-path is_file() check for a caller whose own traversal already classified every entry. ConcurrentErrors::Producer is no longer constructed — the enum is #[non_exhaustive], but removing a variant would still break a downstream match, so it stays until 3.0.
    • NumJobs in src/concurrent_files.rs (added in 1.x, #560): the shared <N|auto> worker-count selector for ConcurrentRunner, used by both the bca CLI and the bca-web server. A clap-agnostic plain FromStr + resolve() -> usize type; Auto resolves cgroup-/cpuset-aware via available_parallelism, falling back to 1. Adding variants is a 3.0 break (it is not #[non_exhaustive]). <NumJobs as FromStr>::Err is the named ParseNumJobsError (Zero / NotAPositiveInteger, each carrying the rejected input(), Display + Error), matching the typed ParseMetricError / ParseLangError convention; it is deliberately exhaustive so callers can match both failure modes without a wildcard.
    • MetricsError in src/error.rs: carries #[non_exhaustive], so adding variants is additive. Current variants are LanguageDisabled(LANG) (the only one produced today) and the reserved-for-future EmptyRoot. The previously-reserved NonUtf8Path and ParseHasErrors variants were removed in the pre-2.0 cleanup (#536); #[non_exhaustive] lets a future strict mode re-add them without a break. The std::error::Error and Display impls are stable; the exact wording of Display output is not.
  • Result shapes

    • FuncSpace, CodeMetrics, SpaceKind, Metrics in src/spaces.rs. These are the JSON / YAML / TOML / CBOR serialization roots and have downstream consumers. FuncSpace and CodeMetrics derive PartialEq (#552) so callers can compare analyses structurally; Eq is omitted because the nested Stats carry float fields (see the per-metric Stats note below). SpaceKind additionally derives Hash (#552) so it can key a HashMap/HashSet. These derive additions only widen the trait set and are additive.

      FuncSpace implements Drop (as do Ops, AstNode, and their wire counterparts), so its fields cannot be moved out of a value by name — let m = space.metrics; is E0509; clone or borrow instead. This is the one source-level shape break landed under a minor bump rather than held for 3.0 (#1056): the compiler-generated Drop glue recursed once per nesting level and aborted the process on a tree deep enough to reach through bca-web's body cap, and an explicit Drop that flattens descendants into a work list is the only way to break that recursion. Treat the Drop impls themselves as an implementation detail — the contract is that these trees tear down in constant stack, not the presence of any particular Drop body.

    • FunctionSpan in src/function.rs.

  • Offender / catalog enums

    • Severity in src/output/offenders.rs (re-exported from the crate root): an ordered severity scale carrying #[non_exhaustive]. As of #552 it derives Hash, PartialOrd/Ord keyed on declaration order. Variants are declared least-severe-first (Warning then Error), so the derived ordering is Error > Warning; callers may rely on severities.iter().max() selecting the worst tier and on >= Severity::Error gating. Any future tier (Info/Note) must be inserted in the correct severity position to preserve this contract.
    • metric_catalog::Direction: derives Hash (#552) so it can key a HashMap/HashSet. Adding these derives is additive.
  • Per-metric Stats: one Stats struct under each src/metrics/<metric>.rs (abc, cognitive, cyclomatic, halstead, loc, mi, nargs, nexits, nom, npa, npm, wmc, plus tokens). These structs carry #[non_exhaustive] (as of 2.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 a 3.0 break.

    To stay forward-compatible within 2.x, callers should treat Stats as 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 exhaustive match / destructuring patterns against the struct. Code that follows those rules will keep compiling across every minor bump in 2.x.

    Every per-metric Stats derives PartialEq (#552), so callers can compare two compute-side Stats directly without round-tripping through to_wire(). Eq is intentionally not derived: the float fields (ratios, averages, ABC magnitude, the derived Halstead scores) preclude it. Comparing Stats from two analyses of the same deterministic source is exact-equal-safe (identical code path, identical f64 bits); do not rely on cross-input float equality. Adding PartialEq is additive: it only widens the derived-trait set.

  • Prelude: big_code_analysis::prelude re-exports the recommended entry points (analyze, Ast, Source, MetricsOptions, MetricsError, LANG, FuncSpace, CodeMetrics, SpaceKind, Metric). The metrics_from_tree entry was removed at 2.0 along with the free function it named; tree adoption now goes through Ast::from_tree_sitter. New items may be added; nothing in the set is removed before 3.0.

  • Per-language Cargo features: the feature set (all-languages, plus per-language features rust, typescript, python, cpp, …) is itself part of the contract. Adding a new language feature is additive. Removing one is a 3.0 break. The all-languages default is permanent within 2.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 scorerisk_score / partial_scorepartial_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_versionbus_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)] (see src/traits.rs for current examples). These exist for macro plumbing and may move at any time, including in patch bumps.
  • The per-language *Code / *Parser types (RustCode, PythonCode, …). They are public because the mk_langs! macro emits them, but they are intended to be reached through LANG rather than referenced by name.
  • Parser and the per-language Checker / Getter / Alterator trait impls: these are internal plumbing. As of 2.0 they are pub(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-pub state to pub(crate). LanguageInfo was likewise demoted to pub(crate), and the Callback trait / AstCallback dispatch were removed at 2.0. None of these are reachable from the public API or appear in the curated rustdoc; treat them as internal plumbing.

What is stable in value

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.0tree-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.

Float precision and non-finite values

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.

Reading serialized output back (the wire module)

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's float_roundtrip feature; 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 nullNaN (omitted-key↔NaN in TOML); see the non-finite policy above.
  • wire::CodeMetrics round-trips the selected metric set: a metric absent from the document stays absent, and wire::CodeMetrics::selected() rebuilds the MetricSet from 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.

Escape hatches

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.

  • Node exposes its tree_sitter::Node through an accessor. Node<'a> in src/node.rs wraps the tree-sitter node; the inner field is private and the node is reached through Node::as_tree_sitter(&self) -> tree_sitter::Node<'a> (the node is Copy, so it is returned by value). Anything you do through that node follows the pinned tree-sitter crate version and will move whenever we bump that pin (typically under a minor release). This mirrors the higher-level Ast::as_tree_sitter seam below; reach for Node::as_tree_sitter only when you already hold a Node from this surface. (breaking, 2.0) The pre-2.0 shape was pub struct Node<'a>(pub OtherNode<'a>), a public tuple field welding the value-not-stable tree_sitter::Node into the stable struct's layout. #556 demoted the field to private and added the accessor; #534 had already narrowed the sibling Cursor / Callback / LanguageInfo surface but did not touch this field. Field-to-method is a source break, so it landed at 2.0.
  • tree_sitter is re-exported as big_code_analysis::tree_sitter. Consumers who construct trees themselves should depend on the re-export rather than adding a sibling tree-sitter dependency; that guarantees the tree_sitter::Tree they pass into Ast::from_tree_sitter agrees with the version the metric walker was compiled against. The re-export follows our pin: bumping tree-sitter to 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_language returns the tree_sitter::Language paired with each enum variant, wrapped in Result<…, MetricsError> so feature-gated builds can report Err(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_sitter is the public entry point of the parse seam. It accepts a caller-built tree_sitter::Tree directly; the internal Tree wrapper used by the metric walker stays crate-private, so the only type a consumer ever sees on this seam is tree_sitter::Tree itself. It follows the tree-sitter pin in the same value-not-stable sense as the re-export above. The former Parser::from_tree / metrics_from_tree entry points were removed at 2.0 (Parser::from_tree demoted to pub(crate) along with Parser; the parser-generic metrics_from_tree deleted) in favor of this single explicit-name seam.
  • Ast is the parse-once seam and, as of 2.0, the single public analysis entry point. Ast::as_tree_sitter exposes the held tree_sitter::Tree; that single method follows the tree-sitter pin in the same value-not-stable sense as the tree_sitter re-export. The rest of Ast'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::find return the public Node wrapper (#728), whose own preorder() / descendants_by_kind() traversal helpers and as_tree_sitter() escape hatch are shape-stable; the raw node kinds and kind_ids it surfaces follow the tree-sitter pin in the same value-not-stable sense as the re-export. The language-dispatched AstInner enum and the matching ast_*_dispatch helpers stay pub(crate); only Ast is exposed. At 2.0 the path-positional get_function_spaces* / metrics_from_tree / get_ops shims, the parser-generic metrics / metrics_with_options / operands_and_operators functions, and the action / Callback dispatch were all removed in favor of these explicit-name Source / Ast seams (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.

CLI argument grammar

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).

CLI artifact formats

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_line became 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 json omits the line key and bca diff-baseline --format json omits start_line (omitted, never null), bca exemptions --format markdown renders - in the Line column, and the text format drops the :line suffix entirely.
  • v3 → v4 (#377): entries key on the qualified symbol (function renamed to qualified) plus a start_line tolerance rather than the exact line, so editing code above a function no longer re-keys it. An optional body_hash field backs --baseline-fuzzy-match. v2/v3 baselines are still read: their bare function name maps to qualified via 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-baseline refresh.
  • v1 → v2: % percent-encoding made total in the UTF-8 fast path so a literal %FF in a filename cannot collide with the %FF escape for byte 0xFF in a non-UTF-8 path. v1 is no longer accepted; regenerate the file.

Output report formats

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 wire module and read back into the matching wire types (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-FuncSpace suppressed scope (it is present in wire::FuncSpace but absent from the flattened CSV columns). Do not expect to parse a projection back into a FuncSpace; 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 columns

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).

SARIF

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.

code-climate

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.

AST / dump JSON

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_name is the tree-sitter grammar field through which the parent reaches the node (None for the root, anonymous tokens, and unnamed children); children is a nested array of AstNode.
  • 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.

Schema-bump operational contract

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:

  1. Update .github/workflows/pages.yml if it pins a bca release (today it builds from checkout, so this is automatic, but a future revert to a pinned install would re-introduce the gap).
  2. Update every install-bca example in the CI integration recipe: the GitHub Actions section (BCA_VERSION/BCA_SHA256 block, the taiki-e/install-action@v2 cache snippet) and the GitLab CI section (variables.BCA_VERSION/BCA_SHA256).
  3. Land a bca release 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 documented BCA_VERSION to that release. Skipping step 2 ships a documented install path that cannot read the artifact main now 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 exit codes

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).

Threshold tiers and the soft ratio

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 at RATIO of any limit (default 0.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 by 0.90; soft=1.0 disables 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.

CLI / manifest list-merge semantics

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):

  • --x and --x-from always union with each other. --exclude patterns and any --exclude-from file patterns combine into one deny-set; likewise --check-exclude and --check-exclude-from. Order does not matter.
  • Positive scope keys (paths, include) are REPLACED by any explicit CLI value. A CLI --paths/--include discards the manifest's list entirely (bca check one.rs with manifest paths = ["src"] checks just one.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-exclude is 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's exclude (replace) vs extend-exclude (add), generalized: targets replace, filters add.
  • --no-config ignores 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.

Python bindings

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 (the pip install target / 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 _native directly. 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.

Language and metric string enums

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.

Error mapping

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).

Typing

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.

REST schema

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 /comment return a JSON object carrying the request id (echoed; empty when the request carried none), a language field, and the endpoint's result. The language value is the #540 canonical slug (routed through guess_languageLANG::name()), the same identifier used by the CLI JSON output and the Python bindings; the comment endpoint reports the guessed language, not its internal ccomment grammar swap. /ast joined the language echo at 2.0 (#654), bringing the published AstResponse library type to {id, language, root}.
  • /metrics result shape. The single root metric space is returned under the root key (a single object, not the misleading plural spaces); its own nested spaces list holds the child spaces. The request flag selecting file-level-only output is scope: "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 plural spaces envelope key and the boolean unit flag were renamed as a 2.0-line break (#638); the old unit key now fails as an unknown field (see Unknown fields below).
  • Span vocabulary. /ast, /function, and /metrics all report node/space spans with start_line / end_line (1-based) and, where columns are emitted, start_col / end_col. /ast's former start_row / end_row keys were renamed to *_line as a 2.0-line break (#638) so a client correlating spans across endpoints no longer special-cases the field names per endpoint. This shape-changes the published Span library type.
  • Unknown fields. Every request body and query string rejects an unrecognized field with a 400 whose error names the offending key and whose error_kind is unknown_field (#633). A typo can no longer silently change analysis semantics; clients probing for feature support use the GET /v1 route index (#643) instead. This is a 2.0-line break: payloads with extra/typo'd fields that 200'd before now 400.
  • /comment result shape. The JSON variant returns code as a string holding the stripped source; the request code arrived 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 JSON code: number[] (a serde Vec<u8> artifact) was changed to string as a 2.0-line break (#629).
  • Empty /comment result. When the source has no removable comments, both content-type variants signal it as 200 with an empty payload: the JSON variant returns {… "code": ""} (empty string) and the octet-stream variant returns an empty body. The former 204 No Content on the octet-stream variant was removed as a 2.0-line break (#558) so the empty outcome shares one status code and one envelope shape across Accept types.
  • Uniform errors. Every error path returns the JSON body {error, error_kind, id} with an appropriate status; no endpoint emits a bare text/plain error. error carries the specific human-readable cause; error_kind carries a stable snake_case machine token clients branch on without string-matching the prose (#631); id is 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 tokens vcs_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-all vcs_internal_error. A file_name that maps to no supported language is a 422 Unprocessable Entity carrying error_kind: "unsupported_language" (the route matched and the body parsed); only an unknown URL is a 404. The former 404 for the unsupported-language case was a 2.0-line break (#634). The per-vcs-cause tokens replace the former single kitchen-sink /vcs 400 message at 2.0 (#631): a bad window now answers vcs_invalid_window with the specific cause, not a sentence listing every possible parameter. vcs_invalid_author_hash_key is new after 2.1.0, which does not carry it: a misused author_hash_key already answered 400 there, but reported the catch-all vcs_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 treat vcs_internal_error on a 400 as 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): top defaults to 50, top_deltas to 10, and points to 12 (formerly hard-required). An explicit top: 0 / top_deltas: 0 still returns all (the #602 0 = all escape). Aligning these from the former unbounded "all" default is a behavioral 2.0-line break: payloads omitting these fields get a smaller result.
  • Introspection. GET /v1/version reports the server and library versions; GET /v1/languages reports the supported languages and their extensions, sourced from the LANG table (not hardcoded), mirroring the Python __version__ / supported_languages() / language_extensions() surface. All routes are served under the /v1 prefix.
  • 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 carried Deprecation: true, a Sunset HTTP-date, and a Link rel="successor-version" naming the canonical /v1 twin; they now 404 like any other unknown URL. Clients must use the /v1 prefix.

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.

MSRV policy

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.).

Process

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 LANG variant, a new MetricsError variant, 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: the Drop impls on the result trees in 2.1.0 (#1056), landed early because holding them for 3.0 would 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.

On the 3.0 horizon

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.