Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion .bca-baseline.toml
Original file line number Diff line number Diff line change
Expand Up @@ -167,12 +167,24 @@ qualified = "dispatch_exemptions"
metric = "nargs"
value = 5.0

[[entry]]
path = "big-code-analysis-cli/src/dispatch.rs"
qualified = "dispatch_exemptions"
metric = "nexits"
value = 5.0

[[entry]]
path = "big-code-analysis-cli/src/dispatch.rs"
qualified = "dispatch_find"
metric = "nargs"
value = 6.0

[[entry]]
path = "big-code-analysis-cli/src/dispatch.rs"
qualified = "dispatch_find"
metric = "nexits"
value = 5.0

[[entry]]
path = "big-code-analysis-cli/src/dispatch.rs"
qualified = "dispatch_functions"
Expand Down Expand Up @@ -1005,7 +1017,7 @@ value = 5.0
path = "src/metrics/loc/perl.rs"
qualified = "PerlCode::compute"
metric = "halstead.effort"
value = 55025.91689557041
value = 52134.594881912846

[[entry]]
path = "src/metrics/loc/shared.rs"
Expand Down
58 changes: 58 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,64 @@ for historical reference.

## [Unreleased]

### Fixed

- The CLI and web crates no longer terminate on a library parse error.
All fifteen `.expect(FEATURES_PINNED)` call sites — eight in `bca`'s
dispatch helpers, seven in `bca-web`'s handlers, plus the constant
itself in each crate — now propagate onto the error channel each
caller already had: an `io::Error` of kind `InvalidData` that the
concurrent runner reports per file and continues past, and the
existing sanitized `500` that logs the cause server-side. The pinned
`all-languages` feature does make `MetricsError::LanguageDisabled`
unreachable, but `MetricsError` is `#[non_exhaustive]` and documents
that variants may be added in a *minor* release, so the `expect` was a
panic scheduled against a routine dependency bump rather than an
invariant. No behaviour changes today: the only reachable outcome is
still success (#1152).

### Changed

- `clippy::arithmetic_side_effects` is enforced on the `loc` metric
module, and the span arithmetic there is now explicitly saturating.
`Loc` is the one metric computing on tree-sitter row coordinates, and
#1051 was a `usize` underflow of exactly that shape — a Rust doc
comment at EOF drove `end - 1` below zero from an input as small as
`/// x`, panicking in debug and wrapping to `usize::MAX` in release.
Validated by replaying the lint against the pre-#1051 tree, where it
flags both reported panic sites. Metric values are unchanged: a
saturating operation is identical to the plain one unless it would
have overflowed, and none does (#1152).
- `clippy::indexing_slicing` is enforced on `src/c_macro.rs`, the C/C++
macro-masking byte lexer, with nine per-function carve-outs each
naming the bound that makes its indexing safe. Validated by replaying
it against the pre-#126 tree, where it flags the `&DOLLARS[..]` slices
that panicked on macro identifiers longer than 2048 bytes. The one
slice whose bound is established in a *different* function —
`step_raw_string`'s delimiter comparison, carried through
`LexState::RawString` — is hardened with `get` rather than allowed
(#1152).
- `clippy::unwrap_used` is enforced on production code across every
crate, as `#![cfg_attr(not(test), warn(...))]` at each of the eight
lib/bin roots rather than a `[workspace.lints]` entry: a Cargo lint
applies to every target of its package, and the ban is a production
rule — this workspace has **0** production `unwrap()` calls against
1,023 legitimate ones in test targets. `cfg(test)` is set for
integration-test crates as well as the unit-test target, so the gate
needs no per-file carve-out and carries zero `#[allow]`s. Adopting it
costs nothing today and fails CI on the first production `unwrap()`
added. `clippy::expect_used` is deliberately **not** enabled — all 37
production `expect` sites already name their invariant in the message,
the form `AGENTS.md` sanctions. The workspace-excluded `enums` codegen
crate carries the same gate: it is CI-linted by `make enums-check` but
invisible to `cargo clippy --workspace`, so its 7 production
`unwrap()` calls were outside the original count. They now propagate
onto the `io::Result` each generator already returned, except the Go
generator's `max()` width, which becomes `unwrap_or(0)`. None was a
reachable crash: every one rests on an invariant as solid as the 37
`expect` sites left alone. The difference is that an `unwrap()` states
no invariant, which is the whole basis for gating it (#1227).

## [2.1.0] - 2026-08-06

A feature and correctness release on the `2.x` line, and the first to
Expand Down
19 changes: 19 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,25 @@ unexpected_cfgs = { level = "warn", check-cfg = ['cfg(chain_audit)'] }
pedantic = { level = "warn", priority = -1 }
module_name_repetitions = "allow"

# `clippy::unwrap_used` is deliberately NOT declared here. It is set as
# `#![cfg_attr(not(test), warn(clippy::unwrap_used))]` at each production
# crate root instead (the eight lib/bin roots), because a Cargo lint
# applies to every target of its package and the `unwrap` ban is a
# production rule: this workspace's test targets hold 1_023 legitimate
# `unwrap()` calls, against 0 in production. `cfg(test)` is set for
# integration-test crates as well as for the unit-test target, so the
# per-root form needs no per-file carve-out and carries zero `#[allow]`s.
#
# `clippy::expect_used` is not enabled at all. All 37 production `expect`
# sites already name their invariant in the message, which is the form
# `AGENTS.md` sanctions, so gating it would buy 37 annotations that each
# restate the line above them. The distinction that matters is not
# `expect`-vs-`unwrap` but *what can invalidate the invariant*: the
# `FEATURES_PINNED` sites removed in #1152 rested on a `#[non_exhaustive]`
# enum a dependency bump could change underneath them, whereas these 37
# rest on facts local to this repository, which review and tests cover.
# See #1227 for the full triage.

[package]
name = "big-code-analysis"
version.workspace = true
Expand Down
5 changes: 5 additions & 0 deletions big-code-analysis-bench/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,11 @@
//! See `docs/development/benchmarking.md` for invocation and for the
//! measurement traps this harness exists to prevent.

// Production-only `unwrap()` ban. See `[workspace.lints.clippy]` in the
// root `Cargo.toml` for why this is a per-root attribute and not a
// Cargo lint (#1227).
#![cfg_attr(not(test), warn(clippy::unwrap_used))]

pub mod cli;
pub mod corpus;
pub mod scaling;
Expand Down
101 changes: 80 additions & 21 deletions big-code-analysis-cli/src/dispatch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ use big_code_analysis::{
use crate::exemptions::FileMarkers;
use crate::formats::{MetricsDispatch, MetricsFormat, dump_csv};
use crate::markdown_report::extract_summaries;
use crate::{Action, Config, FEATURES_PINNED, note, warn};
use crate::{Action, Config, note, warn};

/// Analyze one already-read file via the explicit-name [`Source`] seam.
///
Expand Down Expand Up @@ -66,6 +66,42 @@ fn parse_ast(
)
}

/// [`parse_ast`], with the library error mapped onto the `io::Result`
/// channel every dispatch helper already returns.
///
/// This replaces `.expect(FEATURES_PINNED)` at all eight dispatch call
/// sites (#1152). The feature pin does make [`MetricsError`]'s only
/// reachable variant, `LanguageDisabled`, unreachable here — but
/// `MetricsError` is `#[non_exhaustive]` and its own documentation
/// reserves the right to add variants in a *minor* release, so the
/// `expect` was a panic scheduled against a routine dependency bump
/// rather than an invariant. `ErrorKind::InvalidData` is the honest
/// classification: whatever a future variant turns out to mean, it
/// means this file's bytes did not yield a tree.
///
/// The failure is per-file. `act_on_file` returns this to the
/// concurrent runner, which prints a per-file error line and carries
/// on — so an unparseable file costs that file, where the `expect`
/// unwound a worker mid-walk and took the rest of the run with it.
fn parse_ast_io(
language: LANG,
source: Vec<u8>,
path: &Path,
pr: Option<Arc<PreprocResults>>,
) -> std::io::Result<Ast> {
parse_ast(language, source, path, pr).map_err(parse_error_to_io)
}

/// Lifts a [`MetricsError`] into the `io::Error` channel.
///
/// Split out of [`parse_ast_io`] so the mapping is reachable from a
/// test: the CLI's feature pin makes every current `MetricsError`
/// variant unreachable through `parse_ast_io` itself, so the branch has
/// no end-to-end trigger and would otherwise ship uncovered.
fn parse_error_to_io(err: MetricsError) -> std::io::Error {
std::io::Error::new(std::io::ErrorKind::InvalidData, err)
}

pub(crate) fn act_on_file(path: PathBuf, cfg: &Config) -> std::io::Result<()> {
let Some((path, source, language)) = validate_and_resolve_file(path, cfg)? else {
return Ok(());
Expand Down Expand Up @@ -203,9 +239,9 @@ fn dispatch_dump(
cfg: &Config,
) -> std::io::Result<()> {
// The CLI pins the library's `all-languages` feature, so
// `LanguageDisabled` from `Ast::parse` is unreachable; the `expect`
// documents that invariant.
let ast = parse_ast(language, source, &path, pr).expect(FEATURES_PINNED);
// `LanguageDisabled` from `Ast::parse` is unreachable here; a future
// variant surfaces as a per-file `io::Error` instead (#1152).
let ast = parse_ast_io(language, source, &path, pr)?;
// Per-file banner so a multi-file dump is attributable: the parallel
// walk interleaves trees by worker scheduling, and without a header
// which tree belongs to which file is unrecoverable (#690).
Expand Down Expand Up @@ -281,10 +317,7 @@ fn dispatch_metrics(
// Human-readable metric dump: parse once, then render the tree.
// A walker error degrades to no output (matching the prior
// `Metrics` callback), never an `Err`.
match parse_ast(language, source, &path, pr)
.expect(FEATURES_PINNED)
.metrics(cfg.metrics_options())
{
match parse_ast_io(language, source, &path, pr)?.metrics(cfg.metrics_options()) {
Ok(space) => dump_root_with_color(&space, cfg.color),
Err(_) => Ok(()),
}
Expand Down Expand Up @@ -323,10 +356,7 @@ fn dispatch_ops(
} else {
// Human-readable ops dump: a walker error degrades to no output
// (matching the prior `OpsCode` callback), never an `Err`.
match parse_ast(language, source, &path, pr)
.expect(FEATURES_PINNED)
.ops()
{
match parse_ast_io(language, source, &path, pr)?.ops() {
Ok(ops) => dump_ops_with_color(&ops, cfg.color),
Err(_) => Ok(()),
}
Expand All @@ -349,7 +379,7 @@ fn dispatch_strip_comments(
} else {
language
};
let ast = parse_ast(lang, source, &path, pr).expect(FEATURES_PINNED);
let ast = parse_ast_io(lang, source, &path, pr)?;
if let Some(new_source) = ast.strip_comments() {
if in_place {
write_file(&path, &new_source)?;
Expand Down Expand Up @@ -390,7 +420,7 @@ fn dispatch_functions(
pr: Option<Arc<PreprocResults>>,
cfg: &Config,
) -> std::io::Result<()> {
let ast = parse_ast(language, source, &path, pr).expect(FEATURES_PINNED);
let ast = parse_ast_io(language, source, &path, pr)?;
dump_function_spans_with_color(ast.functions(), &path, cfg.color)
}

Expand All @@ -402,7 +432,7 @@ fn dispatch_find(
cfg: &Config,
filters: &Arc<[String]>,
) -> std::io::Result<()> {
let ast = parse_ast(language, source, &path, pr).expect(FEATURES_PINNED);
let ast = parse_ast_io(language, source, &path, pr)?;
// A walker error degrades to no output, matching `dispatch_metrics`
// / `dispatch_ops`. `Ast::find` is infallible today, but its `Result`
// is contracted to become fallible under a future strict-parsing mode
Expand Down Expand Up @@ -449,9 +479,7 @@ fn dispatch_count(
.count_lock
.clone()
.expect("Count handler initializes count_lock before dispatch");
let (good, total) = parse_ast(language, source, &path, pr)
.expect(FEATURES_PINNED)
.count(&filters[..]);
let (good, total) = parse_ast_io(language, source, &path, pr)?.count(&filters[..]);
stats.add(good, total);
Ok(())
}
Expand Down Expand Up @@ -606,9 +634,7 @@ fn dispatch_exemptions(
}
return Ok(());
};
let markers = parse_ast(language, source, &path, pr)
.expect(FEATURES_PINNED)
.suppressions();
let markers = parse_ast_io(language, source, &path, pr)?.suppressions();
// Empty files are the dominant case (most source carries no
// markers); skip the channel send and the per-file allocation when
// there is nothing to report.
Expand Down Expand Up @@ -655,6 +681,39 @@ mod tests {
use std::sync::Mutex;
use std::sync::atomic::AtomicUsize;

/// The dispatch helpers propagate a library parse failure instead of
/// panicking through `expect(FEATURES_PINNED)` (#1152).
///
/// Unreachable end-to-end by construction: the CLI pins
/// `all-languages`, so `LanguageDisabled` cannot be produced here,
/// and `MetricsError` is `#[non_exhaustive]` precisely so that a
/// *future* variant can be. That is the whole reason the `expect`
/// was wrong, and it is why this asserts on the mapping directly
/// rather than through a `bca` invocation.
///
/// `InvalidData` is load-bearing: `act_on_file`'s caller reports the
/// per-file line and continues, and `BrokenPipe` is the one kind it
/// treats specially, so a mapping that reached for that would
/// silently swallow the failure.
#[test]
fn a_library_parse_error_becomes_an_invalid_data_io_error() {
let err = parse_error_to_io(MetricsError::LanguageDisabled(LANG::Rust));

assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
// The cause survives the lift rather than being flattened to a
// generic string, so the per-file line names the language.
assert!(
err.to_string().contains("rust"),
"the io::Error must carry the library message, got {err}"
);
assert!(
err.get_ref()
.and_then(|inner| inner.downcast_ref::<MetricsError>())
.is_some(),
"the MetricsError must be retrievable, not stringified"
);
}

// Minimal `Config` for exercising `dispatch_preproc` in isolation.
// Only `preproc_lock` and `warning` are load-bearing here; every
// other field is defaulted to the inert value used elsewhere.
Expand Down
16 changes: 4 additions & 12 deletions big-code-analysis-cli/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,10 @@
// section on the entry point adds noise without adding signal.
clippy::missing_panics_doc
)]
// Production-only `unwrap()` ban. See `[workspace.lints.clippy]` in the
// root `Cargo.toml` for why this is a per-root attribute and not a
// Cargo lint (#1227).
#![cfg_attr(not(test), warn(clippy::unwrap_used))]
mod baseline;
mod baseline_diff;
mod check_flags;
Expand Down Expand Up @@ -109,18 +113,6 @@ use big_code_analysis::{
};
use big_code_analysis::{FuncSpace, Ops, get_from_ext, get_language_for_file, read_file};

/// `expect` message used at every `action::<_>` call site inside the
/// extracted `dispatch` module. Kept in `lib.rs` so any module that
/// terminates with `expect(FEATURES_PINNED)` can import the same
/// string and the invariant lives in one place.
///
/// The CLI pins `big-code-analysis` with `features = ["all-languages"]`,
/// so a `LANG` value that reached this point must be enabled at compile
/// time. Any future caller that loosens the feature pin must change
/// this invariant explicitly.
pub(crate) const FEATURES_PINNED: &str =
"CLI pins big-code-analysis features = [\"all-languages\"]";

/// Process exit code for tool errors — bad flags/values, unreadable
/// input, I/O failures. Distinct from [`EXIT_GATE_BREACH`] so CI can
/// tell a broken invocation from a failed metric gate (#594); the full
Expand Down
5 changes: 5 additions & 0 deletions big-code-analysis-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@
//! [`big_code_analysis_cli`] library so the workspace `xtask` crate can
//! reuse the same `clap` definition to render man pages.

// Production-only `unwrap()` ban. See `[workspace.lints.clippy]` in the
// root `Cargo.toml` for why this is a per-root attribute and not a
// Cargo lint (#1227).
#![cfg_attr(not(test), warn(clippy::unwrap_used))]

fn main() {
big_code_analysis_cli::run();
}
4 changes: 4 additions & 0 deletions big-code-analysis-py/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@
//! without spinning up a Python interpreter.

#![allow(unsafe_op_in_unsafe_fn)]
// Production-only `unwrap()` ban. See `[workspace.lints.clippy]` in the
// root `Cargo.toml` for why this is a per-root attribute and not a
// Cargo lint (#1227).
#![cfg_attr(not(test), warn(clippy::unwrap_used))]
// The `#[pymodule]` macro expands to an `extern "C"` init function
// that PyO3 marks `#[unsafe(no_mangle)]`. The expansion contains
// unsafe FFI shims that the macro itself wraps in `unsafe { ... }`;
Expand Down
4 changes: 4 additions & 0 deletions big-code-analysis-web/src/bin/bca-web.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
#![allow(missing_docs)]
// Production-only `unwrap()` ban. See `[workspace.lints.clippy]` in the
// root `Cargo.toml` for why this is a per-root attribute and not a
// Cargo lint (#1227).
#![cfg_attr(not(test), warn(clippy::unwrap_used))]
use std::process::ExitCode;

use clap::Parser;
Expand Down
4 changes: 4 additions & 0 deletions big-code-analysis-web/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@
// The deeply nested `json!` literals in server.rs tests exceed the default
// recursion limit (128) during `json_internal!` macro expansion.
#![recursion_limit = "256"]
// Production-only `unwrap()` ban. See `[workspace.lints.clippy]` in the
// root `Cargo.toml` for why this is a per-root attribute and not a
// Cargo lint (#1227).
#![cfg_attr(not(test), warn(clippy::unwrap_used))]

/// HTTP endpoints and request handlers.
pub mod web;
Expand Down
Loading