From c7e42f8607b00aa679e944ac41ae8deed7ef0e7d Mon Sep 17 00:00:00 2001 From: Elijah Zupancic Date: Fri, 31 Jul 2026 22:27:14 -0700 Subject: [PATCH 01/36] refactor(bench): rename Probe.depths to sizes The scaling harness is about to gain a width-sweeping probe family, which makes "depth" wrong at every entry that does not nest: the field, the per-cell record, and the report column a reader consults when a probe fails all name the axis rather than the quantity. Rename Probe.depths to sizes, Cell.depth to size, and SMOKE_DEPTHS to SMOKE_SIZES, and reword the doc comments to name the probe's swept parameter. No behaviour change; the report's first column header now reads "size". --- big-code-analysis-bench/benches/scaling.rs | 10 +- big-code-analysis-bench/src/cli.rs | 12 +-- big-code-analysis-bench/src/scaling.rs | 107 ++++++++++----------- big-code-analysis-bench/src/shapes.rs | 65 +++++++------ docs/development/benchmarking.md | 2 +- 5 files changed, 97 insertions(+), 99 deletions(-) diff --git a/big-code-analysis-bench/benches/scaling.rs b/big-code-analysis-bench/benches/scaling.rs index 33813e176..0aa64e780 100644 --- a/big-code-analysis-bench/benches/scaling.rs +++ b/big-code-analysis-bench/benches/scaling.rs @@ -1,7 +1,7 @@ //! Complexity-class gate for the metric walk (#1068). //! -//! Measures every probe in `shapes::PROBES` at three doubling depths, -//! fits `time ~ depth^k`, and fails when a probe's `k` exceeds the +//! Measures every probe in `shapes::PROBES` at three doubling sizes, +//! fits `time ~ size^k`, and fails when a probe's `k` exceeds the //! bound it declared. This is where the wall-clock assertions that used //! to live in `cognitive_deep_nesting_is_tractable` and //! `tokens_deep_nesting_is_tractable` went: the unit suite keeps the @@ -71,7 +71,7 @@ fn main() -> ExitCode { let smoke_set; let probes: &[Probe] = if args.mode == Mode::Smoke { eprintln!( - "note: smoke run at shallow depths. Use `cargo bench -p \ + "note: smoke run at small sizes. Use `cargo bench -p \ big-code-analysis-bench --bench scaling` for the real gate." ); smoke_set = scaling::smoke_probes(PROBES); @@ -107,9 +107,9 @@ fn main() -> ExitCode { // its exponent: it was fitted over the cells that finished, so // that number is flattering and quoting it reads as a harness // bug rather than as the worst regression the gate can see. - if let Some((depth, elapsed)) = probe.over_budget { + if let Some((size, elapsed)) = probe.over_budget { eprintln!( - " {name}: one walk at depth {depth} took {elapsed:?}, over the \ + " {name}: one walk at size {size} took {elapsed:?}, over the \ {budget:?} per-walk budget\n {rationale}", name = probe.name, budget = scaling::MAX_CELL_WALK, diff --git a/big-code-analysis-bench/src/cli.rs b/big-code-analysis-bench/src/cli.rs index bf088b44e..ba455205d 100644 --- a/big-code-analysis-bench/src/cli.rs +++ b/big-code-analysis-bench/src/cli.rs @@ -13,20 +13,20 @@ pub const USAGE: &str = "\ usage: cargo bench -p big-code-analysis-bench --bench scaling -- [options] --rounds N measurement rounds per cell (default 7, must be odd) - --no-gate measure at full depth, report the exponents, never fail - --smoke shallow depths, no verdict (the `cargo test` path) + --no-gate measure at full size, report the exponents, never fail + --smoke small sizes, no verdict (the `cargo test` path) --help this message "; /// What a run is for. #[derive(PartialEq, Eq, Clone, Copy, Debug)] pub enum Mode { - /// Measure at the probes' declared depths and fail on a probe that + /// Measure at the probes' declared sizes and fail on a probe that /// left its complexity class. Gate, - /// Measure at the probes' declared depths, report, never fail. + /// Measure at the probes' declared sizes, report, never fail. ReportOnly, - /// Shallow depths, no verdict: enough to prove the harness still + /// Small sizes, no verdict: enough to prove the harness still /// runs, not enough to mean anything. Smoke, } @@ -55,7 +55,7 @@ pub enum Action { /// target; `cargo test --benches` runs the same binary with **no** /// arguments at all. That absence is the only signal distinguishing /// the two, so it selects [`Mode::Smoke`] by default: measuring every -/// probe at production depths in an unoptimised build would add tens +/// probe at production sizes in an unoptimised build would add tens /// of seconds to `cargo test` and produce numbers nobody should read. /// /// An explicit `--no-gate` / `--smoke` wins over `--bench` regardless diff --git a/big-code-analysis-bench/src/scaling.rs b/big-code-analysis-bench/src/scaling.rs index 035e545fa..3e2dcf773 100644 --- a/big-code-analysis-bench/src/scaling.rs +++ b/big-code-analysis-bench/src/scaling.rs @@ -95,8 +95,8 @@ const TARGET_SAMPLE: Duration = Duration::from_millis(1); /// simply finishing sooner. const MAX_ITERATIONS: u32 = 64; -/// Wall clock one walk may take before the harness stops deepening a -/// probe. +/// Wall clock one walk may take before the harness stops enlarging a +/// probe's input. /// /// The failure mode this exists for is the one the retired unit-test /// budgets were guarding against: a reintroduced quadratic walk does @@ -105,8 +105,8 @@ const MAX_ITERATIONS: u32 = 64; /// 1000, 2000 and 4000 for nine rounds each would have run for hours /// and tripped a CI timeout instead of reporting a regression. /// -/// Cells are built in increasing depth order, so a probe that blows -/// past this at one depth is abandoned before the deeper ones are +/// Cells are built in increasing size order, so a probe that blows +/// past this at one size is abandoned before the larger ones are /// attempted, and reported as over budget — which counts as a failure, /// since a walk this slow is the regression. An abandoned probe /// contributes *no* cells to the measurement schedule, not merely no @@ -122,15 +122,15 @@ const MAX_ITERATIONS: u32 = 64; /// is slow until it finishes one. pub const MAX_CELL_WALK: Duration = Duration::from_secs(20); -/// One (probe, depth) cell, reduced across rounds. +/// One (probe, size) cell, reduced across rounds. #[derive(Debug, Clone)] pub struct Cell { - /// Nesting depth of the generated input. - pub depth: usize, + /// Value of the probe's swept parameter for this cell. + pub size: usize, /// Size of the generated input. Reported so a reader can confirm /// the input grew linearly and check cost per byte directly. pub bytes: usize, - /// Headline metric value the walk produced at this depth. + /// Headline metric value the walk produced at this size. pub reading: u64, /// Walks performed inside one timed sample. Reported so a reader /// can tell an amortised measurement from a directly observed one. @@ -149,9 +149,10 @@ pub struct Cell { pub struct ProbeReport { /// The probe's stable identifier. pub name: &'static str, - /// Cells in increasing depth order. + /// Cells in increasing size order. pub cells: Vec, - /// Fitted slope of `ln(median time)` against `ln(depth)`. + /// Fitted slope of `ln(median time)` against `ln(size)`, where + /// "size" is the probe's swept parameter. pub exponent: f64, /// Bound the probe declared for that slope. pub max_exponent: f64, @@ -159,7 +160,7 @@ pub struct ProbeReport { /// self-explanatory without opening the source. pub rationale: &'static str, /// Set when a single walk exceeded [`MAX_CELL_WALK`], carrying the - /// depth it happened at and how long it took. An abandoned probe + /// size it happened at and how long it took. An abandoned probe /// contributes no cells at all, so [`ProbeReport::cells`] is empty /// and [`ProbeReport::exponent`] is `0.0` — flattering, and not the /// verdict. This is. @@ -206,10 +207,10 @@ impl fmt::Display for Report { // `exponent 0.00 (bound 1.50) OVER BOUND` — a passing // number next to a failing verdict, for the worst // regression this gate can see. - if let Some((depth, elapsed)) = probe.over_budget { + if let Some((size, elapsed)) = probe.over_budget { writeln!( f, - "{name} ABANDONED one walk at depth {depth} took {elapsed:?}, \ + "{name} ABANDONED one walk at size {size} took {elapsed:?}, \ over the {MAX_CELL_WALK:?} budget", name = probe.name, )?; @@ -227,14 +228,14 @@ impl fmt::Display for Report { writeln!( f, " {:>7} {:>9} {:>10} {:>10} {:>10} {:>9} {:>5} {:>12}", - "depth", "bytes", "median ms", "min ms", "max ms", "ns/byte", "iter", "reading", + "size", "bytes", "median ms", "min ms", "max ms", "ns/byte", "iter", "reading", )?; for cell in &probe.cells { writeln!( f, - " {depth:>7} {bytes:>9} {median:>10.3} {min:>10.3} \ + " {size:>7} {bytes:>9} {median:>10.3} {min:>10.3} \ {max:>10.3} {per_byte:>9.2} {iterations:>5} {reading:>12}", - depth = cell.depth, + size = cell.size, bytes = cell.bytes, median = millis(cell.median), min = millis(cell.min), @@ -260,7 +261,7 @@ fn millis(duration: Duration) -> f64 { /// timings. struct Pending { probe: usize, - depth: usize, + size: usize, bytes: usize, reading: u64, iterations: u32, @@ -283,36 +284,36 @@ fn iterations_for(single_walk: Duration) -> u32 { .clamp(1, MAX_ITERATIONS) } -/// Depths substituted into every probe for a smoke run. +/// Sizes substituted into every probe for a smoke run. /// /// Two orders of magnitude under the real ones. A smoke run answers /// "does the harness still work", not "how does the walk scale", and /// it happens in contexts (`cargo test`, an unoptimised build) where -/// the production depths would cost tens of seconds and produce a +/// the production sizes would cost tens of seconds and produce a /// number nobody should read. -pub const SMOKE_DEPTHS: [usize; 3] = [32, 64, 128]; +pub const SMOKE_SIZES: [usize; 3] = [32, 64, 128]; -/// Copies `probes` with [`SMOKE_DEPTHS`] substituted. +/// Copies `probes` with [`SMOKE_SIZES`] substituted. #[must_use] pub fn smoke_probes(probes: &[Probe]) -> Vec { probes .iter() .map(|probe| Probe { - depths: SMOKE_DEPTHS, + sizes: SMOKE_SIZES, ..*probe }) .collect() } -/// Measures every probe at every depth and fits a complexity exponent. +/// Measures every probe at every size and fits a complexity exponent. /// /// `rounds` measurement rounds are performed after /// [`WARMUP_ROUNDS`] discarded ones. Every cell is visited once per /// round, with the visit order rotated each round so no cell sits at a /// fixed position in the schedule. /// -/// A probe whose walk exceeds [`MAX_CELL_WALK`] at one depth is -/// reported as over budget, and neither that cell nor the deeper ones +/// A probe whose walk exceeds [`MAX_CELL_WALK`] at one size is +/// reported as over budget, and neither that cell nor the larger ones /// are measured. /// /// # Errors @@ -351,10 +352,10 @@ pub fn run_with_budget( // pathological. Accumulating locally makes that structural // rather than a cleanup someone has to remember. let mut cells = Vec::new(); - // Ascending depth, so an intractably slow walk is caught at the - // cheapest depth and the deeper cells are never attempted. - for depth in probe.depths { - let source = (probe.render)(depth); + // Ascending size, so an intractably slow walk is caught at the + // cheapest size and the larger cells are never attempted. + for size in probe.sizes { + let source = (probe.render)(size); let ast = Ast::parse(Source::new(probe.lang, source.as_bytes()))?; let options = probe.workload.options(); // One untimed walk serves three purposes: it proves the @@ -365,12 +366,12 @@ pub fn run_with_budget( let reading = probe.workload.walk(&ast, options)?; let single_walk = started.elapsed(); if single_walk > max_cell_walk { - over_budget[index] = Some((depth, single_walk)); + over_budget[index] = Some((size, single_walk)); break; } cells.push(Pending { probe: index, - depth, + size, bytes: source.len(), reading, workload: probe.workload, @@ -428,7 +429,7 @@ fn reduce<'a>( .map(|pending| { pending.timings.sort_unstable(); Cell { - depth: pending.depth, + size: pending.size, bytes: pending.bytes, reading: pending.reading, iterations: pending.iterations, @@ -443,7 +444,7 @@ fn reduce<'a>( .iter() .map(|cell| { ( - cell.depth as f64, + cell.size as f64, (cell.median.as_nanos() as f64).max(MIN_MEASURABLE_NS), ) }) @@ -472,11 +473,11 @@ pub fn median(sorted: &[Duration]) -> Duration { } } -/// Fits `time ~ depth^k` by least squares on the log-log points and +/// Fits `time ~ size^k` by least squares on the log-log points and /// returns `k`. /// /// Returns `0.0` for fewer than two points, or when every point shares -/// one depth — there is no slope to recover and reporting a fabricated +/// one size — there is no slope to recover and reporting a fabricated /// one would read as a pass. #[must_use] pub fn fit_exponent(points: &[(f64, f64)]) -> f64 { @@ -503,7 +504,7 @@ mod tests { use std::time::Duration; use super::{ - ProbeReport, Report, SMOKE_DEPTHS, fit_exponent, median, run, run_with_budget, smoke_probes, + ProbeReport, Report, SMOKE_SIZES, fit_exponent, median, run, run_with_budget, smoke_probes, }; use crate::shapes::PROBES; @@ -530,7 +531,7 @@ mod tests { } /// Degenerate inputs return 0 rather than a fabricated slope: too - /// few points, and points that share a single depth. + /// few points, and points that share a single size. /// /// Exact equality is the assertion: `fit_exponent` returns the /// literal `0.0` on these paths rather than computing a value that @@ -605,7 +606,7 @@ mod tests { /// The budget exists because a reintroduced quadratic walk hangs /// rather than fails; retaining the offending cell would walk it /// once per round and multiply the very cost being escaped. A - /// zero budget abandons every probe at its shallowest depth, so + /// zero budget abandons every probe at its smallest size, so /// the invariant is checked without a genuinely slow walk: no /// cells at all, and the run finishes fast enough to sit in the /// unit suite. @@ -617,14 +618,14 @@ mod tests { for probe in &report.probes { assert!( probe.cells.is_empty(), - "{}: abandoned at depth {:?} but kept {} cell(s) to measure", + "{}: abandoned at size {:?} but kept {} cell(s) to measure", probe.name, - probe.over_budget.map(|(depth, _)| depth), + probe.over_budget.map(|(size, _)| size), probe.cells.len(), ); assert!( probe.over_budget.is_some(), - "{}: a zero budget must abandon the shallowest cell", + "{}: a zero budget must abandon the smallest cell", probe.name, ); assert!(!probe.passed(), "{}: an abandoned probe fails", probe.name); @@ -654,10 +655,10 @@ mod tests { /// A one-round run over the real probe set produces a complete, /// well-formed report. /// - /// Runs at [`SMOKE_DEPTHS`]: the scheduling, reduction and - /// reporting logic under test is depth-independent, and the - /// production depths would add half a minute to every `cargo - /// test`. The real depths are exercised by `benches/scaling.rs` + /// Runs at [`SMOKE_SIZES`]: the scheduling, reduction and + /// reporting logic under test is size-independent, and the + /// production sizes would add half a minute to every `cargo + /// test`. The real sizes are exercised by `benches/scaling.rs` /// under the bench profile. /// /// Deliberately does **not** assert on the exponents: this runs @@ -666,23 +667,19 @@ mod tests { /// timing assertion has already produced four false failures. The /// gate lives in `benches/scaling.rs`. #[test] - fn run_produces_a_cell_per_probe_depth() { + fn run_produces_a_cell_per_probe_size() { let probes = smoke_probes(PROBES); let report = run(&probes, 1).expect("every probe language is compiled in"); assert_eq!(report.probes.len(), PROBES.len()); for (result, probe) in report.probes.iter().zip(PROBES) { assert_eq!(result.name, probe.name); - assert_eq!(result.cells.len(), SMOKE_DEPTHS.len()); - for (cell, depth) in result.cells.iter().zip(SMOKE_DEPTHS) { - assert_eq!(cell.depth, depth); - assert!( - cell.bytes > 0, - "{}: empty input at depth {depth}", - probe.name - ); + assert_eq!(result.cells.len(), SMOKE_SIZES.len()); + for (cell, size) in result.cells.iter().zip(SMOKE_SIZES) { + assert_eq!(cell.size, size); + assert!(cell.bytes > 0, "{}: empty input at size {size}", probe.name); assert!( cell.reading > 0, - "{}: zero metric reading at depth {depth}", + "{}: zero metric reading at size {size}", probe.name, ); assert!(cell.min <= cell.median && cell.median <= cell.max); diff --git a/big-code-analysis-bench/src/shapes.rs b/big-code-analysis-bench/src/shapes.rs index efe831c86..4adca4efe 100644 --- a/big-code-analysis-bench/src/shapes.rs +++ b/big-code-analysis-bench/src/shapes.rs @@ -396,9 +396,10 @@ pub struct Probe { pub workload: Workload, /// Generator for the probe's input. pub render: Render, - /// The three depths measured, each a doubling of the previous so - /// the fitted exponent reads directly as "cost per doubling". - pub depths: [usize; 3], + /// The three sizes measured — the values handed to [`Probe::render`] + /// — each a doubling of the previous so the fitted exponent reads + /// directly as "cost per doubling". + pub sizes: [usize; 3], /// Upper bound on the fitted log-log exponent. A linear walk sits /// near 1.0 and a quadratic one near 2.0; the bounds below leave /// enough headroom that measurement noise cannot cross them but @@ -461,7 +462,7 @@ pub const PROBES: &[Probe] = &[ reading: |m| m.tokens.tokens_sum(), }, render: nested_parens, - depths: LINEAR_DEPTHS, + sizes: LINEAR_DEPTHS, max_exponent: LINEAR_BOUND, rationale: "#1052: `tokens` inherits the in-comment flag down the \ traversal. Reverting to the per-leaf ancestor walk is \ @@ -484,7 +485,7 @@ pub const PROBES: &[Probe] = &[ reading: |m| m.cognitive.cognitive_sum(), }, render: nested_whiles, - depths: LINEAR_DEPTHS, + sizes: LINEAR_DEPTHS, max_exponent: LINEAR_BOUND, rationale: "#1062 on a statement shape, and the linear control for \ `cognitive/nested-if`: identical structure, no \ @@ -499,7 +500,7 @@ pub const PROBES: &[Probe] = &[ reading: |m| m.nom.total(), }, render: nested_whiles, - depths: LINEAR_DEPTHS, + sizes: LINEAR_DEPTHS, max_exponent: LINEAR_BOUND, rationale: "Metric control. `Cognitive` declares `Nom` as a \ dependency, so the cognitive-attributable cost is the \ @@ -514,7 +515,7 @@ pub const PROBES: &[Probe] = &[ reading: |m| m.cognitive.cognitive_sum(), }, render: nested_ifs, - depths: LINEAR_DEPTHS, + sizes: LINEAR_DEPTHS, max_exponent: LINEAR_BOUND, rationale: "#1084: `Checker::is_else_if` reads the enclosing \ `else` clause off the walker's ancestor chain. \ @@ -531,7 +532,7 @@ pub const PROBES: &[Probe] = &[ reading: |m| m.loc.lloc(), }, render: nested_whiles, - depths: LINEAR_DEPTHS, + sizes: LINEAR_DEPTHS, max_exponent: LINEAR_BOUND, rationale: "Shape control for `loc/nested-declaration`: the same \ nesting with no `declaration` node, so `loc` never \ @@ -546,7 +547,7 @@ pub const PROBES: &[Probe] = &[ reading: |m| m.loc.lloc(), }, render: nested_declarations, - depths: LINEAR_DEPTHS, + sizes: LINEAR_DEPTHS, max_exponent: LINEAR_BOUND, rationale: "#1084: `loc`'s C-family arm calls \ `Node::count_specific_ancestors` for every \ @@ -566,7 +567,7 @@ pub const PROBES: &[Probe] = &[ reading: |m| m.halstead.length(), }, render: nested_parens, - depths: LINEAR_DEPTHS, + sizes: LINEAR_DEPTHS, max_exponent: LINEAR_BOUND, rationale: "Shape control for `halstead/nested-not`: the same \ nesting through `get_op_type` arms that classify a \ @@ -581,7 +582,7 @@ pub const PROBES: &[Probe] = &[ reading: |m| m.halstead.length(), }, render: nested_nots, - depths: LINEAR_DEPTHS, + sizes: LINEAR_DEPTHS, max_exponent: LINEAR_BOUND, rationale: "#1096: `Getter::get_op_type` asks every `!` token \ whether its parent is an inner-doc-comment marker. \ @@ -600,7 +601,7 @@ pub const PROBES: &[Probe] = &[ reading: |m| m.abc.assignments_sum(), }, render: nested_blocks, - depths: LINEAR_DEPTHS, + sizes: LINEAR_DEPTHS, max_exponent: LINEAR_BOUND, rationale: "Shape control for `abc/nested-if`: the same nesting \ with no condition slot, so the C-family container \ @@ -615,7 +616,7 @@ pub const PROBES: &[Probe] = &[ reading: |m| m.abc.conditions_sum(), }, render: nested_ifs, - depths: LINEAR_DEPTHS, + sizes: LINEAR_DEPTHS, max_exponent: LINEAR_BOUND, rationale: "#1096: every `if (…)` head routes its condition \ through the C-family container walker, which seeds \ @@ -635,7 +636,7 @@ pub const PROBES: &[Probe] = &[ reading: |m| m.cyclomatic.cyclomatic_sum(), }, render: nested_ands, - depths: LINEAR_DEPTHS, + sizes: LINEAR_DEPTHS, max_exponent: LINEAR_BOUND, rationale: "Shape control for `cyclomatic/nested-ternary`: the \ same nesting through an arm that counts the token \ @@ -650,7 +651,7 @@ pub const PROBES: &[Probe] = &[ reading: |m| m.cyclomatic.cyclomatic_sum(), }, render: nested_ternaries, - depths: LINEAR_DEPTHS, + sizes: LINEAR_DEPTHS, max_exponent: LINEAR_BOUND, rationale: "#1096: Python's `Cyclomatic` asks every `else` token \ whether it opens a loop or `try` else-clause, through \ @@ -667,7 +668,7 @@ pub const PROBES: &[Probe] = &[ reading: |m| m.loc.lloc(), }, render: nested_quotes, - depths: LINEAR_DEPTHS, + sizes: LINEAR_DEPTHS, max_exponent: LINEAR_BOUND, rationale: "#1096: Elixir's `loc` catch-all arm asks every named \ node whether its parent is a statement container, so \ @@ -685,7 +686,7 @@ pub const PROBES: &[Probe] = &[ reading: |m| m.nom.total(), }, render: nested_quotes, - depths: LINEAR_DEPTHS, + sizes: LINEAR_DEPTHS, max_exponent: LINEAR_BOUND, rationale: "#1084: Elixir's `is_func` asks \ `elixir_is_inside_quote_block` for every `def`. The \ @@ -702,7 +703,7 @@ pub const PROBES: &[Probe] = &[ reading: |m| m.nom.total(), }, render: nested_fns, - depths: LINEAR_DEPTHS, + sizes: LINEAR_DEPTHS, max_exponent: LINEAR_BOUND, rationale: "One `FuncSpace` per level: the space-nesting \ bookkeeping and the recursive `FuncSpace` tree #1056 \ @@ -719,7 +720,7 @@ pub const PROBES: &[Probe] = &[ reading: |m| m.cognitive.cognitive_sum(), }, render: nested_fns, - depths: LINEAR_DEPTHS, + sizes: LINEAR_DEPTHS, max_exponent: LINEAR_BOUND, rationale: "#1062: `increment_function_depth` asks every function \ node whether a function encloses it. The answer now \ @@ -743,7 +744,7 @@ pub const PROBES: &[Probe] = &[ reading: |ops| ops.operands.len() as u64, }, render: nested_fns, - depths: LINEAR_DEPTHS, + sizes: LINEAR_DEPTHS, max_exponent: LINEAR_BOUND, rationale: "#1110: the only probe that runs `ops_inner`, which \ was otherwise unmeasured. It covers the walk — the \ @@ -769,7 +770,7 @@ pub const PROBES: &[Probe] = &[ reading: |m| m.loc.lloc(), }, render: nested_fns, - depths: LINEAR_DEPTHS, + sizes: LINEAR_DEPTHS, max_exponent: LINEAR_BOUND, rationale: "Shape control for `loc/nested-fn-rows`: the same \ function nesting with every level on one physical row, \ @@ -786,7 +787,7 @@ pub const PROBES: &[Probe] = &[ reading: |m| m.loc.ploc(), }, render: nested_fns_by_row, - depths: LINEAR_DEPTHS, + sizes: LINEAR_DEPTHS, max_exponent: LINEAR_BOUND, rationale: "#1109: `Ploc` / `Cloc` union each space's physical-row \ set into its parent, so a row inside `D` nested spaces \ @@ -806,7 +807,7 @@ pub const PROBES: &[Probe] = &[ reading: |m| m.nom.total(), }, render: nested_fns_by_row, - depths: LINEAR_DEPTHS, + sizes: LINEAR_DEPTHS, max_exponent: LINEAR_BOUND, rationale: "Metric control for `loc/nested-fn-rows`: the same \ row-spread nesting under a metric whose merge is a \ @@ -822,7 +823,7 @@ pub const PROBES: &[Probe] = &[ reading: |m| m.nom.total(), }, render: nested_declared_functions, - depths: LINEAR_DEPTHS, + sizes: LINEAR_DEPTHS, max_exponent: LINEAR_BOUND, rationale: "Shape control for `nom/nested-arrow`: one function and \ one `FuncSpace` per level as there, but declared with \ @@ -838,7 +839,7 @@ pub const PROBES: &[Probe] = &[ reading: |m| m.nom.total(), }, render: nested_arrows, - depths: LINEAR_DEPTHS, + sizes: LINEAR_DEPTHS, max_exponent: LINEAR_BOUND, rationale: "#1088: the JS-family `Checker::is_func` / `is_closure` \ decide whether an `arrow_function` is bound to a name by \ @@ -858,7 +859,7 @@ pub const PROBES: &[Probe] = &[ reading: |m| m.nom.total(), }, render: nested_attributed_fns, - depths: LINEAR_DEPTHS, + sizes: LINEAR_DEPTHS, max_exponent: LINEAR_BOUND, rationale: "#1100: under `exclude_tests` the walker asks every \ node whether it opens a test-only subtree, and Rust \ @@ -880,7 +881,7 @@ pub const PROBES: &[Probe] = &[ reading: |m| m.nom.total(), }, render: nested_cfg_predicate, - depths: LINEAR_DEPTHS, + sizes: LINEAR_DEPTHS, max_exponent: LINEAR_BOUND, rationale: "#1105: `cfg(all(all(… test …)))` is classified by a \ string-level mini-parser, which re-scanned each \ @@ -1021,14 +1022,14 @@ mod tests { assert_eq!(count, names.len(), "duplicate probe name in PROBES"); } - /// Depths double, which is what makes the fitted exponent readable + /// Sizes double, which is what makes the fitted exponent readable /// as "cost per doubling". #[test] - fn probe_depths_double() { + fn probe_sizes_double() { for probe in PROBES { - let [a, b, c] = probe.depths; - assert_eq!(b, a * 2, "{}: depths must double", probe.name); - assert_eq!(c, b * 2, "{}: depths must double", probe.name); + let [a, b, c] = probe.sizes; + assert_eq!(b, a * 2, "{}: sizes must double", probe.name); + assert_eq!(c, b * 2, "{}: sizes must double", probe.name); } } } diff --git a/docs/development/benchmarking.md b/docs/development/benchmarking.md index fa2f3d53b..281d91f60 100644 --- a/docs/development/benchmarking.md +++ b/docs/development/benchmarking.md @@ -69,7 +69,7 @@ Output looks like this: ```text cognitive/nested-while exponent 1.15 (bound 1.50) ok - depth bytes median ms min ms max ms ns/byte iter reading + size bytes median ms min ms max ms ns/byte iter reading 1000 14017 0.759 0.723 0.771 54.17 2 500500 2000 28017 1.792 1.658 1.834 63.98 1 2001000 4000 56017 3.723 3.425 3.838 66.46 1 8002000 From 5b0c1630d490924235cd1b2635e4011abb36a8c8 Mon Sep 17 00:00:00 2001 From: Elijah Zupancic Date: Fri, 31 Jul 2026 22:34:54 -0700 Subject: [PATCH 02/36] fix(build): root each excluded crate in its own workspace MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `exclude` denies workspace membership but does not terminate cargo's upward search for a workspace root. In a git worktree under `.claude/worktrees/` that search escapes the worktree and resolves against the main checkout, where the crate's path is neither a member nor excluded, so `cargo metadata` errors. That killed `cargo fmt --all` and every `make pre-commit` stage chained behind it — the validation gate was unavailable in the isolation mode four skills use by default. Six manifests need the table, not the five vendored grammars the issue listed: `enums` fails identically and takes `enums-check` and both `enums-codegen-drift` stages down with it. Also excludes `.claude/worktrees` from the root workspace, so the outer workspace cannot adopt a crate belonging to a worktree checkout, and adds `utils/check-excluded-manifests.py` (wired into `make lint`, pre-commit and CI) so a future excluded crate cannot reintroduce this silently — which is how it went unnoticed until now. Fixes #1145 --- .pre-commit-config.yaml | 21 +++++ AGENTS.md | 3 +- CLAUDE.md | 8 ++ Cargo.toml | 5 + Makefile | 41 ++++++++- enums/Cargo.toml | 8 ++ tree-sitter-ccomment/Cargo.toml | 8 ++ tree-sitter-mozcpp/Cargo.toml | 8 ++ tree-sitter-mozjs/Cargo.toml | 8 ++ tree-sitter-preproc/Cargo.toml | 8 ++ tree-sitter-tcl/Cargo.toml | 8 ++ utils/check-excluded-manifests-test.py | 122 +++++++++++++++++++++++++ utils/check-excluded-manifests.py | 88 ++++++++++++++++++ 13 files changed, 331 insertions(+), 5 deletions(-) create mode 100644 utils/check-excluded-manifests-test.py create mode 100644 utils/check-excluded-manifests.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 691fd6500..14294d108 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -118,6 +118,16 @@ repos: entry: python3 -m unittest -q utils/check-versions-test.py pass_filenames: false + # Self-tests for the workspace-exclusion gate. Runs on edits to + # the script or its test file (the unit tests import the module, + # so a logic change there must re-run them). + - id: check-excluded-manifests-test + name: check-excluded-manifests-test + language: system + files: '^utils/check-excluded-manifests(-test)?\.py$' + entry: python3 -m unittest -q utils/check-excluded-manifests-test.py + pass_filenames: false + # Sync-test for check-grammar-crate.py's EXTENSIONS table against # src/langs.rs (#869). Re-runs when the script, its test, or the # source-of-truth language table changes. @@ -193,6 +203,17 @@ repos: entry: python3 utils/check-versions.py pass_filenames: false + # Workspace-exclusion gate — every crate in the root + # `[workspace] exclude` array must root its own workspace, or + # cargo's upward search escapes a nested git worktree and + # resolves against the main checkout. See #1145. + - id: check-excluded-manifests + name: check-excluded-manifests + language: system + files: '(^Cargo\.toml|^(enums|tree-sitter-[a-z]+)/Cargo\.toml|^utils/check-excluded-manifests\.py)$' + entry: python3 utils/check-excluded-manifests.py + pass_filenames: false + # Man-page packaging gate — every man/bca*.1 page (the top-level # bca.1 and every bca-*.1 subcommand page) must appear in both # the deb and rpm asset lists of its owning crate (bca-web.1 in diff --git a/AGENTS.md b/AGENTS.md index 75317e60f..5833e2b50 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -62,7 +62,8 @@ and `cargo run -p big-code-analysis-web --`. `check-snapshot-anchors.py`, `check-manpage-assets.py`, `check-grammar-marker-sync.py`, `check-enums-codegen-drift.sh`, `check-grammar-crate.py`, `check-grammars-crates.sh`, - `verify-name-only-churn.py`, and each gate's `*-test.py` self-tests. + `check-excluded-manifests.py`, `verify-name-only-churn.py`, and each + gate's `*-test.py` self-tests. Each resolves the repository root from its own location (`Path(__file__).resolve().parents[1]`) rather than the cwd, so it runs correctly from anywhere; callers invoke them as `utils/`. diff --git a/CLAUDE.md b/CLAUDE.md index a55e67047..acc03e81d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -23,6 +23,14 @@ destroys other agents' in-progress work: - If you see stale worktrees, leave them alone — another agent may be using them, or the user will clean them up manually. +**Run `make py-bootstrap` once per worktree before `make pre-commit`.** +A fresh worktree inherits no `.venv`, so `py-typecheck` reports ~33 +mypy errors (`pytest` untyped without its stubs) and `py-test` dies +with "Couldn't find a virtualenv". Both are bootstrap artifacts, not +regressions. The cargo stages themselves work in a worktree — that +was #1145, fixed by the `[workspace]` tables on the six excluded +crates. + ### Tool choice - **Text search**: built-in `Grep`, or `rg` via Bash. Never `grep`. diff --git a/Cargo.toml b/Cargo.toml index 8199ff07d..8f8700d7e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,6 +27,11 @@ exclude = [ "tree-sitter-mozjs", "tree-sitter-preproc", "tree-sitter-tcl", + # Claude Code worktrees live here. Excluding the directory stops this + # workspace adopting a crate that belongs to a worktree's own checkout + # (#1145); each excluded crate also carries its own `[workspace]` table so + # the same protection holds for a worktree created outside the repository. + ".claude/worktrees", ] [workspace.package] diff --git a/Makefile b/Makefile index fbc597e90..9dde43091 100644 --- a/Makefile +++ b/Makefile @@ -88,7 +88,7 @@ find-by-ext = $(if $(FD),$(FD) --extension $(1) $(FD_EXCLUDE) $(2),find . -name NEXTEST := $(shell command -v cargo-nextest 2>/dev/null) TEST_CMD = $(if $(NEXTEST),$(NEXTEST) nextest run --workspace --all-features,cargo test --workspace --all-features --lib --bins --tests) -.PHONY: help check-tools build build-release check test test-doc chain-audit fmt fmt-check markdown-fmt markdown-lint shellcheck sh-fmt sh-fmt-check toml-fmt toml-fmt-check toml-lint makefile-check actionlint snapshot-anchors grammar-marker-sync grammar-marker-sync-test check-versions check-manpage-assets enums-check enums-codegen-drift enums-codegen-drift-test self-scan self-scan-headroom self-scan-write-baseline self-scan-write-baseline-headroom vcs lint clippy udeps insta-review insta-accept clean distclean install install-cli install-web doc doc-open doc-check doc-check-docsrs book book-serve book-pot book-po-update book-ja book-deploy all pre-commit ci release-check verify-changelog pkg-deb-local pkg-rpm-local dev-env-build dev-env-run dev-env-shell dev-env-rm py-bootstrap py-sync py-relock py-clean py-fmt py-fmt-check py-lint py-typecheck py-test py-stubtest smoke smoke-cli smoke-lib bench bench-scaling bench-walk _check-find _pc-fmt _pc-clippy _pc-test _pc-doc-check _pc-udeps _pc-shellcheck _pc-markdown-lint _pc-toml-lint _pc-makefile-check _pc-actionlint _pc-snapshot-anchors _pc-grammar-marker-sync _pc-grammar-marker-sync-test _pc-check-versions _pc-check-versions-test _pc-check-grammar-crate-test _pc-check-manpage-assets _pc-enums-check _pc-enums-codegen-drift _pc-enums-codegen-drift-test _pc-self-scan _pc-self-scan-headroom _pc-py-fmt _pc-py-typecheck _pc-py-test _pc-py-stubtest _ci-fmt-check _ci-clippy _ci-test _ci-doc-check _ci-build _ci-udeps _ci-shellcheck _ci-markdown-lint _ci-toml-lint _ci-makefile-check _ci-actionlint _ci-snapshot-anchors _ci-grammar-marker-sync _ci-grammar-marker-sync-test _ci-check-versions _ci-check-versions-test _ci-check-grammar-crate-test _ci-check-manpage-assets _ci-enums-check _ci-enums-codegen-drift _ci-enums-codegen-drift-test _ci-enums-codegen-drift-test _ci-self-scan _ci-self-scan-headroom _ci-cargo-pipeline _ci-py-fmt-check _ci-py-lint _ci-py-typecheck _ci-py-test _ci-py-stubtest +.PHONY: help check-tools build build-release check test test-doc chain-audit fmt fmt-check markdown-fmt markdown-lint shellcheck sh-fmt sh-fmt-check toml-fmt toml-fmt-check toml-lint makefile-check actionlint snapshot-anchors grammar-marker-sync grammar-marker-sync-test check-versions check-excluded-manifests check-excluded-manifests-test check-manpage-assets enums-check enums-codegen-drift enums-codegen-drift-test self-scan self-scan-headroom self-scan-write-baseline self-scan-write-baseline-headroom vcs lint clippy udeps insta-review insta-accept clean distclean install install-cli install-web doc doc-open doc-check doc-check-docsrs book book-serve book-pot book-po-update book-ja book-deploy all pre-commit ci release-check verify-changelog pkg-deb-local pkg-rpm-local dev-env-build dev-env-run dev-env-shell dev-env-rm py-bootstrap py-sync py-relock py-clean py-fmt py-fmt-check py-lint py-typecheck py-test py-stubtest smoke smoke-cli smoke-lib bench bench-scaling bench-walk _check-find _pc-fmt _pc-clippy _pc-test _pc-doc-check _pc-udeps _pc-shellcheck _pc-markdown-lint _pc-toml-lint _pc-makefile-check _pc-actionlint _pc-snapshot-anchors _pc-grammar-marker-sync _pc-grammar-marker-sync-test _pc-check-versions _pc-check-versions-test _pc-check-grammar-crate-test _pc-check-excluded-manifests _pc-check-excluded-manifests-test _pc-check-manpage-assets _pc-enums-check _pc-enums-codegen-drift _pc-enums-codegen-drift-test _pc-self-scan _pc-self-scan-headroom _pc-py-fmt _pc-py-typecheck _pc-py-test _pc-py-stubtest _ci-fmt-check _ci-clippy _ci-test _ci-doc-check _ci-build _ci-udeps _ci-shellcheck _ci-markdown-lint _ci-toml-lint _ci-makefile-check _ci-actionlint _ci-snapshot-anchors _ci-grammar-marker-sync _ci-grammar-marker-sync-test _ci-check-versions _ci-check-versions-test _ci-check-grammar-crate-test _ci-check-excluded-manifests _ci-check-excluded-manifests-test _ci-check-manpage-assets _ci-enums-check _ci-enums-codegen-drift _ci-enums-codegen-drift-test _ci-enums-codegen-drift-test _ci-self-scan _ci-self-scan-headroom _ci-cargo-pipeline _ci-py-fmt-check _ci-py-lint _ci-py-typecheck _ci-py-test _ci-py-stubtest # Default target help: @@ -132,6 +132,8 @@ help: @echo " check-versions Enforce lockstep version invariant across owned crates" @echo " check-versions-test Self-tests for the check-versions gate" @echo " check-grammar-crate-test Sync-test EXTENSIONS table vs src/langs.rs" + @echo " check-excluded-manifests Assert workspace-excluded crates root their own workspace" + @echo " check-excluded-manifests-test Self-tests for the check-excluded-manifests gate" @echo " check-manpage-assets Assert every bca-*.1 man page is in deb+rpm asset lists" @echo " enums-check cargo clippy + cargo test on workspace-excluded enums crate" @echo " enums-codegen-drift Block enums codegen output drifting from checked-in files" @@ -424,6 +426,25 @@ check-versions-test: # Man-page packaging gate. Blocks the failure mode from #444: # a bca subcommand man page that drops out of the hand-maintained +# Workspace-exclusion gate. Closes #1145: a crate in the root +# `[workspace] exclude` array must root its own workspace, because +# `exclude` denies membership without stopping cargo's upward search +# for a workspace root. In a git worktree under `.claude/worktrees/` +# that search escapes the worktree and resolves against the main +# checkout, breaking `cargo fmt --all` and every `make pre-commit` +# stage chained behind it. Static lint — no network, no cargo. +check-excluded-manifests: + @echo "Checking workspace-excluded manifests..." + @python3 $(BASE_DIR)utils/check-excluded-manifests.py + +# Self-tests for the workspace-exclusion gate. Kept separate from the +# gate target (matching the check-versions-test pattern) so the gate +# stays a one-line invariant check and test failures get their own +# clean parallel-arm output in the pre-commit/CI DAG. +check-excluded-manifests-test: + @echo "Running check-excluded-manifests self-tests..." + @(cd $(BASE_DIR) && python3 -m unittest -q utils/check-excluded-manifests-test.py) + # deb/rpm asset lists in the CLI / web crate Cargo.toml. Static # lint — no network, no cargo, runs in milliseconds. See #446. check-manpage-assets: @@ -874,7 +895,7 @@ lint: $(MAKE) -j --output-sync=target \ _ci-clippy \ _ci-shellcheck _ci-markdown-lint _ci-toml-lint _ci-makefile-check \ - _ci-actionlint _ci-snapshot-anchors _ci-grammar-marker-sync _ci-grammar-marker-sync-test _ci-check-versions _ci-check-versions-test _ci-check-grammar-crate-test _ci-check-manpage-assets _ci-enums-check _ci-enums-codegen-drift _ci-enums-codegen-drift-test + _ci-actionlint _ci-snapshot-anchors _ci-grammar-marker-sync _ci-grammar-marker-sync-test _ci-check-versions _ci-check-versions-test _ci-check-grammar-crate-test _ci-check-excluded-manifests _ci-check-excluded-manifests-test _ci-check-manpage-assets _ci-enums-check _ci-enums-codegen-drift _ci-enums-codegen-drift-test # --------------------------------------------------------------------------- # Maintenance @@ -1019,7 +1040,7 @@ pre-commit: $(MAKE) -j --output-sync=target \ _pc-test \ _pc-shellcheck _pc-markdown-lint _pc-toml-lint _pc-makefile-check \ - _pc-actionlint _pc-snapshot-anchors _pc-grammar-marker-sync _pc-grammar-marker-sync-test _pc-check-versions _pc-check-versions-test _pc-check-grammar-crate-test _pc-check-manpage-assets _pc-enums-check _pc-enums-codegen-drift _pc-enums-codegen-drift-test \ + _pc-actionlint _pc-snapshot-anchors _pc-grammar-marker-sync _pc-grammar-marker-sync-test _pc-check-versions _pc-check-versions-test _pc-check-grammar-crate-test _pc-check-excluded-manifests _pc-check-excluded-manifests-test _pc-check-manpage-assets _pc-enums-check _pc-enums-codegen-drift _pc-enums-codegen-drift-test \ _pc-manpages \ _pc-self-scan _pc-self-scan-headroom \ _pc-py-fmt _pc-py-typecheck _pc-py-test _pc-py-stubtest @@ -1030,7 +1051,7 @@ ci: $(MAKE) -j --output-sync=target \ _ci-cargo-pipeline \ _ci-shellcheck _ci-markdown-lint _ci-toml-lint _ci-makefile-check \ - _ci-actionlint _ci-snapshot-anchors _ci-grammar-marker-sync _ci-grammar-marker-sync-test _ci-check-versions _ci-check-versions-test _ci-check-grammar-crate-test _ci-check-manpage-assets _ci-enums-check _ci-enums-codegen-drift _ci-enums-codegen-drift-test \ + _ci-actionlint _ci-snapshot-anchors _ci-grammar-marker-sync _ci-grammar-marker-sync-test _ci-check-versions _ci-check-versions-test _ci-check-grammar-crate-test _ci-check-excluded-manifests _ci-check-excluded-manifests-test _ci-check-manpage-assets _ci-enums-check _ci-enums-codegen-drift _ci-enums-codegen-drift-test \ _ci-py-fmt-check _ci-py-lint _ci-py-typecheck _ci-py-test _ci-py-stubtest @echo "CI checks passed" @@ -1143,6 +1164,12 @@ _pc-check-versions-test: _pc-fmt _pc-check-grammar-crate-test: _pc-fmt $(MAKE) check-grammar-crate-test +_pc-check-excluded-manifests: _pc-fmt + $(MAKE) check-excluded-manifests + +_pc-check-excluded-manifests-test: _pc-fmt + $(MAKE) check-excluded-manifests-test + _pc-check-manpage-assets: _pc-fmt $(MAKE) check-manpage-assets @@ -1294,6 +1321,12 @@ _ci-check-versions-test: _ci-check-grammar-crate-test: $(MAKE) check-grammar-crate-test +_ci-check-excluded-manifests: + $(MAKE) check-excluded-manifests + +_ci-check-excluded-manifests-test: + $(MAKE) check-excluded-manifests-test + _ci-check-manpage-assets: $(MAKE) check-manpage-assets diff --git a/enums/Cargo.toml b/enums/Cargo.toml index 5662949c0..2e6888fcd 100644 --- a/enums/Cargo.toml +++ b/enums/Cargo.toml @@ -45,3 +45,11 @@ tree-sitter-mozjs = { package = "bca-tree-sitter-mozjs", path = "../tree-sitter- [profile.release] debug = "line-tables-only" + +# Standalone workspace root. The root manifest `exclude`s this crate, but +# `exclude` denies membership without terminating cargo's upward search for a +# workspace root. Inside a git worktree under `.claude/worktrees/` that search +# escapes the worktree and lands on the main checkout's manifest, where this +# path is neither a member nor excluded — breaking `cargo fmt --all` and every +# `make pre-commit` stage that depends on it (#1145). +[workspace] diff --git a/tree-sitter-ccomment/Cargo.toml b/tree-sitter-ccomment/Cargo.toml index c7b1ba01f..d7f2527f2 100644 --- a/tree-sitter-ccomment/Cargo.toml +++ b/tree-sitter-ccomment/Cargo.toml @@ -36,3 +36,11 @@ cc = "^1.2" [dev-dependencies] tree-sitter = "=0.26.11" + +# Standalone workspace root. The root manifest `exclude`s this crate, but +# `exclude` denies membership without terminating cargo's upward search for a +# workspace root. Inside a git worktree under `.claude/worktrees/` that search +# escapes the worktree and lands on the main checkout's manifest, where this +# path is neither a member nor excluded — breaking `cargo fmt --all` and every +# `make pre-commit` stage that depends on it (#1145). +[workspace] \ No newline at end of file diff --git a/tree-sitter-mozcpp/Cargo.toml b/tree-sitter-mozcpp/Cargo.toml index e586da6aa..5e48c3878 100644 --- a/tree-sitter-mozcpp/Cargo.toml +++ b/tree-sitter-mozcpp/Cargo.toml @@ -48,3 +48,11 @@ build = ["tree-sitter-cpp"] [dev-dependencies] tree-sitter = "=0.26.11" + +# Standalone workspace root. The root manifest `exclude`s this crate, but +# `exclude` denies membership without terminating cargo's upward search for a +# workspace root. Inside a git worktree under `.claude/worktrees/` that search +# escapes the worktree and lands on the main checkout's manifest, where this +# path is neither a member nor excluded — breaking `cargo fmt --all` and every +# `make pre-commit` stage that depends on it (#1145). +[workspace] \ No newline at end of file diff --git a/tree-sitter-mozjs/Cargo.toml b/tree-sitter-mozjs/Cargo.toml index 35ed98ec2..0875c0b89 100644 --- a/tree-sitter-mozjs/Cargo.toml +++ b/tree-sitter-mozjs/Cargo.toml @@ -50,3 +50,11 @@ build = ["tree-sitter-javascript"] [dev-dependencies] tree-sitter = "=0.26.11" + +# Standalone workspace root. The root manifest `exclude`s this crate, but +# `exclude` denies membership without terminating cargo's upward search for a +# workspace root. Inside a git worktree under `.claude/worktrees/` that search +# escapes the worktree and lands on the main checkout's manifest, where this +# path is neither a member nor excluded — breaking `cargo fmt --all` and every +# `make pre-commit` stage that depends on it (#1145). +[workspace] \ No newline at end of file diff --git a/tree-sitter-preproc/Cargo.toml b/tree-sitter-preproc/Cargo.toml index 33321ce59..ff5f7c1b2 100644 --- a/tree-sitter-preproc/Cargo.toml +++ b/tree-sitter-preproc/Cargo.toml @@ -36,3 +36,11 @@ cc = "^1.2" [dev-dependencies] tree-sitter = "=0.26.11" + +# Standalone workspace root. The root manifest `exclude`s this crate, but +# `exclude` denies membership without terminating cargo's upward search for a +# workspace root. Inside a git worktree under `.claude/worktrees/` that search +# escapes the worktree and lands on the main checkout's manifest, where this +# path is neither a member nor excluded — breaking `cargo fmt --all` and every +# `make pre-commit` stage that depends on it (#1145). +[workspace] \ No newline at end of file diff --git a/tree-sitter-tcl/Cargo.toml b/tree-sitter-tcl/Cargo.toml index 1f8e0dde4..85e7d753c 100644 --- a/tree-sitter-tcl/Cargo.toml +++ b/tree-sitter-tcl/Cargo.toml @@ -47,3 +47,11 @@ cc = "^1.2" [dev-dependencies] tree-sitter = "=0.26.11" + +# Standalone workspace root. The root manifest `exclude`s this crate, but +# `exclude` denies membership without terminating cargo's upward search for a +# workspace root. Inside a git worktree under `.claude/worktrees/` that search +# escapes the worktree and lands on the main checkout's manifest, where this +# path is neither a member nor excluded — breaking `cargo fmt --all` and every +# `make pre-commit` stage that depends on it (#1145). +[workspace] \ No newline at end of file diff --git a/utils/check-excluded-manifests-test.py b/utils/check-excluded-manifests-test.py new file mode 100644 index 000000000..1dba3b9fc --- /dev/null +++ b/utils/check-excluded-manifests-test.py @@ -0,0 +1,122 @@ +#!/usr/bin/env python3 +"""Tests for check-excluded-manifests.py. + +Two kinds of test, matching the check-versions-test.py pattern: + +* Unit tests against synthetic manifests, including the exact shapes + that must **not** count as a workspace root — ``[workspace.package]`` + and ``[workspace.dependencies]`` both start with the same eleven + characters, and neither terminates cargo's upward search. +* A smoke test running the real gate against the real repository, + asserting a clean tree reports OK. + +Run with: + python3 -m unittest -q utils/check-excluded-manifests-test.py +""" + +from __future__ import annotations + +import importlib.util +import pathlib +import subprocess +import sys +import tempfile +import unittest + +UTILS_DIR = pathlib.Path(__file__).resolve().parent +REPO_ROOT = UTILS_DIR.parent +SCRIPT_SRC = UTILS_DIR / "check-excluded-manifests.py" + + +def _load_module(): # type: ignore[no-untyped-def] + spec = importlib.util.spec_from_file_location("check_excluded_manifests", SCRIPT_SRC) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +GATE = _load_module() + +ROOT_MANIFEST_SAMPLE = """\ +[workspace] +members = [ + "big-code-analysis-cli", +] +exclude = [ + "enums", + "tree-sitter-tcl", + # Claude Code worktrees live here. + ".claude/worktrees", +] + +[workspace.package] +version = "2.1.0" +""" + + +class ReadExcludedCratesTest(unittest.TestCase): + def test_reads_crate_paths_and_drops_the_worktree_directory(self) -> None: + self.assertEqual( + GATE.read_excluded_crates(ROOT_MANIFEST_SAMPLE), + ["enums", "tree-sitter-tcl"], + ) + + def test_does_not_pick_up_the_members_array(self) -> None: + # `members` sits above `exclude` and holds a quoted entry too; a + # regex anchored on the wrong array would return it. + self.assertNotIn( + "big-code-analysis-cli", GATE.read_excluded_crates(ROOT_MANIFEST_SAMPLE) + ) + + def test_absent_exclude_array_is_a_hard_error(self) -> None: + with self.assertRaises(SystemExit): + GATE.read_excluded_crates('[workspace]\nmembers = ["a"]\n') + + +class MissingWorkspaceTableTest(unittest.TestCase): + def _root_with(self, manifest_body: str) -> pathlib.Path: + root = pathlib.Path(self.enterContext(tempfile.TemporaryDirectory())) + (root / "grammar").mkdir() + (root / "grammar" / "Cargo.toml").write_text(manifest_body, encoding="utf-8") + return root + + def test_manifest_with_a_workspace_table_passes(self) -> None: + root = self._root_with('[package]\nname = "g"\n\n[workspace]\n') + self.assertEqual(GATE.missing_workspace_table(["grammar"], root), []) + + def test_manifest_without_one_is_reported(self) -> None: + root = self._root_with('[package]\nname = "g"\n') + self.assertEqual(GATE.missing_workspace_table(["grammar"], root), ["grammar"]) + + def test_workspace_package_does_not_count_as_a_workspace_root(self) -> None: + # The failure this gate exists to catch: `[workspace.package]` + # and `[workspace.dependencies]` share a prefix with the bare + # table but do not stop cargo's upward search. + for header in ("[workspace.package]", "[workspace.dependencies]"): + with self.subTest(header=header): + root = self._root_with(f'[package]\nname = "g"\n\n{header}\nx = 1\n') + self.assertEqual( + GATE.missing_workspace_table(["grammar"], root), ["grammar"] + ) + + def test_missing_manifest_is_a_hard_error(self) -> None: + root = pathlib.Path(self.enterContext(tempfile.TemporaryDirectory())) + with self.assertRaises(SystemExit): + GATE.missing_workspace_table(["absent"], root) + + +class RealRepositoryTest(unittest.TestCase): + def test_clean_tree_passes(self) -> None: + result = subprocess.run( + [sys.executable, str(SCRIPT_SRC)], + capture_output=True, + text=True, + cwd=REPO_ROOT, + ) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("Excluded manifests OK", result.stdout) + + +if __name__ == "__main__": + unittest.main() diff --git a/utils/check-excluded-manifests.py b/utils/check-excluded-manifests.py new file mode 100644 index 000000000..bcb8e7b4d --- /dev/null +++ b/utils/check-excluded-manifests.py @@ -0,0 +1,88 @@ +#!/usr/bin/env python3 +"""check-excluded-manifests + +Invariants for the crates listed in the root manifest's +``[workspace] exclude`` array — the five vendored ``tree-sitter-*`` +grammars and the ``enums`` codegen helper. + +Every excluded crate must declare its own ``[workspace]`` table. +``exclude`` denies workspace membership but does **not** terminate +cargo's upward search for a workspace root. In a git worktree under +``.claude/worktrees/`` that search escapes the worktree and lands on the +main checkout's manifest, where the crate's path is neither a member nor +excluded — so ``cargo metadata`` errors, taking ``cargo fmt --all`` and +every ``make pre-commit`` stage chained behind it with it (#1145). + +See the `#1145` issue for the worktree traversal this guards. +""" + +from __future__ import annotations + +import pathlib +import re +import sys + +# `parents[1]`, not `parent`: this gate lives in `utils/` but every path +# it reads is anchored at the repository root. +REPO_ROOT = pathlib.Path(__file__).resolve().parents[1] +ROOT_MANIFEST = REPO_ROOT / "Cargo.toml" + +# Excluded entries that name a directory rather than a crate. These have +# no manifest of their own and are exempt from every check below. +NON_CRATE_EXCLUDES = frozenset({".claude/worktrees"}) + +EXCLUDE_ARRAY_RE = re.compile(r"^exclude\s*=\s*\[(.*?)^\]", re.DOTALL | re.MULTILINE) +QUOTED_ENTRY_RE = re.compile(r'"([^"]+)"') +# A bare `[workspace]` table header, not `[workspace.package]` or +# `[workspace.dependencies]` — only the bare form roots a workspace. +WORKSPACE_TABLE_RE = re.compile(r"^\s*\[workspace\]\s*$", re.MULTILINE) + + +def read_excluded_crates(manifest_text: str) -> list[str]: + """Return the crate paths in the root manifest's ``exclude`` array. + + Directory entries listed in :data:`NON_CRATE_EXCLUDES` are dropped — + they gate cargo's search but carry no manifest to check. + """ + match = EXCLUDE_ARRAY_RE.search(manifest_text) + if match is None: + raise SystemExit( + "error: could not locate the [workspace] exclude array in Cargo.toml" + ) + entries = QUOTED_ENTRY_RE.findall(match.group(1)) + return [entry for entry in entries if entry not in NON_CRATE_EXCLUDES] + + +def missing_workspace_table(crates: list[str], root: pathlib.Path) -> list[str]: + """Return excluded crates whose manifest lacks a ``[workspace]`` table.""" + offenders = [] + for crate in crates: + manifest = root / crate / "Cargo.toml" + if not manifest.is_file(): + raise SystemExit(f"error: excluded crate has no manifest: {manifest}") + if WORKSPACE_TABLE_RE.search(manifest.read_text(encoding="utf-8")) is None: + offenders.append(crate) + return offenders + + +def main() -> int: + crates = read_excluded_crates(ROOT_MANIFEST.read_text(encoding="utf-8")) + offenders = missing_workspace_table(crates, REPO_ROOT) + if offenders: + print( + "error: excluded crates missing a [workspace] table:\n" + + "\n".join(f" {crate}/Cargo.toml" for crate in offenders) + + "\n\nWithout it, cargo's upward workspace search escapes a git\n" + "worktree under .claude/worktrees/ and resolves against the main\n" + "checkout, breaking `cargo fmt --all` and `make pre-commit` there\n" + "(#1145). Append an empty `[workspace]` table to each manifest.", + file=sys.stderr, + ) + return 1 + + print(f"Excluded manifests OK ({len(crates)} crates checked).") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) From f0edcf51a7ec5625591db5d493929f3df9a15734 Mon Sep 17 00:00:00 2001 From: Elijah Zupancic Date: Fri, 31 Jul 2026 22:35:15 -0700 Subject: [PATCH 03/36] chore(grammars): pin vendored grammar deps with = requirements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `AGENTS.md` requires external grammar crates to carry `=X.Y.Z` pins, so a plain `cargo update` cannot move a grammar silently and a downstream consumer of the published `bca-tree-sitter-*` crates cannot resolve one freely. Two vendored manifests used caret ranges: `tree-sitter-cpp` in mozcpp (the crate #86 exists to gate) and `tree-sitter-javascript` in mozjs. `tree-sitter-language` stays caret-ranged, against the issue's table. It is the ecosystem's shared `LanguageFn` trait shim rather than a grammar, so the rule does not reach it, and pinning it is unworkable in both directions: `tree-sitter-irules 0.1.1` requires `^0.1.7` and cargo unifies 0.1.x deps, so `=0.1.0` makes the workspace unresolvable; and an `=` pin on a shim every grammar depends on would break resolution for downstream consumers of these published crates. Recorded as an explicit carve-out in AGENTS.md and the gate's PIN_EXEMPT_DEPS. The issue also listed it for tcl alone — it is caret-ranged in all five vendored manifests, four of them spelled without spaces around `=`. `check-excluded-manifests.py` grows the pin check so a future vendored grammar cannot reintroduce the drift. `check-grammar-marker-sync.py` now strips a leading `=` before comparing: its baseline records which upstream version the vendored sources were generated from, so `=0.23.4` and `0.23.4` name the same thing and the literal comparison reported drift for a change that touched no generated byte. Fixes #1151 --- AGENTS.md | 13 ++++- tree-sitter-ccomment/Cargo.toml | 11 +++- tree-sitter-mozcpp/Cargo.toml | 13 ++++- tree-sitter-mozjs/Cargo.toml | 13 ++++- tree-sitter-preproc/Cargo.toml | 11 +++- tree-sitter-tcl/Cargo.toml | 11 +++- utils/check-excluded-manifests-test.py | 58 +++++++++++++++++++ utils/check-excluded-manifests.py | 75 ++++++++++++++++++++++++- utils/check-grammar-marker-sync-test.py | 22 ++++++++ utils/check-grammar-marker-sync.py | 10 +++- 10 files changed, 226 insertions(+), 11 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 5833e2b50..57bafb951 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -503,7 +503,18 @@ weigh them, do not drive them to zero at any cost. ## Tree-sitter grammars External grammar crates are version-pinned (`=0.23.5`, `=0.26.10`, -etc.) in the root `Cargo.toml`. Treat the pinned version as fixed: +etc.) in the root `Cargo.toml` **and in each vendored crate's own +manifest** — `utils/check-excluded-manifests.py` enforces both, so a +new vendored grammar cannot reintroduce a caret range (#1151). + +One deliberate exception: `tree-sitter-language`, the ecosystem's shared +`LanguageFn` trait shim, is **not** a grammar and must stay caret-ranged. +`=`-pinning it makes the workspace unresolvable (`tree-sitter-irules +0.1.1` requires `^0.1.7`, and cargo unifies 0.1.x deps) and would break +downstream consumers of the published `bca-tree-sitter-*` crates. The +gate carries it in `PIN_EXEMPT_DEPS`. + +Treat the pinned version as fixed: - Do not loosen pins to a range without explicit user approval. - Bumping a grammar version is a deliberate, separate change — usually diff --git a/tree-sitter-ccomment/Cargo.toml b/tree-sitter-ccomment/Cargo.toml index d7f2527f2..be6a2c9fd 100644 --- a/tree-sitter-ccomment/Cargo.toml +++ b/tree-sitter-ccomment/Cargo.toml @@ -29,6 +29,15 @@ name = "tree_sitter_ccomment" path = "bindings/rust/lib.rs" [dependencies] +# `tree-sitter-language` is deliberately NOT `=`-pinned. It is the +# ecosystem's shared `LanguageFn` trait shim, not a grammar, so +# AGENTS.md's grammar-pinning rule does not reach it — and pinning it +# is actively harmful twice over. Locally, `tree-sitter-irules 0.1.1` +# requires `^0.1.7`, and cargo unifies 0.1.x deps, so `=0.1.0` makes +# the workspace unresolvable. Downstream, this crate is published: an +# `=` pin on a shim every grammar depends on would break resolution +# for any consumer pairing it with a grammar wanting a newer 0.1.x. +# Measured on #1151; the issue's table listed this row in error. tree-sitter-language="0.1.0" [build-dependencies] @@ -43,4 +52,4 @@ tree-sitter = "=0.26.11" # escapes the worktree and lands on the main checkout's manifest, where this # path is neither a member nor excluded — breaking `cargo fmt --all` and every # `make pre-commit` stage that depends on it (#1145). -[workspace] \ No newline at end of file +[workspace] diff --git a/tree-sitter-mozcpp/Cargo.toml b/tree-sitter-mozcpp/Cargo.toml index 5e48c3878..60184c11e 100644 --- a/tree-sitter-mozcpp/Cargo.toml +++ b/tree-sitter-mozcpp/Cargo.toml @@ -29,6 +29,15 @@ name = "tree_sitter_mozcpp" path = "bindings/rust/lib.rs" [dependencies] +# `tree-sitter-language` is deliberately NOT `=`-pinned. It is the +# ecosystem's shared `LanguageFn` trait shim, not a grammar, so +# AGENTS.md's grammar-pinning rule does not reach it — and pinning it +# is actively harmful twice over. Locally, `tree-sitter-irules 0.1.1` +# requires `^0.1.7`, and cargo unifies 0.1.x deps, so `=0.1.0` makes +# the workspace unresolvable. Downstream, this crate is published: an +# `=` pin on a shim every grammar depends on would break resolution +# for any consumer pairing it with a grammar wanting a newer 0.1.x. +# Measured on #1151; the issue's table listed this row in error. tree-sitter-language="0.1.0" [build-dependencies] @@ -41,7 +50,7 @@ cc = "^1.2" # MUST be accompanied by re-running `./generate-grammars/generate-mozcpp.sh` # and committing the regenerated sources in the same PR. See #400 for the # audit trail on the related tree-sitter-mozjs marker. -tree-sitter-cpp = "0.23.4" +tree-sitter-cpp = "=0.23.4" [package.metadata.cargo-udeps.ignore] build = ["tree-sitter-cpp"] @@ -55,4 +64,4 @@ tree-sitter = "=0.26.11" # escapes the worktree and lands on the main checkout's manifest, where this # path is neither a member nor excluded — breaking `cargo fmt --all` and every # `make pre-commit` stage that depends on it (#1145). -[workspace] \ No newline at end of file +[workspace] diff --git a/tree-sitter-mozjs/Cargo.toml b/tree-sitter-mozjs/Cargo.toml index 0875c0b89..0235f6ba2 100644 --- a/tree-sitter-mozjs/Cargo.toml +++ b/tree-sitter-mozjs/Cargo.toml @@ -29,6 +29,15 @@ name = "tree_sitter_mozjs" path = "bindings/rust/lib.rs" [dependencies] +# `tree-sitter-language` is deliberately NOT `=`-pinned. It is the +# ecosystem's shared `LanguageFn` trait shim, not a grammar, so +# AGENTS.md's grammar-pinning rule does not reach it — and pinning it +# is actively harmful twice over. Locally, `tree-sitter-irules 0.1.1` +# requires `^0.1.7`, and cargo unifies 0.1.x deps, so `=0.1.0` makes +# the workspace unresolvable. Downstream, this crate is published: an +# `=` pin on a shim every grammar depends on would break resolution +# for any consumer pairing it with a grammar wanting a newer 0.1.x. +# Measured on #1151; the issue's table listed this row in error. tree-sitter-language="0.1.0" [build-dependencies] @@ -43,7 +52,7 @@ cc = "^1.2" # was last refreshed when the marker was at 0.23.1 (#1141, 2025-03-30); see # #400 for the audit trail confirming the 0.23.1 → 0.25.0 marker bump did # not include the matching regen. -tree-sitter-javascript = "0.25.0" +tree-sitter-javascript = "=0.25.0" [package.metadata.cargo-udeps.ignore] build = ["tree-sitter-javascript"] @@ -57,4 +66,4 @@ tree-sitter = "=0.26.11" # escapes the worktree and lands on the main checkout's manifest, where this # path is neither a member nor excluded — breaking `cargo fmt --all` and every # `make pre-commit` stage that depends on it (#1145). -[workspace] \ No newline at end of file +[workspace] diff --git a/tree-sitter-preproc/Cargo.toml b/tree-sitter-preproc/Cargo.toml index ff5f7c1b2..1e1ca368b 100644 --- a/tree-sitter-preproc/Cargo.toml +++ b/tree-sitter-preproc/Cargo.toml @@ -29,6 +29,15 @@ name = "tree_sitter_preproc" path = "bindings/rust/lib.rs" [dependencies] +# `tree-sitter-language` is deliberately NOT `=`-pinned. It is the +# ecosystem's shared `LanguageFn` trait shim, not a grammar, so +# AGENTS.md's grammar-pinning rule does not reach it — and pinning it +# is actively harmful twice over. Locally, `tree-sitter-irules 0.1.1` +# requires `^0.1.7`, and cargo unifies 0.1.x deps, so `=0.1.0` makes +# the workspace unresolvable. Downstream, this crate is published: an +# `=` pin on a shim every grammar depends on would break resolution +# for any consumer pairing it with a grammar wanting a newer 0.1.x. +# Measured on #1151; the issue's table listed this row in error. tree-sitter-language="0.1.0" [build-dependencies] @@ -43,4 +52,4 @@ tree-sitter = "=0.26.11" # escapes the worktree and lands on the main checkout's manifest, where this # path is neither a member nor excluded — breaking `cargo fmt --all` and every # `make pre-commit` stage that depends on it (#1145). -[workspace] \ No newline at end of file +[workspace] diff --git a/tree-sitter-tcl/Cargo.toml b/tree-sitter-tcl/Cargo.toml index 85e7d753c..122a54e8e 100644 --- a/tree-sitter-tcl/Cargo.toml +++ b/tree-sitter-tcl/Cargo.toml @@ -40,6 +40,15 @@ name = "tree_sitter_tcl" path = "bindings/rust/lib.rs" [dependencies] +# `tree-sitter-language` is deliberately NOT `=`-pinned. It is the +# ecosystem's shared `LanguageFn` trait shim, not a grammar, so +# AGENTS.md's grammar-pinning rule does not reach it — and pinning it +# is actively harmful twice over. Locally, `tree-sitter-irules 0.1.1` +# requires `^0.1.7`, and cargo unifies 0.1.x deps, so `=0.1.0` makes +# the workspace unresolvable. Downstream, this crate is published: an +# `=` pin on a shim every grammar depends on would break resolution +# for any consumer pairing it with a grammar wanting a newer 0.1.x. +# Measured on #1151; the issue's table listed this row in error. tree-sitter-language = "0.1.0" [build-dependencies] @@ -54,4 +63,4 @@ tree-sitter = "=0.26.11" # escapes the worktree and lands on the main checkout's manifest, where this # path is neither a member nor excluded — breaking `cargo fmt --all` and every # `make pre-commit` stage that depends on it (#1145). -[workspace] \ No newline at end of file +[workspace] diff --git a/utils/check-excluded-manifests-test.py b/utils/check-excluded-manifests-test.py index 1dba3b9fc..c14c9f1f9 100644 --- a/utils/check-excluded-manifests-test.py +++ b/utils/check-excluded-manifests-test.py @@ -106,6 +106,64 @@ def test_missing_manifest_is_a_hard_error(self) -> None: GATE.missing_workspace_table(["absent"], root) +class UnpinnedGrammarDepsTest(unittest.TestCase): + def test_equals_pins_pass_in_both_spellings(self) -> None: + manifest = ( + "[dependencies]\n" + 'tree-sitter-language="=0.1.0"\n' + 'tree-sitter-cpp = "=0.23.4"\n' + 'tree-sitter-tcl = { package = "bca-tree-sitter-tcl", ' + 'path = "../tree-sitter-tcl", version = "=2.1.0" }\n' + ) + self.assertEqual(GATE.unpinned_grammar_deps(manifest), []) + + def test_caret_range_without_spaces_is_caught(self) -> None: + # The no-space spelling is the case that matters most: #1151's + # table missed four entries written exactly this way. + self.assertEqual( + GATE.unpinned_grammar_deps('tree-sitter-perl="1.1.2"\n'), + [("tree-sitter-perl", "1.1.2")], + ) + + def test_the_language_shim_is_exempt_from_pinning(self) -> None: + # `tree-sitter-language` must stay caret-ranged: `=0.1.0` makes + # the workspace unresolvable against `tree-sitter-irules`' + # `^0.1.7`, and an `=` pin on a shared shim in a *published* + # crate breaks downstream resolution (#1151). + for spelling in ('tree-sitter-language="0.1.0"', 'tree-sitter-language = "0.1.0"'): + with self.subTest(spelling=spelling): + self.assertEqual(GATE.unpinned_grammar_deps(spelling + "\n"), []) + + def test_caret_range_with_spaces_is_caught(self) -> None: + self.assertEqual( + GATE.unpinned_grammar_deps('tree-sitter-cpp = "0.23.4"\n'), + [("tree-sitter-cpp", "0.23.4")], + ) + + def test_inline_table_without_a_pin_is_caught(self) -> None: + self.assertEqual( + GATE.unpinned_grammar_deps( + 'tree-sitter-tcl = { path = "../x", version = "2.1.0" }\n' + ), + [("tree-sitter-tcl", "2.1.0")], + ) + + def test_non_grammar_dependencies_are_out_of_scope(self) -> None: + # The pinning rule is about grammars; `cc`, `clap` and `askama` + # deliberately float. + manifest = '[build-dependencies]\ncc = "^1.2"\nclap = "^4.0"\naskama = "^0.16"\n' + self.assertEqual(GATE.unpinned_grammar_deps(manifest), []) + + def test_udeps_ignore_entry_is_not_a_dependency(self) -> None: + # `build = ["tree-sitter-cpp"]` under + # [package.metadata.cargo-udeps.ignore] names a grammar but is + # not a version requirement — matching it would be a false hit. + manifest = ( + "[package.metadata.cargo-udeps.ignore]\nbuild = [\"tree-sitter-cpp\"]\n" + ) + self.assertEqual(GATE.unpinned_grammar_deps(manifest), []) + + class RealRepositoryTest(unittest.TestCase): def test_clean_tree_passes(self) -> None: result = subprocess.run( diff --git a/utils/check-excluded-manifests.py b/utils/check-excluded-manifests.py index bcb8e7b4d..6768f0a17 100644 --- a/utils/check-excluded-manifests.py +++ b/utils/check-excluded-manifests.py @@ -5,7 +5,9 @@ ``[workspace] exclude`` array — the five vendored ``tree-sitter-*`` grammars and the ``enums`` codegen helper. -Every excluded crate must declare its own ``[workspace]`` table. +Two invariants are checked. + +**Every excluded crate must declare its own ``[workspace]`` table.** ``exclude`` denies workspace membership but does **not** terminate cargo's upward search for a workspace root. In a git worktree under ``.claude/worktrees/`` that search escapes the worktree and lands on the @@ -13,7 +15,15 @@ excluded — so ``cargo metadata`` errors, taking ``cargo fmt --all`` and every ``make pre-commit`` stage chained behind it with it (#1145). -See the `#1145` issue for the worktree traversal this guards. +**Every tree-sitter dependency must use an ``=X.Y.Z`` pin.** A caret +range lets ``cargo update`` move a grammar silently, and lets a +downstream consumer of the published ``bca-tree-sitter-*`` crates +resolve one freely — the accidental bump the pinning rule exists to +prevent (#1151). Non-grammar dependencies (``cc``, ``clap``, ``askama``) +are out of scope; the rule is about grammars. + +See AGENTS.md "Tree-sitter grammars" for the pinning rule and the +`#1145` issue for the worktree traversal this guards. """ from __future__ import annotations @@ -37,6 +47,32 @@ # `[workspace.dependencies]` — only the bare form roots a workspace. WORKSPACE_TABLE_RE = re.compile(r"^\s*\[workspace\]\s*$", re.MULTILINE) +# A `tree-sitter*` dependency and its version requirement, in either the +# bare-string form (`tree-sitter-cpp = "=0.23.4"`) or the inline-table +# form (`... = { package = "…", version = "=2.1.0" }`). +# +# Whitespace around `=` is optional and inconsistent across these +# manifests — four of the five spell it `tree-sitter-language="0.1.0"`. +# #1151's own table missed exactly those four for that reason, so the +# `\s*` is load-bearing rather than defensive. +GRAMMAR_DEP_RE = re.compile( + r'^\s*(tree-sitter[\w-]*)\s*=\s*(?:"(?P[^"]+)"' + r'|\{[^}]*\bversion\s*=\s*"(?P[^"]+)")', + re.MULTILINE, +) + +# `tree-sitter` dependencies that must NOT carry an `=` pin. +# +# `tree-sitter-language` is the ecosystem's shared `LanguageFn` trait +# shim, not a grammar, so AGENTS.md's pinning rule does not reach it. +# Pinning it breaks resolution in both directions: `tree-sitter-irules +# 0.1.1` requires `^0.1.7` and cargo unifies 0.1.x deps, so `=0.1.0` +# makes this workspace unresolvable; and these crates are published, so +# an `=` pin on a shim every grammar depends on would break downstream +# consumers pairing it with a grammar wanting a newer 0.1.x. Measured +# on #1151, whose table listed it in error. +PIN_EXEMPT_DEPS = frozenset({"tree-sitter-language"}) + def read_excluded_crates(manifest_text: str) -> list[str]: """Return the crate paths in the root manifest's ``exclude`` array. @@ -65,8 +101,21 @@ def missing_workspace_table(crates: list[str], root: pathlib.Path) -> list[str]: return offenders +def unpinned_grammar_deps(manifest_text: str) -> list[tuple[str, str]]: + """Return ``(dependency, requirement)`` pairs not using an ``=`` pin.""" + unpinned = [] + for match in GRAMMAR_DEP_RE.finditer(manifest_text): + dependency = match.group(1) + requirement = match.group("bare") or match.group("table") + if dependency in PIN_EXEMPT_DEPS or requirement.startswith("="): + continue + unpinned.append((dependency, requirement)) + return unpinned + + def main() -> int: crates = read_excluded_crates(ROOT_MANIFEST.read_text(encoding="utf-8")) + offenders = missing_workspace_table(crates, REPO_ROOT) if offenders: print( @@ -80,6 +129,28 @@ def main() -> int: ) return 1 + drifted = [ + (crate, dep, requirement) + for crate in crates + for dep, requirement in unpinned_grammar_deps( + (REPO_ROOT / crate / "Cargo.toml").read_text(encoding="utf-8") + ) + ] + if drifted: + print( + "error: tree-sitter dependencies without an `=X.Y.Z` pin:\n" + + "\n".join( + f" {crate}/Cargo.toml: {dep} = \"{requirement}\"" + f' (should be "={requirement}")' + for crate, dep, requirement in drifted + ) + + "\n\nA caret range lets `cargo update` move a grammar silently and\n" + "lets a downstream consumer of the published crate resolve one\n" + "freely (#1151). See AGENTS.md \"Tree-sitter grammars\".", + file=sys.stderr, + ) + return 1 + print(f"Excluded manifests OK ({len(crates)} crates checked).") return 0 diff --git a/utils/check-grammar-marker-sync-test.py b/utils/check-grammar-marker-sync-test.py index aea996874..b9324dc0e 100755 --- a/utils/check-grammar-marker-sync-test.py +++ b/utils/check-grammar-marker-sync-test.py @@ -175,6 +175,28 @@ def test_baseline_ahead_of_cargo_trips(self) -> None: self.assertEqual(result.returncode, 1) self.assertIn("0.27.0", result.stderr) + def test_equals_pin_matches_a_bare_baseline_version(self) -> None: + # The baseline records which upstream version the vendored + # sources were generated from, so `=0.25.0` and `0.25.0` name + # the same thing. #1151 tightened these pins without touching a + # generated byte and the literal comparison reported drift. + script = _make_fixture( + self.tmpdir, mozjs_version="=0.25.0", baseline=_BASELINE_MATCHING + ) + result = _run(script) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("OK", result.stdout) + + def test_equals_pin_still_trips_on_a_real_version_bump(self) -> None: + # The `=` strip must not swallow drift: an `=`-pinned marker at + # a *different* version is still a bump without a regen. + script = _make_fixture( + self.tmpdir, mozjs_version="=0.26.0", baseline=_BASELINE_MATCHING + ) + result = _run(script) + self.assertEqual(result.returncode, 1) + self.assertIn("0.26.0", result.stderr) + # --- Cargo.toml-side parsing (tomllib path) --- def test_inline_table_marker_form_supported(self) -> None: diff --git a/utils/check-grammar-marker-sync.py b/utils/check-grammar-marker-sync.py index 05d011733..2206cfffa 100755 --- a/utils/check-grammar-marker-sync.py +++ b/utils/check-grammar-marker-sync.py @@ -191,6 +191,13 @@ def read_marker( Returns the version string when the marker is present with a version pin, `_MarkerStatus.NO_VERSION_PIN` when present without one, and `None` when the marker is absent everywhere. + + The returned version is stripped of a leading `=` requirement + operator. The baseline records *which upstream version the + vendored sources were generated from*, so `=0.23.4` and + `0.23.4` name the same thing; comparing the raw requirement + strings reported drift when #1151 tightened the pins without + touching a single generated byte. Raises `CargoTomlParseError` if the manifest can't be read or parsed — main() catches this and surfaces a structured exit-2 message rather than leaking a Python traceback. @@ -207,7 +214,8 @@ def read_marker( raise CargoTomlParseError( f"{manifest} is not valid TOML: {exc}" ) from exc - return _scan_for_marker(data, marker) + found = _scan_for_marker(data, marker) + return found.removeprefix("=") if isinstance(found, str) else found def _coerce_baseline_value( From 78e0a38b6285aab24f1d533d20932370da561da4 Mon Sep 17 00:00:00 2001 From: Elijah Zupancic Date: Fri, 31 Jul 2026 23:00:52 -0700 Subject: [PATCH 04/36] fix(gates): parse manifests with tomllib in check-excluded-manifests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review of the #1145 / #1151 commits found the new gate hand-rolling regexes over TOML, and a set of smaller defects around it. The regex parser silently skipped legal Cargo syntax rather than failing loud. TOML literal strings were invisible: an `exclude` array entry spelled `'enums'` dropped out of the check, and `tree-sitter-cpp = '0.23.4'` read as no dependency at all, so an unpinned grammar passed. A commented-out `exclude` entry came back as a crate path and killed the run on a missing manifest — and that commit had just added the array's first comment. An inline `exclude = ["enums"]` swallowed the entries of any later multi-line array, and an indented or trailing-nothing `exclude` reported "could not locate". The workspace-table probe matched the text `[workspace]` inside a multi-line string and missed `[ workspace ]`. All of it falls out of parsing with `tomllib`, following the `tomli`-fallback import that check-grammar-marker-sync.py already uses. (F3, F4, F6) The stated invariant was also wrong. The comment claimed only a bare `[workspace]` roots a workspace and the test called that "the failure this gate exists to catch". Probed on cargo 1.95.0: a sub-package with no workspace table under an unrelated root exits 101, while `[workspace.package]`, `[workspace.dependencies]` and `[workspace.lints]` each make `cargo metadata` exit 0. Cargo keys on the `workspace` key, not the header. The gate, the comment and the test now state that. (F5) Remaining fixes: - The `tree-sitter[\w-]*` anchor never reached `dekobon-tree-sitter-groovy`, a live entry in the root and `enums` manifests; matching the substring covers it and any future `bca-tree-sitter-*`. (F1) - AGENTS.md claimed the gate enforced the root manifest's ~20 grammar pins; it only walked the excluded crates, so loosening `tree-sitter-python` there was invisible. The root is now scanned too, which makes the sentence true. (F2) - check-grammar-marker-sync.py's `removeprefix("=")` left the space in `"= 0.25.0"` and reported false drift on a spelling the pin gate called compliant. Stripped now; a pin is defined as exactly `=X.Y.Z`, so a compound `=0.25.0, <0.26` is rejected by the pin gate and cannot reach the marker gate. (F7) - `main()` had no test at all, and suggested `should be "=^0.23.4"` / `"=>=0.23, <0.24"` / `"=*"` — requirements cargo rejects. The suggestion is only offered for a bare version now, and both error branches, the multi-offender join and the message text are tested. (F8) - `test_equals_pin_still_trips_on_a_real_version_bump` passed against both implementations, since `"=0.26.0"` contains `"0.26.0"`. It asserts the stripped form, and a spaced-pin sibling was added; both fail when `.strip()` is removed, and nothing else does. (F9) - The `^\s*` dependency-line anchor was untested — removing it failed nothing. A commented-out dependency fixture now covers it, verified against the pre-rewrite script with the anchor removed. (F10) Smaller items: the new Makefile targets had been spliced into the middle of the man-page gate's comment block, splitting both (F11); the pre-commit `files:` regex `tree-sitter-[a-z]+` could not match `tree-sitter-c-sharp/Cargo.toml` (F12); the gate had no defensive twin in the CI lint job, unlike its four siblings (F13); `tree-sitter` the runtime is pin-required while the `tree-sitter-language` shim is exempt, now with the measured reason recorded (F15); and `[workspace]` sat last in all six excluded manifests, where an empty table adopts any bare key appended after it — hoisted above `[package]` (F16). CHANGELOG records #1145 and #1151, both downstream-visible. (F14) --- .github/workflows/ci.yml | 14 ++ .pre-commit-config.yaml | 7 +- AGENTS.md | 25 +- CHANGELOG.md | 30 +++ Makefile | 10 +- enums/Cargo.toml | 20 +- tree-sitter-ccomment/Cargo.toml | 20 +- tree-sitter-mozcpp/Cargo.toml | 20 +- tree-sitter-mozjs/Cargo.toml | 20 +- tree-sitter-preproc/Cargo.toml | 20 +- tree-sitter-tcl/Cargo.toml | 20 +- utils/check-excluded-manifests-test.py | 301 ++++++++++++++++++++++-- utils/check-excluded-manifests.py | 285 ++++++++++++++++------ utils/check-grammar-marker-sync-test.py | 23 +- utils/check-grammar-marker-sync.py | 23 +- 15 files changed, 687 insertions(+), 151 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f2d1a1534..5acfba6c8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -577,6 +577,20 @@ jobs: # gate without surfacing the regression in CI. - name: grammar-marker-sync self-tests (explicit) run: python3 -m unittest -q utils/check-grammar-marker-sync-test.py + # Defensive twin for the workspace-exclusion gate (#1145, #1151): + # an excluded crate that roots no workspace of its own breaks + # `cargo fmt --all` inside every git worktree, and an unpinned + # tree-sitter dependency lets `cargo update` move a grammar + # silently. `make lint` already runs this gate; invoking it here + # (matching the twins above) keeps it enforced even if a future + # refactor drops it from the aggregate recipe. + - name: check-excluded-manifests (explicit) + run: python3 utils/check-excluded-manifests.py + # Run the gate's own unittests as their own explicit step + # so a refactor that breaks the script can't disable the + # gate without surfacing the regression in CI. + - name: check-excluded-manifests self-tests (explicit) + run: python3 -m unittest -q utils/check-excluded-manifests-test.py # Defensive twin for the enums-codegen-drift gate (#405): # running any grammar regen previously regenerated # `src/c_langs_macros/*.rs` to a pre-optimization form diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 14294d108..01b47b9ea 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -210,7 +210,12 @@ repos: - id: check-excluded-manifests name: check-excluded-manifests language: system - files: '(^Cargo\.toml|^(enums|tree-sitter-[a-z]+)/Cargo\.toml|^utils/check-excluded-manifests\.py)$' + # `tree-sitter-[a-z]+` missed `tree-sitter-c-sharp/` and + # `tree-sitter-mozjs2/` — any hyphen or digit in a crate + # directory took its manifest out of the hook's scope. Match + # any top-level directory, like the broad `Cargo\.toml` + # alternation the check-versions hook uses. + files: '(^Cargo\.toml|^[^/]+/Cargo\.toml|^utils/check-excluded-manifests\.py)$' entry: python3 utils/check-excluded-manifests.py pass_filenames: false diff --git a/AGENTS.md b/AGENTS.md index 57bafb951..1c58b068c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -503,9 +503,19 @@ weigh them, do not drive them to zero at any cost. ## Tree-sitter grammars External grammar crates are version-pinned (`=0.23.5`, `=0.26.10`, -etc.) in the root `Cargo.toml` **and in each vendored crate's own -manifest** — `utils/check-excluded-manifests.py` enforces both, so a -new vendored grammar cannot reintroduce a caret range (#1151). +etc.) in the root `Cargo.toml` **and in each workspace-excluded crate's +own manifest** (the five vendored grammars plus `enums`) — +`utils/check-excluded-manifests.py` enforces both, so neither a root +pin nor a new vendored grammar can reintroduce a caret range (#1151). +Member crates take their grammars through `workspace = true` and carry +no requirement of their own. The gate reads manifests with `tomllib`, +so a literal string (`tree-sitter-cpp = '0.23.4'`) is checked like any +other, and it matches `tree-sitter` anywhere in a dependency name — +`dekobon-tree-sitter-groovy` and any future `bca-tree-sitter-*` are in +scope. A pin means exactly `=X.Y.Z` (whitespace after `=` is fine); a +compound requirement such as `=0.25.0, <0.26` is rejected, because the +`.grammar-marker-baseline.toml` entry that `check-grammar-marker-sync.py` +compares against can name only one version. One deliberate exception: `tree-sitter-language`, the ecosystem's shared `LanguageFn` trait shim, is **not** a grammar and must stay caret-ranged. @@ -514,6 +524,15 @@ One deliberate exception: `tree-sitter-language`, the ecosystem's shared downstream consumers of the published `bca-tree-sitter-*` crates. The gate carries it in `PIN_EXEMPT_DEPS`. +The `tree-sitter` runtime is **not** exempt, though the "not a grammar" +half of that rationale fits it too. The exemption is about unification +pressure and the runtime has none — the lockfile shows 25 crates +depending on `tree-sitter-language` against one external dependent +(`tree-sitter-perl`) for `tree-sitter` — and every manifest here already +pins it at `=0.26.11` with the workspace resolving. Its ABI version is +also what each vendored `parser.c` was generated against, so an +accidental bump is precisely the drift the gate exists to catch. + Treat the pinned version as fixed: - Do not loosen pins to a range without explicit user approval. diff --git a/CHANGELOG.md b/CHANGELOG.md index 841af16a4..80c0ac07d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -420,6 +420,36 @@ for historical reference. ### Fixed +- Every crate the root manifest `exclude`s — the five vendored + `bca-tree-sitter-*` grammars and `enums` — now roots its own + workspace (#1145). `exclude` denies membership without terminating + cargo's upward search for a workspace root, so inside a git worktree + under `.claude/worktrees/` that search escaped the worktree and + resolved against the main checkout, where the crate's path is neither + a member nor excluded; `cargo metadata` errored and took `cargo fmt + --all` and every `make pre-commit` stage chained behind it with it. + `.claude/worktrees` is excluded from the root workspace for the + mirror-image reason. +- The vendored grammar manifests pin their tree-sitter dependencies with + `=X.Y.Z` requirements rather than caret ranges (#1151): + `tree-sitter-cpp` in `bca-tree-sitter-mozcpp` and + `tree-sitter-javascript` in `bca-tree-sitter-mozjs`. Both are + build-dependencies of published crates, so the loose requirement let a + downstream consumer resolve a different grammar than this workspace + builds against, and let a plain `cargo update` move one silently. + `tree-sitter-language` deliberately stays caret-ranged — it is the + ecosystem's shared `LanguageFn` shim, not a grammar, and `=`-pinning + it makes both this workspace and downstream consumers unresolvable. +- A new gate, `utils/check-excluded-manifests.py`, holds both of the + above (wired into `make lint`, `make pre-commit`, `make ci`, the + pre-commit hooks, and the `lint` CI job). It parses manifests with + `tomllib` and checks the root manifest's `[workspace.dependencies]` + block alongside each excluded crate's own tables. +- `utils/check-grammar-marker-sync.py` compares the vendored grammar + marker against its baseline with the requirement operator stripped, so + `=0.23.4`, `= 0.23.4` and `0.23.4` all name the same upstream version. + The literal comparison reported drift for #1151's pin tightening, + which touched no generated byte. - `make book-pot` writes `messages.pot` to the book's `po/` directory again, and now refuses to run against an unsupported mdBook. The target passed a relative `-d po`, which mdBook 0.4 resolved against diff --git a/Makefile b/Makefile index 9dde43091..4c8b5ee25 100644 --- a/Makefile +++ b/Makefile @@ -132,7 +132,7 @@ help: @echo " check-versions Enforce lockstep version invariant across owned crates" @echo " check-versions-test Self-tests for the check-versions gate" @echo " check-grammar-crate-test Sync-test EXTENSIONS table vs src/langs.rs" - @echo " check-excluded-manifests Assert workspace-excluded crates root their own workspace" + @echo " check-excluded-manifests Assert excluded crates root a workspace and grammars are =-pinned" @echo " check-excluded-manifests-test Self-tests for the check-excluded-manifests gate" @echo " check-manpage-assets Assert every bca-*.1 man page is in deb+rpm asset lists" @echo " enums-check cargo clippy + cargo test on workspace-excluded enums crate" @@ -424,15 +424,15 @@ check-versions-test: @echo "Running check-versions self-tests..." @(cd $(BASE_DIR) && python3 -m unittest -q utils/check-versions-test.py) -# Man-page packaging gate. Blocks the failure mode from #444: -# a bca subcommand man page that drops out of the hand-maintained # Workspace-exclusion gate. Closes #1145: a crate in the root # `[workspace] exclude` array must root its own workspace, because # `exclude` denies membership without stopping cargo's upward search # for a workspace root. In a git worktree under `.claude/worktrees/` # that search escapes the worktree and resolves against the main # checkout, breaking `cargo fmt --all` and every `make pre-commit` -# stage chained behind it. Static lint — no network, no cargo. +# stage chained behind it. Also asserts every tree-sitter dependency +# in the root manifest and in each excluded crate carries an `=X.Y.Z` +# pin (#1151). Static lint — no network, no cargo. check-excluded-manifests: @echo "Checking workspace-excluded manifests..." @python3 $(BASE_DIR)utils/check-excluded-manifests.py @@ -445,6 +445,8 @@ check-excluded-manifests-test: @echo "Running check-excluded-manifests self-tests..." @(cd $(BASE_DIR) && python3 -m unittest -q utils/check-excluded-manifests-test.py) +# Man-page packaging gate. Blocks the failure mode from #444: +# a bca subcommand man page that drops out of the hand-maintained # deb/rpm asset lists in the CLI / web crate Cargo.toml. Static # lint — no network, no cargo, runs in milliseconds. See #446. check-manpage-assets: diff --git a/enums/Cargo.toml b/enums/Cargo.toml index 2e6888fcd..623b18848 100644 --- a/enums/Cargo.toml +++ b/enums/Cargo.toml @@ -1,3 +1,15 @@ +# Standalone workspace root. The root manifest `exclude`s this crate, but +# `exclude` denies membership without terminating cargo's upward search for a +# workspace root. Inside a git worktree under `.claude/worktrees/` that search +# escapes the worktree and lands on the main checkout's manifest, where this +# path is neither a member nor excluded — breaking `cargo fmt --all` and every +# `make pre-commit` stage that depends on it (#1145). +# +# Kept at the top of the file rather than the bottom: an empty table as the +# last thing in a manifest silently adopts any bare key appended after it, so +# a future `foo = "1"` meant for `[package]` would become `[workspace].foo`. +[workspace] + [package] name = "enums" version = "2.1.0" @@ -45,11 +57,3 @@ tree-sitter-mozjs = { package = "bca-tree-sitter-mozjs", path = "../tree-sitter- [profile.release] debug = "line-tables-only" - -# Standalone workspace root. The root manifest `exclude`s this crate, but -# `exclude` denies membership without terminating cargo's upward search for a -# workspace root. Inside a git worktree under `.claude/worktrees/` that search -# escapes the worktree and lands on the main checkout's manifest, where this -# path is neither a member nor excluded — breaking `cargo fmt --all` and every -# `make pre-commit` stage that depends on it (#1145). -[workspace] diff --git a/tree-sitter-ccomment/Cargo.toml b/tree-sitter-ccomment/Cargo.toml index be6a2c9fd..5ff978d77 100644 --- a/tree-sitter-ccomment/Cargo.toml +++ b/tree-sitter-ccomment/Cargo.toml @@ -1,3 +1,15 @@ +# Standalone workspace root. The root manifest `exclude`s this crate, but +# `exclude` denies membership without terminating cargo's upward search for a +# workspace root. Inside a git worktree under `.claude/worktrees/` that search +# escapes the worktree and lands on the main checkout's manifest, where this +# path is neither a member nor excluded — breaking `cargo fmt --all` and every +# `make pre-commit` stage that depends on it (#1145). +# +# Kept at the top of the file rather than the bottom: an empty table as the +# last thing in a manifest silently adopts any bare key appended after it, so +# a future `foo = "1"` meant for `[package]` would become `[workspace].foo`. +[workspace] + [package] name = "bca-tree-sitter-ccomment" description = "Ccomment grammar for the tree-sitter parsing library (big-code-analysis fork)" @@ -45,11 +57,3 @@ cc = "^1.2" [dev-dependencies] tree-sitter = "=0.26.11" - -# Standalone workspace root. The root manifest `exclude`s this crate, but -# `exclude` denies membership without terminating cargo's upward search for a -# workspace root. Inside a git worktree under `.claude/worktrees/` that search -# escapes the worktree and lands on the main checkout's manifest, where this -# path is neither a member nor excluded — breaking `cargo fmt --all` and every -# `make pre-commit` stage that depends on it (#1145). -[workspace] diff --git a/tree-sitter-mozcpp/Cargo.toml b/tree-sitter-mozcpp/Cargo.toml index 60184c11e..d8b958ff3 100644 --- a/tree-sitter-mozcpp/Cargo.toml +++ b/tree-sitter-mozcpp/Cargo.toml @@ -1,3 +1,15 @@ +# Standalone workspace root. The root manifest `exclude`s this crate, but +# `exclude` denies membership without terminating cargo's upward search for a +# workspace root. Inside a git worktree under `.claude/worktrees/` that search +# escapes the worktree and lands on the main checkout's manifest, where this +# path is neither a member nor excluded — breaking `cargo fmt --all` and every +# `make pre-commit` stage that depends on it (#1145). +# +# Kept at the top of the file rather than the bottom: an empty table as the +# last thing in a manifest silently adopts any bare key appended after it, so +# a future `foo = "1"` meant for `[package]` would become `[workspace].foo`. +[workspace] + [package] name = "bca-tree-sitter-mozcpp" description = "Mozcpp grammar for the tree-sitter parsing library (big-code-analysis fork)" @@ -57,11 +69,3 @@ build = ["tree-sitter-cpp"] [dev-dependencies] tree-sitter = "=0.26.11" - -# Standalone workspace root. The root manifest `exclude`s this crate, but -# `exclude` denies membership without terminating cargo's upward search for a -# workspace root. Inside a git worktree under `.claude/worktrees/` that search -# escapes the worktree and lands on the main checkout's manifest, where this -# path is neither a member nor excluded — breaking `cargo fmt --all` and every -# `make pre-commit` stage that depends on it (#1145). -[workspace] diff --git a/tree-sitter-mozjs/Cargo.toml b/tree-sitter-mozjs/Cargo.toml index 0235f6ba2..2b48c2a61 100644 --- a/tree-sitter-mozjs/Cargo.toml +++ b/tree-sitter-mozjs/Cargo.toml @@ -1,3 +1,15 @@ +# Standalone workspace root. The root manifest `exclude`s this crate, but +# `exclude` denies membership without terminating cargo's upward search for a +# workspace root. Inside a git worktree under `.claude/worktrees/` that search +# escapes the worktree and lands on the main checkout's manifest, where this +# path is neither a member nor excluded — breaking `cargo fmt --all` and every +# `make pre-commit` stage that depends on it (#1145). +# +# Kept at the top of the file rather than the bottom: an empty table as the +# last thing in a manifest silently adopts any bare key appended after it, so +# a future `foo = "1"` meant for `[package]` would become `[workspace].foo`. +[workspace] + [package] name = "bca-tree-sitter-mozjs" description = "Mozjs grammar for the tree-sitter parsing library (big-code-analysis fork)" @@ -59,11 +71,3 @@ build = ["tree-sitter-javascript"] [dev-dependencies] tree-sitter = "=0.26.11" - -# Standalone workspace root. The root manifest `exclude`s this crate, but -# `exclude` denies membership without terminating cargo's upward search for a -# workspace root. Inside a git worktree under `.claude/worktrees/` that search -# escapes the worktree and lands on the main checkout's manifest, where this -# path is neither a member nor excluded — breaking `cargo fmt --all` and every -# `make pre-commit` stage that depends on it (#1145). -[workspace] diff --git a/tree-sitter-preproc/Cargo.toml b/tree-sitter-preproc/Cargo.toml index 1e1ca368b..749d6a66a 100644 --- a/tree-sitter-preproc/Cargo.toml +++ b/tree-sitter-preproc/Cargo.toml @@ -1,3 +1,15 @@ +# Standalone workspace root. The root manifest `exclude`s this crate, but +# `exclude` denies membership without terminating cargo's upward search for a +# workspace root. Inside a git worktree under `.claude/worktrees/` that search +# escapes the worktree and lands on the main checkout's manifest, where this +# path is neither a member nor excluded — breaking `cargo fmt --all` and every +# `make pre-commit` stage that depends on it (#1145). +# +# Kept at the top of the file rather than the bottom: an empty table as the +# last thing in a manifest silently adopts any bare key appended after it, so +# a future `foo = "1"` meant for `[package]` would become `[workspace].foo`. +[workspace] + [package] name = "bca-tree-sitter-preproc" description = "Preproc grammar for the tree-sitter parsing library (big-code-analysis fork)" @@ -45,11 +57,3 @@ cc = "^1.2" [dev-dependencies] tree-sitter = "=0.26.11" - -# Standalone workspace root. The root manifest `exclude`s this crate, but -# `exclude` denies membership without terminating cargo's upward search for a -# workspace root. Inside a git worktree under `.claude/worktrees/` that search -# escapes the worktree and lands on the main checkout's manifest, where this -# path is neither a member nor excluded — breaking `cargo fmt --all` and every -# `make pre-commit` stage that depends on it (#1145). -[workspace] diff --git a/tree-sitter-tcl/Cargo.toml b/tree-sitter-tcl/Cargo.toml index 122a54e8e..267b14800 100644 --- a/tree-sitter-tcl/Cargo.toml +++ b/tree-sitter-tcl/Cargo.toml @@ -1,3 +1,15 @@ +# Standalone workspace root. The root manifest `exclude`s this crate, but +# `exclude` denies membership without terminating cargo's upward search for a +# workspace root. Inside a git worktree under `.claude/worktrees/` that search +# escapes the worktree and lands on the main checkout's manifest, where this +# path is neither a member nor excluded — breaking `cargo fmt --all` and every +# `make pre-commit` stage that depends on it (#1145). +# +# Kept at the top of the file rather than the bottom: an empty table as the +# last thing in a manifest silently adopts any bare key appended after it, so +# a future `foo = "1"` meant for `[package]` would become `[workspace].foo`. +[workspace] + [package] name = "bca-tree-sitter-tcl" description = "Tcl grammar for the tree-sitter parsing library (big-code-analysis fork)" @@ -56,11 +68,3 @@ cc = "^1.2" [dev-dependencies] tree-sitter = "=0.26.11" - -# Standalone workspace root. The root manifest `exclude`s this crate, but -# `exclude` denies membership without terminating cargo's upward search for a -# workspace root. Inside a git worktree under `.claude/worktrees/` that search -# escapes the worktree and lands on the main checkout's manifest, where this -# path is neither a member nor excluded — breaking `cargo fmt --all` and every -# `make pre-commit` stage that depends on it (#1145). -[workspace] diff --git a/utils/check-excluded-manifests-test.py b/utils/check-excluded-manifests-test.py index c14c9f1f9..34d2e751a 100644 --- a/utils/check-excluded-manifests-test.py +++ b/utils/check-excluded-manifests-test.py @@ -1,12 +1,14 @@ #!/usr/bin/env python3 """Tests for check-excluded-manifests.py. -Two kinds of test, matching the check-versions-test.py pattern: - -* Unit tests against synthetic manifests, including the exact shapes - that must **not** count as a workspace root — ``[workspace.package]`` - and ``[workspace.dependencies]`` both start with the same eleven - characters, and neither terminates cargo's upward search. +Three kinds of test, matching the check-versions-test.py pattern: + +* Unit tests against synthetic manifests, weighted toward the legal + TOML shapes the gate's regex predecessor mis-read: literal strings, + commented-out entries, inline arrays, indented keys, and the text + ``[workspace]`` inside a multi-line string. +* ``main()`` tests over a synthetic repository root, covering both + error branches and the remediation text they print. * A smoke test running the real gate against the real repository, asserting a clean tree reports OK. @@ -16,12 +18,15 @@ from __future__ import annotations +import contextlib import importlib.util +import io import pathlib import subprocess import sys import tempfile import unittest +from unittest import mock UTILS_DIR = pathlib.Path(__file__).resolve().parent REPO_ROOT = UTILS_DIR.parent @@ -73,6 +78,51 @@ def test_absent_exclude_array_is_a_hard_error(self) -> None: with self.assertRaises(SystemExit): GATE.read_excluded_crates('[workspace]\nmembers = ["a"]\n') + def test_literal_string_entries_are_read(self) -> None: + # TOML literal strings are legal Cargo syntax. The regex gate + # matched only double quotes, so a `'enums'` entry dropped out + # of the check entirely without any signal. + self.assertEqual( + GATE.read_excluded_crates( + "[workspace]\nexclude = [\n 'enums',\n \"tree-sitter-tcl\",\n]\n" + ), + ["enums", "tree-sitter-tcl"], + ) + + def test_a_commented_out_entry_is_not_a_crate(self) -> None: + # Commenting an entry out is how a retired crate leaves the + # array. The regex gate returned the comment's quoted text as a + # crate path and then died on the missing manifest. + self.assertEqual( + GATE.read_excluded_crates( + '[workspace]\nexclude = [\n "enums",\n # "tree-sitter-retired",\n]\n' + ), + ["enums"], + ) + + def test_an_inline_array_does_not_swallow_the_next_array(self) -> None: + # `exclude` on one line followed by any other multi-line array: + # the regex ran non-greedily to the next line-initial `]` and + # returned the *other* array's entries as excluded crates. + self.assertEqual( + GATE.read_excluded_crates( + '[workspace]\nexclude = ["enums"]\nmembers = [\n "cli",\n]\n' + ), + ["enums"], + ) + + def test_an_indented_exclude_key_is_found(self) -> None: + # Leading whitespace before a key is valid TOML; the regex was + # anchored at column zero and reported "could not locate". + self.assertEqual( + GATE.read_excluded_crates('[workspace]\n exclude = ["enums"]\n'), + ["enums"], + ) + + def test_a_non_string_entry_is_a_hard_error(self) -> None: + with self.assertRaises(SystemExit): + GATE.read_excluded_crates("[workspace]\nexclude = [1]\n") + class MissingWorkspaceTableTest(unittest.TestCase): def _root_with(self, manifest_body: str) -> pathlib.Path: @@ -89,16 +139,38 @@ def test_manifest_without_one_is_reported(self) -> None: root = self._root_with('[package]\nname = "g"\n') self.assertEqual(GATE.missing_workspace_table(["grammar"], root), ["grammar"]) - def test_workspace_package_does_not_count_as_a_workspace_root(self) -> None: - # The failure this gate exists to catch: `[workspace.package]` - # and `[workspace.dependencies]` share a prefix with the bare - # table but do not stop cargo's upward search. - for header in ("[workspace.package]", "[workspace.dependencies]"): - with self.subTest(header=header): - root = self._root_with(f'[package]\nname = "g"\n\n{header}\nx = 1\n') - self.assertEqual( - GATE.missing_workspace_table(["grammar"], root), ["grammar"] - ) + def test_any_workspace_key_roots_a_workspace(self) -> None: + # Cargo keys on the presence of a `workspace` key, not on the + # bare header. Probed on cargo 1.95.0 against a sub-package + # under an unrelated workspace root: with no workspace table it + # exits 101 ("current package believes it's in a workspace when + # it's not"), and each of these three headers alone makes + # `cargo metadata` exit 0. An earlier revision of this test + # asserted the opposite and encoded cargo semantics that do not + # exist. + for header in ( + "[workspace.package]\nversion = \"0.1.0\"", + "[workspace.dependencies]\nserde = \"1\"", + "[workspace.lints]\nrust.unsafe_code = \"forbid\"", + ): + with self.subTest(header=header.splitlines()[0]): + root = self._root_with(f'[package]\nname = "g"\n\n{header}\n') + self.assertEqual(GATE.missing_workspace_table(["grammar"], root), []) + + def test_a_bracketed_workspace_inside_a_string_is_not_a_table(self) -> None: + # The regex gate matched the *text* `[workspace]` wherever it + # sat, including inside a multi-line string, and reported a + # manifest that roots no workspace as compliant. + root = self._root_with( + '[package]\nname = "g"\ndescription = """\n[workspace]\n"""\n' + ) + self.assertEqual(GATE.missing_workspace_table(["grammar"], root), ["grammar"]) + + def test_a_spaced_workspace_header_counts(self) -> None: + # `[ workspace ]` is valid TOML; the regex demanded the exact + # eleven characters and reported a rooted crate as an offender. + root = self._root_with('[package]\nname = "g"\n\n[ workspace ]\n') + self.assertEqual(GATE.missing_workspace_table(["grammar"], root), []) def test_missing_manifest_is_a_hard_error(self) -> None: root = pathlib.Path(self.enterContext(tempfile.TemporaryDirectory())) @@ -121,7 +193,7 @@ def test_caret_range_without_spaces_is_caught(self) -> None: # The no-space spelling is the case that matters most: #1151's # table missed four entries written exactly this way. self.assertEqual( - GATE.unpinned_grammar_deps('tree-sitter-perl="1.1.2"\n'), + GATE.unpinned_grammar_deps('[dependencies]\ntree-sitter-perl="1.1.2"\n'), [("tree-sitter-perl", "1.1.2")], ) @@ -132,22 +204,67 @@ def test_the_language_shim_is_exempt_from_pinning(self) -> None: # crate breaks downstream resolution (#1151). for spelling in ('tree-sitter-language="0.1.0"', 'tree-sitter-language = "0.1.0"'): with self.subTest(spelling=spelling): - self.assertEqual(GATE.unpinned_grammar_deps(spelling + "\n"), []) + self.assertEqual( + GATE.unpinned_grammar_deps("[dependencies]\n" + spelling + "\n"), [] + ) + + def test_the_runtime_crate_is_not_exempt(self) -> None: + # `tree-sitter` itself stays pin-required, unlike the shim: it + # has no unification pressure here (one external dependent in + # the lockfile) and its ABI version is what each vendored + # `parser.c` was generated against. + self.assertEqual( + GATE.unpinned_grammar_deps('[dev-dependencies]\ntree-sitter = "^0.26"\n'), + [("tree-sitter", "^0.26")], + ) def test_caret_range_with_spaces_is_caught(self) -> None: self.assertEqual( - GATE.unpinned_grammar_deps('tree-sitter-cpp = "0.23.4"\n'), + GATE.unpinned_grammar_deps('[dependencies]\ntree-sitter-cpp = "0.23.4"\n'), [("tree-sitter-cpp", "0.23.4")], ) + def test_a_literal_string_requirement_is_read(self) -> None: + # The regex gate matched double-quoted values only, so this + # unpinned grammar passed the gate as if it were not a + # dependency at all. + self.assertEqual( + GATE.unpinned_grammar_deps("[dependencies]\ntree-sitter-cpp = '0.23.4'\n"), + [("tree-sitter-cpp", "0.23.4")], + ) + + def test_a_name_prefixed_grammar_is_checked(self) -> None: + # `dekobon-tree-sitter-groovy` is a live entry in both the root + # and `enums` manifests; the `^tree-sitter` anchor never reached + # it, and would not reach a future `bca-tree-sitter-*` either. + self.assertEqual( + GATE.unpinned_grammar_deps( + "[dependencies]\n" + 'dekobon-tree-sitter-groovy = "^0.2.2"\n' + 'bca-tree-sitter-mozjs = "2.1.0"\n' + ), + [("dekobon-tree-sitter-groovy", "^0.2.2"), ("bca-tree-sitter-mozjs", "2.1.0")], + ) + def test_inline_table_without_a_pin_is_caught(self) -> None: self.assertEqual( GATE.unpinned_grammar_deps( - 'tree-sitter-tcl = { path = "../x", version = "2.1.0" }\n' + '[dependencies]\ntree-sitter-tcl = { path = "../x", version = "2.1.0" }\n' ), [("tree-sitter-tcl", "2.1.0")], ) + def test_a_workspace_inherited_entry_carries_no_requirement(self) -> None: + # Member crates take grammars through `workspace = true`; the + # requirement they inherit lives in the root manifest, which the + # gate checks directly. Flagging these would be a false hit. + manifest = ( + "[dependencies]\n" + "tree-sitter.workspace = true\n" + "tree-sitter-bash = { workspace = true, optional = true }\n" + ) + self.assertEqual(GATE.unpinned_grammar_deps(manifest), []) + def test_non_grammar_dependencies_are_out_of_scope(self) -> None: # The pinning rule is about grammars; `cc`, `clap` and `askama` # deliberately float. @@ -163,6 +280,150 @@ def test_udeps_ignore_entry_is_not_a_dependency(self) -> None: ) self.assertEqual(GATE.unpinned_grammar_deps(manifest), []) + def test_a_commented_out_dependency_line_is_not_a_dependency(self) -> None: + # The regex gate needed a `^\s*` anchor to keep a commented-out + # line out of the dependency set, and nothing exercised it. + # tomllib cannot see a comment at all; this test fails against + # any return to line matching. + manifest = '[dependencies]\n# tree-sitter-cpp = "0.23.4"\ncc = "^1.2"\n' + self.assertEqual(GATE.unpinned_grammar_deps(manifest), []) + + def test_a_grammar_named_key_outside_a_dependency_table_is_ignored(self) -> None: + # A version-shaped value under a non-dependency table is not a + # requirement. The regex gate read it as one; the walk only + # honours `dependencies` / `build-dependencies` / + # `dev-dependencies`. + manifest = '[package.metadata.grammar-audit]\ntree-sitter-cpp = "0.23.4"\n' + self.assertEqual(GATE.unpinned_grammar_deps(manifest), []) + + def test_nested_dependency_tables_are_reached(self) -> None: + # `[workspace.dependencies]` holds the root manifest's ~20 + # grammar pins, and `[target.'cfg(...)'.build-dependencies]` is + # the deepest shape cargo permits. Both must be walked. + manifest = ( + "[workspace.dependencies]\n" + 'tree-sitter-python = "^0.25.0"\n\n' + "[target.'cfg(unix)'.build-dependencies]\n" + 'tree-sitter-lua = "0.5.0"\n' + ) + self.assertEqual( + GATE.unpinned_grammar_deps(manifest), + [("tree-sitter-python", "^0.25.0"), ("tree-sitter-lua", "0.5.0")], + ) + + +class PinSuggestionTest(unittest.TestCase): + def test_a_bare_version_is_repaired_by_prepending_equals(self) -> None: + self.assertEqual(GATE.pin_suggestion("0.23.4"), 'should be "=0.23.4"') + + def test_a_range_is_not_repaired_by_prepending_equals(self) -> None: + # `should be "=^0.23.4"` / `"=>=0.23, <0.24"` / `"=*"` are all + # requirements cargo rejects; the old message printed each one. + for requirement in ("^0.23.4", ">=0.23, <0.24", "*"): + with self.subTest(requirement=requirement): + message = GATE.pin_suggestion(requirement) + self.assertEqual( + message, 'replace the range with an exact "=X.Y.Z" pin' + ) + + def test_a_spaced_equals_counts_as_a_pin(self) -> None: + # `= 0.25.0` is what check-grammar-marker-sync.py reduces to the + # bare `0.25.0`; the two gates must agree that it is a pin. + self.assertTrue(GATE.is_exact_pin("= 0.25.0")) + + def test_a_compound_requirement_is_not_a_pin(self) -> None: + # `=0.25.0, <0.26` names no single version, so the grammar- + # marker baseline cannot record it. Accepting it here would let + # a manifest through that the marker gate then reports as drift. + self.assertFalse(GATE.is_exact_pin("=0.25.0, <0.26")) + + +class MainTest(unittest.TestCase): + """`main()` over a synthetic repository root. + + Both error branches, the multi-offender join, and the remediation + text were previously unexecuted by any test. + """ + + def _repo(self, root_manifest: str, crates: dict[str, str]) -> pathlib.Path: + root = pathlib.Path(self.enterContext(tempfile.TemporaryDirectory())) + (root / "Cargo.toml").write_text(root_manifest, encoding="utf-8") + for name, body in crates.items(): + (root / name).mkdir(parents=True) + (root / name / "Cargo.toml").write_text(body, encoding="utf-8") + self.enterContext(mock.patch.object(GATE, "REPO_ROOT", root)) + self.enterContext( + mock.patch.object(GATE, "ROOT_MANIFEST", root / "Cargo.toml") + ) + return root + + def _run(self) -> tuple[int, str]: + err = io.StringIO() + with contextlib.redirect_stderr(err), contextlib.redirect_stdout(io.StringIO()): + code = GATE.main() + return code, err.getvalue() + + def test_a_clean_tree_returns_zero(self) -> None: + self._repo( + '[workspace]\nexclude = ["a"]\n\n' + '[workspace.dependencies]\ntree-sitter-cpp = "=0.23.4"\n', + {"a": '[package]\nname = "a"\n\n[workspace]\n'}, + ) + code, err = self._run() + self.assertEqual(code, 0, err) + + def test_missing_workspace_tables_are_listed_together(self) -> None: + self._repo( + '[workspace]\nexclude = ["a", "b"]\n', + { + "a": '[package]\nname = "a"\n', + "b": '[package]\nname = "b"\n', + }, + ) + code, err = self._run() + self.assertEqual(code, 1) + self.assertIn("a/Cargo.toml", err) + self.assertIn("b/Cargo.toml", err) + self.assertIn("#1145", err) + + def test_an_unpinned_grammar_in_an_excluded_crate_is_reported(self) -> None: + self._repo( + '[workspace]\nexclude = ["a"]\n', + { + "a": '[package]\nname = "a"\n\n[dependencies]\n' + 'tree-sitter-cpp = "0.23.4"\n\n[workspace]\n' + }, + ) + code, err = self._run() + self.assertEqual(code, 1) + self.assertIn('a/Cargo.toml: tree-sitter-cpp = "0.23.4"', err) + self.assertIn('should be "=0.23.4"', err) + + def test_an_unpinned_grammar_in_the_root_manifest_is_reported(self) -> None: + # AGENTS.md claims this gate enforces the root manifest's pins. + # Before the root was added to the scan, loosening one there was + # invisible. + self._repo( + '[workspace]\nexclude = ["a"]\n\n' + '[workspace.dependencies]\ntree-sitter-python = "^0.25.0"\n', + {"a": '[package]\nname = "a"\n\n[workspace]\n'}, + ) + code, err = self._run() + self.assertEqual(code, 1) + self.assertIn('Cargo.toml: tree-sitter-python = "^0.25.0"', err) + self.assertIn('replace the range with an exact "=X.Y.Z" pin', err) + + def test_a_range_requirement_is_not_suggested_back_with_an_equals(self) -> None: + self._repo( + '[workspace]\nexclude = ["a"]\n\n' + '[workspace.dependencies]\ntree-sitter-go = ">=0.23, <0.24"\n', + {"a": '[package]\nname = "a"\n\n[workspace]\n'}, + ) + code, err = self._run() + self.assertEqual(code, 1) + self.assertNotIn('"=>=0.23, <0.24"', err) + self.assertIn('replace the range with an exact "=X.Y.Z" pin', err) + class RealRepositoryTest(unittest.TestCase): def test_clean_tree_passes(self) -> None: diff --git a/utils/check-excluded-manifests.py b/utils/check-excluded-manifests.py index 6768f0a17..e2d28cc99 100644 --- a/utils/check-excluded-manifests.py +++ b/utils/check-excluded-manifests.py @@ -1,15 +1,15 @@ #!/usr/bin/env python3 """check-excluded-manifests -Invariants for the crates listed in the root manifest's +Invariants for the root manifest and for the crates listed in its ``[workspace] exclude`` array — the five vendored ``tree-sitter-*`` grammars and the ``enums`` codegen helper. Two invariants are checked. -**Every excluded crate must declare its own ``[workspace]`` table.** -``exclude`` denies workspace membership but does **not** terminate -cargo's upward search for a workspace root. In a git worktree under +**Every excluded crate must root its own workspace.** ``exclude`` +denies workspace membership but does **not** terminate cargo's upward +search for a workspace root. In a git worktree under ``.claude/worktrees/`` that search escapes the worktree and lands on the main checkout's manifest, where the crate's path is neither a member nor excluded — so ``cargo metadata`` errors, taking ``cargo fmt --all`` and @@ -19,8 +19,18 @@ range lets ``cargo update`` move a grammar silently, and lets a downstream consumer of the published ``bca-tree-sitter-*`` crates resolve one freely — the accidental bump the pinning rule exists to -prevent (#1151). Non-grammar dependencies (``cc``, ``clap``, ``askama``) -are out of scope; the rule is about grammars. +prevent (#1151). This covers the root manifest's +``[workspace.dependencies]`` block as well as each excluded crate's own +tables; member crates take their grammars through ``workspace = true`` +and so carry no requirement of their own. Non-grammar dependencies +(``cc``, ``clap``, ``askama``) are out of scope; the rule is about +grammars. + +Manifests are parsed with ``tomllib`` rather than matched with regexes. +The regex version of this gate silently skipped TOML literal strings +(``tree-sitter-cpp = '0.23.4'`` read as no dependency at all), reported +a commented-out ``exclude`` entry as a crate, and counted the text +``[workspace]`` inside a multi-line string as a workspace table. See AGENTS.md "Tree-sitter grammars" for the pinning rule and the `#1145` issue for the worktree traversal this guards. @@ -31,35 +41,41 @@ import pathlib import re import sys +from typing import Any, Iterator + +# tomllib landed in 3.11. On older Python, fall back to the external +# `tomli` package (same API), matching check-grammar-marker-sync.py. +try: + import tomllib +except ImportError: + try: + import tomli as tomllib # type: ignore[import-not-found,no-redef] + except ImportError: + sys.stderr.write( + "error: check-excluded-manifests.py requires Python 3.11+\n" + " (tomllib lives in the standard library starting at 3.11).\n" + " On older Python, install `tomli` and retry:\n" + " pip install tomli\n" + ) + sys.exit(2) # `parents[1]`, not `parent`: this gate lives in `utils/` but every path # it reads is anchored at the repository root. REPO_ROOT = pathlib.Path(__file__).resolve().parents[1] ROOT_MANIFEST = REPO_ROOT / "Cargo.toml" +ROOT_MANIFEST_LABEL = "Cargo.toml" # Excluded entries that name a directory rather than a crate. These have # no manifest of their own and are exempt from every check below. NON_CRATE_EXCLUDES = frozenset({".claude/worktrees"}) -EXCLUDE_ARRAY_RE = re.compile(r"^exclude\s*=\s*\[(.*?)^\]", re.DOTALL | re.MULTILINE) -QUOTED_ENTRY_RE = re.compile(r'"([^"]+)"') -# A bare `[workspace]` table header, not `[workspace.package]` or -# `[workspace.dependencies]` — only the bare form roots a workspace. -WORKSPACE_TABLE_RE = re.compile(r"^\s*\[workspace\]\s*$", re.MULTILINE) - -# A `tree-sitter*` dependency and its version requirement, in either the -# bare-string form (`tree-sitter-cpp = "=0.23.4"`) or the inline-table -# form (`... = { package = "…", version = "=2.1.0" }`). -# -# Whitespace around `=` is optional and inconsistent across these -# manifests — four of the five spell it `tree-sitter-language="0.1.0"`. -# #1151's own table missed exactly those four for that reason, so the -# `\s*` is load-bearing rather than defensive. -GRAMMAR_DEP_RE = re.compile( - r'^\s*(tree-sitter[\w-]*)\s*=\s*(?:"(?P[^"]+)"' - r'|\{[^}]*\bversion\s*=\s*"(?P
[^"]+)")', - re.MULTILINE, -) +# A dependency whose name contains this substring is a grammar (or the +# `tree-sitter` runtime) and falls under the pinning rule. Matching a +# substring rather than a prefix is load-bearing: `dekobon-tree-sitter- +# groovy` is a real entry in both the root and `enums` manifests, and a +# `^tree-sitter` anchor left it — and any future `bca-tree-sitter-*` — +# unchecked. +GRAMMAR_DEP_SUBSTRING = "tree-sitter" # `tree-sitter` dependencies that must NOT carry an `=` pin. # @@ -71,8 +87,52 @@ # an `=` pin on a shim every grammar depends on would break downstream # consumers pairing it with a grammar wanting a newer 0.1.x. Measured # on #1151, whose table listed it in error. +# +# The `tree-sitter` runtime itself is deliberately NOT exempt, though +# the "not a grammar" half of the rationale fits it too. The exemption +# is about unification pressure, and the runtime has none: the lockfile +# shows 25 crates depending on `tree-sitter-language` but only one +# external crate (`tree-sitter-perl`) depending on `tree-sitter`, and +# every manifest here already pins it at `=0.26.11` with the workspace +# resolving. The runtime's ABI version is also what a grammar's +# generated `parser.c` is built against, so an accidental bump is +# exactly the drift this gate exists to catch. PIN_EXEMPT_DEPS = frozenset({"tree-sitter-language"}) +# Cargo dependency-table names. A key is only read as a dependency +# inside one of these, so a same-named key under `[package]`, +# `[features]`, or `[package.metadata.cargo-udeps.ignore]` cannot +# masquerade as one. They may sit at the manifest root, under +# `[workspace]`, or under `[target.'cfg(...)']`. +_DEP_TABLE_NAMES = frozenset({"dependencies", "build-dependencies", "dev-dependencies"}) + +# How deep the recursive dependency-table walk goes. Cargo's deepest +# standard form is `[target.'cfg(...)'.build-dependencies]` (3 levels); +# 6 gives headroom without admitting pathological recursion. Mirrors +# check-grammar-marker-sync.py's `_DEP_SCAN_MAX_DEPTH`. +_DEP_SCAN_MAX_DEPTH = 6 + +# A bare semver requirement, with no comparator. Shared between the +# "is this an exact pin" test and the suggestion text so the two cannot +# disagree about what a bare version looks like. +_BARE_VERSION = r"\d+(?:\.\d+)*(?:[-+][0-9A-Za-z.+-]+)?" +BARE_VERSION_RE = re.compile(rf"^{_BARE_VERSION}$") +# `= 0.25.0` with a space is what cargo accepts and what +# check-grammar-marker-sync.py's `.removeprefix("=").strip()` reduces to +# the bare form. A compound requirement (`=0.25.0, <0.26`) is +# deliberately NOT a pin: it is not a version the grammar-marker +# baseline can name, and admitting it here would make the two gates +# disagree about the same manifest line. +EXACT_PIN_RE = re.compile(rf"^=\s*{_BARE_VERSION}$") + + +def parse_manifest(manifest_text: str, label: str) -> dict[str, Any]: + """Parse a Cargo manifest, exiting with a located error on bad TOML.""" + try: + return tomllib.loads(manifest_text) + except tomllib.TOMLDecodeError as exc: + raise SystemExit(f"error: {label} is not valid TOML: {exc}") from exc + def read_excluded_crates(manifest_text: str) -> list[str]: """Return the crate paths in the root manifest's ``exclude`` array. @@ -80,78 +140,171 @@ def read_excluded_crates(manifest_text: str) -> list[str]: Directory entries listed in :data:`NON_CRATE_EXCLUDES` are dropped — they gate cargo's search but carry no manifest to check. """ - match = EXCLUDE_ARRAY_RE.search(manifest_text) - if match is None: + data = parse_manifest(manifest_text, ROOT_MANIFEST_LABEL) + workspace = data.get("workspace") + exclude = workspace.get("exclude") if isinstance(workspace, dict) else None + if not isinstance(exclude, list): raise SystemExit( "error: could not locate the [workspace] exclude array in Cargo.toml" ) - entries = QUOTED_ENTRY_RE.findall(match.group(1)) - return [entry for entry in entries if entry not in NON_CRATE_EXCLUDES] + for entry in exclude: + if not isinstance(entry, str): + raise SystemExit( + f"error: [workspace] exclude holds a non-string entry: {entry!r}" + ) + return [entry for entry in exclude if entry not in NON_CRATE_EXCLUDES] + + +def has_workspace_key(manifest_text: str, label: str = "Cargo.toml") -> bool: + """True when the manifest carries any top-level ``workspace`` key. + + Cargo terminates its upward workspace search on the *key*, not on + the bare ``[workspace]`` header: a manifest whose only workspace + content is ``[workspace.package]``, ``[workspace.dependencies]``, or + ``[workspace.lints]`` still roots a workspace. Measured on cargo + 1.95.0 — a sub-package with no workspace table under an unrelated + root fails `cargo metadata` with "current package believes it's in a + workspace when it's not", and each of those three headers alone + makes it exit 0. + """ + return "workspace" in parse_manifest(manifest_text, label) def missing_workspace_table(crates: list[str], root: pathlib.Path) -> list[str]: - """Return excluded crates whose manifest lacks a ``[workspace]`` table.""" + """Return excluded crates whose manifest roots no workspace.""" offenders = [] for crate in crates: manifest = root / crate / "Cargo.toml" if not manifest.is_file(): raise SystemExit(f"error: excluded crate has no manifest: {manifest}") - if WORKSPACE_TABLE_RE.search(manifest.read_text(encoding="utf-8")) is None: + text = manifest.read_text(encoding="utf-8") + if not has_workspace_key(text, f"{crate}/Cargo.toml"): offenders.append(crate) return offenders -def unpinned_grammar_deps(manifest_text: str) -> list[tuple[str, str]]: - """Return ``(dependency, requirement)`` pairs not using an ``=`` pin.""" +def _dependency_entries( + data: Any, depth: int = 0, in_dep_table: bool = False +) -> Iterator[tuple[str, Any]]: + """Yield ``(name, value)`` for every entry of a dependency table. + + Descends `[workspace]` / `[target]` / `[target.'cfg(...)']` wrappers + with `in_dep_table` still False, so only entries that really sit in + a dependency table are yielded. Entries themselves are leaves — an + inline table's `version` / `path` / `package` fields are never + re-entered as if they were dependencies. + """ + if depth > _DEP_SCAN_MAX_DEPTH or not isinstance(data, dict): + return + for key, value in data.items(): + if in_dep_table: + yield key, value + continue + if isinstance(value, dict): + yield from _dependency_entries( + value, depth + 1, key in _DEP_TABLE_NAMES + ) + + +def is_exact_pin(requirement: str) -> bool: + """True when ``requirement`` is an ``=X.Y.Z`` pin and nothing else.""" + return EXACT_PIN_RE.match(requirement.strip()) is not None + + +def pin_suggestion(requirement: str) -> str: + """Return the remediation phrase for an unpinned requirement. + + Only a bare version can be repaired by prepending `=`. Saying + `should be "=^0.23.4"` for a caret range — or `"=*"` for a + wildcard — hands the reader a requirement cargo rejects. + """ + if BARE_VERSION_RE.match(requirement.strip()): + return f'should be "={requirement}"' + return 'replace the range with an exact "=X.Y.Z" pin' + + +def unpinned_grammar_deps( + manifest_text: str, label: str = "Cargo.toml" +) -> list[tuple[str, str]]: + """Return ``(dependency, requirement)`` pairs not using an ``=`` pin. + + An entry with no requirement of its own — `{ workspace = true }`, + `{ path = "..." }` — is skipped: the requirement it inherits lives + in the root manifest, which this gate checks directly. + """ unpinned = [] - for match in GRAMMAR_DEP_RE.finditer(manifest_text): - dependency = match.group(1) - requirement = match.group("bare") or match.group("table") - if dependency in PIN_EXEMPT_DEPS or requirement.startswith("="): + for name, value in _dependency_entries(parse_manifest(manifest_text, label)): + if GRAMMAR_DEP_SUBSTRING not in name or name in PIN_EXEMPT_DEPS: + continue + if isinstance(value, str): + requirement = value + elif isinstance(value, dict) and isinstance(value.get("version"), str): + requirement = value["version"] + else: continue - unpinned.append((dependency, requirement)) + if not is_exact_pin(requirement): + unpinned.append((name, requirement)) return unpinned +def _report_missing_workspace(offenders: list[str]) -> None: + print( + "error: excluded crates missing a [workspace] table:\n" + + "\n".join(f" {crate}/Cargo.toml" for crate in offenders) + + "\n\nWithout it, cargo's upward workspace search escapes a git\n" + "worktree under .claude/worktrees/ and resolves against the main\n" + "checkout, breaking `cargo fmt --all` and `make pre-commit` there\n" + "(#1145). Append an empty `[workspace]` table to each manifest.", + file=sys.stderr, + ) + + +def _report_unpinned(drifted: list[tuple[str, str, str]]) -> None: + print( + "error: tree-sitter dependencies without an `=X.Y.Z` pin:\n" + + "\n".join( + f' {label}: {dep} = "{requirement}" ({pin_suggestion(requirement)})' + for label, dep, requirement in drifted + ) + + "\n\nA caret range lets `cargo update` move a grammar silently and\n" + "lets a downstream consumer of the published crate resolve one\n" + "freely (#1151). See AGENTS.md \"Tree-sitter grammars\".", + file=sys.stderr, + ) + + def main() -> int: - crates = read_excluded_crates(ROOT_MANIFEST.read_text(encoding="utf-8")) + root_text = ROOT_MANIFEST.read_text(encoding="utf-8") + crates = read_excluded_crates(root_text) offenders = missing_workspace_table(crates, REPO_ROOT) if offenders: - print( - "error: excluded crates missing a [workspace] table:\n" - + "\n".join(f" {crate}/Cargo.toml" for crate in offenders) - + "\n\nWithout it, cargo's upward workspace search escapes a git\n" - "worktree under .claude/worktrees/ and resolves against the main\n" - "checkout, breaking `cargo fmt --all` and `make pre-commit` there\n" - "(#1145). Append an empty `[workspace]` table to each manifest.", - file=sys.stderr, - ) + _report_missing_workspace(offenders) return 1 - drifted = [ - (crate, dep, requirement) - for crate in crates - for dep, requirement in unpinned_grammar_deps( - (REPO_ROOT / crate / "Cargo.toml").read_text(encoding="utf-8") + # The root manifest is checked alongside the excluded crates: its + # `[workspace.dependencies]` block holds ~20 grammar pins, and + # AGENTS.md claims this gate enforces them. + manifests = [(ROOT_MANIFEST_LABEL, root_text)] + [ + ( + f"{crate}/Cargo.toml", + (REPO_ROOT / crate / "Cargo.toml").read_text(encoding="utf-8"), ) + for crate in crates + ] + drifted = [ + (label, dep, requirement) + for label, text in manifests + for dep, requirement in unpinned_grammar_deps(text, label) ] if drifted: - print( - "error: tree-sitter dependencies without an `=X.Y.Z` pin:\n" - + "\n".join( - f" {crate}/Cargo.toml: {dep} = \"{requirement}\"" - f' (should be "={requirement}")' - for crate, dep, requirement in drifted - ) - + "\n\nA caret range lets `cargo update` move a grammar silently and\n" - "lets a downstream consumer of the published crate resolve one\n" - "freely (#1151). See AGENTS.md \"Tree-sitter grammars\".", - file=sys.stderr, - ) + _report_unpinned(drifted) return 1 - print(f"Excluded manifests OK ({len(crates)} crates checked).") + print( + f"Excluded manifests OK ({len(crates)} crates checked, " + f"{len(manifests)} manifests pin-checked)." + ) return 0 diff --git a/utils/check-grammar-marker-sync-test.py b/utils/check-grammar-marker-sync-test.py index b9324dc0e..2a1318bcb 100755 --- a/utils/check-grammar-marker-sync-test.py +++ b/utils/check-grammar-marker-sync-test.py @@ -187,15 +187,32 @@ def test_equals_pin_matches_a_bare_baseline_version(self) -> None: self.assertEqual(result.returncode, 0, result.stderr) self.assertIn("OK", result.stdout) + def test_spaced_equals_pin_matches_a_bare_baseline_version(self) -> None: + # `= 0.25.0` is a valid cargo pin and check-excluded-manifests.py + # accepts it as one. A bare `removeprefix("=")` left the leading + # space in place, so this gate reported drift on a spelling its + # sibling called compliant — the two gates disagreed by one + # space. + script = _make_fixture( + self.tmpdir, mozjs_version="= 0.25.0", baseline=_BASELINE_MATCHING + ) + result = _run(script) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("OK", result.stdout) + def test_equals_pin_still_trips_on_a_real_version_bump(self) -> None: # The `=` strip must not swallow drift: an `=`-pinned marker at - # a *different* version is still a bump without a regen. + # a *different* version is still a bump without a regen. The + # assertion is on the *stripped* form as the message renders it + # (`repr`, hence the quotes) — asserting the bare substring + # `0.26.0` passed with the strip removed too, since it is a + # substring of `=0.26.0`. script = _make_fixture( - self.tmpdir, mozjs_version="=0.26.0", baseline=_BASELINE_MATCHING + self.tmpdir, mozjs_version="= 0.26.0", baseline=_BASELINE_MATCHING ) result = _run(script) self.assertEqual(result.returncode, 1) - self.assertIn("0.26.0", result.stderr) + self.assertIn("'0.26.0'", result.stderr) # --- Cargo.toml-side parsing (tomllib path) --- diff --git a/utils/check-grammar-marker-sync.py b/utils/check-grammar-marker-sync.py index 2206cfffa..f4aee2351 100755 --- a/utils/check-grammar-marker-sync.py +++ b/utils/check-grammar-marker-sync.py @@ -193,11 +193,22 @@ def read_marker( without one, and `None` when the marker is absent everywhere. The returned version is stripped of a leading `=` requirement - operator. The baseline records *which upstream version the - vendored sources were generated from*, so `=0.23.4` and - `0.23.4` name the same thing; comparing the raw requirement - strings reported drift when #1151 tightened the pins without - touching a single generated byte. + operator *and any whitespace after it*. The baseline records + *which upstream version the vendored sources were generated + from*, so `=0.23.4`, `= 0.23.4` and `0.23.4` all name the same + thing; comparing the raw requirement strings reported drift when + #1151 tightened the pins without touching a single generated + byte. `= 0.23.4` is the spelling the two gates previously + disagreed on — `check-excluded-manifests.py` accepted it as a + pin while a bare `removeprefix("=")` left a leading space here + and reported false drift. + + A compound requirement (`=0.23.4, <0.24`) names no single + version and would still be reported as drift. That is + deliberate and now consistent: `check-excluded-manifests.py` + rejects it as a pin, so it cannot reach a vendored manifest in + the first place. + Raises `CargoTomlParseError` if the manifest can't be read or parsed — main() catches this and surfaces a structured exit-2 message rather than leaking a Python traceback. @@ -215,7 +226,7 @@ def read_marker( f"{manifest} is not valid TOML: {exc}" ) from exc found = _scan_for_marker(data, marker) - return found.removeprefix("=") if isinstance(found, str) else found + return found.removeprefix("=").strip() if isinstance(found, str) else found def _coerce_baseline_value( From ce3c8add3c42188e523b2bbf1fdc202d96319e4b Mon Sep 17 00:00:00 2001 From: Elijah Zupancic Date: Fri, 31 Jul 2026 23:04:14 -0700 Subject: [PATCH 05/36] fix(cli): fail, not panic, when stdout cannot be written MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every emission path that reached stdout through `println!` panicked on a write error instead of returning one. `count` and `preproc` render after the walk on `main` and exited 101; `dump`, `find`, and `strip-comments` crashed a worker and surfaced the I/O error as `Receiver("A thread used to process a file panicked")` — accidentally exit 1, but reported as a thread crash and never naming the path. Worker paths now write through the held stdout guard with `?`, so a failure routes into the `write_failures` tally #1115 installed and `BrokenPipe` stays swallowed. `count` and `preproc` cannot reach that per-file seam, so they go through a new `writeln_stdout_or_die`, which shares `write_stdout_or_die`'s single BrokenPipe decision. `bca dump | head` was itself broken before this: the second and later banners met the closed pipe and panicked, so the run exited 1 with backtrace notes. It is exit 0 again, and pinned. Fixes #1132 --- big-code-analysis-book/src/commands/README.md | 15 ++ big-code-analysis-cli/src/commands.rs | 2 +- big-code-analysis-cli/src/commands/preproc.rs | 5 +- big-code-analysis-cli/src/commands/scan.rs | 8 +- big-code-analysis-cli/src/dispatch.rs | 20 +- big-code-analysis-cli/src/path_io.rs | 39 +++- big-code-analysis-cli/tests/common/mod.rs | 43 +++- big-code-analysis-cli/tests/read_failures.rs | 217 +++++++++++++++++- 8 files changed, 324 insertions(+), 25 deletions(-) diff --git a/big-code-analysis-book/src/commands/README.md b/big-code-analysis-book/src/commands/README.md index 87b9d964d..441b6e79d 100644 --- a/big-code-analysis-book/src/commands/README.md +++ b/big-code-analysis-book/src/commands/README.md @@ -99,6 +99,21 @@ a broken symlink found by walking (neither is a regular file). Both warn explicitly named path that does not exist is a separate, pre-existing error (also exit `1`). +### Unwritable output {#unwritable-output} + +The mirror image is the same rule, and it holds for every emission +path: a run whose output could not be written exits `1`. That covers a +per-file document under an unwritable `--output-dir`, and a full disk +on stdout — `dump`'s banners and trees, `find`'s matches, +`strip-comments`' rewritten source, `count`'s tally, and `preproc`'s +JSON alike. A per-file failure is named on stderr and counted in a +summary line; output assembled after the walk reports the operating +system's error directly. + +The one exemption is a closed downstream pipe: `bca dump | head` is +routine rather than a failure, so `BrokenPipe` is swallowed and the run +still exits `0`. + ## Flag placement and input paths Most subcommands read the input they analyze as a trailing positional diff --git a/big-code-analysis-cli/src/commands.rs b/big-code-analysis-cli/src/commands.rs index 41297675e..110a6b681 100644 --- a/big-code-analysis-cli/src/commands.rs +++ b/big-code-analysis-cli/src/commands.rs @@ -55,7 +55,7 @@ use crate::{ SummaryFile, Tier, TierSpec, die, die_io, group_files_by_basename, legacy_hint, load_baseline, load_preproc_data, load_threshold_config, note, read_exclude_patterns_from, resolve_walk_files, run_walk, run_walk_collecting, run_walk_resolved, validate_output_path, warn, write_atomic, - write_output_or_stdout, write_stdout_or_die, + write_output_or_stdout, write_stdout_or_die, writeln_stdout_or_die, }; mod analyze; diff --git a/big-code-analysis-cli/src/commands/preproc.rs b/big-code-analysis-cli/src/commands/preproc.rs index 55379ce50..cd5a36a34 100644 --- a/big-code-analysis-cli/src/commands/preproc.rs +++ b/big-code-analysis-cli/src/commands/preproc.rs @@ -89,6 +89,9 @@ pub(crate) fn run_command_preproc(globals: GlobalOpts, args: PreprocArgs) { write_file(&output_path, serialized.as_bytes()) .unwrap_or_else(|e| die_io("write preproc output to", &output_path, e)); } else { - println!("{serialized}"); + // Post-walk emission on `main`, so it needs the same fallible + // write `count`'s tally does (#1132): the `println!` this + // replaces exited 101 where the CLI documents 1. + writeln_stdout_or_die(&serialized); } } diff --git a/big-code-analysis-cli/src/commands/scan.rs b/big-code-analysis-cli/src/commands/scan.rs index ab34762ab..29c6128f9 100644 --- a/big-code-analysis-cli/src/commands/scan.rs +++ b/big-code-analysis-cli/src/commands/scan.rs @@ -66,5 +66,11 @@ pub(crate) fn run_command_count( // is back to one; `into_count` recovers the tally (degrading rather // than panicking if a worker poisoned the inner mutex, issue #445). let count = collector.into_count(); - println!("{count}"); + // The tally is emitted on `main` after the walk, so the per-file + // `write_failures` tally cannot see it and the `println!` this + // replaces panicked on an unwritable stdout (#1132). + // `writeln_stdout_or_die` gives it the same `error: …` line, + // `EXIT_TOOL_ERROR`, and `BrokenPipe` tolerance as every other + // post-walk emission. + writeln_stdout_or_die(&count.to_string()); } diff --git a/big-code-analysis-cli/src/dispatch.rs b/big-code-analysis-cli/src/dispatch.rs index c0c223bdf..05f30ee4f 100644 --- a/big-code-analysis-cli/src/dispatch.rs +++ b/big-code-analysis-cli/src/dispatch.rs @@ -215,9 +215,14 @@ fn dispatch_dump( // now renders into memory before printing, which widens that window // from a few instructions to a whole tree walk; `Stdout::lock` is // reentrant, so the nested lock the print takes is fine. + // + // The banner is written through the guard rather than `println!`, + // which panics on a write error instead of returning one (#1132): + // going through `?` routes a full disk into the walk's + // `write_failures` tally and leaves `| head` a swallowed `BrokenPipe`. let stdout = std::io::stdout(); - let _banner_guard = stdout.lock(); - println!("== {} ==", path.display()); + let mut out = stdout.lock(); + writeln!(out, "== {} ==", path.display())?; dump_node_with_color( ast.source(), &ast.root_node(), @@ -352,7 +357,10 @@ fn dispatch_strip_comments( } else if let Some(output) = output { write_file(output, &new_source)?; } else if let Ok(text) = std::str::from_utf8(&new_source) { - println!("{text}"); + // Fallible for the same reason as the `dump` banner (#1132): + // `println!` would panic on a full disk rather than let the + // walk tally the failure and exit 1. + writeln!(std::io::stdout().lock(), "{text}")?; } else { std::io::stdout().write_all(&new_source)?; } @@ -394,8 +402,8 @@ fn dispatch_find( // held across the banner, every match, and the trailing blank line // for the reason given in `dispatch_dump`. let stdout = std::io::stdout(); - let _banner_guard = stdout.lock(); - println!("== {} ==", path.display()); + let mut out = stdout.lock(); + writeln!(out, "== {} ==", path.display())?; for node in &found { dump_node_with_color( ast.source(), @@ -406,7 +414,7 @@ fn dispatch_find( cfg.color, )?; } - println!(); + writeln!(out)?; } Ok(()) } diff --git a/big-code-analysis-cli/src/path_io.rs b/big-code-analysis-cli/src/path_io.rs index 6e0b30574..a0e83da72 100644 --- a/big-code-analysis-cli/src/path_io.rs +++ b/big-code-analysis-cli/src/path_io.rs @@ -4,16 +4,41 @@ use super::*; -/// Write `bytes` to stdout, tolerating `BrokenPipe` (the typical case when -/// the consumer is `head`, `less`, etc.) and `die`ing on anything else. -pub(crate) fn write_stdout_or_die(bytes: &[u8]) { - if let Err(e) = std::io::stdout().lock().write_all(bytes) - && e.kind() != ErrorKind::BrokenPipe - { - die(e); +/// Write every chunk of `parts` to stdout under one lock, tolerating +/// `BrokenPipe` (the typical case when the consumer is `head`, `less`, +/// etc.) and `die`ing on anything else. +/// +/// The single place the stdout-failure policy is decided, so the +/// newline-appending variant below cannot drift from it. +fn write_stdout_parts_or_die(parts: &[&[u8]]) { + let mut out = std::io::stdout().lock(); + for part in parts { + if let Err(e) = out.write_all(part) { + if e.kind() != ErrorKind::BrokenPipe { + die(e); + } + return; + } } } +/// Write `bytes` to stdout under the policy of +/// [`write_stdout_parts_or_die`]. +pub(crate) fn write_stdout_or_die(bytes: &[u8]) { + write_stdout_parts_or_die(&[bytes]); +} + +/// Write `text` and a trailing newline to stdout, under one lock. +/// +/// The `println!` that post-walk emissions (`count`'s tally, `preproc`'s +/// JSON) used instead *panics* on a write error, exiting 101 where the +/// CLI documents `EXIT_TOOL_ERROR` (#1132). The newline is a separate +/// chunk rather than appended to `text`, so a multi-megabyte document is +/// not reallocated and copied just to grow by one byte. +pub(crate) fn writeln_stdout_or_die(text: &str) { + write_stdout_parts_or_die(&[text.as_bytes(), b"\n"]); +} + /// Reject an `--output` path that names an existing directory or whose /// parent directory is missing, mirroring the fast pre-walk validation /// `report` / `exemptions` do. `label` names the subcommand for the diff --git a/big-code-analysis-cli/tests/common/mod.rs b/big-code-analysis-cli/tests/common/mod.rs index ce70f4faa..2f6ee79a1 100644 --- a/big-code-analysis-cli/tests/common/mod.rs +++ b/big-code-analysis-cli/tests/common/mod.rs @@ -69,13 +69,42 @@ pub mod validators; /// (only `BCA_MAX_ORPHANED_TASKS`), so it has nothing to scrub. #[allow(dead_code)] pub fn scrub_ci_env(cmd: &mut Command) -> &mut Command { - cmd.env_remove("GITHUB_STEP_SUMMARY") - .env_remove("GITHUB_ACTIONS") - .env_remove("GITHUB_BASE_REF") - .env_remove("BCA_DIFF_BASE") - .env_remove("GITHUB_EVENT_BEFORE") - .env_remove("GITHUB_REPOSITORY") - .env_remove("GITHUB_RUN_ID") + for var in SCRUBBED_CI_ENV { + cmd.env_remove(var); + } + cmd +} + +/// The variables [`scrub_ci_env`] removes. Named once so the plain +/// `std::process::Command` builder below cannot drift from it. +const SCRUBBED_CI_ENV: [&str; 7] = [ + "GITHUB_STEP_SUMMARY", + "GITHUB_ACTIONS", + "GITHUB_BASE_REF", + "BCA_DIFF_BASE", + "GITHUB_EVENT_BEFORE", + "GITHUB_REPOSITORY", + "GITHUB_RUN_ID", +]; + +/// The same hermetic, env-scrubbed `bca` as [`cli_in`], but as a plain +/// [`std::process::Command`] so the caller can redirect the child's +/// stdout somewhere of its choosing. +/// +/// `assert_cmd::Command::assert` runs the child through +/// `std::process::Command::output`, which unconditionally replaces +/// stdout and stderr with pipes — any redirection configured beforehand +/// is silently discarded. Tests that must point stdout at a real file +/// descriptor (`/dev/full`, a pipe they close early) therefore cannot go +/// through `assert_cmd` at all. +#[allow(dead_code)] +pub fn std_bca_command_in(dir: &Path) -> std::process::Command { + let mut cmd = std::process::Command::new(assert_cmd::cargo::cargo_bin("bca")); + cmd.current_dir(dir); + for var in SCRUBBED_CI_ENV { + cmd.env_remove(var); + } + cmd } /// Build a `bca` `Command` with CI-side env vars scrubbed. The diff --git a/big-code-analysis-cli/tests/read_failures.rs b/big-code-analysis-cli/tests/read_failures.rs index d02e62a1f..f83e8a1e9 100644 --- a/big-code-analysis-cli/tests/read_failures.rs +++ b/big-code-analysis-cli/tests/read_failures.rs @@ -7,13 +7,22 @@ //! every walking subcommand, whether or not the run also produced //! output. //! -//! The second half covers the mirror image, which #1098 left open: a +//! The second part covers the mirror image, which #1098 left open: a //! failure to *write* a per-file document — an unwritable //! `--output-dir`, a full disk — printed the same per-file error and //! still exited 0, so a CI script read a missing or truncated output //! tree as a clean run. //! -//! Unix-only, because the scenario is staged with a mode-000 file. +//! The third part (#1132) covers the emission paths that reached stdout +//! through `println!`, which *panics* on a write error rather than +//! returning one: `count` and `preproc` exited 101, and `dump` / `find` / +//! `strip-comments` crashed a worker thread and reported the I/O error +//! as `Receiver("A thread used to process a file panicked")`. Those +//! cases also pin the `BrokenPipe` exemption from the other direction — +//! `bca dump | head` must stay exit 0. +//! +//! Unix-only, because the scenarios are staged with a mode-000 file and +//! `/dev/full`. //! `unreadable_fixture` probes the real capability rather than the uid, //! so a privileged test runner (root ignores mode bits) skips instead of //! failing. The suite lives in a `#[cfg(unix)]` module rather than @@ -466,4 +475,208 @@ mod unix { restore_dir_permissions(&locked); } + + /// C source carrying a comment, so `strip-comments` has something to + /// emit — over [`TRIVIAL_C`] it produces no output at all and its + /// stdout is never written. + const COMMENTED_C: &str = "/* doc */\nint add(int a, int b) { return a + b; }\n"; + + /// Open `/dev/full` for writing, or `None` when the platform has no + /// such device (it is a Linux-ism) or its writes unexpectedly succeed. + /// + /// The mode-555 directory above cannot stage this scenario: stdout is + /// inherited from the parent, never opened by `bca`, so the only way + /// to make it unwritable is to hand the child a file descriptor that + /// fails every write. The capability is probed rather than inferred + /// from the platform, matching `unreadable_fixture`. + fn dev_full() -> Option { + use std::io::Write; + + let mut file = fs::OpenOptions::new().write(true).open("/dev/full").ok()?; + file.write_all(b"probe").is_err().then_some(file) + } + + /// Run `subcommand` over `source` with the child's stdout pointed at + /// `stdout`, returning its status and captured stderr. + fn run_with_stdout( + dir: &TempDir, + subcommand: &str, + extra: &[&str], + source: &str, + stdout: std::process::Stdio, + ) -> std::process::Output { + common::std_bca_command_in(dir.path()) + .arg(subcommand) + .args(extra) + .args(["--no-config", "--paths", source]) + .stdout(stdout) + .stderr(std::process::Stdio::piped()) + .spawn() + .expect("spawn bca") + .wait_with_output() + .expect("wait for bca") + } + + /// #1132: a subcommand whose *stdout* cannot be written must exit + /// `EXIT_TOOL_ERROR` with the CLI's own `error:` diagnostic — never a + /// panic. `count` and `preproc` exited 101 (a `println!` panic on + /// `main`); `dump`, `find`, and `strip-comments` panicked a worker and + /// surfaced it as `Receiver("A thread used to process a file + /// panicked")`, which misreports an I/O error as a thread crash. + /// + /// The two panic-absence assertions are the load-bearing half. `dump` + /// already exited 1 before the fix, by way of that caught worker + /// panic, so an exit-code-only test passes against the bug it claims + /// to guard. The positive assertions (`error:` plus the OS message) + /// are what keep the absence checks from passing vacuously. + fn assert_stdout_write_failure_exits_one(subcommand: &str, extra: &[&str], body: &str) { + let dir = TempDir::new().expect("tempdir"); + let source = write_fixture(&dir, "ok.c", body); + let Some(full) = dev_full() else { + eprintln!("skipping: no write-failing /dev/full on this platform"); + return; + }; + + let failed = run_with_stdout(&dir, subcommand, extra, &source, full.into()); + let stderr = String::from_utf8_lossy(&failed.stderr).into_owned(); + assert_eq!( + failed.status.code(), + Some(1), + "`bca {subcommand}` must exit 1 on an unwritable stdout; stderr: {stderr}" + ); + assert!( + stderr.contains("error: "), + "`bca {subcommand}` must emit the CLI's own diagnostic; stderr: {stderr}" + ); + assert!( + stderr.contains("No space left on device"), + "the diagnostic must name the I/O failure; stderr: {stderr}" + ); + assert!( + !stderr.contains("panicked at"), + "`bca {subcommand}` must not panic on an unwritable stdout; stderr: {stderr}" + ); + assert!( + !stderr.contains("RUST_BACKTRACE"), + "`bca {subcommand}` must not emit a panic backtrace note; stderr: {stderr}" + ); + + // Control: the same invocation against a writable stdout exits 0, + // so the exit-1 above came from the write and not from a rejected + // flag set or an unusable fixture. + let ok = run_with_stdout( + &dir, + subcommand, + extra, + &source, + std::process::Stdio::null(), + ); + assert!( + ok.status.success(), + "`bca {subcommand}` must succeed with a writable stdout; stderr: {}", + String::from_utf8_lossy(&ok.stderr) + ); + } + + #[test] + fn count_exits_one_when_stdout_cannot_be_written() { + assert_stdout_write_failure_exits_one( + "count", + &["--type", "function_definition", "--language", "c"], + TRIVIAL_C, + ); + } + + #[test] + fn dump_exits_one_when_stdout_cannot_be_written() { + assert_stdout_write_failure_exits_one("dump", &[], TRIVIAL_C); + } + + #[test] + fn find_exits_one_when_stdout_cannot_be_written() { + assert_stdout_write_failure_exits_one( + "find", + &["--type", "function_definition", "--language", "c"], + TRIVIAL_C, + ); + } + + #[test] + fn strip_comments_exits_one_when_stdout_cannot_be_written() { + assert_stdout_write_failure_exits_one("strip-comments", &[], COMMENTED_C); + } + + #[test] + fn preproc_exits_one_when_stdout_cannot_be_written() { + assert_stdout_write_failure_exits_one("preproc", &[], TRIVIAL_C); + } + + /// The other half of the contract: `BrokenPipe` is not a tool error. + /// `bca dump | head -1` is routine, and converting the banner from + /// `println!` to a fallible write is exactly the change that could + /// turn it into an exit-1 (or, as before this fix, an exit-1 *and* a + /// screenful of panic text — the pre-#1132 behaviour this pins + /// against). + /// + /// The fixture shape is load-bearing twice over, and a simpler one + /// makes the test vacuous: + /// + /// - Each file must dump more than a pipe buffer (~64 KiB) so the + /// child is still blocked in a write when the read end closes, + /// rather than finishing into the buffer and never seeing `EPIPE`. + /// - There must be *several* files, because the banner is what this + /// pins and the first one is written before the pipe fills. Workers + /// serialise on the stdout lock, so exactly one banner precedes the + /// block and every later one meets a closed pipe. Against a + /// single-file fixture this test passes with the `println!` banner + /// restored — measured, not assumed. + #[test] + fn dump_exits_zero_when_its_consumer_closes_the_pipe() { + use std::fmt::Write as _; + use std::io::{BufRead, BufReader}; + + let dir = TempDir::new().expect("tempdir"); + let mut body = String::new(); + for i in 0..400 { + writeln!(body, "int f{i}(int a, int b) {{ return a + b * {i}; }}") + .expect("writing to a String is infallible"); + } + for name in ["one.c", "two.c", "three.c"] { + write_fixture(&dir, name, &body); + } + let source = dir.path().to_str().expect("utf8 dir").to_owned(); + + let mut child = common::std_bca_command_in(dir.path()) + .args(["dump", "--no-config", "--paths", &source]) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn() + .expect("spawn bca"); + + // Read one line, then close the read end — the `head -1` shape. + let stdout = child.stdout.take().expect("piped stdout"); + let mut reader = BufReader::new(stdout); + let mut first = String::new(); + reader.read_line(&mut first).expect("read first line"); + assert!( + first.starts_with("== "), + "the first line should be the per-file banner, got {first:?}" + ); + drop(reader); + + let output = child.wait_with_output().expect("wait for bca"); + let stderr = String::from_utf8_lossy(&output.stderr).into_owned(); + assert!( + output.status.success(), + "a closed consumer pipe is routine, not a tool error; stderr: {stderr}" + ); + assert!( + !stderr.contains("panicked at"), + "a closed consumer pipe must not panic a worker; stderr: {stderr}" + ); + assert!( + stderr.is_empty(), + "a closed consumer pipe must be silent; stderr: {stderr}" + ); + } } From 07e968d967979dd9426e33f280ba6189d6c5cafa Mon Sep 17 00:00:00 2001 From: Elijah Zupancic Date: Fri, 31 Jul 2026 23:04:47 -0700 Subject: [PATCH 06/36] test(bench): add a width-axis probe family to the scaling gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every generator in the harness was affine in nesting depth, and `shapes_nest_proportionally_to_depth` required it, so a walk that is linear in depth but quadratic in a parent's child count passed the gate. That is not hypothetical: the fix #1100 originally proposed was green on every depth probe while making a flat 2 000-item file 94x slower, and only an ad-hoc microbenchmark caught it. Probe now carries an Axis. The depth invariant applies to Axis::Depth only; Axis::Width gets shapes_widen_without_deepening, which asserts both that the widest node gains children and that the AST depth stays constant — without the second half a width probe that quietly began nesting would pass while silently measuring the depth axis. Each invariant asserts its axis is populated, so deleting the last probe of an axis fails rather than retiring that axis. nom/wide-attributed-fn renders n top-level `#[inline] fn f() {}` items at 500 / 1 000 / 2 000 under the same exclude_tests attribute scan as its nesting sibling. It fits 0.97-1.00; reintroducing #1100's rejected forward-always scan behind a local patch takes it to 1.99 (35.6 / 145.5 / 560.6 ms against 1.9 / 3.8 / 7.2) while every depth probe stays inside its bound. shapes_parse_without_error and probe_workload_is_exercised now render at the probe's smallest declared size instead of a literal 8, which is eight siblings on a width shape. The whole set costs ~0.4 s in a debug build. Fixes #1133 --- Makefile | 3 +- .../benches/metric_walk.rs | 18 +- big-code-analysis-bench/src/lib.rs | 5 +- big-code-analysis-bench/src/scaling.rs | 22 +- big-code-analysis-bench/src/shapes.rs | 276 +++++++++++++++--- docs/development/benchmarking.md | 147 ++++++---- 6 files changed, 354 insertions(+), 117 deletions(-) diff --git a/Makefile b/Makefile index fbc597e90..37b55525d 100644 --- a/Makefile +++ b/Makefile @@ -800,7 +800,8 @@ py-stubtest: # .github/workflows/benchmark.yml runs `bench-scaling` out of band, and # a human runs these around any change to the metric walk. # -# bench-scaling complexity-class gate: fits time ~ depth^k per probe +# bench-scaling complexity-class gate: fits time ~ size^k per probe +# (size = nesting depth, or one parent's child count) # and fails when k exceeds the probe's bound. # bench-walk criterion measurements over the corpus slice, per # metric. Pass criterion flags after `--`, e.g. diff --git a/big-code-analysis-bench/benches/metric_walk.rs b/big-code-analysis-bench/benches/metric_walk.rs index e967978e2..a4f2e1b42 100644 --- a/big-code-analysis-bench/benches/metric_walk.rs +++ b/big-code-analysis-bench/benches/metric_walk.rs @@ -7,7 +7,7 @@ //! - `corpus/walk` — one benchmark per metric family, walking the //! already-parsed slice. This is the number to quote when a change //! claims to make a metric cheaper. -//! - `shape/walk` — the depth-scaling shapes at a single depth, so a +//! - `shape/walk` — the scaling shapes at a single size, so a //! constant-factor change on a pathological input is visible even //! when its complexity class did not move. //! @@ -30,14 +30,6 @@ use big_code_analysis_bench::corpus::{CorpusFile, CorpusSlice, repo_root}; use big_code_analysis_bench::shapes::PROBES; use criterion::{Criterion, Throughput}; -/// Depth at which each shape is measured in the criterion group. -/// -/// The shallowest depth of the linear probes, which is also the -/// deepest of the quadratic ones: deep enough to be the pathological -/// input the shape exists to represent, shallow enough that the -/// already-quadratic probes still finish in a criterion sample. -const SHAPE_BENCH_DEPTH: usize = 1_000; - fn main() { let slice = CorpusSlice::load(&repo_root()); // Printed before anything is measured: a slice is only a @@ -157,7 +149,13 @@ fn bench_corpus(criterion: &mut Criterion, slice: &CorpusSlice) { fn bench_shapes(criterion: &mut Criterion) { let mut group = criterion.benchmark_group("shape/walk"); for probe in PROBES { - let source = (probe.render)(SHAPE_BENCH_DEPTH); + // Each shape at the smallest size the scaling gate measures it + // at: large enough to be the pathological input the shape + // exists to represent, small enough that an already-quadratic + // probe still finishes inside a criterion sample. Taken from + // the probe because the axes ladder differently — a shared + // literal would be one axis's number imposed on the other. + let source = (probe.render)(probe.sizes[0]); let Ok(ast) = Ast::parse(Source::new(probe.lang, source.as_bytes())) else { eprintln!( "skipping {}: {:?} is not compiled in", diff --git a/big-code-analysis-bench/src/lib.rs b/big-code-analysis-bench/src/lib.rs index be7933d0e..db2020c5b 100644 --- a/big-code-analysis-bench/src/lib.rs +++ b/big-code-analysis-bench/src/lib.rs @@ -2,8 +2,9 @@ //! //! The crate is split three ways: //! -//! - [`shapes`] generates the synthetic depth-scaling inputs and pairs -//! each with the metric selection that exercises one hot path. +//! - [`shapes`] generates the synthetic scaling inputs — nesting on +//! one axis, sibling count on the other — and pairs each with the +//! metric selection that exercises one hot path. //! - [`scaling`] measures those inputs and fits an empirical complexity //! exponent, so a regression is caught as a *class* change rather //! than as a wall-clock budget overrun. diff --git a/big-code-analysis-bench/src/scaling.rs b/big-code-analysis-bench/src/scaling.rs index 3e2dcf773..8da3af753 100644 --- a/big-code-analysis-bench/src/scaling.rs +++ b/big-code-analysis-bench/src/scaling.rs @@ -1,28 +1,30 @@ -//! Interleaved measurement of the depth-scaling probes, and the +//! Interleaved measurement of the scaling probes, and the //! complexity-class fit derived from it. //! //! # What is asserted, and why it is not a duration //! -//! The useful property is "doubling the nesting depth roughly doubles -//! the cost", not "this finishes within N milliseconds". An absolute +//! The useful property is "doubling the input roughly doubles the +//! cost", not "this finishes within N milliseconds". What "doubling" +//! means is the probe's own [`crate::shapes::Axis`] — nesting depth, +//! or one parent's child count. An absolute //! budget is calibrated to one machine; the same assertion produced //! four false failures during the #1052 / #1062 work — on //! `windows-latest`, under `cargo llvm-cov`, and twice on a local host //! running the rest of the validation gate alongside it. //! -//! A ratio between two depths is host-independent, but it is not +//! A ratio between two sizes is host-independent, but it is not //! load-independent: the two measurements are sequential, so a load //! spike between them skews the ratio by itself. Two things fix that //! here. //! -//! - **Interleaving.** Every (probe, depth) cell is measured once per +//! - **Interleaving.** Every (probe, size) cell is measured once per //! round, and the visit order is rotated each round. Contention that //! arrives mid-run lands on all cells rather than on whichever one //! happened to be running, so it inflates the readings without //! tilting the ratio between them. -//! - **Three points, not two.** Each probe is measured at `d`, `2d` -//! and `4d`, and the reported figure is the slope of `ln(time)` -//! against `ln(depth)` — an exponent, near 1.0 for a linear walk and +//! - **Three points, not two.** Each probe is measured at `n`, `2n` +//! and `4n`, and the reported figure is the slope of `ln(time)` +//! against `ln(size)` — an exponent, near 1.0 for a linear walk and //! near 2.0 for a quadratic one. A single pair of timings cannot //! distinguish "twice as slow because quadratic" from "twice as slow //! because busy". @@ -176,7 +178,7 @@ impl ProbeReport { } } -/// A full run of the depth-scaling probes. +/// A full run of the scaling probes. #[derive(Debug, Clone)] pub struct Report { /// Measurement rounds actually performed. @@ -198,7 +200,7 @@ impl Report { impl fmt::Display for Report { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - writeln!(f, "metric-walk depth scaling ({} rounds)", self.rounds)?; + writeln!(f, "metric-walk scaling ({} rounds)", self.rounds)?; for probe in &self.probes { writeln!(f)?; // An abandoned probe has no exponent worth printing: it is diff --git a/big-code-analysis-bench/src/shapes.rs b/big-code-analysis-bench/src/shapes.rs index 4adca4efe..861aaa32a 100644 --- a/big-code-analysis-bench/src/shapes.rs +++ b/big-code-analysis-bench/src/shapes.rs @@ -1,13 +1,24 @@ -//! Synthetic depth-scaling inputs, and the probes built from them. +//! Synthetic scaling inputs, and the probes built from them. //! -//! # Why every generator is affine in `depth` +//! # The two axes +//! +//! A tree grows in two directions, and a walk can be linear in one +//! while being quadratic in the other. [`Axis::Depth`] shapes nest: the +//! size parameter is the number of AST levels. [`Axis::Width`] shapes +//! do not nest at all; the size parameter is the number of siblings +//! under one fixed-depth parent. #1100 is why both are here — the fix +//! it originally proposed was linear on every nesting shape and made a +//! flat 2 000-item file 94x slower by that issue's measurement, which +//! no depth probe can see. +//! +//! # Why every generator is affine in its size //! //! A generator that indents each nesting level makes the *input* grow //! quadratically, so a walk that is perfectly linear in bytes still //! looks superlinear in depth. That mistake invalidated two published //! measurements during the #1052 / #1062 work before it was spotted. //! Every generator here therefore emits a constant number of bytes per -//! nesting level and no indentation at all, which the +//! level (or per sibling) and no indentation at all, which the //! `byte_growth_is_affine` unit test below pins, rather than leaving it //! a convention someone has to remember. @@ -15,10 +26,11 @@ use std::hint::black_box; use big_code_analysis::{Ast, CodeMetrics, LANG, Metric, MetricsError, MetricsOptions, Ops}; -/// Renders a source shape at a given nesting depth. +/// Renders a source shape at a given size — a nesting depth on +/// [`Axis::Depth`], a sibling count on [`Axis::Width`]. /// -/// Every implementation must be affine in `depth`: `len(d)` is -/// `base + per_level * d`. See the module docs for why. +/// Every implementation must be affine in that size: `len(n)` is +/// `base + per_unit * n`. See the module docs for why. pub type Render = fn(usize) -> String; /// Rust: `fn f() -> i32 { (((…1…))) }`. @@ -261,6 +273,26 @@ pub fn nested_attributed_fns(depth: usize) -> String { ) } +/// Rust: `#[inline] fn f() {} #[inline] fn f() {} …`, all at file +/// scope. +/// +/// [`nested_attributed_fns`] rotated onto the width axis: the same +/// `exclude_tests` attribute scan, but the attributed items are +/// siblings of one another rather than nested, so the size parameter +/// is the parent's child count and the AST depth never moves. This is +/// the shape #1100's rejected fix was quadratic on — an unconditional +/// forward pass over the parent's children costs `O(children)` per +/// item, which on a flat file is `O(n^2)`; #1100 measured 94x on +/// 2 000 items. It is also the shape `bindgen` output has. +/// +/// Duplicate `fn f` names are a type-check error, not a parse error, +/// so the grammar accepts the repetition and the walk sees exactly the +/// shape it would on distinctly-named items. +#[must_use] +pub fn wide_attributed_fns(width: usize) -> String { + format!("{}\n", "#[inline] fn f() {} ".repeat(width)) +} + /// Rust: `#[cfg(all(all(… test …)))] fn gone() {}` plus one retained /// function. /// @@ -377,9 +409,25 @@ impl Workload { } } -/// One depth-scaling probe: a shape, the workload that exercises the -/// hot path under test, and the complexity class the walk is expected -/// to stay within. +/// The direction a probe's shape grows in. +/// +/// A walk can be linear in one direction and quadratic in the other, +/// so the axis is what says which claim a probe's exponent supports. +/// It also selects the shape invariant the probe must satisfy: +/// `shapes_nest_proportionally_to_depth` for one, and +/// `shapes_widen_without_deepening` for the other. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum Axis { + /// The size parameter is the shape's nesting depth. + Depth, + /// The size parameter is the number of siblings under one parent, + /// at a depth that does not change with it. + Width, +} + +/// One scaling probe: a shape, the axis it grows along, the workload +/// that exercises the hot path under test, and the complexity class +/// the walk is expected to stay within. /// /// A probe's `reading` is reported alongside every timing so a reader /// can see the walk did real work, and asserted non-zero by @@ -392,6 +440,8 @@ pub struct Probe { pub name: &'static str, /// Language whose grammar the shape is written for. pub lang: LANG, + /// Direction [`Probe::sizes`] grows the shape in. + pub axis: Axis, /// The walk this probe times, and how its headline reading is taken. pub workload: Workload, /// Generator for the probe's input. @@ -421,18 +471,37 @@ pub struct Probe { /// job the retired wall-clock assertions were guarding against. const LINEAR_DEPTHS: [usize; 3] = [1_000, 2_000, 4_000]; -/// Bound for a probe expected to be linear in nesting depth. +/// Sibling counts for the probes whose walk is expected to be linear +/// in a parent's child count. +/// +/// Reasoned independently of [`LINEAR_DEPTHS`] rather than copied: a +/// width shape spends nothing on nesting, so the same numbers would +/// buy a different amount of walk. The top of the ladder is the scale +/// #1100 measured its 94x regression at — a flat file of 2 000 +/// attributed items. The bottom is set from the measurement below it: +/// the 500-wide cell runs 1.86 ms, an order of magnitude above the +/// cheapest cells in the set (~0.2 ms), so fixed per-analysis cost is +/// a rounding error in the fit rather than a term flattening it. +const LINEAR_WIDTHS: [usize; 3] = [500, 1_000, 2_000]; + +/// Bound for a probe expected to be linear in its size parameter. /// /// Set from measurement, not from theory. A genuinely linear walk does -/// not fit exactly 1.0 over these depths: the tree outgrows cache as +/// not fit exactly 1.0 over these sizes: the tree outgrows cache as /// depth rises, so per-byte cost drifts up by roughly a quarter from -/// the shallowest cell to the deepest and every probe here fits +/// the shallowest cell to the deepest and every depth probe here fits /// 0.94-1.31 on an idle host. Before #1084 the three ancestor-walk -/// probes fit 1.95-2.01; the midpoint of those two bands is the cut, -/// and it is now the only bound in the set. +/// probes fit 1.95-2.01; the midpoint of those two bands is the cut. +/// +/// The width probe re-measured onto the same cut rather than +/// inheriting it. `nom/wide-attributed-fn` fits 0.97-1.00, with no +/// per-byte drift up its ladder to spend the headroom on, and #1100's +/// rejected forward-always scan takes it to 1.99 — 560.6 ms against +/// 7.2 ms at 2 000 items, a 78x of its own. One bound still covers +/// the set. const LINEAR_BOUND: f64 = 1.5; -/// The depth-scaling probe set. +/// The scaling probe set. /// /// One entry per hot path identified during the #1052 / #1062 / #1084 / /// #1096 / #1109 work, plus the controls that make those readings @@ -456,6 +525,7 @@ pub const PROBES: &[Probe] = &[ Probe { name: "tokens/nested-paren", lang: LANG::Rust, + axis: Axis::Depth, workload: Workload::Metrics { exclude_tests: false, selection: &[Metric::Tokens], @@ -479,6 +549,7 @@ pub const PROBES: &[Probe] = &[ Probe { name: "cognitive/nested-while", lang: LANG::C, + axis: Axis::Depth, workload: Workload::Metrics { exclude_tests: false, selection: &[Metric::Cognitive], @@ -494,6 +565,7 @@ pub const PROBES: &[Probe] = &[ Probe { name: "nom/nested-while", lang: LANG::C, + axis: Axis::Depth, workload: Workload::Metrics { exclude_tests: false, selection: &[Metric::Nom], @@ -509,6 +581,7 @@ pub const PROBES: &[Probe] = &[ Probe { name: "cognitive/nested-if", lang: LANG::C, + axis: Axis::Depth, workload: Workload::Metrics { exclude_tests: false, selection: &[Metric::Cognitive], @@ -526,6 +599,7 @@ pub const PROBES: &[Probe] = &[ Probe { name: "loc/nested-while", lang: LANG::C, + axis: Axis::Depth, workload: Workload::Metrics { exclude_tests: false, selection: &[Metric::Loc], @@ -541,6 +615,7 @@ pub const PROBES: &[Probe] = &[ Probe { name: "loc/nested-declaration", lang: LANG::C, + axis: Axis::Depth, workload: Workload::Metrics { exclude_tests: false, selection: &[Metric::Loc], @@ -561,6 +636,7 @@ pub const PROBES: &[Probe] = &[ Probe { name: "halstead/nested-paren", lang: LANG::Rust, + axis: Axis::Depth, workload: Workload::Metrics { exclude_tests: false, selection: &[Metric::Halstead], @@ -576,6 +652,7 @@ pub const PROBES: &[Probe] = &[ Probe { name: "halstead/nested-not", lang: LANG::Rust, + axis: Axis::Depth, workload: Workload::Metrics { exclude_tests: false, selection: &[Metric::Halstead], @@ -595,6 +672,7 @@ pub const PROBES: &[Probe] = &[ Probe { name: "abc/nested-block", lang: LANG::C, + axis: Axis::Depth, workload: Workload::Metrics { exclude_tests: false, selection: &[Metric::Abc], @@ -610,6 +688,7 @@ pub const PROBES: &[Probe] = &[ Probe { name: "abc/nested-if", lang: LANG::C, + axis: Axis::Depth, workload: Workload::Metrics { exclude_tests: false, selection: &[Metric::Abc], @@ -630,6 +709,7 @@ pub const PROBES: &[Probe] = &[ Probe { name: "cyclomatic/nested-and", lang: LANG::Python, + axis: Axis::Depth, workload: Workload::Metrics { exclude_tests: false, selection: &[Metric::Cyclomatic], @@ -645,6 +725,7 @@ pub const PROBES: &[Probe] = &[ Probe { name: "cyclomatic/nested-ternary", lang: LANG::Python, + axis: Axis::Depth, workload: Workload::Metrics { exclude_tests: false, selection: &[Metric::Cyclomatic], @@ -662,6 +743,7 @@ pub const PROBES: &[Probe] = &[ Probe { name: "loc/nested-quote", lang: LANG::Elixir, + axis: Axis::Depth, workload: Workload::Metrics { exclude_tests: false, selection: &[Metric::Loc], @@ -680,6 +762,7 @@ pub const PROBES: &[Probe] = &[ Probe { name: "nom/nested-quote", lang: LANG::Elixir, + axis: Axis::Depth, workload: Workload::Metrics { exclude_tests: false, selection: &[Metric::Nom], @@ -697,6 +780,7 @@ pub const PROBES: &[Probe] = &[ Probe { name: "nom/nested-fn", lang: LANG::Rust, + axis: Axis::Depth, workload: Workload::Metrics { exclude_tests: false, selection: &[Metric::Nom], @@ -714,6 +798,7 @@ pub const PROBES: &[Probe] = &[ Probe { name: "cognitive/nested-fn", lang: LANG::Rust, + axis: Axis::Depth, workload: Workload::Metrics { exclude_tests: false, selection: &[Metric::Cognitive], @@ -734,6 +819,7 @@ pub const PROBES: &[Probe] = &[ Probe { name: "ops/nested-fn", lang: LANG::Rust, + axis: Axis::Depth, workload: Workload::Ops { // Depth-invariant on purpose: `nested_fns` reuses one // identifier at every level, so the root vocabulary is the @@ -764,6 +850,7 @@ pub const PROBES: &[Probe] = &[ Probe { name: "loc/nested-fn", lang: LANG::Rust, + axis: Axis::Depth, workload: Workload::Metrics { exclude_tests: false, selection: &[Metric::Loc], @@ -781,6 +868,7 @@ pub const PROBES: &[Probe] = &[ Probe { name: "loc/nested-fn-rows", lang: LANG::Rust, + axis: Axis::Depth, workload: Workload::Metrics { exclude_tests: false, selection: &[Metric::Loc], @@ -801,6 +889,7 @@ pub const PROBES: &[Probe] = &[ Probe { name: "nom/nested-fn-rows", lang: LANG::Rust, + axis: Axis::Depth, workload: Workload::Metrics { exclude_tests: false, selection: &[Metric::Nom], @@ -817,6 +906,7 @@ pub const PROBES: &[Probe] = &[ Probe { name: "nom/nested-declared-function", lang: LANG::Javascript, + axis: Axis::Depth, workload: Workload::Metrics { exclude_tests: false, selection: &[Metric::Nom], @@ -833,6 +923,7 @@ pub const PROBES: &[Probe] = &[ Probe { name: "nom/nested-arrow", lang: LANG::Javascript, + axis: Axis::Depth, workload: Workload::Metrics { exclude_tests: false, selection: &[Metric::Nom], @@ -853,6 +944,7 @@ pub const PROBES: &[Probe] = &[ Probe { name: "nom/nested-attributed-fn", lang: LANG::Rust, + axis: Axis::Depth, workload: Workload::Metrics { exclude_tests: true, selection: &[Metric::Nom], @@ -872,9 +964,34 @@ pub const PROBES: &[Probe] = &[ with `exclude_tests` off, so it controls for both \ the nesting and the flag at once.", }, + Probe { + name: "nom/wide-attributed-fn", + lang: LANG::Rust, + axis: Axis::Width, + workload: Workload::Metrics { + exclude_tests: true, + selection: &[Metric::Nom], + reading: |m| m.nom.total(), + }, + render: wide_attributed_fns, + sizes: LINEAR_WIDTHS, + max_exponent: LINEAR_BOUND, + rationale: "#1100 on the other axis, and the only probe on it. \ + The fix the issue originally proposed — read the \ + attribute run forward from the parent, always — is \ + `O(children)` per item, so on a flat file it is \ + quadratic in the file's item count: #1100 measured \ + a generated 2 000-item file going from 6.0 ms to \ + 569 ms while every depth probe stayed green. \ + Reintroducing that scan takes this probe to 1.99. \ + The scan now budgets the parent's child count \ + against the node's depth, and this probe is what \ + keeps the shallow-wide half of that trade measured.", + }, Probe { name: "nom/nested-cfg-predicate", lang: LANG::Rust, + axis: Axis::Depth, workload: Workload::Metrics { exclude_tests: true, selection: &[Metric::Nom], @@ -899,7 +1016,7 @@ pub const PROBES: &[Probe] = &[ mod tests { use big_code_analysis::{Ast, Source}; - use super::{PROBES, Probe}; + use super::{Axis, PROBES, Probe}; /// Deepest `tree_sitter` node depth reachable from the root. /// @@ -917,14 +1034,41 @@ mod tests { deepest } - fn parse(probe: &Probe, depth: usize) -> Ast { - let source = (probe.render)(depth); + /// Most children any one node in the tree has — the quantity a + /// width shape grows, and the one the `exclude_tests` attribute + /// scan is priced against. + fn max_child_count(ast: &Ast) -> usize { + let mut stack = vec![ast.as_tree_sitter().root_node()]; + let mut widest = 0; + while let Some(node) = stack.pop() { + widest = widest.max(node.child_count()); + let mut cursor = node.walk(); + stack.extend(node.children(&mut cursor)); + } + widest + } + + /// The probes on `axis`, asserted non-empty. + /// + /// Each shape invariant below applies to one axis, and a loop over + /// an empty filtered set passes every assertion inside it. Without + /// this, deleting the last probe of an axis would silently retire + /// that axis's invariant rather than fail — which for the width + /// axis is exactly the state #1133 was filed about. + fn probes_on(axis: Axis) -> Vec<&'static Probe> { + let probes: Vec<&Probe> = PROBES.iter().filter(|probe| probe.axis == axis).collect(); + assert!(!probes.is_empty(), "PROBES must cover the {axis:?} axis"); + probes + } + + fn parse(probe: &Probe, size: usize) -> Ast { + let source = (probe.render)(size); Ast::parse(Source::new(probe.lang, source.as_bytes())) - .unwrap_or_else(|e| panic!("{} at depth {depth} must parse: {e}", probe.name)) + .unwrap_or_else(|e| panic!("{} at size {size} must parse: {e}", probe.name)) } - /// Every generator emits a constant number of bytes per nesting - /// level. + /// Every generator emits a constant number of bytes per unit of + /// its size parameter. /// /// This is the trap that invalidated two measurements during the /// #1052 / #1062 work: an indented generator makes the input grow @@ -938,37 +1082,43 @@ mod tests { assert_eq!( b - a, c - b, - "{}: bytes must grow linearly with depth, got {a} -> {b} -> {c}", + "{}: bytes must grow linearly with size, got {a} -> {b} -> {c}", probe.name, ); assert!( b > a, - "{}: depth must actually add bytes, got {a} -> {b}", + "{}: size must actually add bytes, got {a} -> {b}", probe.name, ); } } - /// Every shape parses cleanly. + /// Every shape parses cleanly at the smallest size the gate + /// measures it at. /// /// Without this, a grammar bump that stops accepting one of these /// snippets would leave the probe measuring `tree_sitter`'s error - /// recovery while still reporting a plausible exponent. + /// recovery while still reporting a plausible exponent. The size + /// comes from the probe rather than from a literal because a + /// parser limit — a recursion cap, a token-count ceiling — is + /// reached at scale and not on a toy input, so a small fixed size + /// answers a different question than the gate asks. #[test] fn shapes_parse_without_error() { for probe in PROBES { - let ast = parse(probe, 8); + let size = probe.sizes[0]; + let ast = parse(probe, size); assert!( !ast.as_tree_sitter().root_node().has_error(), - "{}: shape must parse without an ERROR node:\n{}", + "{}: shape must parse without an ERROR node at size {size}:\n{}…", probe.name, - (probe.render)(8), + (probe.render)(size).chars().take(200).collect::(), ); } } - /// Every shape actually nests: doubling `depth` adds at least - /// `depth` more levels of AST. + /// Every depth shape actually nests: doubling the size adds at + /// least that many more levels of AST. /// /// A shape that flattened — because a grammar started folding the /// repetition into a list node, say — would still parse, still @@ -976,37 +1126,85 @@ mod tests { /// 1.0 while measuring nothing the probe claims to measure. #[test] fn shapes_nest_proportionally_to_depth() { - for probe in PROBES { + for probe in probes_on(Axis::Depth) { let shallow = ast_depth(&parse(probe, 16)); let deep = ast_depth(&parse(probe, 32)); assert!( - deep - shallow >= 16, - "{probe_name}: doubling depth 16 -> 32 added only \ - {added} AST levels ({shallow} -> {deep}); the shape is \ - not nesting", + deep >= shallow + 16, + "{probe_name}: doubling depth 16 -> 32 grew the tree \ + {shallow} -> {deep} AST levels; the shape is not \ + nesting", + probe_name = probe.name, + ); + } + } + + /// Every width shape widens, and *only* widens. + /// + /// The positive half mirrors + /// [`shapes_nest_proportionally_to_depth`]: doubling the size must + /// add at least that many children to the widest node, so a shape + /// that stopped growing is caught. + /// + /// The negative half is the one that matters. A "width" probe that + /// quietly began nesting — a generator edited to wrap its items, + /// or a grammar that started grouping them — would satisfy the + /// first half while measuring the depth axis, and report a healthy + /// exponent for a width bound nothing is testing any more. Pinning + /// the AST depth *constant* across the two sizes is what + /// distinguishes the two axes; nothing else here can. + /// + /// Sixteen and thirty-two, not the probe's own ladder as the two + /// tests below use: both halves are structural claims about the + /// generator, true at any pair of sizes, and 500 vs 1 000 would + /// cost a second of parsing to assert the same thing. + #[test] + fn shapes_widen_without_deepening() { + for probe in probes_on(Axis::Width) { + let (narrow, wide) = (parse(probe, 16), parse(probe, 32)); + let (narrow_width, wide_width) = (max_child_count(&narrow), max_child_count(&wide)); + assert!( + wide_width >= narrow_width + 16, + "{probe_name}: doubling width 16 -> 32 grew the widest \ + node {narrow_width} -> {wide_width} children; the \ + shape is not widening", probe_name = probe.name, - added = deep - shallow, + ); + assert_eq!( + ast_depth(&narrow), + ast_depth(&wide), + "{}: a width shape must not gain AST levels with its \ + size, or its exponent is measuring the depth axis", + probe.name, ); } } /// Every probe's workload produces a non-zero reading on its own - /// shape. + /// shape, at the smallest size the gate measures it at. /// /// Pairing a shape with a workload that scores zero on it would /// benchmark the walk's fixed overhead and nothing else, and the /// resulting exponent would look excellent forever. + /// + /// The size is the probe's own rather than a fixed small one: a + /// literal that happens to suit the nesting shapes is sixteen-odd + /// siblings on a width shape — too small to be the shape the probe + /// stands for, and a reading that only becomes non-zero at scale + /// would fail here for a reason unrelated to the pairing. The + /// whole set costs ~0.4 s in a debug build at these sizes. #[test] fn probe_workload_is_exercised() { for probe in PROBES { - let ast = parse(probe, 8); + let size = probe.sizes[0]; + let ast = parse(probe, size); let reading = probe .workload .walk(&ast, probe.workload.options()) .unwrap_or_else(|e| panic!("{}: walker must succeed: {e}", probe.name)); assert!( reading > 0, - "{}: workload scored zero on its own shape", + "{}: workload scored zero on its own shape at size {size}", probe.name, ); } diff --git a/docs/development/benchmarking.md b/docs/development/benchmarking.md index 281d91f60..0445e7528 100644 --- a/docs/development/benchmarking.md +++ b/docs/development/benchmarking.md @@ -9,9 +9,9 @@ nested files unanalysable. It has two halves, and they answer different questions. - **The complexity-class gate** (`benches/scaling.rs`) answers "does - doubling the nesting depth roughly double the cost?" It fails the - process when a probe's measured exponent exceeds the bound that - probe declares. + doubling the input — its nesting depth, or one parent's child + count — roughly double the cost?" It fails the process when a + probe's measured exponent exceeds the bound that probe declares. - **The criterion benchmarks** (`benches/metric_walk.rs`) answer "how fast is this, and did my change help?" They use [criterion][criterion], which reports a confidence interval rather @@ -60,11 +60,30 @@ git submodule update --init --recursive Each probe pairs a generated source shape with the workload that exercises one hot path — a metric selection through `Ast::metrics`, or the operator/operand walk behind `Ast::ops`. The shape is rendered at three doubling -depths, every (probe, depth) cell is measured once per round with the +sizes, every (probe, size) cell is measured once per round with the visit order rotated between rounds, and the reported figure is the -slope of `ln(time)` against `ln(depth)`. A linear walk sits near 1.0; +slope of `ln(time)` against `ln(size)`. A linear walk sits near 1.0; a quadratic one sits near 2.0. +A probe declares which **axis** its size parameter grows along, because +a walk can be linear in one and quadratic in the other: + +- `Axis::Depth` — the size is the shape's nesting depth. Every probe + but one is on this axis, and they exist because `tree_sitter` stores + no parent pointer, so any predicate that resolves an ancestor by + climbing is `O(depth)` per node. +- `Axis::Width` — the size is the number of siblings under one parent, + at a depth that does not move. `nom/wide-attributed-fn` is the only + one, and it exists because #1100's fix trades the two axes against + each other (see below). + +The axis also selects the shape invariant a probe must satisfy in +`shapes.rs`: a depth shape has to gain AST levels in proportion to its +size, and a width shape has to gain children while its AST depth stays +**constant**. That second half is what stops a "width" probe that +quietly began nesting from reporting a healthy exponent for a bound +nothing is testing any more. + Output looks like this: ```text @@ -80,12 +99,15 @@ Read it as follows. - `median ms` is what the fit uses. `min` and `max` bracket the rounds. A wide spread means the host was busy and the run should be repeated, not interpreted. -- `bytes` grows linearly with `depth` for every shape, by +- `bytes` grows linearly with `size` for every shape, by construction. If it did not, a walk that is linear in input size - would read as superlinear in depth. -- `ns/byte` drifts up with depth even for a linear walk, because the - tree outgrows cache. That drift is why the linear bound is 1.5 and - not 1.05. + would read as superlinear in its size parameter. +- `ns/byte` drifts up with size on a depth shape even for a linear + walk, because the tree outgrows cache. That drift is why the linear + bound is 1.5 and not 1.05. The width shape shows no such drift — + `nom/wide-attributed-fn` holds ~180 across its ladder — which is + observed rather than explained; do not read a cache argument into + it. - `iter` is how many walks were folded into one timed sample. The cheapest cells run in a few hundred microseconds, where clock resolution is a visible fraction of the reading, so the harness @@ -94,38 +116,39 @@ Read it as follows. probe that stopped measuring anything is visible: a shape paired with a workload that scores zero on it would time the walk's fixed overhead and report an excellent exponent forever. A reading that - does not grow with depth is fine as long as it is non-zero — the - depth signal lives in the timing column — but say so in the probe's - `rationale`, as `ops/nested-fn` does. + does not grow with the size is fine as long as it is non-zero — the + scaling signal lives in the timing column — but say so in the + probe's `rationale`, as `ops/nested-fn` does. ### What the probes cover -| Probe | Language | Hot path | Class today | -|---|---|---|---| -| `tokens/nested-paren` | Rust | inherited in-comment flag (#1052) | linear | -| `cognitive/nested-while` | C | `get_nesting_from_map` (#1062) | linear | -| `nom/nested-while` | C | metric control for the row above | linear | -| `cognitive/nested-if` | C | `Checker::is_else_if` | linear | -| `loc/nested-while` | C | shape control for the row below | linear | -| `loc/nested-declaration` | C | `Node::count_specific_ancestors` | linear | -| `nom/nested-quote` | Elixir | `elixir_is_inside_quote_block` | linear | -| `nom/nested-fn` | Rust | `FuncSpace` nesting; metric control for the row below | linear | -| `cognitive/nested-fn` | Rust | `increment_function_depth` (#1062) | linear | -| `ops/nested-fn` | Rust | the `Ast::ops` walk (#1110) | linear | -| `loc/nested-fn` | Rust | shape control for the row below | linear | -| `loc/nested-fn-rows` | Rust | `Ploc::merge` / `Cloc::merge` row-set union (#1109) | linear | -| `nom/nested-fn-rows` | Rust | metric control for the row above | linear | -| `nom/nested-declared-function` | JavaScript | shape control for the row below | linear | -| `nom/nested-arrow` | JavaScript | JS-family `is_func` / `is_closure` (#1088) | linear | -| `halstead/nested-paren` | Rust | shape control for the row below | linear | -| `halstead/nested-not` | Rust | `Getter::get_op_type`'s parent read (#1096) | linear | -| `abc/nested-block` | C | shape control for the row below | linear | -| `abc/nested-if` | C | the C-family ABC container walker (#1096) | linear | -| `cyclomatic/nested-and` | Python | shape control for the row below | linear | -| `cyclomatic/nested-ternary` | Python | `Node::parent_grandparent_match` (#1096) | linear | -| `loc/nested-quote` | Elixir | `loc`'s Elixir catch-all arm (#1096) | linear | -| `nom/nested-attributed-fn` | Rust | the `exclude_tests` outer-attribute scan (#1100) | linear | -| `nom/nested-cfg-predicate` | Rust | the `cfg(...)` predicate classifier (#1105) | linear | +| Probe | Language | Axis | Hot path | Class today | +|---|---|---|---|---| +| `tokens/nested-paren` | Rust | depth | inherited in-comment flag (#1052) | linear | +| `cognitive/nested-while` | C | depth | `get_nesting_from_map` (#1062) | linear | +| `nom/nested-while` | C | depth | metric control for the row above | linear | +| `cognitive/nested-if` | C | depth | `Checker::is_else_if` | linear | +| `loc/nested-while` | C | depth | shape control for the row below | linear | +| `loc/nested-declaration` | C | depth | `Node::count_specific_ancestors` | linear | +| `nom/nested-quote` | Elixir | depth | `elixir_is_inside_quote_block` | linear | +| `nom/nested-fn` | Rust | depth | `FuncSpace` nesting; metric control for the row below | linear | +| `cognitive/nested-fn` | Rust | depth | `increment_function_depth` (#1062) | linear | +| `ops/nested-fn` | Rust | depth | the `Ast::ops` walk (#1110) | linear | +| `loc/nested-fn` | Rust | depth | shape control for the row below | linear | +| `loc/nested-fn-rows` | Rust | depth | `Ploc::merge` / `Cloc::merge` row-set union (#1109) | linear | +| `nom/nested-fn-rows` | Rust | depth | metric control for the row above | linear | +| `nom/nested-declared-function` | JavaScript | depth | shape control for the row below | linear | +| `nom/nested-arrow` | JavaScript | depth | JS-family `is_func` / `is_closure` (#1088) | linear | +| `halstead/nested-paren` | Rust | depth | shape control for the row below | linear | +| `halstead/nested-not` | Rust | depth | `Getter::get_op_type`'s parent read (#1096) | linear | +| `abc/nested-block` | C | depth | shape control for the row below | linear | +| `abc/nested-if` | C | depth | the C-family ABC container walker (#1096) | linear | +| `cyclomatic/nested-and` | Python | depth | shape control for the row below | linear | +| `cyclomatic/nested-ternary` | Python | depth | `Node::parent_grandparent_match` (#1096) | linear | +| `loc/nested-quote` | Elixir | depth | `loc`'s Elixir catch-all arm (#1096) | linear | +| `nom/nested-attributed-fn` | Rust | depth | the `exclude_tests` outer-attribute scan (#1100) | linear | +| `nom/wide-attributed-fn` | Rust | width | the same scan on the width axis (#1100) | linear | +| `nom/nested-cfg-predicate` | Rust | depth | the `cfg(...)` predicate classifier (#1105) | linear | Four of these were quadratic when the harness landed, and they shared one cause: `tree_sitter` stores no parent pointer, so `Node::parent` @@ -217,19 +240,25 @@ the parent's child count against the node's depth, so it reads forward only where that is the cheaper bound and a shallow wide parent keeps exactly the walk it had. -**The width axis is not guarded here.** No probe renders the wide -shape. A fixed-depth width sweep would grow its input affinely, as the -module requires, but it does not nest — and `shapes.rs` asserts that -every probe's shape gains AST levels in proportion to its parameter -(`shapes_nest_proportionally_to_depth`), which such a sweep would fail -by construction. What the unit suite pins -is the *dispatch*, not its cost — +**Both halves of that trade are now measured** (#1133). +`nom/wide-attributed-fn` renders the wide shape — a flat file of +`n` top-level `#[inline] fn f() {}` items, 500 / 1 000 / 2 000 — under +the same `exclude_tests` walk. It fits 0.97-1.00 against the budgeted +dispatch. Reintroducing the rejected forward-always scan behind a local +patch takes it to **1.99**, at 35.6 / 145.5 / 560.6 ms against +1.9 / 3.8 / 7.2 ms — 78x at the top of the ladder, the same order as +the 94x #1100 measured — while every depth probe stayed inside its +1.5 bound, `nom/nested-attributed-fn` (the same scan, nesting instead +of widening) among them at 1.06. That is the evidence that the probe +covers what it claims and that the depth probes do not. + +The unit suite still pins the *dispatch* separately: `the_exclude_tests_prune_reads_forward_up_to_its_depth_scaled_budget` in `src/node.rs` asserts which arm each boundary shape takes, so widening the budget past a shallow parent fails a test rather than -slipping through. The numbers the budget is derived from have no -automated guard at all; re-measure by hand when touching either -constant. +slipping through. The break-even *numbers* the budget is derived from +remain unguarded — the gate sees the complexity class, not the +constant — so re-measure by hand when touching either constant. `nom/nested-cfg-predicate` guards [#1105][cfg-predicate] and is the only probe that grows one *attribute* rather than the code around it: @@ -384,10 +413,18 @@ alone. Add a `Probe` to `PROBES` in `big-code-analysis-bench/src/shapes.rs`. The unit tests in that module enforce what a probe has to satisfy: -bytes affine in depth, no parse errors, AST depth growing with the -depth parameter, a non-zero workload reading, and depths that double. -Set `max_exponent` from a measurement on an idle host, not from -theory, and say in `rationale` which call the probe is watching. +bytes affine in the size parameter, no parse errors at the smallest +declared size, a non-zero workload reading there, sizes that double, +and the invariant its `axis` selects — AST levels growing with the size +on `Axis::Depth`, children growing while AST depth holds still on +`Axis::Width`. Set `max_exponent` from a measurement on an idle host, +not from theory, and say in `rationale` which call the probe is +watching. + +Then falsify it. Reintroduce the regression the probe claims to catch +behind a local patch, confirm the probe goes red and its controls stay +green, and record both numbers — a probe nobody has made fail is a +guard on paper. The width probe's own run is quoted above. ## Criterion measurements @@ -398,7 +435,7 @@ Three groups: - `corpus/walk` is one benchmark per metric family over the already-parsed slice. This is the number to quote when a change claims to make a metric cheaper. -- `shape/walk` is the depth-scaling shapes at a single depth, so a +- `shape/walk` is the scaling shapes at a single size, so a constant-factor change on a pathological input is visible even when its complexity class did not move. @@ -497,7 +534,7 @@ structurally; the fourth is on you. `byte_growth_is_affine` fails if one stops doing so. 2. **`fd -e py | wc -l` is not the corpus.** Report what was analysed, which `CorpusSlice::summary` does for you. -3. **A ratio between two depths is host-independent but not +3. **A ratio between two sizes is host-independent but not load-independent.** The two measurements are sequential, so a load spike between them skews the ratio by itself; best-of-three sheds bursty contention but not sustained overhead. The gate interleaves From da7784f18df0be48bd8b98f14f7a44897b91ce77 Mon Sep 17 00:00:00 2001 From: Elijah Zupancic Date: Fri, 31 Jul 2026 23:05:03 -0700 Subject: [PATCH 07/36] fix(cognitive): correct inverted Python boolean-walk doc, test arm MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `python_apply_boolean_operator`'s doc comment described the enclosing walk as counting control constructs up to the nearest lambda. `count_specific_ancestors` is `(ancestors, check, stop)` and the call passes `python_is_lambda` as `check`, so it does the reverse: it counts lambdas and stops at `expression_list` / `if` / `for` / `while`. Add `python_boolean_in_expression_list_under_lambda`, which discriminates the `ExpressionList` stop arm — previously covered by no test. Both grammar routes that put an `expression_list` under a lambda are exercised: a parenthesised `yield` and an f-string interpolation. Each independently scores 1 with the arm and 2 without, and deleting only that arm makes this test the sole failure across the lib suite. Keep the `if`/`for`/`while` arms with a comment recording why they cannot change a count: they do fire, but no lambda can sit above an `if`/`for`/`while` statement, so stopping there is indistinguishable from running to the module root. The comment sits above the `matches!` rather than inside the pattern, per .claude/rules/formatting.md. No metric values move. Fixes #1090 --- src/metrics/cognitive.rs | 65 +++++++++++++++++++++++++++------ src/metrics/cognitive/python.rs | 20 +++++++++- 2 files changed, 72 insertions(+), 13 deletions(-) diff --git a/src/metrics/cognitive.rs b/src/metrics/cognitive.rs index 9644e8403..13a99e289 100644 --- a/src/metrics/cognitive.rs +++ b/src/metrics/cognitive.rs @@ -3578,17 +3578,15 @@ mod tests { /// Cognitive cost of a boolean sequence inside a `lambda`, under /// each statement kind that can enclose one. /// - /// This pins the scores, not the stop set. `python_boolean_ancestor_ - /// nesting`'s inner `count_specific_ancestors` stops its - /// enclosing-lambda walk at `ExpressionList | IfStatement | - /// ForStatement | WhileStatement`, and none of those four arms is - /// observable: deleting three of them (or the `ExpressionList` arm - /// alone) leaves this test, and the whole 3 097-test lib suite, - /// green (#1090). A lambda body is a single expression, so a lambda can - /// never be an ancestor of an `if`/`for`/`while` *statement* — there - /// is no outer lambda for a missing stop to over-count. Do not - /// "strengthen" this test by asserting on the arms; it cannot - /// discriminate them. Tracked in #1090. + /// This pins the scores, not the stop set. + /// `python_apply_boolean_operator`'s enclosing-lambda walk stops at + /// `ExpressionList | IfStatement | ForStatement | WhileStatement`, + /// and no fixture below discriminates any of those arms: none has a + /// `lambda` above the stop node, so halting there and running to the + /// module root give the same count. Do not "strengthen" this test by + /// asserting on the arms — the one arm that can differ, + /// `ExpressionList`, is discriminated by + /// `python_boolean_in_expression_list_under_lambda` (#1090). #[test] fn python_boolean_in_lambda_scores_under_each_enclosing_statement() { use crate::test_support::metrics_verbatim; @@ -3633,6 +3631,51 @@ mod tests { ); } + /// The `ExpressionList` arm of `python_apply_boolean_operator`'s + /// stop set — the only one of its four arms that can change a score. + /// + /// Two grammar productions can put an `expression_list` under a + /// `lambda`: a parenthesised `yield`, and an f-string interpolation + /// (`_f_expression`). Every other site tree-sitter-python spells + /// `expression_list` at is either a statement (`return`, `del`, + /// `raise`, `for … in`) or an assignment right-hand side, and a + /// lambda body is a single expression, so it can contain none of + /// them. In both fixtures the `expression_list` sits directly above + /// the `boolean_operator` and stops the enclosing-lambda walk before + /// the `lambda` is counted, leaving the +1 boolean sequence alone. + /// + /// Measured, not derived: deleting only the `ExpressionList` arm + /// takes both fixtures from 1 to 2, while the doubly-nested lambda + /// in `python_boolean_in_lambda_scores_under_each_enclosing_statement` + /// stays at 3 (#1090). Whether 1 or 2 is the *right* score is a + /// separate question — this pins current behaviour, and the + /// per-lambda surcharge itself is under review in #1150. + #[test] + fn python_boolean_in_expression_list_under_lambda() { + use crate::test_support::metrics_verbatim; + + for (route, source) in [ + ("parenthesised yield", "k = lambda q: (yield a and b, c)\n"), + ( + "f-string interpolation", + "m = lambda q: f\"{a and b, c}\"\n", + ), + ] { + let metrics = metrics_verbatim( + crate::LANG::Python, + source.as_bytes(), + MetricsOptions::default(), + ); + + assert_eq!( + metrics.cognitive.cognitive_sum(), + 1, + "{route}: +1 boolean sequence only — the `expression_list` \ + stops the enclosing-lambda walk before it reaches the `lambda`" + ); + } + } + #[test] fn python_nested_functions_lambdas() { check_metrics::( diff --git a/src/metrics/cognitive/python.rs b/src/metrics/cognitive/python.rs index 5e94995c3..65c6a82c1 100644 --- a/src/metrics/cognitive/python.rs +++ b/src/metrics/cognitive/python.rs @@ -67,8 +67,12 @@ fn python_comprehension_clause_nesting( /// cost: if walking ancestors (stopping at a `lambda` boundary) finds /// another `boolean_operator` first, this node is nested inside one /// already counted, so the `== 0` guard skips it. The outermost operator -/// then adds one structural unit per enclosing control construct -/// (`expression_list`, `if`/`for`/`while`) up to the nearest lambda. +/// then adds one structural unit per enclosing `lambda`, walking upward +/// only as far as the nearest `expression_list` / `if` / `for` / `while`. +/// `count_specific_ancestors` takes `(ancestors, check, stop)` in that +/// order, so it is the lambdas that get counted and the control +/// constructs that end the walk — the reverse of what this comment +/// claimed before #1090. fn python_apply_boolean_operator<'a>( node: &Node<'a>, ancestors: Ancestors<'a, '_>, @@ -83,6 +87,18 @@ fn python_apply_boolean_operator<'a>( { stats.structural += node.count_specific_ancestors::(ancestors, python_is_lambda, |node| { + // All four arms fire in practice, but only `ExpressionList` + // can change the count: a lambda body is a single + // expression, so no lambda ever sits *above* an + // `if`/`for`/`while` statement, and stopping at one is + // indistinguishable from running to the module root. The + // three statement kinds are kept as an explicit + // statement-boundary set rather than a claim about + // coverage. `ExpressionList` is the observable arm, + // reached via a parenthesised `yield` or an f-string + // interpolation — see + // `python_boolean_in_expression_list_under_lambda` + // (#1090). matches!( node.kind_id().into(), ExpressionList | IfStatement | ForStatement | WhileStatement From edd4b3807739e01793d0afa28191007c37c72c5c Mon Sep 17 00:00:00 2001 From: Elijah Zupancic Date: Fri, 31 Jul 2026 23:19:51 -0700 Subject: [PATCH 08/36] refactor(cli): drop the two-step stdout lock, shorten Stdio paths `Stdout::lock` has returned a `StdoutLock<'static>` since 1.61, so the `let stdout = ...` anchor the dump/find banners kept is dead weight; the rest of the crate, including the sibling hunk in the same function's file, already writes `std::io::stdout().lock()` inline. In the #1132 stdout tests, import `std::process::{Output, Stdio}` rather than spelling the path at six call sites. No assertion, fixture, or control flow changes. --- big-code-analysis-cli/src/dispatch.rs | 6 ++---- big-code-analysis-cli/tests/read_failures.rs | 19 +++++++------------ 2 files changed, 9 insertions(+), 16 deletions(-) diff --git a/big-code-analysis-cli/src/dispatch.rs b/big-code-analysis-cli/src/dispatch.rs index 05f30ee4f..f80837465 100644 --- a/big-code-analysis-cli/src/dispatch.rs +++ b/big-code-analysis-cli/src/dispatch.rs @@ -220,8 +220,7 @@ fn dispatch_dump( // which panics on a write error instead of returning one (#1132): // going through `?` routes a full disk into the walk's // `write_failures` tally and leaves `| head` a swallowed `BrokenPipe`. - let stdout = std::io::stdout(); - let mut out = stdout.lock(); + let mut out = std::io::stdout().lock(); writeln!(out, "== {} ==", path.display())?; dump_node_with_color( ast.source(), @@ -401,8 +400,7 @@ fn dispatch_find( // multi-file `find` output stays attributable. The stdout lock is // held across the banner, every match, and the trailing blank line // for the reason given in `dispatch_dump`. - let stdout = std::io::stdout(); - let mut out = stdout.lock(); + let mut out = std::io::stdout().lock(); writeln!(out, "== {} ==", path.display())?; for node in &found { dump_node_with_color( diff --git a/big-code-analysis-cli/tests/read_failures.rs b/big-code-analysis-cli/tests/read_failures.rs index f83e8a1e9..708ddd8c0 100644 --- a/big-code-analysis-cli/tests/read_failures.rs +++ b/big-code-analysis-cli/tests/read_failures.rs @@ -35,6 +35,7 @@ mod common; #[cfg(unix)] mod unix { use std::fs; + use std::process::{Output, Stdio}; use assert_cmd::Command; use predicates::prelude::*; @@ -503,14 +504,14 @@ mod unix { subcommand: &str, extra: &[&str], source: &str, - stdout: std::process::Stdio, - ) -> std::process::Output { + stdout: Stdio, + ) -> Output { common::std_bca_command_in(dir.path()) .arg(subcommand) .args(extra) .args(["--no-config", "--paths", source]) .stdout(stdout) - .stderr(std::process::Stdio::piped()) + .stderr(Stdio::piped()) .spawn() .expect("spawn bca") .wait_with_output() @@ -564,13 +565,7 @@ mod unix { // Control: the same invocation against a writable stdout exits 0, // so the exit-1 above came from the write and not from a rejected // flag set or an unusable fixture. - let ok = run_with_stdout( - &dir, - subcommand, - extra, - &source, - std::process::Stdio::null(), - ); + let ok = run_with_stdout(&dir, subcommand, extra, &source, Stdio::null()); assert!( ok.status.success(), "`bca {subcommand}` must succeed with a writable stdout; stderr: {}", @@ -648,8 +643,8 @@ mod unix { let mut child = common::std_bca_command_in(dir.path()) .args(["dump", "--no-config", "--paths", &source]) - .stdout(std::process::Stdio::piped()) - .stderr(std::process::Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) .spawn() .expect("spawn bca"); From 8bf8b9b5d32e6e31fcf146b4e3f59f79bf7ad74b Mon Sep 17 00:00:00 2001 From: Elijah Zupancic Date: Sat, 1 Aug 2026 00:04:15 -0700 Subject: [PATCH 09/36] fix(metrics/cognitive): reset nesting at a Python def boundary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Python's `FunctionDefinition` arm bumped the function-depth surcharge but never reset `nesting.conditional`, so a `def` written inside an `if` inherited the enclosing function's nesting on top of its own depth. `def inner` two conditionals deep scored 4 where the byte-equivalent Java local class scores 2. Python was the only module with a syntactic function-definition node missing the reset. The book documents the resetting behaviour as the contract, so the published documentation described behaviour Python did not implement. The `Lambda` arm deliberately keeps its current shape. Every sibling lambda arm is a bare `nesting.lambda += 1` that leaves `conditional` alone, because a lambda amplifies the enclosing nesting rather than replacing it; adding the reset there would break the parity the function-boundary reset exists to preserve. The book sentence naming lambdas alongside nested functions was wrong on its own terms and is split into one bullet per rule. The regression test nests the definition two conditionals deep. At one level the Java half cannot discriminate — reset plus surcharge and no-reset plus no-surcharge both yield 2. No snapshot churn: the integration corpus contains no Python files. Fixes #1149 --- big-code-analysis-book/src/metrics.md | 11 ++-- src/metrics/cognitive.rs | 73 ++++++++++++++++++++++++++- src/metrics/cognitive/python.rs | 22 +++++++- src/test_support.rs | 28 ++++++++++ 4 files changed, 128 insertions(+), 6 deletions(-) diff --git a/big-code-analysis-book/src/metrics.md b/big-code-analysis-book/src/metrics.md index b11895f6f..5e279558c 100644 --- a/big-code-analysis-book/src/metrics.md +++ b/big-code-analysis-book/src/metrics.md @@ -324,12 +324,17 @@ Sonar ecosystem. SonarSource specification adds for languages that expose those shapes syntactically. - For every language with a syntactic function-definition node, a - nested function (a local function, lambda, or a method on a - local / inner class) **resets the nesting counter to zero** at - its boundary and adds a function-depth surcharge, so control flow + nested function — a local function, or a method on a local / + inner class — **resets the nesting counter to zero** at its + boundary and adds a function-depth surcharge, so control flow inside it is scored against the nested function's own depth rather than the enclosing function's nesting. Byte-equivalent constructs therefore score identically across languages. +- A lambda or closure (`x -> …`, `|x| …`, `lambda x: …`, a Ruby + block, an Objective-C block) is not a function boundary in that + sense. It adds a surcharge *on top of* the enclosing nesting + instead of replacing it, so a decision inside a lambda written + inside an `if` is charged for both. ## Cyclomatic Complexity (CC) {#cyclomatic-complexity-cc} diff --git a/src/metrics/cognitive.rs b/src/metrics/cognitive.rs index 13a99e289..a9c57d743 100644 --- a/src/metrics/cognitive.rs +++ b/src/metrics/cognitive.rs @@ -630,7 +630,7 @@ implement_metric_trait!(Cognitive, PreprocCode, CcommentCode); clippy::too_many_lines )] mod tests { - use crate::test_support::{check_func_space, check_metrics}; + use crate::test_support::{check_func_space, check_metrics, function_space}; use super::*; @@ -3705,6 +3705,77 @@ mod tests { ); } + /// #1149: a `def` nested inside a conditional is scored against its + /// own depth, not the enclosing function's. + /// + /// Python was the only language with a syntactic function-definition + /// node that never reset `nesting.conditional` at the boundary, so + /// `inner` charged base(1) + inherited-conditional(1) + + /// function-depth(1) = 3 where every sibling charges base(1) + + /// function-depth(1) = 2. `python_nested_functions_lambdas` missed it + /// because its nested `def` sits at function top level, where + /// `conditional` is already 0. + /// + /// The Java companion is the byte-equivalent construct — Java has no + /// local function, so a method reaches the inside of an `if` only + /// through a class body declared there — and pins the book's + /// "byte-equivalent constructs therefore score identically across + /// languages" claim with a test rather than prose. + /// + /// Both fixtures nest the definition **two** conditionals deep, not + /// one. At one level the Java assertion cannot discriminate: reset + + /// depth-surcharge and no-reset + no-surcharge both yield 2, so + /// deleting both lines from `cognitive/java.rs` leaves it green. At + /// two levels the correct answer stays 2 while an unreset + /// implementation gives 4 (Python, which also bumps depth) or 3 + /// (depth dropped as well). + #[test] + fn python_nested_def_inside_conditional_scores_like_java() { + fn cognitive_of(space: &FuncSpace, name: &str) -> u64 { + function_space(space, name).metrics.cognitive.cognitive() + } + + check_func_space::( + "def outer(a, b, c): + if a: # +1 + if b: # +2 (+1 nesting) + def inner(c): + if c: # +1 base, +1 function depth, +0 inherited + return 1 + return inner", + "nested.py", + |space| { + assert_eq!(cognitive_of(&space, "outer"), 3, "python outer"); + assert_eq!(cognitive_of(&space, "inner"), 2, "python inner"); + }, + ); + + check_func_space::( + "class N { + int outer(boolean a, boolean b, boolean c) { + if (a) { // +1 + if (b) { // +2 (+1 nesting) + class I { + int inner(boolean c) { + if (c) { // +1 base, +1 function depth + return 1; + } + return 0; + } + } + } + } + return 0; + } + }", + "N.java", + |space| { + assert_eq!(cognitive_of(&space, "outer"), 3, "java outer"); + assert_eq!(cognitive_of(&space, "inner"), 2, "java inner"); + }, + ); + } + #[test] fn python_real_function() { check_metrics::( diff --git a/src/metrics/cognitive/python.rs b/src/metrics/cognitive/python.rs index 65c6a82c1..e8ecd1ae6 100644 --- a/src/metrics/cognitive/python.rs +++ b/src/metrics/cognitive/python.rs @@ -203,11 +203,29 @@ impl Cognitive for PythonCode { // spelled out here and kept in sync with it (#422; the // drift guard in checker.rs flags a bump that emits Lambda2). Lambda | Lambda2 => { - // Increase lambda nesting + // A lambda amplifies the enclosing nesting rather than + // replacing it, so it deliberately does NOT take the + // function-boundary reset below: every sibling lambda arm + // (Java `LambdaExpression`, JS `ArrowFunction`, Rust + // `ClosureExpression`, …) leaves `conditional` alone, and + // adding the reset here would break the cross-language + // parity that reset exists to preserve (#1149). nesting.lambda += 1; } + // At a (possibly nested) `def` boundary, reset structural + // nesting to zero and bump the function-depth surcharge when + // this definition is itself nested inside another, so a `def` + // written inside an `if` is scored against its own depth rather + // than the enclosing function's — matching Java, Rust, and + // every other conforming family (#696, #1149). Python is the + // one family that provably needs no `nesting.lambda = 0` + // companion to go with it — a `def` is a statement and a + // lambda body is a single expression, so no + // `function_definition` can sit under a `lambda`. Elsewhere + // that shape is legal (`let f = || { fn g() {} };`) and only + // the JS macro currently carries the extra line. FunctionDefinition => { - // Increase depth function nesting if needed + nesting.conditional = 0; increment_function_depth( &mut nesting.function_depth, node, diff --git a/src/test_support.rs b/src/test_support.rs index 7d191d5b2..dde10563e 100644 --- a/src/test_support.rs +++ b/src/test_support.rs @@ -136,6 +136,34 @@ pub(crate) fn child_space<'a>(func_space: &'a FuncSpace, name: &str) -> &'a Func .unwrap_or_else(|| panic!("expected a child FuncSpace named {name:?}")) } +/// Returns the [`SpaceKind::Function`] space named `name` anywhere in +/// `func_space`'s subtree, panicking unless exactly one matches. +/// +/// [`child_space`] looks only at direct children and ignores `kind`, so it +/// cannot reach a function nested inside a class inside a function — the +/// shape a cross-language nested-function comparison needs. Requiring a +/// unique match keeps a fixture that later grows a second same-named +/// function from silently asserting on whichever one the walk reached +/// first. +#[track_caller] +pub(crate) fn function_space<'a>(func_space: &'a FuncSpace, name: &str) -> &'a FuncSpace { + let mut found: Vec<&FuncSpace> = Vec::new(); + let mut stack = vec![func_space]; + while let Some(space) = stack.pop() { + if space.kind == SpaceKind::Function && space.name.as_deref() == Some(name) { + found.push(space); + } + stack.extend(space.spaces.iter()); + } + match found.as_slice() { + [space] => space, + other => panic!( + "expected exactly one function FuncSpace named {name:?}, found {}", + other.len() + ), + } +} + /// Visits `code`'s tree in pre-order, maintaining the ancestor chain /// exactly as `spaces::compute::metrics_inner` does, and hands each /// node to `check` together with that chain. From ce120981e66195e637fb417c8e5bd1d1d1d5479f Mon Sep 17 00:00:00 2001 From: Elijah Zupancic Date: Sat, 1 Aug 2026 00:11:38 -0700 Subject: [PATCH 10/36] fix(cli): fail the walk when a directory cannot be listed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #1098 made every walking subcommand exit 1 on an unreadable input *file*. A directory the walk could not *list* was a different code path: it warned, dropped its whole subtree before any file was selected, and left the per-file tally at zero — so the run exited 0 over a tree it had not read. `bca check` is the worst case, since a gate reporting clean is indistinguishable from a gate that passed, and `diff --since` rendered the missing subtree as added or removed rather than as the I/O failure it was. `ResolvedFiles` now carries a `WalkErrors` tally that `walk_directory_seed` populates from its `Err` arm, counting only errors carrying an `io::Error`. Every variant still warns, so a malformed ignore file stays visible without becoming fatal. The tally threads through `run_walk_resolved_tallying` into `enforce_complete_walk` ahead of the read and write guards, into `run_walk_resolved` for `strip-comments`, into `vcs rank`, and into a new `DiffError::UnwalkableInputs` for `diff --since`. Two findings the book now records. `--exclude` cannot exempt an unlistable directory — it filters the paths the walk yielded, and one that could not be listed yields none — whereas an ignore-file entry prunes it inside the walker. And a malformed `.gitignore` at or below the walk root never reaches the error arm at all; `ignore` attaches it to the `DirEntry`, so the non-I/O case is only reachable from an ancestor, which is where the negative test stages it. Fixes #1131 --- big-code-analysis-book/src/commands/README.md | 44 +++- big-code-analysis-book/src/commands/check.md | 17 +- big-code-analysis-cli/src/commands/preproc.rs | 2 +- big-code-analysis-cli/src/lib.rs | 170 +++++++++++--- big-code-analysis-cli/src/metric_diff.rs | 14 ++ .../src/metric_diff_tests.rs | 29 +++ big-code-analysis-cli/src/vcs_command.rs | 8 + big-code-analysis-cli/src/walk.rs | 73 +++++- big-code-analysis-cli/src/walk_tests.rs | 8 +- big-code-analysis-cli/tests/common/mod.rs | 44 ++++ big-code-analysis-cli/tests/diff_since.rs | 65 ++++++ .../tests/paths_discovery.rs | 46 ++-- big-code-analysis-cli/tests/read_failures.rs | 212 +++++++++++++++++- big-code-analysis-cli/tests/vcs.rs | 46 ++++ 14 files changed, 700 insertions(+), 78 deletions(-) diff --git a/big-code-analysis-book/src/commands/README.md b/big-code-analysis-book/src/commands/README.md index 441b6e79d..1d0d2e948 100644 --- a/big-code-analysis-book/src/commands/README.md +++ b/big-code-analysis-book/src/commands/README.md @@ -92,12 +92,44 @@ If a tree legitimately contains files you cannot open, prune them with `--exclude` (or `--include` a narrower set). A file removed by those filters is never opened, so it is never a read failure. -The rule covers files the walk selected and then failed to open. Paths -the walk never selects are outside it: a *directory* it cannot list, and -a broken symlink found by walking (neither is a regular file). Both warn -— `bca: warning: skipping walk entry in …` — and the run continues. An -explicitly named path that does not exist is a separate, pre-existing -error (also exit `1`). +### Unlistable directories {#unlistable-directories} + +A directory the walk cannot *list* is the same failure one level up, and +carries the same exit `1`. Its whole subtree drops out of the analysed +set before any file is selected, so every count downstream — the metrics +document, the `count` tally, a `diff --since` side, `vcs rank`'s +ranking — is short by an amount nothing in the output reveals. `bca +check` is the worst case: a gate that reports clean on a tree it could +not read is indistinguishable from a gate that passed. + +Each unlistable entry warns on stderr as `bca: warning: skipping walk +entry in …`, the walk continues so one bad directory does not take down +the rest of the tree, and the run ends with a summary line and exit `1`. + +To exempt a directory you knowingly cannot list, name it in an ignore +file (`.gitignore`, `.ignore`) or narrow `--paths` so the walk never +reaches it. **`--exclude` does not work here**, though it is the right +answer for an unreadable *file*: `--exclude` filters the paths the walk +yielded, and a directory that could not be listed yielded none — the +failure happened before the filter could apply. + +Two neighbouring cases stay non-fatal by design: + +- **A malformed ignore file, or a pattern in one that will not compile.** + The walker reports these through the same channel and they warn + identically, but they describe how the walk was *configured* rather + than files it lost. Only errors carrying an underlying I/O error are + counted, so a stray `.gitignore` typo cannot fail a build. +- **A broken symlink discovered by walking.** It is dropped for not + being a regular file and never surfaces as an error at all — the walk + does not follow links, so it deliberately does not resolve symlinks + and has nothing to report. Treating one as fatal would make a stale + symlink in a vendored tree a hard CI failure. + +An explicitly *named* path is the exception to that last point, and a +pre-existing one: `--paths` resolves a symlink seed once, and a seed +that does not exist — dangling link or typo — is its own error, also +exit `1`. ### Unwritable output {#unwritable-output} diff --git a/big-code-analysis-book/src/commands/check.md b/big-code-analysis-book/src/commands/check.md index ac810238d..b26861528 100644 --- a/big-code-analysis-book/src/commands/check.md +++ b/big-code-analysis-book/src/commands/check.md @@ -24,13 +24,16 @@ fails the pipeline before the change lands. misconfiguration (`1`). A gate that could not read all of its input has no verdict to report, -so two input problems exit `1` rather than `0`: nothing matched -`--paths` / `--include` / `--exclude`, and any input file that failed -to read. The second is the workspace-wide -[unreadable-input rule](README.md#unreadable-input) — `check` is not -special here, it is just where the rule matters most. Both checks run -before the gate is evaluated and are not suppressed by `--no-fail`, -which suppresses threshold failures, not broken input, so neither lets +so three input problems exit `1` rather than `0`: nothing matched +`--paths` / `--include` / `--exclude`, any input file that failed to +read, and any directory the walk could not +[list](README.md#unlistable-directories). The last two are the +workspace-wide [unreadable-input rule](README.md#unreadable-input) — +`check` is not special here, it is just where the rule matters most, +since a gate reporting clean on a tree it could not read is +indistinguishable from a gate that passed. All three run before the gate +is evaluated and are not suppressed by `--no-fail`, which suppresses +threshold failures, not broken input, so none of them lets `--write-baseline` record a partial run. ### Tiered exit codes (`--exit-codes=tiered`) {#tiered-exit-codes---exit-codestiered} diff --git a/big-code-analysis-cli/src/commands/preproc.rs b/big-code-analysis-cli/src/commands/preproc.rs index cd5a36a34..45b1638f1 100644 --- a/big-code-analysis-cli/src/commands/preproc.rs +++ b/big-code-analysis-cli/src/commands/preproc.rs @@ -27,7 +27,7 @@ pub(crate) fn run_command_strip_comments( )); } cfg.explicit_seeds = Arc::new(resolved.explicit_files); - run_walk_resolved(resolved.files, num_jobs, cfg); + run_walk_resolved(resolved.files, num_jobs, cfg, resolved.walk_errors); } /// Recovers the accumulated [`PreprocResults`] from the shared worker diff --git a/big-code-analysis-cli/src/lib.rs b/big-code-analysis-cli/src/lib.rs index 81043d205..f8f5d2f32 100644 --- a/big-code-analysis-cli/src/lib.rs +++ b/big-code-analysis-cli/src/lib.rs @@ -406,7 +406,18 @@ const UTF8_BOM: [u8; 3] = [0xEF, 0xBB, 0xBF]; /// that want the standard exit-1 contract go through the `run_walk*` /// wrappers; `bca diff --since` uses this directly so it can unwind its /// temp trees before reporting. -fn run_walk_resolved_tallying(paths: Vec, num_jobs: usize, cfg: Config) -> WalkFailures { +/// +/// `walk_errors` comes in from the caller's own seed expansion (#1131): +/// the file list is already resolved by the time it arrives here, so +/// this seam cannot observe a traversal failure and must be told of one. +/// Threading it through rather than defaulting it here is what forces a +/// caller that resolved its own list to account for the tally. +fn run_walk_resolved_tallying( + paths: Vec, + num_jobs: usize, + cfg: Config, + walk_errors: WalkErrors, +) -> WalkFailures { let read_failures = Arc::clone(&cfg.read_failures); let write_failures = Arc::clone(&cfg.write_failures); ConcurrentRunner::new(num_jobs, act_on_file) @@ -419,26 +430,91 @@ fn run_walk_resolved_tallying(paths: Vec, num_jobs: usize, cfg: Config) .run(cfg, FilesData { paths }) .unwrap_or_else(|e| die(format_args!("{e:?}"))); WalkFailures { + walk: walk_errors, read: read_failures.load(Ordering::Relaxed), write: write_failures.load(Ordering::Relaxed), } } -/// How many files a walk failed on, split by which end gave way. Both -/// are fatal; counted apart so the summary names the actionable cause. +/// How many inputs a walk failed on, split by which end gave way. All +/// three are fatal; counted apart so the summary names the actionable +/// cause. +/// +/// `walk` is the traversal-side loss (#1131) and is upstream of the +/// other two: an entry the walker could not read never reaches a +/// worker, so it can neither fail to be read nor fail to be written. #[derive(Debug, Clone, Copy, PartialEq, Eq)] struct WalkFailures { + walk: WalkErrors, read: usize, write: usize, } +/// Which end of a walk gave way, and how many inputs it cost. +/// +/// Exists so the priority order between the three is written once. +/// Both consumers — the exit-code guard and `bca diff --since` — must +/// report the *same* end for the same walk, or the two paths describe +/// one failure differently; they used to re-spell the ladder each, and +/// #1131 would have made that a three-way duplication. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum WalkFailure { + Walk(usize), + Read(usize), + Write(usize), +} + +impl WalkFailures { + /// The most upstream failure, or `None` when the walk was complete. + /// + /// Traversal first: an entry the walker could not read never became + /// a file to read or a document to write, so naming it names the + /// cause. Reads before writes for the same reason — a file that + /// could not be read has no output to write either. + fn first(self) -> Option { + if self.walk.count() > 0 { + Some(WalkFailure::Walk(self.walk.count())) + } else if self.read > 0 { + Some(WalkFailure::Read(self.read)) + } else if self.write > 0 { + Some(WalkFailure::Write(self.write)) + } else { + None + } + } +} + +impl WalkFailure { + /// The one-line stderr summary for this failure. + fn summary(self) -> String { + match self { + Self::Walk(count) => walk_failure_summary(count), + Self::Read(count) => read_failure_summary(count), + Self::Write(count) => write_failure_summary(count), + } + } + + /// The same failure as a `bca diff --since` error, tagged with the + /// tree it came from. `DiffError` renders each variant through the + /// matching summary above, so the two surfaces cannot drift. + fn into_diff_error(self, side: DiffSide) -> metric_diff::DiffError { + match self { + Self::Walk(count) => metric_diff::DiffError::UnwalkableInputs { side, count }, + Self::Read(count) => metric_diff::DiffError::UnreadableInputs { side, count }, + Self::Write(count) => metric_diff::DiffError::UnwritableOutputs { side, count }, + } + } +} + /// Resolve the seeds and process the file set concurrently, returning -/// the read-failure tally. The seed-expanding counterpart to -/// [`run_walk_resolved_tallying`]. +/// the failure tallies. The seed-expanding counterpart to +/// [`run_walk_resolved_tallying`], and the only one of the two that can +/// observe a traversal error — `run_walk_resolved_tallying` is handed a +/// file list somebody else expanded. fn run_walk_tallying(globals: GlobalOpts, mut cfg: Config) -> WalkFailures { let (resolved, num_jobs) = resolve_walk_files(globals); cfg.explicit_seeds = Arc::new(resolved.explicit_files); - run_walk_resolved_tallying(resolved.files, num_jobs, cfg) + run_walk_resolved_tallying(resolved.files, num_jobs, cfg, resolved.walk_errors) } /// Summarize a walk that could not read every input file. Shared by the @@ -452,6 +528,19 @@ pub(crate) fn read_failure_summary(count: usize) -> String { ) } +/// Summarize a walk whose *traversal* could not read every entry — +/// typically a directory the process cannot list (#1131). Shaped like +/// [`read_failure_summary`], but a separate wording because the loss is +/// a different one: a whole subtree never entered the resolved file set, +/// so the count is of unreadable entries, not of files. +pub(crate) fn walk_failure_summary(count: usize) -> String { + let noun = if count == 1 { "entry" } else { "entries" }; + format!( + "{count} directory {noun} could not be read (see the warnings above); \ + refusing to trust a partially walked input set" + ) +} + /// The write-side counterpart to [`read_failure_summary`]. pub(crate) fn write_failure_summary(count: usize) -> String { let noun = if count == 1 { "file" } else { "files" }; @@ -461,8 +550,8 @@ pub(crate) fn write_failure_summary(count: usize) -> String { ) } -/// Exit 1 when the walk could not read every input file, or could not -/// write every output document. +/// Exit 1 when the walk could not read every entry it traversed, could +/// not read every input file, or could not write every output document. /// /// A command that analysed less than its input has no complete result to /// report, and the omission is invisible in the output: a missing file @@ -471,6 +560,13 @@ pub(crate) fn write_failure_summary(count: usize) -> String { /// input, so every walking subcommand fails the same way `check` does /// (#1060, #1098) rather than only when the run produced nothing. /// +/// The traversal side is the same argument one directory level up +/// (#1131). A directory the process cannot list drops its whole subtree +/// before any file is selected, so the per-file tally stays zero and the +/// run reported success — `bca check` most damagingly, since a gate +/// reporting clean on a tree it could not read is indistinguishable from +/// a gate that passed. +/// /// The write side is the same argument backwards: an unwritable /// `--output-dir` printed one error per file and still exited 0, which /// a CI script reads as a clean run over a missing output tree. @@ -484,13 +580,8 @@ pub(crate) fn write_failure_summary(count: usize) -> String { /// its baseline through `run_check`, so naming a subcommand here would /// misattribute the failure to a command the user never ran. fn enforce_complete_walk(failures: WalkFailures) { - // Reads first: when a file could not be read there is no output to - // write either, so naming the read is naming the cause. - if failures.read > 0 { - die(read_failure_summary(failures.read)); - } - if failures.write > 0 { - die(write_failure_summary(failures.write)); + if let Some(failure) = failures.first() { + die(failure.summary()); } } @@ -511,8 +602,19 @@ fn run_walk(globals: GlobalOpts, cfg: Config) { /// re-running the seed expansion. /// /// Exits 1 on an unreadable input or unwritable output, as [`run_walk`]. -fn run_walk_resolved(paths: Vec, num_jobs: usize, cfg: Config) { - enforce_complete_walk(run_walk_resolved_tallying(paths, num_jobs, cfg)); +/// +/// `walk_errors` is the caller's own [`resolve_walk_files`] tally: this +/// entry point does not expand seeds, so it cannot observe a traversal +/// failure and must be handed one (#1131). Taking it as a parameter +/// rather than defaulting it is what stops a future caller from +/// resolving its own file list and silently dropping the count. +fn run_walk_resolved(paths: Vec, num_jobs: usize, cfg: Config, walk_errors: WalkErrors) { + enforce_complete_walk(run_walk_resolved_tallying( + paths, + num_jobs, + cfg, + walk_errors, + )); } /// Like [`run_walk`], but returns the resolved terminal file list. @@ -526,7 +628,12 @@ fn run_walk_collecting(globals: GlobalOpts, mut cfg: Config) -> Vec { let (resolved, num_jobs) = resolve_walk_files(globals); cfg.explicit_seeds = Arc::new(resolved.explicit_files); let paths = resolved.files; - enforce_complete_walk(run_walk_resolved_tallying(paths.clone(), num_jobs, cfg)); + enforce_complete_walk(run_walk_resolved_tallying( + paths.clone(), + num_jobs, + cfg, + resolved.walk_errors, + )); paths } @@ -612,22 +719,17 @@ pub(crate) fn walk_metric_set( // always reaped, even on the error paths. let collected = collector.join(); - if failures.read > 0 { - return Err(metric_diff::DiffError::UnreadableInputs { - side, - count: failures.read, - }); - } - // Nothing is written per file any more, so this tally is expected to - // be zero. Kept because `write_failures` is generic walk machinery - // that a future dispatch change could start incrementing: the rule - // it enforces — never report a diff derived from an incomplete walk - // (#1098) — outlives the particular way the walk emits. - if failures.write > 0 { - return Err(metric_diff::DiffError::UnwritableOutputs { - side, - count: failures.write, - }); + // Any incomplete walk is an error here rather than a gap: a file + // missing from one side's set is indistinguishable from one the + // commit added or removed, so tolerating the loss yields a *wrong* + // comparison (#1098) — and an unlistable directory loses a whole + // subtree at once (#1131). + // + // The write arm is expected to be unreachable now that nothing is + // written per file, and is kept because `write_failures` is generic + // walk machinery a future dispatch change could start incrementing. + if let Some(failure) = failures.first() { + return Err(failure.into_diff_error(side)); } collected.map_err(|_| metric_diff::DiffError::CollectorPanicked { side })? diff --git a/big-code-analysis-cli/src/metric_diff.rs b/big-code-analysis-cli/src/metric_diff.rs index 44145dc85..e1d60bc47 100644 --- a/big-code-analysis-cli/src/metric_diff.rs +++ b/big-code-analysis-cli/src/metric_diff.rs @@ -122,6 +122,13 @@ pub(crate) enum DiffError { /// comparison would otherwise report as added or removed rather /// than as the I/O failure it is (#1098). UnreadableInputs { side: DiffSide, count: usize }, + /// A `--since` walk could not *list* every directory on one side, so + /// that side's set is short a whole subtree rather than a single + /// file (#1131). Distinct from [`Self::UnreadableInputs`] because + /// the count means something different — unreadable entries, not + /// files — and because the two are reported by different layers: the + /// traversal, and the worker that opened what the traversal found. + UnwalkableInputs { side: DiffSide, count: usize }, /// A `--since` walk could not write every per-file document it later /// reloads, so that side's set is short the same way — and for the /// same reason it must not be reported as a code change. @@ -167,6 +174,13 @@ impl std::fmt::Display for DiffError { crate::read_failure_summary(*count) ) } + Self::UnwalkableInputs { side, count } => { + write!( + f, + "diff --since {side} tree: {}", + crate::walk_failure_summary(*count) + ) + } } } } diff --git a/big-code-analysis-cli/src/metric_diff_tests.rs b/big-code-analysis-cli/src/metric_diff_tests.rs index ea0af6bd4..ef8b0a5cf 100644 --- a/big-code-analysis-cli/src/metric_diff_tests.rs +++ b/big-code-analysis-cli/src/metric_diff_tests.rs @@ -389,6 +389,35 @@ fn unreadable_inputs_error_names_the_side() { ); } +/// #1131's sibling variant. The two must stay distinguishable in the +/// rendered text: one counts files a worker could not open, the other +/// entries the traversal could not list, and a user told "1 input file" +/// about an unlistable directory would go looking for the wrong thing. +/// The `Before` side is unreachable end to end for the reason above. +#[test] +fn unwalkable_inputs_error_names_the_side_and_counts_entries() { + let before = DiffError::UnwalkableInputs { + side: DiffSide::Before, + count: 1, + }; + assert_eq!( + before.to_string(), + "diff --since before tree: 1 directory entry could not be read (see \ + the warnings above); refusing to trust a partially walked input set" + ); + + let after = DiffError::UnwalkableInputs { + side: DiffSide::After, + count: 2, + }; + assert!( + after + .to_string() + .starts_with("diff --since after tree: 2 directory entries could not be read"), + "rendered: {after}" + ); +} + // --- #1116: the in-memory set must equal the file round-trip -------- /// `set_from_spaces` must produce exactly what `load_dir_set` produced diff --git a/big-code-analysis-cli/src/vcs_command.rs b/big-code-analysis-cli/src/vcs_command.rs index 968d485d4..2a63f6ed9 100644 --- a/big-code-analysis-cli/src/vcs_command.rs +++ b/big-code-analysis-cli/src/vcs_command.rs @@ -330,6 +330,14 @@ fn rank( // behave exactly as elsewhere; intersect the result with the tracked // set (untracked / binary files are simply absent from the index). let (resolved, _jobs) = crate::resolve_walk_files(globals.clone()); + // A directory the walk could not list silently shortens the ranking, + // and a ranking is only as useful as its completeness — the same + // argument the analysing subcommands make in `enforce_complete_walk` + // (#1131). `vcs` reads history, never file contents, so this is the + // only I/O tally it has. + if resolved.walk_errors.count() > 0 { + die(crate::walk_failure_summary(resolved.walk_errors.count())); + } let selected = resolved.files; let mut covered: HashSet = HashSet::new(); diff --git a/big-code-analysis-cli/src/walk.rs b/big-code-analysis-cli/src/walk.rs index 9a9f90b2e..962d0004c 100644 --- a/big-code-analysis-cli/src/walk.rs +++ b/big-code-analysis-cli/src/walk.rs @@ -141,9 +141,51 @@ impl WalkFilters<'_> { /// nonexistent-explicit-path error. A file discovered by walking a /// directory seed is *not* in this set, so a tree full of READMEs and /// configs stays silently skipped (gated behind `-w`). +/// +/// `walk_errors` carries the #1131 tally: entries the directory walk +/// itself could not read. It is reported separately from the worker +/// pool's `read_failures` because the two describe different losses — +/// a read failure names a file the walk *selected*, whereas an +/// unlistable directory removes its whole subtree before anything can +/// be selected, leaving nothing for a worker to fail on. pub(crate) struct ResolvedFiles { pub(crate) files: Vec, pub(crate) explicit_files: std::collections::HashSet, + pub(crate) walk_errors: WalkErrors, +} + +/// How many walk entries the traversal could not read (#1131). +/// +/// A newtype rather than a bare `usize` so it cannot be confused with +/// the several other counts threaded through the same call chain (the +/// worker pool's read/write failure tallies, the job count, the +/// resolved file count) — and so a call site that forwards it has to +/// name what it is forwarding. +/// +/// **Only `ignore::Error`s carrying an underlying `io::Error` are +/// counted.** The reachable non-I/O case is a malformed ignore file in +/// an *ancestor* of the walk root, which `Worker::add_parents` surfaces +/// as an `Error::Glob`; it stays a warning, because it describes how the +/// walk was configured rather than a subtree the walk dropped. Widening +/// the tally to every variant would make a stray `.gitignore` typo fail +/// a build — pinned by +/// `malformed_parent_gitignore_warns_but_still_exits_zero` in +/// `tests/read_failures.rs`. +/// +/// `Error::Loop` is not a concern here: `follow_links` is off, so the +/// walker never runs its symlink-loop check. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +#[must_use] +pub(crate) struct WalkErrors(usize); + +impl WalkErrors { + fn add(&mut self, count: usize) { + self.0 += count; + } + + pub(crate) fn count(self) -> usize { + self.0 + } } /// What kind of on-disk object a `--paths` seed resolves to, used to @@ -218,6 +260,7 @@ pub(crate) fn expand_seed_paths( // reachable from two seeds was analyzed and counted twice (#704). let mut seen: std::collections::HashSet = std::collections::HashSet::new(); let mut explicit_files: std::collections::HashSet = std::collections::HashSet::new(); + let mut walk_errors = WalkErrors::default(); for seed in paths.into_iter().map(walk_seed::reanchor_seed) { // Classify the seed *without* following a final symlink so the // seed-level existence/kind check is symmetric with the walk's @@ -262,7 +305,7 @@ pub(crate) fn expand_seed_paths( } continue; } - for path in walk_directory_seed(&seed, no_ignore, threads, filters) { + for path in walk_directory_seed(&seed, no_ignore, threads, filters, &mut walk_errors) { // Overlapping seeds (`--paths src --paths src/lib.rs`, or // two seeds whose trees intersect) must contribute each file // exactly once (#704). The dedupe lives here, with the @@ -285,6 +328,7 @@ pub(crate) fn expand_seed_paths( ResolvedFiles { files: out, explicit_files, + walk_errors, } } @@ -302,12 +346,26 @@ pub(crate) fn expand_seed_paths( /// run (#704): a single EACCES directory deep in a large tree previously took /// down every file the walk had yet to reach. This mirrors the per-file /// tolerance the worker pool already applies to unparseable files. +/// +/// Tolerating the entry is not the same as reporting success, though, so +/// each I/O-backed error is also added to `errors` for the caller's +/// exit-code guard (#1131) — an unlistable directory removes its whole +/// subtree from the resolved set, which is invisible in the output. fn walk_directory_seed( seed: &Path, no_ignore: bool, threads: usize, filters: &WalkFilters<'_>, + errors: &mut WalkErrors, ) -> Vec { + // Reporting the tally through a return value instead of `errors` + // costs `expand_seed_paths` more than it saves: measured, the + // tuple-plus-accumulate shape puts that function's halstead.effort + // at 50_003 against a hard limit of 50_000, trading a soft nargs + // tier for a hard breach. The five parameters are each + // independently meaningful, and no two bundle under a name worth + // having. + // bca: suppress(nargs) use ignore::{WalkBuilder, WalkState}; let mut wb = WalkBuilder::new(seed); wb.hidden(true) @@ -328,8 +386,13 @@ fn walk_directory_seed( // the per-entry hot path does not serialize the walker threads // against each other. let (tx, rx) = crossbeam::channel::unbounded(); + // The error tally is a shared counter rather than a second channel + // item: a walk error yields no path, so widening `tx`'s item to an + // enum would make every hot-path send pay for the rare case. + let io_errors = AtomicUsize::new(0); wb.build_parallel().run(|| { let tx = tx.clone(); + let io_errors = &io_errors; Box::new(move |entry| { match entry { Ok(entry) => { @@ -356,6 +419,13 @@ fn walk_directory_seed( "bca: warning: skipping walk entry in {}: {e}", seed.display() ); + // Every variant warns; only an I/O-backed one is + // tallied, because only that one means the walk lost + // files it should have seen (#1131). See + // [`WalkErrors`] for why the rest stay non-fatal. + if e.io_error().is_some() { + io_errors.fetch_add(1, Ordering::Relaxed); + } } } WalkState::Continue @@ -364,6 +434,7 @@ fn walk_directory_seed( // Drop the builder's own sender so the drain below terminates; every // visitor clone is already gone, `run` having joined its threads. drop(tx); + errors.add(io_errors.into_inner()); // A parallel walk yields entries in whatever order its threads // happen to finish, so without this sort the resolved file list — diff --git a/big-code-analysis-cli/src/walk_tests.rs b/big-code-analysis-cli/src/walk_tests.rs index 68fc4ff69..a6d437baa 100644 --- a/big-code-analysis-cli/src/walk_tests.rs +++ b/big-code-analysis-cli/src/walk_tests.rs @@ -43,8 +43,14 @@ fn walk_directory_seed_returns_sorted_paths() { include: &empty, exclude: &empty, }; - let found = walk_directory_seed(root, true, 8, &filters); + let mut errors = WalkErrors::default(); + let found = walk_directory_seed(root, true, 8, &filters, &mut errors); + assert_eq!( + errors.count(), + 0, + "a fully readable fixture tree must record no walk errors" + ); assert_eq!( found.len(), created.len(), diff --git a/big-code-analysis-cli/tests/common/mod.rs b/big-code-analysis-cli/tests/common/mod.rs index 2f6ee79a1..b99be46b4 100644 --- a/big-code-analysis-cli/tests/common/mod.rs +++ b/big-code-analysis-cli/tests/common/mod.rs @@ -242,3 +242,47 @@ pub fn unreadable_fixture(dir: &Path, name: &str, body: &str) -> Option Option { + use std::os::unix::fs::PermissionsExt; + + let path = dir.join(name); + std::fs::create_dir_all(&path).expect("create fixture dir"); + std::fs::write(path.join(file), body).expect("write fixture"); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o000)).expect("chmod 000"); + std::fs::read_dir(&path).is_err().then_some(path) +} + +/// Give a mode-stripped fixture directory its bits back so `TempDir`'s +/// recursive delete can remove it. The counterpart to +/// [`unlistable_dir`], and to the mode-555 output directories the +/// write-failure tests stage. +#[cfg(unix)] +#[allow(dead_code)] +pub fn restore_dir_access(path: &Path) { + use std::os::unix::fs::PermissionsExt; + + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755)).expect("chmod 755"); +} diff --git a/big-code-analysis-cli/tests/diff_since.rs b/big-code-analysis-cli/tests/diff_since.rs index b6fe9c2c5..a740034bc 100644 --- a/big-code-analysis-cli/tests/diff_since.rs +++ b/big-code-analysis-cli/tests/diff_since.rs @@ -560,6 +560,71 @@ fn since_errors_when_the_after_side_has_an_unreadable_file() { .stdout(predicate::str::is_empty()); } +/// #1131: the same wrong-diff outcome one directory level up. A +/// directory the after-side walk cannot *list* drops its whole subtree +/// before any file is selected, so the per-file read tally stays zero +/// and the run reported a clean diff with every file under it marked +/// removed. +/// +/// The control run is what makes the claim, not the exit code: it shows +/// the same tree pairing both files with an empty `removed_files`, so +/// the locked run's failure is the traversal and not a fixture that +/// never paired in the first place. +/// +/// Staged on the after side for the reason given on +/// [`since_errors_when_the_after_side_has_an_unreadable_file`] — git +/// records no directory mode bits, so the before-side extraction is +/// always listable. +#[cfg(unix)] +#[test] +fn since_errors_when_the_after_side_has_an_unlistable_directory() { + let repo = repo_with_flat_commit(); + let nested = repo.path().join("src/nested"); + fs::create_dir(&nested).expect("mkdir nested"); + fs::write(nested.join("inner.rs"), FLAT_SOURCE).expect("write inner"); + git(repo.path(), &["add", "."]); + git(repo.path(), &["commit", "-q", "-m", "nested"]); + + // Control: both sides see `src/nested/inner.rs`, so nothing is + // removed. This is the comparison the locked run must not silently + // replace. + let assert = cli() + .current_dir(repo.path()) + .args(["diff", "--since", "HEAD", "src", "--format", "json"]) + .assert() + .success(); + let stdout = String::from_utf8(assert.get_output().stdout.clone()).expect("utf8"); + let doc: serde_json::Value = serde_json::from_str(&stdout).expect("json"); + let removed = doc["removed_files"] + .as_array() + .expect("removed_files array"); + assert!( + removed.is_empty(), + "the readable control must pair every file: {removed:?}" + ); + + if !common::deny_all_access(&nested) { + eprintln!("skipping: this process can list a mode-000 directory"); + return; + } + + cli() + .current_dir(repo.path()) + .args(["diff", "--since", "HEAD", "src", "--format", "json"]) + .assert() + .code(1) + .stderr(predicate::str::contains("Permission denied")) + .stderr(predicate::str::contains( + "diff --since after tree: 1 directory entry could not be read", + )) + // No document at all, so `inner.rs` cannot be consumed as a + // removed file. Before the fix this stream carried a full JSON + // diff listing it in `removed_files`. + .stdout(predicate::str::is_empty()); + + common::restore_dir_access(&nested); +} + /// Pull `(old, new)` for the `cyclomatic.sum` field out of the /// `--format json` diff document, searching the `cyclomatic` bucket's /// changed entries. diff --git a/big-code-analysis-cli/tests/paths_discovery.rs b/big-code-analysis-cli/tests/paths_discovery.rs index f60c0dc97..e8ad0232e 100644 --- a/big-code-analysis-cli/tests/paths_discovery.rs +++ b/big-code-analysis-cli/tests/paths_discovery.rs @@ -615,9 +615,19 @@ fn paths_from_tolerates_non_utf8_line() { /// #704: a per-entry walk error (here, an unreadable subdirectory) must /// skip-with-warning and keep processing the rest of the tree, not abort /// the whole run. Unix-only: it relies on POSIX directory permissions. +/// +/// #1131 kept that tolerance and changed only what happens *after* the +/// walk: the run now exits 1, because a subtree missing from the result +/// is invisible in it. The continuation property this test exists for is +/// therefore observed on streamed stdout rather than through the +/// `--output` aggregate — a post-walk document the guard suppresses +/// precisely so a partial one cannot look complete. The two claims are +/// deliberately kept in one test: "warns", "continues", and "then fails" +/// are one contract, and splitting them invites a future change to +/// satisfy one while dropping another. #[cfg(unix)] #[test] -fn unreadable_subdir_warns_but_continues() { +fn unreadable_subdir_warns_continues_then_fails_the_run() { use std::os::unix::fs::PermissionsExt; let dir = TempDir::new().unwrap(); @@ -632,32 +642,22 @@ fn unreadable_subdir_warns_but_continues() { std::fs::write(locked.join("hidden.py"), "def g(): return 2\n").unwrap(); std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o000)).unwrap(); - let out = dir.path().join("agg.json"); - let result = cli(dir.path()) - .args([ - "metrics", - "--no-config", - "--paths", - src.to_str().unwrap(), - "-O", - "json", - "--output", - out.to_str().unwrap(), - ]) + cli(dir.path()) + .args(["metrics", "--no-config", "--paths", src.to_str().unwrap()]) .assert() - .success() - .stderr(predicate::str::contains("skipping walk entry")); + .code(1) + .stderr(predicate::str::contains("skipping walk entry")) + .stderr(predicate::str::contains( + "1 directory entry could not be read", + )) + // The readable sibling was still analyzed despite the locked + // subdir — the #704 property. `hidden.py` is the file the walk + // lost, and its absence is what the exit code above reports. + .stdout(predicate::str::contains("ok.py")) + .stdout(predicate::str::contains("hidden.py").not()); // Restore permissions so the TempDir can be cleaned up on drop. std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o755)).unwrap(); - - // The readable sibling was still analyzed despite the locked subdir. - let _ = result; - assert_eq!( - aggregate_len(&out), - 1, - "ok.py must still be analyzed after the unreadable subdir is skipped", - ); } // --- #1114: the directory walk runs in parallel ----------------------- diff --git a/big-code-analysis-cli/tests/read_failures.rs b/big-code-analysis-cli/tests/read_failures.rs index 708ddd8c0..67f0de689 100644 --- a/big-code-analysis-cli/tests/read_failures.rs +++ b/big-code-analysis-cli/tests/read_failures.rs @@ -21,8 +21,16 @@ //! cases also pin the `BrokenPipe` exemption from the other direction — //! `bca dump | head` must stay exit 0. //! -//! Unix-only, because the scenarios are staged with a mode-000 file and -//! `/dev/full`. +//! The fourth part (#1131) moves the same rule one directory level up: a +//! directory the walk could not *list* drops its whole subtree before +//! any file is selected, so the per-file tally stayed zero and every +//! subcommand — `bca check` included — reported success over a tree it +//! had not read. Its negative case pins the deliberate exemption: only +//! walk errors carrying an `io::Error` are fatal, so a malformed +//! `.gitignore` keeps warning and keeps exiting 0. +//! +//! Unix-only, because the scenarios are staged with a mode-000 file, a +//! mode-000 directory, and `/dev/full`. //! `unreadable_fixture` probes the real capability rather than the uid, //! so a privileged test runner (root ignores mode bits) skips instead of //! failing. The suite lives in a `#[cfg(unix)]` module rather than @@ -351,6 +359,202 @@ mod unix { .stderr(predicate::str::contains("could not be read").not()); } + /// The traversal-side summary line (#1131). Mirrors [`SUMMARY`], but + /// counts unreadable *entries*: an unlistable directory costs the run + /// a whole subtree, not one file. + const WALK_SUMMARY: &str = "1 directory entry could not be read"; + + /// Stage the #1131 tree under a fresh tempdir: one readable source + /// file at the top, and a subdirectory holding another that the + /// process cannot list. Returns the tempdir plus the locked + /// directory, or `None` when the denial does not bite. + /// + /// The readable file matters. Without it the walk resolves zero + /// files, and `check` would exit 1 through its pre-existing + /// "no input files matched" guard — an exit code that looks + /// identical to the one under test. + fn tree_with_unlistable_subdir() -> Option<(TempDir, std::path::PathBuf)> { + let dir = TempDir::new().expect("tempdir"); + write_fixture(&dir, "top.c", TRIVIAL_C); + let locked = common::unlistable_dir(dir.path(), "sub", "inner.c", TRIVIAL_C)?; + Some((dir, locked)) + } + + /// Drive `subcommand` over a tree containing a directory it cannot + /// list and assert the #1131 contract: exit 1, the per-entry warning, + /// and the summary line. + /// + /// Before the fix every one of these exited 0 with the subtree + /// silently missing from the result. + fn assert_unlistable_directory_exits_one(subcommand: &str, extra: &[&str]) { + let Some((dir, locked)) = tree_with_unlistable_subdir() else { + eprintln!("skipping: this process can list a mode-000 directory"); + return; + }; + + cli(&dir) + .arg(subcommand) + .args(extra) + .args([ + "--no-config", + "--paths", + dir.path().to_str().expect("utf8 dir"), + ]) + .assert() + .code(1) + .stderr(predicate::str::contains("skipping walk entry")) + .stderr(predicate::str::contains("Permission denied")) + .stderr(predicate::str::contains(WALK_SUMMARY)); + + common::restore_dir_access(&locked); + } + + #[test] + fn metrics_exits_one_when_a_directory_cannot_be_listed() { + assert_unlistable_directory_exits_one("metrics", &[]); + } + + #[test] + fn count_exits_one_when_a_directory_cannot_be_listed() { + assert_unlistable_directory_exits_one( + "count", + &["--type", "function_definition", "--language", "c"], + ); + } + + /// `strip-comments` resolves its own file list before dispatching + /// (it rejects a multi-file `--output`), so it reaches the guard + /// through `run_walk_resolved` rather than `run_walk` — a separate + /// path that has to be handed the tally explicitly. + #[test] + fn strip_comments_exits_one_when_a_directory_cannot_be_listed() { + assert_unlistable_directory_exits_one("strip-comments", &[]); + } + + /// The headline case. `bca check` is the CI gate, so reporting clean + /// on a tree it could not fully read is worse than a wrong diff — + /// it is indistinguishable from success. + /// + /// The threshold is deliberately one the readable file *breaches* + /// (`add` has one exit; the limit is 0), so the gate has a verdict to + /// report and would exit 2 if the walk guard did not pre-empt it. + /// Exit 1 therefore proves both halves of the contract: the run fails, + /// and it fails as a tool error rather than as a metric regression. + /// A threshold the tree passes would leave exit 1 consistent with + /// "clean gate plus tool error", which is the weaker claim. + #[test] + fn check_exits_one_rather_than_two_when_a_directory_cannot_be_listed() { + assert_unlistable_directory_exits_one("check", &["--threshold", "nexits=0"]); + } + + /// The same threshold against the same tree, nothing locked: exit 2. + /// Without this control the test above cannot tell the tool-error + /// exit from a gate that simply never fired. + #[test] + fn check_exits_two_on_the_same_threshold_when_the_tree_is_readable() { + let dir = TempDir::new().expect("tempdir"); + write_fixture(&dir, "top.c", TRIVIAL_C); + + cli(&dir) + .args([ + "check", + "--threshold", + "nexits=0", + "--no-config", + "--paths", + dir.path().to_str().expect("utf8 dir"), + ]) + .assert() + .code(2) + .stderr(predicate::str::contains("could not be read").not()); + } + + /// The negative case, and the reason the tally is filtered on + /// `ignore::Error::io_error()` rather than counting every error the + /// walker reports. + /// + /// A malformed `.gitignore` in a *parent* of the walk root reaches + /// the same `Err` arm as an unlistable directory — the parallel + /// walker visits `add_parents` failures as errors — but it describes + /// how the walk was configured, not files it lost. It must keep + /// warning and keep exiting 0. Drop the `io_error()` filter and this + /// test is the one that fails. + /// + /// The parent placement is load-bearing: a malformed `.gitignore` in + /// the walk root or below is attached to the `DirEntry` + /// (`Worker::read_dir` sets `dent.err`) and never reaches a visitor + /// at all, so the same fixture one directory lower produces no + /// warning and pins nothing. Measured against `ignore` 0.4.31. + #[test] + fn malformed_parent_gitignore_warns_but_still_exits_zero() { + let dir = TempDir::new().expect("tempdir"); + // `[z-a]` is a reversed character range: globset rejects it, so + // `ignore` reports the line as an `Error::Glob` carrying no + // `io::Error`. + fs::write(dir.path().join(".gitignore"), "[z-a]\n").expect("write gitignore"); + let root = dir.path().join("root"); + fs::create_dir(&root).expect("create walk root"); + fs::write(root.join("top.c"), TRIVIAL_C).expect("write fixture"); + + common::cli_in(&root) + .args([ + "metrics", + "--no-config", + "--paths", + root.to_str().expect("utf8 root"), + ]) + .assert() + .success() + .stdout(predicate::str::contains("top.c")) + // The warning is half the contract: a benign walk error must + // stay *visible*, just not fatal. + .stderr(predicate::str::contains("invalid range")) + .stderr(predicate::str::contains("could not be read").not()); + } + + /// The escape hatch for a tree that legitimately contains a + /// directory you cannot list — and the asymmetry with the read-side + /// hatch that makes it worth pinning. + /// + /// An *ignore file* prunes the directory inside the walker, which + /// never descends and so never fails. `--exclude` does **not**: it is + /// a post-walk filter over the paths the walker yielded, so the + /// listing has already been attempted and already failed by the time + /// it applies. Both halves are asserted here, because documenting + /// only the working one would send a user to the flag that cannot + /// help. + #[test] + fn ignore_file_prunes_an_unlistable_directory_but_exclude_does_not() { + let Some((dir, locked)) = tree_with_unlistable_subdir() else { + eprintln!("skipping: this process can list a mode-000 directory"); + return; + }; + let root = dir.path().to_str().expect("utf8 dir").to_owned(); + + cli(&dir) + .args([ + "metrics", + "--no-config", + "--paths", + &root, + "--exclude", + "**/sub/**", + ]) + .assert() + .code(1) + .stderr(predicate::str::contains(WALK_SUMMARY)); + + fs::write(dir.path().join(".gitignore"), "sub/\n").expect("write gitignore"); + cli(&dir) + .args(["metrics", "--no-config", "--paths", &root]) + .assert() + .success() + .stdout(predicate::str::contains("top.c")) + .stderr(predicate::str::contains("could not be read").not()); + + common::restore_dir_access(&locked); + } + /// The write-side summary line. Mirrors [`SUMMARY`]. const WRITE_SUMMARY: &str = "1 output file could not be written"; @@ -433,9 +637,7 @@ mod unix { /// Give the mode-555 fixture its write bits back so `TempDir`'s /// recursive delete can remove it. fn restore_dir_permissions(path: &str) { - use std::os::unix::fs::PermissionsExt; - - fs::set_permissions(path, fs::Permissions::from_mode(0o755)).expect("chmod 755"); + common::restore_dir_access(std::path::Path::new(path)); } #[test] diff --git a/big-code-analysis-cli/tests/vcs.rs b/big-code-analysis-cli/tests/vcs.rs index 5cfcb41e8..c453785d9 100644 --- a/big-code-analysis-cli/tests/vcs.rs +++ b/big-code-analysis-cli/tests/vcs.rs @@ -834,3 +834,49 @@ fn bad_recent_window_names_its_own_flag() { .and(predicate::str::contains("\"12parsec\"")), ); } + +/// #1131: `bca vcs` intersects the standard walk with the git index, so +/// a directory it cannot list silently shortens the ranking exactly as +/// it shortened a metrics document. `vcs` reads history rather than file +/// contents, so this traversal tally is the only I/O failure it can +/// observe — without it the run reported a complete ranking over a tree +/// it had not finished walking. +/// +/// The readable control run is what makes the claim: it shows the same +/// invocation ranking `src/nested/inner.rs`, so the locked run's exit 1 +/// is the guard and not an unrelated rejection. +#[cfg(unix)] +#[test] +fn vcs_exits_one_when_a_directory_cannot_be_listed() { + let repo = repo_two_commits(); + let nested = repo.path().join("src/nested"); + std::fs::create_dir(&nested).expect("mkdir nested"); + std::fs::write(nested.join("inner.rs"), "fn c() {}\n").expect("write inner"); + let now = now(); + git_at(repo.path(), now - 3 * DAY, &["add", "."]); + git_at(repo.path(), now - 3 * DAY, &["commit", "-qm", "add nested"]); + + cli() + .current_dir(repo.path()) + .args(["vcs", "--paths", ".", "--format", "json"]) + .assert() + .success() + .stdout(predicate::str::contains("src/nested/inner.rs")); + + if !common::deny_all_access(&nested) { + eprintln!("skipping: this process can list a mode-000 directory"); + return; + } + + cli() + .current_dir(repo.path()) + .args(["vcs", "--paths", ".", "--format", "json"]) + .assert() + .code(1) + .stderr(predicate::str::contains( + "1 directory entry could not be read", + )) + .stdout(predicate::str::is_empty()); + + common::restore_dir_access(&nested); +} From 30556fc34e1a03e654cd9a1991086616c1fd353d Mon Sep 17 00:00:00 2001 From: Elijah Zupancic Date: Sat, 1 Aug 2026 00:16:56 -0700 Subject: [PATCH 11/36] fix(cli): flush stdout so vcs failures exit 1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `std::io::Stdout` is a `LineWriter` over a 1 KiB buffer, so a document containing no newline and shorter than that is accepted into the buffer and only written by the exit-time cleanup flush, whose error is discarded. `formats::write_text` wrote and never flushed, so `bca vcs -O json`, `vcs commit`, and `vcs trend` — the three that emit compact JSON — exited 0 with their reports silently dropped against /dev/full (measured: 782 / 852 / 836 bytes). Every other `vcs` format carries a newline and was already exiting 1, which is why #1132's sweep missed these. `path_io::write_stdout_parts_or_die` had the identical hole. No shipped subcommand can reach it with a newline-free document — every one is line-oriented or pretty-printed JSON, so the buffer spills on an interior newline — so that half is latent and is pinned by a unit test on the new `write_parts_flushed` seam rather than end-to-end. The newline chunk in `writeln_stdout_or_die` made its robustness incidental; the flush now makes it explicit, and its rationale no longer claims an allocation saving that never existed. `strip-comments`' non-UTF-8 stdout branch carried the same shape (no newline, no flush, a second lock) and is folded into `write_stripped_on_stdout` alongside its UTF-8 sibling. Also adds a bench guard: flipping `exclude_tests` off on `nom/wide-attributed-fn` deleted the whole `should_skip_subtree` scan the probe exists to price and failed zero tests, because `nom.total()` reads the same either way on an `#[inline]`-attributed shape. The new check classifies each shape by walking it both ways and requires a prunable one to be probed with the flag on — discriminating on the shape, not on the flag, since selecting by the flag is vacuous against exactly that flip. The exit-1 claim in the book's command overview is now true for `vcs` too, and says so. Corrects the Python cognitive comment that asserted an unpinned coverage claim and then disclaimed it four lines later. --- CHANGELOG.md | 12 ++ big-code-analysis-bench/src/shapes.rs | 69 +++++++- big-code-analysis-book/src/commands/README.md | 9 +- big-code-analysis-cli/src/dispatch.rs | 28 +++- big-code-analysis-cli/src/formats.rs | 20 ++- big-code-analysis-cli/src/lib_tests.rs | 66 ++++++++ big-code-analysis-cli/src/path_io.rs | 54 +++++-- big-code-analysis-cli/tests/read_failures.rs | 150 ++++++++++++++++++ src/metrics/cognitive/python.rs | 11 +- 9 files changed, 383 insertions(+), 36 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 80c0ac07d..2156663ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -420,6 +420,18 @@ for historical reference. ### Fixed +- `bca vcs`, `bca vcs commit`, and `bca vcs trend` exit `1` when their + report cannot be written to stdout. All three emit compact JSON, and + `std::io::Stdout` is a `LineWriter` over a 1 KiB buffer: a document + containing no newline and shorter than that was accepted into the + buffer and only written by the exit-time cleanup flush, whose error is + discarded — so a full disk or a closed `>` target produced exit `0` + with no output at all. #1132 fixed the walk's stdout paths and missed + these three, because every other `vcs` format (`yaml`, `toml`, + `markdown`, `html`, `csv`, the default table) contains newlines and + was already surfacing the failure. `path_io::write_stdout_parts_or_die` + carried the same missing flush; no shipped subcommand can reach it + with a newline-free document, so that half was latent. - Every crate the root manifest `exclude`s — the five vendored `bca-tree-sitter-*` grammars and `enums` — now roots its own workspace (#1145). `exclude` denies membership without terminating diff --git a/big-code-analysis-bench/src/shapes.rs b/big-code-analysis-bench/src/shapes.rs index 861aaa32a..b6fd3129c 100644 --- a/big-code-analysis-bench/src/shapes.rs +++ b/big-code-analysis-bench/src/shapes.rs @@ -1016,7 +1016,7 @@ pub const PROBES: &[Probe] = &[ mod tests { use big_code_analysis::{Ast, Source}; - use super::{Axis, PROBES, Probe}; + use super::{Axis, PROBES, Probe, Workload}; /// Deepest `tree_sitter` node depth reachable from the root. /// @@ -1210,6 +1210,73 @@ mod tests { } } + /// Every probe whose *shape* the exclusion hook prunes walks with + /// `exclude_tests` on. + /// + /// [`probe_workload_is_exercised`] cannot see this. The two + /// attributed-function shapes carry `#[inline]`, which no + /// `exclude_tests` rule prunes, so `nom.total()` reads the size + /// parameter with the flag on or off: flipping + /// `exclude_tests: true` to `false` on `nom/wide-attributed-fn` + /// deletes the whole `Checker::should_skip_subtree` attribute scan + /// the probe exists to price, and fails nothing (measured, #1133). + /// + /// The discriminator is deliberately the shape and not the flag. + /// Selecting the probes *by* `exclude_tests: true` and asserting + /// something about them is vacuous against exactly that flip — the + /// flipped probe leaves the selected set and the loop passes + /// (measured, too). So each shape is classified by walking it both + /// ways under forced options: one whose reading moves is one the + /// hook prunes, and that probe must be running with the flag set. + /// + /// `#[inline]` is rewritten to `#[test]` first, which is what makes + /// the attributed shapes prunable at all. Reading the source back + /// through `probe.render` rather than restating it keeps the guard + /// coupled to the generator: one that stopped emitting attributes + /// stops being classified and trips the floor below. + /// + /// Size 16 rather than the probe's own ladder, as + /// [`shapes_widen_without_deepening`] uses: "the hook prunes this + /// shape" is structural and true at any size. + #[test] + fn shapes_the_exclusion_hook_prunes_are_probed_with_it_on() { + let mut pruned_shapes = Vec::new(); + for probe in PROBES { + let Workload::Metrics { exclude_tests, .. } = probe.workload else { + continue; + }; + let source = (probe.render)(16).replace("#[inline]", "#[test]"); + let ast = Ast::parse(Source::new(probe.lang, source.as_bytes())).unwrap_or_else(|e| { + panic!("{}: test-attributed shape must parse: {e}", probe.name) + }); + let walk = |options| { + probe + .workload + .walk(&ast, options) + .unwrap_or_else(|e| panic!("{}: walker must succeed: {e}", probe.name)) + }; + let options = probe.workload.options(); + if walk(options.with_exclude_tests(true)) == walk(options.with_exclude_tests(false)) { + continue; + } + pruned_shapes.push(probe.name); + assert!( + exclude_tests, + "{}: the exclusion hook prunes this shape, so the probe \ + exists to price that walk — but its workload sets \ + `exclude_tests: false`, which never reaches it", + probe.name, + ); + } + assert!( + pruned_shapes.len() >= 2, + "only {pruned_shapes:?} are pruned by the exclusion hook; \ + `Checker::should_skip_subtree` needs a probe on each axis, \ + and a shape that stopped carrying attributes drops out of \ + this check silently" + ); + } + /// Probe names are unique — they key the report and the gate. #[test] fn probe_names_are_unique() { diff --git a/big-code-analysis-book/src/commands/README.md b/big-code-analysis-book/src/commands/README.md index 441b6e79d..190cc30c0 100644 --- a/big-code-analysis-book/src/commands/README.md +++ b/big-code-analysis-book/src/commands/README.md @@ -105,10 +105,11 @@ The mirror image is the same rule, and it holds for every emission path: a run whose output could not be written exits `1`. That covers a per-file document under an unwritable `--output-dir`, and a full disk on stdout — `dump`'s banners and trees, `find`'s matches, -`strip-comments`' rewritten source, `count`'s tally, and `preproc`'s -JSON alike. A per-file failure is named on stderr and counted in a -summary line; output assembled after the walk reports the operating -system's error directly. +`strip-comments`' rewritten source, `count`'s tally, `preproc`'s JSON, +and the single-document reports from `vcs`, `vcs commit`, and `vcs +trend` alike, in every format. A per-file failure is named on stderr +and counted in a summary line; output assembled after the walk reports +the operating system's error directly. The one exemption is a closed downstream pipe: `bca dump | head` is routine rather than a failure, so `BrokenPipe` is swallowed and the run diff --git a/big-code-analysis-cli/src/dispatch.rs b/big-code-analysis-cli/src/dispatch.rs index f80837465..5cbe424a3 100644 --- a/big-code-analysis-cli/src/dispatch.rs +++ b/big-code-analysis-cli/src/dispatch.rs @@ -355,18 +355,34 @@ fn dispatch_strip_comments( write_file(&path, &new_source)?; } else if let Some(output) = output { write_file(output, &new_source)?; - } else if let Ok(text) = std::str::from_utf8(&new_source) { - // Fallible for the same reason as the `dump` banner (#1132): - // `println!` would panic on a full disk rather than let the - // walk tally the failure and exit 1. - writeln!(std::io::stdout().lock(), "{text}")?; } else { - std::io::stdout().write_all(&new_source)?; + write_stripped_on_stdout(&new_source)?; } } Ok(()) } +/// Emit comment-stripped `source` on stdout: one lock, a trailing +/// newline, then an explicit flush. +/// +/// Fallible for the same reason as the `dump` banner (#1132): `println!` +/// would panic on a full disk rather than let the walk tally the failure +/// and exit 1. The non-UTF-8 branch used to be a bare +/// `stdout().write_all` — no newline, no flush — so on a `LineWriter` +/// stdout its bytes could sit in the buffer until the exit-time cleanup +/// flush, whose error nobody reads. Both branches now share the shape +/// the UTF-8 one had. +fn write_stripped_on_stdout(source: &[u8]) -> std::io::Result<()> { + let mut out = std::io::stdout().lock(); + if let Ok(text) = std::str::from_utf8(source) { + writeln!(out, "{text}")?; + } else { + out.write_all(source)?; + out.write_all(b"\n")?; + } + out.flush() +} + fn dispatch_functions( language: LANG, source: Vec, diff --git a/big-code-analysis-cli/src/formats.rs b/big-code-analysis-cli/src/formats.rs index 14988a3af..12223ed30 100644 --- a/big-code-analysis-cli/src/formats.rs +++ b/big-code-analysis-cli/src/formats.rs @@ -655,13 +655,21 @@ fn ensure_parent_dir(path: &Path) -> std::io::Result<()> { /// stdout when `output` is `None`. Shared by the `vcs commit` / `vcs trend` /// / `vcs` single-file emit paths so they cannot drift. pub(crate) fn write_text(content: &str, output: Option<&PathBuf>) -> std::io::Result<()> { - match output { - Some(path) => { - ensure_parent_dir(path)?; - std::fs::write(path, content) - } - None => std::io::stdout().lock().write_all(content.as_bytes()), + if let Some(path) = output { + ensure_parent_dir(path)?; + return std::fs::write(path, content); } + // The flush is what makes the failure observable. `Stdout` is a + // `LineWriter` over a 1 KiB buffer, so a payload with no newline + // anywhere in it and shorter than that is accepted here and only + // reaches the fd during the exit-time cleanup flush, whose error is + // discarded: `bca vcs -O json`, `vcs commit`, and `vcs trend` — the + // three that emit *compact* JSON — all exited 0 having emitted + // nothing (#1132's sweep missed them; every other format carries a + // newline, so the buffer spills on its own). + let mut out = std::io::stdout().lock(); + out.write_all(content.as_bytes())?; + out.flush() } /// Serialize `value` as JSON and write it through [`write_text`]. diff --git a/big-code-analysis-cli/src/lib_tests.rs b/big-code-analysis-cli/src/lib_tests.rs index 18d208098..930ccb27c 100644 --- a/big-code-analysis-cli/src/lib_tests.rs +++ b/big-code-analysis-cli/src/lib_tests.rs @@ -1031,6 +1031,72 @@ fn collect_lines_handles_bom_then_whitespace_then_pattern() { ); } +/// Accepts every write and fails only at flush, the way `Stdout`'s +/// 1 KiB `LineWriter` behaves toward a payload containing no newline: +/// the bytes are taken into the buffer and the fd is not touched until +/// something flushes. +struct FlushFailingSink { + written: Vec, +} + +impl std::io::Write for FlushFailingSink { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + self.written.extend_from_slice(buf); + Ok(buf.len()) + } + + fn flush(&mut self) -> std::io::Result<()> { + Err(std::io::Error::new( + std::io::ErrorKind::StorageFull, + "no space left on device", + )) + } +} + +/// The flush in `write_parts_flushed`, which no byte of output can +/// reveal. +/// +/// `write_stdout_parts_or_die` decides the whole CLI's stdout-failure +/// policy, and its doc promises a `die` on anything but `BrokenPipe`. +/// That was false for a payload with no newline in it under 1 KiB: +/// `LineWriter` buffers it, the writes all report success, and the real +/// write happens in the exit-time cleanup flush whose error is +/// discarded — exit 0, no output. No shipped subcommand emits such a +/// document through this helper (they are line-oriented or +/// pretty-printed JSON, so an interior newline spills the buffer and the +/// error surfaces from a `write_all`), which is why the hole survived +/// #1132 and why it is pinned here rather than end-to-end. `bca vcs` +/// had the identical hole on a path that *could* produce one, and +/// `tests/read_failures.rs` covers that half. +/// +/// Deleting the `out.flush()` makes this the only failing test in the +/// workspace — verified. +#[test] +fn write_parts_flushed_surfaces_an_error_only_the_flush_reports() { + let mut sink = FlushFailingSink { + written: Vec::new(), + }; + let err = write_parts_flushed(&mut sink, &[b"vocabulary", b"\n"]) + .expect_err("a sink that fails at flush must not report success"); + + assert_eq!(err.kind(), std::io::ErrorKind::StorageFull); + assert_eq!( + sink.written, b"vocabulary\n", + "every chunk is forwarded in order before the flush is attempted", + ); +} + +/// The success path: chunks concatenate in argument order, so +/// `writeln_stdout_or_die`'s two-chunk shape emits `text` then the +/// newline rather than the other way round. +#[test] +fn write_parts_flushed_concatenates_chunks_in_order() { + let mut sink = Vec::new(); + write_parts_flushed(&mut sink, &[b"first", b"second", b"\n"]) + .expect("writing to a Vec is infallible"); + assert_eq!(sink, b"firstsecond\n"); +} + #[test] fn split_path_lines_keeps_hash_prefixed_lines_as_literal_paths() { // Pins the doc claim on `read_paths_from`: `#` is a path diff --git a/big-code-analysis-cli/src/path_io.rs b/big-code-analysis-cli/src/path_io.rs index a0e83da72..86dd1b7ff 100644 --- a/big-code-analysis-cli/src/path_io.rs +++ b/big-code-analysis-cli/src/path_io.rs @@ -4,24 +4,48 @@ use super::*; -/// Write every chunk of `parts` to stdout under one lock, tolerating -/// `BrokenPipe` (the typical case when the consumer is `head`, `less`, -/// etc.) and `die`ing on anything else. +/// Write every chunk of `parts` to stdout under one lock, flush, and +/// `die` on any failure other than `BrokenPipe` (the typical case when +/// the consumer is `head`, `less`, etc.). /// /// The single place the stdout-failure policy is decided, so the /// newline-appending variant below cannot drift from it. +/// +/// The flush carries the same `BrokenPipe` exemption as the writes, and +/// is what makes the policy true rather than incidental: `Stdout` is a +/// `LineWriter` over a 1 KiB buffer, so a payload containing no newline +/// *anywhere* in it and shorter than that never reaches the fd here — it +/// goes out in the exit-time cleanup flush, whose error is discarded and +/// the run exits 0 having emitted nothing. That is the shape `bca vcs` +/// shipped with (see [`crate::formats::write_text`]). +/// +/// No caller of *this* helper can stage it today: every document `bca` +/// prints through it is either line-oriented or pretty-printed JSON, so +/// the buffer spills on an interior newline and the error surfaces from +/// a `write_all`. The hole is therefore latent rather than live — which +/// is exactly why it needs pinning here instead of end-to-end, and why +/// [`write_parts_flushed`] is a seam. fn write_stdout_parts_or_die(parts: &[&[u8]]) { let mut out = std::io::stdout().lock(); - for part in parts { - if let Err(e) = out.write_all(part) { - if e.kind() != ErrorKind::BrokenPipe { - die(e); - } - return; - } + if let Err(e) = write_parts_flushed(&mut out, parts) + && e.kind() != ErrorKind::BrokenPipe + { + die(e); } } +/// Write every chunk of `parts` to `out` in order, then flush. +/// +/// Split out so the flush can be exercised against a sink that accepts +/// every write and fails only at flush time — the shape a `LineWriter` +/// presents to a newline-free payload, and one no shipped subcommand can +/// produce (see [`write_stdout_parts_or_die`]). Without the seam, +/// deleting `out.flush()` fails no test in the workspace. +pub(crate) fn write_parts_flushed(out: &mut impl Write, parts: &[&[u8]]) -> std::io::Result<()> { + parts.iter().try_for_each(|part| out.write_all(part))?; + out.flush() +} + /// Write `bytes` to stdout under the policy of /// [`write_stdout_parts_or_die`]. pub(crate) fn write_stdout_or_die(bytes: &[u8]) { @@ -32,9 +56,13 @@ pub(crate) fn write_stdout_or_die(bytes: &[u8]) { /// /// The `println!` that post-walk emissions (`count`'s tally, `preproc`'s /// JSON) used instead *panics* on a write error, exiting 101 where the -/// CLI documents `EXIT_TOOL_ERROR` (#1132). The newline is a separate -/// chunk rather than appended to `text`, so a multi-megabyte document is -/// not reallocated and copied just to grow by one byte. +/// CLI documents `EXIT_TOOL_ERROR` (#1132). The newline is passed as a +/// second chunk so both go out under the *one* lock +/// [`write_stdout_parts_or_die`] holds — a parallel walk cannot then +/// split a line from its terminator — and so the `BrokenPipe`-vs-`die` +/// decision and the flush stay in a single place. (An equivalent +/// `writeln!` would be no more costly; it would just re-decide the +/// policy here.) pub(crate) fn writeln_stdout_or_die(text: &str) { write_stdout_parts_or_die(&[text.as_bytes(), b"\n"]); } diff --git a/big-code-analysis-cli/tests/read_failures.rs b/big-code-analysis-cli/tests/read_failures.rs index 708ddd8c0..4dac0eeae 100644 --- a/big-code-analysis-cli/tests/read_failures.rs +++ b/big-code-analysis-cli/tests/read_failures.rs @@ -21,6 +21,19 @@ //! cases also pin the `BrokenPipe` exemption from the other direction — //! `bca dump | head` must stay exit 0. //! +//! The fourth part covers what that sweep missed: `bca vcs`, whose +//! emission runs through `formats::write_text` rather than the walk's +//! stdout helpers. `Stdout` is a `LineWriter` over a 1 KiB buffer, so a +//! shorter payload containing no newline at all is accepted into the +//! buffer and only written during the exit-time cleanup flush, whose +//! error nobody reads — the run exits 0 having emitted nothing. `vcs`'s +//! *compact* JSON is the only document `bca` prints with that shape; +//! everything else is line-oriented or pretty-printed, so the buffer +//! spills on an interior newline. The matching hole in +//! `path_io::write_stdout_parts_or_die` cannot be reached from any +//! subcommand and is pinned by a unit test on `write_parts_flushed` +//! instead. +//! //! Unix-only, because the scenarios are staged with a mode-000 file and //! `/dev/full`. //! `unreadable_fixture` probes the real capability rather than the uid, @@ -606,6 +619,143 @@ mod unix { assert_stdout_write_failure_exits_one("preproc", &[], TRIVIAL_C); } + /// Run `git ` in `dir` with fixed identities, reporting + /// whether it succeeded. `git` may be absent or refuse to build a + /// repo (a sandbox with no writable config, a hostile `GIT_*` + /// environment), which is a skip rather than a failure — the same + /// probe-the-capability convention [`dev_full`] and + /// `unreadable_fixture` follow. + fn git(dir: &TempDir, args: &[&str]) -> bool { + std::process::Command::new("git") + .args(args) + .current_dir(dir.path()) + .env("GIT_AUTHOR_NAME", "Ada") + .env("GIT_AUTHOR_EMAIL", "ada@example.com") + .env("GIT_COMMITTER_NAME", "Ada") + .env("GIT_COMMITTER_EMAIL", "ada@example.com") + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .is_ok_and(|status| status.success()) + } + + /// A throwaway repo with two commits touching one file, which is + /// the least history `bca vcs` / `vcs commit` / `vcs trend` all + /// produce a ranked document from. + fn git_repo_with_history() -> Option { + let dir = TempDir::new().expect("tempdir"); + if !git(&dir, &["init", "-q", "-b", "main"]) + || !git(&dir, &["config", "commit.gpgsign", "false"]) + { + return None; + } + write_fixture(&dir, "work.c", TRIVIAL_C); + if !git(&dir, &["add", "."]) || !git(&dir, &["commit", "-qm", "add work"]) { + return None; + } + write_fixture(&dir, "work.c", COMMENTED_C); + git(&dir, &["commit", "-aqm", "fix work"]).then_some(dir) + } + + /// #1132's sweep missed `bca vcs`, whose emission runs through + /// `formats::write_text` rather than the walk's stdout helpers. That + /// wrote the document with no flush, so `vcs -O json`, `vcs commit`, + /// and `vcs trend` — the three shapes whose document is *compact* + /// JSON — exited **0** with their output dropped. Measured before + /// the fix: 782 / 852 / 836 bytes on this fixture, all silently + /// discarded, while `yaml` / `toml` / `markdown` / `html` / `csv` / + /// the default table already exited 1. + /// + /// What separates them is not the *trailing* newline but any newline + /// at all: `LineWriter` flushes through the last one it finds, so a + /// pretty-printed document surfaces the error from its interior + /// newline even though it ends in `}`. The control run therefore + /// asserts the payload is newline-free and inside the buffer, rather + /// than assuming it — either property lost, and the test would pass + /// against the unflushed code. + fn assert_vcs_stdout_write_failure_exits_one(extra: &[&str]) { + let Some(repo) = git_repo_with_history() else { + eprintln!("skipping: no usable git for the `bca vcs` fixture"); + return; + }; + let Some(full) = dev_full() else { + eprintln!("skipping: no write-failing /dev/full on this platform"); + return; + }; + let run = |stdout: Stdio| { + common::std_bca_command_in(repo.path()) + .arg("vcs") + .arg("--no-config") + .args(extra) + .stdout(stdout) + .stderr(Stdio::piped()) + .spawn() + .expect("spawn bca") + .wait_with_output() + .expect("wait for bca") + }; + + let failed = run(full.into()); + let stderr = String::from_utf8_lossy(&failed.stderr).into_owned(); + assert_eq!( + failed.status.code(), + Some(1), + "`bca vcs {extra:?}` must exit 1 on an unwritable stdout; stderr: {stderr}" + ); + assert!( + stderr.contains("error: ") && stderr.contains("No space left on device"), + "`bca vcs {extra:?}` must name the I/O failure in its own \ + diagnostic; stderr: {stderr}" + ); + assert!( + !stderr.contains("panicked at"), + "`bca vcs {extra:?}` must not panic on an unwritable stdout; stderr: {stderr}" + ); + + let ok = run(Stdio::piped()); + assert!( + ok.status.success(), + "`bca vcs {extra:?}` must succeed with a writable stdout; stderr: {}", + String::from_utf8_lossy(&ok.stderr) + ); + assert!( + !ok.stdout.is_empty() && !ok.stdout.contains(&b'\n'), + "`bca vcs {extra:?}` must still emit a document with no newline \ + in it for this test to reach the missing flush at all; got {} \ + bytes, first newline at {:?}", + ok.stdout.len(), + ok.stdout.iter().position(|&b| b == b'\n'), + ); + assert!( + ok.stdout.len() < STDOUT_LINE_BUFFER_BYTES, + "`bca vcs {extra:?}` emits {} bytes, at or over stdout's line \ + buffer — a payload that large is written through on its own \ + and no longer exercises the flush", + ok.stdout.len(), + ); + } + + /// Capacity `std::io::stdout`'s `LineWriter` is built with. A + /// newline-free payload shorter than this is swallowed whole by the + /// buffer, which is the precondition every assertion above depends + /// on; a longer one spills straight to the fd and fails on its own. + const STDOUT_LINE_BUFFER_BYTES: usize = 1_024; + + #[test] + fn vcs_json_exits_one_when_stdout_cannot_be_written() { + assert_vcs_stdout_write_failure_exits_one(&["-O", "json"]); + } + + #[test] + fn vcs_commit_exits_one_when_stdout_cannot_be_written() { + assert_vcs_stdout_write_failure_exits_one(&["commit"]); + } + + #[test] + fn vcs_trend_exits_one_when_stdout_cannot_be_written() { + assert_vcs_stdout_write_failure_exits_one(&["trend"]); + } + /// The other half of the contract: `BrokenPipe` is not a tool error. /// `bca dump | head -1` is routine, and converting the banner from /// `println!` to a fallible write is exactly the change that could diff --git a/src/metrics/cognitive/python.rs b/src/metrics/cognitive/python.rs index 65c6a82c1..ead33fa66 100644 --- a/src/metrics/cognitive/python.rs +++ b/src/metrics/cognitive/python.rs @@ -87,12 +87,11 @@ fn python_apply_boolean_operator<'a>( { stats.structural += node.count_specific_ancestors::(ancestors, python_is_lambda, |node| { - // All four arms fire in practice, but only `ExpressionList` - // can change the count: a lambda body is a single - // expression, so no lambda ever sits *above* an - // `if`/`for`/`while` statement, and stopping at one is - // indistinguishable from running to the module root. The - // three statement kinds are kept as an explicit + // Only `ExpressionList` can change the count: a lambda + // body is a single expression, so no lambda ever sits + // *above* an `if`/`for`/`while` statement, and stopping + // at one is indistinguishable from running to the module + // root. The three statement kinds are kept as an explicit // statement-boundary set rather than a claim about // coverage. `ExpressionList` is the observable arm, // reached via a parenthesised `yield` or an f-string From fd984553e5d10d43e7d5f6bfd14d31c1eef23a68 Mon Sep 17 00:00:00 2001 From: Elijah Zupancic Date: Sat, 1 Aug 2026 00:32:48 -0700 Subject: [PATCH 12/36] perf(vcs): hold one blame session per worker thread `per_function` rebuilt everything it needed for each file: a `to_thread_local()` repository handle, a re-read `.mailmap`, and a commit-metadata map that died on return, so a commit touching 200 files was decoded, mailmap-resolved, and classified 200 times. Worse, that handle carried no ODB object cache at all, and no configuration on the engine could give it one: `into_sync()` discards both cache layers, `to_thread_local()` rebuilds through `gix_odb::Cache::from` with `object_cache: None`, and the `setup_objects` that runs on rebuild re-applies a pack cache unconditionally but an object cache only when `gitoxide.objects.cacheLimit` is non-zero - which defaults to zero. All of that collapses into one change. `BlameSession` (new, public) holds the handle, its object cache, the mailmap, and the commit memo for a thread's lifetime; the CLI keeps one per worker in a thread-local, and the Python batch path keeps one per repository. `PerFunctionBlame::per_function` is unchanged as the one-shot form. Holding a handle for a thread rather than a file does not widen the issue-#579 staleness window: the `ThreadSafeRepository` and its `gix_odb::Store` are already fixed for the engine's lifetime, and a handle refreshes its own pack-index snapshot on a miss before reporting one, so the retry re-reads exactly what a fresh handle would. `gix::Repository::reload` inside the retry would re-open from disk to observe external writes the miss is not about. `vcs commit` and `vcs trend` gained the object cache their sibling walks already set; trend's comment about freeing a cache it never set is now true. Over this repository's `src/` (325 files) at `--jobs 1`, min of seven interleaved runs: wall 2.94s -> 1.78s, CPU 2.82s -> 1.61s. Output is byte-identical, as is `bca vcs`, `vcs commit`, and `vcs trend`. The per-file `canonicalize` the issue lists first is left alone: it measures 4ms over these 325 files, 0.25% of the run, and deriving repo-relative paths lexically instead would change what a symlinked in-tree directory resolves to. Fixes #1117 --- .bca-baseline.toml | 73 ++++++--- STABILITY.md | 3 +- big-code-analysis-cli/src/vcs_command.rs | 161 ++++++++++++++++++- big-code-analysis-py/src/batch.rs | 21 +-- big-code-analysis-py/src/lib.rs | 22 ++- big-code-analysis-py/src/vcs.rs | 20 ++- src/vcs/git/blame.rs | 196 +++++++++++++++++++++-- src/vcs/git/blame_tests.rs | 85 ++++++++++ src/vcs/git/jit.rs | 5 +- src/vcs/git/mod.rs | 2 +- src/vcs/git/trend.rs | 3 +- src/vcs/mod.rs | 2 +- tests/vcs_per_function.rs | 160 ++++++++++++++++++ 13 files changed, 688 insertions(+), 65 deletions(-) diff --git a/.bca-baseline.toml b/.bca-baseline.toml index d53e3946b..27d7de15f 100644 --- a/.bca-baseline.toml +++ b/.bca-baseline.toml @@ -182,14 +182,14 @@ value = 6.0 [[entry]] path = "big-code-analysis-cli/src/dispatch.rs" qualified = "dispatch_metrics" -start_line = 231 +start_line = 235 metric = "nargs" value = 7.0 [[entry]] path = "big-code-analysis-cli/src/dispatch.rs" qualified = "dispatch_ops" -start_line = 290 +start_line = 294 metric = "nargs" value = 8.0 @@ -252,7 +252,7 @@ value = 7.0 [[entry]] path = "big-code-analysis-cli/src/lib.rs" qualified = "legacy_hint" -start_line = 678 +start_line = 723 metric = "halstead.effort" value = 59351.805921378844 @@ -287,7 +287,7 @@ value = 8.0 [[entry]] path = "big-code-analysis-cli/src/metric_diff.rs" qualified = "MetricDiff::from_sets" -start_line = 238 +start_line = 255 metric = "nargs" value = 8.0 @@ -326,24 +326,31 @@ start_line = 851 metric = "halstead.effort" value = 56531.05676283881 +[[entry]] +path = "big-code-analysis-cli/src/vcs_command.rs" +qualified = "" +start_line = 1 +metric = "loc.ploc" +value = 529.0 + [[entry]] path = "big-code-analysis-cli/src/vcs_command.rs" qualified = "build_options" -start_line = 233 +start_line = 234 metric = "nargs" value = 8.0 [[entry]] path = "big-code-analysis-cli/src/vcs_command.rs" qualified = "rank" -start_line = 323 +start_line = 324 metric = "nargs" value = 7.0 [[entry]] path = "big-code-analysis-cli/src/vcs_command.rs" qualified = "write_table" -start_line = 466 +start_line = 467 metric = "nexits" value = 5.0 @@ -378,28 +385,28 @@ value = 6.0 [[entry]] path = "big-code-analysis-py/src/batch.rs" qualified = "analyze_paths" -start_line = 622 +start_line = 625 metric = "nargs" value = 12.0 [[entry]] path = "big-code-analysis-py/src/batch.rs" qualified = "analyze_paths" -start_line = 622 +start_line = 625 metric = "nexits" value = 5.0 [[entry]] path = "big-code-analysis-py/src/lib.rs" qualified = "PyVcsOptions::py_new" -start_line = 599 +start_line = 607 metric = "nargs" value = 15.0 [[entry]] path = "big-code-analysis-py/src/lib.rs" qualified = "_native" -start_line = 830 +start_line = 838 metric = "nexits" value = 35.0 @@ -413,21 +420,21 @@ value = 8.0 [[entry]] path = "big-code-analysis-py/src/lib.rs" qualified = "extract_as_of" -start_line = 532 +start_line = 540 metric = "nexits" value = 5.0 [[entry]] path = "big-code-analysis-py/src/lib.rs" qualified = "register_vcs_submodule" -start_line = 893 +start_line = 901 metric = "nexits" value = 14.0 [[entry]] path = "big-code-analysis-py/src/lib.rs" qualified = "vcs_trend" -start_line = 746 +start_line = 754 metric = "nargs" value = 7.0 @@ -812,7 +819,7 @@ value = 10.0 [[entry]] path = "src/metrics/npa/python.rs" qualified = "python_self_attr_name_bytes" -start_line = 305 +start_line = 303 metric = "nexits" value = 6.0 @@ -835,7 +842,7 @@ path = "src/node.rs" qualified = "Node<'a>" start_line = 93 metric = "nom" -value = 35.0 +value = 34.0 [[entry]] path = "src/ops.rs" @@ -991,17 +998,31 @@ start_line = 192 metric = "nargs" value = 10.0 +[[entry]] +path = "src/vcs/git/blame.rs" +qualified = "PerFunctionBlame" +start_line = 255 +metric = "nom" +value = 30.0 + +[[entry]] +path = "src/vcs/git/blame.rs" +qualified = "PerFunctionBlame::blame_spans" +start_line = 391 +metric = "nargs" +value = 7.0 + [[entry]] path = "src/vcs/git/blame.rs" qualified = "PerFunctionBlame::open" -start_line = 262 +start_line = 266 metric = "nexits" value = 6.0 [[entry]] path = "src/vcs/git/blame.rs" qualified = "PerFunctionBlame::resolve_commit" -start_line = 438 +start_line = 518 metric = "nexits" value = 6.0 @@ -1085,49 +1106,49 @@ value = 9.0 [[entry]] path = "src/vcs/git/jit.rs" qualified = "collect_touched" -start_line = 205 +start_line = 208 metric = "nexits" value = 10.0 [[entry]] path = "src/vcs/git/jit.rs" -qualified = "collect_touched::" -start_line = 224 +qualified = "collect_touched::" +start_line = 227 metric = "nexits" value = 7.0 [[entry]] path = "src/vcs/git/jit.rs" qualified = "compute_features" -start_line = 161 +start_line = 164 metric = "nargs" value = 7.0 [[entry]] path = "src/vcs/git/jit.rs" qualified = "compute_features" -start_line = 161 +start_line = 164 metric = "nexits" value = 5.0 [[entry]] path = "src/vcs/git/jit.rs" qualified = "experience_features" -start_line = 475 +start_line = 478 metric = "nargs" value = 7.0 [[entry]] path = "src/vcs/git/jit.rs" qualified = "experience_features" -start_line = 475 +start_line = 478 metric = "nexits" value = 6.0 [[entry]] path = "src/vcs/git/jit.rs" qualified = "history_features" -start_line = 388 +start_line = 391 metric = "halstead.effort" value = 52560.02538757721 diff --git a/STABILITY.md b/STABILITY.md index f99f7e4e8..06d085bed 100644 --- a/STABILITY.md +++ b/STABILITY.md @@ -280,7 +280,8 @@ envelope (`bca vcs`'s report, `POST /vcs`, `vcs_metrics()`, and 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, LineSpan}`, the +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 only the file space). The per-function block shares diff --git a/big-code-analysis-cli/src/vcs_command.rs b/big-code-analysis-cli/src/vcs_command.rs index 968d485d4..d42e4dabd 100644 --- a/big-code-analysis-cli/src/vcs_command.rs +++ b/big-code-analysis-cli/src/vcs_command.rs @@ -10,6 +10,7 @@ //! file (or stdout) — a whole-repo report is one document, not the //! per-file directory that `metrics`/`ops` emit (issue #573). +use std::cell::RefCell; use std::collections::HashSet; use std::io::Write; use std::path::{Path, PathBuf}; @@ -721,7 +722,7 @@ pub(crate) fn default_blame(globals: &GlobalOpts) -> Option, ) { // Pre-order over descendants (the root is the file space). The same // traversal order is replayed in `assign_child_stats`, so the returned @@ -731,7 +732,7 @@ pub(crate) fn inject_per_function( if spans.is_empty() { return; } - match blame.per_function(path, &spans) { + match blame_with_thread_session(blame, path, &spans) { Ok(stats) => { // `per_function` returns exactly one `Stats` per span, and // `assign_child_stats` replays the identical pre-order, so the @@ -754,6 +755,49 @@ pub(crate) fn inject_per_function( } } +thread_local! { + /// The calling worker's [`vcs::BlameSession`], reused across every + /// file that thread blames. + /// + /// A session is `!Sync` (it owns a `gix::Repository`), so it cannot + /// live in the shared `Config` alongside the engine; and + /// `ConcurrentRunner`'s per-file callback is a plain + /// `Fn(PathBuf, &Config)` with no per-thread state slot to thread one + /// through. A thread-local is the seam that exists. It is dropped when + /// the worker exits, which is the end of the run. + static BLAME_SESSION: RefCell> = const { RefCell::new(None) }; +} + +/// Blame `path` through this thread's session, opening one (or replacing +/// a session belonging to a different engine) on first use. +/// +/// The engine check is not decoration: one process can build more than +/// one `Config` — the test suite does — and a session carries its +/// engine's repository, target ref, windows, and bot filter. Serving a +/// second engine from the first one's session would answer with the +/// wrong repository's history rather than fail. +fn blame_with_thread_session( + blame: &Arc, + path: &Path, + spans: &[vcs::LineSpan], +) -> Result, vcs::Error> { + // The borrow spans the whole blame rather than just the slot swap: + // the session is what the blame runs against, and moving it out and + // back would lose it on an unwind. `with_borrow_mut`'s panic-on- + // re-entry is unreachable — nothing the blame calls touches + // `BLAME_SESSION`, which is private to this module and read here only. + BLAME_SESSION.with_borrow_mut(|slot| { + if slot + .as_ref() + .is_none_or(|session| !Arc::ptr_eq(session.engine(), blame)) + { + *slot = None; + } + slot.get_or_insert_with(|| blame.session()) + .per_function(path, spans) + }) +} + /// Collect the 1-based inclusive line span of every descendant space, in /// pre-order. Saturates a span line past `u32::MAX` (no real source file /// reaches that line count) rather than wrapping. @@ -1055,4 +1099,117 @@ fn outer(x: i32) -> i32 { let rel: PathBuf = ["src", "work.rs"].iter().collect(); assert_eq!(path_to_string(&rel).as_deref(), Some("src/work.rs")); } + + /// Fixed "now" for the blame fixture below; commit times are offsets + /// before it, so the window arithmetic is exact. + const FIXED_NOW: i64 = 1_700_000_000; + const DAY: i64 = 86_400; + + /// A throwaway repo with `src/work.rs` created 200 days before + /// [`FIXED_NOW`] and edited 5 days before it — two commits, one per + /// side of a 30-day window. + fn blame_fixture() -> tempfile::TempDir { + fn git(dir: &Path, secs: i64, args: &[&str]) { + let date = format!("@{secs} +0000"); + let status = std::process::Command::new("git") + .args(args) + .current_dir(dir) + .env("GIT_AUTHOR_NAME", "Ada") + .env("GIT_AUTHOR_EMAIL", "ada@example.com") + .env("GIT_AUTHOR_DATE", &date) + .env("GIT_COMMITTER_NAME", "Ada") + .env("GIT_COMMITTER_EMAIL", "ada@example.com") + .env("GIT_COMMITTER_DATE", &date) + .status() + .expect("run git"); + assert!(status.success(), "git {args:?} failed"); + } + + let dir = tempfile::tempdir().expect("tempdir"); + git(dir.path(), FIXED_NOW, &["init", "-q", "-b", "main"]); + git( + dir.path(), + FIXED_NOW, + &["config", "commit.gpgsign", "false"], + ); + // Neutralise a contributor's global `core.hooksPath` (#941): + // `--no-verify` skips the commit hooks, not `post-commit`. + let no_hooks = dir.path().join(".bca-empty-hooks"); + std::fs::create_dir_all(&no_hooks).expect("mkdir empty hooks dir"); + let no_hooks = no_hooks.to_str().expect("hooks path is valid UTF-8"); + git( + dir.path(), + FIXED_NOW, + &["config", "core.hooksPath", no_hooks], + ); + git(dir.path(), FIXED_NOW, &["config", "core.autocrlf", "false"]); + std::fs::create_dir(dir.path().join("src")).expect("mkdir src"); + std::fs::write( + dir.path().join("src/work.rs"), + "fn f() {\n let x = 1;\n}\n", + ) + .expect("write"); + git(dir.path(), FIXED_NOW - 200 * DAY, &["add", "-A"]); + git( + dir.path(), + FIXED_NOW - 200 * DAY, + &["commit", "-q", "--no-verify", "-m", "create"], + ); + std::fs::write( + dir.path().join("src/work.rs"), + "fn f() {\n let x = 2;\n}\n", + ) + .expect("rewrite"); + git(dir.path(), FIXED_NOW - 5 * DAY, &["add", "-A"]); + git( + dir.path(), + FIXED_NOW - 5 * DAY, + &["commit", "-q", "--no-verify", "-m", "edit"], + ); + dir + } + + /// The per-thread blame session must be rebuilt when the engine it is + /// asked to serve is not the one it was opened from (issue #1117). + /// + /// Both engines here point at the **same** work tree and differ only + /// in their long window, so serving the second from the first's + /// session returns a plausible number rather than an error — exactly + /// the silent failure the `Arc::ptr_eq` guard exists to prevent. + /// Deleting that guard makes the `narrow` assertion below report 2. + #[test] + fn thread_session_is_rebuilt_for_a_different_engine() { + let repo = blame_fixture(); + let file = repo.path().join("src/work.rs"); + let spans = [vcs::LineSpan::new(1, 3)]; + + let mut wide_opts = Options::default(); + wide_opts.as_of = Some(FIXED_NOW); + let mut narrow_opts = wide_opts.clone(); + // 30 days: only the second commit falls inside. + narrow_opts.long_window_secs = 30 * DAY; + narrow_opts.recent_window_secs = 30 * DAY; + + let wide = Arc::new( + vcs::PerFunctionBlame::open(repo.path(), wide_opts).expect("open wide engine"), + ); + let narrow = Arc::new( + vcs::PerFunctionBlame::open(repo.path(), narrow_opts).expect("open narrow engine"), + ); + + let first = blame_with_thread_session(&wide, &file, &spans).expect("wide blame"); + assert_eq!(first[0].commits_long, 2, "365d window sees both commits"); + + let second = blame_with_thread_session(&narrow, &file, &spans).expect("narrow blame"); + assert_eq!( + second[0].commits_long, 1, + "30d window must see only the recent commit — a stale session \ + would answer with the wide engine's 2" + ); + + // Switching back must rebuild again rather than keep the narrow + // session, so the guard is not a one-way latch. + let third = blame_with_thread_session(&wide, &file, &spans).expect("wide re-blame"); + assert_eq!(third[0].commits_long, 2, "switching back must rebuild"); + } } diff --git a/big-code-analysis-py/src/batch.rs b/big-code-analysis-py/src/batch.rs index 53ee00bf7..1377c12e2 100644 --- a/big-code-analysis-py/src/batch.rs +++ b/big-code-analysis-py/src/batch.rs @@ -46,7 +46,7 @@ use pyo3::exceptions::{PyTypeError, PyValueError}; use pyo3::prelude::*; use pyo3::types::{PyTuple, PyType}; -use big_code_analysis::vcs::{HistoryIndex, PerFunctionBlame}; +use big_code_analysis::vcs::{BlameSession, HistoryIndex}; use crate::analysis::{self, AnalysisError, AnalyzeOptions}; use crate::conversion; @@ -474,7 +474,7 @@ fn push_one_result( /// /// `analyze(p, vcs=True)` walks a repository's history once **per file**; /// the batch path instead builds one [`HistoryIndex`] (and/or one -/// [`PerFunctionBlame`] engine) per **containing repository** and reuses it +/// [`BlameSession`]) per **containing repository** and reuses it /// across every file in that repo — the amortisation win the CLI walker /// already gets. The cache is keyed by the discovered work-tree root /// (`vcs::workdir_root`), so two files in different subdirectories of one @@ -496,7 +496,7 @@ struct VcsRepoCache { // "discovery/open already failed here" — cached so a whole out-of-repo // tree is probed at most once. indexes: HashMap>, - blames: HashMap>, + blames: HashMap>, } impl VcsRepoCache { @@ -561,14 +561,17 @@ impl VcsRepoCache { } } if self.vcs_per_function { - let opened = if self.blames.contains_key(&root) { - self.blames.get(&root) - } else { + if !self.blames.contains_key(&root) { let built = py.detach(|| vcs_bridge::open_blame_for(&root)); self.blames.insert(root.clone(), built); - self.blames.get(&root) - }; - if let Some(Some(blame)) = opened { + } + // `contains_key` + `get_mut` rather than the index path's + // `get` + `entry().or_insert()`: the cached value is now a + // blame *session*, which accumulates the repository handle, + // mailmap, and resolved-commit memo across every file in this + // repo (#1117), so each blame needs it by `&mut` — and this + // shape keeps the hit path from cloning `root`. + if let Some(Some(blame)) = self.blames.get_mut(&root) { json = attach_or_keep(json, |j| { vcs_bridge::inject_vcs_per_function_with_blame(j, path, blame) }); diff --git a/big-code-analysis-py/src/lib.rs b/big-code-analysis-py/src/lib.rs index e9fc30dd7..adc7c277e 100644 --- a/big-code-analysis-py/src/lib.rs +++ b/big-code-analysis-py/src/lib.rs @@ -317,12 +317,20 @@ fn analyze( // a one-shot history walk of its repository; `vcs_per_function=True` // (#329 / #578) blames the file once and attaches a block to every // nested function space. The two are independent (file-level vs nested - // spaces), so both can be set. The expensive part of each is the - // history walk / blame-engine open, which touches no Python objects — - // so it runs off-GIL (#620), mirroring `vcs_metrics()` and the batch - // path. The cheap JSON-injection step (which builds Python errors) - // stays under the re-acquired GIL. For whole-repo ranking prefer + // spaces), so both can be set. For `vcs=True` the expensive part is + // the history walk, which touches no Python objects and so runs + // off-GIL (#620), mirroring `vcs_metrics()` and the batch path; the + // cheap JSON-injection step (which builds Python errors) stays under + // the re-acquired GIL. For whole-repo ranking prefer // `vcs_metrics()`, which walks history once. + // + // `vcs_per_function=True` does **not** get that split: only the + // session open is detached below, and the blame itself runs inside + // `inject_vcs_per_function_with_blame` with the GIL held. #1117 made + // the open cheaper still (it now defers the handle, mailmap, and + // commit memo), which widens the gap rather than closing it — + // splitting the injector so the blame runs off-GIL and only the JSON + // attach re-acquires is tracked separately. match result { None => Ok(None), Some(mut json) => { @@ -334,8 +342,8 @@ fn analyze( } if vcs_per_function { let root = crate::vcs::repo_root_for(&path).to_path_buf(); - if let Some(blame) = py.detach(|| crate::vcs::open_blame_for(&root)) { - json = crate::vcs::inject_vcs_per_function_with_blame(json, &path, &blame)?; + if let Some(mut blame) = py.detach(|| crate::vcs::open_blame_for(&root)) { + json = crate::vcs::inject_vcs_per_function_with_blame(json, &path, &mut blame)?; } } conversion::json_string_to_py(py, &json).map(Some) diff --git a/big-code-analysis-py/src/vcs.rs b/big-code-analysis-py/src/vcs.rs index e6de4a05f..c2c644dd6 100644 --- a/big-code-analysis-py/src/vcs.rs +++ b/big-code-analysis-py/src/vcs.rs @@ -301,10 +301,20 @@ pub(crate) fn build_index_for(root: &Path) -> Option { } /// Open a default-options [`PerFunctionBlame`] engine for the repository -/// containing `root`, for the batch `vcs_per_function=True` path (#670). -/// Returns `None` when `root` is not inside a repository. -pub(crate) fn open_blame_for(root: &Path) -> Option { - vcs::PerFunctionBlame::open(root, Options::default()).ok() +/// containing `root` and return a blame session on it, for the +/// `vcs_per_function=True` paths (#670). Returns `None` when `root` is not +/// inside a repository. +/// +/// A session rather than the bare engine because the batch path blames +/// every file in one repository through it: the repository handle, its +/// object cache, the `.mailmap`, and the resolved-commit memo are then +/// built once per repository instead of once per file (#1117). The +/// single-file path opens (and drops) one session for its one blame, +/// which costs the same as the bare engine did. +pub(crate) fn open_blame_for(root: &Path) -> Option { + vcs::PerFunctionBlame::open(root, Options::default()) + .ok() + .map(|engine| std::sync::Arc::new(engine).session()) } /// Inject a file-level `vcs` block using a pre-built [`HistoryIndex`]. @@ -382,7 +392,7 @@ fn attach_vcs_to_space(space: &mut Value, wire_vcs: &mut wire::Vcs) -> Result<() pub(crate) fn inject_vcs_per_function_with_blame( funcspace_json: String, file_path: &Path, - blame: &vcs::PerFunctionBlame, + blame: &mut vcs::BlameSession, ) -> Result { let mut doc: Value = serde_json::from_str(&funcspace_json) .map_err(|e| PyValueError::new_err(format!("parsing metrics JSON: {e}")))?; diff --git a/src/vcs/git/blame.rs b/src/vcs/git/blame.rs index 22b41c56d..cba4b4e84 100644 --- a/src/vcs/git/blame.rs +++ b/src/vcs/git/blame.rs @@ -76,6 +76,7 @@ use std::collections::HashMap; use std::path::{Path, PathBuf}; +use std::sync::Arc; use bstr::BString; use gix::ObjectId; @@ -206,10 +207,13 @@ fn is_transient_object_miss(err: &gix::object::find::existing::with_conversion:: /// /// Holds a [`gix::ThreadSafeRepository`] (so the field can be shared /// read-only across the CLI's worker threads — a plain -/// [`gix::Repository`] is not `Sync`); each [`per_function`] call clones -/// a thread-local handle. Construct once per invocation with [`open`]. +/// [`gix::Repository`] is not `Sync`). Construct once per invocation with +/// [`open`], then open one [`BlameSession`] per worker thread with +/// [`session`]: the session carries the thread-local handle, its object +/// cache, the mailmap, and the resolved-commit memo, none of which +/// survive a per-file handle. /// -/// [`per_function`]: PerFunctionBlame::per_function +/// [`session`]: PerFunctionBlame::session /// [`open`]: PerFunctionBlame::open pub struct PerFunctionBlame { repo: gix::ThreadSafeRepository, @@ -308,6 +312,55 @@ impl PerFunctionBlame { &self.workdir } + /// Open a reusable [`BlameSession`] on the calling thread. + /// + /// Every cost that [`BlameSession::per_function`] would otherwise pay + /// per file — the thread-local repository handle, its object cache, + /// the parsed `.mailmap`, and the resolved-commit memo — lives on the + /// session instead, so a caller that blames many files in one repository + /// pays each once. Sessions are `!Sync` by construction (they hold a + /// [`gix::Repository`]); a worker pool wants one per thread, not one + /// shared. + /// + /// Holding a handle for a whole thread rather than a single file does + /// **not** widen the issue-#579 staleness window. The + /// `ThreadSafeRepository` — and the `gix_odb::Store` behind it — is + /// already fixed for the engine's lifetime, so a handle's age adds no + /// config or pack staleness of its own; and a handle refreshes its own + /// pack-index snapshot on a miss before reporting one, so the retry in + /// [`per_function`](Self::per_function) re-reads exactly what a fresh + /// handle would. `gix::Repository::reload` (a full re-open from disk) + /// is therefore the wrong tool inside that retry: the miss it guards + /// is an internal gix index-load race, not an external write we need + /// to observe. + #[must_use] + pub fn session(self: &Arc) -> BlameSession { + BlameSession { + engine: Arc::clone(self), + state: self.new_state(), + } + } + + /// Build the per-thread state a blame run needs: a thread-local + /// repository handle carrying the object cache, the repository + /// mailmap, and an empty commit-metadata memo. + fn new_state(&self) -> SessionState { + let mut repo = self.repo.to_thread_local(); + // The engine's `ThreadSafeRepository` cannot carry this: `into_sync` + // discards both ODB cache layers and `to_thread_local` rebuilds the + // handle with `object_cache: None` (gix's `setup_objects` re-applies + // only the pack cache unless `gitoxide.objects.cacheLimit` is set, + // and it defaults to 0). The cache has to be set here, on the + // thread-local handle, or the blame path runs without one entirely. + repo.object_cache_size_if_unset(super::OBJECT_CACHE_BYTES); + let mailmap = repo.open_mailmap(); + SessionState { + repo, + mailmap, + commits: HashMap::new(), + } + } + /// Blame the file at `absolute` once and return one [`Stats`] per /// entry in `spans`, in the same order. /// @@ -316,13 +369,31 @@ impl PerFunctionBlame { /// recent activity), so the caller can attach a block to every /// function space uniformly. /// + /// This is the one-shot form: it builds and discards a + /// [`BlameSession`] per call. Callers blaming more than one file + /// should hold a session ([`session`](Self::session)) instead. + /// /// # Errors /// /// Returns [`Error::Blame`] when the path lies outside the working /// tree, is not valid UTF-8, or the blame itself fails (e.g. the /// file does not exist at the target ref). pub fn per_function(&self, absolute: &Path, spans: &[LineSpan]) -> Result, Error> { - let repo = self.repo.to_thread_local(); + self.blame_spans(&mut self.new_state(), absolute, spans) + } + + /// Blame `absolute` against `state`'s repository handle and fold the + /// result into one [`Stats`] per span. Distinct blamed commits + /// resolve through `state.commits`, so a session that spans many + /// files resolves each commit once per *session* rather than once per + /// file — a `T`-worker walk still resolves a widely-touching commit + /// up to `T` times, once per thread's session. + fn blame_spans( + &self, + state: &mut SessionState, + absolute: &Path, + spans: &[LineSpan], + ) -> Result, Error> { let relative = self.repo_relative(absolute)?; // `blame_file` takes its options by value, so clone the (cheap) @@ -338,7 +409,11 @@ impl PerFunctionBlame { #[allow(clippy::result_large_err)] let outcome = retry_transient( MAX_BLAME_ATTEMPTS, - || repo.blame_file(relative.as_ref(), self.head, options.clone()), + || { + state + .repo + .blame_file(relative.as_ref(), self.head, options.clone()) + }, is_transient_blame_miss, ) .map_err(|e| Error::Blame(e.to_string()))?; @@ -346,13 +421,18 @@ impl PerFunctionBlame { // Resolve each distinct blamed commit once: timestamp, window // membership, participants, and message classification. A commit // outside the long window or authored solely by filtered bots - // resolves to `None` and contributes to no span. - let mailmap = repo.open_mailmap(); - let resolver = ParticipantResolver::new(&mailmap, self.bots.as_ref()); - let mut meta: HashMap> = HashMap::new(); + // resolves to `None` and contributes to no span. Destructured so + // the memo can be filled while the mailmap it is resolved against + // stays borrowed — two disjoint fields of the same session. + let SessionState { + repo, + mailmap, + commits: meta, + } = state; + let resolver = ParticipantResolver::new(mailmap, self.bots.as_ref()); for entry in &outcome.entries { if let std::collections::hash_map::Entry::Vacant(slot) = meta.entry(entry.commit_id) { - slot.insert(self.resolve_commit(&repo, entry.commit_id, &resolver)?); + slot.insert(self.resolve_commit(repo, entry.commit_id, &resolver)?); } } @@ -375,7 +455,7 @@ impl PerFunctionBlame { Ok(spans .iter() - .map(|span| self.aggregate_span(*span, &runs, &meta)) + .map(|span| self.aggregate_span(*span, &runs, meta)) .collect()) } @@ -512,6 +592,100 @@ impl PerFunctionBlame { } } +/// The per-thread state a run of blames reuses, held by a +/// [`BlameSession`] (or built and discarded by the one-shot +/// [`PerFunctionBlame::per_function`]). +struct SessionState { + /// Thread-local repository handle, carrying the object cache. + repo: gix::Repository, + /// The repository's parsed `.mailmap`. + mailmap: gix::mailmap::Snapshot, + /// Metadata for every commit blamed so far, `None` for one outside + /// the long window or authored solely by filtered bots. This is the + /// entry that costs the most to rebuild: a commit touching 200 files + /// is decoded, mailmap-resolved, and classified once per session + /// instead of once per file. + /// + /// Bounded by the distinct in-window commits with surviving blamed + /// lines, not by repository size, at roughly 150 bytes each — a + /// quarter of a megabyte for this repository, single-digit megabytes + /// per thread at kernel scale. Held per session, so a `T`-worker walk + /// holds up to `T` copies. + commits: HashMap>, +} + +/// A [`PerFunctionBlame`] bound to one thread, holding the state a blame +/// would otherwise rebuild per file. +/// +/// Obtained from [`PerFunctionBlame::session`]. Not `Sync` — it holds a +/// [`gix::Repository`] — so a worker pool wants one session per thread. +/// The engine it was opened from is kept alive by the session and +/// readable through [`engine`](Self::engine), so a caller memoising +/// sessions can confirm a cached one belongs to the engine at hand. +pub struct BlameSession { + engine: Arc, + state: SessionState, +} + +// A session must stay `Send` — the Python batch path moves one across +// `Python::detach`, and a worker pool that builds sessions centrally +// would too. `Sync` is deliberately *not* asserted: the session owns a +// `gix::Repository`, which is not `Sync`, and one session per thread is +// the intended shape. +const _: fn() = || { + fn assert_send() {} + assert_send::(); +}; + +// Hand-written for the same reason as `PerFunctionBlame`'s: the +// repository handle and the mailmap are large and carry nothing a reader +// of a `Debug` config dump wants. +impl std::fmt::Debug for BlameSession { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("BlameSession") + .field("engine", &self.engine) + .field("commits_resolved", &self.state.commits.len()) + .finish_non_exhaustive() + } +} + +impl BlameSession { + /// The engine this session was opened from. + /// + /// Compare with [`Arc::ptr_eq`] to confirm a memoised session belongs + /// to the engine a caller is about to blame with. + #[must_use] + pub fn engine(&self) -> &Arc { + &self.engine + } + + /// How many distinct commits this session has resolved so far — the + /// size of the memo that makes a session worth holding. + /// + /// Grows only when a blame meets a commit the session has not seen, + /// so it is also how a caller sizes the session's memory (see + /// `SessionState::commits`) or confirms the memo is doing its job. + #[must_use] + pub fn commits_resolved(&self) -> usize { + self.state.commits.len() + } + + /// Blame the file at `absolute` once and return one [`Stats`] per + /// entry in `spans`, in the same order — the session-backed form of + /// [`PerFunctionBlame::per_function`], with identical semantics. + /// + /// # Errors + /// + /// See [`PerFunctionBlame::per_function`]. + pub fn per_function( + &mut self, + absolute: &Path, + spans: &[LineSpan], + ) -> Result, Error> { + self.engine.blame_spans(&mut self.state, absolute, spans) + } +} + /// One blamed commit's metadata, resolved once and shared across every /// span that the commit touches. struct CommitMeta { diff --git a/src/vcs/git/blame_tests.rs b/src/vcs/git/blame_tests.rs index 6bbdca98e..a0a4082c2 100644 --- a/src/vcs/git/blame_tests.rs +++ b/src/vcs/git/blame_tests.rs @@ -174,3 +174,88 @@ fn line_run_from_blame_hunk_saturates_at_u32_ceiling() { assert_eq!(wide.lo, u32::MAX); assert_eq!(wide.hi, u32::MAX - 1); } + +/// Round-tripping a repository through [`gix::ThreadSafeRepository`] +/// drops the ODB object cache, and the rebuilt thread-local handle does +/// **not** get one back — so the cache has to be set *after* +/// `to_thread_local`, which is what `PerFunctionBlame::new_state` does +/// (issue #1117). +/// +/// Pinning gix's half of that here rather than trusting a read of its +/// sources: `into_sync` keeps only `objects.into_inner().store()`, +/// `to_thread_local` rebuilds through `gix_odb::Cache::from` (which sets +/// `object_cache: None`), and the `setup_objects` that runs on rebuild +/// re-applies a pack cache unconditionally but an object cache only when +/// `gitoxide.objects.cacheLimit` is non-zero — and it defaults to zero. +/// A future gix that starts propagating the cache fails the first +/// assertion, at which point `new_state`'s call becomes redundant rather +/// than load-bearing, and this is the place that says so. +/// +/// No commit is needed: the question is about handle construction, not +/// about any object in the database. +#[test] +fn thread_local_handle_carries_no_object_cache_until_one_is_set() { + let dir = tempfile::tempdir().expect("temp dir"); + let repo = gix::init(dir.path()).expect("init repository"); + let shared = repo.into_sync(); + + let plain = shared.to_thread_local(); + assert!( + !plain.objects.has_object_cache(), + "gix handed back an object cache on a fresh thread-local handle; \ + if that is now the default, new_state's object_cache_size_if_unset \ + is no longer load-bearing" + ); + // The *pack* cache is re-applied on rebuild, so an assertion that + // merely checked "some cache exists" would pass against the bug. + assert!( + plain.objects.has_pack_cache(), + "setup_objects re-applies the pack cache unconditionally" + ); + + let mut cached = shared.to_thread_local(); + cached.object_cache_size_if_unset(super::super::OBJECT_CACHE_BYTES); + assert!( + cached.objects.has_object_cache(), + "object_cache_size_if_unset must take on the thread-local handle" + ); +} + +/// The state a session blames through must carry the object cache +/// (issue #1117) — the production half of the contract the test above +/// pins on gix's side. +/// +/// Worth its own test because deleting `new_state`'s +/// `object_cache_size_if_unset` is invisible everywhere else: an object +/// cache changes only how fast a blame runs, never what it returns, so +/// no value assertion anywhere in the suite can see it. +/// +/// The fixture only needs a resolvable `HEAD`, not a blameable file, so +/// the commit is written in-process against the empty tree rather than +/// through the `git` CLI. +#[test] +fn a_session_handle_carries_the_object_cache() { + let dir = tempfile::tempdir().expect("temp dir"); + let repo = gix::init(dir.path()).expect("init repository"); + let who = gix::actor::SignatureRef { + name: "Ada".into(), + email: "ada@example.com".into(), + time: "1700000000 +0000", + }; + repo.commit_as( + who, + who, + "HEAD", + "seed", + repo.empty_tree().id, + gix::commit::NO_PARENT_IDS, + ) + .expect("write seed commit"); + + let engine = super::PerFunctionBlame::open(dir.path(), crate::vcs::options::Options::default()) + .expect("open blame engine"); + assert!( + engine.new_state().repo.objects.has_object_cache(), + "a blame session must set an object cache on its thread-local handle" + ); +} diff --git a/src/vcs/git/jit.rs b/src/vcs/git/jit.rs index ca8483cd3..47e202063 100644 --- a/src/vcs/git/jit.rs +++ b/src/vcs/git/jit.rs @@ -65,7 +65,10 @@ impl Touched { /// Score the commit `spec` resolves to. See /// [`crate::vcs::score_commit`] for the public contract. pub(crate) fn score_commit(root: &Path, spec: &str, options: &Options) -> Result { - let repo = super::repo::open(root)?.repo; + let mut repo = super::repo::open(root)?.repo; + // Scoring one commit re-reads the same trees and blobs across the + // tree diff, the per-file history walks, and the experience walk. + repo.object_cache_size_if_unset(super::OBJECT_CACHE_BYTES); let commit = super::repo::resolve_commit(&repo, spec)?; let now = options.as_of.unwrap_or_else(current_unix_seconds); diff --git a/src/vcs/git/mod.rs b/src/vcs/git/mod.rs index 9209aaae6..d89280d63 100644 --- a/src/vcs/git/mod.rs +++ b/src/vcs/git/mod.rs @@ -21,7 +21,7 @@ mod jit; mod repo; mod trend; -pub use blame::{LineSpan, PerFunctionBlame}; +pub use blame::{BlameSession, LineSpan, PerFunctionBlame}; pub(crate) use cached::build_cached; pub(crate) use diff_parse::score_diff; pub(crate) use jit::score_commit; diff --git a/src/vcs/git/trend.rs b/src/vcs/git/trend.rs index 983a5b8cc..a98b3c3df 100644 --- a/src/vcs/git/trend.rs +++ b/src/vcs/git/trend.rs @@ -57,7 +57,8 @@ pub(crate) fn build_trend( // known O(points × commits) cost — the earlier "O(commits) lookup // rather than its own walk" optimisation no longer holds. A follow-up // could hoist this walk and share it with the per-point builds. - let OpenRepo { repo, .. } = repo::open(root)?; + let OpenRepo { mut repo, .. } = repo::open(root)?; + repo.object_cache_size_if_unset(super::OBJECT_CACHE_BYTES); // Take the owned id so the borrowing `Commit` is dropped before the // repo handle is. let tip = repo::resolve_commit(&repo, &base.reference)?.id; diff --git a/src/vcs/mod.rs b/src/vcs/mod.rs index 33e01d9b7..ac6edd2b6 100644 --- a/src/vcs/mod.rs +++ b/src/vcs/mod.rs @@ -61,7 +61,7 @@ pub use trend::{TREND_SCHEMA_VERSION, Trend, TrendDelta, TrendDeltas}; /// Per-function change-history attribution (issue #329), surfaced when a /// front end opts into per-function VCS metrics. See [`PerFunctionBlame`]. #[cfg(feature = "vcs-git")] -pub use git::{LineSpan, PerFunctionBlame}; +pub use git::{BlameSession, LineSpan, PerFunctionBlame}; use std::collections::HashMap; use std::path::{Path, PathBuf}; diff --git a/tests/vcs_per_function.rs b/tests/vcs_per_function.rs index b8fcba984..57cd7dda1 100644 --- a/tests/vcs_per_function.rs +++ b/tests/vcs_per_function.rs @@ -335,3 +335,163 @@ fn empty_span_list_yields_no_stats() { let stats = per_function(&repo, opts(), "src/work.rs", &[]); assert!(stats.is_empty(), "no spans → no per-function stats"); } + +/// A [`BlameSession`] reused across files must answer exactly what a +/// fresh one-shot engine call answers for each of them (issue #1117). +/// +/// The session carries a commit-metadata memo across files, so the risk +/// the hoist introduces is cross-file contamination: a commit resolved +/// while blaming `a.rs` is reused when `b.rs` blames the same commit, and +/// a memo keyed or filtered wrongly would silently mis-attribute. The +/// fixture is built so that matters — every file shares the `shared` +/// commit, and the files differ in which *other* commits touch them, so +/// a memo that leaked per-file state would show up as a wrong +/// `commits_long` / `authors_long` rather than as an equal-but-wrong +/// value on both sides. +/// +/// The session also blames one file *twice*, out of order relative to +/// the reference pass, so a memo that mutated on read (or an outcome +/// cached against the wrong path) is caught too. +#[test] +fn session_matches_one_shot_across_files_and_repeat_blames() { + let repo = Repo::init(); + // One commit touching every file: the memo entry each later blame + // reuses. + repo.write("src/a.rs", TWO_FUNCS); + repo.write("src/b.rs", TWO_FUNCS); + repo.write("src/c.rs", TWO_FUNCS); + repo.commit("Ada", "ada@example.com", FIXED_NOW - 200 * DAY, "shared"); + // Then per-file edits by different authors, so the three files do + // not share a single answer that any implementation would produce. + repo.write( + "src/a.rs", + "fn first() {\n let x = 11;\n}\nfn second() {\n let y = 2;\n}\n", + ); + repo.commit("Grace", "grace@example.com", FIXED_NOW - 5 * DAY, "edit a"); + repo.write( + "src/b.rs", + "fn first() {\n let x = 1;\n}\nfn second() {\n let y = 22;\n}\n", + ); + repo.commit("Alan", "alan@example.com", FIXED_NOW - 50 * DAY, "fix b"); + + let spans = [LineSpan::new(1, 3), LineSpan::new(4, 6)]; + let files = ["src/a.rs", "src/b.rs", "src/c.rs"]; + + // Reference: one engine per call, exactly as the pre-session code ran. + let expected: Vec> = files + .iter() + .map(|rel| per_function(&repo, opts(), rel, &spans)) + .collect(); + // The fixture is only meaningful if the three files really differ. + assert_ne!( + expected[0], expected[1], + "fixture is degenerate: a.rs and b.rs blame identically, so a \ + contaminated memo could not be distinguished from a clean one" + ); + + let engine = + std::sync::Arc::new(PerFunctionBlame::open(repo.path(), opts()).expect("open engine")); + let mut session = engine.session(); + // Reverse order, with `src/a.rs` blamed again at the end against a + // memo warmed by every other file. + for (rel, want) in files.iter().zip(&expected).rev() { + let got = session + .per_function(&repo.path().join(rel), &spans) + .expect("session blame"); + assert_eq!(&got, want, "session diverged from one-shot for {rel}"); + } + let again = session + .per_function(&repo.path().join("src/a.rs"), &spans) + .expect("session re-blame"); + assert_eq!( + again, expected[0], + "re-blaming a file through a warm session changed its answer" + ); + + // A failed blame must leave the session usable: the memo is filled + // only on the `Ok` branch, so a mid-blame error writes nothing and + // the next file must still answer correctly. + let missing = repo.path().join("src/never-committed.rs"); + std::fs::write(&missing, TWO_FUNCS).expect("write untracked file"); + assert!( + session.per_function(&missing, &spans).is_err(), + "an untracked file must fail rather than blame" + ); + assert_eq!( + session + .per_function(&repo.path().join("src/b.rs"), &spans) + .expect("blame after a failure"), + expected[1], + "a failed blame poisoned the session" + ); +} + +/// The session's commit memo must accumulate **across** files, not just +/// within one blame (issue #1117). +/// +/// This is the perf invariant the whole change exists for, and it is +/// invisible to every value assertion: a memo cleared at the top of each +/// blame returns identical `Stats`. `commits_resolved` can see it, but +/// only against a fixture where the running union is larger than any one +/// file's commit set — otherwise a per-call clear reports the same +/// numbers a working memo does. So `a.rs` and `b.rs` are given +/// **disjoint** commits: after blaming both, a memo holds 3 and a clear +/// holds `b.rs`'s 1. Verified by inserting `meta.clear()` in +/// `blame_spans` — this test is the only failure. +#[test] +fn session_memoises_commits_across_files() { + let repo = Repo::init(); + // a.rs: created, then edited — two commits, neither touching b.rs. + repo.write("src/a.rs", TWO_FUNCS); + repo.commit("Ada", "ada@example.com", FIXED_NOW - 200 * DAY, "create a"); + // b.rs: created by a commit that touches nothing else. + repo.write("src/b.rs", TWO_FUNCS); + repo.commit( + "Grace", + "grace@example.com", + FIXED_NOW - 100 * DAY, + "create b", + ); + repo.write( + "src/a.rs", + "fn first() {\n let x = 1;\n}\nfn second() {\n let y = 22;\n}\n", + ); + repo.commit("Alan", "alan@example.com", FIXED_NOW - 5 * DAY, "edit a"); + + let spans = [LineSpan::new(1, 3), LineSpan::new(4, 6)]; + let engine = + std::sync::Arc::new(PerFunctionBlame::open(repo.path(), opts()).expect("open engine")); + let mut session = engine.session(); + assert_eq!( + session.commits_resolved(), + 0, + "a fresh session memoises nothing" + ); + + session + .per_function(&repo.path().join("src/a.rs"), &spans) + .expect("blame a"); + assert_eq!( + session.commits_resolved(), + 2, + "a.rs survives from its create and its edit" + ); + + session + .per_function(&repo.path().join("src/b.rs"), &spans) + .expect("blame b"); + assert_eq!( + session.commits_resolved(), + 3, + "b.rs's one commit must be added to a.rs's two, not replace them" + ); + + session + .per_function(&repo.path().join("src/a.rs"), &spans) + .expect("re-blame a"); + assert_eq!( + session.commits_resolved(), + 3, + "re-blaming a file must resolve nothing new" + ); +} From 40ebdd574f9629efbccf7250bd87327863cb8c69 Mon Sep 17 00:00:00 2001 From: Elijah Zupancic Date: Sat, 1 Aug 2026 01:12:41 -0700 Subject: [PATCH 13/36] fix(metrics/nargs): count Perl subroutine signatures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `PerlCode` sat on the default `NArgs` impl, whose `compute_args` looks up a `parameters` field. tree-sitter-perl emits a signature as an unnamed `function_signature` child instead, so the lookup missed and every Perl sub reported 0 arguments — a `nargs` limit passed unconditionally on any Perl codebase, reading as "no offenders" rather than "not measured". Add `impl NArgs for PerlCode` backed by `compute_perl_args`, which locates the signature and counts every child that is neither punctuation nor a comment. The negative filter is what makes a defaulted parameter count: `sub deflt($x, $y = 5, @rest)` wraps `$y = 5` in a `binary_expression`, so a positive scalar/array/hash variant list would report 2 where the answer is 3. It also needs the comment exclusion, since a multi-line signature carries `comments` children directly under `function_signature` — a documented 3-parameter sub read 6 without it. `Perl::NormalComma` joins `is_non_arg`: the separator inside a signature is `normal_comma` (369), and the bare `COMMA` (12) already listed there is that node's child, not the separator. Two forms measured and deliberately left at 0: a prototype (`sub f($$)`) is a `function_prototype`, not a parameter list; and an anonymous sub's signature parses inside an `ERROR` node upstream, so closures stay uncounted rather than pinning us to a parse shape that will change. Both are covered by tests so a grammar bump surfaces them. Fixes #1147 --- big-code-analysis-book/src/metrics.md | 20 + .../src/recipes/thresholds.md | 19 +- .../src/default_thresholds.rs | 5 +- src/checker/perl.rs | 6 +- src/metrics/nargs.rs | 385 +++++++++++++++++- 5 files changed, 414 insertions(+), 21 deletions(-) diff --git a/big-code-analysis-book/src/metrics.md b/big-code-analysis-book/src/metrics.md index 5e279558c..ff91ae33f 100644 --- a/big-code-analysis-book/src/metrics.md +++ b/big-code-analysis-book/src/metrics.md @@ -702,6 +702,26 @@ The implementation handles default arguments, variadic arguments, keyword-only arguments, and destructured parameters consistently per language. +### Languages where it reads 0 {#nargs-language-gaps} + +A metric that silently reports 0 reads as "no offenders" rather than +"not measured", so it is worth knowing where the count is inert: + +- **Bash** — correct and permanent. The shell has no formal parameter + list; arguments arrive as `$1`, `$2`, and so on. +- **Elixir** — a measurement gap, not a language property, tracked in + [issue #1142](https://github.com/dekobon/big-code-analysis/issues/1142). +- **Perl subs without a signature** — correct. Signatures + (`sub add($x, $y)`) are counted; a sub that reads its arguments from + `@_` declares no formal parameters to count. +- **Perl anonymous subs** — a gap, and an upstream one: the grammar + parses an anonymous sub's signature inside an error node, so + `my $f = sub ($x) {…}` reads 0 even though it has a signature. + +A `nargs` limit passes unconditionally on any codebase made only of +those, so gate it per language rather than repository-wide. See +[Choosing thresholds](recipes/thresholds.md#language-gaps). + ### How to read it A function with many arguments is hard to call correctly and even diff --git a/big-code-analysis-book/src/recipes/thresholds.md b/big-code-analysis-book/src/recipes/thresholds.md index c428fff1b..c89546c24 100644 --- a/big-code-analysis-book/src/recipes/thresholds.md +++ b/big-code-analysis-book/src/recipes/thresholds.md @@ -166,13 +166,18 @@ Ruby. Prefer re-deriving from your own repository over adopting any of those fou ### Metrics that do not apply to every language {#language-gaps} -`nargs` reports 0 for every Bash, Perl, and Elixir function. For Bash that is correct: the shell has -no formal parameter list, arguments arrive as `$1`, `$2`, and so on, and the limit is simply inert. -For Perl and Elixir it is a gap. Both have formal parameter lists, and both are parsed but not -counted — Perl subroutine signatures (`sub add($x, $y)`, stable since 5.20 and on by default under -`use v5.36`) in [issue #1147](https://github.com/dekobon/big-code-analysis/issues/1147), Elixir in -[issue #1142](https://github.com/dekobon/big-code-analysis/issues/1142). In all three cases a -`nargs` limit passes unconditionally, which reads as "no offenders" rather than "not measured". +`nargs` reports 0 for every Bash and Elixir function. For Bash that is correct and permanent: the +shell has no formal parameter list, arguments arrive as `$1`, `$2`, and so on, and the limit is +simply inert. For Elixir it is a gap — the language has formal parameter lists and they are parsed +but not counted, tracked in +[issue #1142](https://github.com/dekobon/big-code-analysis/issues/1142). In both cases a `nargs` +limit passes unconditionally, which reads as "no offenders" rather than "not measured". + +Perl is counted, but only where the source declares a signature (`sub add($x, $y)`, stable since +5.20 and on by default under `use v5.36`). A sub *without* a signature takes its arguments from `@_` +and has no formal parameter list to measure, so it reads 0 — correctly. Perl's `nargs` distribution +is therefore sparse on codebases predating `use v5.36`, and one anonymous-sub form still reads 0 +because the grammar misparses its signature. `wmc`, `npm`, and `npa` are only produced for languages with a class-like container. They are absent from Bash, C, Go, Lua, Perl, and Tcl output. diff --git a/big-code-analysis-cli/src/default_thresholds.rs b/big-code-analysis-cli/src/default_thresholds.rs index 2c9bd2b97..3afd2c745 100644 --- a/big-code-analysis-cli/src/default_thresholds.rs +++ b/big-code-analysis-cli/src/default_thresholds.rs @@ -83,8 +83,9 @@ pub(crate) const DEFAULT_THRESHOLDS: &[DefaultThreshold] = &[ limit: 5, rationale: &[ "Matches RuboCop's ParameterLists; Code Climate is stricter at 4.", - "Note Bash, Perl, and Elixir report 0 arguments unconditionally,", - "so this limit is inert for them (#1142, #1147).", + "Note Bash and Elixir report 0 arguments unconditionally, so this", + "limit is inert for them (#1142). Perl counts signature subs; an", + "`@_`-style sub has no formal parameters and reads 0.", ], }, DefaultThreshold { diff --git a/src/checker/perl.rs b/src/checker/perl.rs index d9aa8d9ad..cb2fb89f5 100644 --- a/src/checker/perl.rs +++ b/src/checker/perl.rs @@ -42,10 +42,14 @@ impl Checker for PerlCode { ) } + // `NormalComma` (369) is the separator inside a subroutine signature; + // the bare `COMMA` (12) it wraps is that node's *child*, so listing + // only `COMMA` let every signature separator count as a parameter + // (#1147). fn is_non_arg(node: &Node) -> bool { matches!( node.kind_id().into(), - Perl::LPAREN | Perl::COMMA | Perl::RPAREN | Perl::FatComma + Perl::LPAREN | Perl::COMMA | Perl::RPAREN | Perl::FatComma | Perl::NormalComma ) } diff --git a/src/metrics/nargs.rs b/src/metrics/nargs.rs index 1dfe55a0f..d3c671914 100644 --- a/src/metrics/nargs.rs +++ b/src/metrics/nargs.rs @@ -495,6 +495,77 @@ impl NArgs for IrulesCode { } } +// tree-sitter-perl emits a subroutine signature as an unnamed +// `function_signature` child rather than under a `parameters` field, so +// the shared `compute_args` helper never sees it. `FunctionSignature2` +// is the hidden `_function_signature` supertype, listed defensively per +// the lesson-2 convention. +// +// A bare attribute swallows the signature — `sub f :lvalue ($z)` parses +// as `function_attribute → function_signature` — while an attribute +// carrying its own parens (`sub f :prototype($$) ($a, $b)`) leaves the +// signature a direct child. Look one level into `function_attribute` so +// both spellings count; a `:prototype($$)` argument list is a +// `function_prototype`, a different kind, so it cannot be mistaken for a +// signature. +fn perl_signature<'a>(node: &Node<'a>) -> Option> { + fn is_signature(id: u16) -> bool { + matches!( + id.into(), + Perl::FunctionSignature | Perl::FunctionSignature2 + ) + } + node.children().find_map(|child| { + if is_signature(child.kind_id()) { + Some(child) + } else if child.kind_id() == Perl::FunctionAttribute { + child.first_child(is_signature) + } else { + None + } + }) +} + +// Count every signature child that is neither punctuation nor a comment: +// a defaulted parameter (`$y = 5`) is a `binary_expression`, not a bare +// `scalar_variable`, so a positive variant list would undercount it. The +// negative filter also survives signature forms the grammar may add — at +// the price of needing the comment exclusion, since a multi-line +// signature documents its parameters with `comments` children sitting +// directly under `function_signature`. +fn compute_perl_args(node: &Node, nargs: &mut usize) { + let Some(signature) = perl_signature(node) else { + return; + }; + signature.act_on_child(&mut |n| { + if !PerlCode::is_non_arg(n) && !PerlCode::is_comment(n) { + *nargs += 1; + } + }); +} + +impl NArgs for PerlCode { + fn compute<'a>(node: &Node<'a>, ancestors: Ancestors<'a, '_>, stats: &mut Stats) { + if Self::is_func(node, ancestors) { + compute_perl_args(node, &mut stats.fn_nargs); + return; + } + + // Every anonymous sub reads 0 today: tree-sitter-perl 1.1.2 parses + // its signature inside an `ERROR` node + // (`anonymous_function → sub → ERROR → function_signature`), so + // `perl_signature` finds nothing among the direct children. + // Deliberately not recovered — descending through `ERROR` would + // pin us to a parse shape upstream will change. The call stays + // here so a grammar fix starts counting (and fails + // `perl_anonymous_sub_signature_is_zero`) rather than passing + // silently; revisit at the next `recreate-grammars.sh` bump. + if Self::is_closure(node, ancestors) { + compute_perl_args(node, &mut stats.closure_nargs); + } + } +} + implement_metric_trait!( [NArgs], PythonCode, @@ -506,7 +577,6 @@ implement_metric_trait!( PreprocCode, CcommentCode, JavaCode, - PerlCode, BashCode, PhpCode, CsharpCode, @@ -1743,10 +1813,11 @@ mod tests { #[test] fn perl_single_function() { - // Perl args arrive via `@_` rather than as formal parameters in the - // `sub` signature, so nargs is always 0. To make sure the test still - // discriminates "function parsed" from "function silently dropped", - // also assert nom recognised exactly one function. + // This sub declares no signature, so it has no formal parameters to + // count and nargs is 0 — args arrive via `@_`. Signature-carrying + // subs are counted; see `perl_signature_function`. To make sure the + // test still discriminates "function parsed" from "function silently + // dropped", also assert nom recognised exactly one function. check_metrics::( "sub greet { my ($name) = @_; @@ -1779,8 +1850,10 @@ mod tests { #[test] fn perl_single_closure() { - // Same caveat as `perl_single_function`: closures take their - // arguments through `@_`, so nargs stays 0. Assert via nom that the + // This closure declares no signature, so nargs stays 0; it takes its + // arguments through `@_`. A signature-carrying closure also reads 0, + // for an unrelated upstream-grammar reason — see + // `perl_anonymous_sub_signature_is_zero`. Assert via nom that the // anonymous function was actually identified as a closure. check_metrics::( "my $f = sub { @@ -1814,8 +1887,9 @@ mod tests { #[test] fn perl_multiple_functions() { - // Same caveat as `perl_single_function`. Assert nom counted both - // top-level subs so the test fails if either sub is dropped. + // Neither sub declares a signature, so both count 0. Assert nom + // counted both top-level subs so the test fails if either sub is + // dropped. check_metrics::( "sub a { return 1; } sub b { @@ -1849,8 +1923,9 @@ mod tests { #[test] fn perl_nested_closure() { - // Same caveat as `perl_single_function`. Assert nom recognised one - // outer sub plus one nested closure. + // Neither the outer sub nor the nested closure declares a signature, + // so both count 0. Assert nom recognised one outer sub plus one + // nested closure. check_metrics::( "sub outer { my $inner = sub { return 42; }; @@ -1881,6 +1956,294 @@ mod tests { ); } + /// Regression for #1147: a signature sub reported 0 because the + /// signature is an unnamed `function_signature` child, not a + /// `parameters` field. + #[test] + fn perl_signature_function() { + check_metrics::( + "use feature 'signatures'; + sub add($x, $y) { return $x + $y; }", + "foo.pl", + |metric| { + assert_eq!(metric.nom.functions_sum(), 1); + let s = &metric.nargs; + assert_eq!(s.function_args_sum(), 2); + assert_eq!(s.function_args_max(), 2); + insta::assert_json_snapshot!( + metric.nargs, + @r#" + { + "function_args": 2, + "closure_args": 0, + "function_args_average": 2.0, + "closure_args_average": 0.0, + "total": 2, + "average": 2.0, + "function_args_min": 0, + "function_args_max": 2, + "closure_args_min": 0, + "closure_args_max": 0 + } + "# + ); + }, + ); + } + + /// A defaulted parameter is a `binary_expression`, not a bare + /// `scalar_variable`, so counting only the variable kinds would report + /// 2 here instead of 3. Pins the negative filter in + /// `compute_perl_args` (#1147). + #[test] + fn perl_signature_defaults_and_slurpy() { + check_metrics::( + "use feature 'signatures'; + sub deflt($x, $y = 5, @rest) { return $x; }", + "foo.pl", + |metric| { + assert_eq!(metric.nom.functions_sum(), 1); + let s = &metric.nargs; + assert_eq!(s.function_args_sum(), 3); + assert_eq!(s.function_args_max(), 3); + insta::assert_json_snapshot!( + metric.nargs, + @r#" + { + "function_args": 3, + "closure_args": 0, + "function_args_average": 3.0, + "closure_args_average": 0.0, + "total": 3, + "average": 3.0, + "function_args_min": 0, + "function_args_max": 3, + "closure_args_min": 0, + "closure_args_max": 0 + } + "# + ); + }, + ); + } + + /// A signature sub and an `@_` sub in one file: the min/max and the + /// average have to keep the zero-argument sub in the divisor rather + /// than folding it away. + #[test] + fn perl_signature_and_at_underscore_mixed() { + check_metrics::( + "use feature 'signatures'; + sub sig($x, $y, $z) { return $x; } + sub legacy { my ($a) = @_; return $a; }", + "foo.pl", + |metric| { + assert_eq!(metric.nom.functions_sum(), 2); + let s = &metric.nargs; + assert_eq!(s.function_args_sum(), 3); + assert_eq!(s.function_args_max(), 3); + // 3 args over 2 functions: the zero-argument sub stays in + // the divisor, so a fold that dropped it would read 3.0. + insta::assert_json_snapshot!( + metric.nargs, + @r#" + { + "function_args": 3, + "closure_args": 0, + "function_args_average": 1.5, + "closure_args_average": 0.0, + "total": 3, + "average": 1.5, + "function_args_min": 0, + "function_args_max": 3, + "closure_args_min": 0, + "closure_args_max": 0 + } + "# + ); + }, + ); + } + + /// Perl puts subroutine attributes before the signature + /// (`sub NAME ATTRS SIG BLOCK`), and a bare attribute swallows the + /// signature into its own `function_attribute` node. Pins the + /// one-level descent in `perl_signature`. + #[test] + fn perl_signature_behind_attribute() { + check_metrics::( + "use feature 'signatures'; + sub attrs :lvalue ($z) { return $z; }", + "foo.pl", + |metric| { + assert_eq!(metric.nom.functions_sum(), 1); + let s = &metric.nargs; + assert_eq!(s.function_args_sum(), 1); + assert_eq!(s.function_args_max(), 1); + insta::assert_json_snapshot!( + metric.nargs, + @r#" + { + "function_args": 1, + "closure_args": 0, + "function_args_average": 1.0, + "closure_args_average": 0.0, + "total": 1, + "average": 1.0, + "function_args_min": 0, + "function_args_max": 1, + "closure_args_min": 0, + "closure_args_max": 0 + } + "# + ); + }, + ); + } + + /// A multi-line signature documents its parameters with `comments` + /// children sitting directly under `function_signature`, so the + /// negative filter has to exclude them or a documented 3-parameter sub + /// reads 6 and trips the default `nargs` limit of 5. + #[test] + fn perl_signature_comments_are_not_parameters() { + check_metrics::( + "use feature 'signatures'; + sub documented( + $host, # hostname to connect to + $port, # TCP port + $timeout, # seconds + ) { return $host; }", + "foo.pl", + |metric| { + assert_eq!(metric.nom.functions_sum(), 1); + let s = &metric.nargs; + assert_eq!(s.function_args_sum(), 3); + assert_eq!(s.function_args_max(), 3); + insta::assert_json_snapshot!( + metric.nargs, + @r#" + { + "function_args": 3, + "closure_args": 0, + "function_args_average": 3.0, + "closure_args_average": 0.0, + "total": 3, + "average": 3.0, + "function_args_min": 0, + "function_args_max": 3, + "closure_args_min": 0, + "closure_args_max": 0 + } + "# + ); + }, + ); + } + + /// The two shapes that must stay at 0 for reasons the counting rule + /// depends on: an empty signature has no children but the parens, and + /// a prototype (`($$)`) is a `function_prototype`, a different kind + /// that `perl_signature` deliberately does not match. + #[test] + fn perl_empty_signature_and_prototype_are_zero() { + check_metrics::( + "use feature 'signatures'; + sub empty() { return 1; } + sub proto($$) { return 1; }", + "foo.pl", + |metric| { + assert_eq!(metric.nom.functions_sum(), 2); + let s = &metric.nargs; + assert_eq!(s.function_args_sum(), 0); + assert_eq!(s.function_args_max(), 0); + }, + ); + } + + /// Perl 5.38's `method` is a second `is_func` kind + /// (`function_definition_without_sub`) reaching the same helper, so it + /// gets its own fixture rather than riding on the `sub` tests. + #[test] + fn perl_method_signature_function() { + check_metrics::( + "use v5.38; + class Point { + method shift_by($dx, $dy) { return $dx; } + }", + "foo.pl", + |metric| { + let s = &metric.nargs; + assert_eq!(s.function_args_sum(), 2); + assert_eq!(s.function_args_max(), 2); + }, + ); + } + + /// `FunctionSignature2` is the hidden `_function_signature` supertype; + /// `perl_signature` lists it defensively. Pin that the grammar never + /// emits it, so a bump that promotes the rule fails loudly instead of + /// changing behaviour invisibly (lesson 34). + #[test] + fn perl_hidden_function_signature_is_unreachable() { + let mut hidden = false; + let mut emitted = false; + crate::test_support::for_each_node_with_chain::( + b"use feature 'signatures';\nsub add($x, $y) { return $x + $y; }\n", + |node, _| { + hidden |= node.kind_id() == Perl::FunctionSignature2 as u16; + emitted |= node.kind_id() == Perl::FunctionSignature as u16; + }, + ); + assert!( + emitted, + "fixture must reach a real `function_signature`, else the \ + hidden-rule check below is vacuous" + ); + assert!( + !hidden, + "grammar now emits the hidden `_function_signature`; re-check \ + the defensive arm in `perl_signature`" + ); + } + + /// Upstream-grammar limitation, deliberately pinned: tree-sitter-perl + /// 1.1.2 parses an anonymous sub's signature inside an `ERROR` node, + /// so a signature-carrying closure counts 0. A grammar bump that fixes + /// the parse should fail this test rather than shift metrics silently. + #[test] + fn perl_anonymous_sub_signature_is_zero() { + check_metrics::( + "use feature 'signatures'; + my $mul = sub ($p, $q) { return $p * $q; };", + "foo.pl", + |metric| { + assert_eq!(metric.nom.functions_sum(), 0); + assert_eq!(metric.nom.closures_sum(), 1); + let s = &metric.nargs; + assert_eq!(s.closure_args_sum(), 0); + assert_eq!(s.closure_args_max(), 0); + insta::assert_json_snapshot!( + metric.nargs, + @r#" + { + "function_args": 0, + "closure_args": 0, + "function_args_average": 0.0, + "closure_args_average": 0.0, + "total": 0, + "average": 0.0, + "function_args_min": 0, + "function_args_max": 0, + "closure_args_min": 0, + "closure_args_max": 0 + } + "# + ); + }, + ); + } + #[test] fn java_no_functions() { check_metrics::( From 45011536ee458466b279a000d7f164e7d8ef84ef Mon Sep 17 00:00:00 2001 From: Elijah Zupancic Date: Sat, 1 Aug 2026 01:26:49 -0700 Subject: [PATCH 14/36] fix(abc): count ternary operand slots as unary conditions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The C family, the JS family, PHP, and Perl counted a ternary's `?` and nothing else, so `a ? !b : !c` scored 1 where Java, C#, and Groovy scored 4. Each family now walks the ternary's condition and both branch operands through its existing unary-condition helper, as `java_walk_ternary` has always done. That is the paper-faithful reading: `src/metrics/abc.rs` records GMetrics' rule that a value treated as boolean counts, "examples include `if (x)` and `return !ready`", and a negated ternary branch is exactly that case. It also makes each family's `_inspect_container` boolean-context seed reachable — the seed was written for a ternary parent the dispatcher could never hand it. Slots are addressed by grammar field, not by child index. The issue reported a uniform `0 / 2 / 4` layout; the C-family and PHP grammars in fact mark the consequence OPTIONAL to admit the short ternary `a ?: b`, which moves the alternative to child(3), and tree-sitter-perl names the branches `true` / `false` rather than `consequence` / `alternative`. A copied index or a copied C-family field name silently drops an operand in both cases; regression tests pin each. `(a > 0) ? b : -b` still reads 2 in all eleven languages. The `!` is a type-free proxy for "this operand is boolean", so an unnegated branch contributes nothing and the fix is purely additive. Adding one arm pushed five already-at-limit ABC dispatchers past the self-scan thresholds. Each is an exhaustive one-arm-per-grammar-kind match, so they take an in-source `bca: suppress` marker with a rationale in place of their previous baseline entries, rather than another ratchet. Ruby, Python, Tcl, and iRules have the same gap and are measured and filed as #1161; Python's is narrower (condition slot only). Fixes #1102 --- .bca-baseline.toml | 35 --- big-code-analysis-book/src/metrics.md | 3 +- src/metrics/abc.rs | 260 +++++++++++++++++- src/metrics/abc/c.rs | 14 +- src/metrics/abc/cpp.rs | 48 ++++ src/metrics/abc/js_family.rs | 72 ++++- src/metrics/abc/mozcpp.rs | 14 +- src/metrics/abc/objc.rs | 14 +- src/metrics/abc/perl.rs | 53 +++- src/metrics/abc/php.rs | 47 +++- ...t_else_ternary_case_default_try_catch.snap | 8 +- ...t_not_counted_directly_ternary_counts.snap | 8 +- tests/cpp_mozcpp_parity.rs | 5 +- tests/mozcpp_grammar_metrics.rs | 8 +- tests/repositories/big-code-analysis-output | 2 +- 15 files changed, 515 insertions(+), 76 deletions(-) diff --git a/.bca-baseline.toml b/.bca-baseline.toml index d53e3946b..72560af23 100644 --- a/.bca-baseline.toml +++ b/.bca-baseline.toml @@ -620,20 +620,6 @@ start_line = 21 metric = "halstead.effort" value = 84039.86210807812 -[[entry]] -path = "src/metrics/abc/c.rs" -qualified = "CCode::compute" -start_line = 17 -metric = "cyclomatic" -value = 15.0 - -[[entry]] -path = "src/metrics/abc/cpp.rs" -qualified = "CppCode::compute" -start_line = 104 -metric = "cyclomatic" -value = 15.0 - [[entry]] path = "src/metrics/abc/go.rs" qualified = "GoCode::compute" @@ -655,27 +641,6 @@ start_line = 156 metric = "cyclomatic" value = 16.0 -[[entry]] -path = "src/metrics/abc/mozcpp.rs" -qualified = "MozcppCode::compute" -start_line = 17 -metric = "cyclomatic" -value = 15.0 - -[[entry]] -path = "src/metrics/abc/objc.rs" -qualified = "ObjcCode::compute" -start_line = 31 -metric = "cyclomatic" -value = 16.0 - -[[entry]] -path = "src/metrics/abc/perl.rs" -qualified = "PerlCode::compute" -start_line = 199 -metric = "halstead.effort" -value = 71163.29612014526 - [[entry]] path = "src/metrics/abc/perl.rs" qualified = "perl_inspect_container" diff --git a/big-code-analysis-book/src/metrics.md b/big-code-analysis-book/src/metrics.md index 5e279558c..1933c6789 100644 --- a/big-code-analysis-book/src/metrics.md +++ b/big-code-analysis-book/src/metrics.md @@ -145,7 +145,8 @@ application would over-count. | All languages | `default` / `_` wildcard arm excluded from the condition set | Fitzpatrick's Figure 2 lists `default`, but it falls through unconditionally — counting it would inflate `C` on every `switch` / `match` regardless of body. big-code-analysis omits it for every language (the Rust `_ =>` and Java `default:` arms included). | | Tcl | Chain-operand unary conditions wired; bare-truthy / argument / `return` slots are not | Each operand of a `&&` / `\|\|` chain inside `expr {…}` counts as one condition, so `if {$a && $b}` reports two. The broader Phase 2B slot routing is not wired, so a bare-truthy `if {$a}` still reports zero. | | iRules | Chain-operand unary conditions wired (unlike its Tcl sibling); bare-truthy / argument / `return` slots are not | Each operand of a `&&` / `\|\|` / `and` / `or` chain counts as one condition (Rule 9), so `if {!$a && !$b}` reports two. iRules also recognises the word-form string-match comparators (`contains`, `starts_with`, `ends_with`, `equals`, `matches`, …) that Tcl lacks (Tcl's `eq` / `ne` / `in` / `ni` are shared). The broader Phase 2B slot routing is not wired, so a bare-truthy `if {$a}` still reports zero. | -| All Phase 2 languages (Java, Groovy, C#, Rust, Go, JavaScript, TypeScript, TSX, Mozjs, PHP, C++, Python, Perl, Lua) | `if (true) {}`, `m(!a, !b)`, `return !x` count their operand(s) | Phase 2B routes `if` / `while` / `do-while` / argument-list / `return` / ternary slots through the same walker, so the rule applies uniformly across decision-bearing positions. A bare `return x` continues to report zero — Fitzpatrick treats an identifier in a return slot as a value, not a unary conditional. | +| All Phase 2 languages (Java, Groovy, C#, Rust, Go, JavaScript, TypeScript, TSX, Mozjs, PHP, C, C++, Objective-C, Mozcpp, Python, Perl, Lua) | `if (true) {}`, `m(!a, !b)`, `return !x` count their operand(s) | Phase 2B routes `if` / `while` / `do-while` / argument-list / `return` slots through the same walker, so the rule applies uniformly across decision-bearing positions. A bare `return x` continues to report zero — Fitzpatrick treats an identifier in a return slot as a value, not a unary conditional. | +| Ternary slots: Java, Groovy, C#, C, C++, Objective-C, Mozcpp, JavaScript, TypeScript, TSX, Mozjs, PHP, Perl | `a ? !b : !c` counts its condition and both branch operands | The same walker also runs over a ternary's three operand slots, so `a ? !b : !c` reports 4 (the `?` plus three unary conditions) rather than 1. Languages with no ternary (Rust, Go, Kotlin, Lua, Elixir) are unaffected. Ruby, Python, Tcl and iRules do have one but do not yet route it (issue #1161). | | Ruby | Bare-predicate `if` / `unless` / `while` / `until` (block and modifier forms) count one condition | Idiomatic Ruby favours bare predicates (`if flag`, `x if flag`); counting the condition slot keeps ABC conditions at or above Ruby's cyclomatic decision count (the alignment enforced across the other languages). A comparison (`if a == b`) or `&&` / `\|\|` chain in the predicate is counted by its own operator / walker arm and is not double-counted. | | Bash | `if` / `elif` / `while` and each non-wildcard `case` arm count one condition | A Bash predicate is a *command*, so the branch keyword itself — not an embedded boolean expression — is the condition signal. Each matches a Bash cyclomatic decision; the bare `*)` case arm (the analogue of `default:`) is excluded, mirroring the cyclomatic standard count. | | Kotlin | `try` counts a condition alongside `catch` | Fitzpatrick counts both keywords, and Java / C# / C++ / Groovy already count both; Kotlin previously counted only the catch block. | diff --git a/src/metrics/abc.rs b/src/metrics/abc.rs index 5601330fb..358e81072 100644 --- a/src/metrics/abc.rs +++ b/src/metrics/abc.rs @@ -443,7 +443,7 @@ implement_metric_trait!(Abc, PreprocCode, CcommentCode); clippy::too_many_lines )] mod tests { - use crate::test_support::{check_func_space, check_metrics}; + use crate::test_support::{check_func_space, check_metrics, metrics_verbatim}; use crate::traits::ParserTrait; use super::*; @@ -3383,6 +3383,58 @@ function f(int $a, int $b): int { ); } + // Issue #1102, PHP half. See + // `cpp_ternary_operand_slots_count_as_unary_conditions` for the + // rule. PHP's ABC dispatcher has no `?`-token arm — the grammar + // does emit the token, but the `conditional_expression` node is + // what carries the tally's +1 — so the arm keeps that increment and + // adds the operand slots. + #[test] + fn php_ternary_operand_slots_count_as_unary_conditions() { + // ternary (1) + condition `$a` (1) + `!$b` (1) + `!$c` (1) = 4. + check_metrics::( + "` (1) = 2, unchanged by + // the fix. + check_metrics::( + " 0) ? $b : -$b; }\n", + "foo.php", + |metric| assert_eq!(metric.abc.conditions_sum(), 2), + ); + // Nested (PHP 8 requires the inner ternary parenthesised): two + // ternary nodes plus the two bare-variable conditions = 4. + check_metrics::( + "( + "( + "("void f() { x = a ? !b : !c; }", "foo.cpp", |metric| { + assert_eq!(metric.abc.conditions_sum(), 4); + }); + // No-double-count pin: `?` (1) + `>` (1) = 2, unchanged by the + // fix. The parenthesised condition unwraps to a + // `binary_expression`, which is not a boolean terminal, and + // neither branch is negated — the `!` is the type-free proxy for + // "this operand is boolean", so an unnegated branch contributes + // nothing. + check_metrics::("void f() { x = (a > 0) ? b : -b; }", "foo.cpp", |metric| { + assert_eq!(metric.abc.conditions_sum(), 2); + }); + // Nested: outer `?` (1) + outer condition `a` (1) + inner `?` + // (1) + inner condition `b` (1) = 4. The outer consequence is + // the inner ternary — neither a boolean terminal nor a + // paren / `!` wrapper — so it adds nothing on its own and the + // inner one is reached by the walk, not by descent. + check_metrics::("void f() { x = a ? b ? c : d : e; }", "foo.cpp", |metric| { + assert_eq!(metric.abc.conditions_sum(), 4); + }); + // A negated *condition* is the only input that reaches the + // walker's `else` fallback: `!a` is neither a boolean terminal + // (so the terminal arm skips it) nor an operand slot (so + // `cpp_inspect_container` is never called on it from anywhere + // else). Every other condition fixture in this file wraps a + // comparison, which the fallback resolves to 0 — delete the + // fallback and only this case moves. `?` (1) + `!a` (1) = 2. + check_metrics::("void f() { x = !a ? b : c; }", "foo.cpp", |metric| { + assert_eq!(metric.abc.conditions_sum(), 2); + }); + } + + // The GNU short-ternary `a ?: b` elides the consequence, so the + // C-family grammar marks that field optional and the alternative + // lands at child(3) rather than child(4). Addressing the operand + // slots by grammar field name — never by index — is what keeps `!b` + // counted here; a fixed `child(4)` reads `None` and scores 2. + #[test] + fn cpp_elided_ternary_consequence_still_walks_the_alternative() { + // `?` (1) + condition `a` (1) + `!b` (1) = 3. + check_metrics::("void f() { x = a ?: !b; }", "foo.cpp", |metric| { + assert_eq!(metric.abc.conditions_sum(), 3); + }); + } + + // `cpp_walk_ternary` is shared by the C, ObjC, and Mozcpp ABC impls + // exactly as `cpp_inspect_container` is, so each needs its own + // dispatcher arm. Mozcpp owns no file extension and so gets no + // integration-snapshot coverage at all — this parity assertion is + // its only guard. + // + // The expected value is *derived from the C++ run*, not hardcoded, + // so the four languages cannot silently drift apart if the C++ + // expectation ever legitimately moves. + #[test] + fn c_family_ternary_operand_slots_agree_with_cpp() { + const SRC: &str = "void f() { x = a ? !b : !c; }\n"; + // `metrics_verbatim` rather than `check_metrics`: the latter + // takes a bare `fn` and so cannot close over the reference + // value. + let conditions = |lang: LANG, src: &str| { + metrics_verbatim(lang, src.as_bytes(), MetricsOptions::default()) + .abc + .conditions_sum() + }; + + let cpp = conditions(LANG::Cpp, SRC); + // Non-degenerate: a zeroed reference would make every + // comparison below vacuous. + assert_eq!(cpp, 4, "C++ reference value for `a ? !b : !c`"); + + assert_eq!(conditions(LANG::C, SRC), cpp, "C must match C++"); + assert_eq!(conditions(LANG::Mozcpp, SRC), cpp, "Mozcpp must match C++"); + assert_eq!( + conditions( + LANG::Objc, + "@implementation Foo\n\ + - (void)bar {\n\ + x = a ? !b : !c;\n\ + }\n\ + @end\n", + ), + cpp, + "ObjC must match C++" + ); + } + #[test] fn cpp_switch_cases_count_default_excluded() { // `case 1`, `case 2` → 2 conditions. `default` is intentionally @@ -6886,20 +7032,74 @@ function f(int $a, int $b): int { // - `a > 0` → 1 // - `else` opens an else_clause → 1 // - `?` ternary → 1 + // - the ternary's bare-identifier condition `a` → 1 (#1102) // - `case 1` → 1 // - `default` → 0 (fallthrough, #469) // - `try` + `catch` → 2 - // Total C = 6. + // Total C = 7. check_metrics::( "function f(a) { if (a > 0) {} else {} let x = a ? 1 : 2; switch (x) { case 1: break; default: break; } try { } catch (e) { } }", "foo.js", |metric| { - assert_eq!(metric.abc.conditions_sum(), 6); + assert_eq!(metric.abc.conditions_sum(), 7); insta::assert_json_snapshot!(metric.abc); }, ); } + // Issue #1102, JS-family half. See + // `cpp_ternary_operand_slots_count_as_unary_conditions` for the + // rule; the two families were behind Java by the same three units. + #[test] + fn javascript_ternary_operand_slots_count_as_unary_conditions() { + // `?` (1) + condition `a` (1) + `!b` (1) + `!c` (1) = 4. + check_metrics::( + "function f() { x = a ? !b : !c; }", + "foo.js", + |metric| assert_eq!(metric.abc.conditions_sum(), 4), + ); + // No-double-count pin: `?` (1) + `>` (1) = 2, unchanged by the + // fix — the parenthesised condition unwraps to a + // `binary_expression` (not a boolean terminal) and neither + // branch is negated. + check_metrics::( + "function f() { x = (a > 0) ? b : -b; }", + "foo.js", + |metric| assert_eq!(metric.abc.conditions_sum(), 2), + ); + // Nested: two `?` tokens plus the two bare-identifier + // conditions = 4. + check_metrics::( + "function f() { x = a ? b ? c : d : e; }", + "foo.js", + |metric| assert_eq!(metric.abc.conditions_sum(), 4), + ); + // A negated condition is the only input reaching the walker's + // `else` fallback — see the C++ sibling for why. `?` (1) + + // `!a` (1) = 2. + check_metrics::("function f() { x = !a ? b : c; }", "foo.js", |metric| { + assert_eq!(metric.abc.conditions_sum(), 2); + }); + } + + // TypeScript expands the same `ts_abc_compute!` arm from a separate + // macro body than JavaScript's `js_abc_compute!`, so wiring one and + // not the other is a live failure mode; TSX and Mozjs are clones of + // these two. + #[test] + fn typescript_ternary_operand_slots_count_as_unary_conditions() { + check_metrics::( + "function f() { x = a ? !b : !c; }", + "foo.ts", + |metric| assert_eq!(metric.abc.conditions_sum(), 4), + ); + check_metrics::( + "function f() { x = (a > 0) ? b : -b; }", + "foo.ts", + |metric| assert_eq!(metric.abc.conditions_sum(), 2), + ); + } + #[test] fn javascript_instanceof_counts_condition() { // `x instanceof Foo` is a binary expression whose operator is @@ -7524,11 +7724,12 @@ function f(int $a, int $b): int { // `binary_expression` parent that triggers the walker; the // other two keyword forms parse under a distinct grammar // node and contribute zero. Net: 4 walker-firing lines × 2 - // scalar-variable operands + 1 ternary `?` = 9. The exact - // mix of "which two keyword forms are silent" is grammar- - // version-dependent; a future grammar bump that normalises - // the keyword forms' parent kind will shift this count to - // 13. See follow-up note above the test name. + // scalar-variable operands + 1 ternary node + 1 for the + // ternary's bare `$a` condition operand (#1102) = 10. The + // exact mix of "which two keyword forms are silent" is + // grammar-version-dependent; a future grammar bump that + // normalises the keyword forms' parent kind will shift this + // count to 14. See follow-up note above the test name. check_metrics::( "sub f {\n\ my $r;\n\ @@ -7546,16 +7747,53 @@ function f(int $a, int $b): int { assert_eq!(metric.abc.assignments_sum(), 7); assert_eq!(metric.abc.branches_sum(), 0); // 4 walker-triggered lines × 2 operands + 1 ternary - // `?` = 9. The two remaining low-precedence keyword - // forms (one of `and`/`or`/`xor`) fall under a + // node + 1 for its bare `$a` condition operand = 10. + // The two remaining low-precedence keyword forms (one + // of `and`/`or`/`xor`) fall under a // non-binary_expression parent in this grammar // version and contribute zero via the walker. - assert_eq!(metric.abc.conditions_sum(), 9); + assert_eq!(metric.abc.conditions_sum(), 10); insta::assert_json_snapshot!(metric.abc); }, ); } + // Issue #1102, Perl half. See + // `cpp_ternary_operand_slots_count_as_unary_conditions` for the + // rule. Like PHP, Perl's ABC dispatcher has no `?`-token arm — the + // grammar does emit the token, but the `ternary_expression` node is + // what carries the tally's +1. tree-sitter-perl names the branch + // fields `true` / `false` rather than the C-family `consequence` / + // `alternative`, so a copied C-family gate would match nothing. + #[test] + fn perl_ternary_operand_slots_count_as_unary_conditions() { + // ternary (1) + condition `$a` (1) + `!$b` (1) + `!$c` (1) = 4. + check_metrics::("sub f { my $x = $a ? !$b : !$c; }", "foo.pl", |metric| { + assert_eq!(metric.abc.conditions_sum(), 4); + }); + // No-double-count pin: ternary (1) + `>` (1) = 2, unchanged by + // the fix. + check_metrics::( + "sub f { my $x = ($a > 0) ? $b : -$b; }", + "foo.pl", + |metric| assert_eq!(metric.abc.conditions_sum(), 2), + ); + // A negated *condition* takes the walker's `else` fallback — + // `!$a` is neither a boolean terminal nor a paren wrapper, so + // only `perl_inspect_container` can classify it. Delete the + // fallback and this reads 1. ternary (1) + `!$a` (1) = 2. + check_metrics::("sub f { my $x = !$a ? $b : $c; }", "foo.pl", |metric| { + assert_eq!(metric.abc.conditions_sum(), 2); + }); + // Nested: two ternary nodes plus the two bare-variable + // conditions = 4. + check_metrics::( + "sub f { my $x = $a ? ($b ? $c : $d) : $e; }", + "foo.pl", + |metric| assert_eq!(metric.abc.conditions_sum(), 4), + ); + } + #[test] fn perl_elsif_and_else_count_conditions() { // `if (… == …) { … } elsif (… < …) { … } else { … }` → diff --git a/src/metrics/abc/c.rs b/src/metrics/abc/c.rs index bc96af7c4..3a1fc926d 100644 --- a/src/metrics/abc/c.rs +++ b/src/metrics/abc/c.rs @@ -9,7 +9,9 @@ clippy::cast_sign_loss )] -use super::cpp::{cpp_count_unary_conditions, cpp_inspect_child, cpp_inspect_container}; +use super::cpp::{ + cpp_count_unary_conditions, cpp_inspect_child, cpp_inspect_container, cpp_walk_ternary, +}; use super::{Abc, Stats}; use crate::*; @@ -20,6 +22,10 @@ impl Abc for CCode { ancestors: Ancestors<'a, '_>, stats: &mut Stats, ) { + // bca: suppress(cyclomatic) + // Exhaustive one-arm-per-grammar-kind dispatch table; see the + // rationale on `CppCode::compute`, of which this is the + // C-grammar sibling. use C::*; match node.kind_id().into() { @@ -115,6 +121,12 @@ impl Abc for CCode { ArgumentList | ArgumentList2 => { cpp_count_unary_conditions(node, &mut stats.conditions); } + // `a ? !b : !c` — the ternary's own `?` token is already + // counted by the condition arm above; this walks the three + // operand slots (issue #1102). + ConditionalExpression => { + cpp_walk_ternary(node, &mut stats.conditions); + } _ => {} } } diff --git a/src/metrics/abc/cpp.rs b/src/metrics/abc/cpp.rs index b043d41ed..032346fa7 100644 --- a/src/metrics/abc/cpp.rs +++ b/src/metrics/abc/cpp.rs @@ -78,6 +78,40 @@ pub(super) fn cpp_inspect_child(node: &Node, idx: usize, conditions: &mut f64) { } } +// Phase-2B (issues #403 / #1102): a ternary's condition and its two +// branch operands are each a Fitzpatrick Rule 9 unary condition, exactly +// as `java_walk_ternary` already counts them. Without this the C family +// scored `a ? !b : !c` as 1 (the `?` token alone) against Java's 4, and +// `cpp_inspect_container`'s `conditional_expression` boolean-context +// seed was unreachable. +// +// Slots are addressed by grammar FIELD, not by child index: the C-family +// `conditional_expression` marks `consequence` optional to admit the GNU +// elision `a ?: b`, which shifts the alternative from child(4) to +// child(3). String-kind-based like its neighbours so the Mozilla fork's +// differing kind_ids (#732) stay covered by this one implementation. +// +// The condition needs its own terminal check because +// `cpp_inspect_container` only counts *after* unwrapping a `(...)` / +// `!...` layer, so a bare `a ? … : …` would otherwise score zero. +pub(super) fn cpp_walk_ternary(node: &Node, conditions: &mut f64) { + if let Some(condition) = node.child_by_field_name("condition") { + if matches!(condition.kind(), cpp_bool_terminal_kinds!()) { + *conditions += 1.; + } else { + cpp_inspect_container(&condition, node, conditions); + } + } + // Branch operands carry no terminal check: an unnegated branch is + // type-free and contributes nothing, which is what keeps + // `(a > 0) ? b : -b` at 2 (the `?` and the `>`). + for field in ["consequence", "alternative"] { + if let Some(branch) = node.child_by_field_name(field) { + cpp_inspect_container(&branch, node, conditions); + } + } +} + pub(super) fn cpp_count_unary_conditions(list_node: &Node, conditions: &mut f64) { let list_kind = list_node.kind(); let mut cursor = list_node.cursor(); @@ -107,6 +141,14 @@ impl Abc for CppCode { ancestors: Ancestors<'a, '_>, stats: &mut Stats, ) { + // bca: suppress(cyclomatic) + // Exhaustive one-arm-per-grammar-kind dispatch table: the + // cyclomatic count here is the number of node kinds the C++ + // grammar can hand us, not branching a reader must hold in + // their head. Every arm is independent and self-describing, and + // the table only ever grows as the grammar does (#1102 added + // the ternary arm). Splitting it would scatter one lookup + // across helpers with no semantic boundary to split on. use Cpp::*; match node.kind_id().into() { @@ -222,6 +264,12 @@ impl Abc for CppCode { ArgumentList | ArgumentList2 => { cpp_count_unary_conditions(node, &mut stats.conditions); } + // `a ? !b : !c` — the ternary's own `?` token is already + // counted by the condition arm above; this walks the three + // operand slots (issue #1102). + ConditionalExpression => { + cpp_walk_ternary(node, &mut stats.conditions); + } _ => {} } } diff --git a/src/metrics/abc/js_family.rs b/src/metrics/abc/js_family.rs index b761e399b..792e180db 100644 --- a/src/metrics/abc/js_family.rs +++ b/src/metrics/abc/js_family.rs @@ -29,7 +29,7 @@ use crate::*; // — every expression kind whose evaluated value is implicitly boolean // in an `if` / `while` / ternary slot. macro_rules! impl_js_family_unary_walker { - ($Lang:ident, $inspect:ident, $count:ident, $terminals:path) => { + ($Lang:ident, $inspect:ident, $count:ident, $walk_ternary:ident, $terminals:path) => { fn $inspect(container_node: &Node, parent: &Node, conditions: &mut f64) { use $Lang::*; @@ -69,6 +69,36 @@ macro_rules! impl_js_family_unary_walker { } } + // Phase-2B (issues #403 / #1102): a ternary's condition and its + // two branch operands are each a Fitzpatrick Rule 9 unary + // condition, exactly as `java_walk_ternary` already counts them. + // Without this the JS family scored `a ? !b : !c` as 1 (the `?` + // token alone) against Java's 4, and `$inspect`'s + // `TernaryExpression` boolean-context seed was unreachable. + // + // Slots are addressed by grammar FIELD rather than by child + // index, so a grammar re-order cannot silently retarget them. + // The condition needs its own terminal check because `$inspect` + // only counts *after* unwrapping a `(...)` / `!...` layer, so a + // bare `a ? … : …` would otherwise score zero. Branch operands + // get no such check: an unnegated branch is type-free and + // contributes nothing, which is what keeps `(a > 0) ? b : -b` + // at 2 (the `?` and the `>`). + fn $walk_ternary(node: &Node, conditions: &mut f64) { + if let Some(condition) = node.child_by_field_name("condition") { + if matches!(condition.kind_id().into(), $terminals!()) { + *conditions += 1.; + } else { + $inspect(&condition, node, conditions); + } + } + for field in ["consequence", "alternative"] { + if let Some(branch) = node.child_by_field_name(field) { + $inspect(&branch, node, conditions); + } + } + } + fn $count(list_node: &Node, conditions: &mut f64) { use $Lang::*; @@ -99,6 +129,7 @@ impl_js_family_unary_walker!( Typescript, typescript_inspect_container, typescript_count_unary_conditions, + typescript_walk_ternary, typescript_bool_terminal_kinds ); @@ -106,6 +137,7 @@ impl_js_family_unary_walker!( Tsx, tsx_inspect_container, tsx_count_unary_conditions, + tsx_walk_ternary, tsx_bool_terminal_kinds ); @@ -113,6 +145,7 @@ impl_js_family_unary_walker!( Javascript, javascript_inspect_container, javascript_count_unary_conditions, + javascript_walk_ternary, javascript_bool_terminal_kinds ); @@ -120,6 +153,7 @@ impl_js_family_unary_walker!( Mozjs, mozjs_inspect_container, mozjs_count_unary_conditions, + mozjs_walk_ternary, mozjs_bool_terminal_kinds ); @@ -141,7 +175,7 @@ impl_js_family_unary_walker!( // keep the `Var` slot. Augmented assignments (`+=`) and update // expressions (`++`, `--`) always count. macro_rules! ts_abc_compute { - ($lang:ident, $count_unary:path, $inspect_container:path) => { + ($lang:ident, $count_unary:path, $inspect_container:path, $walk_ternary:path) => { fn compute<'a>( node: &Node<'a>, _code: &'a [u8], @@ -245,6 +279,12 @@ macro_rules! ts_abc_compute { Arguments => { $count_unary(node, &mut stats.conditions); } + // `a ? !b : !c` — the ternary's own `?` token is + // already counted by the condition arm above; this + // walks the three operand slots (issue #1102). + TernaryExpression => { + $walk_ternary(node, &mut stats.conditions); + } _ => {} } } @@ -255,12 +295,18 @@ impl Abc for TypescriptCode { ts_abc_compute!( Typescript, typescript_count_unary_conditions, - typescript_inspect_container + typescript_inspect_container, + typescript_walk_ternary ); } impl Abc for TsxCode { - ts_abc_compute!(Tsx, tsx_count_unary_conditions, tsx_inspect_container); + ts_abc_compute!( + Tsx, + tsx_count_unary_conditions, + tsx_inspect_container, + tsx_walk_ternary + ); } // JavaScript / Mozjs share TypeScript's expression / statement @@ -280,7 +326,7 @@ impl Abc for TsxCode { // bindings can be reassigned and the initial value is the first // assignment of the binding's lifetime. macro_rules! js_abc_compute { - ($lang:ident, $count_unary:path, $inspect_container:path) => { + ($lang:ident, $count_unary:path, $inspect_container:path, $walk_ternary:path) => { fn compute<'a>( node: &Node<'a>, _code: &'a [u8], @@ -351,6 +397,12 @@ macro_rules! js_abc_compute { Arguments => { $count_unary(node, &mut stats.conditions); } + // `a ? !b : !c` — the ternary's own `?` token is + // already counted by the condition arm above; this + // walks the three operand slots (issue #1102). + TernaryExpression => { + $walk_ternary(node, &mut stats.conditions); + } _ => {} } } @@ -361,10 +413,16 @@ impl Abc for JavascriptCode { js_abc_compute!( Javascript, javascript_count_unary_conditions, - javascript_inspect_container + javascript_inspect_container, + javascript_walk_ternary ); } impl Abc for MozjsCode { - js_abc_compute!(Mozjs, mozjs_count_unary_conditions, mozjs_inspect_container); + js_abc_compute!( + Mozjs, + mozjs_count_unary_conditions, + mozjs_inspect_container, + mozjs_walk_ternary + ); } diff --git a/src/metrics/abc/mozcpp.rs b/src/metrics/abc/mozcpp.rs index 8334941f7..d11bb75f4 100644 --- a/src/metrics/abc/mozcpp.rs +++ b/src/metrics/abc/mozcpp.rs @@ -9,7 +9,9 @@ clippy::cast_sign_loss )] -use super::cpp::{cpp_count_unary_conditions, cpp_inspect_child, cpp_inspect_container}; +use super::cpp::{ + cpp_count_unary_conditions, cpp_inspect_child, cpp_inspect_container, cpp_walk_ternary, +}; use super::{Abc, Stats}; use crate::*; @@ -20,6 +22,10 @@ impl Abc for MozcppCode { ancestors: Ancestors<'a, '_>, stats: &mut Stats, ) { + // bca: suppress(cyclomatic) + // Exhaustive one-arm-per-grammar-kind dispatch table; see the + // rationale on `CppCode::compute`, of which this is the + // Mozilla-fork clone. use Mozcpp::*; match node.kind_id().into() { @@ -135,6 +141,12 @@ impl Abc for MozcppCode { ArgumentList | ArgumentList2 => { cpp_count_unary_conditions(node, &mut stats.conditions); } + // `a ? !b : !c` — the ternary's own `?` token is already + // counted by the condition arm above; this walks the three + // operand slots (issue #1102). + ConditionalExpression => { + cpp_walk_ternary(node, &mut stats.conditions); + } _ => {} } } diff --git a/src/metrics/abc/objc.rs b/src/metrics/abc/objc.rs index c019f0854..939b16303 100644 --- a/src/metrics/abc/objc.rs +++ b/src/metrics/abc/objc.rs @@ -9,7 +9,9 @@ clippy::cast_sign_loss )] -use super::cpp::{cpp_count_unary_conditions, cpp_inspect_child, cpp_inspect_container}; +use super::cpp::{ + cpp_count_unary_conditions, cpp_inspect_child, cpp_inspect_container, cpp_walk_ternary, +}; use super::{Abc, Stats}; use crate::*; @@ -34,6 +36,10 @@ impl Abc for ObjcCode { ancestors: Ancestors<'a, '_>, stats: &mut Stats, ) { + // bca: suppress(cyclomatic) + // Exhaustive one-arm-per-grammar-kind dispatch table; see the + // rationale on `CppCode::compute`, of which this is the + // Objective-C sibling. use Objc::*; match node.kind_id().into() { @@ -92,6 +98,12 @@ impl Abc for ObjcCode { ArgumentList | ArgumentList2 => { cpp_count_unary_conditions(node, &mut stats.conditions); } + // `a ? !b : !c` — the ternary's own `?` token is already + // counted by the condition arm above; this walks the three + // operand slots (issue #1102). + ConditionalExpression => { + cpp_walk_ternary(node, &mut stats.conditions); + } _ => {} } } diff --git a/src/metrics/abc/perl.rs b/src/metrics/abc/perl.rs index a890db039..fae2e3a9c 100644 --- a/src/metrics/abc/perl.rs +++ b/src/metrics/abc/perl.rs @@ -156,6 +156,39 @@ fn perl_last_named_child<'a>(node: &Node<'a>) -> Option> { last_named } +// Phase-2B (issues #403 / #1102): a ternary's condition and its two +// branch operands are each a Fitzpatrick Rule 9 unary condition, exactly +// as `java_walk_ternary` already counts them. Without this Perl scored +// `$a ? !$b : !$c` as 1 (the `ternary_expression` node alone) against +// Java's 4, and `perl_inspect_container`'s `TernaryExpression` +// boolean-context seed was unreachable. +// +// Slots are addressed by grammar FIELD, not by child index. +// tree-sitter-perl names the branches `true` / `false` rather than the +// C-family `consequence` / `alternative`, and all three slots are +// mandatory — Perl has no short-ternary elision. +// +// The condition needs its own terminal check because +// `perl_inspect_container` only counts *after* unwrapping a `(...)` / +// `!...` layer, so a bare `$a ? … : …` would otherwise score zero. +// Branch operands get no such check: an unnegated branch is type-free +// and contributes nothing, which is what keeps `($a > 0) ? $b : -$b` +// at 2 (the ternary node and the `>`). +fn perl_walk_ternary(node: &Node, conditions: &mut f64) { + if let Some(condition) = node.child_by_field_name("condition") { + if matches!(condition.kind_id().into(), perl_bool_terminal_kinds!()) { + *conditions += 1.; + } else { + perl_inspect_container(&condition, node, conditions); + } + } + for field in ["true", "false"] { + if let Some(branch) = node.child_by_field_name(field) { + perl_inspect_container(&branch, node, conditions); + } + } +} + fn perl_is_call_argument_parent(parent: Node) -> bool { use Perl as P; matches!( @@ -202,6 +235,13 @@ impl Abc for PerlCode { ancestors: Ancestors<'a, '_>, stats: &mut Stats, ) { + // bca: suppress(halstead) + // Exhaustive one-arm-per-grammar-kind dispatch table; see the + // rationale on `CppCode::compute`. Perl's arm list is the + // longest of the family — tree-sitter-perl tokenises all + // nineteen assignment operators and all six call-expression + // wrappers separately — so `halstead.effort` here is a count of + // distinct enum operands, not of reasoning a reader must do. use Perl as P; match node.kind_id().into() { @@ -264,9 +304,7 @@ impl Abc for PerlCode { P::EQEQ | P::BANGEQ | P::LT | P::GT | P::LTEQ | P::GTEQ | P::LTEQGT | P::Eq | P::Ne | P::Lt | P::Gt | P::Le | P::Ge | P::Cmp | P::EQTILDE | P::BANGTILDE - // Ternary `a ? b : c` and each `elsif` / `else` clause of - // an `if` / `unless` chain. - | P::TernaryExpression + // Each `elsif` / `else` clause of an `if` / `unless` chain. | P::ElsifClause | P::ElseClause => { stats.conditions += 1.; @@ -305,6 +343,15 @@ impl Abc for PerlCode { P::Array if ancestors.parent(node).is_some_and(perl_is_call_argument_parent) => { perl_count_unary_conditions(node, &mut stats.conditions); } + // `$a ? !$b : !$c`. Unlike the C family, this dispatcher + // has no `?`-token arm — the grammar emits the token, but + // the `ternary_expression` node is what carries the + // condition tally's +1 — so this arm keeps that increment + // and adds the three operand slots (issue #1102). + P::TernaryExpression => { + stats.conditions += 1.; + perl_walk_ternary(node, &mut stats.conditions); + } _ => {} } } diff --git a/src/metrics/abc/php.rs b/src/metrics/abc/php.rs index f7d05f0ad..8042e0746 100644 --- a/src/metrics/abc/php.rs +++ b/src/metrics/abc/php.rs @@ -69,6 +69,39 @@ fn php_inspect_child(node: &Node, idx: usize, conditions: &mut f64) { } } +// Phase-2B (issues #403 / #1102): a ternary's condition and its two +// branch operands are each a Fitzpatrick Rule 9 unary condition, exactly +// as `java_walk_ternary` already counts them. Without this PHP scored +// `$a ? !$b : !$c` as 1 (the `conditional_expression` node alone) +// against Java's 4, and `php_inspect_container`'s `ConditionalExpression` +// boolean-context seed was unreachable. +// +// Slots are addressed by grammar FIELD, not by child index. PHP names +// the consequence `body` (not `consequence`) and marks it OPTIONAL to +// admit the short ternary `$a ?: $b`, which shifts the alternative from +// child(4) to child(3). +// +// The condition needs its own terminal check because +// `php_inspect_container` only counts *after* unwrapping a `(...)` / +// `!...` layer, so a bare `$a ? … : …` would otherwise score zero. +// Branch operands get no such check: an unnegated branch is type-free +// and contributes nothing, which is what keeps `($a > 0) ? $b : -$b` +// at 2 (the ternary node and the `>`). +fn php_walk_ternary(node: &Node, conditions: &mut f64) { + if let Some(condition) = node.child_by_field_name("condition") { + if matches!(condition.kind_id().into(), php_bool_terminal_kinds!()) { + *conditions += 1.; + } else { + php_inspect_container(&condition, node, conditions); + } + } + for field in ["body", "alternative"] { + if let Some(branch) = node.child_by_field_name(field) { + php_inspect_container(&branch, node, conditions); + } + } +} + // Returns the value slot of a PHP `argument` wrapper node. // Positional argument `m(!$a)` has a single named child — the value. // Named argument `m(name: !$a)` has children `name`, `:`, value — the @@ -176,8 +209,8 @@ impl Abc for PhpCode { stats.branches += 1.; } // Conditions: comparison and identity operators (anonymous tokens - // inside `binary_expression`), `instanceof`, ternary `?`, and - // control-flow arms. + // inside `binary_expression`), `instanceof`, and control-flow + // arms. The ternary has its own arm below (#1102). EQEQ | EQEQEQ | BANGEQ @@ -189,7 +222,6 @@ impl Abc for PhpCode { | LTEQGT | LTGT | Instanceof - | ConditionalExpression | ElseClause | ElseClause2 | ElseIfClause @@ -234,6 +266,15 @@ impl Abc for PhpCode { Arguments => { php_count_unary_conditions(node, &mut stats.conditions); } + // `$a ? !$b : !$c`. Unlike the C family, this dispatcher + // has no `?`-token arm — the grammar emits the token, but + // the `conditional_expression` node is what carries the + // condition tally's +1 — so this arm keeps that increment + // and adds the three operand slots (issue #1102). + ConditionalExpression => { + stats.conditions += 1.; + php_walk_ternary(node, &mut stats.conditions); + } _ => {} } } diff --git a/src/metrics/snapshots/big_code_analysis__metrics__abc__tests__javascript_else_ternary_case_default_try_catch.snap b/src/metrics/snapshots/big_code_analysis__metrics__abc__tests__javascript_else_ternary_case_default_try_catch.snap index 5a21aacb0..60e1dc32e 100644 --- a/src/metrics/snapshots/big_code_analysis__metrics__abc__tests__javascript_else_ternary_case_default_try_catch.snap +++ b/src/metrics/snapshots/big_code_analysis__metrics__abc__tests__javascript_else_ternary_case_default_try_catch.snap @@ -5,16 +5,16 @@ expression: metric.abc { "assignments": 1, "branches": 0, - "conditions": 6, - "magnitude": 6.082762530298219, + "conditions": 7, + "magnitude": 7.0710678118654755, "value": 0.0, "assignments_average": 0.5, "branches_average": 0.0, - "conditions_average": 3.0, + "conditions_average": 3.5, "assignments_min": 0, "assignments_max": 1, "branches_min": 0, "branches_max": 0, "conditions_min": 0, - "conditions_max": 6 + "conditions_max": 7 } diff --git a/src/metrics/snapshots/big_code_analysis__metrics__abc__tests__perl_short_circuit_not_counted_directly_ternary_counts.snap b/src/metrics/snapshots/big_code_analysis__metrics__abc__tests__perl_short_circuit_not_counted_directly_ternary_counts.snap index 9ae52e67d..80d867062 100644 --- a/src/metrics/snapshots/big_code_analysis__metrics__abc__tests__perl_short_circuit_not_counted_directly_ternary_counts.snap +++ b/src/metrics/snapshots/big_code_analysis__metrics__abc__tests__perl_short_circuit_not_counted_directly_ternary_counts.snap @@ -5,16 +5,16 @@ expression: metric.abc { "assignments": 7, "branches": 0, - "conditions": 9, - "magnitude": 11.40175425099138, + "conditions": 10, + "magnitude": 12.206555615733702, "value": 0.0, "assignments_average": 3.5, "branches_average": 0.0, - "conditions_average": 4.5, + "conditions_average": 5.0, "assignments_min": 0, "assignments_max": 7, "branches_min": 0, "branches_max": 0, "conditions_min": 0, - "conditions_max": 9 + "conditions_max": 10 } diff --git a/tests/cpp_mozcpp_parity.rs b/tests/cpp_mozcpp_parity.rs index e55028192..6f7b5ba53 100644 --- a/tests/cpp_mozcpp_parity.rs +++ b/tests/cpp_mozcpp_parity.rs @@ -84,10 +84,11 @@ fn cpp_and_mozcpp_agree_on_plain_cpp() { .map_or_else(|| panic!("metric_sums omitted {key}: {cpp:?}"), |(_, v)| *v) }; // conditions: `<=>` +1, the `< 0` on its result +1, the `if (a < b)` - // +1, `try` +1, `catch` +1, the `less ? a : b` ternary +1 = 6. + // +1, `try` +1, `catch` +1, the `less ? a : b` ternary +1, and that + // ternary's bare-identifier condition operand +1 (#1102) = 7. assert_eq!( get("abc.conditions"), - 6, + 7, "fixture exercises <=>/try/catch conditions: {cpp:?}" ); assert!( diff --git a/tests/mozcpp_grammar_metrics.rs b/tests/mozcpp_grammar_metrics.rs index d071d2ac8..a0197b41b 100644 --- a/tests/mozcpp_grammar_metrics.rs +++ b/tests/mozcpp_grammar_metrics.rs @@ -188,10 +188,14 @@ mod mozcpp_metrics { "abc assignments parity" ); // Non-degenerate, and pins the divergent-id terminals are all - // counted: o->ready, arr[i], ns::enabled, run(), (bool)i = 5. + // counted: `o->ready` and `arr[i]` via the `&&` walker, + // `ns::enabled` and `run()` via the `||` walker, `(bool)i` as + // the ternary's condition operand (#1102), plus the ternary's + // own `?` token = 6. Before #1102 the `(bool)i` cast reached no + // walker at all and the total was 5. assert_eq!( cpp.metrics.abc.conditions_sum(), - 5, + 6, "fixture exercises all five divergent-id boolean terminals" ); } diff --git a/tests/repositories/big-code-analysis-output b/tests/repositories/big-code-analysis-output index f89a0402d..fed6e69e4 160000 --- a/tests/repositories/big-code-analysis-output +++ b/tests/repositories/big-code-analysis-output @@ -1 +1 @@ -Subproject commit f89a0402d09ffde7b49e6af90f4ce655751a9004 +Subproject commit fed6e69e4af1bbc5bfef4f27fa4a0c181956c6bf From 00ef2b529e6b9097f31fe0d645591091593677d1 Mon Sep 17 00:00:00 2001 From: Elijah Zupancic Date: Sat, 1 Aug 2026 01:42:41 -0700 Subject: [PATCH 15/36] fix(metrics/nargs): count Elixir function arguments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `nargs` reported 0 for every Elixir function. The blocker was not the `is_func` work the issue describes — #275 already taught `is_func_with_code` to recognise `def` / `defp` / `defmacro` / `defmacrop`, including the #310 rule that a `def` inside `quote do … end` declares nothing. It was that `NArgs::compute` was the only metric in `src/spaces/compute.rs` not handed the source bytes, so no Elixir impl could reach that predicate. Thread `code: &[u8]` through `NArgs::compute` (12 impls plus the single dispatch site) and switch the trait's default from `is_func` to `is_func_with_code`, mirroring `Nom::compute`. Elixir is the only language that overrides that predicate, so no other count moves — the point is that the next language whose declarations are only visible in the source text does not silently report 0 here. `impl NArgs for ElixirCode` then walks the two `arguments` levels a `def` puts between the macro and its parameter list. Four shapes, all measured against tree-sitter-elixir 0.3.5: * `call` has only a `target` field — contrary to the issue, there is no `arguments` field, so both levels are found by kind. * a guarded head interposes a `when` `binary_operator`; unwrapping it is what keeps guarded clauses from counting 0. The same alias appears as a guarded `fn` clause's `left`, where without the unwrap every closure counted the guard's fixed three children. * a bare `identifier` head (`def noargs, do: 1`) has no parameter list and stops there rather than falling through to the enclosing `arguments`, which holds the `do:` pair. * `def a + b` and `def -a` define `+/2` and `-/1`; their head is the operator node, with the operands as parameters. Closures take the first `stab_clause` only, never a sum: every clause of one `fn` has the same arity, so summing reports 2n. Elixir comes off the "reads 0" list in the book and in the `bca init` rationale, leaving Bash as the only entry. The `.bca-baseline.toml` refresh is the one-token growth the extra parameter costs `compute_per_node`'s halstead.effort. Fixes #1142 --- .bca-baseline.toml | 30 +- big-code-analysis-book/src/metrics.md | 10 +- .../src/recipes/thresholds.md | 10 +- .../src/default_thresholds.rs | 6 +- src/metrics/nargs.rs | 283 ++++++++++++++++-- src/spaces/compute.rs | 2 +- 6 files changed, 294 insertions(+), 47 deletions(-) diff --git a/.bca-baseline.toml b/.bca-baseline.toml index d53e3946b..913b2e270 100644 --- a/.bca-baseline.toml +++ b/.bca-baseline.toml @@ -182,14 +182,14 @@ value = 6.0 [[entry]] path = "big-code-analysis-cli/src/dispatch.rs" qualified = "dispatch_metrics" -start_line = 231 +start_line = 235 metric = "nargs" value = 7.0 [[entry]] path = "big-code-analysis-cli/src/dispatch.rs" qualified = "dispatch_ops" -start_line = 290 +start_line = 294 metric = "nargs" value = 8.0 @@ -252,7 +252,7 @@ value = 7.0 [[entry]] path = "big-code-analysis-cli/src/lib.rs" qualified = "legacy_hint" -start_line = 678 +start_line = 825 metric = "halstead.effort" value = 59351.805921378844 @@ -287,7 +287,7 @@ value = 8.0 [[entry]] path = "big-code-analysis-cli/src/metric_diff.rs" qualified = "MetricDiff::from_sets" -start_line = 238 +start_line = 269 metric = "nargs" value = 8.0 @@ -343,7 +343,7 @@ value = 7.0 [[entry]] path = "big-code-analysis-cli/src/vcs_command.rs" qualified = "write_table" -start_line = 466 +start_line = 474 metric = "nexits" value = 5.0 @@ -781,6 +781,20 @@ start_line = 171 metric = "nargs" value = 7.0 +[[entry]] +path = "src/metrics/nargs.rs" +qualified = "" +start_line = 1 +metric = "loc.ploc" +value = 525.0 + +[[entry]] +path = "src/metrics/nargs.rs" +qualified = "ObjcCode::compute" +start_line = 325 +metric = "nargs" +value = 7.0 + [[entry]] path = "src/metrics/npa/csharp.rs" qualified = "CsharpCode::compute" @@ -812,7 +826,7 @@ value = 10.0 [[entry]] path = "src/metrics/npa/python.rs" qualified = "python_self_attr_name_bytes" -start_line = 305 +start_line = 303 metric = "nexits" value = 6.0 @@ -835,7 +849,7 @@ path = "src/node.rs" qualified = "Node<'a>" start_line = 93 metric = "nom" -value = 35.0 +value = 34.0 [[entry]] path = "src/ops.rs" @@ -940,7 +954,7 @@ path = "src/spaces/compute.rs" qualified = "compute_per_node" start_line = 218 metric = "halstead.effort" -value = 62326.31232911353 +value = 63007.70094399036 [[entry]] path = "src/spaces/compute.rs" diff --git a/big-code-analysis-book/src/metrics.md b/big-code-analysis-book/src/metrics.md index ff91ae33f..a1b8acf5d 100644 --- a/big-code-analysis-book/src/metrics.md +++ b/big-code-analysis-book/src/metrics.md @@ -708,9 +708,8 @@ A metric that silently reports 0 reads as "no offenders" rather than "not measured", so it is worth knowing where the count is inert: - **Bash** — correct and permanent. The shell has no formal parameter - list; arguments arrive as `$1`, `$2`, and so on. -- **Elixir** — a measurement gap, not a language property, tracked in - [issue #1142](https://github.com/dekobon/big-code-analysis/issues/1142). + list; arguments arrive as `$1`, `$2`, and so on. It is the only + language where every function reads 0. - **Perl subs without a signature** — correct. Signatures (`sub add($x, $y)`) are counted; a sub that reads its arguments from `@_` declares no formal parameters to count. @@ -718,8 +717,9 @@ A metric that silently reports 0 reads as "no offenders" rather than parses an anonymous sub's signature inside an error node, so `my $f = sub ($x) {…}` reads 0 even though it has a signature. -A `nargs` limit passes unconditionally on any codebase made only of -those, so gate it per language rather than repository-wide. See +A `nargs` limit is therefore inert on a Bash codebase and sparse on +Perl written before `use v5.36`. Gate it per language rather than +repository-wide; see [Choosing thresholds](recipes/thresholds.md#language-gaps). ### How to read it diff --git a/big-code-analysis-book/src/recipes/thresholds.md b/big-code-analysis-book/src/recipes/thresholds.md index c89546c24..cc66caa05 100644 --- a/big-code-analysis-book/src/recipes/thresholds.md +++ b/big-code-analysis-book/src/recipes/thresholds.md @@ -166,12 +166,10 @@ Ruby. Prefer re-deriving from your own repository over adopting any of those fou ### Metrics that do not apply to every language {#language-gaps} -`nargs` reports 0 for every Bash and Elixir function. For Bash that is correct and permanent: the -shell has no formal parameter list, arguments arrive as `$1`, `$2`, and so on, and the limit is -simply inert. For Elixir it is a gap — the language has formal parameter lists and they are parsed -but not counted, tracked in -[issue #1142](https://github.com/dekobon/big-code-analysis/issues/1142). In both cases a `nargs` -limit passes unconditionally, which reads as "no offenders" rather than "not measured". +`nargs` reports 0 for every Bash function, and that is correct and permanent: the shell has no +formal parameter list, arguments arrive as `$1`, `$2`, and so on, and the limit is simply inert. A +`nargs` limit therefore passes unconditionally on a Bash codebase, which reads as "no offenders" +rather than "not measured". Bash is the only language left in that position. Perl is counted, but only where the source declares a signature (`sub add($x, $y)`, stable since 5.20 and on by default under `use v5.36`). A sub *without* a signature takes its arguments from `@_` diff --git a/big-code-analysis-cli/src/default_thresholds.rs b/big-code-analysis-cli/src/default_thresholds.rs index 3afd2c745..5c2bbb392 100644 --- a/big-code-analysis-cli/src/default_thresholds.rs +++ b/big-code-analysis-cli/src/default_thresholds.rs @@ -83,9 +83,9 @@ pub(crate) const DEFAULT_THRESHOLDS: &[DefaultThreshold] = &[ limit: 5, rationale: &[ "Matches RuboCop's ParameterLists; Code Climate is stricter at 4.", - "Note Bash and Elixir report 0 arguments unconditionally, so this", - "limit is inert for them (#1142). Perl counts signature subs; an", - "`@_`-style sub has no formal parameters and reads 0.", + "Note Bash reports 0 arguments unconditionally — the shell has no", + "formal parameter list — so this limit is inert for it. Perl", + "counts signature subs; an `@_`-style sub reads 0 correctly.", ], }, DefaultThreshold { diff --git a/src/metrics/nargs.rs b/src/metrics/nargs.rs index d3c671914..7903bb556 100644 --- a/src/metrics/nargs.rs +++ b/src/metrics/nargs.rs @@ -235,8 +235,16 @@ where { /// Walk `node` and update `stats` with this metric for the language /// implementing the trait. - fn compute<'a>(node: &Node<'a>, ancestors: Ancestors<'a, '_>, stats: &mut Stats) { - if Self::is_func(node, ancestors) { + /// + /// Uses the source-aware [`Checker::is_func_with_code`] rather than the + /// byte-less `is_func`, exactly as [`crate::nom::Nom::compute`] does. + /// For every grammar with a syntactic function-definition node the two + /// are the same predicate, so no count moves; the point is that a + /// language whose declarations are only recognisable from the source + /// text — Elixir's `def` is an ordinary `Call` (#275) — does not + /// silently report 0 here (#1142). + fn compute<'a>(node: &Node<'a>, code: &[u8], ancestors: Ancestors<'a, '_>, stats: &mut Stats) { + if Self::is_func_with_code(node, code, ancestors) { compute_args::(node, &mut stats.fn_nargs); return; } @@ -248,7 +256,7 @@ where } impl NArgs for CppCode { - fn compute<'a>(node: &Node<'a>, ancestors: Ancestors<'a, '_>, stats: &mut Stats) { + fn compute<'a>(node: &Node<'a>, _code: &[u8], ancestors: Ancestors<'a, '_>, stats: &mut Stats) { if Self::is_func(node, ancestors) { if let Some(declarator) = node.child_by_field_name("declarator") { let new_node = declarator; @@ -267,7 +275,7 @@ impl NArgs for CppCode { } impl NArgs for CCode { - fn compute<'a>(node: &Node<'a>, ancestors: Ancestors<'a, '_>, stats: &mut Stats) { + fn compute<'a>(node: &Node<'a>, _code: &[u8], ancestors: Ancestors<'a, '_>, stats: &mut Stats) { if Self::is_func(node, ancestors) { if let Some(declarator) = node.child_by_field_name("declarator") { let new_node = declarator; @@ -286,7 +294,7 @@ impl NArgs for CCode { } impl NArgs for MozcppCode { - fn compute<'a>(node: &Node<'a>, ancestors: Ancestors<'a, '_>, stats: &mut Stats) { + fn compute<'a>(node: &Node<'a>, _code: &[u8], ancestors: Ancestors<'a, '_>, stats: &mut Stats) { if Self::is_func(node, ancestors) { if let Some(declarator) = node.child_by_field_name("declarator") { let new_node = declarator; @@ -314,7 +322,12 @@ impl NArgs for MozcppCode { // * a block `^(int x){ … }` holds its params in a `parameter_list` // child rather than under a `parameters` field. impl NArgs for ObjcCode { - fn compute<'a>(node: &Node<'a>, _ancestors: Ancestors<'a, '_>, stats: &mut Stats) { + fn compute<'a>( + node: &Node<'a>, + _code: &[u8], + _ancestors: Ancestors<'a, '_>, + stats: &mut Stats, + ) { match node.kind_id().into() { Objc::FunctionDefinition | Objc::FunctionDefinition2 => { if let Some(declarator) = node.child_by_field_name("declarator") { @@ -376,7 +389,7 @@ fn compute_go_args(node: &Node, nargs: &mut usize) { } impl NArgs for GoCode { - fn compute<'a>(node: &Node<'a>, ancestors: Ancestors<'a, '_>, stats: &mut Stats) { + fn compute<'a>(node: &Node<'a>, _code: &[u8], ancestors: Ancestors<'a, '_>, stats: &mut Stats) { if Self::is_func(node, ancestors) { compute_go_args(node, &mut stats.fn_nargs); return; @@ -418,7 +431,7 @@ fn compute_kotlin_lambda_args(node: &Node, nargs: &mut usize) { } impl NArgs for KotlinCode { - fn compute<'a>(node: &Node<'a>, ancestors: Ancestors<'a, '_>, stats: &mut Stats) { + fn compute<'a>(node: &Node<'a>, _code: &[u8], ancestors: Ancestors<'a, '_>, stats: &mut Stats) { if Self::is_func(node, ancestors) { compute_kotlin_func_args(node, &mut stats.fn_nargs); return; @@ -445,7 +458,7 @@ fn compute_lua_args(node: &Node, nargs: &mut usize) { } impl NArgs for LuaCode { - fn compute<'a>(node: &Node<'a>, ancestors: Ancestors<'a, '_>, stats: &mut Stats) { + fn compute<'a>(node: &Node<'a>, _code: &[u8], ancestors: Ancestors<'a, '_>, stats: &mut Stats) { if Self::is_func(node, ancestors) { compute_lua_args(node, &mut stats.fn_nargs); } else if Self::is_closure(node, ancestors) { @@ -465,7 +478,7 @@ fn compute_tcl_args(node: &Node, nargs: &mut usize) { } impl NArgs for TclCode { - fn compute<'a>(node: &Node<'a>, ancestors: Ancestors<'a, '_>, stats: &mut Stats) { + fn compute<'a>(node: &Node<'a>, _code: &[u8], ancestors: Ancestors<'a, '_>, stats: &mut Stats) { if Self::is_func(node, ancestors) { compute_tcl_args(node, &mut stats.fn_nargs); } @@ -488,7 +501,7 @@ fn compute_irules_args(node: &Node, nargs: &mut usize) { } impl NArgs for IrulesCode { - fn compute<'a>(node: &Node<'a>, ancestors: Ancestors<'a, '_>, stats: &mut Stats) { + fn compute<'a>(node: &Node<'a>, _code: &[u8], ancestors: Ancestors<'a, '_>, stats: &mut Stats) { if Self::is_func(node, ancestors) { compute_irules_args(node, &mut stats.fn_nargs); } @@ -545,7 +558,7 @@ fn compute_perl_args(node: &Node, nargs: &mut usize) { } impl NArgs for PerlCode { - fn compute<'a>(node: &Node<'a>, ancestors: Ancestors<'a, '_>, stats: &mut Stats) { + fn compute<'a>(node: &Node<'a>, _code: &[u8], ancestors: Ancestors<'a, '_>, stats: &mut Stats) { if Self::is_func(node, ancestors) { compute_perl_args(node, &mut stats.fn_nargs); return; @@ -566,6 +579,100 @@ impl NArgs for PerlCode { } } +// Elixir has no function-definition node. `def bar(a, b, c)` is a `Call` +// whose `arguments` holds a *second* `Call` carrying the real parameter +// list, which is why the `parameters`-field heuristic in `compute_args` +// finds nothing: +// +// call (def) +// ├─ identifier def <- the `target` field +// ╰─ arguments <- NOT a field; tree-sitter-elixir +// ╰─ call bar(a, b, c) gives `call` only a `target` field, +// ├─ identifier bar so both `arguments` levels have to +// ╰─ arguments (a, b, c) be found by kind. +// +// A guarded head interposes a `when` `binary_operator` whose `left` is +// that `Call` — without unwrapping it every guarded clause counts 0, and +// guards are a large fraction of real Elixir. A head that is a bare +// `identifier` (`def noargs, do: 1`) has no parameter list and counts 0. +// +// `def a + b` and `def -a` define the operator functions `+/2` and `-/1`. +// Their head is the operator node itself, with the parameters as its +// operands and no `arguments` container to walk, so the arity comes from +// the operator's shape. +fn elixir_declared_args(node: &Node, code: &[u8]) -> usize { + let Some(head) = elixir_arguments(node).and_then(|a| a.children().find(Node::is_named)) else { + return 0; + }; + let head = elixir_unwrap_guard(&head, code); + match head.kind_id().into() { + Elixir::Call => elixir_arguments(&head).map_or(0, |p| count_elixir_args(&p)), + Elixir::BinaryOperator => 2, + Elixir::UnaryOperator => 1, + _ => 0, + } +} + +fn elixir_arguments<'a>(node: &Node<'a>) -> Option> { + node.first_child(|id| id == Elixir::Arguments) +} + +// Returns the guarded expression when `node` is a `when` guard, and +// `node` itself otherwise. Matching `BinaryOperator` alone would also +// unwrap an operator definition, whose operands are the parameters. +fn elixir_unwrap_guard<'a>(node: &Node<'a>, code: &[u8]) -> Node<'a> { + let is_when = node.kind_id() == Elixir::BinaryOperator + && node + .child_by_field_name("operator") + .and_then(|op| op.utf8_text(code)) + == Some("when"); + if is_when { + node.child_by_field_name("left").unwrap_or(*node) + } else { + *node + } +} + +fn count_elixir_args(params: &Node) -> usize { + let mut nargs = 0; + params.act_on_child(&mut |n| { + if !ElixirCode::is_non_arg(n) { + nargs += 1; + } + }); + nargs +} + +impl NArgs for ElixirCode { + fn compute<'a>(node: &Node<'a>, code: &[u8], ancestors: Ancestors<'a, '_>, stats: &mut Stats) { + // `is_func` is byte-less and constant `false` for Elixir, because a + // `def` is textually indistinguishable from any other `Call`. The + // code-aware predicate (#275) is the only one that identifies one, + // and it also excludes a `def` inside `quote do … end`, which + // declares nothing until the macro expands (#310). + if Self::is_func_with_code(node, code, ancestors) { + stats.fn_nargs += elixir_declared_args(node, code); + return; + } + + // Every clause of one `fn` must have the same arity, so the first + // `stab_clause` gives the closure's argument count. Summing the + // clauses would report `2n` for an n-clause function — do not + // "fix" this into a sum. + // + // A guarded clause (`fn x when is_integer(x) -> …`) aliases its + // `left` to a `binary_operator`, exactly as a guarded `def` head + // does, so it needs the same unwrap — otherwise every guarded + // closure counts the fixed 3 children of the guard expression. + if Self::is_closure(node, ancestors) + && let Some(clause) = node.first_child(|id| id == Elixir::StabClause) + && let Some(params) = clause.child_by_field_name("left") + { + stats.closure_nargs += count_elixir_args(&elixir_unwrap_guard(¶ms, code)); + } + } +} + implement_metric_trait!( [NArgs], PythonCode, @@ -580,7 +687,6 @@ implement_metric_trait!( BashCode, PhpCode, CsharpCode, - ElixirCode, RubyCode ); @@ -589,7 +695,7 @@ implement_metric_trait!( // for a `parameters` field) misses them. Match the closure_parameters // child directly and count its `closure_parameter` grand-children. impl NArgs for GroovyCode { - fn compute<'a>(node: &Node<'a>, ancestors: Ancestors<'a, '_>, stats: &mut Stats) { + fn compute<'a>(node: &Node<'a>, _code: &[u8], ancestors: Ancestors<'a, '_>, stats: &mut Stats) { use crate::languages::language_groovy::Groovy; if Self::is_func(node, ancestors) { @@ -3344,18 +3450,147 @@ proc g {x y z} { puts $x }", ); } + /// Regression for #1142: the parameter list sits two `arguments` + /// levels down, so the `parameters`-field heuristic found nothing and + /// every Elixir function reported 0. + #[test] + fn elixir_named_function_args() { + check_metrics::( + "defmodule Foo do\n def bar(a, b, c) do\n a + b + c\n end\nend\n", + "foo.ex", + |metric| { + assert_eq!(metric.nom.functions_sum(), 1); + let s = &metric.nargs; + assert_eq!(s.function_args_sum(), 3); + assert_eq!(s.function_args_max(), 3); + }, + ); + } + + /// A guard interposes a `when` `binary_operator` between the macro's + /// `arguments` and the head `Call`. Without unwrapping it every + /// guarded clause — a large fraction of real Elixir — counts 0. + #[test] + fn elixir_guarded_clause_args() { + check_metrics::( + "defmodule Foo do\n defp baz(x) when is_integer(x), do: x\nend\n", + "foo.ex", + |metric| { + assert_eq!(metric.nom.functions_sum(), 1); + let s = &metric.nargs; + assert_eq!(s.function_args_sum(), 1); + assert_eq!(s.function_args_max(), 1); + }, + ); + } + + /// `def noargs, do: 1` puts a bare `identifier` where the head `Call` + /// would be. It has no parameter list, and the walk must stop there + /// rather than fall through to the enclosing `arguments` — which + /// holds the `do:` keyword pair and would count 1. + #[test] + fn elixir_zero_arg_function_has_no_parameter_list() { + check_metrics::( + "defmodule Foo do\n def noargs, do: 1\nend\n", + "foo.ex", + |metric| { + assert_eq!(metric.nom.functions_sum(), 1); + assert_eq!(metric.nargs.function_args_sum(), 0); + }, + ); + } + + /// Pattern and defaulted parameters are `map` and `binary_operator` + /// nodes rather than plain identifiers, so the punctuation-negative + /// filter is what keeps them counted. + #[test] + fn elixir_pattern_and_default_args() { + check_metrics::( + "defmodule Foo do\n def f(%{a: x}, b \\\\ 1), do: {x, b}\nend\n", + "foo.ex", + |metric| { + assert_eq!(metric.nom.functions_sum(), 1); + let s = &metric.nargs; + assert_eq!(s.function_args_sum(), 2); + assert_eq!(s.function_args_max(), 2); + }, + ); + } + + /// Every clause of one `fn` has the same arity, so a two-clause + /// two-argument closure is 2 — summing the clauses would report 4. + #[test] + fn elixir_multi_clause_closure_counts_one_clause() { + check_metrics::( + "defmodule Foo do\n def run do\n fn\n a, b -> a + b\n a, _ -> a\n end\n end\nend\n", + "foo.ex", + |metric| { + assert_eq!(metric.nom.closures_sum(), 1); + let s = &metric.nargs; + assert_eq!(s.closure_args_sum(), 2); + assert_eq!(s.closure_args_max(), 2); + }, + ); + } + + /// A guarded `fn` clause aliases its `left` to the same `when` + /// `binary_operator` a guarded `def` head uses, so it needs the same + /// unwrap. Without it the count is the guard expression's fixed three + /// children — 3 for any arity, which is why the four-parameter form is + /// the fixture here. + #[test] + fn elixir_guarded_closure_args() { + check_metrics::( + "defmodule Foo do\n def run do\n fn a, b, c, d when is_integer(a) -> a + b + c + d end\n end\nend\n", + "foo.ex", + |metric| { + assert_eq!(metric.nom.closures_sum(), 1); + let s = &metric.nargs; + assert_eq!(s.closure_args_sum(), 4); + assert_eq!(s.closure_args_max(), 4); + }, + ); + } + + /// `def a + b` and `def -a` define the operator functions `+/2` and + /// `-/1`. Their head is the operator node itself, with no `arguments` + /// container to walk, so the arity comes from the operator's shape. + #[test] + fn elixir_operator_definition_args() { + check_metrics::( + "defmodule Foo do\n def a + b, do: {a, b}\n def -a, do: a\nend\n", + "foo.ex", + |metric| { + assert_eq!(metric.nom.functions_sum(), 2); + let s = &metric.nargs; + assert_eq!(s.function_args_sum(), 3); + assert_eq!(s.function_args_max(), 2); + }, + ); + } + + /// A `def` inside `quote do … end` is a code template, not a + /// declaration, and must not contribute arguments (#310). The quoted + /// head carries three parameters, so dropping the rule reads 3. + #[test] + fn elixir_quoted_def_contributes_no_args() { + check_metrics::( + "defmodule Foo do\n defmacro mac do\n quote do\n def generated(p, q, r), do: p + q + r\n end\n end\nend\n", + "foo.ex", + |metric| { + assert_eq!(metric.nargs.function_args_sum(), 0); + }, + ); + } + + /// Only `def` / `defp` / `defmacro` / `defmacrop` declare a function. + /// `defmodule` and `defdelegate` are ordinary `Call`s of the same + /// shape — `defdelegate log(msg), to: Logger` has a head `Call` with + /// one parameter, so a gate that matched any macro would read 1. #[test] - fn elixir_default_nargs_is_zero() { - // Documents Elixir's current default-impl behaviour. `def` calls - // are not recognised as functions (`is_func` returns `false`), - // and Elixir anonymous functions hold their parameters inside - // a `stab_clause` (not a parameter-list field), so the default - // `NArgs::compute` heuristic finds no formal parameters to - // count. This anchors the limitation so a future real impl - // that wires up `stab_clause`-based argument counting cannot - // silently regress to zero. + fn elixir_non_method_macros_count_zero() { check_metrics::( - "defmodule Foo do\n def add(a, b), do: a + b\n def use_anon do\n add2 = fn x, y -> x + y end\n add2.(1, 2)\n end\nend\n", + "defmodule Foo do\n defdelegate log(msg), to: Logger\nend\n", "foo.ex", |metric| { assert_eq!(metric.nargs.function_args_sum(), 0); diff --git a/src/spaces/compute.rs b/src/spaces/compute.rs index 496672f51..6418c0477 100644 --- a/src/spaces/compute.rs +++ b/src/spaces/compute.rs @@ -261,7 +261,7 @@ fn compute_per_node<'a, T: ParserTrait>( T::Tokens::compute(node, &mut last.metrics.tokens, in_comment); } if selected.contains(Metric::Nargs) { - T::NArgs::compute(node, ancestors, &mut last.metrics.nargs); + T::NArgs::compute(node, code, ancestors, &mut last.metrics.nargs); } if selected.contains(Metric::Nexits) { T::Exit::compute(node, code, &mut last.metrics.nexits); From 035e079ea13010d4b31bc7c8a888b66470b3b916 Mon Sep 17 00:00:00 2001 From: Elijah Zupancic Date: Sat, 1 Aug 2026 01:46:05 -0700 Subject: [PATCH 16/36] chore(self-scan): refresh baseline line numbers after wave-4 merges --- .bca-baseline.toml | 96 ++++++++++++++++++++-------------------------- 1 file changed, 41 insertions(+), 55 deletions(-) diff --git a/.bca-baseline.toml b/.bca-baseline.toml index 913b2e270..e31471720 100644 --- a/.bca-baseline.toml +++ b/.bca-baseline.toml @@ -326,24 +326,31 @@ start_line = 851 metric = "halstead.effort" value = 56531.05676283881 +[[entry]] +path = "big-code-analysis-cli/src/vcs_command.rs" +qualified = "" +start_line = 1 +metric = "loc.ploc" +value = 532.0 + [[entry]] path = "big-code-analysis-cli/src/vcs_command.rs" qualified = "build_options" -start_line = 233 +start_line = 234 metric = "nargs" value = 8.0 [[entry]] path = "big-code-analysis-cli/src/vcs_command.rs" qualified = "rank" -start_line = 323 +start_line = 324 metric = "nargs" value = 7.0 [[entry]] path = "big-code-analysis-cli/src/vcs_command.rs" qualified = "write_table" -start_line = 474 +start_line = 475 metric = "nexits" value = 5.0 @@ -378,28 +385,28 @@ value = 6.0 [[entry]] path = "big-code-analysis-py/src/batch.rs" qualified = "analyze_paths" -start_line = 622 +start_line = 625 metric = "nargs" value = 12.0 [[entry]] path = "big-code-analysis-py/src/batch.rs" qualified = "analyze_paths" -start_line = 622 +start_line = 625 metric = "nexits" value = 5.0 [[entry]] path = "big-code-analysis-py/src/lib.rs" qualified = "PyVcsOptions::py_new" -start_line = 599 +start_line = 607 metric = "nargs" value = 15.0 [[entry]] path = "big-code-analysis-py/src/lib.rs" qualified = "_native" -start_line = 830 +start_line = 838 metric = "nexits" value = 35.0 @@ -413,21 +420,21 @@ value = 8.0 [[entry]] path = "big-code-analysis-py/src/lib.rs" qualified = "extract_as_of" -start_line = 532 +start_line = 540 metric = "nexits" value = 5.0 [[entry]] path = "big-code-analysis-py/src/lib.rs" qualified = "register_vcs_submodule" -start_line = 893 +start_line = 901 metric = "nexits" value = 14.0 [[entry]] path = "big-code-analysis-py/src/lib.rs" qualified = "vcs_trend" -start_line = 746 +start_line = 754 metric = "nargs" value = 7.0 @@ -620,20 +627,6 @@ start_line = 21 metric = "halstead.effort" value = 84039.86210807812 -[[entry]] -path = "src/metrics/abc/c.rs" -qualified = "CCode::compute" -start_line = 17 -metric = "cyclomatic" -value = 15.0 - -[[entry]] -path = "src/metrics/abc/cpp.rs" -qualified = "CppCode::compute" -start_line = 104 -metric = "cyclomatic" -value = 15.0 - [[entry]] path = "src/metrics/abc/go.rs" qualified = "GoCode::compute" @@ -655,27 +648,6 @@ start_line = 156 metric = "cyclomatic" value = 16.0 -[[entry]] -path = "src/metrics/abc/mozcpp.rs" -qualified = "MozcppCode::compute" -start_line = 17 -metric = "cyclomatic" -value = 15.0 - -[[entry]] -path = "src/metrics/abc/objc.rs" -qualified = "ObjcCode::compute" -start_line = 31 -metric = "cyclomatic" -value = 16.0 - -[[entry]] -path = "src/metrics/abc/perl.rs" -qualified = "PerlCode::compute" -start_line = 199 -metric = "halstead.effort" -value = 71163.29612014526 - [[entry]] path = "src/metrics/abc/perl.rs" qualified = "perl_inspect_container" @@ -1005,17 +977,31 @@ start_line = 192 metric = "nargs" value = 10.0 +[[entry]] +path = "src/vcs/git/blame.rs" +qualified = "PerFunctionBlame" +start_line = 255 +metric = "nom" +value = 30.0 + +[[entry]] +path = "src/vcs/git/blame.rs" +qualified = "PerFunctionBlame::blame_spans" +start_line = 391 +metric = "nargs" +value = 7.0 + [[entry]] path = "src/vcs/git/blame.rs" qualified = "PerFunctionBlame::open" -start_line = 262 +start_line = 266 metric = "nexits" value = 6.0 [[entry]] path = "src/vcs/git/blame.rs" qualified = "PerFunctionBlame::resolve_commit" -start_line = 438 +start_line = 518 metric = "nexits" value = 6.0 @@ -1099,49 +1085,49 @@ value = 9.0 [[entry]] path = "src/vcs/git/jit.rs" qualified = "collect_touched" -start_line = 205 +start_line = 208 metric = "nexits" value = 10.0 [[entry]] path = "src/vcs/git/jit.rs" -qualified = "collect_touched::" -start_line = 224 +qualified = "collect_touched::" +start_line = 227 metric = "nexits" value = 7.0 [[entry]] path = "src/vcs/git/jit.rs" qualified = "compute_features" -start_line = 161 +start_line = 164 metric = "nargs" value = 7.0 [[entry]] path = "src/vcs/git/jit.rs" qualified = "compute_features" -start_line = 161 +start_line = 164 metric = "nexits" value = 5.0 [[entry]] path = "src/vcs/git/jit.rs" qualified = "experience_features" -start_line = 475 +start_line = 478 metric = "nargs" value = 7.0 [[entry]] path = "src/vcs/git/jit.rs" qualified = "experience_features" -start_line = 475 +start_line = 478 metric = "nexits" value = 6.0 [[entry]] path = "src/vcs/git/jit.rs" qualified = "history_features" -start_line = 388 +start_line = 391 metric = "halstead.effort" value = 52560.02538757721 From 56db8ce35d2aa0d33cf208aefccd2f180fe525ac Mon Sep 17 00:00:00 2001 From: Elijah Zupancic Date: Sat, 1 Aug 2026 02:39:50 -0700 Subject: [PATCH 17/36] fix(ops): open spaces with the source-aware predicate `ops_inner` decided a node opened a function space with the byte-less `is_func || is_func_space`, while `spaces::compute::metrics_inner` uses `promotes_to_func_space_with_code`. Elixir's `defmodule` / `def` / `defp` / `defmacro` are plain `Call` nodes distinguished only by their target identifier text (#275), so `bca ops` opened no space for any of them: an Elixir file came back with only its file-level vocabulary where `bca metrics` returned a full module/function tree. The same split existed for the space kind, where `classify_space_kind` called `get_space_kind` against `open_func_space`'s `get_space_kind_with_code`. Both call sites now match the metrics walk. Elixir is the only language overriding a `*_with_code` predicate today, so every other language's output is byte-identical; `make bench-scaling` is flat (ops/nested-fn 1.03 -> 1.10, inside run-to-run noise, all 25 probes within bound). Adds tests/ops_metrics_space_parity.rs, which asserts the `ops()` and `metrics()` space trees agree in shape, name, kind, and line span for one fixture per language. Its fixture table is an exhaustive match on `LANG`, so a new language cannot compile without one. Plus three Elixir ops tests, including a `def` inside `quote do ... end` that must open no space (#310). FIXME(#1162) markers record the same divergence at two seams this fix deliberately leaves alone: `bca functions` (src/function.rs) and `bca find --type function` (src/parser.rs). Fixes #1130 --- src/function.rs | 8 + src/ops.rs | 143 +++++++++++++-- src/parser.rs | 7 + tests/ops_metrics_space_parity.rs | 285 ++++++++++++++++++++++++++++++ 4 files changed, 431 insertions(+), 12 deletions(-) create mode 100644 tests/ops_metrics_space_parity.rs diff --git a/src/function.rs b/src/function.rs index 14c99e75e..d193501dc 100644 --- a/src/function.rs +++ b/src/function.rs @@ -57,6 +57,14 @@ pub(crate) fn function(parser: &T) -> Vec { let code = parser.code(); let mut spans = Vec::new(); root.act_on_node(&mut |n, ancestors| { + // FIXME(#1162): byte-less `is_func` cannot see Elixir's + // macro-shaped `def` / `defp` / `defmacro`, which are `Call` + // nodes identified by their target text (#275), so + // `bca functions` reports nothing for an Elixir file whose + // `bca metrics` tree is full of Function spaces. `code` and + // `ancestors` are both in hand here, so the fix is + // `is_func_with_code` — deliberately not applied with #1130, + // which scoped itself to the `ops` walk. if T::Checker::is_func(n, ancestors) { let start_line = n.start_row() + 1; let end_line = n.end_row() + 1; diff --git a/src/ops.rs b/src/ops.rs index 5d52dae0f..ef28bcb94 100644 --- a/src/ops.rs +++ b/src/ops.rs @@ -158,20 +158,25 @@ crate::observation::counter!(space_kind_lookups); /// /// Classification happens after the decision that a space opens, the /// same way [`crate::spaces::compute`]'s `open_func_space` does it -/// (#522). That is a claim about *when*, not about *which*: the two -/// seams disagree on which nodes open a space — `ops_inner` opens on -/// `is_func || is_func_space` where `open_func_space` uses -/// `promotes_to_func_space_with_code`, so `bca ops` reports no nested -/// spaces for an Elixir input where `bca metrics` reports two. That -/// divergence is tracked in #1130 and is not what this function is -/// about. +/// (#522), and through the same source-aware classifier, so a space +/// both seams open carries the same [`SpaceKind`] in either walk. +/// The `_with_code` variant is what lets Elixir's macro-shaped +/// `defmodule` / `def` declarations — plain `Call` nodes distinguished +/// only by their target identifier text — come back as `Class` / +/// `Function` rather than `Unknown` (#275, #1130). /// /// The lookup is a per-language `match` on the node's kind for most /// grammars, but C#'s reaches a child scan for a bodied indexer or -/// property, so it is not free on every node either. -fn classify_space_kind(node: &Node) -> SpaceKind { +/// property and Elixir's reads the `Call` target text and scans the +/// ancestor chain for an enclosing `quote` block, so it is not free on +/// every node either. +fn classify_space_kind<'a, T: ParserTrait>( + node: &Node<'a>, + code: &[u8], + ancestors: Ancestors<'a, '_>, +) -> SpaceKind { space_kind_lookups::record(); - T::Getter::get_space_kind(node) + T::Getter::get_space_kind_with_code(node, code, ancestors) } /// Render a space's vocabulary: byte-lexicographically ordered, one @@ -337,10 +342,17 @@ pub(crate) fn ops_inner( let ancestors = Ancestors::checked(&chain, &node); - let func_space = T::Checker::is_func(&node, ancestors) || T::Checker::is_func_space(&node); + // Same predicate `spaces::compute::metrics_inner` opens on, so + // the two walks agree on which nodes become spaces. The + // byte-less `is_func || is_func_space` this replaced could not + // see Elixir's macro-shaped declarations, which are `Call` + // nodes identified by their target text, so `bca ops` opened no + // space for a `defmodule` / `def` / `defp` / `defmacro` — only + // for `Source` and an explicit `fn … -> … end` (#1130). + let func_space = T::Checker::promotes_to_func_space_with_code(&node, code, ancestors); let new_level = if func_space { - let kind = classify_space_kind::(&node); + let kind = classify_space_kind::(&node, code, ancestors); let state = State { ops: Ops::new::(&node, code, ancestors, kind), halstead_maps: HalsteadMaps::new(), @@ -1333,4 +1345,111 @@ mod tests { ); } } + + /// One flattened space: `(depth, kind, name, start_line, end_line)`. + #[cfg(feature = "elixir")] + type FlatSpace = (usize, crate::SpaceKind, String, usize, usize); + + /// Flattens an `Ops` tree in preorder, so a test can pin the whole + /// tree in one `assert_eq!` and see the surrounding spaces when one + /// is wrong. + /// + /// `end_line` is carried as well as `start_line` because a change to + /// the promote predicate can move a space's *extent* without moving + /// its head — a `def` that swallows its sibling would keep the same + /// start line. + #[cfg(feature = "elixir")] + fn flatten(ops: &Ops, depth: usize, out: &mut Vec) { + out.push(( + depth, + ops.kind, + ops.name.clone().unwrap_or_else(|| "".to_owned()), + ops.start_line, + ops.end_line, + )); + for child in &ops.spaces { + flatten(child, depth + 1, out); + } + } + + #[cfg(feature = "elixir")] + fn elixir_ops_tree(source: &str) -> Vec { + let ops = crate::test_support::parse_named(LANG::Elixir, "foo.ex", source) + .ops() + .expect("ops walk must yield a top-level Ops"); + let mut flat = Vec::new(); + flatten(&ops, 0, &mut flat); + flat + } + + /// Issue #1130: Elixir's `defmodule` / `def` are `Call` nodes whose + /// target identifier text spells the keyword, so only the + /// source-aware promote predicate can recognise them. Before the + /// fix `ops()` returned the bare file-level `Unit` for this input + /// while `metrics()` returned the full module/function tree. + #[cfg(feature = "elixir")] + #[test] + fn elixir_ops_opens_module_and_function_spaces_1130() { + use crate::SpaceKind::{Class, Function, Unit}; + + assert_eq!( + elixir_ops_tree("defmodule Foo do\n def bar(x) do\n x + 1\n end\nend\n"), + vec![ + (0, Unit, "foo.ex".to_owned(), 1, 5), + (1, Class, "Foo".to_owned(), 1, 5), + (2, Function, "bar".to_owned(), 2, 4), + ], + ); + } + + /// An `AnonymousFunction` is the one Elixir space the byte-less + /// predicate could already see, so this pins that the source-aware + /// predicate did not lose it — and that it nests under the `def` + /// space rather than being reparented to the file root. + #[cfg(feature = "elixir")] + #[test] + fn elixir_ops_opens_anonymous_function_space() { + use crate::SpaceKind::{Class, Function, Unit}; + + assert_eq!( + elixir_ops_tree( + "defmodule Foo do\n def bar(list) do\n \ + Enum.map(list, fn x -> x * 2 end)\n end\nend\n" + ), + vec![ + (0, Unit, "foo.ex".to_owned(), 1, 5), + (1, Class, "Foo".to_owned(), 1, 5), + (2, Function, "bar".to_owned(), 2, 4), + (3, Function, "".to_owned(), 3, 3), + ], + ); + } + + /// Issue #310: a `def` inside `quote do … end` is a code *template* + /// emitted later by macro expansion, not a declaration of the + /// enclosing module, so it opens no space. This is the case only the + /// source-aware predicate can get right — the byte-less one never + /// saw any `def` at all, so it was accidentally "correct" here while + /// being wrong everywhere else. + #[cfg(feature = "elixir")] + #[test] + fn elixir_ops_skips_def_inside_quote_block_310() { + use crate::SpaceKind::{Class, Function, Unit}; + + // The quoted `def` heads line 4. Its name resolves to the + // `` placeholder (the head is `unquote(name)`, not a + // literal identifier), so the absence of a fourth entry — not a + // name match — is what pins it out of the tree. + assert_eq!( + elixir_ops_tree( + "defmodule Foo do\n defmacro gen(name) do\n quote do\n \ + def unquote(name)(x) do\n x + 1\n end\n end\n end\nend\n", + ), + vec![ + (0, Unit, "foo.ex".to_owned(), 1, 9), + (1, Class, "Foo".to_owned(), 1, 9), + (2, Function, "gen".to_owned(), 2, 8), + ], + ); + } } diff --git a/src/parser.rs b/src/parser.rs index 94a45a5e5..4d304aff5 100644 --- a/src/parser.rs +++ b/src/parser.rs @@ -188,6 +188,13 @@ impl< // JS-family `is_func` consults one, and it answers the same // either way — a climb costs `O(depth)` rather than `O(1)` // per matched function expression (#1088). + // + // FIXME(#1162): byte-less `is_func` also cannot see + // Elixir's macro-shaped `def` / `defmacro` (#275), so + // `bca find --type function` matches nothing there. + // Unlike `src/function.rs`, this closure has no `code` + // in scope, so the fix needs the predicate type to + // widen — see the issue. "function" => res.push(Box::new(|node: &Node| { T::is_func(node, Ancestors::unknown()) })), diff --git a/tests/ops_metrics_space_parity.rs b/tests/ops_metrics_space_parity.rs new file mode 100644 index 000000000..f4b22f5fb --- /dev/null +++ b/tests/ops_metrics_space_parity.rs @@ -0,0 +1,285 @@ +//! Cross-language parity test for the **space tree** produced by the +//! two AST walks. +//! +//! `spaces::compute::metrics_inner` (behind [`analyze`]) and +//! `ops::ops_inner` (behind [`Ast::ops`]) are separate walks that must +//! open a function space on exactly the same nodes and label each with +//! the same [`SpaceKind`]. Nothing forces them to: each carries its own +//! copy of the promote-and-classify decision, and a divergence is +//! invisible from either side alone — `bca ops` simply reports fewer +//! spaces, with no error and no wrong metric value anywhere. +//! +//! That is exactly how #1130 survived: `ops_inner` opened on the +//! byte-less `is_func || is_func_space`, which cannot see Elixir's +//! macro-shaped `defmodule` / `def` declarations (they are plain `Call` +//! nodes distinguished only by their target identifier text, #275), so +//! `bca ops` returned a bare file-level space for every Elixir input +//! while `bca metrics` returned a full module/function tree. +//! +//! The fixture table is an exhaustive `match` on [`LANG`], so adding a +//! language variant fails to compile until a fixture is supplied — the +//! `add-lang` workflow trips over this test rather than discovering the +//! divergence in the field. + +use big_code_analysis::{Ast, FuncSpace, LANG, MetricsOptions, Ops, Source, SpaceKind, analyze}; + +/// One fixture per language, chosen to open at least one *nested* +/// space: a walk that opens no space below the file root would agree +/// with any other walk vacuously. +/// +/// Returns `(source, extension)`. The extension only reaches the space +/// name of the file-level `Unit`, which both walks take from +/// `Source::name` rather than from the AST, so it is cosmetic — but it +/// keeps a failure message readable. +fn fixture(lang: LANG) -> (&'static str, &'static str) { + // Exhaustive per-language dispatch table: one arm per LANG variant + // is the point of this function, and splitting it would break the + // compile-time completeness check the test relies on. The repo's own + // `.bcaignore` excludes `./tests/**`, so this marker is for the + // per-edit `bca check` hook rather than for the self-scan gate. + // bca: suppress(cyclomatic) + match lang { + LANG::Javascript | LANG::Mozjs => ( + "function f(a) {\n const g = (b) => b * 2;\n return g(a) + 1;\n}\n", + "js", + ), + LANG::Typescript => ( + "function f(a: number): number {\n const g = (b: number) => b * 2;\n \ + return g(a) + 1;\n}\n", + "ts", + ), + LANG::Tsx => ( + "function f(a: number): number {\n const g = (b: number) => b * 2;\n \ + return g(a) + 1;\n}\n", + "tsx", + ), + LANG::Java => ( + "import java.util.function.IntUnaryOperator;\n\nclass Parity {\n \ + int f(int a) {\n IntUnaryOperator g = b -> b * 2;\n \ + return g.applyAsInt(a) + 1;\n }\n}\n", + "java", + ), + LANG::Go => ( + "package p\n\nfunc f(a int) int {\n g := func(b int) int { return b * 2 }\n \ + return g(a) + 1\n}\n", + "go", + ), + LANG::Kotlin => ( + "fun f(a: Int): Int {\n val g = { b: Int -> b * 2 }\n return g(a) + 1\n}\n", + "kt", + ), + LANG::Lua => ( + "function f(a)\n local g = function(b) return b * 2 end\n return g(a) + 1\nend\n", + "lua", + ), + LANG::Rust => ( + "fn f(a: i32) -> i32 {\n let g = |b: i32| b * 2;\n g(a) + 1\n}\n", + "rs", + ), + LANG::Tcl => ("proc f {a} {\n return $a\n}\n", "tcl"), + // `when { … }` is the dominant real iRules shape and a + // separate `Checker` arm from `proc`; exercise both. + LANG::Irules => ( + "proc f { a } {\n return $a\n}\n\nwhen HTTP_REQUEST {\n log local0. \"hi\"\n}\n", + "irule", + ), + LANG::C => ("int f(int a) {\n return a + 1;\n}\n", "c"), + LANG::Cpp | LANG::Mozcpp => ( + "int f(int a) {\n auto g = [](int b) { return b * 2; };\n return g(a) + 1;\n}\n", + "cpp", + ), + // A real Objective-C method, not the plain C function the + // grammar would also accept — the `MethodDefinition` arm is + // Objc-specific and nothing else here reaches it. + LANG::Objc => ( + "@implementation Parity\n- (int)f:(int)a {\n return a + 1;\n}\n@end\n", + "m", + ), + // An expression-bodied property alongside an ordinary method: + // per `.claude/rules/grammar-dispatch.md` §6, `is_func`, + // `is_func_space`, and `get_space_kind` must stay gated by the + // same child-presence predicate for these, and a mismatch shows + // up here as an ops/metrics divergence. + LANG::Csharp => ( + "class Parity {\n int _w;\n int W => _w;\n int F(int a) {\n \ + return a + 1;\n }\n}\n", + "cs", + ), + // The reason this file exists: every space below the root is a + // `Call` node the byte-less predicates cannot recognise. + LANG::Elixir => ( + "defmodule Foo do\n def bar(x) do\n x + 1\n end\nend\n", + "ex", + ), + LANG::Python => ( + "def f(a):\n def g(b):\n return b * 2\n return g(a) + 1\n", + "py", + ), + LANG::Bash => ("f() {\n echo \"$1\"\n}\n", "sh"), + LANG::Perl => ( + "sub f {\n my ($a) = @_;\n my $g = sub { return $_[0] * 2; };\n \ + return $g->($a) + 1;\n}\n", + "pl", + ), + LANG::Php => ( + " ( + "def f(a)\n g = lambda { |b| b * 2 }\n g.call(a) + 1\nend\n", + "rb", + ), + LANG::Groovy => ( + "def f(int a) {\n def g = { int b -> b * 2 }\n return g(a) + 1\n}\n", + "groovy", + ), + // `Ccomment` and `Preproc` are internal C-family helper grammars + // with no function-space concept at all: both walks open the file + // root and nothing else. Included so the `match` stays exhaustive + // — the parity claim still holds, it is just a one-node tree. + LANG::Ccomment => ("/* a comment */\n", "c"), + LANG::Preproc => ("#define A 1\n", "h"), + } +} + +/// The fields the two walks are supposed to agree on. +/// +/// One trait rather than two renderers: a second renderer would be free +/// to drift from the first — printing a field one side does not — and +/// two renderers that disagree is the exact failure mode this file +/// exists to catch, one level up. +trait SpaceTree: Sized { + fn describe(&self) -> (Option<&str>, SpaceKind, usize, usize); + fn children(&self) -> &[Self]; +} + +impl SpaceTree for FuncSpace { + fn describe(&self) -> (Option<&str>, SpaceKind, usize, usize) { + ( + self.name.as_deref(), + self.kind, + self.start_line, + self.end_line, + ) + } + fn children(&self) -> &[Self] { + &self.spaces + } +} + +impl SpaceTree for Ops { + fn describe(&self) -> (Option<&str>, SpaceKind, usize, usize) { + ( + self.name.as_deref(), + self.kind, + self.start_line, + self.end_line, + ) + } + fn children(&self) -> &[Self] { + &self.spaces + } +} + +/// Renders a space tree as one line per space, depth encoded as +/// indentation. +/// +/// Comparing rendered trees rather than walking two structures in +/// lockstep means a mismatch anywhere prints both trees in full, so the +/// reader sees *which* space diverged and what its neighbours were — +/// the failure mode here is a missing subtree, which a pairwise +/// recursion reports as a confusing count mismatch at the parent. +fn render(node: &T, depth: usize, out: &mut String) { + use std::fmt::Write as _; + + let (name, kind, start, end) = node.describe(); + let _ = writeln!( + out, + "{:indent$}{kind:?} {name:?} lines {start}..{end}", + "", + indent = depth * 2, + ); + for child in node.children() { + render(child, depth + 1, out); + } +} + +fn rendered(node: &T) -> String { + let mut out = String::new(); + render(node, 0, &mut out); + out +} + +/// First line that differs between the two renderings, for a failure +/// message that names the diverging space instead of dumping a diff the +/// reader has to align by eye. +fn first_divergence(metrics: &str, ops: &str) -> String { + let mut metrics_lines = metrics.lines(); + let mut ops_lines = ops.lines(); + loop { + match (metrics_lines.next(), ops_lines.next()) { + (Some(m), Some(o)) if m == o => {} + (Some(m), Some(o)) => return format!("metrics has `{m}`, ops has `{o}`"), + (Some(m), None) => return format!("metrics has `{m}`, ops has no space there"), + (None, Some(o)) => return format!("ops has `{o}`, metrics has no space there"), + (None, None) => return "trees are identical".to_owned(), + } + } +} + +#[test] +fn ops_and_metrics_agree_on_the_space_tree() { + let mut checked = 0; + + for lang in LANG::into_enum_iter() { + if !lang.is_enabled() { + continue; + } + checked += 1; + + let (source, ext) = fixture(lang); + let name = format!("parity.{ext}"); + + let space = analyze( + Source::new(lang, source.as_bytes()).with_name(Some(name.clone())), + MetricsOptions::default(), + ) + .unwrap_or_else(|e| panic!("{lang:?}: analyze failed: {e}")); + let ops = Ast::parse(Source::new(lang, source.as_bytes()).with_name(Some(name))) + .unwrap_or_else(|e| panic!("{lang:?}: parse failed: {e}")) + .ops() + .unwrap_or_else(|e| panic!("{lang:?}: ops failed: {e}")); + + let from_metrics = rendered(&space); + let from_ops = rendered(&ops); + + assert_eq!( + from_metrics, + from_ops, + "{lang:?}: ops and metrics space trees diverge — {}\n\ + metrics tree:\n{from_metrics}\nops tree:\n{from_ops}", + first_divergence(&from_metrics, &from_ops), + ); + + // A fixture whose only space is the file root would satisfy the + // assertion above no matter how badly the two walks disagreed + // about nested spaces — which is precisely the #1130 shape. The + // two helper grammars genuinely have no nested spaces to open. + if !matches!(lang, LANG::Ccomment | LANG::Preproc) { + assert!( + !space.spaces.is_empty(), + "{lang:?}: fixture opens no nested space, so the parity \ + assertion above cannot detect a divergence", + ); + } + } + + // Every language is feature-gated, so a build with none enabled + // leaves a zero-iteration loop and a test that reports green while + // asserting nothing. + assert!( + checked > 0, + "at least one language feature must be enabled for this test to mean anything" + ); +} From 14e25ec801cfdae8753abf6ce72687ad6eb59fa5 Mon Sep 17 00:00:00 2001 From: Elijah Zupancic Date: Sat, 1 Aug 2026 02:45:33 -0700 Subject: [PATCH 18/36] perf(spaces): finalize parent Halstead/MI once, not per child MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `finalize`'s pop arm re-derived the parent's Halstead `Stats` from its occurrence maps after every child space merged into it. That is three map traversals — one over the parent's whole accumulated operand vocabulary — per popped child, for a result the parent's own `finalize_state` recomputes and overwrites. `O(children x vocabulary)` where `O(vocabulary)` suffices, so quadratic in a file's function count. Split `compute_halstead_mi_and_wmc` into `compute_halstead_and_mi` and `compute_wmc`. The pop arm keeps only the latter: `wmc::Stats::merge` routes a child's cyclomatic on the parent's recorded `space_kind`, which stays `Unknown` until `Wmc::compute` runs, so dropping that call fails 133 lib tests. Halstead and MI have no such dependency — both `merge`s are no-ops on `Stats`, nothing else reads a parent's intermediate values, and every state reaches `finalize_state` exactly once. Guarded by a new width probe, `halstead/wide-distinct-fn`, on the axis #1133 added. It needs a taller ladder than `LINEAR_WIDTHS`: the redundant pass is a second visit to data the walk already touched, so its share of the total is set by the sibling count alone and enriching each function's vocabulary raises both terms together. At 500/1000/2000 the regression fits 1.37-1.39 — a real 2.1x slowdown the 1.5 bound passes. At 4000/8000/16000 the gate reads 1.09 clean and 2.17 with the per-child pass restored, 45.6 ms against 507.4 ms on the top cell, and it is the only probe that fails. Metric values are unchanged: `cargo test --workspace --all-features` green from a clean tree with no `.snap.new` under the output submodule. Real-code effect is modest, since it is quadratic in one parent's child count: no measurable change on the corpus slice (files capped at 64 KB), ~10% CPU on the corpus's widest file (a 2.4 MB C++ source with 1,808 top-level spaces, 95.6 ms to 86.7 ms). Also corrects `mi::Stats::merge`'s comment, which named this commit's renamed function and the wrong module. Fixes #1106 --- big-code-analysis-bench/src/shapes.rs | 89 ++++++++++++++++++++++++++- docs/development/benchmarking.md | 53 +++++++++++++--- src/metrics/halstead.rs | 6 +- src/metrics/mi.rs | 8 +-- src/spaces/compute.rs | 46 +++++++++++--- 5 files changed, 176 insertions(+), 26 deletions(-) diff --git a/big-code-analysis-bench/src/shapes.rs b/big-code-analysis-bench/src/shapes.rs index b6fd3129c..52513cf96 100644 --- a/big-code-analysis-bench/src/shapes.rs +++ b/big-code-analysis-bench/src/shapes.rs @@ -22,6 +22,7 @@ //! `byte_growth_is_affine` unit test below pins, rather than leaving it //! a convention someone has to remember. +use std::fmt::Write as _; use std::hint::black_box; use big_code_analysis::{Ast, CodeMetrics, LANG, Metric, MetricsError, MetricsOptions, Ops}; @@ -293,6 +294,45 @@ pub fn wide_attributed_fns(width: usize) -> String { format!("{}\n", "#[inline] fn f() {} ".repeat(width)) } +/// Rust: `fn f000000() { let v000000 = 000000; } fn f000001() { … }`, +/// all at file scope. +/// +/// The width shape for the space-merge arm. Each function opens its own +/// `FuncSpace`, so the size parameter is the number of *direct children* +/// the file's `Unit` space accumulates — the `C` in the `O(C x U)` that +/// #1106 was filed about, where `U` is the parent's merged Halstead +/// vocabulary. +/// +/// Every identifier and literal is unique to its function, which is what +/// makes `U` grow with `C` rather than saturate: `HalsteadMaps::operands` +/// is keyed by source text, so a file of N identical functions has a +/// vocabulary of constant size and would keep the merge arm linear no +/// matter how it is written. +/// +/// Six-digit zero padding, not the bare index, so the bytes per function +/// stay constant up to a million siblings. `byte_growth_is_affine` does +/// **not** cover this: it samples 10 / 20 / 30, where the unpadded form +/// is affine too. What the padding buys is affine growth across the 4- +/// to 5-digit boundary, which this probe's own ladder crosses at 10 000 +/// — unpadded, 4 000 / 8 000 / 16 000 render 128 670 / 260 670 / 542 670 +/// bytes and inflate the fitted exponent by ~0.06 with nothing in +/// `mod tests` able to see it. +/// +/// Rust permits leading zeros in a decimal literal, so `000000` is an +/// ordinary `integer_literal` and not an octal escape or a parse error. +#[must_use] +pub fn wide_distinct_fns(width: usize) -> String { + let mut source = String::new(); + for i in 0..width { + // `fmt::Write for String` never returns `Err`, so this is the + // one shape whose generator has a `Result` to discard. The + // alternatives clippy leaves are `format!`-into-`push_str` and + // `map(format!).collect()`, and it rejects both. + let _ = writeln!(source, "fn f{i:06}() {{ let v{i:06} = {i:06}; }}"); + } + source +} + /// Rust: `#[cfg(all(all(… test …)))] fn gone() {}` plus one retained /// function. /// @@ -484,6 +524,27 @@ const LINEAR_DEPTHS: [usize; 3] = [1_000, 2_000, 4_000]; /// a rounding error in the fit rather than a term flattening it. const LINEAR_WIDTHS: [usize; 3] = [500, 1_000, 2_000]; +/// Sibling counts for a width probe whose quadratic term is a *second* +/// pass over data the linear walk already touched, rather than extra +/// work per node. +/// +/// Eight times [`LINEAR_WIDTHS`], and deliberately not harmonised down +/// to it. #1106's redundant pass costs one map visit per (child, +/// vocabulary-entry) pair while the walk it rides on costs a fixed +/// amount per token, so the ratio between the two terms is set by the +/// sibling count alone — enriching each function's vocabulary raises +/// both terms together and does not move it. On [`LINEAR_WIDTHS`] the +/// regression fits 1.37-1.39 against a 1.07-1.09 baseline: a real 2.1x +/// slowdown at 2 000 siblings that the bound would have passed. Here +/// the gate reads 1.09 clean and 2.17 regressed. +/// +/// The taller ladder costs ~0.8 s of a `--gate` run over nine rounds +/// and a 624 KB largest input. A reintroduced quadratic pays ~6 s +/// there, and its slowest single walk — 0.5 s — is well inside +/// `scaling::MAX_CELL_WALK`, so the gate reports the exponent rather +/// than abandoning the probe as over budget. +const SPACE_MERGE_WIDTHS: [usize; 3] = [4_000, 8_000, 16_000]; + /// Bound for a probe expected to be linear in its size parameter. /// /// Set from measurement, not from theory. A genuinely linear walk does @@ -976,7 +1037,7 @@ pub const PROBES: &[Probe] = &[ render: wide_attributed_fns, sizes: LINEAR_WIDTHS, max_exponent: LINEAR_BOUND, - rationale: "#1100 on the other axis, and the only probe on it. \ + rationale: "#1100 on the other axis, and the first probe on it. \ The fix the issue originally proposed — read the \ attribute run forward from the parent, always — is \ `O(children)` per item, so on a flat file it is \ @@ -1010,6 +1071,32 @@ pub const PROBES: &[Probe] = &[ the code around it, so `nom/nested-fn` is not a \ control for it; the bound alone is the guard.", }, + Probe { + name: "halstead/wide-distinct-fn", + lang: LANG::Rust, + axis: Axis::Width, + workload: Workload::Metrics { + exclude_tests: false, + selection: &[Metric::Halstead], + reading: |m| m.halstead.unique_operands(), + }, + render: wide_distinct_fns, + sizes: SPACE_MERGE_WIDTHS, + max_exponent: LINEAR_BOUND, + rationale: "#1106: popping a child space merged its Halstead maps \ + into the parent's and then re-derived the parent's \ + `Stats` from them — three map traversals, one of them \ + over the parent's whole accumulated operand \ + vocabulary, once per child, for a result the parent's \ + own finalize overwrites. That is \ + `O(children x vocabulary)`, quadratic in a file's \ + function count. Restoring the per-child pass takes \ + this probe from 1.09 to 2.17 — the gate's only \ + failure — and its 16 000-sibling cell from 45.6 ms to \ + 507.4 ms. A depth probe cannot see it whatever it \ + nests: the cost is per popped child, and a nesting \ + shape has one child per space at every depth.", + }, ]; #[cfg(test)] diff --git a/docs/development/benchmarking.md b/docs/development/benchmarking.md index 0445e7528..d0508b507 100644 --- a/docs/development/benchmarking.md +++ b/docs/development/benchmarking.md @@ -68,14 +68,15 @@ a quadratic one sits near 2.0. A probe declares which **axis** its size parameter grows along, because a walk can be linear in one and quadratic in the other: -- `Axis::Depth` — the size is the shape's nesting depth. Every probe - but one is on this axis, and they exist because `tree_sitter` stores - no parent pointer, so any predicate that resolves an ancestor by +- `Axis::Depth` — the size is the shape's nesting depth. Most probes + are on this axis, and they exist because `tree_sitter` stores no + parent pointer, so any predicate that resolves an ancestor by climbing is `O(depth)` per node. - `Axis::Width` — the size is the number of siblings under one parent, - at a depth that does not move. `nom/wide-attributed-fn` is the only - one, and it exists because #1100's fix trades the two axes against - each other (see below). + at a depth that does not move. `nom/wide-attributed-fn` exists + because #1100's fix trades the two axes against each other, and + `halstead/wide-distinct-fn` because per-child work at a space-merge + boundary is priced by the parent's child count (both below). The axis also selects the shape invariant a probe must satisfy in `shapes.rs`: a depth shape has to gain AST levels in proportion to its @@ -149,6 +150,7 @@ Read it as follows. | `nom/nested-attributed-fn` | Rust | depth | the `exclude_tests` outer-attribute scan (#1100) | linear | | `nom/wide-attributed-fn` | Rust | width | the same scan on the width axis (#1100) | linear | | `nom/nested-cfg-predicate` | Rust | depth | the `cfg(...)` predicate classifier (#1105) | linear | +| `halstead/wide-distinct-fn` | Rust | width | per-child work at the space-merge boundary (#1106) | linear | Four of these were quadratic when the harness landed, and they shared one cause: `tree_sitter` stores no parent pointer, so `Node::parent` @@ -210,8 +212,9 @@ than deferred: the two synthetic-`Unit`-root pushes hand it a node that outside any walk, and the `Npm` arms that test a node's children cannot extend a borrowed slice by one element without allocating. -The last two probes are the first that walk under a non-default -`MetricsOptions`. Both hot paths are reachable only with +`nom/nested-attributed-fn` and `nom/wide-attributed-fn` are the first +probes that walk under a non-default `MetricsOptions`. Both hot paths +are reachable only with `exclude_tests` set, which `Workload::Metrics` now carries as its own field — it sits on that variant rather than on `Probe` because `Ast::ops` takes no options at all, so an `Ops` probe setting it would @@ -269,6 +272,35 @@ the indexed scan that replaced it. `nom/nested-fn` is not a control for it — the file keeps its two items at every depth — so the bound alone is the guard. +`halstead/wide-distinct-fn` is the second width probe, and it comes +from a different family than every row above it. Nothing here climbs an +ancestor chain: [#1106][space-merge] found the parent's Halstead `Stats` +being re-derived from its occurrence maps *once per popped child space*, +which is three map traversals — one of them over the parent's whole +accumulated operand vocabulary — for work the parent's own finalize +repeats and overwrites. That is `O(children × vocabulary)` where +`O(vocabulary)` suffices: quadratic in a file's function count, and +invisible to every depth probe, because the cost is charged per popped +child and a nesting shape has one child per space however deep it +goes. The shape is `n` sibling `fn f000000() { let v000000 = 000000; }` items, +each with an identifier set unique to itself, because +`HalsteadMaps::operands` is keyed by source text and a file of `n` +*identical* functions has a constant vocabulary that keeps the arm +linear no matter how it is written. + +Its ladder is 4 000 / 8 000 / 16 000, eight times the other width +probe's, and that is load-bearing rather than incidental. The redundant +pass is a second visit to data the walk already touched, so its cost +sits against the walk's own per-token cost in a ratio fixed by the +sibling count alone — giving each function a richer vocabulary raises +both terms together. At 500 / 1 000 / 2 000 the regression fits +1.37-1.39 against a 1.07-1.09 baseline: a real 2.1x slowdown that the +1.5 bound passes. At 4 000 / 8 000 / 16 000 the gate reads **1.09** +clean and **2.17** with the per-child pass restored behind a local +patch — the only failing probe in that run, at 507.4 ms against +45.6 ms on the 16 000-sibling cell. Do not harmonise the two width +ladders; the shorter one cannot see this class. + Treat the linear bounds above as covering the walk's ancestor *chain* threading, not every `O(depth)` lookup in the crate. @@ -351,6 +383,7 @@ a walk's chain bookkeeping, not just around a change to its cost. The [halstead-climbs]: https://github.com/dekobon/big-code-analysis/issues/1096 [attribute-scan]: https://github.com/dekobon/big-code-analysis/issues/1100 [cfg-predicate]: https://github.com/dekobon/big-code-analysis/issues/1105 +[space-merge]: https://github.com/dekobon/big-code-analysis/issues/1106 The ten control probes are what make the other readings mean something. @@ -370,8 +403,8 @@ something. probe it sits next to, with the one node that triggers the walk removed. Before [#1084][parent-walk] each fitted near 1.0 where its counterpart fitted near 2.0, which is what attributed the quadratic - cost to that call rather than to nesting in general. Now that all - twenty-four fit near 1.0, the pair is what would localise a relapse: a + cost to that call rather than to nesting in general. Now that every + probe fits near 1.0, the pair is what would localise a relapse: a probe drifting up while its control holds means the ancestor lookup, not the shape. diff --git a/src/metrics/halstead.rs b/src/metrics/halstead.rs index 27ea772b5..bf8ed9bc1 100644 --- a/src/metrics/halstead.rs +++ b/src/metrics/halstead.rs @@ -146,9 +146,9 @@ impl Stats { // double-counting operators/operands they share. Cross-space // aggregation is instead done by unioning the occurrence maps // (`HalsteadMaps::merge`) and re-running `finalize` on the parent - // (see `spaces.rs`). Summing the finalized fields here — mirroring - // the sibling metrics' `merge` — would silently inflate every parent - // space's n1/n2/N1/N2. + // (see `spaces/compute.rs`). Summing the finalized fields here — + // mirroring the sibling metrics' `merge` — would silently inflate + // every parent space's n1/n2/N1/N2. pub(crate) fn merge(&mut self, _other: &Stats) {} /// Returns `η1`, the number of distinct operators diff --git a/src/metrics/mi.rs b/src/metrics/mi.rs index 8c9f2c6c4..eaad19903 100644 --- a/src/metrics/mi.rs +++ b/src/metrics/mi.rs @@ -51,10 +51,10 @@ impl fmt::Display for Stats { impl Stats { // Intentionally a no-op. MI is a derived metric: the parent space // recomputes it from its merged Loc / Cyclomatic / Halstead inputs - // (`compute_halstead_mi_and_wmc` in `spaces.rs`), so there is nothing - // to roll up from a child's finalized `Stats`. Combining the fields - // here would double-apply inputs already captured by the parent's - // recompute. (Same rationale as `halstead::Stats::merge`.) + // (`compute_halstead_and_mi` in `spaces/compute.rs`), so there is + // nothing to roll up from a child's finalized `Stats`. Combining + // the fields here would double-apply inputs already captured by the + // parent's recompute. (Same rationale as `halstead::Stats::merge`.) pub(crate) fn merge(&mut self, _other: &Stats) {} #[inline] diff --git a/src/spaces/compute.rs b/src/spaces/compute.rs index 6418c0477..0d5fd3928 100644 --- a/src/spaces/compute.rs +++ b/src/spaces/compute.rs @@ -10,8 +10,16 @@ use std::hash::BuildHasherDefault; use super::*; +/// Derives the two metrics that read from a space's *complete* state: +/// Halstead's `Stats` from the accumulated occurrence maps, and MI from +/// the resulting volume plus the space's final LOC and cyclomatic. +/// +/// Both are single-assignment — each call overwrites the previous +/// result rather than accumulating — so running this before a space has +/// absorbed all of its children is wasted work, not a partial sum. Only +/// [`finalize_state`] calls it, once per space (#1106). #[inline] -fn compute_halstead_mi_and_wmc(state: &mut State, selected: MetricSet) { +fn compute_halstead_and_mi(state: &mut State, selected: MetricSet) { if selected.contains(Metric::Halstead) { state .halstead_maps @@ -30,6 +38,19 @@ fn compute_halstead_mi_and_wmc(state: &mut State, selected: Metr &mut state.space.metrics.mi, ); } +} + +/// Records the space kind `wmc::Stats::merge` dispatches on, for the +/// kinds WMC recognises, plus the cumulative cyclomatic those kinds +/// contribute. +/// +/// Unlike [`compute_halstead_and_mi`] this must also run on a *parent* +/// before each child merges into it: `wmc::Stats::merge` routes the +/// child's contribution on `self.space_kind`, which stays `Unknown` +/// until this runs, and an `Unknown` parent silently drops every +/// method's cyclomatic from its class WMC. +#[inline] +fn compute_wmc(state: &mut State, selected: MetricSet) { if selected.contains(Metric::Wmc) { T::Wmc::compute( state.space.kind, @@ -112,16 +133,25 @@ fn compute_sum(state: &mut State, selected: MetricSet) { } } -/// Runs the four per-space finalization passes (min/max, sum, Halstead + -/// MI + WMC, averages) on a single [`State`]. Shared by both the +/// Runs the per-space finalization passes (min/max, sum, Halstead, MI, +/// WMC, averages) on a single [`State`]. Shared by both the /// single-element and pop arms of [`finalize`] so the call sequence stays -/// identical in both. The pop arm performs an *additional* Halstead -/// recompute on the parent after merging the child's maps — that extra -/// pass is intentionally left in [`finalize`], not folded in here. +/// identical in both, and reached exactly once per space — every state is +/// finalized either when it is popped or, for the root, in the +/// single-element arm. +/// +/// [`finalize`]'s pop arm additionally calls [`compute_wmc`] on the +/// *parent* before each child merges into it, because `wmc::Stats::merge` +/// dispatches on the parent's recorded `space_kind`. It deliberately does +/// **not** re-run [`compute_halstead_and_mi`] there: `halstead::Stats` and +/// `mi::Stats` both have no-op `merge`s, so nothing reads a parent's +/// intermediate Halstead/MI, and this call overwrites them from the final +/// maps anyway (#1106). fn finalize_state(state: &mut State, selected: MetricSet) { compute_minmax(state, selected); compute_sum(state, selected); - compute_halstead_mi_and_wmc::(state, selected); + compute_halstead_and_mi::(state, selected); + compute_wmc::(state, selected); compute_averages(state, selected); } @@ -146,7 +176,7 @@ fn finalize(state_stack: &mut Vec, diff_level: usize, sel .last_mut() .expect("invariant: state_stack has remaining elements after pop"); last_state.halstead_maps.merge(&state.halstead_maps); - compute_halstead_mi_and_wmc::(last_state, selected); + compute_wmc::(last_state, selected); // Merge function spaces last_state.space.metrics.merge(&state.space.metrics); From 98d108c7cfbfcacc9cbac54f761db2e944e31f06 Mon Sep 17 00:00:00 2001 From: Elijah Zupancic Date: Sat, 1 Aug 2026 02:55:37 -0700 Subject: [PATCH 19/36] fix(check): report when a named path overrides an exclude MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An explicitly named path overrides the walker's exclude deny-set by design — the ripgrep convention that a path you named is a direct request — but nothing said so. Any caller that names paths one at a time therefore analyzed files the project had put out of scope and reported them as offenders, which is what the shipped per-edit agent hooks do on every edit. Keep the override; make it visible and document it. `expand_seed_paths` now emits, for a named seed the deny-set covers: bca: warning: matches an exclude pattern () but was named explicitly; analyzing anyway naming the glob in the spelling the user configured, so the entry can be found and moved. It is silent for a seed no language claims, since `git diff --name-only | bca metrics --paths-from -` feeds in whole changesets where such files are the majority and produce no output either way. Naming the glob needs the pattern list alongside the compiled set, so `ExcludeGlobs` pairs them and `mk_globset_retaining` is the single place the compile-time pattern skip lives — the caller's original list is not index-aligned with the set. Fixes a second defect found while verifying the hooks' own invocation shape: `anchor_against_seeds` left an absolute explicitly-named seed unanchored, so a `./`-anchored `[check.exclude]` glob never matched it. Both hooks pass absolute paths, so the one exclude surface meant to survive an explicit path did not. It now falls back to `file_seed_match_path`, the same CWD-relative form the walk's include filter derives. The residual manifest-root-vs-cwd anchoring gap is tracked in #1164. Move this repo's own gate-scope exclusions out of `.bcaignore` and into `bca.toml`'s `[check] exclude`: `.claude`, `.opencode`, `utils`, `xtask`, `enums`, `generate-grammars`, and the benchmark harness. These were never "do not analyze this" — they are dev tooling that ships nothing and should not gate. Eleven of their files produced false offenders when named explicitly; none do now. `.bcaignore` keeps only walk-scope entries (build output, vendored trees, prose, the third-party corpora) and its header states the split. The book's "Explicit paths bypass the filter" section, the `--exclude` / `--exclude-from` / `--paths-from` long help, the agent-feedback recipe, and both hook scripts now state the rule: walker excludes shape what gets analyzed, check excludes shape what gets gated, and a per-file invocation only respects the second. `expand_seed_paths`'s three accumulators become a `SeedSet`, which states the dedupe invariant once instead of at each branch that feeds it; that also takes its halstead.effort from 46_430 to 30_844, clear of both gate tiers. `exemptions.rs`'s `run_json` now anchors its cwd at the fixture. It ran from the inherited repo root, so manifest discovery unioned this repository's new `[check] exclude` globs into its assertions (#491). Fixes #1146 --- .bcaignore | 26 +- .claude/hooks/bca-check.sh | 10 + .github/workflows/pages.yml | 14 +- .opencode/plugins/bca-check.js | 10 + Makefile | 6 +- bca.toml | 25 ++ big-code-analysis-book/src/commands/README.md | 59 +++- .../src/recipes/agent-feedback.md | 45 ++- big-code-analysis-cli/src/cli_args/mod.rs | 24 +- big-code-analysis-cli/src/path_io.rs | 64 +++- big-code-analysis-cli/src/walk.rs | 134 ++++++-- big-code-analysis-cli/src/walk_seed.rs | 20 +- big-code-analysis-cli/src/walk_tests.rs | 8 +- big-code-analysis-cli/tests/exemptions.rs | 8 +- .../tests/explicit_path_excludes.rs | 301 ++++++++++++++++++ man/bca-check.1 | 10 +- man/bca-count.1 | 10 +- man/bca-diff.1 | 10 +- man/bca-dump.1 | 10 +- man/bca-exemptions.1 | 10 +- man/bca-find.1 | 10 +- man/bca-functions.1 | 10 +- man/bca-init.1 | 10 +- man/bca-metrics.1 | 10 +- man/bca-ops.1 | 10 +- man/bca-preproc.1 | 10 +- man/bca-report.1 | 10 +- man/bca-strip-comments.1 | 10 +- man/bca-vcs.1 | 10 +- 29 files changed, 780 insertions(+), 114 deletions(-) create mode 100644 big-code-analysis-cli/tests/explicit_path_excludes.rs diff --git a/.bcaignore b/.bcaignore index f9a941da2..686f87298 100644 --- a/.bcaignore +++ b/.bcaignore @@ -4,6 +4,22 @@ # `.bca-baseline.toml` in sync — the baseline keys are sensitive to # which files the walker visits. # +# This file is *walk* scope: what gets analyzed at all. It is not the +# place to exempt something from the threshold gate — an explicitly +# named path overrides these globs by design (the ripgrep convention; +# see `commands/README.md`), so a per-file caller such as the agent +# hooks in `.claude/hooks/` and `.opencode/plugins/` bypasses every +# entry here and reports offenders in files `make self-scan` correctly +# ignores. Gate-scope exemptions belong in `bca.toml`'s +# `[check] exclude`, which survives an explicit path (#1146). +# +# So an entry earns its place here only when the files should never be +# measured: build output, vendored or generated trees, prose, and the +# large third-party corpora under `tests/repositories/`. Dev tooling we +# author but do not ship — `utils/`, `xtask/`, `enums/`, the benchmark +# harness, the editor hooks — moved to `[check] exclude` in #1146, +# because "do not gate this" was always the actual intent. +# # Patterns use the `./`-prefix convention. The walker re-anchors every # seed to that form (#488), so these match the same files regardless of # whether the root was spelled `.`, `$PWD`, or a manifest-resolved @@ -21,20 +37,10 @@ # test modules (#1066). It is not a sibling unit-test file, so the # `*_tests.rs` glob does not reach it, and none of it ships. ./**/test_support.rs -./enums/** -./.opencode/** ./target/** ./big-code-analysis-book/** ./docs/** ./packaging/** -./utils/** -./xtask/** -# Dev tooling, same treatment as ./xtask/**: the benchmark harness -# (#1068) ships nothing, and its report formatters are long runs of -# `writeln!(...)?` whose `nexits` counts say nothing about production -# maintainability. -./big-code-analysis-bench/** -./generate-grammars/** ./man/** ./abuild-keys/** ./**/examples/** diff --git a/.claude/hooks/bca-check.sh b/.claude/hooks/bca-check.sh index fb62d3a88..626d89bf6 100755 --- a/.claude/hooks/bca-check.sh +++ b/.claude/hooks/bca-check.sh @@ -8,6 +8,16 @@ # model. Stays silent (exit 0) on a clean file, an unsupported file # type, a tool error, or a missing analyzer — it must never block an # edit. +# +# Scope note (#1146): this passes ONE named path, and a named path +# overrides every walker exclude by design — `.bcaignore`, `--exclude`, +# and the manifest `exclude` list all shape directory-walk scope, not +# gate scope. Files that must never be gated therefore belong in +# bca.toml's `[check] exclude`, which survives an explicit path. This +# repo's dev-tooling globs live there for that reason; `bca` warns on +# stderr, naming the glob, whenever a named path overrides a walker +# exclude, so a miswired entry shows up here rather than silently +# producing false offenders. set -euo pipefail root="${CLAUDE_PROJECT_DIR:-$PWD}" diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index b65aba25f..cf8b2c5c7 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -189,13 +189,19 @@ jobs: contents: read security-events: write actions: read - # The workspace-minus-vendored exclude list lives in `.bcaignore` - # at the repo root and is wired in via `exclude_from` in the - # auto-discovered `bca.toml` manifest, so the invocations below - # pick it up without an explicit `--exclude-from` flag. If you + # The walk deny-set — build output, vendored trees, prose, and the + # third-party corpora under `tests/repositories/` — lives in + # `.bcaignore` at the repo root and is wired in via `exclude_from` + # in the auto-discovered `bca.toml` manifest, so the invocations + # below pick it up without an explicit `--exclude-from` flag. If you # edit `.bcaignore`, also refresh `.bca-baseline.toml` in the same # commit — the baseline keys are sensitive to which files the # walker actually visits. + # + # The manifest's `[check] exclude` is a separate, gate-only list + # (#1146): the dev-tooling trees named there are walked, so they + # appear in the reports and hotspot tables published below, and are + # exempt only from the threshold gate step. steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: diff --git a/.opencode/plugins/bca-check.js b/.opencode/plugins/bca-check.js index 04c16ce7e..719fd0531 100644 --- a/.opencode/plugins/bca-check.js +++ b/.opencode/plugins/bca-check.js @@ -17,6 +17,16 @@ // guidance text from .claude/hooks/bca-guidance.txt so the two hooks // never drift. Both behaviours go beyond the book's bare-`bca` + // inlined-string example (see the note at the bottom of this file). +// +// Scope note (#1146): this passes ONE named path, and a named path +// overrides every walker exclude by design — `.bcaignore`, `--exclude`, +// and the manifest `exclude` list all shape directory-walk scope, not +// gate scope. Files that must never be gated therefore belong in +// bca.toml's `[check] exclude`, which survives an explicit path. This +// repo's dev-tooling globs live there for that reason (this file +// included); `bca` warns on stderr, naming the glob, whenever a named +// path overrides a walker exclude, so a miswired entry surfaces here +// rather than silently producing false offenders. import { existsSync, readFileSync, accessSync, constants } from "node:fs" import { resolve, sep } from "node:path" diff --git a/Makefile b/Makefile index 69ed4ffa0..934325253 100644 --- a/Makefile +++ b/Makefile @@ -602,7 +602,11 @@ self-scan-write-baseline-headroom: # over the default 12mo / 90d windows. Informational only — it never # gates (always exits 0). Path selection and the `.bcaignore` deny-set # come from the auto-discovered `bca.toml` manifest, exactly like -# `make self-scan`, so the file universe matches the threshold gate. +# `make self-scan`. The two file universes are close but not identical: +# `.bcaignore` shapes what is *walked* and both commands honour it, +# whereas `[check] exclude` shapes what is *gated* and only `check` +# consults it — so the dev-tooling trees exempted there (#1146) are +# ranked here and absent from the gate. # `BCA_VCS_TOP` overrides the row cap (default 40; 0 = all). This prints # the quick terminal table; for the styled, sortable page (published to # Pages by the `pages.yml` workflow, issue #573) run diff --git a/bca.toml b/bca.toml index 2d54044ef..b10b1aedf 100644 --- a/bca.toml +++ b/bca.toml @@ -47,6 +47,31 @@ exclude_tests = true [check] baseline = ".bca-baseline.toml" +# Dev tooling this repo authors but does not ship. These are analysed — +# they show up in `bca report`, and `bca metrics` measures them like any +# other source — but they never gate. That distinction is the whole +# reason they live here rather than in `.bcaignore` (#1146): a walker +# exclude shapes what gets *analysed* and is overridden by an +# explicitly-named path, so the per-file agent hooks in `.claude/hooks/` +# and `.opencode/plugins/` bypassed it and reported offenders in files +# `make self-scan` correctly ignores. A `[check] exclude` glob survives +# an explicit path, which is what "never gate this" has to mean. +# +# The exempted code is real, and some of it is genuinely complex: the +# `utils/` gate scripts are argument-parsing `main`s, `enums/`'s +# `sanitize_identifier` is a character-class table, and the benchmark +# harness's formatters are long runs of `writeln!(...)?` whose `nexits` +# says nothing about maintainability. None of it ships to a user. +exclude = [ + "./.claude/**", + "./.opencode/**", + "./big-code-analysis-bench/**", + "./enums/**", + "./generate-grammars/**", + "./utils/**", + "./xtask/**", +] + # bca metric threshold configuration. # # These limits are this repository's own calibration and are expected to diff --git a/big-code-analysis-book/src/commands/README.md b/big-code-analysis-book/src/commands/README.md index 78ed6b3bc..ed5214677 100644 --- a/big-code-analysis-book/src/commands/README.md +++ b/big-code-analysis-book/src/commands/README.md @@ -276,13 +276,52 @@ gets the same treatment as a fresh `git clone`. Hidden files (those whose basename starts with `.`) are filtered during the walk, matching the previous behavior. -### Explicit paths bypass the filter - -Files passed by name — via `--paths` or `--paths-from` — are always -analyzed, even when they would be excluded by `.gitignore`. This makes -it safe to do `bca metrics --paths-from -` from `git diff ---name-only`-style pipelines without losing files that happen to be -covered by a wildcard ignore rule. +### Explicit paths bypass the filter {#explicit-paths-bypass-the-filter} + +Files passed by name — via `--paths`, `--paths-from`, or a trailing +positional path — are always analyzed, even when the project's ignore +rules cover them. This makes it safe to do `bca metrics --paths-from -` +from `git diff --name-only`-style pipelines without losing files that +happen to match a wildcard ignore rule. + +The override is deliberate, and it is the same rule `rg` and `fd` +apply: a path you named is a direct request. It spans every deny-set +the walk consults — `.gitignore` and friends, `-X` / `--exclude`, +`--exclude-from`, a `.bcaignore`, and a manifest `exclude` list alike. +`rg --glob '!x.txt' pattern x.txt` searches `x.txt` for exactly this +reason. + +Two boundaries on it: + +- **`-I` / `--include` still applies.** The allow-list narrows *which* + named files are analyzed, so `bca metrics -I '*.rs' notes.md` analyzes + nothing. Only the deny-sets are overridden. +- **`[check] exclude` still applies.** The gate-exemption set + (`--check-exclude` / `--check-exclude-from`, or `exclude` under + `[check]` in `bca.toml`) is not a walk filter — it drops *violations*, + not files — so it survives an explicit path and reports `bca: skipped + N violations via [check.exclude]` when it fires. One caveat while + [#1164](https://github.com/dekobon/big-code-analysis/issues/1164) is + open: for an explicitly named path those globs resolve against the + working directory rather than the manifest root, so run `bca` from the + directory holding `bca.toml` — which is what CI and the agent hooks + already do. + +That second point is the one to reach for. A walker exclude shapes what +gets **analyzed**; a check exclude shapes what gets **gated**. Anything +you want kept out of the threshold gate permanently — dev tooling, +generated code you still want measured, a subtree under active +rewrite — belongs in `[check] exclude`, because any caller that names +paths one at a time bypasses the walker excludes by design. Per-file +callers are not hypothetical: that is the shape the +[agent feedback hooks](../recipes/agent-feedback.md) use on every edit. + +When an explicitly named path does override a walker exclude, `bca` +says so on stderr and names the glob: + +```text +bca: warning: utils/gate.py matches an exclude pattern (./utils/**) but was named explicitly; analyzing anyway +``` ### Path discovery flags @@ -290,8 +329,10 @@ covered by a wildcard ignore rule. awareness when expanding directory seeds. - `--paths-from ` — read newline-separated input paths from ``, or from stdin when `` is `-`. Combined as a union - with any `--paths` values; `-I` / `-X` globs still apply. Blank - lines are skipped; `#` is treated as a path character (not a + with any `--paths` values. `-I` globs still apply; `-X` globs do not + reach an entry that names a file directly, per + [Explicit paths bypass the filter](#explicit-paths-bypass-the-filter). + Blank lines are skipped; `#` is treated as a path character (not a comment). To pass a file literally named `-`, write `./-`. - `--exclude-from ` — read newline-separated `--exclude` glob patterns from ``, or from stdin when `` is `-`. diff --git a/big-code-analysis-book/src/recipes/agent-feedback.md b/big-code-analysis-book/src/recipes/agent-feedback.md index 9982f4be0..07940840c 100644 --- a/big-code-analysis-book/src/recipes/agent-feedback.md +++ b/big-code-analysis-book/src/recipes/agent-feedback.md @@ -21,7 +21,9 @@ an agent everything it needs: too complex?" without parsing anything. - Baseline filtering, in-source suppression markers, and `[check.exclude]` globs, so the signal an agent sees is the same - ratcheted signal a human sees. + ratcheted signal a human sees — provided the project's exclusions + live under [`[check]`](#put-the-hooks-exclusions-under-check) rather + than in a walker deny-set. What was missing is the wiring. This page is that wiring: a copy-pasteable feedback loop per tool, plus the agent-facing guidance @@ -68,6 +70,47 @@ the `--threshold` flags are unnecessary — a bare `bca check ` reads the committed limits, baseline, and excludes, so the agent loop gates on exactly what CI gates on. +### Put the hook's exclusions under `[check]` {#put-the-hooks-exclusions-under-check} + +One thing does *not* carry over from the CI invocation, and it will +produce false positives if you skip it. A hook names one file per run, +and [an explicitly named path overrides every walker +exclude](../commands/README.md#explicit-paths-bypass-the-filter) — the +`rg` convention that a path you named is a direct request. So a file +your project keeps out of scope with `-X`, `--exclude-from`, a +`.bcaignore`, or a manifest `exclude` list is nonetheless analyzed the +moment the hook passes it by name, and reported as an offender under an +`exit 2` that frames it as a problem to address. + +Walker excludes shape what gets **analyzed**. Check excludes shape what +gets **gated**. A per-file hook invocation only respects the second: + +```toml +# bca.toml — survives an explicit path, so the hook sees what CI sees. +[check] +exclude = ["./utils/**", "./benches/**"] +``` + +`bca` warns on stderr whenever an explicitly named path overrides a +walker exclude, naming the glob, so the miswiring is visible rather than +silent: + +```text +bca: warning: utils/gate.py matches an exclude pattern (./utils/**) but was named explicitly; analyzing anyway +``` + +Treat that line as a to-do: the entry it names wants moving to +`[check] exclude`. This repository moved its own dev-tooling globs there +for exactly this reason. + +One constraint on where the hook runs: while +[#1164](https://github.com/dekobon/big-code-analysis/issues/1164) is +open, a `[check] exclude` glob resolves against the *working directory* +rather than the manifest root when the path is named explicitly. Both +hooks below inherit the agent's working directory, which is the project +root, so they are unaffected — but a hook that `cd`s into a subdirectory +first would see its exemptions stop matching. + ## Claude Code **Mechanism:** a diff --git a/big-code-analysis-cli/src/cli_args/mod.rs b/big-code-analysis-cli/src/cli_args/mod.rs index 09fce2d43..5b236c30e 100644 --- a/big-code-analysis-cli/src/cli_args/mod.rs +++ b/big-code-analysis-cli/src/cli_args/mod.rs @@ -104,6 +104,15 @@ pub(crate) struct WalkSelectionArgs { /// CLI `--exclude` never silently un-excludes a directory the /// project config deliberately skipped. Pass `--no-config` to ignore /// the manifest entirely. + /// + /// Shapes directory-walk scope only: a file named directly on the + /// command line overrides every exclude glob and is analyzed anyway + /// (the ripgrep/fd convention), with a warning on stderr naming the + /// glob it overrode. `-I` / `--include` is not overridden. To exempt + /// something from `bca check`'s threshold gate whichever way it is + /// named, use `--check-exclude` / `[check] exclude` instead. + // The per-file agent hooks in the book's agent-feedback recipe are + // the caller this bit exists for; see #1146. #[clap(long, short = 'X', num_args(1), action = clap::ArgAction::Append, help_heading = "Input selection")] pub(crate) exclude: Vec, /// Force a language instead of inferring from extension. Accepts a @@ -122,10 +131,12 @@ pub(crate) struct WalkSelectionArgs { #[clap(long, help_heading = "Input selection")] pub(crate) no_skip_generated: bool, /// Read newline-separated input paths from a file. Use `-` to read - /// from stdin. Combined as a union with any `--paths` values; globs - /// still apply. Blank lines are skipped; `#` is treated as a path - /// character (not a comment). To pass a file literally named `-`, - /// use `./-`. + /// from stdin. Combined as a union with any `--paths` values. + /// `--include` globs still apply; `--exclude` globs do not reach an + /// entry that names a file directly, since an explicitly-named path + /// overrides the deny-set. Blank lines are skipped; `#` is treated + /// as a path character (not a comment). To pass a file literally + /// named `-`, use `./-`. #[clap(long = "paths-from", value_parser, help_heading = "Input selection")] pub(crate) paths_from: Option, /// Read additional `--exclude` glob patterns from a file (one per @@ -135,6 +146,11 @@ pub(crate) struct WalkSelectionArgs { /// Patterns are unioned with any `--exclude` values into a single /// deny-set; order does not matter. Convention is a `.bcaignore` /// at the repo root, mirroring `.gitignore` / `.dockerignore`. + /// + /// Carries the same scope as `--exclude`: these patterns shape the + /// directory walk, and a file named directly on the command line + /// overrides them (with a warning naming the glob). Put gate-only + /// exemptions in `--check-exclude-from` / `[check] exclude`. #[clap(long = "exclude-from", value_parser, help_heading = "Input selection")] pub(crate) exclude_from: Option, /// Disable `.gitignore` / `.ignore` / global gitignore awareness diff --git a/big-code-analysis-cli/src/path_io.rs b/big-code-analysis-cli/src/path_io.rs index 86dd1b7ff..f6cc3f0d4 100644 --- a/big-code-analysis-cli/src/path_io.rs +++ b/big-code-analysis-cli/src/path_io.rs @@ -98,31 +98,78 @@ pub(crate) fn write_output_or_stdout(output: Option<&Path>, verb: &str, bytes: & } pub(crate) fn mk_globset(elems: Vec) -> Result { + mk_globset_retaining(elems).map(|(set, _)| set) +} + +/// [`mk_globset`] plus the subset of `elems` it actually compiled, in +/// compile order, so a `GlobSet::matches` index names the pattern the +/// user wrote. The skip below drops patterns, so the caller's original +/// `Vec` is *not* index-aligned with the set — deriving the retained +/// list anywhere but inside this loop would misattribute every pattern +/// after the first dropped one. +fn mk_globset_retaining(elems: Vec) -> Result<(GlobSet, Vec), String> { if elems.is_empty() { - return Ok(GlobSet::empty()); + return Ok((GlobSet::empty(), Vec::new())); } let mut globset = GlobSetBuilder::new(); - for e in &elems { + let mut retained = Vec::with_capacity(elems.len()); + for e in elems { // Normalise the optional leading `./` so `dir/**` and `./dir/**` // compile to the same glob; the match-path side is stripped // symmetrically in `WalkFilters::passes` / the `[check.exclude]` // filter (#726). The emptiness skip runs *after* the strip so a // bare `./` (empty once normalised) is skipped like an empty // pattern instead of compiling an empty glob. - let pattern = walk_seed::strip_dot_slash(e); + let pattern = walk_seed::strip_dot_slash(&e); if pattern.is_empty() { continue; } globset .add(Glob::new(pattern).map_err(|err| format!("invalid glob pattern {e:?}: {err}"))?); + retained.push(e); } - globset + let set = globset .build() - .map_err(|err| format!("failed to build glob set: {err}")) + .map_err(|err| format!("failed to build glob set: {err}"))?; + Ok((set, retained)) +} + +/// An exclude deny-set paired with the source spelling of each pattern +/// it was compiled from, so a match can be reported *by name* rather +/// than as an anonymous "something excluded this". +/// +/// The pairing comes from [`mk_globset_retaining`], which is the only +/// place the compile-time pattern skip lives, so the two halves cannot +/// drift out of index alignment. +pub(crate) struct ExcludeGlobs { + set: GlobSet, + patterns: Vec, +} + +impl ExcludeGlobs { + pub(crate) fn is_empty(&self) -> bool { + self.set.is_empty() + } + + pub(crate) fn is_match(&self, path: impl AsRef) -> bool { + self.set.is_match(path) + } + + /// The first configured pattern `path` matches, or `None`. + /// + /// One pattern rather than all of them: the caller reports an + /// override the user is expected to *act* on by moving the entry to + /// another surface, and naming the first offender is enough to find + /// the line. Enumerating every overlapping glob would lengthen the + /// line without changing the action. + pub(crate) fn first_match(&self, path: impl AsRef) -> Option<&str> { + let idx = *self.set.matches(path).first()?; + self.patterns.get(idx).map(String::as_str) + } } -/// Build an exclude [`GlobSet`] from inline `patterns` unioned with any +/// Build an [`ExcludeGlobs`] from inline `patterns` unioned with any /// read from the `from` file (`.gitignore`-style, `-` for stdin). Dies /// (exit 1) on a file-read or glob-compile error. Shared by the walker's /// `--exclude` / `--exclude-from` deny-set and `bca check`'s @@ -135,11 +182,12 @@ pub(crate) fn build_exclude_globset( mut patterns: Vec, from: Option<&Path>, flag: &str, -) -> GlobSet { +) -> ExcludeGlobs { if let Some(src) = from { patterns.extend(read_exclude_patterns_from(src, flag).unwrap_or_else(|e| die(e))); } - mk_globset(patterns).unwrap_or_else(|e| die(e)) + let (set, patterns) = mk_globset_retaining(patterns).unwrap_or_else(|e| die(e)); + ExcludeGlobs { set, patterns } } /// Group a resolved file list by basename into the diff --git a/big-code-analysis-cli/src/walk.rs b/big-code-analysis-cli/src/walk.rs index 962d0004c..20176f34b 100644 --- a/big-code-analysis-cli/src/walk.rs +++ b/big-code-analysis-cli/src/walk.rs @@ -103,7 +103,13 @@ pub(crate) fn valid_languages() -> String { /// convention (empty globset = no-op) with the patterns it applies to. pub(crate) struct WalkFilters<'a> { include: &'a GlobSet, - exclude: &'a GlobSet, + exclude: &'a ExcludeGlobs, + /// Whether `--language` forces a language, which makes every named + /// file analyzable regardless of its extension. Only the + /// exclude-override warning consults it — see + /// [`Self::warn_exclude_overridden`] for why a filter bundle knows + /// about languages at all. + language_forced: bool, } impl WalkFilters<'_> { @@ -130,6 +136,52 @@ impl WalkFilters<'_> { let match_path = walk_seed::strip_cur_dir(match_path); self.include.is_empty() || self.include.is_match(match_path) } + + /// Report on stderr that the explicitly-named `seed` overrode the + /// exclude deny-set, naming the pattern it matched. + /// + /// This is the gap between [`Self::includes`] and [`Self::passes`] + /// made visible (#1146). The override is deliberate, but it used to + /// be silent, so a file the project had put out of scope came back + /// as a `bca check` offender for any caller that named paths one at + /// a time — the shape the per-edit agent hooks use. The wording sits + /// beside `bca check`'s `bca: skipped N violations via + /// [check.exclude]` so the two read as one family. + /// + /// Lives here rather than at the call site because the pattern + /// spelling is this type's to know, and because the message is the + /// only reason `expand_seed_paths` would need it. + /// + /// Silent for a seed no language claims, which is why this consults + /// the language table at all: the advertised `git diff --name-only | + /// bca metrics --paths-from -` pipeline feeds in whole changesets, + /// where lockfiles, Markdown, and generated assets are the majority + /// and produce no output either way. Warning that such a file is + /// being "analyzed anyway" would be both noisy and untrue. + fn warn_exclude_overridden(&self, seed: &Path, match_path: &Path) { + if !self.language_forced && !seed_has_known_language(seed) { + return; + } + if let Some(glob) = self + .exclude + .first_match(walk_seed::strip_cur_dir(match_path)) + { + eprintln!( + "bca: warning: {} matches an exclude pattern ({glob}) \ + but was named explicitly; analyzing anyway", + seed.display() + ); + } + } +} + +/// Does `seed`'s extension map to a supported language? Mirrors the +/// per-file dispatch's own extension lookup, which is what decides +/// whether a walked file is analyzed or silently skipped. +fn seed_has_known_language(seed: &Path) -> bool { + seed.extension() + .and_then(|ext| ext.to_str()) + .is_some_and(|ext| get_from_ext(ext).is_some()) } /// Resolved file set plus the subset of seeds that were *explicitly @@ -231,6 +283,48 @@ pub(crate) fn seed_kind(seed: &Path) -> std::io::Result { }) } +/// The resolved file set under construction, plus the dedupe index that +/// keeps overlapping seeds (`--paths src --paths src/lib.rs`, or two +/// seeds whose trees intersect) from contributing a file twice (#704). +/// +/// A type rather than three locals in [`expand_seed_paths`] because the +/// dedupe is an invariant of the *set*, not of either branch that feeds +/// it: nothing reaches `files` without first passing `seen`. Stated once +/// here, it cannot be half-applied by a future third feeder — the file +/// branch and the walk branch previously restated it separately. +#[derive(Default)] +struct SeedSet { + files: Vec, + seen: std::collections::HashSet, + explicit_files: std::collections::HashSet, +} + +impl SeedSet { + /// Admit an explicitly-named file seed, recording it in + /// `explicit_files` so the per-file dispatch can tell it from a + /// directory-expansion product: an explicitly-named file with an + /// unrecognized language must warn and may exit 1 (#663), whereas a + /// walked one stays silently skipped. + /// + /// Returns whether the seed was newly admitted, so the caller can + /// scope a one-shot per-seed diagnostic to its first mention. + fn push_explicit(&mut self, seed: &Path) -> bool { + if !self.seen.insert(seed.to_path_buf()) { + return false; + } + self.explicit_files.insert(seed.to_path_buf()); + self.files.push(seed.to_path_buf()); + true + } + + /// Admit a file discovered by expanding a directory seed. + fn push_walked(&mut self, path: PathBuf) { + if self.seen.insert(path.clone()) { + self.files.push(path); + } + } +} + pub(crate) fn expand_seed_paths( mut paths: Vec, paths_from: Option, @@ -253,13 +347,7 @@ pub(crate) fn expand_seed_paths( if paths.is_empty() { paths.push(PathBuf::from(".")); } - let mut out: Vec = Vec::new(); - // Track which emitted paths we have already pushed so overlapping - // seeds (`--paths src --paths src/lib.rs`, or two seeds whose trees - // intersect) contribute each file exactly once. Without this a file - // reachable from two seeds was analyzed and counted twice (#704). - let mut seen: std::collections::HashSet = std::collections::HashSet::new(); - let mut explicit_files: std::collections::HashSet = std::collections::HashSet::new(); + let mut found = SeedSet::default(); let mut walk_errors = WalkErrors::default(); for seed in paths.into_iter().map(walk_seed::reanchor_seed) { // Classify the seed *without* following a final symlink so the @@ -294,26 +382,17 @@ pub(crate) fn expand_seed_paths( // seed's CWD-relative form so `--include 'src/**'` treats // `--paths "$PWD/src/f.rs"` and `--paths src/f.rs` alike. let include_form = walk_seed::file_seed_match_path(&seed); - if filters.includes(&include_form) && seen.insert(seed.clone()) { - // Record the explicit seed (in its emitted form) so the - // per-file dispatch can distinguish it from a - // directory-expansion product: an explicitly-named file - // with an unrecognized language must warn + may exit 1 - // (#663), whereas a walked one stays silently skipped. - explicit_files.insert(seed.clone()); - out.push(seed); + // Gated on the admission so an overlapping seed warns once + // rather than per mention (#1146). + if filters.includes(&include_form) && found.push_explicit(&seed) { + filters.warn_exclude_overridden(&seed, &include_form); } continue; } + // The dedupe spans every seed, so it belongs to `found` rather + // than to one seed's walk — a single walk cannot see the others. for path in walk_directory_seed(&seed, no_ignore, threads, filters, &mut walk_errors) { - // Overlapping seeds (`--paths src --paths src/lib.rs`, or - // two seeds whose trees intersect) must contribute each file - // exactly once (#704). The dedupe lives here, with the - // caller that owns `seen` across every seed — the walk of a - // single seed cannot see the others. - if seen.insert(path.clone()) { - out.push(path); - } + found.push_walked(path); } } // A walk that resolved zero files is almost always a mistake — an @@ -322,12 +401,12 @@ pub(crate) fn expand_seed_paths( // bare `bca metrics` in an empty tree is not silently a no-op (#596). // Non-gate commands still exit 0; `check` layers its own hard error // (`no input files matched`) on top for CI safety. - if out.is_empty() { + if found.files.is_empty() { warn("0 files matched"); } ResolvedFiles { - files: out, - explicit_files, + files: found.files, + explicit_files: found.explicit_files, walk_errors, } } @@ -466,6 +545,7 @@ pub(crate) fn resolve_walk_files(globals: GlobalOpts) -> (ResolvedFiles, usize) let filters = WalkFilters { include: &include, exclude: &exclude, + language_forced: globals.language.is_some(), }; let resolved = expand_seed_paths( globals.paths, diff --git a/big-code-analysis-cli/src/walk_seed.rs b/big-code-analysis-cli/src/walk_seed.rs index 844f5e12e..4967e9efb 100644 --- a/big-code-analysis-cli/src/walk_seed.rs +++ b/big-code-analysis-cli/src/walk_seed.rs @@ -222,9 +222,21 @@ pub(crate) fn match_path_for(seed: &std::path::Path, path: &std::path::Path) -> /// so have lost the per-seed association `match_path_for` relies on. /// /// A `seed` equal to `path` (a single explicit file `--paths`) does not -/// anchor — the walk's file-seed branch matches it as spelled — so that -/// seed is skipped and a later *directory* seed that contains the path -/// may still anchor it. A `path` no seed contains is returned unchanged. +/// anchor here — the walk's file-seed branch matches it as spelled — so +/// that seed is skipped and a later *directory* seed that contains the +/// path may still anchor it. When none does, the path falls back to +/// [`file_seed_match_path`], the same CWD-relative form the walk's own +/// include filter derives for a file seed; a path outside the CWD keeps +/// its absolute form, the only stable identity for it. +/// +/// That fallback is load-bearing (#1146). `bca check "$PWD/f.js"` — the +/// shape both shipped per-edit agent hooks use — reaches here with the +/// file seed as its own only seed, so before the fallback the absolute +/// path was matched verbatim and a `./`-anchored `[check.exclude]` glob +/// never fired. `[check] exclude` is the one exclude surface an +/// explicitly-named path does *not* override, so it has to hold for +/// every spelling of that path. +/// /// `seeds` must already be [`reanchor_seed`]-normalised (the form the /// walk emitted). pub(crate) fn anchor_against_seeds(seeds: &[PathBuf], path: &std::path::Path) -> PathBuf { @@ -236,7 +248,7 @@ pub(crate) fn anchor_against_seeds(seeds: &[PathBuf], path: &std::path::Path) -> // path == seed (file seed) or not under it: try the next seed. _ => None, }) - .unwrap_or_else(|| path.to_path_buf()) + .unwrap_or_else(|| file_seed_match_path(path)) } #[cfg(test)] diff --git a/big-code-analysis-cli/src/walk_tests.rs b/big-code-analysis-cli/src/walk_tests.rs index a6d437baa..1f83a81c1 100644 --- a/big-code-analysis-cli/src/walk_tests.rs +++ b/big-code-analysis-cli/src/walk_tests.rs @@ -38,10 +38,12 @@ fn walk_directory_seed_returns_sorted_paths() { } } - let empty = mk_globset(Vec::new()).expect("empty globset"); + let empty_include = mk_globset(Vec::new()).expect("empty globset"); + let empty_exclude = build_exclude_globset(Vec::new(), None, "--exclude-from"); let filters = WalkFilters { - include: &empty, - exclude: &empty, + include: &empty_include, + exclude: &empty_exclude, + language_forced: false, }; let mut errors = WalkErrors::default(); let found = walk_directory_seed(root, true, 8, &filters, &mut errors); diff --git a/big-code-analysis-cli/tests/exemptions.rs b/big-code-analysis-cli/tests/exemptions.rs index 8662dc4a7..b85aa97ab 100644 --- a/big-code-analysis-cli/tests/exemptions.rs +++ b/big-code-analysis-cli/tests/exemptions.rs @@ -42,7 +42,13 @@ fn marker_fixture() -> TempDir { fn run_json(dir: &TempDir, extra: &[&str]) -> Value { let assert = { - let mut cmd = cli(); + // Anchor the cwd at the fixture, not the inherited repo root: + // manifest discovery climbs from the *cwd*, not from `--paths`, + // so the fixture's `.git` marker only halts discovery once the + // process is standing in it (#491). Left at the repo root, these + // runs unioned this repository's own `[check] exclude` globs + // into every `excludes` assertion below. + let mut cmd = common::cli_in(dir.path()); cmd.args([ "exemptions", "--paths", diff --git a/big-code-analysis-cli/tests/explicit_path_excludes.rs b/big-code-analysis-cli/tests/explicit_path_excludes.rs new file mode 100644 index 000000000..c25ef7548 --- /dev/null +++ b/big-code-analysis-cli/tests/explicit_path_excludes.rs @@ -0,0 +1,301 @@ +//! Integration tests for the explicitly-named-path exclude rule (#1146). +//! +//! A path named directly on the command line overrides the walker's +//! deny-set — `--exclude`, `--exclude-from`, `.bcaignore`, and a +//! manifest `exclude` list alike — matching the ripgrep convention that +//! an explicit path is a direct request. That is deliberate, but it used +//! to be silent, so any caller that names paths one at a time (the +//! shipped per-edit agent hooks) reported offenders in files the project +//! had put out of scope. +//! +//! Three contracts are pinned here, and the asymmetry between them is +//! the point — a future change must not quietly unify them: +//! +//! | surface | overridden by an explicit path? | +//! | --- | --- | +//! | `exclude` / `exclude_from` / `.bcaignore` | yes, with a warning | +//! | `-I` / `--include` | no | +//! | `[check] exclude` | no | + +use std::fs; +use std::path::Path; + +use assert_cmd::Command; +use predicates::prelude::*; +use tempfile::TempDir; + +mod common; + +fn cli(dir: &Path) -> Command { + common::cli_in(dir) +} + +/// Cyclomatic == 4 (three decision points plus one), so a +/// `--threshold cyclomatic=1` run always finds it. The function name +/// embeds its file so an assertion can say *which* offender survived. +fn branchy(fn_name: &str) -> String { + format!( + "pub fn {fn_name}(n: i32) -> i32 {{ if n < 0 {{ 1 }} else if n == 0 {{ 2 }} \ + else if n < 10 {{ 3 }} else {{ 4 }} }}\n" + ) +} + +/// Two offenders — `skipme/a.rs`, which every exclude below covers, and +/// `kept.rs`, which none of them do — plus a `bca.toml` carrying +/// `exclude_body` (an `exclude` key for the walker deny-set, a +/// `[check] exclude` table for the gate-exemption set, or nothing). +/// +/// `kept.rs` exists so the directory-walk case can assert a *positive*: +/// without a surviving offender, "the excluded file is absent" is +/// indistinguishable from a run that found nothing at all. The `.git` +/// marker halts manifest discovery here rather than at the repo root +/// (#491). +fn fixture(exclude_body: &str) -> TempDir { + let dir = TempDir::new().unwrap(); + fs::create_dir(dir.path().join(".git")).unwrap(); + fs::create_dir(dir.path().join("skipme")).unwrap(); + fs::write( + dir.path().join("skipme").join("a.rs"), + branchy("skipme_offender"), + ) + .unwrap(); + fs::write(dir.path().join("kept.rs"), branchy("kept_offender")).unwrap(); + fs::write( + dir.path().join("bca.toml"), + format!("paths = [\".\"]\n{exclude_body}\n[thresholds]\ncyclomatic = 1\n"), + ) + .unwrap(); + dir +} + +/// The walker deny-set does not reach a path the caller named, so the +/// offender is still reported — and the override is announced on stderr +/// naming the exact glob it overrode, in the glob's configured spelling +/// so the reader can find the line to edit. +#[test] +fn explicit_path_overrides_manifest_exclude_and_warns_naming_the_glob() { + let dir = fixture("exclude = [\"./skipme/**\"]\n"); + + cli(dir.path()) + .args(["check", "skipme/a.rs", "--no-summary", "--no-remediation"]) + .assert() + .code(2) + .stderr(predicate::str::contains("skipme_offender")) + .stderr(predicate::str::contains( + "bca: warning: skipme/a.rs matches an exclude pattern (./skipme/**) \ + but was named explicitly; analyzing anyway", + )); +} + +/// The counterpart: the same manifest, reached through the *walk*, +/// excludes the file outright. Without this the test above could pass +/// against a build where `exclude` did nothing at all. +/// +/// `kept_offender` is the positive half. Asserting only the absence of +/// `skipme_offender` would hold for a run that resolved no files, or one +/// that failed before analysing anything. +#[test] +fn same_manifest_exclude_still_drops_the_file_on_a_directory_walk() { + let dir = fixture("exclude = [\"./skipme/**\"]\n"); + + cli(dir.path()) + .args(["check", "--no-summary", "--no-remediation"]) + .assert() + .code(2) + .stderr(predicate::str::contains("kept_offender")) + .stderr(predicate::str::contains("skipme_offender").not()) + // No override happened, so no warning — the diagnostic must not + // fire for files the walk selected. + .stderr(predicate::str::contains("named explicitly").not()); +} + +/// The `-X` / `--exclude` CLI surface behaves as the manifest key does, +/// and the reported glob is the *matching* pattern rather than whichever +/// one happens to sit first. +/// +/// The pattern list is deliberately shaped so every wrong lookup names +/// a *different* glob, which no single-pattern fixture can detect: +/// +/// | index | caller's list | compiled deny-set | +/// | --- | --- | --- | +/// | 0 | `./` (drops — empty once normalised) | `kept.rs` | +/// | 1 | `kept.rs` | `./skipme/**` | +/// | 2 | `./skipme/**` | — | +/// +/// The match is at deny-set index 1. Reading the caller's original list +/// at that index yields `kept.rs` — the off-by-one-per-dropped-pattern +/// misattribution `mk_globset_retaining` exists to prevent — and simply +/// taking the first configured pattern yields `kept.rs` too. +#[test] +fn override_warning_names_the_matching_glob_not_a_neighbour() { + let dir = fixture(""); + + cli(dir.path()) + .args([ + "check", + "skipme/a.rs", + "-X", + "./", + "-X", + "kept.rs", + "-X", + "./skipme/**", + "--no-summary", + "--no-remediation", + ]) + .assert() + .code(2) + .stderr(predicate::str::contains( + "matches an exclude pattern (./skipme/**)", + )); +} + +/// The third walker surface: a `.bcaignore`-style file reached through +/// `--exclude-from`. It unions into the same deny-set, so it must warn +/// identically — this is the surface a project's ignore rules actually +/// live in. +#[test] +fn explicit_path_overrides_exclude_from_file_and_warns() { + let dir = fixture(""); + fs::write(dir.path().join(".bcaignore"), "# ignored\n\n./skipme/**\n").unwrap(); + + cli(dir.path()) + .args([ + "check", + "skipme/a.rs", + "--exclude-from", + ".bcaignore", + "--no-summary", + "--no-remediation", + ]) + .assert() + .code(2) + .stderr(predicate::str::contains("skipme_offender")) + .stderr(predicate::str::contains( + "matches an exclude pattern (./skipme/**)", + )); +} + +/// A named file no language claims produces no analysis, so calling it +/// "analyzed anyway" would be both noisy and false. The advertised +/// `git diff --name-only | bca metrics --paths-from -` pipeline feeds in +/// whole changesets, where such files are the majority. +/// +/// The `.rs` sibling in the same excluded directory *does* warn in the +/// same run, so a build that simply stopped warning altogether fails +/// here rather than passing. +#[test] +fn override_warning_is_silent_for_a_seed_no_language_claims() { + let dir = fixture("exclude = [\"./skipme/**\"]\n"); + fs::write(dir.path().join("skipme").join("notes.md"), "# notes\n").unwrap(); + + cli(dir.path()) + .args([ + "metrics", + "skipme/notes.md", + "skipme/a.rs", + "--format", + "json", + "--output", + dir.path().join("out.json").to_str().unwrap(), + ]) + .assert() + .success() + .stderr(predicate::str::contains("skipme/a.rs matches an exclude")) + .stderr(predicate::str::contains("notes.md matches an exclude").not()); +} + +/// `[check] exclude` is the surface that survives an explicit path: the +/// file is analysed, its violation is dropped, and the existing +/// `[check.exclude]` skip line reports the drop. Pinned deliberately +/// against the case above so a future change cannot unify the two +/// exclude surfaces without a test failing. +#[test] +fn explicit_path_does_not_override_check_exclude() { + let dir = fixture("[check]\nexclude = [\"./skipme/**\"]\n"); + + cli(dir.path()) + .args(["check", "skipme/a.rs", "--no-summary", "--no-remediation"]) + .assert() + .success() + .stderr(predicate::str::contains("skipme_offender").not()) + // The skip line proves the offender existed and was dropped; + // without it a clean exit could mean the fixture stopped + // offending. + .stderr(predicate::str::contains( + "skipped 1 violations via [check.exclude]", + )) + // ... and no override warning, because nothing was overridden. + .stderr(predicate::str::contains("named explicitly").not()); +} + +/// The same, with the path spelled absolutely — the shape both shipped +/// agent hooks use, and the one that failed before #1146: an explicit +/// file seed is its own only seed, so the violation path reached the +/// `[check.exclude]` filter unanchored and a `./`-anchored glob never +/// matched it. +#[test] +fn absolute_explicit_path_does_not_override_check_exclude() { + let dir = fixture("[check]\nexclude = [\"./skipme/**\"]\n"); + let abs = dir.path().join("skipme").join("a.rs"); + + cli(dir.path()) + .args([ + "check", + abs.to_str().unwrap(), + "--no-summary", + "--no-remediation", + ]) + .assert() + .success() + .stderr(predicate::str::contains("skipme_offender").not()) + .stderr(predicate::str::contains( + "skipped 1 violations via [check.exclude]", + )); +} + +/// `--include` is an allow-list, not a deny-set, and an explicit path +/// does not override it: a named file the allow-list does not admit is +/// filtered out and the run has no input at all (`check`'s hard error, +/// exit 1). Pins that the override widened `passes` to `includes` and +/// no further. +#[test] +fn explicit_path_does_not_override_include() { + let dir = fixture(""); + + cli(dir.path()) + .args([ + "check", + "skipme/a.rs", + "--include", + "*.py", + "--no-summary", + "--no-remediation", + ]) + .assert() + .code(1) + .stderr(predicate::str::contains("skipme_offender").not()) + .stderr(predicate::str::contains("no input files matched")); +} + +/// The include allow-list admitting the file is what makes the test +/// above a statement about `--include` rather than about any narrowing +/// glob: with a matching pattern the same invocation reports the +/// offender. +#[test] +fn explicit_path_is_analyzed_when_include_admits_it() { + let dir = fixture(""); + + cli(dir.path()) + .args([ + "check", + "skipme/a.rs", + "--include", + "*.rs", + "--no-summary", + "--no-remediation", + ]) + .assert() + .code(2) + .stderr(predicate::str::contains("skipme_offender")); +} diff --git a/man/bca-check.1 b/man/bca-check.1 index 83988ceb8..825d09918 100644 --- a/man/bca-check.1 +++ b/man/bca-check.1 @@ -158,7 +158,9 @@ Input files or directories to analyze. Unioned with any positional `[PATHS]`. De Glob to include files. Repeat the flag to add multiple globs (`\-I \*(Aq*.rs\*(Aq \-I \*(Aq*.toml\*(Aq`); each occurrence takes exactly one value, so a positional argument that follows is never swallowed. A leading `./` is optional: `dir/**` and `./dir/**` are equivalent .TP \fB\-X\fR, \fB\-\-exclude\fR \fI\fR -Glob to exclude files. Repeat the flag to add multiple globs (`\-X \*(Aq*.tmp\*(Aq \-X \*(Aq*.bak\*(Aq`); each occurrence takes exactly one value, so a positional argument that follows is never swallowed. A leading `./` is optional: `dir/**` and `./dir/**` are equivalent. CLI values are *merged with* (unioned, not a replacement for) any `bca.toml` `exclude` list and any `\-\-exclude\-from` patterns, so a CLI `\-\-exclude` never silently un\-excludes a directory the project config deliberately skipped. Pass `\-\-no\-config` to ignore the manifest entirely +Glob to exclude files. Repeat the flag to add multiple globs (`\-X \*(Aq*.tmp\*(Aq \-X \*(Aq*.bak\*(Aq`); each occurrence takes exactly one value, so a positional argument that follows is never swallowed. A leading `./` is optional: `dir/**` and `./dir/**` are equivalent. CLI values are *merged with* (unioned, not a replacement for) any `bca.toml` `exclude` list and any `\-\-exclude\-from` patterns, so a CLI `\-\-exclude` never silently un\-excludes a directory the project config deliberately skipped. Pass `\-\-no\-config` to ignore the manifest entirely. + +Shapes directory\-walk scope only: a file named directly on the command line overrides every exclude glob and is analyzed anyway (the ripgrep/fd convention), with a warning on stderr naming the glob it overrode. `\-I` / `\-\-include` is not overridden. To exempt something from `bca check`\*(Aqs threshold gate whichever way it is named, use `\-\-check\-exclude` / `[check] exclude` instead. .TP \fB\-l\fR, \fB\-\-language\fR \fI\fR Force a language instead of inferring from extension. Accepts a canonical language name (`rust`, `python`, `cpp`, …) or a file extension (`rs`, `py`, …). An unrecognized value is a hard error @@ -167,10 +169,12 @@ Force a language instead of inferring from extension. Accepts a canonical langua Disable auto\-skip of files marked as generated (e.g. `@generated`, `DO NOT EDIT`, `GENERATED CODE` near the top). By default the CLI skips such files so generated bindings do not skew metrics .TP \fB\-\-paths\-from\fR \fI\fR -Read newline\-separated input paths from a file. Use `\-` to read from stdin. Combined as a union with any `\-\-paths` values; globs still apply. Blank lines are skipped; `#` is treated as a path character (not a comment). To pass a file literally named `\-`, use `./\-` +Read newline\-separated input paths from a file. Use `\-` to read from stdin. Combined as a union with any `\-\-paths` values. `\-\-include` globs still apply; `\-\-exclude` globs do not reach an entry that names a file directly, since an explicitly\-named path overrides the deny\-set. Blank lines are skipped; `#` is treated as a path character (not a comment). To pass a file literally named `\-`, use `./\-` .TP \fB\-\-exclude\-from\fR \fI\fR -Read additional `\-\-exclude` glob patterns from a file (one per line, `.gitignore`\-style). Blank lines and lines whose first non\-whitespace character is `#` are skipped. Use `\-` to read from stdin; to pass a file literally named `\-`, use `./\-`. Patterns are unioned with any `\-\-exclude` values into a single deny\-set; order does not matter. Convention is a `.bcaignore` at the repo root, mirroring `.gitignore` / `.dockerignore` +Read additional `\-\-exclude` glob patterns from a file (one per line, `.gitignore`\-style). Blank lines and lines whose first non\-whitespace character is `#` are skipped. Use `\-` to read from stdin; to pass a file literally named `\-`, use `./\-`. Patterns are unioned with any `\-\-exclude` values into a single deny\-set; order does not matter. Convention is a `.bcaignore` at the repo root, mirroring `.gitignore` / `.dockerignore`. + +Carries the same scope as `\-\-exclude`: these patterns shape the directory walk, and a file named directly on the command line overrides them (with a warning naming the glob). Put gate\-only exemptions in `\-\-check\-exclude\-from` / `[check] exclude`. .TP \fB\-\-no\-ignore\fR Disable `.gitignore` / `.ignore` / global gitignore awareness when expanding input directories. Explicit file paths are always honored regardless of this flag diff --git a/man/bca-count.1 b/man/bca-count.1 index 005a7d2d4..e90822be5 100644 --- a/man/bca-count.1 +++ b/man/bca-count.1 @@ -23,7 +23,9 @@ Input files or directories to analyze. Unioned with any positional `[PATHS]`. De Glob to include files. Repeat the flag to add multiple globs (`\-I \*(Aq*.rs\*(Aq \-I \*(Aq*.toml\*(Aq`); each occurrence takes exactly one value, so a positional argument that follows is never swallowed. A leading `./` is optional: `dir/**` and `./dir/**` are equivalent .TP \fB\-X\fR, \fB\-\-exclude\fR \fI\fR -Glob to exclude files. Repeat the flag to add multiple globs (`\-X \*(Aq*.tmp\*(Aq \-X \*(Aq*.bak\*(Aq`); each occurrence takes exactly one value, so a positional argument that follows is never swallowed. A leading `./` is optional: `dir/**` and `./dir/**` are equivalent. CLI values are *merged with* (unioned, not a replacement for) any `bca.toml` `exclude` list and any `\-\-exclude\-from` patterns, so a CLI `\-\-exclude` never silently un\-excludes a directory the project config deliberately skipped. Pass `\-\-no\-config` to ignore the manifest entirely +Glob to exclude files. Repeat the flag to add multiple globs (`\-X \*(Aq*.tmp\*(Aq \-X \*(Aq*.bak\*(Aq`); each occurrence takes exactly one value, so a positional argument that follows is never swallowed. A leading `./` is optional: `dir/**` and `./dir/**` are equivalent. CLI values are *merged with* (unioned, not a replacement for) any `bca.toml` `exclude` list and any `\-\-exclude\-from` patterns, so a CLI `\-\-exclude` never silently un\-excludes a directory the project config deliberately skipped. Pass `\-\-no\-config` to ignore the manifest entirely. + +Shapes directory\-walk scope only: a file named directly on the command line overrides every exclude glob and is analyzed anyway (the ripgrep/fd convention), with a warning on stderr naming the glob it overrode. `\-I` / `\-\-include` is not overridden. To exempt something from `bca check`\*(Aqs threshold gate whichever way it is named, use `\-\-check\-exclude` / `[check] exclude` instead. .TP \fB\-l\fR, \fB\-\-language\fR \fI\fR Force a language instead of inferring from extension. Accepts a canonical language name (`rust`, `python`, `cpp`, …) or a file extension (`rs`, `py`, …). An unrecognized value is a hard error @@ -32,10 +34,12 @@ Force a language instead of inferring from extension. Accepts a canonical langua Disable auto\-skip of files marked as generated (e.g. `@generated`, `DO NOT EDIT`, `GENERATED CODE` near the top). By default the CLI skips such files so generated bindings do not skew metrics .TP \fB\-\-paths\-from\fR \fI\fR -Read newline\-separated input paths from a file. Use `\-` to read from stdin. Combined as a union with any `\-\-paths` values; globs still apply. Blank lines are skipped; `#` is treated as a path character (not a comment). To pass a file literally named `\-`, use `./\-` +Read newline\-separated input paths from a file. Use `\-` to read from stdin. Combined as a union with any `\-\-paths` values. `\-\-include` globs still apply; `\-\-exclude` globs do not reach an entry that names a file directly, since an explicitly\-named path overrides the deny\-set. Blank lines are skipped; `#` is treated as a path character (not a comment). To pass a file literally named `\-`, use `./\-` .TP \fB\-\-exclude\-from\fR \fI\fR -Read additional `\-\-exclude` glob patterns from a file (one per line, `.gitignore`\-style). Blank lines and lines whose first non\-whitespace character is `#` are skipped. Use `\-` to read from stdin; to pass a file literally named `\-`, use `./\-`. Patterns are unioned with any `\-\-exclude` values into a single deny\-set; order does not matter. Convention is a `.bcaignore` at the repo root, mirroring `.gitignore` / `.dockerignore` +Read additional `\-\-exclude` glob patterns from a file (one per line, `.gitignore`\-style). Blank lines and lines whose first non\-whitespace character is `#` are skipped. Use `\-` to read from stdin; to pass a file literally named `\-`, use `./\-`. Patterns are unioned with any `\-\-exclude` values into a single deny\-set; order does not matter. Convention is a `.bcaignore` at the repo root, mirroring `.gitignore` / `.dockerignore`. + +Carries the same scope as `\-\-exclude`: these patterns shape the directory walk, and a file named directly on the command line overrides them (with a warning naming the glob). Put gate\-only exemptions in `\-\-check\-exclude\-from` / `[check] exclude`. .TP \fB\-\-no\-ignore\fR Disable `.gitignore` / `.ignore` / global gitignore awareness when expanding input directories. Explicit file paths are always honored regardless of this flag diff --git a/man/bca-diff.1 b/man/bca-diff.1 index 171a9c157..5a7ed23be 100644 --- a/man/bca-diff.1 +++ b/man/bca-diff.1 @@ -61,7 +61,9 @@ Input files or directories to analyze. Unioned with any positional `[PATHS]`. De Glob to include files. Repeat the flag to add multiple globs (`\-I \*(Aq*.rs\*(Aq \-I \*(Aq*.toml\*(Aq`); each occurrence takes exactly one value, so a positional argument that follows is never swallowed. A leading `./` is optional: `dir/**` and `./dir/**` are equivalent .TP \fB\-X\fR, \fB\-\-exclude\fR \fI\fR -Glob to exclude files. Repeat the flag to add multiple globs (`\-X \*(Aq*.tmp\*(Aq \-X \*(Aq*.bak\*(Aq`); each occurrence takes exactly one value, so a positional argument that follows is never swallowed. A leading `./` is optional: `dir/**` and `./dir/**` are equivalent. CLI values are *merged with* (unioned, not a replacement for) any `bca.toml` `exclude` list and any `\-\-exclude\-from` patterns, so a CLI `\-\-exclude` never silently un\-excludes a directory the project config deliberately skipped. Pass `\-\-no\-config` to ignore the manifest entirely +Glob to exclude files. Repeat the flag to add multiple globs (`\-X \*(Aq*.tmp\*(Aq \-X \*(Aq*.bak\*(Aq`); each occurrence takes exactly one value, so a positional argument that follows is never swallowed. A leading `./` is optional: `dir/**` and `./dir/**` are equivalent. CLI values are *merged with* (unioned, not a replacement for) any `bca.toml` `exclude` list and any `\-\-exclude\-from` patterns, so a CLI `\-\-exclude` never silently un\-excludes a directory the project config deliberately skipped. Pass `\-\-no\-config` to ignore the manifest entirely. + +Shapes directory\-walk scope only: a file named directly on the command line overrides every exclude glob and is analyzed anyway (the ripgrep/fd convention), with a warning on stderr naming the glob it overrode. `\-I` / `\-\-include` is not overridden. To exempt something from `bca check`\*(Aqs threshold gate whichever way it is named, use `\-\-check\-exclude` / `[check] exclude` instead. .TP \fB\-l\fR, \fB\-\-language\fR \fI\fR Force a language instead of inferring from extension. Accepts a canonical language name (`rust`, `python`, `cpp`, …) or a file extension (`rs`, `py`, …). An unrecognized value is a hard error @@ -70,10 +72,12 @@ Force a language instead of inferring from extension. Accepts a canonical langua Disable auto\-skip of files marked as generated (e.g. `@generated`, `DO NOT EDIT`, `GENERATED CODE` near the top). By default the CLI skips such files so generated bindings do not skew metrics .TP \fB\-\-paths\-from\fR \fI\fR -Read newline\-separated input paths from a file. Use `\-` to read from stdin. Combined as a union with any `\-\-paths` values; globs still apply. Blank lines are skipped; `#` is treated as a path character (not a comment). To pass a file literally named `\-`, use `./\-` +Read newline\-separated input paths from a file. Use `\-` to read from stdin. Combined as a union with any `\-\-paths` values. `\-\-include` globs still apply; `\-\-exclude` globs do not reach an entry that names a file directly, since an explicitly\-named path overrides the deny\-set. Blank lines are skipped; `#` is treated as a path character (not a comment). To pass a file literally named `\-`, use `./\-` .TP \fB\-\-exclude\-from\fR \fI\fR -Read additional `\-\-exclude` glob patterns from a file (one per line, `.gitignore`\-style). Blank lines and lines whose first non\-whitespace character is `#` are skipped. Use `\-` to read from stdin; to pass a file literally named `\-`, use `./\-`. Patterns are unioned with any `\-\-exclude` values into a single deny\-set; order does not matter. Convention is a `.bcaignore` at the repo root, mirroring `.gitignore` / `.dockerignore` +Read additional `\-\-exclude` glob patterns from a file (one per line, `.gitignore`\-style). Blank lines and lines whose first non\-whitespace character is `#` are skipped. Use `\-` to read from stdin; to pass a file literally named `\-`, use `./\-`. Patterns are unioned with any `\-\-exclude` values into a single deny\-set; order does not matter. Convention is a `.bcaignore` at the repo root, mirroring `.gitignore` / `.dockerignore`. + +Carries the same scope as `\-\-exclude`: these patterns shape the directory walk, and a file named directly on the command line overrides them (with a warning naming the glob). Put gate\-only exemptions in `\-\-check\-exclude\-from` / `[check] exclude`. .TP \fB\-\-no\-ignore\fR Disable `.gitignore` / `.ignore` / global gitignore awareness when expanding input directories. Explicit file paths are always honored regardless of this flag diff --git a/man/bca-dump.1 b/man/bca-dump.1 index d8d1055e5..b224f4061 100644 --- a/man/bca-dump.1 +++ b/man/bca-dump.1 @@ -26,7 +26,9 @@ Input files or directories to analyze. Unioned with any positional `[PATHS]`. De Glob to include files. Repeat the flag to add multiple globs (`\-I \*(Aq*.rs\*(Aq \-I \*(Aq*.toml\*(Aq`); each occurrence takes exactly one value, so a positional argument that follows is never swallowed. A leading `./` is optional: `dir/**` and `./dir/**` are equivalent .TP \fB\-X\fR, \fB\-\-exclude\fR \fI\fR -Glob to exclude files. Repeat the flag to add multiple globs (`\-X \*(Aq*.tmp\*(Aq \-X \*(Aq*.bak\*(Aq`); each occurrence takes exactly one value, so a positional argument that follows is never swallowed. A leading `./` is optional: `dir/**` and `./dir/**` are equivalent. CLI values are *merged with* (unioned, not a replacement for) any `bca.toml` `exclude` list and any `\-\-exclude\-from` patterns, so a CLI `\-\-exclude` never silently un\-excludes a directory the project config deliberately skipped. Pass `\-\-no\-config` to ignore the manifest entirely +Glob to exclude files. Repeat the flag to add multiple globs (`\-X \*(Aq*.tmp\*(Aq \-X \*(Aq*.bak\*(Aq`); each occurrence takes exactly one value, so a positional argument that follows is never swallowed. A leading `./` is optional: `dir/**` and `./dir/**` are equivalent. CLI values are *merged with* (unioned, not a replacement for) any `bca.toml` `exclude` list and any `\-\-exclude\-from` patterns, so a CLI `\-\-exclude` never silently un\-excludes a directory the project config deliberately skipped. Pass `\-\-no\-config` to ignore the manifest entirely. + +Shapes directory\-walk scope only: a file named directly on the command line overrides every exclude glob and is analyzed anyway (the ripgrep/fd convention), with a warning on stderr naming the glob it overrode. `\-I` / `\-\-include` is not overridden. To exempt something from `bca check`\*(Aqs threshold gate whichever way it is named, use `\-\-check\-exclude` / `[check] exclude` instead. .TP \fB\-l\fR, \fB\-\-language\fR \fI\fR Force a language instead of inferring from extension. Accepts a canonical language name (`rust`, `python`, `cpp`, …) or a file extension (`rs`, `py`, …). An unrecognized value is a hard error @@ -35,10 +37,12 @@ Force a language instead of inferring from extension. Accepts a canonical langua Disable auto\-skip of files marked as generated (e.g. `@generated`, `DO NOT EDIT`, `GENERATED CODE` near the top). By default the CLI skips such files so generated bindings do not skew metrics .TP \fB\-\-paths\-from\fR \fI\fR -Read newline\-separated input paths from a file. Use `\-` to read from stdin. Combined as a union with any `\-\-paths` values; globs still apply. Blank lines are skipped; `#` is treated as a path character (not a comment). To pass a file literally named `\-`, use `./\-` +Read newline\-separated input paths from a file. Use `\-` to read from stdin. Combined as a union with any `\-\-paths` values. `\-\-include` globs still apply; `\-\-exclude` globs do not reach an entry that names a file directly, since an explicitly\-named path overrides the deny\-set. Blank lines are skipped; `#` is treated as a path character (not a comment). To pass a file literally named `\-`, use `./\-` .TP \fB\-\-exclude\-from\fR \fI\fR -Read additional `\-\-exclude` glob patterns from a file (one per line, `.gitignore`\-style). Blank lines and lines whose first non\-whitespace character is `#` are skipped. Use `\-` to read from stdin; to pass a file literally named `\-`, use `./\-`. Patterns are unioned with any `\-\-exclude` values into a single deny\-set; order does not matter. Convention is a `.bcaignore` at the repo root, mirroring `.gitignore` / `.dockerignore` +Read additional `\-\-exclude` glob patterns from a file (one per line, `.gitignore`\-style). Blank lines and lines whose first non\-whitespace character is `#` are skipped. Use `\-` to read from stdin; to pass a file literally named `\-`, use `./\-`. Patterns are unioned with any `\-\-exclude` values into a single deny\-set; order does not matter. Convention is a `.bcaignore` at the repo root, mirroring `.gitignore` / `.dockerignore`. + +Carries the same scope as `\-\-exclude`: these patterns shape the directory walk, and a file named directly on the command line overrides them (with a warning naming the glob). Put gate\-only exemptions in `\-\-check\-exclude\-from` / `[check] exclude`. .TP \fB\-\-no\-ignore\fR Disable `.gitignore` / `.ignore` / global gitignore awareness when expanding input directories. Explicit file paths are always honored regardless of this flag diff --git a/man/bca-exemptions.1 b/man/bca-exemptions.1 index 3da83c530..9473f990f 100644 --- a/man/bca-exemptions.1 +++ b/man/bca-exemptions.1 @@ -59,7 +59,9 @@ Input files or directories to analyze. Unioned with any positional `[PATHS]`. De Glob to include files. Repeat the flag to add multiple globs (`\-I \*(Aq*.rs\*(Aq \-I \*(Aq*.toml\*(Aq`); each occurrence takes exactly one value, so a positional argument that follows is never swallowed. A leading `./` is optional: `dir/**` and `./dir/**` are equivalent .TP \fB\-X\fR, \fB\-\-exclude\fR \fI\fR -Glob to exclude files. Repeat the flag to add multiple globs (`\-X \*(Aq*.tmp\*(Aq \-X \*(Aq*.bak\*(Aq`); each occurrence takes exactly one value, so a positional argument that follows is never swallowed. A leading `./` is optional: `dir/**` and `./dir/**` are equivalent. CLI values are *merged with* (unioned, not a replacement for) any `bca.toml` `exclude` list and any `\-\-exclude\-from` patterns, so a CLI `\-\-exclude` never silently un\-excludes a directory the project config deliberately skipped. Pass `\-\-no\-config` to ignore the manifest entirely +Glob to exclude files. Repeat the flag to add multiple globs (`\-X \*(Aq*.tmp\*(Aq \-X \*(Aq*.bak\*(Aq`); each occurrence takes exactly one value, so a positional argument that follows is never swallowed. A leading `./` is optional: `dir/**` and `./dir/**` are equivalent. CLI values are *merged with* (unioned, not a replacement for) any `bca.toml` `exclude` list and any `\-\-exclude\-from` patterns, so a CLI `\-\-exclude` never silently un\-excludes a directory the project config deliberately skipped. Pass `\-\-no\-config` to ignore the manifest entirely. + +Shapes directory\-walk scope only: a file named directly on the command line overrides every exclude glob and is analyzed anyway (the ripgrep/fd convention), with a warning on stderr naming the glob it overrode. `\-I` / `\-\-include` is not overridden. To exempt something from `bca check`\*(Aqs threshold gate whichever way it is named, use `\-\-check\-exclude` / `[check] exclude` instead. .TP \fB\-l\fR, \fB\-\-language\fR \fI\fR Force a language instead of inferring from extension. Accepts a canonical language name (`rust`, `python`, `cpp`, …) or a file extension (`rs`, `py`, …). An unrecognized value is a hard error @@ -68,10 +70,12 @@ Force a language instead of inferring from extension. Accepts a canonical langua Disable auto\-skip of files marked as generated (e.g. `@generated`, `DO NOT EDIT`, `GENERATED CODE` near the top). By default the CLI skips such files so generated bindings do not skew metrics .TP \fB\-\-paths\-from\fR \fI\fR -Read newline\-separated input paths from a file. Use `\-` to read from stdin. Combined as a union with any `\-\-paths` values; globs still apply. Blank lines are skipped; `#` is treated as a path character (not a comment). To pass a file literally named `\-`, use `./\-` +Read newline\-separated input paths from a file. Use `\-` to read from stdin. Combined as a union with any `\-\-paths` values. `\-\-include` globs still apply; `\-\-exclude` globs do not reach an entry that names a file directly, since an explicitly\-named path overrides the deny\-set. Blank lines are skipped; `#` is treated as a path character (not a comment). To pass a file literally named `\-`, use `./\-` .TP \fB\-\-exclude\-from\fR \fI\fR -Read additional `\-\-exclude` glob patterns from a file (one per line, `.gitignore`\-style). Blank lines and lines whose first non\-whitespace character is `#` are skipped. Use `\-` to read from stdin; to pass a file literally named `\-`, use `./\-`. Patterns are unioned with any `\-\-exclude` values into a single deny\-set; order does not matter. Convention is a `.bcaignore` at the repo root, mirroring `.gitignore` / `.dockerignore` +Read additional `\-\-exclude` glob patterns from a file (one per line, `.gitignore`\-style). Blank lines and lines whose first non\-whitespace character is `#` are skipped. Use `\-` to read from stdin; to pass a file literally named `\-`, use `./\-`. Patterns are unioned with any `\-\-exclude` values into a single deny\-set; order does not matter. Convention is a `.bcaignore` at the repo root, mirroring `.gitignore` / `.dockerignore`. + +Carries the same scope as `\-\-exclude`: these patterns shape the directory walk, and a file named directly on the command line overrides them (with a warning naming the glob). Put gate\-only exemptions in `\-\-check\-exclude\-from` / `[check] exclude`. .TP \fB\-\-no\-ignore\fR Disable `.gitignore` / `.ignore` / global gitignore awareness when expanding input directories. Explicit file paths are always honored regardless of this flag diff --git a/man/bca-find.1 b/man/bca-find.1 index 3ae9f50e6..ce571eac4 100644 --- a/man/bca-find.1 +++ b/man/bca-find.1 @@ -29,7 +29,9 @@ Input files or directories to analyze. Unioned with any positional `[PATHS]`. De Glob to include files. Repeat the flag to add multiple globs (`\-I \*(Aq*.rs\*(Aq \-I \*(Aq*.toml\*(Aq`); each occurrence takes exactly one value, so a positional argument that follows is never swallowed. A leading `./` is optional: `dir/**` and `./dir/**` are equivalent .TP \fB\-X\fR, \fB\-\-exclude\fR \fI\fR -Glob to exclude files. Repeat the flag to add multiple globs (`\-X \*(Aq*.tmp\*(Aq \-X \*(Aq*.bak\*(Aq`); each occurrence takes exactly one value, so a positional argument that follows is never swallowed. A leading `./` is optional: `dir/**` and `./dir/**` are equivalent. CLI values are *merged with* (unioned, not a replacement for) any `bca.toml` `exclude` list and any `\-\-exclude\-from` patterns, so a CLI `\-\-exclude` never silently un\-excludes a directory the project config deliberately skipped. Pass `\-\-no\-config` to ignore the manifest entirely +Glob to exclude files. Repeat the flag to add multiple globs (`\-X \*(Aq*.tmp\*(Aq \-X \*(Aq*.bak\*(Aq`); each occurrence takes exactly one value, so a positional argument that follows is never swallowed. A leading `./` is optional: `dir/**` and `./dir/**` are equivalent. CLI values are *merged with* (unioned, not a replacement for) any `bca.toml` `exclude` list and any `\-\-exclude\-from` patterns, so a CLI `\-\-exclude` never silently un\-excludes a directory the project config deliberately skipped. Pass `\-\-no\-config` to ignore the manifest entirely. + +Shapes directory\-walk scope only: a file named directly on the command line overrides every exclude glob and is analyzed anyway (the ripgrep/fd convention), with a warning on stderr naming the glob it overrode. `\-I` / `\-\-include` is not overridden. To exempt something from `bca check`\*(Aqs threshold gate whichever way it is named, use `\-\-check\-exclude` / `[check] exclude` instead. .TP \fB\-l\fR, \fB\-\-language\fR \fI\fR Force a language instead of inferring from extension. Accepts a canonical language name (`rust`, `python`, `cpp`, …) or a file extension (`rs`, `py`, …). An unrecognized value is a hard error @@ -38,10 +40,12 @@ Force a language instead of inferring from extension. Accepts a canonical langua Disable auto\-skip of files marked as generated (e.g. `@generated`, `DO NOT EDIT`, `GENERATED CODE` near the top). By default the CLI skips such files so generated bindings do not skew metrics .TP \fB\-\-paths\-from\fR \fI\fR -Read newline\-separated input paths from a file. Use `\-` to read from stdin. Combined as a union with any `\-\-paths` values; globs still apply. Blank lines are skipped; `#` is treated as a path character (not a comment). To pass a file literally named `\-`, use `./\-` +Read newline\-separated input paths from a file. Use `\-` to read from stdin. Combined as a union with any `\-\-paths` values. `\-\-include` globs still apply; `\-\-exclude` globs do not reach an entry that names a file directly, since an explicitly\-named path overrides the deny\-set. Blank lines are skipped; `#` is treated as a path character (not a comment). To pass a file literally named `\-`, use `./\-` .TP \fB\-\-exclude\-from\fR \fI\fR -Read additional `\-\-exclude` glob patterns from a file (one per line, `.gitignore`\-style). Blank lines and lines whose first non\-whitespace character is `#` are skipped. Use `\-` to read from stdin; to pass a file literally named `\-`, use `./\-`. Patterns are unioned with any `\-\-exclude` values into a single deny\-set; order does not matter. Convention is a `.bcaignore` at the repo root, mirroring `.gitignore` / `.dockerignore` +Read additional `\-\-exclude` glob patterns from a file (one per line, `.gitignore`\-style). Blank lines and lines whose first non\-whitespace character is `#` are skipped. Use `\-` to read from stdin; to pass a file literally named `\-`, use `./\-`. Patterns are unioned with any `\-\-exclude` values into a single deny\-set; order does not matter. Convention is a `.bcaignore` at the repo root, mirroring `.gitignore` / `.dockerignore`. + +Carries the same scope as `\-\-exclude`: these patterns shape the directory walk, and a file named directly on the command line overrides them (with a warning naming the glob). Put gate\-only exemptions in `\-\-check\-exclude\-from` / `[check] exclude`. .TP \fB\-\-no\-ignore\fR Disable `.gitignore` / `.ignore` / global gitignore awareness when expanding input directories. Explicit file paths are always honored regardless of this flag diff --git a/man/bca-functions.1 b/man/bca-functions.1 index 70616534a..3c10dadfc 100644 --- a/man/bca-functions.1 +++ b/man/bca-functions.1 @@ -20,7 +20,9 @@ Input files or directories to analyze. Unioned with any positional `[PATHS]`. De Glob to include files. Repeat the flag to add multiple globs (`\-I \*(Aq*.rs\*(Aq \-I \*(Aq*.toml\*(Aq`); each occurrence takes exactly one value, so a positional argument that follows is never swallowed. A leading `./` is optional: `dir/**` and `./dir/**` are equivalent .TP \fB\-X\fR, \fB\-\-exclude\fR \fI\fR -Glob to exclude files. Repeat the flag to add multiple globs (`\-X \*(Aq*.tmp\*(Aq \-X \*(Aq*.bak\*(Aq`); each occurrence takes exactly one value, so a positional argument that follows is never swallowed. A leading `./` is optional: `dir/**` and `./dir/**` are equivalent. CLI values are *merged with* (unioned, not a replacement for) any `bca.toml` `exclude` list and any `\-\-exclude\-from` patterns, so a CLI `\-\-exclude` never silently un\-excludes a directory the project config deliberately skipped. Pass `\-\-no\-config` to ignore the manifest entirely +Glob to exclude files. Repeat the flag to add multiple globs (`\-X \*(Aq*.tmp\*(Aq \-X \*(Aq*.bak\*(Aq`); each occurrence takes exactly one value, so a positional argument that follows is never swallowed. A leading `./` is optional: `dir/**` and `./dir/**` are equivalent. CLI values are *merged with* (unioned, not a replacement for) any `bca.toml` `exclude` list and any `\-\-exclude\-from` patterns, so a CLI `\-\-exclude` never silently un\-excludes a directory the project config deliberately skipped. Pass `\-\-no\-config` to ignore the manifest entirely. + +Shapes directory\-walk scope only: a file named directly on the command line overrides every exclude glob and is analyzed anyway (the ripgrep/fd convention), with a warning on stderr naming the glob it overrode. `\-I` / `\-\-include` is not overridden. To exempt something from `bca check`\*(Aqs threshold gate whichever way it is named, use `\-\-check\-exclude` / `[check] exclude` instead. .TP \fB\-l\fR, \fB\-\-language\fR \fI\fR Force a language instead of inferring from extension. Accepts a canonical language name (`rust`, `python`, `cpp`, …) or a file extension (`rs`, `py`, …). An unrecognized value is a hard error @@ -29,10 +31,12 @@ Force a language instead of inferring from extension. Accepts a canonical langua Disable auto\-skip of files marked as generated (e.g. `@generated`, `DO NOT EDIT`, `GENERATED CODE` near the top). By default the CLI skips such files so generated bindings do not skew metrics .TP \fB\-\-paths\-from\fR \fI\fR -Read newline\-separated input paths from a file. Use `\-` to read from stdin. Combined as a union with any `\-\-paths` values; globs still apply. Blank lines are skipped; `#` is treated as a path character (not a comment). To pass a file literally named `\-`, use `./\-` +Read newline\-separated input paths from a file. Use `\-` to read from stdin. Combined as a union with any `\-\-paths` values. `\-\-include` globs still apply; `\-\-exclude` globs do not reach an entry that names a file directly, since an explicitly\-named path overrides the deny\-set. Blank lines are skipped; `#` is treated as a path character (not a comment). To pass a file literally named `\-`, use `./\-` .TP \fB\-\-exclude\-from\fR \fI\fR -Read additional `\-\-exclude` glob patterns from a file (one per line, `.gitignore`\-style). Blank lines and lines whose first non\-whitespace character is `#` are skipped. Use `\-` to read from stdin; to pass a file literally named `\-`, use `./\-`. Patterns are unioned with any `\-\-exclude` values into a single deny\-set; order does not matter. Convention is a `.bcaignore` at the repo root, mirroring `.gitignore` / `.dockerignore` +Read additional `\-\-exclude` glob patterns from a file (one per line, `.gitignore`\-style). Blank lines and lines whose first non\-whitespace character is `#` are skipped. Use `\-` to read from stdin; to pass a file literally named `\-`, use `./\-`. Patterns are unioned with any `\-\-exclude` values into a single deny\-set; order does not matter. Convention is a `.bcaignore` at the repo root, mirroring `.gitignore` / `.dockerignore`. + +Carries the same scope as `\-\-exclude`: these patterns shape the directory walk, and a file named directly on the command line overrides them (with a warning naming the glob). Put gate\-only exemptions in `\-\-check\-exclude\-from` / `[check] exclude`. .TP \fB\-\-no\-ignore\fR Disable `.gitignore` / `.ignore` / global gitignore awareness when expanding input directories. Explicit file paths are always honored regardless of this flag diff --git a/man/bca-init.1 b/man/bca-init.1 index c552ffe66..bfd484338 100644 --- a/man/bca-init.1 +++ b/man/bca-init.1 @@ -29,7 +29,9 @@ Input files or directories to analyze. Unioned with any positional `[PATHS]`. De Glob to include files. Repeat the flag to add multiple globs (`\-I \*(Aq*.rs\*(Aq \-I \*(Aq*.toml\*(Aq`); each occurrence takes exactly one value, so a positional argument that follows is never swallowed. A leading `./` is optional: `dir/**` and `./dir/**` are equivalent .TP \fB\-X\fR, \fB\-\-exclude\fR \fI\fR -Glob to exclude files. Repeat the flag to add multiple globs (`\-X \*(Aq*.tmp\*(Aq \-X \*(Aq*.bak\*(Aq`); each occurrence takes exactly one value, so a positional argument that follows is never swallowed. A leading `./` is optional: `dir/**` and `./dir/**` are equivalent. CLI values are *merged with* (unioned, not a replacement for) any `bca.toml` `exclude` list and any `\-\-exclude\-from` patterns, so a CLI `\-\-exclude` never silently un\-excludes a directory the project config deliberately skipped. Pass `\-\-no\-config` to ignore the manifest entirely +Glob to exclude files. Repeat the flag to add multiple globs (`\-X \*(Aq*.tmp\*(Aq \-X \*(Aq*.bak\*(Aq`); each occurrence takes exactly one value, so a positional argument that follows is never swallowed. A leading `./` is optional: `dir/**` and `./dir/**` are equivalent. CLI values are *merged with* (unioned, not a replacement for) any `bca.toml` `exclude` list and any `\-\-exclude\-from` patterns, so a CLI `\-\-exclude` never silently un\-excludes a directory the project config deliberately skipped. Pass `\-\-no\-config` to ignore the manifest entirely. + +Shapes directory\-walk scope only: a file named directly on the command line overrides every exclude glob and is analyzed anyway (the ripgrep/fd convention), with a warning on stderr naming the glob it overrode. `\-I` / `\-\-include` is not overridden. To exempt something from `bca check`\*(Aqs threshold gate whichever way it is named, use `\-\-check\-exclude` / `[check] exclude` instead. .TP \fB\-l\fR, \fB\-\-language\fR \fI\fR Force a language instead of inferring from extension. Accepts a canonical language name (`rust`, `python`, `cpp`, …) or a file extension (`rs`, `py`, …). An unrecognized value is a hard error @@ -38,10 +40,12 @@ Force a language instead of inferring from extension. Accepts a canonical langua Disable auto\-skip of files marked as generated (e.g. `@generated`, `DO NOT EDIT`, `GENERATED CODE` near the top). By default the CLI skips such files so generated bindings do not skew metrics .TP \fB\-\-paths\-from\fR \fI\fR -Read newline\-separated input paths from a file. Use `\-` to read from stdin. Combined as a union with any `\-\-paths` values; globs still apply. Blank lines are skipped; `#` is treated as a path character (not a comment). To pass a file literally named `\-`, use `./\-` +Read newline\-separated input paths from a file. Use `\-` to read from stdin. Combined as a union with any `\-\-paths` values. `\-\-include` globs still apply; `\-\-exclude` globs do not reach an entry that names a file directly, since an explicitly\-named path overrides the deny\-set. Blank lines are skipped; `#` is treated as a path character (not a comment). To pass a file literally named `\-`, use `./\-` .TP \fB\-\-exclude\-from\fR \fI\fR -Read additional `\-\-exclude` glob patterns from a file (one per line, `.gitignore`\-style). Blank lines and lines whose first non\-whitespace character is `#` are skipped. Use `\-` to read from stdin; to pass a file literally named `\-`, use `./\-`. Patterns are unioned with any `\-\-exclude` values into a single deny\-set; order does not matter. Convention is a `.bcaignore` at the repo root, mirroring `.gitignore` / `.dockerignore` +Read additional `\-\-exclude` glob patterns from a file (one per line, `.gitignore`\-style). Blank lines and lines whose first non\-whitespace character is `#` are skipped. Use `\-` to read from stdin; to pass a file literally named `\-`, use `./\-`. Patterns are unioned with any `\-\-exclude` values into a single deny\-set; order does not matter. Convention is a `.bcaignore` at the repo root, mirroring `.gitignore` / `.dockerignore`. + +Carries the same scope as `\-\-exclude`: these patterns shape the directory walk, and a file named directly on the command line overrides them (with a warning naming the glob). Put gate\-only exemptions in `\-\-check\-exclude\-from` / `[check] exclude`. .TP \fB\-\-no\-ignore\fR Disable `.gitignore` / `.ignore` / global gitignore awareness when expanding input directories. Explicit file paths are always honored regardless of this flag diff --git a/man/bca-metrics.1 b/man/bca-metrics.1 index 5186f081b..aa5245805 100644 --- a/man/bca-metrics.1 +++ b/man/bca-metrics.1 @@ -59,7 +59,9 @@ Input files or directories to analyze. Unioned with any positional `[PATHS]`. De Glob to include files. Repeat the flag to add multiple globs (`\-I \*(Aq*.rs\*(Aq \-I \*(Aq*.toml\*(Aq`); each occurrence takes exactly one value, so a positional argument that follows is never swallowed. A leading `./` is optional: `dir/**` and `./dir/**` are equivalent .TP \fB\-X\fR, \fB\-\-exclude\fR \fI\fR -Glob to exclude files. Repeat the flag to add multiple globs (`\-X \*(Aq*.tmp\*(Aq \-X \*(Aq*.bak\*(Aq`); each occurrence takes exactly one value, so a positional argument that follows is never swallowed. A leading `./` is optional: `dir/**` and `./dir/**` are equivalent. CLI values are *merged with* (unioned, not a replacement for) any `bca.toml` `exclude` list and any `\-\-exclude\-from` patterns, so a CLI `\-\-exclude` never silently un\-excludes a directory the project config deliberately skipped. Pass `\-\-no\-config` to ignore the manifest entirely +Glob to exclude files. Repeat the flag to add multiple globs (`\-X \*(Aq*.tmp\*(Aq \-X \*(Aq*.bak\*(Aq`); each occurrence takes exactly one value, so a positional argument that follows is never swallowed. A leading `./` is optional: `dir/**` and `./dir/**` are equivalent. CLI values are *merged with* (unioned, not a replacement for) any `bca.toml` `exclude` list and any `\-\-exclude\-from` patterns, so a CLI `\-\-exclude` never silently un\-excludes a directory the project config deliberately skipped. Pass `\-\-no\-config` to ignore the manifest entirely. + +Shapes directory\-walk scope only: a file named directly on the command line overrides every exclude glob and is analyzed anyway (the ripgrep/fd convention), with a warning on stderr naming the glob it overrode. `\-I` / `\-\-include` is not overridden. To exempt something from `bca check`\*(Aqs threshold gate whichever way it is named, use `\-\-check\-exclude` / `[check] exclude` instead. .TP \fB\-l\fR, \fB\-\-language\fR \fI\fR Force a language instead of inferring from extension. Accepts a canonical language name (`rust`, `python`, `cpp`, …) or a file extension (`rs`, `py`, …). An unrecognized value is a hard error @@ -68,10 +70,12 @@ Force a language instead of inferring from extension. Accepts a canonical langua Disable auto\-skip of files marked as generated (e.g. `@generated`, `DO NOT EDIT`, `GENERATED CODE` near the top). By default the CLI skips such files so generated bindings do not skew metrics .TP \fB\-\-paths\-from\fR \fI\fR -Read newline\-separated input paths from a file. Use `\-` to read from stdin. Combined as a union with any `\-\-paths` values; globs still apply. Blank lines are skipped; `#` is treated as a path character (not a comment). To pass a file literally named `\-`, use `./\-` +Read newline\-separated input paths from a file. Use `\-` to read from stdin. Combined as a union with any `\-\-paths` values. `\-\-include` globs still apply; `\-\-exclude` globs do not reach an entry that names a file directly, since an explicitly\-named path overrides the deny\-set. Blank lines are skipped; `#` is treated as a path character (not a comment). To pass a file literally named `\-`, use `./\-` .TP \fB\-\-exclude\-from\fR \fI\fR -Read additional `\-\-exclude` glob patterns from a file (one per line, `.gitignore`\-style). Blank lines and lines whose first non\-whitespace character is `#` are skipped. Use `\-` to read from stdin; to pass a file literally named `\-`, use `./\-`. Patterns are unioned with any `\-\-exclude` values into a single deny\-set; order does not matter. Convention is a `.bcaignore` at the repo root, mirroring `.gitignore` / `.dockerignore` +Read additional `\-\-exclude` glob patterns from a file (one per line, `.gitignore`\-style). Blank lines and lines whose first non\-whitespace character is `#` are skipped. Use `\-` to read from stdin; to pass a file literally named `\-`, use `./\-`. Patterns are unioned with any `\-\-exclude` values into a single deny\-set; order does not matter. Convention is a `.bcaignore` at the repo root, mirroring `.gitignore` / `.dockerignore`. + +Carries the same scope as `\-\-exclude`: these patterns shape the directory walk, and a file named directly on the command line overrides them (with a warning naming the glob). Put gate\-only exemptions in `\-\-check\-exclude\-from` / `[check] exclude`. .TP \fB\-\-no\-ignore\fR Disable `.gitignore` / `.ignore` / global gitignore awareness when expanding input directories. Explicit file paths are always honored regardless of this flag diff --git a/man/bca-ops.1 b/man/bca-ops.1 index a3db68da0..c6cce5433 100644 --- a/man/bca-ops.1 +++ b/man/bca-ops.1 @@ -50,7 +50,9 @@ Input files or directories to analyze. Unioned with any positional `[PATHS]`. De Glob to include files. Repeat the flag to add multiple globs (`\-I \*(Aq*.rs\*(Aq \-I \*(Aq*.toml\*(Aq`); each occurrence takes exactly one value, so a positional argument that follows is never swallowed. A leading `./` is optional: `dir/**` and `./dir/**` are equivalent .TP \fB\-X\fR, \fB\-\-exclude\fR \fI\fR -Glob to exclude files. Repeat the flag to add multiple globs (`\-X \*(Aq*.tmp\*(Aq \-X \*(Aq*.bak\*(Aq`); each occurrence takes exactly one value, so a positional argument that follows is never swallowed. A leading `./` is optional: `dir/**` and `./dir/**` are equivalent. CLI values are *merged with* (unioned, not a replacement for) any `bca.toml` `exclude` list and any `\-\-exclude\-from` patterns, so a CLI `\-\-exclude` never silently un\-excludes a directory the project config deliberately skipped. Pass `\-\-no\-config` to ignore the manifest entirely +Glob to exclude files. Repeat the flag to add multiple globs (`\-X \*(Aq*.tmp\*(Aq \-X \*(Aq*.bak\*(Aq`); each occurrence takes exactly one value, so a positional argument that follows is never swallowed. A leading `./` is optional: `dir/**` and `./dir/**` are equivalent. CLI values are *merged with* (unioned, not a replacement for) any `bca.toml` `exclude` list and any `\-\-exclude\-from` patterns, so a CLI `\-\-exclude` never silently un\-excludes a directory the project config deliberately skipped. Pass `\-\-no\-config` to ignore the manifest entirely. + +Shapes directory\-walk scope only: a file named directly on the command line overrides every exclude glob and is analyzed anyway (the ripgrep/fd convention), with a warning on stderr naming the glob it overrode. `\-I` / `\-\-include` is not overridden. To exempt something from `bca check`\*(Aqs threshold gate whichever way it is named, use `\-\-check\-exclude` / `[check] exclude` instead. .TP \fB\-l\fR, \fB\-\-language\fR \fI\fR Force a language instead of inferring from extension. Accepts a canonical language name (`rust`, `python`, `cpp`, …) or a file extension (`rs`, `py`, …). An unrecognized value is a hard error @@ -59,10 +61,12 @@ Force a language instead of inferring from extension. Accepts a canonical langua Disable auto\-skip of files marked as generated (e.g. `@generated`, `DO NOT EDIT`, `GENERATED CODE` near the top). By default the CLI skips such files so generated bindings do not skew metrics .TP \fB\-\-paths\-from\fR \fI\fR -Read newline\-separated input paths from a file. Use `\-` to read from stdin. Combined as a union with any `\-\-paths` values; globs still apply. Blank lines are skipped; `#` is treated as a path character (not a comment). To pass a file literally named `\-`, use `./\-` +Read newline\-separated input paths from a file. Use `\-` to read from stdin. Combined as a union with any `\-\-paths` values. `\-\-include` globs still apply; `\-\-exclude` globs do not reach an entry that names a file directly, since an explicitly\-named path overrides the deny\-set. Blank lines are skipped; `#` is treated as a path character (not a comment). To pass a file literally named `\-`, use `./\-` .TP \fB\-\-exclude\-from\fR \fI\fR -Read additional `\-\-exclude` glob patterns from a file (one per line, `.gitignore`\-style). Blank lines and lines whose first non\-whitespace character is `#` are skipped. Use `\-` to read from stdin; to pass a file literally named `\-`, use `./\-`. Patterns are unioned with any `\-\-exclude` values into a single deny\-set; order does not matter. Convention is a `.bcaignore` at the repo root, mirroring `.gitignore` / `.dockerignore` +Read additional `\-\-exclude` glob patterns from a file (one per line, `.gitignore`\-style). Blank lines and lines whose first non\-whitespace character is `#` are skipped. Use `\-` to read from stdin; to pass a file literally named `\-`, use `./\-`. Patterns are unioned with any `\-\-exclude` values into a single deny\-set; order does not matter. Convention is a `.bcaignore` at the repo root, mirroring `.gitignore` / `.dockerignore`. + +Carries the same scope as `\-\-exclude`: these patterns shape the directory walk, and a file named directly on the command line overrides them (with a warning naming the glob). Put gate\-only exemptions in `\-\-check\-exclude\-from` / `[check] exclude`. .TP \fB\-\-no\-ignore\fR Disable `.gitignore` / `.ignore` / global gitignore awareness when expanding input directories. Explicit file paths are always honored regardless of this flag diff --git a/man/bca-preproc.1 b/man/bca-preproc.1 index 9cea1d83e..040e64663 100644 --- a/man/bca-preproc.1 +++ b/man/bca-preproc.1 @@ -23,7 +23,9 @@ Input files or directories to analyze. Unioned with any positional `[PATHS]`. De Glob to include files. Repeat the flag to add multiple globs (`\-I \*(Aq*.rs\*(Aq \-I \*(Aq*.toml\*(Aq`); each occurrence takes exactly one value, so a positional argument that follows is never swallowed. A leading `./` is optional: `dir/**` and `./dir/**` are equivalent .TP \fB\-X\fR, \fB\-\-exclude\fR \fI\fR -Glob to exclude files. Repeat the flag to add multiple globs (`\-X \*(Aq*.tmp\*(Aq \-X \*(Aq*.bak\*(Aq`); each occurrence takes exactly one value, so a positional argument that follows is never swallowed. A leading `./` is optional: `dir/**` and `./dir/**` are equivalent. CLI values are *merged with* (unioned, not a replacement for) any `bca.toml` `exclude` list and any `\-\-exclude\-from` patterns, so a CLI `\-\-exclude` never silently un\-excludes a directory the project config deliberately skipped. Pass `\-\-no\-config` to ignore the manifest entirely +Glob to exclude files. Repeat the flag to add multiple globs (`\-X \*(Aq*.tmp\*(Aq \-X \*(Aq*.bak\*(Aq`); each occurrence takes exactly one value, so a positional argument that follows is never swallowed. A leading `./` is optional: `dir/**` and `./dir/**` are equivalent. CLI values are *merged with* (unioned, not a replacement for) any `bca.toml` `exclude` list and any `\-\-exclude\-from` patterns, so a CLI `\-\-exclude` never silently un\-excludes a directory the project config deliberately skipped. Pass `\-\-no\-config` to ignore the manifest entirely. + +Shapes directory\-walk scope only: a file named directly on the command line overrides every exclude glob and is analyzed anyway (the ripgrep/fd convention), with a warning on stderr naming the glob it overrode. `\-I` / `\-\-include` is not overridden. To exempt something from `bca check`\*(Aqs threshold gate whichever way it is named, use `\-\-check\-exclude` / `[check] exclude` instead. .TP \fB\-l\fR, \fB\-\-language\fR \fI\fR Force a language instead of inferring from extension. Accepts a canonical language name (`rust`, `python`, `cpp`, …) or a file extension (`rs`, `py`, …). An unrecognized value is a hard error @@ -32,10 +34,12 @@ Force a language instead of inferring from extension. Accepts a canonical langua Disable auto\-skip of files marked as generated (e.g. `@generated`, `DO NOT EDIT`, `GENERATED CODE` near the top). By default the CLI skips such files so generated bindings do not skew metrics .TP \fB\-\-paths\-from\fR \fI\fR -Read newline\-separated input paths from a file. Use `\-` to read from stdin. Combined as a union with any `\-\-paths` values; globs still apply. Blank lines are skipped; `#` is treated as a path character (not a comment). To pass a file literally named `\-`, use `./\-` +Read newline\-separated input paths from a file. Use `\-` to read from stdin. Combined as a union with any `\-\-paths` values. `\-\-include` globs still apply; `\-\-exclude` globs do not reach an entry that names a file directly, since an explicitly\-named path overrides the deny\-set. Blank lines are skipped; `#` is treated as a path character (not a comment). To pass a file literally named `\-`, use `./\-` .TP \fB\-\-exclude\-from\fR \fI\fR -Read additional `\-\-exclude` glob patterns from a file (one per line, `.gitignore`\-style). Blank lines and lines whose first non\-whitespace character is `#` are skipped. Use `\-` to read from stdin; to pass a file literally named `\-`, use `./\-`. Patterns are unioned with any `\-\-exclude` values into a single deny\-set; order does not matter. Convention is a `.bcaignore` at the repo root, mirroring `.gitignore` / `.dockerignore` +Read additional `\-\-exclude` glob patterns from a file (one per line, `.gitignore`\-style). Blank lines and lines whose first non\-whitespace character is `#` are skipped. Use `\-` to read from stdin; to pass a file literally named `\-`, use `./\-`. Patterns are unioned with any `\-\-exclude` values into a single deny\-set; order does not matter. Convention is a `.bcaignore` at the repo root, mirroring `.gitignore` / `.dockerignore`. + +Carries the same scope as `\-\-exclude`: these patterns shape the directory walk, and a file named directly on the command line overrides them (with a warning naming the glob). Put gate\-only exemptions in `\-\-check\-exclude\-from` / `[check] exclude`. .TP \fB\-\-no\-ignore\fR Disable `.gitignore` / `.ignore` / global gitignore awareness when expanding input directories. Explicit file paths are always honored regardless of this flag diff --git a/man/bca-report.1 b/man/bca-report.1 index 70e1e209c..4710b4d11 100644 --- a/man/bca-report.1 +++ b/man/bca-report.1 @@ -58,7 +58,9 @@ Input files or directories to analyze. Unioned with any positional `[PATHS]`. De Glob to include files. Repeat the flag to add multiple globs (`\-I \*(Aq*.rs\*(Aq \-I \*(Aq*.toml\*(Aq`); each occurrence takes exactly one value, so a positional argument that follows is never swallowed. A leading `./` is optional: `dir/**` and `./dir/**` are equivalent .TP \fB\-X\fR, \fB\-\-exclude\fR \fI\fR -Glob to exclude files. Repeat the flag to add multiple globs (`\-X \*(Aq*.tmp\*(Aq \-X \*(Aq*.bak\*(Aq`); each occurrence takes exactly one value, so a positional argument that follows is never swallowed. A leading `./` is optional: `dir/**` and `./dir/**` are equivalent. CLI values are *merged with* (unioned, not a replacement for) any `bca.toml` `exclude` list and any `\-\-exclude\-from` patterns, so a CLI `\-\-exclude` never silently un\-excludes a directory the project config deliberately skipped. Pass `\-\-no\-config` to ignore the manifest entirely +Glob to exclude files. Repeat the flag to add multiple globs (`\-X \*(Aq*.tmp\*(Aq \-X \*(Aq*.bak\*(Aq`); each occurrence takes exactly one value, so a positional argument that follows is never swallowed. A leading `./` is optional: `dir/**` and `./dir/**` are equivalent. CLI values are *merged with* (unioned, not a replacement for) any `bca.toml` `exclude` list and any `\-\-exclude\-from` patterns, so a CLI `\-\-exclude` never silently un\-excludes a directory the project config deliberately skipped. Pass `\-\-no\-config` to ignore the manifest entirely. + +Shapes directory\-walk scope only: a file named directly on the command line overrides every exclude glob and is analyzed anyway (the ripgrep/fd convention), with a warning on stderr naming the glob it overrode. `\-I` / `\-\-include` is not overridden. To exempt something from `bca check`\*(Aqs threshold gate whichever way it is named, use `\-\-check\-exclude` / `[check] exclude` instead. .TP \fB\-l\fR, \fB\-\-language\fR \fI\fR Force a language instead of inferring from extension. Accepts a canonical language name (`rust`, `python`, `cpp`, …) or a file extension (`rs`, `py`, …). An unrecognized value is a hard error @@ -67,10 +69,12 @@ Force a language instead of inferring from extension. Accepts a canonical langua Disable auto\-skip of files marked as generated (e.g. `@generated`, `DO NOT EDIT`, `GENERATED CODE` near the top). By default the CLI skips such files so generated bindings do not skew metrics .TP \fB\-\-paths\-from\fR \fI\fR -Read newline\-separated input paths from a file. Use `\-` to read from stdin. Combined as a union with any `\-\-paths` values; globs still apply. Blank lines are skipped; `#` is treated as a path character (not a comment). To pass a file literally named `\-`, use `./\-` +Read newline\-separated input paths from a file. Use `\-` to read from stdin. Combined as a union with any `\-\-paths` values. `\-\-include` globs still apply; `\-\-exclude` globs do not reach an entry that names a file directly, since an explicitly\-named path overrides the deny\-set. Blank lines are skipped; `#` is treated as a path character (not a comment). To pass a file literally named `\-`, use `./\-` .TP \fB\-\-exclude\-from\fR \fI\fR -Read additional `\-\-exclude` glob patterns from a file (one per line, `.gitignore`\-style). Blank lines and lines whose first non\-whitespace character is `#` are skipped. Use `\-` to read from stdin; to pass a file literally named `\-`, use `./\-`. Patterns are unioned with any `\-\-exclude` values into a single deny\-set; order does not matter. Convention is a `.bcaignore` at the repo root, mirroring `.gitignore` / `.dockerignore` +Read additional `\-\-exclude` glob patterns from a file (one per line, `.gitignore`\-style). Blank lines and lines whose first non\-whitespace character is `#` are skipped. Use `\-` to read from stdin; to pass a file literally named `\-`, use `./\-`. Patterns are unioned with any `\-\-exclude` values into a single deny\-set; order does not matter. Convention is a `.bcaignore` at the repo root, mirroring `.gitignore` / `.dockerignore`. + +Carries the same scope as `\-\-exclude`: these patterns shape the directory walk, and a file named directly on the command line overrides them (with a warning naming the glob). Put gate\-only exemptions in `\-\-check\-exclude\-from` / `[check] exclude`. .TP \fB\-\-no\-ignore\fR Disable `.gitignore` / `.ignore` / global gitignore awareness when expanding input directories. Explicit file paths are always honored regardless of this flag diff --git a/man/bca-strip-comments.1 b/man/bca-strip-comments.1 index 0a648b080..2e2ce1a2f 100644 --- a/man/bca-strip-comments.1 +++ b/man/bca-strip-comments.1 @@ -26,7 +26,9 @@ Input files or directories to analyze. Unioned with any positional `[PATHS]`. De Glob to include files. Repeat the flag to add multiple globs (`\-I \*(Aq*.rs\*(Aq \-I \*(Aq*.toml\*(Aq`); each occurrence takes exactly one value, so a positional argument that follows is never swallowed. A leading `./` is optional: `dir/**` and `./dir/**` are equivalent .TP \fB\-X\fR, \fB\-\-exclude\fR \fI\fR -Glob to exclude files. Repeat the flag to add multiple globs (`\-X \*(Aq*.tmp\*(Aq \-X \*(Aq*.bak\*(Aq`); each occurrence takes exactly one value, so a positional argument that follows is never swallowed. A leading `./` is optional: `dir/**` and `./dir/**` are equivalent. CLI values are *merged with* (unioned, not a replacement for) any `bca.toml` `exclude` list and any `\-\-exclude\-from` patterns, so a CLI `\-\-exclude` never silently un\-excludes a directory the project config deliberately skipped. Pass `\-\-no\-config` to ignore the manifest entirely +Glob to exclude files. Repeat the flag to add multiple globs (`\-X \*(Aq*.tmp\*(Aq \-X \*(Aq*.bak\*(Aq`); each occurrence takes exactly one value, so a positional argument that follows is never swallowed. A leading `./` is optional: `dir/**` and `./dir/**` are equivalent. CLI values are *merged with* (unioned, not a replacement for) any `bca.toml` `exclude` list and any `\-\-exclude\-from` patterns, so a CLI `\-\-exclude` never silently un\-excludes a directory the project config deliberately skipped. Pass `\-\-no\-config` to ignore the manifest entirely. + +Shapes directory\-walk scope only: a file named directly on the command line overrides every exclude glob and is analyzed anyway (the ripgrep/fd convention), with a warning on stderr naming the glob it overrode. `\-I` / `\-\-include` is not overridden. To exempt something from `bca check`\*(Aqs threshold gate whichever way it is named, use `\-\-check\-exclude` / `[check] exclude` instead. .TP \fB\-l\fR, \fB\-\-language\fR \fI\fR Force a language instead of inferring from extension. Accepts a canonical language name (`rust`, `python`, `cpp`, …) or a file extension (`rs`, `py`, …). An unrecognized value is a hard error @@ -35,10 +37,12 @@ Force a language instead of inferring from extension. Accepts a canonical langua Disable auto\-skip of files marked as generated (e.g. `@generated`, `DO NOT EDIT`, `GENERATED CODE` near the top). By default the CLI skips such files so generated bindings do not skew metrics .TP \fB\-\-paths\-from\fR \fI\fR -Read newline\-separated input paths from a file. Use `\-` to read from stdin. Combined as a union with any `\-\-paths` values; globs still apply. Blank lines are skipped; `#` is treated as a path character (not a comment). To pass a file literally named `\-`, use `./\-` +Read newline\-separated input paths from a file. Use `\-` to read from stdin. Combined as a union with any `\-\-paths` values. `\-\-include` globs still apply; `\-\-exclude` globs do not reach an entry that names a file directly, since an explicitly\-named path overrides the deny\-set. Blank lines are skipped; `#` is treated as a path character (not a comment). To pass a file literally named `\-`, use `./\-` .TP \fB\-\-exclude\-from\fR \fI\fR -Read additional `\-\-exclude` glob patterns from a file (one per line, `.gitignore`\-style). Blank lines and lines whose first non\-whitespace character is `#` are skipped. Use `\-` to read from stdin; to pass a file literally named `\-`, use `./\-`. Patterns are unioned with any `\-\-exclude` values into a single deny\-set; order does not matter. Convention is a `.bcaignore` at the repo root, mirroring `.gitignore` / `.dockerignore` +Read additional `\-\-exclude` glob patterns from a file (one per line, `.gitignore`\-style). Blank lines and lines whose first non\-whitespace character is `#` are skipped. Use `\-` to read from stdin; to pass a file literally named `\-`, use `./\-`. Patterns are unioned with any `\-\-exclude` values into a single deny\-set; order does not matter. Convention is a `.bcaignore` at the repo root, mirroring `.gitignore` / `.dockerignore`. + +Carries the same scope as `\-\-exclude`: these patterns shape the directory walk, and a file named directly on the command line overrides them (with a warning naming the glob). Put gate\-only exemptions in `\-\-check\-exclude\-from` / `[check] exclude`. .TP \fB\-\-no\-ignore\fR Disable `.gitignore` / `.ignore` / global gitignore awareness when expanding input directories. Explicit file paths are always honored regardless of this flag diff --git a/man/bca-vcs.1 b/man/bca-vcs.1 index fbe38bbc4..fc11bdacf 100644 --- a/man/bca-vcs.1 +++ b/man/bca-vcs.1 @@ -120,7 +120,9 @@ Input files or directories to analyze. Unioned with any positional `[PATHS]`. De Glob to include files. Repeat the flag to add multiple globs (`\-I \*(Aq*.rs\*(Aq \-I \*(Aq*.toml\*(Aq`); each occurrence takes exactly one value, so a positional argument that follows is never swallowed. A leading `./` is optional: `dir/**` and `./dir/**` are equivalent .TP \fB\-X\fR, \fB\-\-exclude\fR \fI\fR -Glob to exclude files. Repeat the flag to add multiple globs (`\-X \*(Aq*.tmp\*(Aq \-X \*(Aq*.bak\*(Aq`); each occurrence takes exactly one value, so a positional argument that follows is never swallowed. A leading `./` is optional: `dir/**` and `./dir/**` are equivalent. CLI values are *merged with* (unioned, not a replacement for) any `bca.toml` `exclude` list and any `\-\-exclude\-from` patterns, so a CLI `\-\-exclude` never silently un\-excludes a directory the project config deliberately skipped. Pass `\-\-no\-config` to ignore the manifest entirely +Glob to exclude files. Repeat the flag to add multiple globs (`\-X \*(Aq*.tmp\*(Aq \-X \*(Aq*.bak\*(Aq`); each occurrence takes exactly one value, so a positional argument that follows is never swallowed. A leading `./` is optional: `dir/**` and `./dir/**` are equivalent. CLI values are *merged with* (unioned, not a replacement for) any `bca.toml` `exclude` list and any `\-\-exclude\-from` patterns, so a CLI `\-\-exclude` never silently un\-excludes a directory the project config deliberately skipped. Pass `\-\-no\-config` to ignore the manifest entirely. + +Shapes directory\-walk scope only: a file named directly on the command line overrides every exclude glob and is analyzed anyway (the ripgrep/fd convention), with a warning on stderr naming the glob it overrode. `\-I` / `\-\-include` is not overridden. To exempt something from `bca check`\*(Aqs threshold gate whichever way it is named, use `\-\-check\-exclude` / `[check] exclude` instead. .TP \fB\-l\fR, \fB\-\-language\fR \fI\fR Force a language instead of inferring from extension. Accepts a canonical language name (`rust`, `python`, `cpp`, …) or a file extension (`rs`, `py`, …). An unrecognized value is a hard error @@ -129,10 +131,12 @@ Force a language instead of inferring from extension. Accepts a canonical langua Disable auto\-skip of files marked as generated (e.g. `@generated`, `DO NOT EDIT`, `GENERATED CODE` near the top). By default the CLI skips such files so generated bindings do not skew metrics .TP \fB\-\-paths\-from\fR \fI\fR -Read newline\-separated input paths from a file. Use `\-` to read from stdin. Combined as a union with any `\-\-paths` values; globs still apply. Blank lines are skipped; `#` is treated as a path character (not a comment). To pass a file literally named `\-`, use `./\-` +Read newline\-separated input paths from a file. Use `\-` to read from stdin. Combined as a union with any `\-\-paths` values. `\-\-include` globs still apply; `\-\-exclude` globs do not reach an entry that names a file directly, since an explicitly\-named path overrides the deny\-set. Blank lines are skipped; `#` is treated as a path character (not a comment). To pass a file literally named `\-`, use `./\-` .TP \fB\-\-exclude\-from\fR \fI\fR -Read additional `\-\-exclude` glob patterns from a file (one per line, `.gitignore`\-style). Blank lines and lines whose first non\-whitespace character is `#` are skipped. Use `\-` to read from stdin; to pass a file literally named `\-`, use `./\-`. Patterns are unioned with any `\-\-exclude` values into a single deny\-set; order does not matter. Convention is a `.bcaignore` at the repo root, mirroring `.gitignore` / `.dockerignore` +Read additional `\-\-exclude` glob patterns from a file (one per line, `.gitignore`\-style). Blank lines and lines whose first non\-whitespace character is `#` are skipped. Use `\-` to read from stdin; to pass a file literally named `\-`, use `./\-`. Patterns are unioned with any `\-\-exclude` values into a single deny\-set; order does not matter. Convention is a `.bcaignore` at the repo root, mirroring `.gitignore` / `.dockerignore`. + +Carries the same scope as `\-\-exclude`: these patterns shape the directory walk, and a file named directly on the command line overrides them (with a warning naming the glob). Put gate\-only exemptions in `\-\-check\-exclude\-from` / `[check] exclude`. .TP \fB\-\-no\-ignore\fR Disable `.gitignore` / `.ignore` / global gitignore awareness when expanding input directories. Explicit file paths are always honored regardless of this flag From 6abae2b3bc48109b73110606efd0817a4d017199 Mon Sep 17 00:00:00 2001 From: Elijah Zupancic Date: Sat, 1 Aug 2026 06:38:40 -0700 Subject: [PATCH 20/36] fix(walk): case-fold the seed's language lookup The #1146 exclude-override warning gated itself on a local `extension()` + `get_from_ext` pair, which is the body of the library's `get_language_for_file` minus the ASCII case-fold #1111 put inside it. A mixed-case extension therefore resolved to no language and returned early, so `bca check SKIPME/A.RS` analyzed the file and reported its offenders while saying nothing about the override it had just performed -- the exact silent asymmetry #1146 exists to remove, surviving for every mixed-case extension. Call `get_language_for_file` instead and drop the wrapper, whose whole doc comment was the imprecise claim that it mirrored the per-file dispatch (the dispatch uses `guess_language`, which also falls back to modeline and shebang). The remaining rationale moves onto `warn_exclude_overridden`, including why the extension half is deliberate here: the walk has not read the file's bytes. Verified by perturbation -- restoring the old spelling makes the new test the only failure in the CLI suite, and its output shows the defect directly (`B.RS` reported as an offender, only `a.rs` warned). --- big-code-analysis-cli/src/lib.rs | 2 +- big-code-analysis-cli/src/walk.rs | 21 +++++----- .../tests/explicit_path_excludes.rs | 40 +++++++++++++++++++ 3 files changed, 52 insertions(+), 11 deletions(-) diff --git a/big-code-analysis-cli/src/lib.rs b/big-code-analysis-cli/src/lib.rs index f8f5d2f32..dfa2a7081 100644 --- a/big-code-analysis-cli/src/lib.rs +++ b/big-code-analysis-cli/src/lib.rs @@ -104,7 +104,7 @@ use big_code_analysis::{ ConcurrentRunner, CountCollector, FilesData, MetricsOptions, NumJobs, PreprocResults, SuppressionPolicy, }; -use big_code_analysis::{FuncSpace, Ops, get_from_ext, read_file}; +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 diff --git a/big-code-analysis-cli/src/walk.rs b/big-code-analysis-cli/src/walk.rs index 20176f34b..763e79757 100644 --- a/big-code-analysis-cli/src/walk.rs +++ b/big-code-analysis-cli/src/walk.rs @@ -158,8 +158,18 @@ impl WalkFilters<'_> { /// where lockfiles, Markdown, and generated assets are the majority /// and produce no output either way. Warning that such a file is /// being "analyzed anyway" would be both noisy and untrue. + /// + /// [`get_language_for_file`] rather than a local `extension()` + + /// `get_from_ext` pair, which is what this shipped as: that spelling + /// dropped the ASCII case-fold #1111 put *inside* + /// `get_language_for_file`, so `bca check SKIPME/A.RS` analyzed the + /// file and stayed silent about the override — the exact asymmetry + /// #1146 exists to remove, surviving for every mixed-case extension. + /// It is deliberately the extension half of `guess_language` only: + /// the dispatch's modeline and shebang fallbacks need the file's + /// bytes, and the walk has not read them here. fn warn_exclude_overridden(&self, seed: &Path, match_path: &Path) { - if !self.language_forced && !seed_has_known_language(seed) { + if !self.language_forced && get_language_for_file(seed).is_none() { return; } if let Some(glob) = self @@ -175,15 +185,6 @@ impl WalkFilters<'_> { } } -/// Does `seed`'s extension map to a supported language? Mirrors the -/// per-file dispatch's own extension lookup, which is what decides -/// whether a walked file is analyzed or silently skipped. -fn seed_has_known_language(seed: &Path) -> bool { - seed.extension() - .and_then(|ext| ext.to_str()) - .is_some_and(|ext| get_from_ext(ext).is_some()) -} - /// Resolved file set plus the subset of seeds that were *explicitly /// named files* (not products of a directory expansion). /// diff --git a/big-code-analysis-cli/tests/explicit_path_excludes.rs b/big-code-analysis-cli/tests/explicit_path_excludes.rs index c25ef7548..e516ecac1 100644 --- a/big-code-analysis-cli/tests/explicit_path_excludes.rs +++ b/big-code-analysis-cli/tests/explicit_path_excludes.rs @@ -205,6 +205,46 @@ fn override_warning_is_silent_for_a_seed_no_language_claims() { .stderr(predicate::str::contains("notes.md matches an exclude").not()); } +/// The seed's language must be resolved through the library's own +/// `get_language_for_file`, ASCII case-fold included (#1111). A local +/// `extension()` + `get_from_ext` pair — what this shipped as — skips +/// that fold, so `SKIPME/A.RS` resolved to no language, the guard above +/// returned early, and the file was analyzed with the override left +/// silent: the exact asymmetry #1146 removes, surviving for every +/// mixed-case extension. +/// +/// Both spellings are named in one invocation. The lowercase half is the +/// control: a build that stopped warning altogether fails on it instead +/// of passing this test for the wrong reason. +#[test] +fn override_warning_survives_a_mixed_case_extension() { + let dir = fixture("exclude = [\"./skipme/**\"]\n"); + fs::write( + dir.path().join("skipme").join("B.RS"), + branchy("upper_offender"), + ) + .unwrap(); + + cli(dir.path()) + .args([ + "check", + "skipme/B.RS", + "skipme/a.rs", + "--no-summary", + "--no-remediation", + ]) + .assert() + .code(2) + // Reported as an offender, so the file really was analyzed and + // the unannounced override was a live one. + .stderr(predicate::str::contains("upper_offender")) + .stderr(predicate::str::contains( + "bca: warning: skipme/B.RS matches an exclude pattern (./skipme/**) \ + but was named explicitly; analyzing anyway", + )) + .stderr(predicate::str::contains("skipme/a.rs matches an exclude")); +} + /// `[check] exclude` is the surface that survives an explicit path: the /// file is analysed, its violation is dropped, and the existing /// `[check.exclude]` skip line reports the drop. Pinned deliberately From 2828ddacd40776f7e9160df0fd6bfccb3b03765d Mon Sep 17 00:00:00 2001 From: Elijah Zupancic Date: Sat, 1 Aug 2026 06:53:41 -0700 Subject: [PATCH 21/36] fix(cli): exempt BrokenPipe from the vcs write path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three follow-ups from a review of the whole batch branch. The `write_text` flush added while closing #1132 made a closed consumer pipe visible as an `io::Error`, and every `vcs` emit site died on it — so `bca vcs --format json | head` exited 1 where `bca dump | head` exits 0. That contradicts the policy the same batch documents on `path_io::write_stdout_parts_or_die` and pins for `dump` alone. `die_unless_broken_pipe` now routes all four emit sites through one place. The regression test uses a 400-file fixture so the document outgrows the 64 KiB pipe buffer and the close is actually observable. The privileged-runner skip guards added for #1131 could never fire: `deny_all_access` probes with `fs::read`, which returns `EISDIR` for every directory whatever its mode, so under root the tests proceeded and failed on the exit-code assertion instead of skipping. The new `deny_dir_listing` probes with `read_dir`, and `unlistable_dir` is refactored onto it. CHANGELOG gains the entries for every metric-value and CLI-contract change in the batch — #1102, #1106, #1117, #1130, #1131, #1142, #1146, #1147 and #1149 were all unrecorded, as was `BlameSession` under `### Added` despite already being in STABILITY.md. --- CHANGELOG.md | 77 ++++++++++++++++++ big-code-analysis-cli/src/path_io.rs | 24 ++++++ big-code-analysis-cli/src/vcs_command.rs | 2 +- big-code-analysis-cli/src/vcs_jit.rs | 4 +- big-code-analysis-cli/src/vcs_trend.rs | 2 +- big-code-analysis-cli/tests/common/mod.rs | 32 ++++++-- big-code-analysis-cli/tests/diff_since.rs | 2 +- big-code-analysis-cli/tests/read_failures.rs | 84 ++++++++++++++++++++ big-code-analysis-cli/tests/vcs.rs | 2 +- 9 files changed, 216 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2156663ea..06ae4ce36 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,14 @@ for historical reference. ### Added +- `big_code_analysis::vcs::BlameSession`, a per-thread handle obtained + from the new `PerFunctionBlame::session` (#1117). It carries the + thread-local repository handle, its object cache, the parsed + `.mailmap`, and the resolved-commit memo, so a caller blaming many + files in one repository pays each once instead of once per file; + `PerFunctionBlame::per_function` keeps its one-shot semantics by + building and discarding a session. Additive — see `STABILITY.md`. + - `ConcurrentRunner::without_path_verification`, which skips the per-path `is_file()` check during dispatch (#1114). `FilesData::paths` is documented as a terminal file list, so the check is a safety net for @@ -94,6 +102,66 @@ for historical reference. ### Changed +- **Metric values move.** A ternary's condition and its two branch + operands now each count as a Fitzpatrick Rule 9 unary condition in + `abc.conditions`, matching what Java, Groovy, and C# already did + (#1102). `a ? !b : !c` scored 1 — the `?` alone — and now scores 4. + Affects C, C++, Mozcpp, Objective-C, JavaScript, TypeScript, TSX, + Mozjs, PHP, and Perl. Ruby and Python are **not** covered by this + pass and still score the flat `+1`, so a cross-language comparison of + ternary-heavy code remains uneven. + +- **Metric values move.** `nargs` counts formal parameters for Elixir + (#1142) and for Perl subroutine signatures (#1147), both of which + reported 0 unconditionally. Elixir's `def`/`defp`/`defmacro` are + `Call` nodes whose parameter list sits two `arguments` levels down, so + the shared `parameters`-field heuristic found nothing; Perl's + signature is an unnamed `function_signature` child. A `def` inside + `quote do … end` still contributes nothing (#310), an `@_`-style Perl + sub still reads 0 correctly, and Bash still reports 0 (the shell has + no formal parameter list). Anonymous Perl subs read 0 pending an + upstream grammar fix. + +- **Metric values move.** Python resets structural nesting at a `def` + boundary, so a function defined inside a conditional is scored against + its own depth rather than the enclosing function's (#1149). Python was + the only family with a syntactic function-definition node that did + not, charging an inherited-conditional surcharge no sibling language + charges; a `def` two conditionals deep now scores 2 where it scored 4. + +- `bca ops` opens the same function spaces as `bca metrics`, through the + same source-aware promote-and-classify predicate (#1130). The two + walks each carried their own copy of the decision and the `ops` copy + was byte-less, so every Elixir input came back as a bare file-level + space while `bca metrics` returned the full module/function tree. + `tests/ops_metrics_space_parity.rs` pins the agreement per language. + `bca functions` and `bca find --type function` still use the byte-less + predicate and remain blind to Elixir — tracked as `FIXME(#1162)`. + +- Every walking subcommand exits `1` when the traversal could not read + an entry — typically a directory the process cannot list (#1131). A + whole subtree drops out of the resolved set before any file is + selected, so the per-file read tally stayed zero and the run reported + success over a tree it had not read; `bca check` was the worst case, + being indistinguishable from a clean gate. `bca diff --since` reports + it as `UnwalkableInputs` and `bca vcs` gates its ranking the same way. + An ignore file still prunes such a directory before the walker + descends; `--exclude` does not, being a post-walk filter. + +- A path named directly on the command line still overrides the walker's + `--exclude` / `--exclude-from` / `.bcaignore` / manifest `exclude` + deny-set, but now says so on stderr, naming the glob it overrode + (#1146). Silent for a seed no language claims, so a + `git diff --name-only | bca … --paths-from -` pipeline does not warn + about lockfiles and Markdown. `[check] exclude` is unchanged: it is + gate scope, survives an explicit path, and is where a + "never gate this" entry belongs. An absolute explicit path now anchors + against the CWD before the `[check] exclude` globs are applied, so a + `./`-prefixed glob matches every spelling of the same file. + +- `bca strip-comments` terminates non-UTF-8 output on stdout with a + newline, matching the UTF-8 branch, and flushes both (#1132). + - `ConcurrentRunner::new`'s `num_jobs` is now the consumer-thread count rather than a budget shared with a dedicated producer thread, which spawned `max(2, num_jobs) - 1` consumers and left one slot idle @@ -420,6 +488,15 @@ for historical reference. ### Fixed +- `bca vcs`, `bca vcs commit`, and `bca vcs trend` exit `0` again when + their consumer closes the pipe (`bca vcs … | head`). The + `write_text` flush below made the resulting `EPIPE` visible, and those + emitters `die`d on every I/O error, so a routine pipeline became + `error: writing vcs output: Broken pipe` and exit `1` while `dump`, + `metrics`, and `ops` piped into the same consumer exited `0`. The + `BrokenPipe` exemption the rest of the CLI applies is now shared by + the `vcs` family; a genuine write failure still exits `1`. + - `bca vcs`, `bca vcs commit`, and `bca vcs trend` exit `1` when their report cannot be written to stdout. All three emit compact JSON, and `std::io::Stdout` is a `LineWriter` over a 1 KiB buffer: a document diff --git a/big-code-analysis-cli/src/path_io.rs b/big-code-analysis-cli/src/path_io.rs index f6cc3f0d4..dbf7586da 100644 --- a/big-code-analysis-cli/src/path_io.rs +++ b/big-code-analysis-cli/src/path_io.rs @@ -52,6 +52,30 @@ pub(crate) fn write_stdout_or_die(bytes: &[u8]) { write_stdout_parts_or_die(&[bytes]); } +/// Apply [`write_stdout_parts_or_die`]'s policy to an emission that +/// wrote itself: `die` with `context` on any failure other than +/// `BrokenPipe`. +/// +/// The `vcs` family emits through [`crate::formats::write_text`] rather +/// than through the helpers above, and used to `die` on *every* error. +/// Once `write_text` grew the flush that makes a write failure visible +/// at all (#1132), that turned the routine `bca vcs … | head` into an +/// `error: writing vcs output: Broken pipe` and an exit 1, while +/// `dump` / `metrics` / `ops` piped into the same consumer exit 0. A +/// closed consumer is not a tool error on one subcommand and routine on +/// the rest. +/// +/// Safe for the `--output ` half of those emitters too: a regular +/// file cannot produce `EPIPE`, so the exemption can only fire on the +/// stdout path it is written for. +pub(crate) fn die_unless_broken_pipe(result: std::io::Result<()>, context: &str) { + if let Err(e) = result + && e.kind() != ErrorKind::BrokenPipe + { + die(format_args!("{context}: {e}")); + } +} + /// Write `text` and a trailing newline to stdout, under one lock. /// /// The `println!` that post-walk emissions (`count`'s tally, `preproc`'s diff --git a/big-code-analysis-cli/src/vcs_command.rs b/big-code-analysis-cli/src/vcs_command.rs index 948286acd..8f4d03722 100644 --- a/big-code-analysis-cli/src/vcs_command.rs +++ b/big-code-analysis-cli/src/vcs_command.rs @@ -113,7 +113,7 @@ pub(crate) fn run(mut globals: GlobalOpts, args: VcsArgs) { files: entries, }; - emit(&report, &args).unwrap_or_else(|e| die(format_args!("writing vcs output: {e}"))); + crate::die_unless_broken_pipe(emit(&report, &args), "writing vcs output"); } /// Reject the cache controls when they accompany a `vcs` subcommand. diff --git a/big-code-analysis-cli/src/vcs_jit.rs b/big-code-analysis-cli/src/vcs_jit.rs index ce2ec75a1..1bf23f842 100644 --- a/big-code-analysis-cli/src/vcs_jit.rs +++ b/big-code-analysis-cli/src/vcs_jit.rs @@ -56,7 +56,7 @@ fn run_commit(root: &Path, args: &VcsArgs, jit: &JitArgs) { let report = score_commit(root, &jit.commit, &options).unwrap_or_else(|e| die(format_args!("{e}"))); - emit(&report, jit).unwrap_or_else(|e| die(format_args!("writing jit output: {e}"))); + crate::die_unless_broken_pipe(emit(&report, jit), "writing jit output"); // CI gate: a score at or above the threshold exits 2 (the `check` // "metric gate" convention; exit 1 stays reserved for tool errors). @@ -81,7 +81,7 @@ fn run_diff(source: &Path, jit: &JitArgs) { let diff = read_diff(source).unwrap_or_else(|e| die(format_args!("reading diff: {e}"))); let report = score_diff(&diff).unwrap_or_else(|e| die(format_args!("{e}"))); - emit(&report, jit).unwrap_or_else(|e| die(format_args!("writing jit output: {e}"))); + crate::die_unless_broken_pipe(emit(&report, jit), "writing jit output"); if let Some(threshold) = jit.fail_above && report.partial_risk_score >= threshold diff --git a/big-code-analysis-cli/src/vcs_trend.rs b/big-code-analysis-cli/src/vcs_trend.rs index f7cb6a4a7..494b2d41b 100644 --- a/big-code-analysis-cli/src/vcs_trend.rs +++ b/big-code-analysis-cli/src/vcs_trend.rs @@ -33,7 +33,7 @@ pub(crate) fn run(root: &Path, args: &VcsArgs, trend: &TrendArgs) { // `args.top` (parent `--top`) keeps the riskiest files; `top_deltas` // trims each delta list. let report = wire::VcsTrend::from_trend(&result, args.top, trend.top_deltas); - emit(&report, trend).unwrap_or_else(|e| die(format_args!("writing vcs trend output: {e}"))); + crate::die_unless_broken_pipe(emit(&report, trend), "writing vcs trend output"); } /// Serialize the trend in the requested structured format to a single file diff --git a/big-code-analysis-cli/tests/common/mod.rs b/big-code-analysis-cli/tests/common/mod.rs index b99be46b4..c70a5009c 100644 --- a/big-code-analysis-cli/tests/common/mod.rs +++ b/big-code-analysis-cli/tests/common/mod.rs @@ -227,6 +227,26 @@ pub fn deny_all_access(path: &Path) -> bool { std::fs::read(path).is_err() } +/// Strip every permission bit from the *directory* `path` so listing it +/// fails with `EACCES`, returning whether the denial actually took +/// effect. +/// +/// The directory counterpart to [`deny_all_access`], and not a +/// convenience wrapper: that function probes with `fs::read`, which +/// fails with `EISDIR` on **every** directory regardless of its mode, so +/// it reports the denial as effective even where it is not. A caller +/// that used it to guard a "skip when privileged" branch (root ignores +/// mode bits) would never take that branch and would fail instead. The +/// probe here is `read_dir` — the operation the walk actually performs. +#[cfg(unix)] +#[allow(dead_code)] +pub fn deny_dir_listing(path: &Path) -> bool { + use std::os::unix::fs::PermissionsExt; + + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o000)).expect("chmod 000"); + std::fs::read_dir(path).is_err() +} + /// Write `body` to `name` under `dir` and lock it with /// [`deny_all_access`], returning the path — or `None` when the lock /// could not be made to bite. @@ -249,9 +269,10 @@ pub fn unreadable_fixture(dir: &Path, name: &str, body: &str) -> Option Option { - use std::os::unix::fs::PermissionsExt; - let path = dir.join(name); std::fs::create_dir_all(&path).expect("create fixture dir"); std::fs::write(path.join(file), body).expect("write fixture"); - std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o000)).expect("chmod 000"); - std::fs::read_dir(&path).is_err().then_some(path) + deny_dir_listing(&path).then_some(path) } /// Give a mode-stripped fixture directory its bits back so `TempDir`'s diff --git a/big-code-analysis-cli/tests/diff_since.rs b/big-code-analysis-cli/tests/diff_since.rs index a740034bc..8996f33de 100644 --- a/big-code-analysis-cli/tests/diff_since.rs +++ b/big-code-analysis-cli/tests/diff_since.rs @@ -603,7 +603,7 @@ fn since_errors_when_the_after_side_has_an_unlistable_directory() { "the readable control must pair every file: {removed:?}" ); - if !common::deny_all_access(&nested) { + if !common::deny_dir_listing(&nested) { eprintln!("skipping: this process can list a mode-000 directory"); return; } diff --git a/big-code-analysis-cli/tests/read_failures.rs b/big-code-analysis-cli/tests/read_failures.rs index 575b4ce1d..983cfbc32 100644 --- a/big-code-analysis-cli/tests/read_failures.rs +++ b/big-code-analysis-cli/tests/read_failures.rs @@ -958,6 +958,90 @@ mod unix { assert_vcs_stdout_write_failure_exits_one(&["trend"]); } + /// Capacity of a Linux pipe, and the reason the fixture below is + /// 400 files rather than one. A document that fits here is accepted + /// whole before the reader can close, so the child never meets the + /// closed pipe and the assertion passes against an unfixed build. + const PIPE_BUFFER_BYTES: usize = 64 * 1_024; + + /// A repository with enough tracked files that `bca vcs --top 0` + /// emits more than [`PIPE_BUFFER_BYTES`] of compact JSON. + fn git_repo_with_many_files() -> Option { + let dir = TempDir::new().expect("tempdir"); + if !git(&dir, &["init", "-q", "-b", "main"]) + || !git(&dir, &["config", "commit.gpgsign", "false"]) + { + return None; + } + for i in 0..400 { + write_fixture(&dir, &format!("f{i:04}.c"), TRIVIAL_C); + } + if !git(&dir, &["add", "."]) { + return None; + } + git(&dir, &["commit", "-qm", "add sources"]).then_some(dir) + } + + /// The pipe-close half of the #1132 contract, for the `vcs` family. + /// + /// `bca vcs … | head` is routine. `formats::write_text` had no + /// `BrokenPipe` exemption and `emit`'s caller `die`d on every error, + /// so once the flush that makes a write failure *visible* landed, + /// a closed consumer became `error: writing vcs output: Broken pipe` + /// and exit 1 — while `dump`, `metrics`, and `ops` piped into the + /// same consumer exit 0. The + /// `vcs_*_exits_one_when_stdout_cannot_be_written` tests above + /// cannot see this: `/dev/full` fails every write with `ENOSPC`, + /// which must stay fatal. + /// + /// Both runs are load-bearing. The control asserts the document + /// really exceeds the pipe buffer, without which the child would + /// complete its write into the buffer and never observe the close — + /// and the test would pass against the unfixed code. + #[test] + fn vcs_exits_zero_when_its_consumer_closes_the_pipe() { + use std::io::Read; + + let Some(repo) = git_repo_with_many_files() else { + eprintln!("skipping: no usable git for the `bca vcs` fixture"); + return; + }; + let run = || { + common::std_bca_command_in(repo.path()) + .args(["vcs", "--no-config", "--top", "0", "--format", "json"]) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("spawn bca") + }; + + let control = run().wait_with_output().expect("wait for bca"); + assert!( + control.stdout.len() > PIPE_BUFFER_BYTES, + "the document must outgrow the pipe buffer for the close to \ + be observable at all; got {} bytes", + control.stdout.len(), + ); + + // Read one byte, then close the read end — the `head -c1` shape. + let mut child = run(); + let mut stdout = child.stdout.take().expect("piped stdout"); + let mut first = [0u8; 1]; + stdout.read_exact(&mut first).expect("read first byte"); + drop(stdout); + + let output = child.wait_with_output().expect("wait for bca"); + let stderr = String::from_utf8_lossy(&output.stderr).into_owned(); + assert!( + output.status.success(), + "a closed consumer pipe is routine, not a tool error; stderr: {stderr}" + ); + assert!( + stderr.is_empty(), + "a closed consumer pipe must be silent; stderr: {stderr}" + ); + } + /// The other half of the contract: `BrokenPipe` is not a tool error. /// `bca dump | head -1` is routine, and converting the banner from /// `println!` to a fallible write is exactly the change that could diff --git a/big-code-analysis-cli/tests/vcs.rs b/big-code-analysis-cli/tests/vcs.rs index c453785d9..6d1503044 100644 --- a/big-code-analysis-cli/tests/vcs.rs +++ b/big-code-analysis-cli/tests/vcs.rs @@ -863,7 +863,7 @@ fn vcs_exits_one_when_a_directory_cannot_be_listed() { .success() .stdout(predicate::str::contains("src/nested/inner.rs")); - if !common::deny_all_access(&nested) { + if !common::deny_dir_listing(&nested) { eprintln!("skipping: this process can list a mode-000 directory"); return; } From 004106420f9005137ac39eb0b0903afce065b2f4 Mon Sep 17 00:00:00 2001 From: Elijah Zupancic Date: Sat, 1 Aug 2026 07:18:41 -0700 Subject: [PATCH 22/36] test(cli): stop two guards skipping where they should run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both found by an audit-tests pass over the whole batch, and both demonstrated rather than argued. `read_failures`' git helper reported any non-zero git exit as "no usable git", so a host with a global `core.hooksPath` containing a failing `pre-commit` turned the entire `bca vcs` stdout contract into four silent skips — every ENOSPC case plus the BrokenPipe case added for the regression this batch introduced. Verified by running the compiled binary under such a HOME. The fixture now neutralises the host the way `blame_fixture` already does for #941 (hooks path, gpgsign, gc.auto, autocrlf), and only `git init` may skip: once a repository exists, every later command runs against a directory the test owns, so a failure is a fixture defect, not an environment to tolerate. `paths_discovery`'s unlistable-directory test was the one #1131 case left off the capability-probe convention, so a privileged runner failed on the exit-code assertion instead of skipping. Verified under `unshare -r`: it failed before, skips now. It uses the shared `deny_dir_listing`, which probes with `read_dir` — root ignores mode bits for a listing, while `fs::read` returns EISDIR whatever the mode, which is what made the original guard inert. --- .../tests/paths_discovery.rs | 14 ++-- big-code-analysis-cli/tests/read_failures.rs | 72 +++++++++++++------ 2 files changed, 62 insertions(+), 24 deletions(-) diff --git a/big-code-analysis-cli/tests/paths_discovery.rs b/big-code-analysis-cli/tests/paths_discovery.rs index e8ad0232e..5a8d8b1c9 100644 --- a/big-code-analysis-cli/tests/paths_discovery.rs +++ b/big-code-analysis-cli/tests/paths_discovery.rs @@ -628,8 +628,6 @@ fn paths_from_tolerates_non_utf8_line() { #[cfg(unix)] #[test] fn unreadable_subdir_warns_continues_then_fails_the_run() { - use std::os::unix::fs::PermissionsExt; - let dir = TempDir::new().unwrap(); let src = dir.path().join("src"); std::fs::create_dir_all(&src).unwrap(); @@ -637,10 +635,18 @@ fn unreadable_subdir_warns_continues_then_fails_the_run() { // A subdirectory the walk cannot descend into (mode 000). `ignore` // surfaces the EACCES as a per-entry error. + // + // The probe is `read_dir`, not `fs::read`: root ignores mode bits + // for a directory listing, but `fs::read` returns `EISDIR` whatever + // the mode, so probing with it reports a denial that is not there + // and the test fails instead of skipping under a privileged runner. let locked = src.join("locked"); std::fs::create_dir(&locked).unwrap(); std::fs::write(locked.join("hidden.py"), "def g(): return 2\n").unwrap(); - std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o000)).unwrap(); + if !common::deny_dir_listing(&locked) { + eprintln!("skipping: this process can list a mode-000 directory"); + return; + } cli(dir.path()) .args(["metrics", "--no-config", "--paths", src.to_str().unwrap()]) @@ -657,7 +663,7 @@ fn unreadable_subdir_warns_continues_then_fails_the_run() { .stdout(predicate::str::contains("hidden.py").not()); // Restore permissions so the TempDir can be cleaned up on drop. - std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o755)).unwrap(); + common::restore_dir_access(&locked); } // --- #1114: the directory walk runs in parallel ----------------------- diff --git a/big-code-analysis-cli/tests/read_failures.rs b/big-code-analysis-cli/tests/read_failures.rs index 983cfbc32..1843aaf94 100644 --- a/big-code-analysis-cli/tests/read_failures.rs +++ b/big-code-analysis-cli/tests/read_failures.rs @@ -841,22 +841,60 @@ mod unix { .is_ok_and(|status| status.success()) } + /// `git` that refuses to be skipped past. + /// + /// Only the initial `git init` may legitimately fail — a machine + /// without git. Once a repository exists, every later command + /// operates on a directory this test owns, so a failure is a defect + /// in the fixture, not an environment we should tolerate. Returning + /// `None` there turned an unrelated host misconfiguration into four + /// silently-passing tests. + fn git_or_panic(dir: &TempDir, args: &[&str]) { + assert!(git(dir, args), "git {args:?} failed in the vcs fixture"); + } + + /// `git init` plus the host-config neutralisation every fixture in + /// this workspace needs, returning `None` only when git is absent. + /// + /// `commit.gpgsign` alone is not enough. A global `core.hooksPath` + /// with a failing `pre-commit` makes every `git commit` here fail, + /// and because the old helper reported that as "no usable git", the + /// entire `bca vcs` stdout contract — both the ENOSPC cases and the + /// `BrokenPipe` case — evaporated into skips on such a machine. + /// `blame_fixture` in `src/vcs_command.rs` already neutralises this + /// class (#941); these fixtures now do the same. + fn git_init_neutralized() -> Option { + let dir = TempDir::new().expect("tempdir"); + if !git(&dir, &["init", "-q", "-b", "main"]) { + return None; + } + let hooks = dir.path().join("empty-hooks"); + fs::create_dir_all(&hooks).expect("create empty hooks dir"); + git_or_panic(&dir, &["config", "commit.gpgsign", "false"]); + git_or_panic( + &dir, + &[ + "config", + "core.hooksPath", + hooks.to_str().expect("utf8 hooks path"), + ], + ); + git_or_panic(&dir, &["config", "gc.auto", "0"]); + git_or_panic(&dir, &["config", "core.autocrlf", "false"]); + Some(dir) + } + /// A throwaway repo with two commits touching one file, which is /// the least history `bca vcs` / `vcs commit` / `vcs trend` all /// produce a ranked document from. fn git_repo_with_history() -> Option { - let dir = TempDir::new().expect("tempdir"); - if !git(&dir, &["init", "-q", "-b", "main"]) - || !git(&dir, &["config", "commit.gpgsign", "false"]) - { - return None; - } + let dir = git_init_neutralized()?; write_fixture(&dir, "work.c", TRIVIAL_C); - if !git(&dir, &["add", "."]) || !git(&dir, &["commit", "-qm", "add work"]) { - return None; - } + git_or_panic(&dir, &["add", "."]); + git_or_panic(&dir, &["commit", "-qm", "add work"]); write_fixture(&dir, "work.c", COMMENTED_C); - git(&dir, &["commit", "-aqm", "fix work"]).then_some(dir) + git_or_panic(&dir, &["commit", "-aqm", "fix work"]); + Some(dir) } /// #1132's sweep missed `bca vcs`, whose emission runs through @@ -967,19 +1005,13 @@ mod unix { /// A repository with enough tracked files that `bca vcs --top 0` /// emits more than [`PIPE_BUFFER_BYTES`] of compact JSON. fn git_repo_with_many_files() -> Option { - let dir = TempDir::new().expect("tempdir"); - if !git(&dir, &["init", "-q", "-b", "main"]) - || !git(&dir, &["config", "commit.gpgsign", "false"]) - { - return None; - } + let dir = git_init_neutralized()?; for i in 0..400 { write_fixture(&dir, &format!("f{i:04}.c"), TRIVIAL_C); } - if !git(&dir, &["add", "."]) { - return None; - } - git(&dir, &["commit", "-qm", "add sources"]).then_some(dir) + git_or_panic(&dir, &["add", "."]); + git_or_panic(&dir, &["commit", "-qm", "add sources"]); + Some(dir) } /// The pipe-close half of the #1132 contract, for the `vcs` family. From 7d3deccf435f428568a5167bc9af5784d06ad558 Mon Sep 17 00:00:00 2001 From: Elijah Zupancic Date: Sat, 1 Aug 2026 07:35:45 -0700 Subject: [PATCH 23/36] docs(cognitive): document Python's per-lambda boolean surcharge `python_apply_boolean_operator` adds one structural unit to a boolean operator for each enclosing `lambda`, halting the walk at the nearest `expression_list`. That is a deviation from Campbell, who gives a boolean sequence a fundamental increment and no nesting increment. No other language applies it, and it appeared nowhere in the book. Reviewed and deliberately kept: the intuition it encodes is defensible and removing it would move every Python score for a construct nobody has reported as wrong. Documented instead, in the Cognitive Complexity per-language deviations list, with a measured table covering the per-lambda ladder, the operator-switch interaction, and both boundary cases. Two things the walk does that a reader would otherwise get wrong: the `if`/`for`/`while` stops are inert, because a lambda body is a single expression and no lambda can sit above one of those statements; and "outermost operator" is scoped to a lambda body, so a chain that switches operators still pays two sequence increments. Zero metric change. `python.rs` gains a pointer to the book section rather than a second copy of the prose; #1090 already corrected the in-source description. Fixes #1150 --- CHANGELOG.md | 4 ++++ big-code-analysis-book/src/metrics.md | 32 +++++++++++++++++++++++++++ src/metrics/cognitive/python.rs | 6 +++++ 3 files changed, 42 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 06ae4ce36..252f75f98 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -129,6 +129,10 @@ for historical reference. not, charging an inherited-conditional surcharge no sibling language charges; a `def` two conditionals deep now scores 2 where it scored 4. +- Documented Python's per-enclosing-`lambda` surcharge on boolean + operators in the book's *Cognitive Complexity → Per-language + deviations* list (#1150). No behaviour change. + - `bca ops` opens the same function spaces as `bca metrics`, through the same source-aware promote-and-classify predicate (#1130). The two walks each carried their own copy of the decision and the `ops` copy diff --git a/big-code-analysis-book/src/metrics.md b/big-code-analysis-book/src/metrics.md index 8d30364a2..3da9b046c 100644 --- a/big-code-analysis-book/src/metrics.md +++ b/big-code-analysis-book/src/metrics.md @@ -336,6 +336,38 @@ Sonar ecosystem. sense. It adds a surcharge *on top of* the enclosing nesting instead of replacing it, so a decision inside a lambda written inside an `if` is charged for both. +- **Python** charges a boolean operator an extra `+1` for each + enclosing `lambda`, on top of the `+1` the boolean sequence itself + earns. No other language does this. Only the outermost operator + inside a given lambda body pays the surcharge, and the walk that + counts those lambdas stops at the nearest enclosing + `expression_list`. (It also stops at `if`, `for` and `while`, but a + lambda body is a single expression, so no lambda ever sits above one + of those statements and those three arms never change a count.) The + sequence increments themselves still follow the operator-switch rule + above, so a mixed chain inside one lambda pays two of them plus a + single surcharge. Measured: + + | Python source | `cognitive.sum` | + | --- | --- | + | `g = a and b` | 1 | + | `g = lambda y: y and a` | 2 | + | `g = lambda y: y and a and b` | 2 | + | `g = lambda y: y and a or b` | 3 | + | `g = lambda x: (lambda y: y and a)` | 3 | + | `k = lambda q: (yield a and b, c)` | 1 | + | `def f(a): g = lambda x: x` | 0 | + + The last two rows are the boundaries. The parenthesised `yield` puts + an `expression_list` between the operator and the `lambda`, ending + the walk before it reaches one, so only the fundamental `+1` is left; + and a lambda containing no boolean operator costs nothing by itself. + + Campbell gives a boolean sequence a fundamental increment and no + nesting increment, so this is an addition to the specification rather + than an implementation of it. Issue #1150 reviewed the rule and kept + it deliberately. Python's boolean-operator cost is not comparable + with another language's score for the same code. ## Cyclomatic Complexity (CC) {#cyclomatic-complexity-cc} diff --git a/src/metrics/cognitive/python.rs b/src/metrics/cognitive/python.rs index 3557059e8..53d441535 100644 --- a/src/metrics/cognitive/python.rs +++ b/src/metrics/cognitive/python.rs @@ -73,6 +73,12 @@ fn python_comprehension_clause_nesting( /// order, so it is the lambdas that get counted and the control /// constructs that end the walk — the reverse of what this comment /// claimed before #1090. +/// +/// The per-lambda surcharge is a deliberate Python-only deviation from +/// Campbell, reviewed and kept in #1150; the user-facing statement of +/// it, with the measured ladder, is the Python bullet under *Cognitive +/// Complexity → Per-language deviations* in +/// `big-code-analysis-book/src/metrics.md`. fn python_apply_boolean_operator<'a>( node: &Node<'a>, ancestors: Ancestors<'a, '_>, From 869910bb9369d30eda4e4f526e6fe854f9d2e4e3 Mon Sep 17 00:00:00 2001 From: Elijah Zupancic Date: Sat, 1 Aug 2026 07:45:04 -0700 Subject: [PATCH 24/36] test(vcs): shrink the per-function perf fixture and its budget MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `perf_10k_lines_200_commits_under_30s` rebuilt and rewrote a full 10 000-line file for each of 201 commits, spawning 407 `git` processes and dominating the `vcs_per_function` binary's cost. Cut the history depth to 50 commits, keeping the file at 10 000 lines / 2 000 spans — span count is the dimension per-function bucketing scales in, and the one #329's acceptance criterion is really about. The budget moves with the fixture, not after it: leaving 30 s over a quarter-size fixture would silently quadruple the regression the assertion tolerates. Re-derived at the same headroom ratio from measurements on one 16-core host (median of 5 runs, debug build): 2000 funcs / 200 commits: blame 0.53 s, 407 spawns, budget 30 s (57x) 2000 funcs / 50 commits: blame 0.14 s, 107 spawns, budget 8 s (57x) The derivation is recorded in the test, along with what the shrink trades away — sensitivity to a superlinear-in-history regression — so the next person rescaling it does not re-measure from scratch. The old work-product guard, `any(|s| s.commits_long > 0)`, held if a single span attributed anything. Both windows are exact by construction, so the test now asserts the exact history: `commits_long` is 2 for the edited functions and 1 for the rest, and the set carrying a recent commit equals exactly the edited set. Perturbing the fixture's edit target by one function fails both; the old guard passed it. Whole binary, best of 5: 1.69 s → 0.82 s wall, 1.63 → 0.82 CPU-s, 499 → 199 `git` spawns. Fixes #1125 --- tests/vcs_per_function.rs | 97 ++++++++++++++++++++++++++++++++------- 1 file changed, 81 insertions(+), 16 deletions(-) diff --git a/tests/vcs_per_function.rs b/tests/vcs_per_function.rs index 57cd7dda1..59e2393dc 100644 --- a/tests/vcs_per_function.rs +++ b/tests/vcs_per_function.rs @@ -224,17 +224,49 @@ fn untracked_file_blame_errors() { } #[test] -fn perf_10k_lines_200_commits_under_30s() { - // Acceptance criterion (issue #329): per-function VCS on a 10k-line, - // 200-commit file completes well under 30 s. We build a 2000-function - // file (5 lines each = 10_000 lines), make 200 commits each editing a - // spread-out line, then time the single whole-file blame + bucketing - // across all 2000 function spans. Only the blame call is timed — the - // fixture's 200 `git` invocations are setup, not the measured work. +fn perf_10k_lines_50_commits_under_8s() { + // Issue #329's acceptance criterion was literally "per-function VCS on + // a 10 000-line, 200-commit file completes well under 30 s". #1125 + // rescaled the fixture to 50 commits: the 10 000 lines / 2000 spans + // stay, because span count is the dimension per-function bucketing + // scales in, and the per-commit budget is preserved (30 s / 200 and + // 8 s / 50 are both ~0.15 s per commit). What the shrink does trade + // away is sensitivity to a *superlinear*-in-history regression: a + // quadratic one now has to be ~4x worse before it trips. + // + // We build a 2000-function file (5 lines each), make 50 commits each + // editing a spread-out line, then time the single whole-file blame + + // bucketing across all 2000 spans. Only the blame call is timed — the + // fixture's `git` invocations are setup, not the measured work. + // + // Fixture size and budget were re-derived together, and must stay that + // way: leaving 30 s over a quarter-size fixture would quadruple the + // regression the assertion tolerates while covering less. Measured on + // one 16-core host under concurrent load, debug build, median of 5 + // runs (min in brackets): + // + // 2000 funcs / 200 commits (pre-#1125): blame 0.53 s [0.49], 407 + // `git` spawns. Budget 30 s ⇒ 57x headroom. + // 2000 funcs / 50 commits (current): blame 0.14 s [0.13], 107 + // `git` spawns. Budget 8 s ⇒ 57x headroom. + // + // That headroom is what carries over, not the 30 s literal, and it is + // deliberately loose: at 57x this is a smoke alarm for a catastrophic + // regression, not a benchmark. A 10x slowdown passes. It is sized so a + // shared CI runner under load cannot flake it. use std::fmt::Write as _; const FUNCS: u32 = 2_000; const LINES_PER_FUNC: u32 = 5; - const COMMITS: usize = 200; + const COMMITS: usize = 50; + // Stride between successive edited functions, so the edited set is + // exactly the stride's multiples — which is what the work-product + // assertions below are derived from. That identity needs every commit + // to land on a distinct function, hence the bound. + const EDIT_STRIDE: usize = 7; + const _: () = assert!( + COMMITS * EDIT_STRIDE < FUNCS as usize, + "edited functions must stay distinct and in range" + ); // Unique identifiers per line so the fixture resembles real source // rather than 10_000 near-identical lines. (Pathologically repetitive @@ -260,9 +292,11 @@ fn perf_10k_lines_200_commits_under_30s() { for commit in 1..=COMMITS { // Each commit edits exactly one function's value line, spreading // localized edits across the file over history (as real commits do). - let target = (commit * 7) % values.len(); - values[target] += 1; + values[commit * EDIT_STRIDE] += 1; repo.write("big.rs", &render(&values)); + // Quarter-day spacing — the only sub-day commit spacing in + // `tests/`, so this is also the one fixture that puts several + // commits inside a single `age_days` bucket. let age = i64::try_from(COMMITS - commit).expect("commit age fits i64"); let secs = FIXED_NOW - age * DAY / 4; repo.commit("Ada", "ada@example.com", secs, &format!("edit {commit}")); @@ -314,15 +348,46 @@ fn perf_10k_lines_200_commits_under_30s() { FUNCS as usize, "one stats record per function span" ); - // Guard against the perf test "passing" while blame silently produced - // all-zero stats: the edited functions must show in-window commits. + // A timing assertion that does not check the work product is worthless: + // a regression that returns early with empty or all-zero stats would + // pass the budget trivially. Both windows are known exactly by + // construction, so assert the exact history rather than "non-zero" — + // `>= 1` would also admit a regression that credited every function + // with the whole file's history. + let edited: Vec = (1..=COMMITS).map(|c| c * EDIT_STRIDE).collect(); + // The seed commit is 300 days old, inside the default 12mo long + // window, so every function keeps it; an edited function additionally + // keeps the commit that rewrote its value line. + let expect_long = |index: usize| if edited.contains(&index) { 2 } else { 1 }; + let wrong: Vec<(usize, u32)> = stats + .iter() + .enumerate() + .filter(|(index, s)| s.commits_long != expect_long(*index)) + .map(|(index, s)| (index, s.commits_long)) + .collect(); assert!( - stats.iter().any(|s| s.commits_long > 0), - "blame attributed no commits to any function — bucketing is a no-op" + wrong.is_empty(), + "{} function(s) have an unexpected commits_long; first few \ + (index, value): {:?}", + wrong.len(), + &wrong[..wrong.len().min(5)] + ); + // Every edit commit lands inside the default 90d recent window, so the + // recent bucket names exactly the edited functions. Pinning the set + // (not its size) catches attribution that lands on the wrong span. + let with_recent: Vec = stats + .iter() + .enumerate() + .filter(|(_, s)| s.commits_recent > 0) + .map(|(index, _)| index) + .collect(); + assert_eq!( + with_recent, edited, + "recent commits should land on exactly the edited functions" ); assert!( - elapsed < std::time::Duration::from_secs(30), - "per-function blame took {elapsed:?}, over the 30s budget" + elapsed < std::time::Duration::from_secs(8), + "per-function blame took {elapsed:?}, over the 8s budget" ); } From d5b57604b1e23b1ac64c59b921a993d1187e8602 Mon Sep 17 00:00:00 2001 From: Elijah Zupancic Date: Sat, 1 Aug 2026 07:52:20 -0700 Subject: [PATCH 25/36] refactor(cognitive): extract the shared function-boundary rule Eighteen per-language modules spelled out the same two statements at their function-definition arm: reset structural nesting, then bump the function-depth surcharge when this definition is lexically nested in another. That pair is one named concept, and every one of those arms already carried a comment saying so. `enter_function_boundary` gives it a name. The issue listed seventeen modules. Python is the eighteenth: #1149 added its reset after the issue was written, so it now conforms and the annotation the issue planned for it is unnecessary. Two callers still spell the statements out, for opposite reasons, and both now say which half of the shared rule they are opting out of. Elixir takes the reset without the depth bump, because `increment_function_depth` matches `Call` ancestors by kind_id and every `def` inside a `defmodule` has one. The `js_cognitive!` macro takes the pair plus a `nesting.lambda` reset no other language performs, so a `function` declaration inside an arrow function does not inherit that arrow's lambda surcharge: measured, `() => { function g() { if (x) {} } }` scores 1 where the all-arrow equivalent scores 3. Zero behaviour change, verified by perturbation rather than by a new test, since a symmetric-sum field admits none. Deleting the reset from inside the helper fails exactly six tests, one per language that guards the channel, which is what proves the eighteen call sites route through it. Transposing the two statements fails none, confirming the ordering carries no meaning. Fixes #1103 --- CHANGELOG.md | 11 ++++++---- src/metrics/cognitive.rs | 37 +++++++++++++++++++++++++++++++-- src/metrics/cognitive/bash.rs | 8 +------ src/metrics/cognitive/c.rs | 5 ++--- src/metrics/cognitive/cpp.rs | 5 ++--- src/metrics/cognitive/csharp.rs | 5 ++--- src/metrics/cognitive/elixir.rs | 6 +++++- src/metrics/cognitive/go.rs | 5 ++--- src/metrics/cognitive/groovy.rs | 5 ++--- src/metrics/cognitive/irules.rs | 5 ++--- src/metrics/cognitive/java.rs | 5 ++--- src/metrics/cognitive/kotlin.rs | 5 ++--- src/metrics/cognitive/lua.rs | 5 ++--- src/metrics/cognitive/mozcpp.rs | 5 ++--- src/metrics/cognitive/objc.rs | 5 ++--- src/metrics/cognitive/perl.rs | 5 ++--- src/metrics/cognitive/php.rs | 5 ++--- src/metrics/cognitive/python.rs | 8 +------ src/metrics/cognitive/ruby.rs | 5 ++--- src/metrics/cognitive/rust.rs | 9 +------- src/metrics/cognitive/tcl.rs | 8 +------ 21 files changed, 79 insertions(+), 78 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 252f75f98..2c717b5fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -255,10 +255,13 @@ for historical reference. which now hold the `Nesting` struct end to end instead of destructuring it into three same-typed locals and rebuilding it, with the `conditional + function_depth + lambda` sum folded into a new - `Nesting::total()` (#1086); and `node_text`'s safety - documentation no longer describes a UTF-8 char-boundary panic that - cannot occur for a `&[u8]` parameter, with the same-parse - precondition now stated on the `Getter` trait (#1059). `bca.toml`'s + `Nesting::total()` (#1086); the two statements that make up the + function-boundary rule moved behind a shared `enter_function_boundary` + helper, replacing eighteen longhand copies and leaving Elixir and the + `js_cognitive!` macro visibly opted out at their call sites (#1103); + and `node_text`'s safety documentation no longer describes a UTF-8 + char-boundary panic that cannot occur for a `&[u8]` parameter, with + the same-parse precondition now stated on the `Getter` trait (#1059). `bca.toml`'s `exclude_tests` comment, which claimed the option does not lower `loc.sloc`, was corrected — #722 made it do exactly that (#1066). diff --git a/src/metrics/cognitive.rs b/src/metrics/cognitive.rs index a9c57d743..fc1f581d7 100644 --- a/src/metrics/cognitive.rs +++ b/src/metrics/cognitive.rs @@ -306,6 +306,33 @@ fn increment_function_depth<'a, T: PartialEq + From>( } } +/// Applies the function-boundary rule at `node`, which every language +/// with a syntactic function-definition kind shares (#696). +/// +/// It moves two of [`Nesting`]'s three channels. Structural nesting +/// restarts at zero, so control flow written inside this function is +/// charged against its own depth rather than the enclosing function's; +/// and the function-depth surcharge rises when this definition is +/// itself lexically nested in one of `stops`. Byte-equivalent +/// constructs therefore score the same across languages, which is the +/// property the book's per-language deviations list states. +/// +/// The two statements were spelled out longhand in eighteen modules +/// before #1103. Two callers still spell them out, for opposite +/// reasons: `elixir.rs` takes only the reset and deliberately skips the +/// depth bump, while the `js_cognitive!` macro takes the pair *plus* a +/// `nesting.lambda` reset no other language performs. Each says why at +/// its own site. +fn enter_function_boundary<'a, T: PartialEq + From>( + nesting: &mut Nesting, + node: &Node<'a>, + ancestors: Ancestors<'a, '_>, + stops: &[T], +) { + nesting.conditional = 0; + increment_function_depth(&mut nesting.function_depth, node, ancestors, stops); +} + /// Charges `node`'s construct at the current nesting level and opens a /// new structural level for its children. /// @@ -400,10 +427,16 @@ macro_rules! js_cognitive { }); } FunctionDeclaration => { - // Reset lambda nesting at function for JS + // The JS family takes the shared function-boundary + // rule plus one extra channel: `nesting.lambda` is + // reset too, which no other language does. A `function` + // declaration written inside an arrow function starts a + // fresh lexical scope, so it should not inherit that + // arrow's lambda surcharge. That third statement is why + // this arm spells the pair out rather than calling + // `enter_function_boundary` (#1103). nesting.conditional = 0; nesting.lambda = 0; - // Increase depth function nesting if needed increment_function_depth( &mut nesting.function_depth, node, diff --git a/src/metrics/cognitive/bash.rs b/src/metrics/cognitive/bash.rs index bc4ed82ca..de728eefc 100644 --- a/src/metrics/cognitive/bash.rs +++ b/src/metrics/cognitive/bash.rs @@ -47,13 +47,7 @@ impl Cognitive for BashCode { compute_booleans(node, stats, AMPAMP, PIPEPIPE); } FunctionDefinition => { - nesting.conditional = 0; - increment_function_depth( - &mut nesting.function_depth, - node, - ancestors, - &[FunctionDefinition], - ); + enter_function_boundary(&mut nesting, node, ancestors, &[FunctionDefinition]); } _ => {} } diff --git a/src/metrics/cognitive/c.rs b/src/metrics/cognitive/c.rs index 9ab8bf1b5..26a0105c2 100644 --- a/src/metrics/cognitive/c.rs +++ b/src/metrics/cognitive/c.rs @@ -52,9 +52,8 @@ impl Cognitive for CCode { // definition missed the SonarSource B-nesting amplification // (#696). FunctionDefinition | FunctionDefinition2 => { - nesting.conditional = 0; - increment_function_depth( - &mut nesting.function_depth, + enter_function_boundary( + &mut nesting, node, ancestors, &[FunctionDefinition, FunctionDefinition2], diff --git a/src/metrics/cognitive/cpp.rs b/src/metrics/cognitive/cpp.rs index ebff0cac3..d05e8be14 100644 --- a/src/metrics/cognitive/cpp.rs +++ b/src/metrics/cognitive/cpp.rs @@ -60,9 +60,8 @@ impl Cognitive for CppCode { | FunctionDefinition2 | FunctionDefinition3 | FunctionDefinition4 => { - nesting.conditional = 0; - increment_function_depth( - &mut nesting.function_depth, + enter_function_boundary( + &mut nesting, node, ancestors, &[ diff --git a/src/metrics/cognitive/csharp.rs b/src/metrics/cognitive/csharp.rs index 57dec834b..d61882c63 100644 --- a/src/metrics/cognitive/csharp.rs +++ b/src/metrics/cognitive/csharp.rs @@ -92,9 +92,8 @@ impl Cognitive for CsharpCode { | AccessorDeclaration | LocalFunctionStatement | LocalFunctionDeclaration => { - nesting.conditional = 0; - increment_function_depth( - &mut nesting.function_depth, + enter_function_boundary( + &mut nesting, node, ancestors, &[ diff --git a/src/metrics/cognitive/elixir.rs b/src/metrics/cognitive/elixir.rs index ed517b25c..80f67f37e 100644 --- a/src/metrics/cognitive/elixir.rs +++ b/src/metrics/cognitive/elixir.rs @@ -98,7 +98,11 @@ impl Cognitive for ElixirCode { // truly nested method definitions are not a // concern — the lambda channel via // `AnonymousFunction` handles the analogous - // higher-order case. + // higher-order case. Elixir is therefore the one + // language that takes the reset without the depth + // bump, so it resets inline instead of calling the + // shared `enter_function_boundary` the other + // eighteen modules use (#1103). nesting.conditional = 0; } _ => {} diff --git a/src/metrics/cognitive/go.rs b/src/metrics/cognitive/go.rs index b4fc4bdf8..5b759d0ff 100644 --- a/src/metrics/cognitive/go.rs +++ b/src/metrics/cognitive/go.rs @@ -45,9 +45,8 @@ impl Cognitive for GoCode { compute_booleans(node, stats, G::AMPAMP, G::PIPEPIPE); } G::FunctionDeclaration | G::MethodDeclaration => { - nesting.conditional = 0; - increment_function_depth( - &mut nesting.function_depth, + enter_function_boundary( + &mut nesting, node, ancestors, &[G::FunctionDeclaration, G::MethodDeclaration], diff --git a/src/metrics/cognitive/groovy.rs b/src/metrics/cognitive/groovy.rs index bb7d0c3c0..72cf03341 100644 --- a/src/metrics/cognitive/groovy.rs +++ b/src/metrics/cognitive/groovy.rs @@ -81,9 +81,8 @@ impl Cognitive for GroovyCode { // nested method previously inherited the enclosing nesting and // missed the SonarSource B-nesting amplification (#696). MethodDeclaration | ConstructorDeclaration => { - nesting.conditional = 0; - increment_function_depth( - &mut nesting.function_depth, + enter_function_boundary( + &mut nesting, node, ancestors, &[MethodDeclaration, ConstructorDeclaration], diff --git a/src/metrics/cognitive/irules.rs b/src/metrics/cognitive/irules.rs index 0f888cad2..75f01a027 100644 --- a/src/metrics/cognitive/irules.rs +++ b/src/metrics/cognitive/irules.rs @@ -64,9 +64,8 @@ impl Cognitive for IrulesCode { // All four function-space kinds reset nesting and bump the // function depth (see the `IrulesCode` Checker impl). Procedure | WhenEvent | OnHandler | TrapHandler => { - nesting.conditional = 0; - increment_function_depth( - &mut nesting.function_depth, + enter_function_boundary( + &mut nesting, node, ancestors, &[Procedure, WhenEvent, OnHandler, TrapHandler], diff --git a/src/metrics/cognitive/java.rs b/src/metrics/cognitive/java.rs index b40373b22..3720e1be9 100644 --- a/src/metrics/cognitive/java.rs +++ b/src/metrics/cognitive/java.rs @@ -64,9 +64,8 @@ impl Cognitive for JavaCode { // enclosing nesting and every nested method missed the // SonarSource B-nesting amplification (#696). MethodDeclaration | ConstructorDeclaration => { - nesting.conditional = 0; - increment_function_depth( - &mut nesting.function_depth, + enter_function_boundary( + &mut nesting, node, ancestors, &[MethodDeclaration, ConstructorDeclaration], diff --git a/src/metrics/cognitive/kotlin.rs b/src/metrics/cognitive/kotlin.rs index d94f6b908..7351488d8 100644 --- a/src/metrics/cognitive/kotlin.rs +++ b/src/metrics/cognitive/kotlin.rs @@ -84,9 +84,8 @@ impl Cognitive for KotlinCode { }); } FunctionDeclaration | SecondaryConstructor => { - nesting.conditional = 0; - increment_function_depth( - &mut nesting.function_depth, + enter_function_boundary( + &mut nesting, node, ancestors, &[FunctionDeclaration, SecondaryConstructor], diff --git a/src/metrics/cognitive/lua.rs b/src/metrics/cognitive/lua.rs index bcc01b5e1..1bdd4296b 100644 --- a/src/metrics/cognitive/lua.rs +++ b/src/metrics/cognitive/lua.rs @@ -53,9 +53,8 @@ impl Cognitive for LuaCode { compute_booleans(node, stats, And, Or); } FunctionDeclaration | FunctionDeclaration2 | FunctionDeclaration3 => { - nesting.conditional = 0; - increment_function_depth( - &mut nesting.function_depth, + enter_function_boundary( + &mut nesting, node, ancestors, &[ diff --git a/src/metrics/cognitive/mozcpp.rs b/src/metrics/cognitive/mozcpp.rs index 9de993ed2..2d9a89918 100644 --- a/src/metrics/cognitive/mozcpp.rs +++ b/src/metrics/cognitive/mozcpp.rs @@ -60,9 +60,8 @@ impl Cognitive for MozcppCode { | FunctionDefinition2 | FunctionDefinition3 | FunctionDefinition4 => { - nesting.conditional = 0; - increment_function_depth( - &mut nesting.function_depth, + enter_function_boundary( + &mut nesting, node, ancestors, &[ diff --git a/src/metrics/cognitive/objc.rs b/src/metrics/cognitive/objc.rs index ab05328cd..3e269802d 100644 --- a/src/metrics/cognitive/objc.rs +++ b/src/metrics/cognitive/objc.rs @@ -59,9 +59,8 @@ impl Cognitive for ObjcCode { // boundaries: reset structural nesting and bump the // function-depth surcharge when nested inside another (#696). FunctionDefinition | FunctionDefinition2 | MethodDefinition => { - nesting.conditional = 0; - increment_function_depth( - &mut nesting.function_depth, + enter_function_boundary( + &mut nesting, node, ancestors, &[FunctionDefinition, FunctionDefinition2, MethodDefinition], diff --git a/src/metrics/cognitive/perl.rs b/src/metrics/cognitive/perl.rs index e046f772d..739a5d426 100644 --- a/src/metrics/cognitive/perl.rs +++ b/src/metrics/cognitive/perl.rs @@ -95,9 +95,8 @@ impl Cognitive for PerlCode { compute_perl_booleans(node, stats); } P::FunctionDefinition | P::FunctionDefinitionWithoutSub => { - nesting.conditional = 0; - increment_function_depth( - &mut nesting.function_depth, + enter_function_boundary( + &mut nesting, node, ancestors, &[P::FunctionDefinition, P::FunctionDefinitionWithoutSub], diff --git a/src/metrics/cognitive/php.rs b/src/metrics/cognitive/php.rs index c73fba6f5..319c2ecc2 100644 --- a/src/metrics/cognitive/php.rs +++ b/src/metrics/cognitive/php.rs @@ -93,9 +93,8 @@ impl Cognitive for PhpCode { // the lambda arm above and intentionally do *not* reset nesting, // mirroring how the siblings treat lambda vs named-function arms. FunctionDefinition | MethodDeclaration => { - nesting.conditional = 0; - increment_function_depth( - &mut nesting.function_depth, + enter_function_boundary( + &mut nesting, node, ancestors, &[FunctionDefinition, MethodDeclaration], diff --git a/src/metrics/cognitive/python.rs b/src/metrics/cognitive/python.rs index 53d441535..82e19fbc7 100644 --- a/src/metrics/cognitive/python.rs +++ b/src/metrics/cognitive/python.rs @@ -230,13 +230,7 @@ impl Cognitive for PythonCode { // that shape is legal (`let f = || { fn g() {} };`) and only // the JS macro currently carries the extra line. FunctionDefinition => { - nesting.conditional = 0; - increment_function_depth( - &mut nesting.function_depth, - node, - ancestors, - &[FunctionDefinition], - ); + enter_function_boundary(&mut nesting, node, ancestors, &[FunctionDefinition]); } _ => {} } diff --git a/src/metrics/cognitive/ruby.rs b/src/metrics/cognitive/ruby.rs index 415ba06b6..7fa63f750 100644 --- a/src/metrics/cognitive/ruby.rs +++ b/src/metrics/cognitive/ruby.rs @@ -130,9 +130,8 @@ impl Cognitive for RubyCode { compute_ruby_booleans(node, stats); } R::Method | R::SingletonMethod => { - nesting.conditional = 0; - increment_function_depth( - &mut nesting.function_depth, + enter_function_boundary( + &mut nesting, node, ancestors, &[R::Method, R::SingletonMethod], diff --git a/src/metrics/cognitive/rust.rs b/src/metrics/cognitive/rust.rs index d002de9c2..bf7635ac4 100644 --- a/src/metrics/cognitive/rust.rs +++ b/src/metrics/cognitive/rust.rs @@ -54,14 +54,7 @@ impl Cognitive for RustCode { compute_booleans(node, stats, AMPAMP, PIPEPIPE); } FunctionItem => { - nesting.conditional = 0; - // Increase depth function nesting if needed - increment_function_depth( - &mut nesting.function_depth, - node, - ancestors, - &[FunctionItem], - ); + enter_function_boundary(&mut nesting, node, ancestors, &[FunctionItem]); } ClosureExpression => { nesting.lambda += 1; diff --git a/src/metrics/cognitive/tcl.rs b/src/metrics/cognitive/tcl.rs index 42114e0cb..d7df45f7e 100644 --- a/src/metrics/cognitive/tcl.rs +++ b/src/metrics/cognitive/tcl.rs @@ -58,13 +58,7 @@ impl Cognitive for TclCode { compute_booleans(node, stats, AMPAMP, PIPEPIPE); } Procedure => { - nesting.conditional = 0; - increment_function_depth( - &mut nesting.function_depth, - node, - ancestors, - &[Procedure], - ); + enter_function_boundary(&mut nesting, node, ancestors, &[Procedure]); } _ => {} } From 9f18fcfcb50805f293a8ff53052775c818f8025a Mon Sep 17 00:00:00 2001 From: Elijah Zupancic Date: Sat, 1 Aug 2026 08:14:22 -0700 Subject: [PATCH 26/36] chore(self-scan): converge abc onto the shipped default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #1143 step 2. `abc` 50 -> 40 costs zero hard-tier offenders — the one the issue measured (`run_command_report` at 40.82) has since fallen to 39.06 under this batch's changes. Two functions land in the soft tier (39.06 and 39.15 against a 0.95-scaled 38); both are under the hard limit and are recorded in the baseline rather than suppressed, so the early warning stays visible. Step 1 (`nargs` 7 -> 6) is NOT taken, against the issue's recommendation. It reads as a free ratchet on a hard-tier measurement — zero new offenders, since all 73 offenders at limit 5 sit at exactly 6 — but the soft tier scales every limit by BCA_HEADROOM (0.95), so a limit of 6 puts all 73 permanently in the 95-100% band at once. They can never clear it, because they are the limit. That buys ~73 baseline entries for no hard-tier gain, which is the "reads as debt rather than as a decision" outcome #1143 exists to avoid. Measured, not reasoned: 73 soft offenders at `nargs = 6 (limit 5.7)`. `cognitive` 25 -> 15 (step 3) remains open at 19 offenders, all between 16 and 20 — re-measured after #1102 and #1103 moved the ABC walker family the issue's original count was built on. --- .bca-baseline.toml | 35 +++++++++++++++++++++-------------- bca.toml | 25 ++++++++++++++++++------- 2 files changed, 39 insertions(+), 21 deletions(-) diff --git a/.bca-baseline.toml b/.bca-baseline.toml index e31471720..741b6b62a 100644 --- a/.bca-baseline.toml +++ b/.bca-baseline.toml @@ -144,6 +144,13 @@ start_line = 118 metric = "nargs" value = 7.0 +[[entry]] +path = "big-code-analysis-cli/src/commands/report.rs" +qualified = "run_command_report" +start_line = 5 +metric = "abc" +value = 39.06404996924922 + [[entry]] path = "big-code-analysis-cli/src/commands/report.rs" qualified = "run_command_report" @@ -263,6 +270,13 @@ start_line = 1 metric = "loc.ploc" value = 601.0 +[[entry]] +path = "big-code-analysis-cli/src/markdown_report.rs" +qualified = "extract_summaries_inner" +start_line = 128 +metric = "abc" +value = 39.153543900903784 + [[entry]] path = "big-code-analysis-cli/src/markdown_report.rs" qualified = "write_language_section" @@ -665,14 +679,14 @@ value = 16.0 [[entry]] path = "src/metrics/cognitive.rs" qualified = "tcl_switch_decision_arms" -start_line = 501 +start_line = 534 metric = "nargs" value = 7.0 [[entry]] path = "src/metrics/cognitive.rs" qualified = "tcl_switch_decision_arms" -start_line = 501 +start_line = 534 metric = "nexits" value = 6.0 @@ -697,13 +711,6 @@ start_line = 17 metric = "nargs" value = 7.0 -[[entry]] -path = "src/metrics/cognitive/ruby.rs" -qualified = "RubyCode::compute" -start_line = 29 -metric = "halstead.effort" -value = 47981.403382270764 - [[entry]] path = "src/metrics/loc/c.rs" qualified = "CCode::compute" @@ -826,9 +833,9 @@ value = 34.0 [[entry]] path = "src/ops.rs" qualified = "ops_inner" -start_line = 305 +start_line = 310 metric = "halstead.effort" -value = 74314.37901478147 +value = 70750.09027709182 [[entry]] path = "src/output/checkstyle.rs" @@ -924,21 +931,21 @@ value = 8.0 [[entry]] path = "src/spaces/compute.rs" qualified = "compute_per_node" -start_line = 218 +start_line = 248 metric = "halstead.effort" value = 63007.70094399036 [[entry]] path = "src/spaces/compute.rs" qualified = "compute_per_node" -start_line = 218 +start_line = 248 metric = "nargs" value = 7.0 [[entry]] path = "src/spaces/compute.rs" qualified = "metrics_inner" -start_line = 514 +start_line = 544 metric = "halstead.effort" value = 125902.05368685763 diff --git a/bca.toml b/bca.toml index b10b1aedf..7aed8dad9 100644 --- a/bca.toml +++ b/bca.toml @@ -119,12 +119,23 @@ exclude = [ # enough to be meaningless, or fire on the long tail of dispatch arms. # They are still computed and visible in `bca report markdown|html`. [thresholds] -# cognitive, nargs, and abc are looser here than the shipped defaults -# (15 / 5 / 40). That is not a calibration — it is the pre-#1140 -# folklore value, kept because converging costs ~95 new baseline -# entries in one commit, which reads as debt rather than as a decision. -# Tracked in #1143; tighten one metric per change, absorbing offenders -# as they are genuinely fixed rather than baselining the lot. +# `cognitive` is looser here than the shipped default (15). That is not +# a calibration — it is the pre-#1140 folklore value. Re-measured on +# 2026-08-01: converging costs 19 new offenders, all between 16 and 20, +# so it is a broad shallow overshoot rather than a few pathological +# functions. Tracked in #1143. +# +# `abc` has converged onto the shipped default (40). +# +# `nargs` deliberately stays at 7. #1143 proposed 7 -> 6 as a free +# ratchet on the strength of a hard-tier measurement: zero new offenders, +# because all 73 offenders at limit 5 sit at exactly 6. But the soft tier +# scales every limit by BCA_HEADROOM (0.95), so a limit of 6 puts all 73 +# of them permanently in the 95-100% band at once — they cannot ever +# clear it, because they are the limit. That is ~73 baseline entries +# bought for no hard-tier gain, which is the "reads as debt rather than +# as a decision" outcome #1143 exists to avoid. Measured 2026-08-01. +# The honest options are to stay at 7 or to do the real 6 -> 5 work. cognitive = 25 cyclomatic = 15 "halstead.effort" = 50000 @@ -157,5 +168,5 @@ cyclomatic = 15 nom = 30 nargs = 7 nexits = 5 -abc = 50 +abc = 40 wmc = 60 From 25e14fb0dd66e3966f97c3f2c1630263125ed7c9 Mon Sep 17 00:00:00 2001 From: Elijah Zupancic Date: Sat, 1 Aug 2026 08:40:11 -0700 Subject: [PATCH 27/36] feat(config): per-language [thresholds] overrides in bca.toml A single [thresholds] table has to fit every language in the tree, and the measured spread is too wide for that: the 97.5th-percentile per-function cognitive value runs from 4 in C# to 50 in C. A [thresholds.lang.] table now layers over the global one per metric, so a language inherits every limit it does not restate. Slugs are the canonical language names --language accepts; an unknown one is a tool error with a did-you-mean hint, while a recognised language with no override falls through to the global table. --print-effective-config emits one fully resolved table per overridden language, inherited limits included, and still round-trips through --config. The soft tier is derived per language from that language's resolved hard limit, so there is no [thresholds.lang..soft] syntax. That alone was not enough: classify_check_outcome read the hard-breach ceiling from one global map keyed by metric name, so every soft violation in a loosened language escalated to exit 5. Each resolved threshold now carries the ceiling of the table that produced it, stamped onto the Violation, and the classifier reads it from there. Four defects surfaced while implementing and are fixed here: - An absolute [thresholds.soft] value was not derived per language, so a language tightening its hard limit below it inverted the tiers. Now rejected, naming the table, as a "x" factor above 1 already is. Excluded for the lower-is-worse mi.* family, whose soft scaling is separately wrong (#1166). - A scale-relative soft limit could not resolve against a hard limit that lived only in a language table. - An alias-spelled limit ([thresholds] sloc = N) never escalated at the soft tier, because the ceiling lookup compared a canonical name against a raw-spelled map. - A metric gated only by a language table was left uncomputed by the #1113 walk narrowing, so it read its zero default and passed. Preproc and Ccomment slugs are rejected rather than accepted: bca check never gates those grammars, so such a table could not fire. thresholds.rs crossed its loc.ploc limit, so the soft tier moved to threshold_soft.rs alongside the new threshold_lang.rs, leaving the engine, the soft tier and the per-language tier in three modules. Fixes #1141 --- .bca-baseline.toml | 35 +- big-code-analysis-book/src/commands/check.md | 80 +++ .../src/recipes/thresholds.md | 78 +- big-code-analysis-cli/src/baseline_tests.rs | 4 + big-code-analysis-cli/src/check_flags.rs | 2 +- big-code-analysis-cli/src/check_format.rs | 10 + big-code-analysis-cli/src/cli_args/check.rs | 2 +- big-code-analysis-cli/src/commands.rs | 5 +- big-code-analysis-cli/src/commands/check.rs | 19 +- .../src/commands/check/effective_config.rs | 85 ++- .../src/commands/check/outcome.rs | 26 +- .../src/commands/check/thresholds.rs | 202 +++++- big-code-analysis-cli/src/commands_tests.rs | 86 ++- big-code-analysis-cli/src/dispatch.rs | 9 +- big-code-analysis-cli/src/lib.rs | 15 +- big-code-analysis-cli/src/manifest.rs | 2 +- big-code-analysis-cli/src/manifest_tests.rs | 35 +- big-code-analysis-cli/src/threshold_lang.rs | 236 +++++++ .../src/threshold_lang_tests.rs | 214 ++++++ big-code-analysis-cli/src/threshold_soft.rs | 154 ++++ big-code-analysis-cli/src/thresholds.rs | 282 ++++---- big-code-analysis-cli/src/thresholds_tests.rs | 7 + .../tests/check_exit_codes.rs | 67 ++ .../tests/check_lang_thresholds.rs | 668 ++++++++++++++++++ 24 files changed, 2000 insertions(+), 323 deletions(-) create mode 100644 big-code-analysis-cli/src/threshold_lang.rs create mode 100644 big-code-analysis-cli/src/threshold_lang_tests.rs create mode 100644 big-code-analysis-cli/src/threshold_soft.rs create mode 100644 big-code-analysis-cli/tests/check_lang_thresholds.rs diff --git a/.bca-baseline.toml b/.bca-baseline.toml index e31471720..ed99d09b6 100644 --- a/.bca-baseline.toml +++ b/.bca-baseline.toml @@ -63,7 +63,7 @@ value = 47834.17129195417 [[entry]] path = "big-code-analysis-cli/src/commands.rs" qualified = "run" -start_line = 100 +start_line = 101 metric = "halstead.effort" value = 136001.1105471366 @@ -91,14 +91,14 @@ value = 7.0 [[entry]] path = "big-code-analysis-cli/src/commands/check.rs" qualified = "emit_check_results" -start_line = 552 +start_line = 553 metric = "nargs" value = 8.0 [[entry]] path = "big-code-analysis-cli/src/commands/check.rs" qualified = "filter_by_baseline" -start_line = 363 +start_line = 364 metric = "nargs" value = 8.0 @@ -107,19 +107,19 @@ path = "big-code-analysis-cli/src/commands/check.rs" qualified = "run_check" start_line = 13 metric = "halstead.effort" -value = 70257.02522730803 +value = 70554.29290465376 [[entry]] path = "big-code-analysis-cli/src/commands/check/effective_config.rs" qualified = "EffectiveConfig::from_resolved" -start_line = 136 +start_line = 192 metric = "nargs" value = 14.0 [[entry]] path = "big-code-analysis-cli/src/commands/check/effective_config.rs" qualified = "print_effective_config" -start_line = 22 +start_line = 23 metric = "nargs" value = 9.0 @@ -252,7 +252,7 @@ value = 7.0 [[entry]] path = "big-code-analysis-cli/src/lib.rs" qualified = "legacy_hint" -start_line = 825 +start_line = 830 metric = "halstead.effort" value = 59351.805921378844 @@ -312,19 +312,12 @@ start_line = 28 metric = "halstead.effort" value = 66737.04848699087 -[[entry]] -path = "big-code-analysis-cli/src/thresholds.rs" -qualified = "" -start_line = 1 -metric = "loc.ploc" -value = 526.0 - [[entry]] path = "big-code-analysis-cli/src/thresholds.rs" qualified = "ThresholdSet::evaluate_with_policy" -start_line = 851 +start_line = 802 metric = "halstead.effort" -value = 56531.05676283881 +value = 57580.96697647807 [[entry]] path = "big-code-analysis-cli/src/vcs_command.rs" @@ -826,9 +819,9 @@ value = 34.0 [[entry]] path = "src/ops.rs" qualified = "ops_inner" -start_line = 305 +start_line = 310 metric = "halstead.effort" -value = 74314.37901478147 +value = 70750.09027709182 [[entry]] path = "src/output/checkstyle.rs" @@ -924,21 +917,21 @@ value = 8.0 [[entry]] path = "src/spaces/compute.rs" qualified = "compute_per_node" -start_line = 218 +start_line = 248 metric = "halstead.effort" value = 63007.70094399036 [[entry]] path = "src/spaces/compute.rs" qualified = "compute_per_node" -start_line = 218 +start_line = 248 metric = "nargs" value = 7.0 [[entry]] path = "src/spaces/compute.rs" qualified = "metrics_inner" -start_line = 514 +start_line = 544 metric = "halstead.effort" value = 125902.05368685763 diff --git a/big-code-analysis-book/src/commands/check.md b/big-code-analysis-book/src/commands/check.md index b26861528..0ffef5a71 100644 --- a/big-code-analysis-book/src/commands/check.md +++ b/big-code-analysis-book/src/commands/check.md @@ -158,6 +158,68 @@ run gates correctly. A bare family head with no single threshold scalar (`halstead`, `mi`) is ambiguous and rejected with a "did you mean" hint listing the concrete sub-metrics — pick one (e.g. `halstead.volume`). +### Per-language limits (`[thresholds.lang.]`) {#per-language-limits} + +Metric distributions vary by language more than by project — the +measured 97.5th-percentile per-function `cognitive` value runs from 4 in +C# to 50 in C. A `[thresholds.lang.]` table gives one language its +own limits, layered over the project-wide table: + +```toml +[thresholds] +cognitive = 15 +cyclomatic = 15 +"loc.ploc" = 600 + +[thresholds.lang.c] +cognitive = 30 +"loc.ploc" = 1200 + +[thresholds.lang.elixir] +nom = 150 +wmc = 300 +``` + +C is now gated at `cognitive = 30` and `loc.ploc = 1200` while keeping +the project's `cyclomatic = 15`; every other language keeps all three. +The override is **per metric**, not a replacement table — a language +inherits every limit it does not restate. Which numbers to change, and +the two cases where an override is a correction rather than tuning, are +in [Choosing thresholds](../recipes/thresholds.md#per-language). + +The key is the canonical language slug, the same vocabulary `--language` +accepts: `rust`, `python`, `cpp`, `csharp`, `objc`, `tsx`, `mozcpp`, +`mozjs`, and so on — `bca check --language nonsense` prints the full +list. Two rules point in opposite directions here: + +- An **unknown slug in the manifest** is a tool error (exit `1`) with a + did-you-mean hint. A typo'd `[thresholds.lang.rust-lang]` must not + silently leave a gate at the project limit while the author believes + it was loosened. +- An **unrecognised file language** falls through to the global table, + as does any language with no override of its own. Nothing is silently + ungated. (A file whose extension maps to no grammar at all is skipped + by the walk before the gate ever sees it — with a warning if you named + it explicitly.) + +`--threshold` on the command line stays global and still applies last +and absolutely: it overrides the project table *and* every per-language +one, so a limit you type is the limit that runs. + +`--print-effective-config` prints one fully resolved table per +overridden language — inherited limits included, not a diff — so the +number that will actually fire is the number you read: + +```toml +[thresholds] +cognitive = 15.0 +cyclomatic = 15.0 + +[thresholds.lang.c] +cognitive = 30.0 +cyclomatic = 15.0 +``` + ## Two-tier thresholds (`--tier`) `--tier ` selects which threshold tier the gate @@ -198,6 +260,24 @@ The soft tier resolves in a fixed order: a bare `soft`; `soft=1.0` disables scaling). 4. Repeated `--threshold name=value` flags apply last, absolutely. +Steps 1 to 3 run **once per language**, against that language's own +resolved hard limits. There is no `[thresholds.lang.].soft` table +and none is needed: with `[thresholds] cognitive = 15`, +`[thresholds.lang.c] cognitive = 30`, and `--tier=soft=0.9`, C's soft +band is `27` — nine tenths of *its* limit, and the ceiling a C offender +is measured against for the exit-`5` escalation is `30`, not `15`. +Derive either from the project's `15` and every C function between 15 +and 30 reports "also breaches the hard limit" while sitting inside the +limit the project configured for it. + +The one combination that *can* invert the two tiers is an absolute +`[thresholds.soft]` value above a language's tightened hard limit — +`[thresholds.soft] cognitive = 12` alongside +`[thresholds.lang.csharp] cognitive = 4`. That is a tool error (exit +`1`) naming the offending table, for the same reason a `"x"` +factor above `1` is rejected at parse time: a soft tier that fires +*after* the hard gate is never the intent. + The soft `RATIO` (and the scale factor in a `"x"` string) must be in `(0, 1]`. The `[check] headroom` manifest key supplies the ratio for a bare `--tier=soft`. The deprecated `--headroom ` flag is a diff --git a/big-code-analysis-book/src/recipes/thresholds.md b/big-code-analysis-book/src/recipes/thresholds.md index cc66caa05..57e6bac60 100644 --- a/big-code-analysis-book/src/recipes/thresholds.md +++ b/big-code-analysis-book/src/recipes/thresholds.md @@ -125,9 +125,12 @@ is inline branching, and they favour long `switch`-style dispatch over polymorph `cognitive` limit flags 5% to 15% of their functions, against 3% in the median language. Raise `cognitive` and `halstead.effort` first; those two carry most of the excess. +In a single-language repository this is the whole `[thresholds]` table; in a mixed one it belongs +under the language it describes, or it drags every other language up with it. + ```toml -# Gating a C, Tcl, Bash, Lua, Perl, or Go codebase. -[thresholds] +# Gating C. The same shape suits Tcl, Bash, Lua, Perl, and Go. +[thresholds.lang.c] cognitive = 30 cyclomatic = 20 abc = 50 @@ -143,13 +146,12 @@ Elixir `defmodule` holds dozens of functions by design, so roughly a third of El is usually a handful of methods, so the default never fires. ```toml -# Elixir. -[thresholds] +# Elixir modules and Rust impls sit at opposite ends of the same metric. +[thresholds.lang.elixir] nom = 150 wmc = 300 -# Rust. -[thresholds] +[thresholds.lang.rust] nom = 20 wmc = 40 ``` @@ -180,28 +182,58 @@ because the grammar misparses its signature. `wmc`, `npm`, and `npa` are only produced for languages with a class-like container. They are absent from Bash, C, Go, Lua, Perl, and Tcl output. -### Applying overrides today {#applying-overrides} +A per-language override cannot fix any of these. Bash `nargs` is `0` for every function, so no +limit — not even `0`, which fires only on a value *above* it — can make the gate say anything. The +override mechanism tunes limits; it does not add a measurement the grammar cannot supply. -A single `bca.toml` carries one `[thresholds]` table for the whole project. Per-language tables in -the manifest are proposed in -[issue #1141](https://github.com/dekobon/big-code-analysis/issues/1141) and are not implemented yet. -Until they are, a polyglot repository has two options. +### Applying overrides {#applying-overrides} -Run `bca check` once per language, selecting files by glob and passing that language's limits on the -command line: +Write them into `bca.toml` as `[thresholds.lang.]` tables. Each one layers over the project's +`[thresholds]` per metric, so a language inherits every limit it does not restate: -```bash -bca check --no-config -I '*.rs' \ - --threshold cognitive=15 --threshold cyclomatic=15 --threshold nargs=5 -bca check --no-config -I '*.c' \ - --threshold cognitive=30 --threshold cyclomatic=20 --threshold nargs=5 -``` +```toml +[thresholds] +cognitive = 15 +cyclomatic = 15 +nargs = 5 +nom = 30 +wmc = 60 -Note that `.h` is analyzed with the C++ grammar, not the C one, so a C project's headers land in -whichever glob covers `*.h`. See [Supported Languages](../languages.md) for the extension map. +# Procedural, dispatch-heavy: raise the two metrics carrying the excess. +[thresholds.lang.c] +cognitive = 30 +cyclomatic = 20 +"halstead.effort" = 120000 +"loc.ploc" = 1200 + +# A defmodule is not a class; nom and wmc need module-sized limits. +[thresholds.lang.elixir] +nom = 150 +wmc = 300 + +# Compact and heavily abstracted: tighten, or the gate never fires. +[thresholds.lang.csharp] +cognitive = 8 +cyclomatic = 8 +``` -Or keep one table sized for the loosest language and let per-file baselines carry the rest. That is -simpler to maintain and strictly weaker: the stricter languages stop being gated. +The key is the canonical language slug, the same vocabulary `--language` accepts (`cpp`, `csharp`, +`objc`, `tsx`, `mozcpp`, `mozjs`); an unknown one is a tool error rather than a silent no-op. A +language with no table of its own is gated by `[thresholds]`. Under `--tier=soft` each language's +soft band is derived from *its own* resolved limit, so a language that raises a limit gets a soft +band scaled from the raised number rather than the project-wide one. The full reference — including +how `--print-effective-config` renders the resolved tables — is in +[`bca check`](../commands/check.md#per-language-limits). + +Two things to watch when splitting a table this way. `.h` is analyzed with the C++ grammar, not the +C one, so a C project's headers are gated by `[thresholds.lang.cpp]`; see +[Supported Languages](../languages.md) for the extension map. And `--threshold` on the command line +stays global — it overrides every per-language table too, which is what you want for a one-off run +and a surprise if you expected it to compose. + +The alternative is still available and still worse: keep one table sized for the loosest language +and let per-file baselines carry the rest. It is simpler to maintain and strictly weaker, because +the stricter languages stop being gated at all. ## Per-use-case profiles {#profiles} diff --git a/big-code-analysis-cli/src/baseline_tests.rs b/big-code-analysis-cli/src/baseline_tests.rs index d5e53618e..12bbbcf0d 100644 --- a/big-code-analysis-cli/src/baseline_tests.rs +++ b/big-code-analysis-cli/src/baseline_tests.rs @@ -17,6 +17,7 @@ fn v(path: &str, function: &str, start_line: usize, metric: &'static str, value: metric, value, limit: 1.0, + hard_limit: Some(1.0), lower_is_worse: false, body_hash: None, suppressed: false, @@ -761,6 +762,7 @@ fn from_str_defensive_anchor_normalization() { metric: "cyclomatic", value: 5.0, limit: 1.0, + hard_limit: Some(1.0), lower_is_worse: false, body_hash: None, suppressed: false, @@ -1012,6 +1014,7 @@ fn baseline_covers_distinguishes_non_utf8_paths() { metric: "cyclomatic", value: 5.0, limit: 1.0, + hard_limit: Some(1.0), lower_is_worse: false, body_hash: None, suppressed: false, @@ -1024,6 +1027,7 @@ fn baseline_covers_distinguishes_non_utf8_paths() { metric: "cyclomatic", value: 5.0, limit: 1.0, + hard_limit: Some(1.0), lower_is_worse: false, body_hash: None, suppressed: false, diff --git a/big-code-analysis-cli/src/check_flags.rs b/big-code-analysis-cli/src/check_flags.rs index 0d61255eb..d76a6bdb6 100644 --- a/big-code-analysis-cli/src/check_flags.rs +++ b/big-code-analysis-cli/src/check_flags.rs @@ -87,7 +87,7 @@ fn parse_soft_ratio(ratio_str: &str) -> Result { let ratio: f64 = ratio_str .parse() .map_err(|_| format!("soft ratio must be a number; got {ratio_str:?}"))?; - if crate::thresholds::is_valid_scale_ratio(ratio) { + if crate::threshold_soft::is_valid_scale_ratio(ratio) { Ok(ratio) } else { Err(format!("soft ratio must be in (0, 1]; got {ratio}")) diff --git a/big-code-analysis-cli/src/check_format.rs b/big-code-analysis-cli/src/check_format.rs index 3184d7c13..e4c838976 100644 --- a/big-code-analysis-cli/src/check_format.rs +++ b/big-code-analysis-cli/src/check_format.rs @@ -567,6 +567,11 @@ pub(crate) fn violation_to_offender(v: Violation) -> OffenderRecord { metric, value, limit, + // The hard-tier ceiling exists to drive the soft-tier + // hard-breach escalation in `classify_check_outcome`, which runs + // before this conversion; the serialized offender record reports + // the limit that was actually gated against. + hard_limit: _, // Metric direction is re-derived from the catalog by the // offender formatters (`OffenderRecord::default_message`, // Code Climate severity), so it is not carried on the record. @@ -605,6 +610,7 @@ mod tests { metric: "cyclomatic", value: 5.0, limit: 1.0, + hard_limit: Some(1.0), lower_is_worse: false, body_hash: None, suppressed: false, @@ -651,6 +657,7 @@ mod tests { metric: "cyclomatic", value: 5.0, limit: 1.0, + hard_limit: Some(1.0), lower_is_worse: false, body_hash: None, suppressed: false, @@ -671,6 +678,7 @@ mod tests { metric, value: 17.0, limit: 5.0, + hard_limit: Some(5.0), lower_is_worse: false, body_hash: None, suppressed: false, @@ -813,6 +821,7 @@ mod tests { metric: "cyclomatic", value: 17.0, limit: 5.0, + hard_limit: Some(5.0), lower_is_worse: false, body_hash: None, suppressed: false, @@ -882,6 +891,7 @@ mod tests { metric: "cyclomatic", value: 17.0, limit: 5.0, + hard_limit: Some(5.0), lower_is_worse: false, body_hash: None, suppressed: false, diff --git a/big-code-analysis-cli/src/cli_args/check.rs b/big-code-analysis-cli/src/cli_args/check.rs index 41bf17986..443c7b62c 100644 --- a/big-code-analysis-cli/src/cli_args/check.rs +++ b/big-code-analysis-cli/src/cli_args/check.rs @@ -370,7 +370,7 @@ impl CheckArgs { // Range-validate the alias ratio here (exit 1, tool error) — clap // does not parse `--headroom` through `TierSpec`, so the `(0, 1]` // check the canonical form gets at parse time must be replicated. - if !crate::thresholds::is_valid_scale_ratio(ratio) { + if !crate::threshold_soft::is_valid_scale_ratio(ratio) { die(format_args!("--headroom must be in (0, 1]; got {ratio}")); } match self.tier { diff --git a/big-code-analysis-cli/src/commands.rs b/big-code-analysis-cli/src/commands.rs index 110a6b681..d610cb7c3 100644 --- a/big-code-analysis-cli/src/commands.rs +++ b/big-code-analysis-cli/src/commands.rs @@ -42,9 +42,10 @@ use crate::markdown_report::advisory::AdvisoryThresholds; use crate::markdown_report::{FunctionSummary, generate_report_with_vcs}; use crate::metric_catalog::write_metrics; use crate::metric_diff::DiffSide; +use crate::threshold_lang::LanguageThresholds; +use crate::threshold_soft::{SoftLimit, scale_threshold}; use crate::thresholds::{ - ParsedThresholds, SoftLimit, ThresholdSet, Violation, breaches_limit, render_violation_line, - scale_threshold, + ParsedThresholds, ThresholdSet, Violation, breaches_limit, render_violation_line, }; use big_code_analysis::{FuncSpace, Ops}; diff --git a/big-code-analysis-cli/src/commands/check.rs b/big-code-analysis-cli/src/commands/check.rs index 535f907c3..5967c0490 100644 --- a/big-code-analysis-cli/src/commands/check.rs +++ b/big-code-analysis-cli/src/commands/check.rs @@ -41,8 +41,7 @@ pub(crate) fn run_check( let tier = args.resolved_tier(); let tiered_exit_codes = args.resolved_exit_codes() == Some(crate::ExitCodes::Tiered); let ResolvedThresholds { - set, - hard_limits, + thresholds, provenance, } = validate_and_build_thresholds(&mut args, base_thresholds, tier); // `--print-effective-config` is a read-only debug aid: print the @@ -53,7 +52,7 @@ pub(crate) fn run_check( print_effective_config( &globals, &args, - &set, + &thresholds, manifest, format, tier, @@ -68,7 +67,7 @@ pub(crate) fn run_check( // `format_remediation_block` needs the resolved `--paths` / // `--exclude` set to compose a copy-paste-safe refresh command. let globals_for_remediation = globals.clone(); - let walk = run_check_walk(globals, &args, preproc, set); + let walk = run_check_walk(globals, &args, preproc, thresholds); enforce_usable_input(&walk); let violations = walk.violations; @@ -128,7 +127,7 @@ pub(crate) fn run_check( let any_violations = !active.is_empty(); // Categorise the active violations for the exit-code contract (#385) // before `emit_check_results` consumes them. - let outcome = classify_check_outcome(&active, tier.tier(), &hard_limits); + let outcome = classify_check_outcome(&active, tier.tier()); // Build the remediation block ONLY when we have something to // remediate. Empty active set (clean run) gets no trailing block — // there is no baseline to refresh and no artifact worth pointing @@ -204,7 +203,7 @@ fn run_check_walk( globals: GlobalOpts, args: &CheckArgs, preproc: Option>, - set: Arc, + thresholds: Arc, ) -> CheckWalk { let (tx, rx) = crossbeam::channel::unbounded(); let files_dispatched = Arc::new(AtomicUsize::new(0)); @@ -213,10 +212,12 @@ fn run_check_walk( // Halstead being the most expensive per node — and throw the rest // away. `validate_and_build_thresholds` rejects an empty set before // the walk, so this is never an empty selection, which `with_only` - // would read as "compute nothing". - let selected_metrics = Some(set.selected_metrics()); + // would read as "compute nothing". The selection is the union across + // languages (#1141): a metric only a `[thresholds.lang.]` table + // gates would otherwise stay at its zero default. + let selected_metrics = Some(thresholds.selected_metrics()); let cfg = Config { - threshold_set: Some(set), + thresholds: Some(thresholds), selected_metrics, check_tx: Some(tx), files_dispatched: Some(Arc::clone(&files_dispatched)), diff --git a/big-code-analysis-cli/src/commands/check/effective_config.rs b/big-code-analysis-cli/src/commands/check/effective_config.rs index 204ac362b..f0b7ca86c 100644 --- a/big-code-analysis-cli/src/commands/check/effective_config.rs +++ b/big-code-analysis-cli/src/commands/check/effective_config.rs @@ -15,21 +15,28 @@ use super::*; /// over TOML — the same field names; same shape. /// /// The resolved layers (headroom scaling per #373, `[thresholds.soft]` -/// / `--tier` per #375, the tiered exit-code style per #385) are already -/// folded into the serialized view; future layers (baseline state per -/// #381) will extend `EffectiveConfig` additively. This printer is the -/// single place that needs to learn about them. +/// / `--tier` per #375, the tiered exit-code style per #385, the +/// per-language tables per #1141) are already folded into the serialized +/// view; future layers (baseline state per #381) will extend +/// `EffectiveConfig` additively. This printer is the single place that +/// needs to learn about them. pub(crate) fn print_effective_config( globals: &GlobalOpts, args: &CheckArgs, - set: &ThresholdSet, + thresholds: &LanguageThresholds, manifest: Option<&Manifest>, format: PrintConfigFormat, tier: TierSpec, tiered_exit_codes: bool, ) { - let effective = - EffectiveConfig::from_resolved(globals, args, set, manifest, tier, tiered_exit_codes); + let effective = EffectiveConfig::from_resolved( + globals, + args, + thresholds, + manifest, + tier, + tiered_exit_codes, + ); let serialized = match format { PrintConfigFormat::Toml => toml::to_string_pretty(&effective) .unwrap_or_else(|e| die(format_args!("serialize effective config to TOML: {e}"))), @@ -56,10 +63,50 @@ pub(crate) fn print_effective_config( /// by `--config`. #[derive(serde::Serialize)] pub(crate) struct EffectiveConfig { - pub(crate) thresholds: BTreeMap, + pub(crate) thresholds: EffectiveThresholds, pub(crate) check: EffectiveCheck, } +/// The resolved `[thresholds]` view: the global limits, plus one +/// *fully resolved* sub-table per language carrying a +/// `[thresholds.lang.]` override (#1141). +/// +/// Each language table lists every limit that will apply to that +/// language, inherited entries included — not a diff against the global +/// table. Someone auditing a gate wants the number that fires, not a +/// delta to compute; and the printed form stays directly consumable by +/// `--config`, which reads the same nesting. +pub(crate) struct EffectiveThresholds { + pub(crate) global: BTreeMap, + pub(crate) lang: BTreeMap<&'static str, BTreeMap>, +} + +impl serde::Serialize for EffectiveThresholds { + /// Hand-written because this one TOML table holds two value shapes — + /// scalar limits and a nested table of tables — which a single + /// `BTreeMap` cannot express without an enum wrapper around every + /// limit. Serializing from the two typed fields keeps the shape + /// explicit and puts the language tables last in both TOML and JSON. + /// + /// The `toml` serializer additionally reorders values ahead of + /// tables on its own, so the emitted order here is for readability + /// and for the JSON form; do not read it as the thing that keeps the + /// TOML valid. + fn serialize(&self, serializer: S) -> Result { + use serde::ser::SerializeMap; + + let has_lang = !self.lang.is_empty(); + let mut map = serializer.serialize_map(Some(self.global.len() + usize::from(has_lang)))?; + for (name, limit) in &self.global { + map.serialize_entry(name, limit)?; + } + if has_lang { + map.serialize_entry(crate::threshold_lang::LANG_SUBTABLE_KEY, &self.lang)?; + } + map.end() + } +} + #[derive(serde::Serialize)] pub(crate) struct EffectiveCheck { pub(crate) paths: Vec, @@ -127,6 +174,15 @@ pub(crate) struct EffectiveCheck { pub(crate) baseline_fuzzy_match: bool, } +/// One resolved set's `(metric, limit)` pairs, in the shape a +/// serialized `[thresholds]` table takes. Shared by the global table and +/// every per-language one so the two render identically. +fn resolved_limits(set: &ThresholdSet) -> BTreeMap { + set.iter() + .map(|(name, limit)| (name.to_owned(), limit)) + .collect() +} + impl EffectiveConfig { /// Project the resolved `ThresholdSet` + the original CLI args into /// a serializable view. Paths are rendered with [`Path::display`] @@ -136,15 +192,18 @@ impl EffectiveConfig { pub(crate) fn from_resolved( globals: &GlobalOpts, args: &CheckArgs, - set: &ThresholdSet, + resolved: &LanguageThresholds, manifest: Option<&Manifest>, tier: TierSpec, tiered_exit_codes: bool, ) -> Self { - let thresholds: BTreeMap = set - .iter() - .map(|(name, limit)| (name.to_owned(), limit)) - .collect(); + let thresholds = EffectiveThresholds { + global: resolved_limits(resolved.global()), + lang: resolved + .languages() + .map(|(slug, set)| (slug, resolved_limits(set))) + .collect(), + }; let check = EffectiveCheck { paths: globals .paths diff --git a/big-code-analysis-cli/src/commands/check/outcome.rs b/big-code-analysis-cli/src/commands/check/outcome.rs index c73009b54..17019e2f1 100644 --- a/big-code-analysis-cli/src/commands/check/outcome.rs +++ b/big-code-analysis-cli/src/commands/check/outcome.rs @@ -48,16 +48,26 @@ impl CheckOutcome { /// Categorise the kept violations for the exit-code contract (#385). /// -/// `hard_limits` holds the resolved hard-tier limit per metric. It is -/// consulted only at the soft tier, where a violation whose value also -/// exceeds the hard limit escalates to [`CheckOutcome::HardBreach`]. At -/// the hard tier every violation already exceeds the hard limit, so the -/// escalation is suppressed (it would otherwise swallow the new/regr -/// split) and only baseline coverage drives the result. +/// Each violation carries `hard_limit`, the resolved hard-tier ceiling +/// for its metric *under the table that gated its file* — which since +/// #1141 may be a `[thresholds.lang.]` override rather than the +/// project-wide one, and which is `None` for a metric with a soft limit +/// but no hard one (nothing to breach). It is consulted only at the soft +/// tier, where a violation whose value also exceeds that ceiling +/// escalates to [`CheckOutcome::HardBreach`]. At the hard tier every +/// violation already exceeds it, so the escalation is suppressed (it +/// would otherwise swallow the new/regr split) and only baseline +/// coverage drives the result. +/// +/// Reading the ceiling off the violation rather than a global map is +/// what keeps a loosened language from reporting exit 5 for every +/// function between the project limit and its own: a C function at +/// cognitive 24 under `[thresholds.lang.c] cognitive = 25` is a +/// soft-band encroachment, not a hard breach, even though the project +/// table says 15. pub(crate) fn classify_check_outcome( pairs: &[(Violation, Option)], tier: Tier, - hard_limits: &BTreeMap, ) -> CheckOutcome { if pairs.is_empty() { return CheckOutcome::Clean; @@ -75,7 +85,7 @@ pub(crate) fn classify_check_outcome( // `Baseline::classify` treats a NaN as `Regressed` rather than a // magnitude. A NaN has no meaningful distance from the ceiling. if tier == Tier::Soft - && let Some(&hard) = hard_limits.get(v.metric) + && let Some(hard) = v.hard_limit && breaches_limit(v.value, hard, v.lower_is_worse) { has_hard_breach = true; diff --git a/big-code-analysis-cli/src/commands/check/thresholds.rs b/big-code-analysis-cli/src/commands/check/thresholds.rs index 4123348ef..e91db805a 100644 --- a/big-code-analysis-cli/src/commands/check/thresholds.rs +++ b/big-code-analysis-cli/src/commands/check/thresholds.rs @@ -1,5 +1,7 @@ //! `bca check` threshold-layer resolution (manifest, --config, tier, --threshold). +use std::collections::BTreeSet; + use super::super::*; use super::*; @@ -12,14 +14,15 @@ pub(crate) const DEFAULT_SOFT_HEADROOM: f64 = 0.95; /// Resolved threshold layers handed back to [`run_check`]. /// -/// `set` is the gate the walker compares against (the requested tier's -/// limits). `hard_limits` is the hard-tier limit per metric — equal to -/// `set` at the hard tier, but the *un-scaled* ceilings at the soft -/// tier, so [`classify_check_outcome`] can tell a soft-band -/// encroachment apart from a true hard breach (#385). +/// `thresholds` is the gate the walker compares against: the global set +/// plus one fully resolved set per `[thresholds.lang.]` language +/// (#1141). Each resolved threshold carries both the requested tier's +/// limit and that language's *un-scaled* hard ceiling, so +/// [`classify_check_outcome`] can tell a soft-band encroachment apart +/// from a true hard breach (#385) without re-deriving which table +/// applied. pub(crate) struct ResolvedThresholds { - pub(crate) set: Arc, - pub(crate) hard_limits: BTreeMap, + pub(crate) thresholds: Arc, /// Tier/headroom the gate resolved to (issue #486). Stamped into the /// baseline on `--write-baseline` and compared against a loaded /// baseline's recorded provenance to warn on a stricter-than-baseline @@ -96,15 +99,15 @@ pub(crate) fn resolve_check_output_format(args: &mut CheckArgs) { } /// Validate `--output` / `--output-format` pairing, then resolve the -/// effective threshold set per the documented resolution order +/// effective threshold sets per the documented resolution order /// (#373/#374/#375/#380): the manifest `[thresholds]` base, the -/// `--config` file merged on top (keys win on collision), the tier -/// resolution (hard verbatim, or soft via `[thresholds.soft]` / -/// `--headroom`), and finally the absolute `--threshold` CLI overrides. -/// Dies if no thresholds were configured. Also returns the un-scaled -/// hard-tier limits per metric (#385) so the caller can tell a soft-band -/// encroachment apart from a true hard breach. The set is wrapped in -/// `Arc` so it can be cloned into each walker worker's `Config`. +/// `--config` file merged on top (keys win on collision), the +/// per-language `[thresholds.lang.]` overrides layered per metric +/// (#1141), the tier resolution (hard verbatim, or soft via +/// `[thresholds.soft]` / `--headroom`), and finally the absolute +/// `--threshold` CLI overrides. Dies if no thresholds were configured. +/// The result is wrapped in `Arc` so it can be cloned into each walker +/// worker's `Config`. pub(crate) fn validate_and_build_thresholds( args: &mut CheckArgs, base_thresholds: ParsedThresholds, @@ -117,51 +120,176 @@ pub(crate) fn validate_and_build_thresholds( // Layer 1: the manifest `[thresholds]` table (empty when no // `bca.toml` was discovered). Layer 2: `--config` merges on top, // its keys winning on collision, preserving every existing recipe. - // Both the hard and soft layers merge the same way. - let ParsedThresholds { mut hard, mut soft } = base_thresholds; + // The hard, soft, and per-language layers all merge the same way — + // per-language nested one level deeper, so a `--config` override of + // one metric for one language leaves that language's other limits + // alone. + let ParsedThresholds { + mut hard, + mut soft, + mut lang, + } = base_thresholds; if let Some(config) = args.config.as_deref() { let cfg = load_threshold_config(config); hard.extend(cfg.hard); soft.extend(cfg.soft); + for (slug, overrides) in cfg.lang { + lang.entry(slug).or_default().extend(overrides); + } } // The soft ratio (the `RATIO` in `--tier=soft=RATIO`) was already // validated to `(0, 1]` by `TierSpec::from_str` at parse time, so a // typo is a clap usage error before we ever reach here. - // Capture whether a soft table is configured before `resolve_tier` + // Capture whether a soft table is configured before the resolver // borrows `soft`, so provenance resolution (#486) matches the same // branch the tier resolver takes. let soft_table_present = !soft.is_empty(); - // Layer 3: tier resolution. Produces the per-metric limits the gate - // compares against. Clone `hard` so the un-scaled hard-tier limits - // survive for #385 hard-breach detection below. - let mut merged = resolve_tier(tier, hard.clone(), &soft); - - // Layer 4: `--threshold` CLI flags override the resolved limit for - // the same metric name. They are absolute — applied *after* any - // scaling — because a user who typed an exact limit means it, not a - // fraction of it. The same value also defines the hard-tier ceiling - // for that metric (#385): an explicit `--threshold` is the user's - // declared limit, replacing whatever the hard table held. - for (name, limit) in &args.thresholds { - merged.insert(name.clone(), *limit); - hard.insert(name.clone(), *limit); - } - let set = ThresholdSet::build(&merged).unwrap_or_else(|e| die(e)); - if set.is_empty() { + let layers = SharedLayers::new(&args.thresholds, tier, &soft, &lang, &hard); + let thresholds = layers.resolve_all(&hard, &lang); + if thresholds.is_empty() { die( "no thresholds configured; pass --threshold, --config, or a bca.toml [thresholds] table", ); } + // Legal, but worth saying out loud: with no global `[thresholds]`, + // every language outside the listed set is walked and gated against + // nothing, and reports a clean exit 0 (#1141). + if let Some(gated) = thresholds.languages_gated_without_a_global_table() { + note(format!( + "no global [thresholds] table: only {} {} gated; every other language \ + passes unconditionally", + gated.join(", "), + if gated.len() == 1 { "is" } else { "are" }, + )); + } ResolvedThresholds { - set: Arc::new(set), - hard_limits: hard, + thresholds: Arc::new(thresholds), provenance: resolve_provenance(tier, soft_table_present), } } +/// The per-metric override tables, keyed by canonical language slug. +type LanguageOverrides = BTreeMap<&'static str, BTreeMap>; + +/// The threshold layers every table in one run shares, separated from +/// the per-table hard limits they are applied to. +/// +/// Grouping them is not just parameter hygiene: it is what makes "the +/// soft tier is derived per language" checkable at a glance. The soft +/// overrides and the CLI layer are the *same* for every table; only +/// `hard` differs, and that is precisely why each language's soft band +/// comes out scaled from its own limit. +struct SharedLayers<'a> { + /// Absolute `--threshold` overrides, applied last to every table. + cli: &'a [(String, f64)], + tier: TierSpec, + /// The global `[thresholds.soft]` overrides, resolved afresh against + /// whichever hard table is being built. + soft: &'a BTreeMap, + /// Metrics that *some* table gives a hard limit. A scale-relative + /// soft entry needs a base to scale, and since #1141 that base may + /// live only in a `[thresholds.lang.*]` table — so a table without + /// one skips the entry rather than failing the whole run. + hard_somewhere: BTreeSet<&'a str>, +} + +impl<'a> SharedLayers<'a> { + fn new( + cli: &'a [(String, f64)], + tier: TierSpec, + soft: &'a BTreeMap, + lang: &'a LanguageOverrides, + hard: &'a BTreeMap, + ) -> Self { + let mut hard_somewhere: BTreeSet<&str> = hard.keys().map(String::as_str).collect(); + for overrides in lang.values() { + hard_somewhere.extend(overrides.keys().map(String::as_str)); + } + Self { + cli, + tier, + soft, + hard_somewhere, + } + } + + /// Resolve the global table plus one fully resolved table per + /// language carrying an override (#1141). + fn resolve_all( + &self, + hard: &BTreeMap, + lang: &LanguageOverrides, + ) -> LanguageThresholds { + let per_language = lang + .iter() + .map(|(slug, overrides)| { + // Per-metric override with inheritance, not wholesale + // replacement: start from the global hard table so a + // language that raises `cognitive` still gates everything + // else at the project limit. + let mut lang_hard = hard.clone(); + lang_hard.extend(overrides.iter().map(|(k, v)| (k.clone(), *v))); + let context = format!("[thresholds.lang.{slug}]"); + (*slug, self.resolve_one(lang_hard, Some(&context))) + }) + .collect(); + LanguageThresholds::new(self.resolve_one(hard.clone(), None), per_language) + } + + /// Resolve one hard table — the global one, or a language's — + /// through the tier and the `--threshold` CLI layer. + /// + /// The soft tier is **derived from `hard`**, which is already this + /// table's resolved hard limits — so a language that loosens a limit + /// gets a soft band scaled from *its* limit, never from the + /// project-wide one. That is what keeps the soft band from sitting + /// above the ceiling its offenders are escalated against: were it + /// derived globally, every function between the project limit and + /// the language's own would report a hard breach (exit 5) while + /// sitting inside the limit configured for it. There is deliberately + /// no `[thresholds.lang..soft]` syntax; the derivation needs + /// none. + /// + /// `--threshold` is applied last and absolutely, to the resolved + /// limit *and* the hard ceiling, for every table: a limit the user + /// typed on the command line means exactly that number, and a + /// per-language table must not quietly outrank it. + /// + /// `context` names the offending table in a build error, and is + /// `None` for the global set so its long-standing message is + /// unprefixed. + fn resolve_one(&self, mut hard: BTreeMap, context: Option<&str>) -> ThresholdSet { + // Drop a scale-relative soft entry whose metric is gated by some + // *other* table: this one has no limit for it to scale, and + // nothing to gate either. Where no table supplies a base, the + // entry is left in so `resolve_tier` reports the original "no + // hard limit exists" error rather than silently dropping a + // threshold (#1141). + let soft: BTreeMap = self + .soft + .iter() + .filter(|(name, limit)| { + !matches!(limit, SoftLimit::Scale(_)) + || hard.contains_key(name.as_str()) + || !self.hard_somewhere.contains(name.as_str()) + }) + .map(|(name, limit)| (name.clone(), *limit)) + .collect(); + let mut merged = resolve_tier(self.tier, hard.clone(), &soft); + for (name, limit) in self.cli { + merged.insert(name.clone(), *limit); + hard.insert(name.clone(), *limit); + } + ThresholdSet::build_tiered(&merged, &hard).unwrap_or_else(|e| match context { + Some(table) => die(format_args!("{table}: {e}")), + None => die(e), + }) + } +} + /// Resolve the per-metric limits for the requested tier (#375/#688). /// /// The soft ratio rides on the [`TierSpec`] itself — there is no longer diff --git a/big-code-analysis-cli/src/commands_tests.rs b/big-code-analysis-cli/src/commands_tests.rs index 41b87aaab..b6f7848d2 100644 --- a/big-code-analysis-cli/src/commands_tests.rs +++ b/big-code-analysis-cli/src/commands_tests.rs @@ -18,6 +18,7 @@ fn violation(path: &str, function: &str, value: f64, limit: f64) -> Violation { metric: "cyclomatic", value, limit, + hard_limit: Some(limit), lower_is_worse: false, body_hash: None, suppressed: false, @@ -560,7 +561,10 @@ fn effective_config_toml_roundtrips_through_threshold_config_schema() { thresholds.insert("halstead.volume".to_owned(), 1_000.0); let effective = EffectiveConfig { - thresholds: thresholds.clone(), + thresholds: EffectiveThresholds { + global: thresholds.clone(), + lang: BTreeMap::new(), + }, check: EffectiveCheck { paths: vec!["src/".to_owned()], include: vec!["*.rs".to_owned()], @@ -611,7 +615,10 @@ fn effective_config_json_serializes_threshold_overrides() { let mut thresholds = BTreeMap::new(); thresholds.insert("cyclomatic".to_owned(), 22.0); let effective = EffectiveConfig { - thresholds, + thresholds: EffectiveThresholds { + global: thresholds, + lang: BTreeMap::new(), + }, check: EffectiveCheck { paths: Vec::new(), include: Vec::new(), @@ -675,10 +682,21 @@ fn effective_config_reflects_resolved_threshold_set() { }; let args = check_args_for_remediation(None, None, false); - let effective = - EffectiveConfig::from_resolved(&globals, &args, &set, None, crate::TierSpec::Hard, false); - assert_eq!(effective.thresholds.get("cyclomatic"), Some(&11.0)); - assert_eq!(effective.thresholds.get("cognitive"), Some(&13.0)); + let resolved = LanguageThresholds::new(set, BTreeMap::new()); + let effective = EffectiveConfig::from_resolved( + &globals, + &args, + &resolved, + None, + crate::TierSpec::Hard, + false, + ); + assert_eq!(effective.thresholds.global.get("cyclomatic"), Some(&11.0)); + assert_eq!(effective.thresholds.global.get("cognitive"), Some(&13.0)); + assert!( + effective.thresholds.lang.is_empty(), + "no per-language overrides were configured" + ); assert_eq!(effective.check.paths, vec!["src/".to_owned()]); assert_eq!(effective.check.include, vec!["*.rs".to_owned()]); assert!(effective.check.exclude_tests); @@ -822,20 +840,17 @@ fn apply_check_exclude_unions_flag_and_file() { /// Build a `(Violation, Option)` pair for the classifier /// tests: `value` drives hard-breach detection; `coverage` selects the /// new/regressed bucket (`None` models "no `--baseline` supplied"). +/// +/// `violation` stamps `hard_limit` from the same `10.0`, which is the +/// hard-tier shape (soft limit == hard ceiling). Soft-tier tests that +/// need the two to differ set `hard_limit` themselves. fn pair(value: f64, coverage: Option) -> (Violation, Option) { (violation("a.rs", "f", value, 10.0), coverage) } -/// Hard-tier limits used across the soft-tier escalation tests: a -/// `cyclomatic` ceiling of 10. The `violation` helper stamps the metric -/// as `cyclomatic`, so this key always matches. -fn hard_limits() -> BTreeMap { - BTreeMap::from([("cyclomatic".to_owned(), 10.0)]) -} - #[test] fn classify_empty_pairs_is_clean() { - let outcome = classify_check_outcome(&[], Tier::Hard, &hard_limits()); + let outcome = classify_check_outcome(&[], Tier::Hard); assert_eq!(outcome, CheckOutcome::Clean); } @@ -845,14 +860,14 @@ fn classify_no_baseline_is_new_only() { // counts as a new offender — there is nothing baselined to regress // against. let pairs = [pair(20.0, None), pair(30.0, None)]; - let outcome = classify_check_outcome(&pairs, Tier::Hard, &hard_limits()); + let outcome = classify_check_outcome(&pairs, Tier::Hard); assert_eq!(outcome, CheckOutcome::NewOnly); } #[test] fn classify_new_variant_is_new_only() { let pairs = [pair(20.0, Some(Coverage::New))]; - let outcome = classify_check_outcome(&pairs, Tier::Hard, &hard_limits()); + let outcome = classify_check_outcome(&pairs, Tier::Hard); assert_eq!(outcome, CheckOutcome::NewOnly); } @@ -862,7 +877,7 @@ fn classify_regressed_only() { pair(20.0, Some(Coverage::Regressed { recorded: 15.0 })), pair(30.0, Some(Coverage::Regressed { recorded: 25.0 })), ]; - let outcome = classify_check_outcome(&pairs, Tier::Hard, &hard_limits()); + let outcome = classify_check_outcome(&pairs, Tier::Hard); assert_eq!(outcome, CheckOutcome::RegressionOnly); } @@ -872,7 +887,7 @@ fn classify_mixed_new_and_regression() { pair(20.0, Some(Coverage::New)), pair(30.0, Some(Coverage::Regressed { recorded: 25.0 })), ]; - let outcome = classify_check_outcome(&pairs, Tier::Hard, &hard_limits()); + let outcome = classify_check_outcome(&pairs, Tier::Hard); assert_eq!(outcome, CheckOutcome::Mixed); } @@ -881,7 +896,7 @@ fn classify_soft_tier_hard_breach_escalates_over_regression() { // Soft tier, value 12 over the hard ceiling 10: a true breach, more // urgent than the regression bucket it would otherwise land in. let pairs = [pair(12.0, Some(Coverage::Regressed { recorded: 11.0 }))]; - let outcome = classify_check_outcome(&pairs, Tier::Soft, &hard_limits()); + let outcome = classify_check_outcome(&pairs, Tier::Soft); assert_eq!(outcome, CheckOutcome::HardBreach); } @@ -890,7 +905,7 @@ fn classify_soft_tier_encroachment_is_not_hard_breach() { // Soft tier, value 8: over the soft band (the gate already kept it) // but under the hard ceiling 10 — encroachment, not a breach. let pairs = [pair(8.0, Some(Coverage::New))]; - let outcome = classify_check_outcome(&pairs, Tier::Soft, &hard_limits()); + let outcome = classify_check_outcome(&pairs, Tier::Soft); assert_eq!(outcome, CheckOutcome::NewOnly); } @@ -899,7 +914,7 @@ fn classify_hard_tier_never_escalates_to_breach() { // At the hard tier every violation is over the hard limit, so the // breach escalation is suppressed and the new/regr split survives. let pairs = [pair(20.0, Some(Coverage::Regressed { recorded: 15.0 }))]; - let outcome = classify_check_outcome(&pairs, Tier::Hard, &hard_limits()); + let outcome = classify_check_outcome(&pairs, Tier::Hard); assert_eq!(outcome, CheckOutcome::RegressionOnly); } @@ -909,34 +924,35 @@ fn classify_soft_tier_nan_value_is_not_breach() { // escalates to a hard breach; it falls to the new/regr split. Pins // the documented defensive branch in `classify_check_outcome`. let pairs = [pair(f64::NAN, Some(Coverage::New))]; - let outcome = classify_check_outcome(&pairs, Tier::Soft, &hard_limits()); + let outcome = classify_check_outcome(&pairs, Tier::Soft); assert_eq!(outcome, CheckOutcome::NewOnly); } #[test] -fn classify_soft_tier_unknown_metric_is_not_breach() { - // A metric absent from the hard-limit map cannot be a hard breach - // (no ceiling to exceed); it falls through to the new/regr split. - let pairs = [pair(999.0, Some(Coverage::New))]; - let outcome = classify_check_outcome(&pairs, Tier::Soft, &BTreeMap::new()); +fn classify_soft_tier_metric_without_hard_ceiling_is_not_breach() { + // A `[thresholds.soft]` absolute limit with no `[thresholds]` + // counterpart leaves the metric with no hard ceiling to exceed, so + // however far the value overshoots it stays an encroachment. + let mut pairs = [pair(999.0, Some(Coverage::New))]; + pairs[0].0.hard_limit = None; + let outcome = classify_check_outcome(&pairs, Tier::Soft); assert_eq!(outcome, CheckOutcome::NewOnly); } /// Build a `(Violation, Option)` pair for a lower-is-worse /// `mi.original` metric (#837): a value *below* the floor is the breach. +/// +/// The soft floor is 50 and the hard floor 10 — the realistic soft-tier +/// shape for a lower-is-worse metric, where the early-warning floor sits +/// *above* the ceiling that constitutes a real breach. fn pair_low(value: f64, coverage: Option) -> (Violation, Option) { let mut v = violation("a.rs", "f", value, 50.0); v.metric = "mi.original"; v.lower_is_worse = true; + v.hard_limit = Some(10.0); (v, coverage) } -/// Hard-tier limits for the lower-is-worse escalation tests: an -/// `mi.original` *floor* of 10. A value below 10 is a hard breach. -fn hard_limits_mi() -> BTreeMap { - BTreeMap::from([("mi.original".to_owned(), 10.0)]) -} - #[test] fn classify_soft_tier_lower_is_worse_hard_breach_escalates() { // mi.original is lower-is-worse with a hard floor of 10. A value of 5 @@ -944,7 +960,7 @@ fn classify_soft_tier_lower_is_worse_hard_breach_escalates() { // false. Before #837 the hardcoded `value > hard` missed this and the // outcome fell to RegressionOnly, under-reporting the exit code. let pairs = [pair_low(5.0, Some(Coverage::Regressed { recorded: 8.0 }))]; - let outcome = classify_check_outcome(&pairs, Tier::Soft, &hard_limits_mi()); + let outcome = classify_check_outcome(&pairs, Tier::Soft); assert_eq!(outcome, CheckOutcome::HardBreach); } @@ -953,7 +969,7 @@ fn classify_soft_tier_lower_is_worse_above_floor_is_not_breach() { // mi.original value 15 is above the hard floor 10, so it is not a hard // breach; it falls to the new/regr split. let pairs = [pair_low(15.0, Some(Coverage::New))]; - let outcome = classify_check_outcome(&pairs, Tier::Soft, &hard_limits_mi()); + let outcome = classify_check_outcome(&pairs, Tier::Soft); assert_eq!(outcome, CheckOutcome::NewOnly); } diff --git a/big-code-analysis-cli/src/dispatch.rs b/big-code-analysis-cli/src/dispatch.rs index 5cbe424a3..0f1d476ea 100644 --- a/big-code-analysis-cli/src/dispatch.rs +++ b/big-code-analysis-cli/src/dispatch.rs @@ -528,9 +528,14 @@ fn dispatch_check_file( // by users who opted in via `--baseline-fuzzy-match`. let source_for_hash = cfg.fuzzy_baseline.then(|| source.clone()); if let Ok(space) = analyze_file(language, source, &path, pr, cfg.metrics_options()) - && let (Some(set), Some(tx)) = (cfg.threshold_set.as_ref(), cfg.check_tx.as_ref()) + && let (Some(thresholds), Some(tx)) = (cfg.thresholds.as_ref(), cfg.check_tx.as_ref()) && !matches!(language, LANG::Preproc | LANG::Ccomment) { + // Select this file's gate: the `[thresholds.lang.]` set + // when one exists, the global set otherwise (#1141). The + // fallback is inside `for_language`, so a language nobody + // overrode takes the same code path as one that was. + let set = thresholds.for_language(language); // Pass the path through as `&Path` so non-UTF-8 bytes are // preserved on each emitted `Violation`. Display / offender // serialization decide their own lossy strategy at the output @@ -667,7 +672,7 @@ mod tests { markdown_tx: None, report_hotspot_tx: None, strip_prefix: String::new(), - threshold_set: None, + thresholds: None, check_tx: None, exemptions_tx: None, files_dispatched: None, diff --git a/big-code-analysis-cli/src/lib.rs b/big-code-analysis-cli/src/lib.rs index dfa2a7081..bd90f0f98 100644 --- a/big-code-analysis-cli/src/lib.rs +++ b/big-code-analysis-cli/src/lib.rs @@ -58,6 +58,8 @@ mod metric_diff; mod path_io; mod provenance; mod qualified_name; +mod threshold_lang; +mod threshold_soft; mod threshold_suggestion; mod thresholds; mod vcs_command; @@ -94,9 +96,10 @@ use formats::{JitFormat, MetricsFormat, ReportFormat, TrendFormat, VcsFormat}; use markdown_report::FunctionSummary; use metric_catalog::ListMetricsMode; use metric_diff::DiffSide; +use threshold_lang::LanguageThresholds; use thresholds::{ - ParsedThresholds, ThresholdConfig, ThresholdSet, Violation, parse_cli_threshold, - parse_fail_above, split_thresholds_table, + ParsedThresholds, ThresholdConfig, Violation, parse_cli_threshold, parse_fail_above, + split_thresholds_table, }; use big_code_analysis::LANG; @@ -178,9 +181,11 @@ struct Config { report_hotspot_tx: Option>, /// Path prefix stripped from file paths in the markdown report. strip_prefix: String, - /// Pre-resolved thresholds for `Action::Check`. `None` for every + /// Pre-resolved thresholds for `Action::Check`: the global set plus + /// one fully resolved set per language carrying a + /// `[thresholds.lang.]` override (#1141). `None` for every /// other action. - threshold_set: Option>, + thresholds: Option>, /// Sender for streaming [`Violation`] records when running `check`. check_tx: Option>, /// Sender for streaming per-file suppression-marker batches when @@ -334,7 +339,7 @@ impl Config { markdown_tx: None, report_hotspot_tx: None, strip_prefix: String::new(), - threshold_set: None, + thresholds: None, check_tx: None, exemptions_tx: None, files_dispatched: None, diff --git a/big-code-analysis-cli/src/manifest.rs b/big-code-analysis-cli/src/manifest.rs index de0023cf0..f9b72c92b 100644 --- a/big-code-analysis-cli/src/manifest.rs +++ b/big-code-analysis-cli/src/manifest.rs @@ -678,7 +678,7 @@ impl Manifest { /// fails both comparisons, is rejected too.) fn headroom(&self) -> Option { let ratio = self.raw.check.headroom.or(self.raw.headroom)?; - if !crate::thresholds::is_valid_scale_ratio(ratio) { + if !crate::threshold_soft::is_valid_scale_ratio(ratio) { die(format_args!( "bca.toml: headroom must be in (0, 1]; got {ratio}" )); diff --git a/big-code-analysis-cli/src/manifest_tests.rs b/big-code-analysis-cli/src/manifest_tests.rs index f38bb7ec0..a6091f459 100644 --- a/big-code-analysis-cli/src/manifest_tests.rs +++ b/big-code-analysis-cli/src/manifest_tests.rs @@ -46,7 +46,40 @@ fn thresholds_extracts_scalars_and_ignores_subtables() { // The soft override is captured as an absolute limit. assert_eq!( parsed.soft.get("cyclomatic"), - Some(&crate::thresholds::SoftLimit::Absolute(13.0)) + Some(&crate::threshold_soft::SoftLimit::Absolute(13.0)) + ); +} + +/// `[thresholds.lang.]` reaches [`Manifest::thresholds`] through +/// the same untyped `[thresholds]` map the `soft` sub-table uses (#1141), +/// so it needs no `RawManifest` field of its own — and, because +/// `KNOWN_SUB_TABLES` deliberately does not walk `[thresholds]`, no +/// allowlist entry either. Pin both halves: the nesting is split out as +/// a per-language layer, *and* it draws no "unrecognized key" warning. +#[test] +fn thresholds_lang_subtable_is_split_out_and_draws_no_warning() { + let text = "[thresholds]\n\ + cognitive = 15\n\ + [thresholds.lang.c]\n\ + cognitive = 25\n"; + let raw: RawManifest = toml::from_str(text).expect("parse"); + let parsed = manifest(raw).thresholds(); + + assert_eq!(parsed.hard.get("cognitive"), Some(&15.0)); + assert!( + !parsed.hard.contains_key("lang"), + "the lang sub-table must not be treated as a scalar limit" + ); + assert_eq!(parsed.hard.len(), 1); + assert_eq!(parsed.lang["c"].get("cognitive"), Some(&25.0)); + + assert!( + unknown_top_level_keys(text).is_empty(), + "`thresholds` is already an allowlisted top-level key" + ); + assert!( + unknown_sub_table_keys(text).is_empty(), + "`[thresholds]` keys are validated by split_thresholds_table, not the allowlist" ); } diff --git a/big-code-analysis-cli/src/threshold_lang.rs b/big-code-analysis-cli/src/threshold_lang.rs new file mode 100644 index 000000000..538070061 --- /dev/null +++ b/big-code-analysis-cli/src/threshold_lang.rs @@ -0,0 +1,236 @@ +//! Per-language threshold overrides (`[thresholds.lang.]`, issue #1141). +//! +//! A single `[thresholds]` table has to fit every language in the tree, +//! and the measured spread is too wide for that: the 97.5th-percentile +//! per-function `cognitive` value runs from 4 in C# to 50 in C. This +//! module owns the two halves of the fix — the slug vocabulary and its +//! parse, and the [`LanguageThresholds`] lookup the check walk consults +//! once per file. +//! +//! Two rules point in opposite directions and are easy to conflate: +//! +//! - An **unknown slug written in the manifest** is a hard error. A +//! typo'd `[thresholds.lang.rust-lang]` would otherwise leave the user +//! believing a gate is loosened when it is not. +//! - A **recognised language with no override of its own** falls through +//! to the global table, so nothing is silently ungated. +//! +//! The `preproc` / `ccomment` pseudo-grammars sit outside both rules: +//! `bca check` never gates them at all, so their slugs are rejected +//! rather than accepted as tables that could never fire. A file whose +//! extension maps to no grammar is likewise skipped by the walk before +//! the gate sees it. + +use std::collections::BTreeMap; +use std::sync::Arc; + +use big_code_analysis::{LANG, Metric}; + +use crate::thresholds::{ThresholdSet, threshold_scalar}; + +/// Reserved key inside `[thresholds]` that introduces the per-language +/// override sub-tables (`[thresholds.lang.]`). The nesting under a +/// reserved `lang` key — rather than a flat `[thresholds.]` — +/// keeps every future reserved key (`soft` and its successors) out of +/// the language namespace, where a collision would be silent. +pub(crate) const LANG_SUBTABLE_KEY: &str = "lang"; + +/// Canonical language slugs accepted as `[thresholds.lang.]` keys, +/// sorted for the did-you-mean hint and the error listing. +/// +/// Derived from [`LANG::name`] — the same values [`LANG`]'s `FromStr` +/// matches and [`crate::walk::valid_languages`] lists for `--language` — +/// so the manifest and the flag cannot grow two spellings of one +/// language. `slug_vocabulary_matches_the_language_flag` pins that. +pub(crate) fn known_language_slugs() -> Vec<&'static str> { + let mut names: Vec<&'static str> = LANG::into_enum_iter().map(|lang| lang.name()).collect(); + names.sort_unstable(); + names +} + +/// Parse the `[thresholds.lang]` sub-table into one `metric = limit` map +/// per language slug, keyed by [`LANG::name`]. +/// +/// Only the metrics a language actually overrides appear in its map; the +/// rest are inherited from the global table when the set is resolved. +/// An empty override table is dropped rather than recorded, so it +/// behaves exactly like an absent one instead of showing up in +/// `--print-effective-config` as a language whose limits differ. +pub(crate) fn parse_language_tables( + value: &toml::Value, +) -> Result>, String> { + let tables = value.as_table().ok_or_else(|| { + "[thresholds.lang] must be a table of per-language sub-tables \ + (e.g. `[thresholds.lang.rust]`)" + .to_owned() + })?; + let mut out = BTreeMap::new(); + for (slug, table) in tables { + let lang = parse_slug(slug)?; + let limits = parse_one_language_table(slug, table)?; + if !limits.is_empty() { + out.insert(lang.name(), limits); + } + } + Ok(out) +} + +/// Parse one `[thresholds.lang.]` table's `metric = limit` pairs. +/// `slug` is used only to attribute errors to the table they came from. +fn parse_one_language_table( + slug: &str, + table: &toml::Value, +) -> Result, String> { + let table = table.as_table().ok_or_else(|| { + format!("[thresholds.lang.{slug}] must be a table of `metric = ` entries") + })?; + let context = format!("[thresholds.lang.{slug}]"); + let mut limits = BTreeMap::new(); + for (name, value) in table { + // `soft` is the one wrong guess a reader is likely to make here, + // and the generic "expected a number, got table" would send them + // looking for a typo rather than at the design. + if name == crate::threshold_soft::SOFT_SUBTABLE_KEY { + return Err(format!( + "[thresholds.lang.{slug}.soft] is not a table: a language's soft tier is \ + derived from its own hard limits, so `--tier=soft` already scales \ + [thresholds.lang.{slug}] without one" + )); + } + limits.insert(name.clone(), threshold_scalar(&context, name, value)?); + } + Ok(limits) +} + +/// Resolve one `[thresholds.lang]` key to its [`LANG`], with the same +/// error shape and did-you-mean hint the unknown-metric path produces. +/// +/// The `preproc` and `ccomment` pseudo-grammars parse successfully but +/// are rejected here: `bca check` excludes them from the gate entirely, +/// so a table naming one is a threshold that can never fire — exactly +/// the silent no-op the unknown-slug error exists to prevent. +fn parse_slug(slug: &str) -> Result { + let lang: LANG = slug.parse().map_err(|_| { + let known = known_language_slugs(); + format!( + "unknown language {slug:?} in [thresholds.lang]{}; known languages: {}", + crate::threshold_suggestion::format_suggestion(slug, &known), + known.join(", ") + ) + })?; + if matches!(lang, LANG::Preproc | LANG::Ccomment) { + return Err(format!( + "[thresholds.lang.{slug}] has no effect: {slug} is an auxiliary grammar that \ + `bca check` never gates, so a limit set here could never fire" + )); + } + Ok(lang) +} + +/// The resolved gate: one global [`ThresholdSet`] plus one *fully +/// resolved* set per language carrying an override. +/// +/// Per-language sets are complete, not deltas — each is the global table +/// with that language's overrides applied per metric — so selecting one +/// is a lookup, never a merge, and `--print-effective-config` can print +/// the number that will actually apply. +#[derive(Debug)] +pub(crate) struct LanguageThresholds { + global: Arc, + /// Keyed by canonical slug ([`LANG::name`]) rather than by [`LANG`], + /// which is not `Ord`. The string keys also give + /// `--print-effective-config` a deterministic slug-sorted order for + /// free. + per_language: BTreeMap<&'static str, Arc>, +} + +impl LanguageThresholds { + pub(crate) fn new( + global: ThresholdSet, + per_language: BTreeMap<&'static str, ThresholdSet>, + ) -> Self { + Self { + global: Arc::new(global), + per_language: per_language + .into_iter() + .map(|(slug, set)| (slug, Arc::new(set))) + .collect(), + } + } + + /// The set that gates `lang`. Falls back to the global set for every + /// language without an override — including any a future grammar + /// adds — so there is exactly one selection path and no language can + /// end up ungated by omission. + pub(crate) fn for_language(&self, lang: LANG) -> &ThresholdSet { + self.per_language.get(lang.name()).unwrap_or(&self.global) + } + + /// The global set, for surfaces that gate nothing language-specific + /// (`--print-effective-config`'s `[thresholds]` table). + pub(crate) fn global(&self) -> &ThresholdSet { + &self.global + } + + /// The per-language sets, slug-sorted. + pub(crate) fn languages(&self) -> impl Iterator { + self.per_language + .iter() + .map(|(slug, set)| (*slug, set.as_ref())) + } + + /// True when no tier of this gate configures a single threshold. A + /// per-language set inherits the global table, so it can only be + /// empty when the global one is — but check every set anyway, since + /// a future layering could break that and a silently empty gate + /// green-lights CI. + pub(crate) fn is_empty(&self) -> bool { + self.global.is_empty() && self.per_language.values().all(|set| set.is_empty()) + } + + /// The slugs gated by an override when the global table configures + /// nothing — i.e. the *only* languages this run gates. + /// + /// A manifest with `[thresholds.lang.c]` and no `[thresholds]` is + /// legal and does exactly what it says, but every other language in + /// the tree is then walked and gated against an empty set: no + /// offenders, exit 0, no signal. Before per-language tables an empty + /// gate always died, so the caller warns rather than let that + /// difference pass unremarked. + pub(crate) fn languages_gated_without_a_global_table(&self) -> Option> { + if !self.global.is_empty() { + return None; + } + let gated: Vec<&'static str> = self + .per_language + .iter() + .filter(|(_, set)| !set.is_empty()) + .map(|(slug, _)| *slug) + .collect(); + (!gated.is_empty()).then_some(gated) + } + + /// The union of every set's metric families, for the `--metrics`-style + /// narrowing the check walk applies (#1113). Must be the union, not + /// the global set's families: a metric that only a + /// `[thresholds.lang.]` table gates would otherwise be left at + /// its zero default and silently disarm that language's gate. + pub(crate) fn selected_metrics(&self) -> Vec { + let mut selected = self.global.selected_metrics(); + for set in self.per_language.values() { + for metric in set.selected_metrics() { + if !selected.contains(&metric) { + selected.push(metric); + } + } + } + selected + } +} + +#[cfg(test)] +// Threshold limits are exact `f64` config values, never computed, so +// comparing them is the contract rather than a float-precision hazard. +#[allow(clippy::float_cmp)] +#[path = "threshold_lang_tests.rs"] +mod tests; diff --git a/big-code-analysis-cli/src/threshold_lang_tests.rs b/big-code-analysis-cli/src/threshold_lang_tests.rs new file mode 100644 index 000000000..9c02fbfd9 --- /dev/null +++ b/big-code-analysis-cli/src/threshold_lang_tests.rs @@ -0,0 +1,214 @@ +//! Unit tests for [`crate::threshold_lang`]. + +use super::*; +use crate::Action; +use crate::thresholds::ThresholdSet; +use crate::walk::resolve_language; + +/// The two auxiliary grammars `bca check` never gates, and so never +/// accepts as `[thresholds.lang]` keys either. +const UNGATED: [LANG; 2] = [LANG::Preproc, LANG::Ccomment]; + +/// The manifest slug vocabulary and `--language` must stay one list. +/// +/// The failure this pins is a second, drifting spelling table: someone +/// adds a language, wires it into `--language`, and hand-writes a slug +/// map for `[thresholds.lang]` that spells it differently (or omits it), +/// so a manifest override silently never matches any file. +#[test] +fn slug_vocabulary_matches_the_language_flag() { + let slugs = known_language_slugs(); + for lang in LANG::into_enum_iter() { + let slug = lang.name(); + assert!( + slugs.contains(&slug), + "{slug} is a LANG but not an accepted [thresholds.lang] slug" + ); + // The same string, fed to `--language`, must resolve to the same + // variant the slug parser produced. + assert_eq!( + resolve_language(Some(slug), &Action::Check), + Some(lang), + "--language {slug} and [thresholds.lang.{slug}] disagree" + ); + if !UNGATED.contains(&lang) { + assert_eq!(parse_slug(slug), Ok(lang)); + } + } + assert_eq!( + slugs.len(), + LANG::into_enum_iter().count(), + "slug list and LANG must be the same size" + ); +} + +/// `preproc` and `ccomment` parse as languages but are rejected as +/// override keys. +/// +/// `dispatch_check_file` skips both grammars before the threshold set is +/// ever consulted, so a `[thresholds.lang.preproc]` table is a limit +/// that can never fire — the same silent no-op the unknown-slug error +/// exists to prevent, which is why it is an error rather than a table +/// `--print-effective-config` would advertise as live. +#[test] +fn ungated_pseudo_language_slugs_are_rejected() { + for lang in UNGATED { + let slug = lang.name(); + assert!( + slug.parse::().is_ok(), + "{slug} must still be a real LANG, or this test proves nothing" + ); + let err = parse_slug(slug).expect_err("auxiliary grammars are not gated"); + assert!( + err.contains(&format!("[thresholds.lang.{slug}] has no effect")), + "error names the offending table: {err}" + ); + } +} + +/// The awkward spellings the issue called out by name, written as +/// literals so a rename in `mk_langs!` fails here rather than silently +/// invalidating every `bca.toml` in the wild. +#[test] +fn awkward_slugs_keep_their_documented_spelling() { + for (slug, expected) in [ + ("cpp", LANG::Cpp), + ("csharp", LANG::Csharp), + ("objc", LANG::Objc), + ("tsx", LANG::Tsx), + ("mozcpp", LANG::Mozcpp), + ("mozjs", LANG::Mozjs), + ] { + assert_eq!(parse_slug(slug), Ok(expected), "slug {slug}"); + } +} + +/// An unknown slug is rejected with the same error shape (and +/// did-you-mean hint) the unknown-metric path produces — never a silent +/// no-op that leaves the user believing a gate was loosened. +#[test] +fn unknown_slug_error_includes_suggestion() { + let err = parse_slug("rustlang").expect_err("`rustlang` is not a language"); + assert!( + err.contains("unknown language \"rustlang\" in [thresholds.lang]"), + "error names the offending key and table: {err}" + ); + assert!( + err.contains("did you mean `rust`?"), + "error suggests the near miss: {err}" + ); + assert!( + err.contains("known languages: "), + "error lists the accepted set: {err}" + ); +} + +/// Parse a `[thresholds.lang]` body — the fixture is written as if the +/// `[thresholds.lang]` header were already consumed, so `[c]` here is +/// `[thresholds.lang.c]` in a real manifest. +fn parse(toml_src: &str) -> Result>, String> { + let table: toml::Table = toml::from_str(toml_src).expect("fixture parses as TOML"); + parse_language_tables(&toml::Value::Table(table)) +} + +#[test] +fn parses_one_table_per_language() { + let parsed = parse("[c]\ncognitive = 25\n[elixir]\nnom = 100\n").expect("valid tables"); + assert_eq!(parsed.len(), 2); + assert_eq!(parsed["c"]["cognitive"], 25.0); + assert_eq!(parsed["elixir"]["nom"], 100.0); + // Only the overridden metric is recorded; inheritance happens later, + // at resolution, against the global table. + assert_eq!(parsed["c"].len(), 1); +} + +#[test] +fn empty_language_table_is_dropped() { + // `[thresholds.lang.c]` with no keys resolves to the global set, so + // recording it would make `--print-effective-config` claim a + // difference that does not exist. + assert!(parse("[c]\n").expect("valid table").is_empty()); +} + +#[test] +fn non_table_shapes_are_rejected() { + let err = parse_language_tables(&toml::Value::Integer(3)).expect_err("not a table"); + assert!(err.contains("[thresholds.lang] must be a table"), "{err}"); + + let table: toml::Table = toml::from_str("c = 25").expect("fixture parses"); + let err = + parse_language_tables(&toml::Value::Table(table)).expect_err("language value not a table"); + assert!(err.contains("[thresholds.lang.c] must be a table"), "{err}"); +} + +/// `[thresholds.lang..soft]` is the wrong guess a reader is most +/// likely to make, so it gets the design decision rather than the +/// generic "expected a number, got table". +#[test] +fn a_nested_soft_table_explains_why_it_does_not_exist() { + let err = parse("[c.soft]\ncognitive = 5\n").expect_err("no per-language soft table"); + assert!( + err.contains("[thresholds.lang.c.soft] is not a table") + && err.contains("derived from its own hard limits"), + "error points at the derivation, not a typo: {err}" + ); +} + +/// A non-numeric limit is attributed to the language table it was +/// written in, not to the global `[thresholds]`. +#[test] +fn non_numeric_limit_names_the_language_table() { + let err = parse("[c]\ncognitive = \"lots\"\n").expect_err("string is not a limit"); + assert_eq!( + err, + "[thresholds.lang.c] \"cognitive\": expected a number, got string" + ); +} + +/// `for_language` has one fallback path, not a table of special cases: +/// any language without an override of its own gates against the global +/// set. +#[test] +fn unoverridden_languages_fall_back_to_the_global_set() { + let global = ThresholdSet::build(&BTreeMap::from([("cognitive".to_owned(), 15.0)])) + .expect("global set builds"); + let c = ThresholdSet::build(&BTreeMap::from([("cognitive".to_owned(), 25.0)])) + .expect("C set builds"); + let resolved = LanguageThresholds::new(global, BTreeMap::from([(LANG::C.name(), c)])); + + let limit_for = |lang| { + resolved + .for_language(lang) + .iter() + .find(|(name, _)| *name == "cognitive") + .expect("cognitive is configured") + .1 + }; + assert_eq!(limit_for(LANG::C), 25.0, "the overridden language"); + assert_eq!(limit_for(LANG::Rust), 15.0, "an un-overridden language"); + assert_eq!(limit_for(LANG::Elixir), 15.0, "another un-overridden one"); +} + +/// The walk narrows metric computation to the families the gate reads +/// (#1113). A metric that only a per-language table gates must survive +/// that narrowing, or its gate silently reads a zero default. +#[test] +fn selected_metrics_unions_every_language() { + let global = ThresholdSet::build(&BTreeMap::from([("cognitive".to_owned(), 15.0)])) + .expect("global set builds"); + let elixir = ThresholdSet::build(&BTreeMap::from([ + ("cognitive".to_owned(), 15.0), + ("nom".to_owned(), 100.0), + ])) + .expect("Elixir set builds"); + let resolved = LanguageThresholds::new(global, BTreeMap::from([(LANG::Elixir.name(), elixir)])); + + let selected = resolved.selected_metrics(); + assert!( + selected.contains(&Metric::Nom), + "a language-only metric must be computed: {selected:?}" + ); + assert!(selected.contains(&Metric::Cognitive), "{selected:?}"); + // Deduplicated: `cognitive` appears in both sets. + assert_eq!(selected.len(), 2, "{selected:?}"); +} diff --git a/big-code-analysis-cli/src/threshold_soft.rs b/big-code-analysis-cli/src/threshold_soft.rs new file mode 100644 index 000000000..b6223c6f3 --- /dev/null +++ b/big-code-analysis-cli/src/threshold_soft.rs @@ -0,0 +1,154 @@ +//! The soft threshold tier: its limit forms, their parsing, and the +//! ratio scaling shared with `--headroom` (issue #375). +//! +//! `bca check --tier=soft` gates against an early-warning band that +//! fires *before* the hard `[thresholds]` limits. A `[thresholds.soft]` +//! entry is either an absolute number or a `"x"` string scaling +//! the metric's hard limit, and the scale form cannot be resolved until +//! the manifest and `--config` layers have merged — which is why +//! [`SoftLimit`] is a parsed-but-unresolved value rather than an `f64`. +//! +//! Sits alongside [`crate::threshold_lang`], the per-language tier; +//! [`crate::thresholds`] owns the metric registry and the evaluation +//! engine both feed. + +/// Reserved key inside `[thresholds]` that introduces the soft-tier +/// sub-table (`[thresholds.soft]`). Every other key in the table is a +/// hard-limit metric name. No metric is named `soft`, so the reservation +/// never collides with a real threshold. +pub(crate) const SOFT_SUBTABLE_KEY: &str = "soft"; + +/// One soft-tier limit, before resolution against the hard tier. +/// +/// `[thresholds.soft]` values are either a plain number (an absolute soft +/// limit) or a `"x"` string (scale the metric's hard limit by +/// `ratio`). The scale form is resolved lazily because it needs the +/// merged hard limit, which is only known after the manifest and +/// `--config` layers combine — see [`SoftLimit::resolve`]. +#[derive(Debug, Clone, Copy, PartialEq)] +pub(crate) enum SoftLimit { + /// An explicit soft limit, used as-is. + Absolute(f64), + /// A factor in `(0, 1]` applied to the metric's hard limit. + Scale(f64), +} + +impl SoftLimit { + /// Resolve to a concrete limit. `Absolute` ignores `hard`; `Scale` + /// multiplies the metric's hard limit, erroring when no hard limit + /// exists for the metric to scale (a scale factor relative to + /// nothing is meaningless). + pub(crate) fn resolve(self, name: &str, hard: Option) -> Result { + match self { + Self::Absolute(value) => Ok(value), + Self::Scale(factor) => { + let base = hard.ok_or_else(|| { + format!( + "[thresholds.soft] {name:?} uses scale-relative syntax but no \ + hard [thresholds] limit exists for {name:?} to scale; give it an \ + absolute soft limit or add a hard limit first" + ) + })?; + Ok(scale_threshold(base, factor)) + } + } + } +} + +/// Significant figures retained when scaling a threshold by a ratio +/// (`--headroom` or a `[thresholds.soft]` `"x"` factor). Trims +/// float-multiplication artifacts (e.g. `7 * 0.95 == 6.6499999999999995`) +/// to a readable `6.65` while preserving full precision for the largest +/// thresholds seen in practice (`halstead.effort`, on the order of +/// `50000`). At 6 figures the rounding error is far below any metric's +/// granularity, so the offender set is identical to the un-rounded +/// product. This matches the `{:.6g}` rounding the now-removed +/// `bca-self-scan-headroom.py` helper used (#373), so soft-gate offender +/// lines render byte-for-byte the same whether the band came from +/// `--headroom` or a per-metric scale factor. +const HEADROOM_SIG_FIGS: i32 = 6; + +/// Whether `ratio` is a valid soft-tier scaling factor: the half-open +/// interval `(0, 1]`. `1.0` is the no-op identity (parity with the hard +/// gate); a factor `> 1` would make the soft tier *looser* than the hard +/// gate, which is never the early-warning intent; `0`, negatives, and +/// `NaN` (which fails both comparisons) are usage errors. Shared by the +/// `--headroom` scalar (CLI and `bca.toml`) and the `[thresholds.soft]` +/// `"x"` form so the accepted range is defined in exactly one +/// place; callers compose their own context-specific error message. +pub(crate) fn is_valid_scale_ratio(ratio: f64) -> bool { + 0.0 < ratio && ratio <= 1.0 +} + +/// Scale a threshold `limit` by `ratio`, rounding to +/// [`HEADROOM_SIG_FIGS`] significant figures. `ratio` is assumed already +/// validated (see [`is_valid_scale_ratio`]) to lie in `(0, 1]`. Shared +/// by the `--headroom` scalar path and the `[thresholds.soft]` +/// scale-relative form so both round identically. +pub(crate) fn scale_threshold(limit: f64, ratio: f64) -> f64 { + let scaled = limit * ratio; + // `log10(0)` is `-inf`; short-circuit the degenerate inputs so the + // magnitude maths below only sees finite, non-zero values. + if scaled == 0.0 || !scaled.is_finite() { + return scaled; + } + // `log10` of a finite, non-zero f64 lies in roughly [-323, 308], so + // its floor always fits an i32 — the truncating cast cannot lose + // information here. + #[allow(clippy::cast_possible_truncation)] + let magnitude = scaled.abs().log10().floor() as i32; + let decimals = (HEADROOM_SIG_FIGS - 1) - magnitude; + let factor = 10f64.powi(decimals); + // For an absurdly tiny limit the sig-fig `factor` overflows to + // infinity, and `scaled * factor / factor` would be NaN. No real + // metric threshold is subnormal, but guard it so the function is + // total: such a value is already far below any rounding granularity, + // so return it unrounded rather than poisoning the threshold set + // with NaN. + if !factor.is_finite() { + return scaled; + } + (scaled * factor).round() / factor +} + +/// Parse one `[thresholds.soft]` value: a number (absolute) or a +/// `"x"` scale string. +#[allow(clippy::cast_precision_loss)] +pub(crate) fn parse_soft_value(name: &str, value: &toml::Value) -> Result { + match value { + toml::Value::Integer(i) => Ok(SoftLimit::Absolute(*i as f64)), + toml::Value::Float(f) => Ok(SoftLimit::Absolute(*f)), + toml::Value::String(s) => parse_scale_str(name, s), + other => Err(format!( + "[thresholds.soft] {name:?}: expected a number or a \"x\" scale \ + string (e.g. \"0.95x\"), got {}", + other.type_str() + )), + } +} + +/// Parse a `"x"` scale string (case-insensitive `x` suffix). The +/// factor must lie in `(0, 1]`, matching `--headroom`: a soft tier looser +/// than the hard tier is never the intent (the soft tier is an +/// early-warning band that fires *before* the hard gate). +fn parse_scale_str(name: &str, s: &str) -> Result { + let trimmed = s.trim(); + let factor_str = trimmed + .strip_suffix('x') + .or_else(|| trimmed.strip_suffix('X')) + .ok_or_else(|| { + format!( + "[thresholds.soft] {name:?}: scale string {s:?} must end in `x` (e.g. \"0.95x\")" + ) + })?; + let factor: f64 = factor_str + .trim() + .parse() + .map_err(|e| format!("[thresholds.soft] {name:?}: invalid scale factor in {s:?}: {e}"))?; + if !is_valid_scale_ratio(factor) { + return Err(format!( + "[thresholds.soft] {name:?}: scale factor must be in (0, 1]; got {factor}" + )); + } + Ok(SoftLimit::Scale(factor)) +} diff --git a/big-code-analysis-cli/src/thresholds.rs b/big-code-analysis-cli/src/thresholds.rs index 7e0e86097..a0b05536c 100644 --- a/big-code-analysis-cli/src/thresholds.rs +++ b/big-code-analysis-cli/src/thresholds.rs @@ -27,6 +27,7 @@ use serde::Deserialize; use crate::baseline::Coverage; use crate::format_util::MetricScalar; use crate::qualified_name::qualified_symbol; +use crate::threshold_soft::{SOFT_SUBTABLE_KEY, SoftLimit, parse_soft_value}; /// The space kind each metric's threshold gates (issue #969) — owned by /// the library catalog so the CLI gate and the Python `to_sarif` binding @@ -291,106 +292,8 @@ pub(crate) struct ThresholdConfig { pub(crate) thresholds: BTreeMap, } -/// Reserved key inside `[thresholds]` that introduces the soft-tier -/// sub-table (`[thresholds.soft]`). Every other key in the table is a -/// hard-limit metric name. No metric is named `soft`, so the reservation -/// never collides with a real threshold. -pub(crate) const SOFT_SUBTABLE_KEY: &str = "soft"; - -/// One soft-tier limit, before resolution against the hard tier. -/// -/// `[thresholds.soft]` values are either a plain number (an absolute soft -/// limit) or a `"x"` string (scale the metric's hard limit by -/// `ratio`). The scale form is resolved lazily because it needs the -/// merged hard limit, which is only known after the manifest and -/// `--config` layers combine — see [`SoftLimit::resolve`]. -#[derive(Debug, Clone, Copy, PartialEq)] -pub(crate) enum SoftLimit { - /// An explicit soft limit, used as-is. - Absolute(f64), - /// A factor in `(0, 1]` applied to the metric's hard limit. - Scale(f64), -} - -impl SoftLimit { - /// Resolve to a concrete limit. `Absolute` ignores `hard`; `Scale` - /// multiplies the metric's hard limit, erroring when no hard limit - /// exists for the metric to scale (a scale factor relative to - /// nothing is meaningless). - pub(crate) fn resolve(self, name: &str, hard: Option) -> Result { - match self { - Self::Absolute(value) => Ok(value), - Self::Scale(factor) => { - let base = hard.ok_or_else(|| { - format!( - "[thresholds.soft] {name:?} uses scale-relative syntax but no \ - hard [thresholds] limit exists for {name:?} to scale; give it an \ - absolute soft limit or add a hard limit first" - ) - })?; - Ok(scale_threshold(base, factor)) - } - } - } -} - -/// Significant figures retained when scaling a threshold by a ratio -/// (`--headroom` or a `[thresholds.soft]` `"x"` factor). Trims -/// float-multiplication artifacts (e.g. `7 * 0.95 == 6.6499999999999995`) -/// to a readable `6.65` while preserving full precision for the largest -/// thresholds seen in practice (`halstead.effort`, on the order of -/// `50000`). At 6 figures the rounding error is far below any metric's -/// granularity, so the offender set is identical to the un-rounded -/// product. This matches the `{:.6g}` rounding the now-removed -/// `bca-self-scan-headroom.py` helper used (#373), so soft-gate offender -/// lines render byte-for-byte the same whether the band came from -/// `--headroom` or a per-metric scale factor. -const HEADROOM_SIG_FIGS: i32 = 6; - -/// Whether `ratio` is a valid soft-tier scaling factor: the half-open -/// interval `(0, 1]`. `1.0` is the no-op identity (parity with the hard -/// gate); a factor `> 1` would make the soft tier *looser* than the hard -/// gate, which is never the early-warning intent; `0`, negatives, and -/// `NaN` (which fails both comparisons) are usage errors. Shared by the -/// `--headroom` scalar (CLI and `bca.toml`) and the `[thresholds.soft]` -/// `"x"` form so the accepted range is defined in exactly one -/// place; callers compose their own context-specific error message. -pub(crate) fn is_valid_scale_ratio(ratio: f64) -> bool { - 0.0 < ratio && ratio <= 1.0 -} - -/// Scale a threshold `limit` by `ratio`, rounding to -/// [`HEADROOM_SIG_FIGS`] significant figures. `ratio` is assumed already -/// validated (see [`is_valid_scale_ratio`]) to lie in `(0, 1]`. Shared -/// by the `--headroom` scalar path and the `[thresholds.soft]` -/// scale-relative form so both round identically. -pub(crate) fn scale_threshold(limit: f64, ratio: f64) -> f64 { - let scaled = limit * ratio; - // `log10(0)` is `-inf`; short-circuit the degenerate inputs so the - // magnitude maths below only sees finite, non-zero values. - if scaled == 0.0 || !scaled.is_finite() { - return scaled; - } - // `log10` of a finite, non-zero f64 lies in roughly [-323, 308], so - // its floor always fits an i32 — the truncating cast cannot lose - // information here. - #[allow(clippy::cast_possible_truncation)] - let magnitude = scaled.abs().log10().floor() as i32; - let decimals = (HEADROOM_SIG_FIGS - 1) - magnitude; - let factor = 10f64.powi(decimals); - // For an absurdly tiny limit the sig-fig `factor` overflows to - // infinity, and `scaled * factor / factor` would be NaN. No real - // metric threshold is subnormal, but guard it so the function is - // total: such a value is already far below any rounding granularity, - // so return it unrounded rather than poisoning the threshold set - // with NaN. - if !factor.is_finite() { - return scaled; - } - (scaled * factor).round() / factor -} - -/// The hard and soft layers extracted from one `[thresholds]` table. +/// The hard, soft, and per-language layers extracted from one +/// `[thresholds]` table. #[derive(Debug, Default)] pub(crate) struct ParsedThresholds { /// Scalar `metric = limit` entries (the hard tier). @@ -398,29 +301,45 @@ pub(crate) struct ParsedThresholds { /// `[thresholds.soft]` overrides, unresolved (scale factors still /// relative to the hard tier). pub(crate) soft: BTreeMap, + /// `[thresholds.lang.]` per-metric overrides, keyed by the + /// canonical language slug + /// ([`LANG::name`](big_code_analysis::LANG::name)). Each inner map + /// holds only the metrics that language overrides; the rest are + /// inherited from [`Self::hard`] at resolution time. See + /// [`crate::threshold_lang`]. + pub(crate) lang: BTreeMap<&'static str, BTreeMap>, } -/// Split a raw `[thresholds]` table into its hard scalar limits and the -/// nested `[thresholds.soft]` overrides. Hard values must be numbers; -/// the `soft` key must be a sub-table whose values are numbers or -/// `"x"` scale strings. Any other shape is a config error — -/// callers `die` on `Err` so a malformed table never silently degrades -/// into a missing limit. +/// Split a raw `[thresholds]` table into its hard scalar limits, the +/// nested `[thresholds.soft]` overrides, and the per-language +/// `[thresholds.lang.]` tables. Hard values must be numbers; the +/// `soft` key must be a sub-table whose values are numbers or +/// `"x"` scale strings; the `lang` key must be a sub-table of +/// per-language sub-tables of numbers. Any other shape is a config +/// error — callers `die` on `Err` so a malformed table never silently +/// degrades into a missing limit. pub(crate) fn split_thresholds_table( raw: &BTreeMap, ) -> Result { let mut out = ParsedThresholds::default(); for (key, value) in raw { - if key == SOFT_SUBTABLE_KEY { - let table = value.as_table().ok_or_else(|| { - "[thresholds.soft] must be a table of `metric = ` entries" - .to_string() - })?; - for (name, sub) in table { - out.soft.insert(name.clone(), parse_soft_value(name, sub)?); + match key.as_str() { + SOFT_SUBTABLE_KEY => { + let table = value.as_table().ok_or_else(|| { + "[thresholds.soft] must be a table of `metric = ` entries" + .to_string() + })?; + for (name, sub) in table { + out.soft.insert(name.clone(), parse_soft_value(name, sub)?); + } + } + crate::threshold_lang::LANG_SUBTABLE_KEY => { + out.lang = crate::threshold_lang::parse_language_tables(value)?; + } + _ => { + out.hard + .insert(key.clone(), threshold_scalar("[thresholds]", key, value)?); } - } else { - out.hard.insert(key.clone(), threshold_scalar(key, value)?); } } Ok(out) @@ -428,60 +347,25 @@ pub(crate) fn split_thresholds_table( /// Parse a hard-tier scalar limit. Accepts TOML integers and floats; /// `i64 -> f64` is exact for the small limits metrics carry in practice. +/// `table` names the enclosing table for the error message, so a +/// per-language override reports `[thresholds.lang.c]` rather than +/// blaming the global table. #[allow(clippy::cast_precision_loss)] -fn threshold_scalar(name: &str, value: &toml::Value) -> Result { +pub(crate) fn threshold_scalar( + table: &str, + name: &str, + value: &toml::Value, +) -> Result { match value { toml::Value::Integer(i) => Ok(*i as f64), toml::Value::Float(f) => Ok(*f), other => Err(format!( - "[thresholds] {name:?}: expected a number, got {}", + "{table} {name:?}: expected a number, got {}", other.type_str() )), } } -/// Parse one `[thresholds.soft]` value: a number (absolute) or a -/// `"x"` scale string. -#[allow(clippy::cast_precision_loss)] -fn parse_soft_value(name: &str, value: &toml::Value) -> Result { - match value { - toml::Value::Integer(i) => Ok(SoftLimit::Absolute(*i as f64)), - toml::Value::Float(f) => Ok(SoftLimit::Absolute(*f)), - toml::Value::String(s) => parse_scale_str(name, s), - other => Err(format!( - "[thresholds.soft] {name:?}: expected a number or a \"x\" scale \ - string (e.g. \"0.95x\"), got {}", - other.type_str() - )), - } -} - -/// Parse a `"x"` scale string (case-insensitive `x` suffix). The -/// factor must lie in `(0, 1]`, matching `--headroom`: a soft tier looser -/// than the hard tier is never the intent (the soft tier is an -/// early-warning band that fires *before* the hard gate). -fn parse_scale_str(name: &str, s: &str) -> Result { - let trimmed = s.trim(); - let factor_str = trimmed - .strip_suffix('x') - .or_else(|| trimmed.strip_suffix('X')) - .ok_or_else(|| { - format!( - "[thresholds.soft] {name:?}: scale string {s:?} must end in `x` (e.g. \"0.95x\")" - ) - })?; - let factor: f64 = factor_str - .trim() - .parse() - .map_err(|e| format!("[thresholds.soft] {name:?}: invalid scale factor in {s:?}: {e}"))?; - if !is_valid_scale_ratio(factor) { - return Err(format!( - "[thresholds.soft] {name:?}: scale factor must be in (0, 1]; got {factor}" - )); - } - Ok(SoftLimit::Scale(factor)) -} - /// One offending `(function, metric)` pair. #[derive(Debug, Clone)] pub(crate) struct Violation { @@ -511,8 +395,23 @@ pub(crate) struct Violation { pub(crate) metric: &'static str, /// Observed metric value. pub(crate) value: f64, - /// Configured limit. + /// Configured limit for the tier the gate ran at. pub(crate) limit: f64, + /// The hard-tier ceiling for this metric *under the table that + /// gated this file's language* — equal to [`Self::limit`] at the + /// hard tier, the un-scaled ceiling at the soft tier, and `None` + /// for a metric that has a `[thresholds.soft]` limit but no hard + /// one (there is no ceiling to breach). + /// + /// Stamped here rather than looked up afterwards because the + /// ceiling is per-language once `[thresholds.lang.]` + /// overrides exist (#1141), and by classification time the + /// offender is all that is left of the file that produced it. + /// Drives the [`CheckOutcome::HardBreach`] escalation in + /// `classify_check_outcome`. + /// + /// [`CheckOutcome::HardBreach`]: crate::CheckOutcome::HardBreach + pub(crate) hard_limit: Option, /// `true` when this metric is lower-is-worse (the `mi.*` /// Maintainability Index family): the value breached by falling /// *below* the limit, and [`Violation::ratio`] inverts to @@ -703,6 +602,11 @@ fn format_regressed_tag(recorded: f64, value: f64) -> String { struct ResolvedThreshold { extractor: &'static MetricExtractor, limit: f64, + /// The hard-tier ceiling for this metric, carried alongside the + /// tier-resolved `limit` so each emitted [`Violation`] can be + /// stamped with the ceiling that applies to *its* language (#1141). + /// `None` when the metric has a soft limit but no hard one. + hard_limit: Option, /// `true` for the lower-is-worse `mi.*` family: a value *below* the /// limit is the violation, and the breach ratio inverts to /// `limit / value`. @@ -745,10 +649,26 @@ pub(crate) struct ThresholdSet { } impl ThresholdSet { - /// Build from a `metric=limit` map (CLI flags merged on top of TOML). - /// Unknown metric names produce an error listing the valid set, rather - /// than being silently ignored. + /// Build a hard-tier set, where every limit doubles as its own + /// ceiling. Test-only shorthand for [`Self::build_tiered`]: the + /// resolver always knows both layers and passes them separately. + #[cfg(test)] pub(crate) fn build(raw: &BTreeMap) -> Result { + Self::build_tiered(raw, raw) + } + + /// Build from the tier-resolved limits plus the un-scaled hard-tier + /// ceilings, so each emitted [`Violation`] carries both (#385). + /// Unknown metric names produce an error listing the valid set, + /// rather than being silently ignored. + /// + /// A metric absent from `hard` — a `[thresholds.soft]` absolute + /// limit with no hard counterpart — gets no ceiling, so breaching it + /// stays a soft-band encroachment rather than escalating to exit 5. + pub(crate) fn build_tiered( + raw: &BTreeMap, + hard: &BTreeMap, + ) -> Result { let mut entries = Vec::with_capacity(raw.len()); for (name, limit) in raw { // Accept the bare `diff --metric` spelling as an alias for the @@ -766,10 +686,42 @@ impl ThresholdSet { ) })?; validate_threshold_value(*limit, name)?; + let lower_is_worse = metric_is_lower_is_worse(extractor.name); + let hard_limit = hard.get(name).copied(); + // A soft limit looser than its own hard ceiling inverts the + // tier: the early-warning gate stays quiet while the hard + // gate fires, and any offender that *does* trip the soft band + // exceeds the ceiling too and escalates straight to exit 5. + // `parse_scale_str` already rejects the equivalent + // `"x"` form (a factor above 1); this closes the + // absolute form, which per-language hard overrides make easy + // to hit by accident (#1141). + // + // Higher-is-worse metrics only. The lower-is-worse `mi.*` + // family is a *floor*, so tightening it means raising it — + // but `resolve_tier`'s blanket ratio multiplies every limit, + // which lowers an `mi.*` floor and therefore already emits a + // soft tier looser than its hard one for any pre-existing + // `[thresholds] mi.original = N` plus `--tier=soft`. That is + // a separate defect in the scaling direction (#1166); + // rejecting it here would fail those runs rather than fix + // them. Drop the guard once #1166 lands. + if !lower_is_worse + && let Some(hard) = hard_limit + && breaches_limit(*limit, hard, lower_is_worse) + { + return Err(format!( + "[thresholds.soft] {name:?}: soft limit {} is looser than the hard \ + limit {}; the soft tier must fire before the hard gate, not after it", + MetricScalar(*limit), + MetricScalar(hard), + )); + } entries.push(ResolvedThreshold { extractor, limit: *limit, - lower_is_worse: metric_is_lower_is_worse(extractor.name), + hard_limit, + lower_is_worse, scope: metric_scope(extractor.name), }); } @@ -885,6 +837,7 @@ impl ThresholdSet { let ResolvedThreshold { extractor, limit, + hard_limit, lower_is_worse, scope, } = entry; @@ -932,6 +885,7 @@ impl ThresholdSet { metric: extractor.name, value, limit: *limit, + hard_limit: *hard_limit, lower_is_worse: *lower_is_worse, body_hash: None, suppressed, diff --git a/big-code-analysis-cli/src/thresholds_tests.rs b/big-code-analysis-cli/src/thresholds_tests.rs index 142cb4b3a..88a478bee 100644 --- a/big-code-analysis-cli/src/thresholds_tests.rs +++ b/big-code-analysis-cli/src/thresholds_tests.rs @@ -5,6 +5,7 @@ // self-scan walker skips this file the same way it skips `./tests/`. use super::*; +use crate::threshold_soft::is_valid_scale_ratio; /// Locks the threshold-engine extractor vocabulary against /// `threshold_metric_for_name` so the two stay in sync. @@ -283,6 +284,7 @@ fn violation_display_is_stable() { metric: "cyclomatic", value: 17.0, limit: 15.0, + hard_limit: Some(15.0), lower_is_worse: false, body_hash: None, suppressed: false, @@ -303,6 +305,7 @@ fn violation_display_keeps_fractional_precision() { metric: "halstead.volume", value: 12.5, limit: 10.0, + hard_limit: Some(10.0), lower_is_worse: false, body_hash: None, suppressed: false, @@ -338,6 +341,7 @@ fn violation_path_preserves_non_utf8_bytes() { metric: "cyclomatic", value: 5.0, limit: 1.0, + hard_limit: Some(1.0), lower_is_worse: false, body_hash: None, suppressed: false, @@ -525,6 +529,7 @@ fn tokens_threshold_never_suppressed() { entries: vec![ResolvedThreshold { extractor, limit: -0.5, + hard_limit: Some(-0.5), lower_is_worse: false, scope: metric_scope(extractor.name), }], @@ -618,6 +623,7 @@ fn sample_violation() -> Violation { metric: "cyclomatic", value: 30.0, limit: 10.0, + hard_limit: Some(10.0), lower_is_worse: false, body_hash: None, suppressed: false, @@ -934,6 +940,7 @@ fn mi_ratio_inverts_so_lower_value_ranks_worse() { metric: "mi.original", value: 5.0, limit: 50.0, + hard_limit: Some(50.0), lower_is_worse: true, body_hash: None, suppressed: false, diff --git a/big-code-analysis-cli/tests/check_exit_codes.rs b/big-code-analysis-cli/tests/check_exit_codes.rs index e44ca4028..866d0a58b 100644 --- a/big-code-analysis-cli/tests/check_exit_codes.rs +++ b/big-code-analysis-cli/tests/check_exit_codes.rs @@ -296,6 +296,73 @@ fn strict_soft_encroachment_exits_two_not_five() { .code(2); } +/// A `[thresholds.soft]` absolute limit with no `[thresholds]` +/// counterpart gives the metric a soft band and no hard ceiling, so no +/// value can escalate it to a hard breach however far it overshoots. +/// +/// Pinned because the ceiling moved from a lookaside map onto the +/// offender record in #1141: "the metric is absent from the hard table" +/// became "the offender carries no ceiling", and fabricating one from +/// the soft limit instead would silently turn every such offender into +/// exit 5. That substitution failed no test before this one. +#[test] +fn strict_soft_only_metric_has_no_hard_ceiling_to_breach() { + let dir = TempDir::new().unwrap(); + let src = write_branchy(&dir, 12); + let config = dir.path().join("thresholds.toml"); + // `cognitive` is gated only at the soft tier; `cyclomatic` supplies + // the hard table so the run has a hard tier at all. + fs::write( + &config, + "[thresholds]\ncyclomatic = 100\n[thresholds.soft]\ncognitive = 2\n", + ) + .unwrap(); + + cli(dir.path()) + .args([ + "check", + "--paths", + &src, + "--config", + config.to_str().unwrap(), + "--tier=soft", + "--exit-codes=tiered", + ]) + .assert() + .code(2); +} + +/// A limit written with the bare `diff --metric` alias (`sloc` for +/// `loc.sloc`) still escalates to a hard breach under the soft tier. +/// +/// Until #1141 the escalation compared the offender's *canonical* metric +/// name against a map keyed by the spelling the user wrote, so +/// `[thresholds] sloc = N` never matched and no value, however large, +/// could reach exit 5. Carrying the ceiling on the offender resolves +/// both sides from the same key and fixes it. +#[test] +fn strict_alias_spelled_limit_still_escalates_to_hard_breach() { + let dir = TempDir::new().unwrap(); + let src = write_branchy(&dir, 3); + let config = dir.path().join("thresholds.toml"); + // `sloc = 2` against a file well over it: the soft band is 1, the + // hard ceiling 2, and the file breaches both. + fs::write(&config, "[thresholds]\nsloc = 2\n").unwrap(); + + cli(dir.path()) + .args([ + "check", + "--paths", + &src, + "--config", + config.to_str().unwrap(), + "--tier=soft=0.5", + "--exit-codes=tiered", + ]) + .assert() + .code(5); +} + // -- Workspace-wide convention (#561) ------------------------------------- // // The 0/1 split is documented as a cross-subcommand contract (top-level diff --git a/big-code-analysis-cli/tests/check_lang_thresholds.rs b/big-code-analysis-cli/tests/check_lang_thresholds.rs new file mode 100644 index 000000000..1b80614b7 --- /dev/null +++ b/big-code-analysis-cli/tests/check_lang_thresholds.rs @@ -0,0 +1,668 @@ +//! Integration tests for `[thresholds.lang.]` per-language +//! threshold overrides (issue #1141). +//! +//! The fixtures span the three ends of the measured per-language spread +//! the feature exists for — C (loosest), C# (tightest), Elixir (whose +//! `defmodule` is a Container holding many functions, not a class) — +//! plus Rust as a language nobody overrides. Every metric value +//! asserted below was measured with `bca check --threshold =0` +//! against these exact fixtures, not estimated. + +use std::fs; +use std::path::Path; + +use assert_cmd::Command; +use predicates::prelude::*; +use tempfile::TempDir; + +mod common; + +fn cli(dir: &Path) -> Command { + common::cli_in(dir) +} + +/// The offender lines from a `bca check` stderr stream, isolated from +/// the summary and remediation blocks. +/// +/// Filtering matters here: the remediation footer echoes the resolved +/// `--paths` list, so a bare `stderr.contains("branchy.c")` reads as an +/// offender even when C was gated clean — the precise false pass these +/// tests exist to rule out. +fn offenders(stderr: &str) -> Vec<&str> { + stderr + .lines() + .filter(|line| line.contains(" (limit ")) + .collect() +} + +/// C: `cognitive = 6`, `cyclomatic = 7`. +const BRANCHY_C: &str = "int classify(int n) { + if (n < 0) { return -1; } + if (n == 0) { return 0; } + if (n < 10) { return 1; } + if (n < 100) { return 2; } + if (n < 1000) { return 3; } + if (n < 10000) { return 4; } + return 5; +} +"; + +/// C: `cognitive = 12` — twice `BRANCHY_C`, so the two straddle a limit +/// set between them. +const WIDE_C: &str = "int wide(int n) { + if (n == 1) { return 1; } + if (n == 2) { return 2; } + if (n == 3) { return 3; } + if (n == 4) { return 4; } + if (n == 5) { return 5; } + if (n == 6) { return 6; } + if (n == 7) { return 7; } + if (n == 8) { return 8; } + if (n == 9) { return 9; } + if (n == 10) { return 10; } + if (n == 11) { return 11; } + if (n == 12) { return 12; } + return 0; +} +"; + +/// Rust: `cognitive = 6`, `cyclomatic = 7` — deliberately the same +/// scores as `BRANCHY_C`, so a difference in outcome can only come from +/// the language, never from the code. +const BRANCHY_RUST: &str = "pub fn classify(n: i32) -> i32 { + if n < 0 { return -1; } + if n == 0 { return 0; } + if n < 10 { return 1; } + if n < 100 { return 2; } + if n < 1000 { return 3; } + if n < 10000 { return 4; } + 5 +} +"; + +/// C#: `cognitive = 6`, `cyclomatic = 7` on `Sample::Classify`. +const BRANCHY_CSHARP: &str = "public class Sample +{ + public int Classify(int n) + { + if (n < 0) { return -1; } + if (n == 0) { return 0; } + if (n < 10) { return 1; } + if (n < 100) { return 2; } + if (n < 1000) { return 3; } + if (n < 10000) { return 4; } + return 5; + } +} +"; + +/// Elixir: the `Sample` module is a Container space with `nom = 4`. +const MODULE_ELIXIR: &str = "defmodule Sample do + def one(a), do: a + 1 + def two(a), do: a + 2 + def three(a), do: a + 3 + def four(a), do: a + 4 +end +"; + +/// Write the polyglot fixture tree plus a `bca.toml` carrying +/// `thresholds_toml`, and return the directory. `cli` anchors the +/// process cwd here, so the manifest is auto-discovered exactly as it +/// would be at a repo root. +fn polyglot_tree(thresholds_toml: &str) -> TempDir { + let dir = TempDir::new().expect("tempdir"); + for (name, body) in [ + ("branchy.c", BRANCHY_C), + ("wide.c", WIDE_C), + ("branchy.rs", BRANCHY_RUST), + ("Sample.cs", BRANCHY_CSHARP), + ("sample.ex", MODULE_ELIXIR), + ] { + fs::write(dir.path().join(name), body).expect("write fixture"); + } + fs::write(dir.path().join("bca.toml"), thresholds_toml).expect("write manifest"); + dir +} + +/// An override applies to its own language and to no other. Both +/// fixtures score `cyclomatic = 7`, so only the language differs. +#[test] +fn override_applies_to_its_language_only() { + let dir = polyglot_tree( + "paths = [\"branchy.c\", \"branchy.rs\"]\n\ + [thresholds]\n\ + cyclomatic = 5\n\ + [thresholds.lang.c]\n\ + cyclomatic = 10\n", + ); + + let assert = cli(dir.path()).arg("check").assert().code(2); + let stderr = String::from_utf8(assert.get_output().stderr.clone()).expect("utf8 stderr"); + let offenders = offenders(&stderr); + assert_eq!(offenders.len(), 1, "exactly one offender: {offenders:?}"); + assert!( + offenders[0].contains("branchy.rs") && offenders[0].ends_with("cyclomatic = 7 (limit 5)"), + "Rust keeps the global limit of 5: {offenders:?}" + ); +} + +/// A per-language table overrides *per metric* and inherits the rest. +/// C raises `cyclomatic` only, so its `cognitive` still gates at the +/// project limit — and the reported limit proves which table won. +#[test] +fn unoverridden_metric_inherits_the_global_limit() { + let dir = polyglot_tree( + "paths = [\"branchy.c\"]\n\ + [thresholds]\n\ + cyclomatic = 5\n\ + cognitive = 4\n\ + [thresholds.lang.c]\n\ + cyclomatic = 10\n", + ); + + let assert = cli(dir.path()).arg("check").assert().code(2); + let stderr = String::from_utf8(assert.get_output().stderr.clone()).expect("utf8 stderr"); + let offenders = offenders(&stderr); + assert_eq!(offenders.len(), 1, "exactly one offender: {offenders:?}"); + assert!( + offenders[0].ends_with("classify: cognitive = 6 (limit 4)"), + "cognitive inherits the global 4: {offenders:?}" + ); +} + +/// The structural cases from #1140 that an override *corrects* rather +/// than tunes: an Elixir `defmodule` is a Container holding many +/// functions, so `nom` needs a module-sized limit, while C# wants a +/// tighter one than the project default. +#[test] +fn corrective_overrides_at_both_ends_of_the_spread() { + let dir = polyglot_tree( + "paths = [\"sample.ex\", \"Sample.cs\"]\n\ + [thresholds]\n\ + nom = 3\n\ + cognitive = 20\n\ + [thresholds.lang.elixir]\n\ + nom = 100\n\ + [thresholds.lang.csharp]\n\ + cognitive = 4\n", + ); + + let assert = cli(dir.path()).arg("check").assert().code(2); + let stderr = String::from_utf8(assert.get_output().stderr.clone()).expect("utf8 stderr"); + let offenders = offenders(&stderr); + assert_eq!(offenders.len(), 1, "exactly one offender: {offenders:?}"); + assert!( + offenders[0].ends_with("Sample::Classify: cognitive = 6 (limit 4)"), + "C# gates at its tightened limit; the Elixir module's nom = 4 sits \ + under its raised limit of 100: {offenders:?}" + ); +} + +/// A metric no global table mentions still gates the language that +/// names it. +/// +/// The check walk computes only the metric families its thresholds read +/// (#1113). Derive that selection from the global set alone and `nom` is +/// never computed here, so the Elixir module's `nom = 4` reads as the +/// zero default and the gate passes — silently, with no offender and no +/// warning. +/// +/// The global limit is deliberately `cyclomatic`, whose dependency set +/// is empty. An earlier draft used `cognitive`, which pulls in `Nom` via +/// `Metric::dependencies` — so `nom` was computed regardless and the +/// test passed against a build with no union at all. +#[test] +fn a_metric_only_a_language_table_gates_is_still_computed() { + let dir = polyglot_tree( + "paths = [\"sample.ex\"]\n\ + [thresholds]\n\ + cyclomatic = 20\n\ + [thresholds.lang.elixir]\n\ + nom = 3\n", + ); + + let assert = cli(dir.path()).arg("check").assert().code(2); + let stderr = String::from_utf8(assert.get_output().stderr.clone()).expect("utf8 stderr"); + let offenders = offenders(&stderr); + assert_eq!(offenders.len(), 1, "exactly one offender: {offenders:?}"); + assert!( + offenders[0].ends_with("Sample: nom = 4 (limit 3)"), + "nom must be computed for Elixir even though no global limit names it: {offenders:?}" + ); +} + +/// An unknown slug is a tool error (exit 1) with a did-you-mean hint — +/// never a silent no-op that leaves the author believing a gate moved. +#[test] +fn unknown_slug_is_a_hard_error() { + let dir = polyglot_tree( + "paths = [\"branchy.rs\"]\n\ + [thresholds]\n\ + cyclomatic = 5\n\ + [thresholds.lang.rustlang]\n\ + cyclomatic = 10\n", + ); + + cli(dir.path()) + .arg("check") + .assert() + .code(1) + .stderr(predicate::str::contains( + "unknown language \"rustlang\" in [thresholds.lang]", + )) + .stderr(predicate::str::contains("did you mean `rust`?")); +} + +/// An unknown *metric* inside a language table names that table, so the +/// author does not go hunting through the global `[thresholds]`. +#[test] +fn unknown_metric_in_a_language_table_names_the_table() { + let dir = polyglot_tree( + "paths = [\"branchy.rs\"]\n\ + [thresholds]\n\ + cyclomatic = 5\n\ + [thresholds.lang.c]\n\ + cyclomatick = 10\n", + ); + + cli(dir.path()) + .arg("check") + .assert() + .code(1) + .stderr(predicate::str::contains( + "[thresholds.lang.c]: unknown threshold metric \"cyclomatick\"", + )) + .stderr(predicate::str::contains("did you mean `cyclomatic`?")); +} + +/// A language nobody overrode is gated by the global table through the +/// same fallback that serves an unrecognised file language: one code +/// path, no per-language special case. (A file whose *extension* maps to +/// no grammar never reaches the gate at all — the walk skips it before +/// dispatch — so the fallback is what covers every language the tool +/// does analyse.) +#[test] +fn language_without_an_override_uses_the_global_table() { + let dir = polyglot_tree( + "paths = [\"Sample.cs\", \"unknown.zzz\"]\n\ + [thresholds]\n\ + cyclomatic = 5\n\ + [thresholds.lang.c]\n\ + cyclomatic = 100\n", + ); + fs::write(dir.path().join("unknown.zzz"), "nothing parses this\n").expect("write fixture"); + + let assert = cli(dir.path()).arg("check").assert().code(2); + let stderr = String::from_utf8(assert.get_output().stderr.clone()).expect("utf8 stderr"); + let offenders = offenders(&stderr); + assert_eq!(offenders.len(), 1, "exactly one offender: {offenders:?}"); + assert!( + offenders[0].ends_with("Sample::Classify: cyclomatic = 7 (limit 5)"), + "C# falls through to the global limit: {offenders:?}" + ); + assert!( + stderr.contains("skipping explicitly-named file with unrecognized language"), + "an unrecognised file language never reaches the gate at all: {stderr}" + ); +} + +/// `--threshold` is applied last and absolutely, so it outranks a +/// per-language table too — otherwise a command-line limit would be +/// silently inert for exactly the languages a project tuned. +#[test] +fn cli_threshold_outranks_a_language_override() { + let dir = polyglot_tree( + "paths = [\"branchy.c\"]\n\ + [thresholds]\n\ + cyclomatic = 5\n\ + [thresholds.lang.c]\n\ + cyclomatic = 100\n", + ); + + cli(dir.path()) + .args(["check", "--threshold", "cyclomatic=6"]) + .assert() + .code(2) + .stderr(predicate::str::contains( + "classify: cyclomatic = 7 (limit 6)", + )); +} + +/// `--print-effective-config` emits one fully resolved table per +/// overridden language — inherited limits included, not a diff — and the +/// result is still valid TOML that `--config` can read back. +#[test] +fn print_effective_config_renders_resolved_per_language_tables() { + let dir = polyglot_tree( + "paths = [\"branchy.c\"]\n\ + [thresholds]\n\ + cyclomatic = 5\n\ + cognitive = 4\n\ + [thresholds.lang.c]\n\ + cyclomatic = 10\n", + ); + + let assert = cli(dir.path()) + .args(["check", "--print-effective-config", "toml"]) + .assert() + .success(); + let stdout = String::from_utf8(assert.get_output().stdout.clone()).expect("utf8 stdout"); + + let parsed: toml::Table = toml::from_str(&stdout).expect("effective config is valid TOML"); + let thresholds = parsed["thresholds"] + .as_table() + .expect("[thresholds] is a table"); + assert_eq!(thresholds["cyclomatic"].as_float(), Some(5.0)); + assert_eq!(thresholds["cognitive"].as_float(), Some(4.0)); + + let c = thresholds["lang"]["c"] + .as_table() + .expect("[thresholds.lang.c] is a table"); + assert_eq!(c["cyclomatic"].as_float(), Some(10.0), "the override"); + assert_eq!( + c["cognitive"].as_float(), + Some(4.0), + "inherited limits are printed too, not left for the reader to infer" + ); + assert_eq!(c.len(), 2, "exactly the resolved set: {c:?}"); + + // The documented "pipe it back through `--config`" contract, end to + // end. `--config` is a second entry point into + // `split_thresholds_table`, distinct from manifest discovery, so this + // is the only test that would notice the `lang` layer being honoured + // on one path and dropped on the other. + let echoed = dir.path().join("effective.toml"); + fs::write(&echoed, &stdout).expect("write effective config"); + let assert = cli(dir.path()) + .args([ + "check", + "--no-config", + "--paths", + "branchy.c", + "--config", + echoed.to_str().expect("utf8 path"), + "--print-effective-config", + "toml", + ]) + .assert() + .success(); + let reparsed = String::from_utf8(assert.get_output().stdout.clone()).expect("utf8 stdout"); + let reparsed: toml::Table = toml::from_str(&reparsed).expect("round-tripped config is TOML"); + assert_eq!( + reparsed["thresholds"], parsed["thresholds"], + "the resolved thresholds must survive a --config round trip" + ); +} + +/// The soft tier derives from each language's *resolved* hard limit. +/// +/// This is the case a globally-derived soft tier gets wrong, and gets +/// wrong silently. With `[thresholds] cognitive = 4`, +/// `[thresholds.lang.c] cognitive = 10`, and `--tier=soft=0.5`, C's soft +/// band is `5`. A C function at cognitive 6 breaches that band while +/// sitting well under C's own hard limit of 10 — an encroachment, exit +/// 2. Compare against the *global* ceiling of 4 instead and 6 reads as a +/// hard breach, exit 5, for every C function between 4 and 10. +#[test] +fn soft_tier_derives_from_the_language_hard_limit() { + let dir = polyglot_tree( + "paths = [\"branchy.c\"]\n\ + [thresholds]\n\ + cognitive = 4\n\ + [thresholds.lang.c]\n\ + cognitive = 10\n", + ); + + cli(dir.path()) + .args(["check", "--tier=soft=0.5", "--exit-codes=tiered"]) + .assert() + .code(2) + .stderr(predicate::str::contains( + "classify: cognitive = 6 (limit 5)", + )); +} + +/// The other half of the same contract: a value past the language's own +/// hard limit *is* a hard breach (exit 5). Without this, the test above +/// would pass equally against a build that never escalates at all. +#[test] +fn soft_tier_still_escalates_past_the_language_hard_limit() { + let dir = polyglot_tree( + "paths = [\"wide.c\"]\n\ + [thresholds]\n\ + cognitive = 4\n\ + [thresholds.lang.c]\n\ + cognitive = 10\n", + ); + + cli(dir.path()) + .args(["check", "--tier=soft=0.5", "--exit-codes=tiered"]) + .assert() + .code(5) + .stderr(predicate::str::contains("wide: cognitive = 12 (limit 5)")); +} + +/// At the soft tier each table is resolved against its *own* hard +/// limits: an inherited limit stays the language's, and a global +/// `[thresholds.soft]` override applies on top of it. +/// +/// Note what this test does *not* try to prove. A general +/// `soft <= hard` loop over these tables cannot fail: a scale factor is +/// bounded to `(0, 1]` at parse time, and the one shape that could +/// invert the tiers — an absolute soft value above a language's +/// tightened hard limit — is rejected at resolution, so it never reaches +/// a printed config to assert on. That rejection is pinned by +/// `soft_limit_looser_than_a_language_hard_limit_is_rejected`; this test +/// pins the numbers each table actually resolves to. +#[test] +fn soft_tier_resolves_each_table_against_its_own_hard_limits() { + let manifest = "paths = [\"branchy.c\"]\n\ + [thresholds]\n\ + cognitive = 4\n\ + cyclomatic = 8\n\ + [thresholds.soft]\n\ + cyclomatic = 6\n\ + [thresholds.lang.c]\n\ + cognitive = 10\n\ + [thresholds.lang.csharp]\n\ + cognitive = 2\n"; + + let read = |args: &[&str]| -> toml::Table { + let dir = polyglot_tree(manifest); + let assert = cli(dir.path()).args(args).assert().success(); + let stdout = String::from_utf8(assert.get_output().stdout.clone()).expect("utf8 stdout"); + toml::from_str(&stdout).expect("effective config is valid TOML") + }; + + let hard = read(&["check", "--print-effective-config", "toml"]); + let soft = read(&["check", "--print-effective-config", "toml", "--tier=soft"]); + + // `cognitive` has no soft override, so it inherits each table's own + // hard limit — C keeps 10, not the project's 4. + assert_eq!( + soft["thresholds"]["lang"]["c"]["cognitive"].as_float(), + Some(10.0) + ); + assert_eq!( + soft["thresholds"]["lang"]["csharp"]["cognitive"].as_float(), + Some(2.0) + ); + // The absolute soft override applies to every table, and 6 is below + // the hard 8 each of them inherits. + assert_eq!(soft["thresholds"]["cyclomatic"].as_float(), Some(6.0)); + assert_eq!( + soft["thresholds"]["lang"]["c"]["cyclomatic"].as_float(), + Some(6.0) + ); + + // The hard tier is unchanged by any of this: each table still + // carries both metrics at their un-scaled limits, so the soft + // numbers above are a tier difference and not a lost override. + let hard_c = hard["thresholds"]["lang"]["c"] + .as_table() + .expect("[thresholds.lang.c] is a table"); + assert_eq!(hard_c["cognitive"].as_float(), Some(10.0)); + assert_eq!(hard_c["cyclomatic"].as_float(), Some(8.0)); +} + +/// A soft limit looser than the hard limit it warns about is rejected, +/// naming the table that produced the clash. +/// +/// Per-language overrides make this easy to hit by accident: an absolute +/// `[thresholds.soft]` value is written once against the project limit, +/// then a language *tightens* its hard limit below it. The soft gate +/// would then stay silent while the hard gate fires, and any offender +/// that did trip the soft band would exceed the ceiling too and escalate +/// straight to exit 5. +#[test] +fn soft_limit_looser_than_a_language_hard_limit_is_rejected() { + let dir = polyglot_tree( + "paths = [\"Sample.cs\"]\n\ + [thresholds]\n\ + cognitive = 15\n\ + [thresholds.soft]\n\ + cognitive = 12\n\ + [thresholds.lang.csharp]\n\ + cognitive = 4\n", + ); + + cli(dir.path()) + .args(["check", "--tier=soft"]) + .assert() + .code(1) + .stderr(predicate::str::contains("[thresholds.lang.csharp]")) + .stderr(predicate::str::contains( + "soft limit 12 is looser than the hard limit 4", + )); +} + +/// A scale-relative soft limit resolves against whichever table supplies +/// the hard limit, even when that is only a language table. +/// +/// `[thresholds.soft] nom = "0.9x"` has nothing to scale in the global +/// table here. Resolving each table in isolation would fail the whole run +/// with "no hard `[thresholds]` limit exists for `nom`" against a +/// manifest that plainly defines one. +#[test] +fn scale_relative_soft_resolves_against_a_language_only_hard_limit() { + let dir = polyglot_tree( + "paths = [\"sample.ex\"]\n\ + [thresholds]\n\ + cyclomatic = 20\n\ + [thresholds.soft]\n\ + nom = \"0.5x\"\n\ + [thresholds.lang.elixir]\n\ + nom = 6\n", + ); + + let assert = cli(dir.path()) + .args(["check", "--print-effective-config", "toml", "--tier=soft"]) + .assert() + .success(); + let stdout = String::from_utf8(assert.get_output().stdout.clone()).expect("utf8 stdout"); + let parsed: toml::Table = toml::from_str(&stdout).expect("effective config is valid TOML"); + + let thresholds = parsed["thresholds"] + .as_table() + .expect("[thresholds] is a table"); + assert!( + !thresholds.contains_key("nom"), + "the global table has no nom limit for the scale to apply to: {thresholds:?}" + ); + assert_eq!( + thresholds["lang"]["elixir"]["nom"].as_float(), + Some(3.0), + "Elixir's nom scales from its own hard 6" + ); +} + +/// A scale-relative soft limit that *no* table can supply a base for is +/// still the long-standing hard error — the language-aware lookup must +/// not turn a genuinely orphaned entry into a silent drop. +#[test] +fn scale_relative_soft_with_no_hard_limit_anywhere_still_errors() { + let dir = polyglot_tree( + "paths = [\"sample.ex\"]\n\ + [thresholds]\n\ + cyclomatic = 20\n\ + [thresholds.soft]\n\ + nom = \"0.5x\"\n\ + [thresholds.lang.elixir]\n\ + cognitive = 6\n", + ); + + cli(dir.path()) + .args(["check", "--tier=soft"]) + .assert() + .code(1) + .stderr(predicate::str::contains( + "uses scale-relative syntax but no hard [thresholds] limit exists", + )); +} + +/// `--config` merges into a per-language table per metric, leaving that +/// language's other overrides alone — the same rule the global table +/// follows, one level deeper. +#[test] +fn config_merges_into_a_language_table_per_metric() { + let dir = polyglot_tree( + "paths = [\"branchy.c\"]\n\ + [thresholds]\n\ + cognitive = 4\n\ + [thresholds.lang.c]\n\ + cognitive = 30\n\ + cyclomatic = 40\n", + ); + let overlay = dir.path().join("tighten.toml"); + fs::write(&overlay, "[thresholds.lang.c]\ncognitive = 5\n").expect("write overlay"); + + let assert = cli(dir.path()) + .args([ + "check", + "--print-effective-config", + "toml", + "--config", + overlay.to_str().expect("utf8 path"), + ]) + .assert() + .success(); + let stdout = String::from_utf8(assert.get_output().stdout.clone()).expect("utf8 stdout"); + let parsed: toml::Table = toml::from_str(&stdout).expect("effective config is valid TOML"); + + let c = parsed["thresholds"]["lang"]["c"] + .as_table() + .expect("[thresholds.lang.c] is a table"); + assert_eq!(c["cognitive"].as_float(), Some(5.0), "--config wins"); + assert_eq!( + c["cyclomatic"].as_float(), + Some(40.0), + "a metric --config did not mention keeps the manifest override" + ); +} + +/// Gating only a language, with no global `[thresholds]`, is legal and +/// says so out loud. +/// +/// It is the one shape where a `bca check` run can exit 0 having gated +/// nothing at all in most of the tree, and before per-language tables an +/// empty gate always died. The note is the only signal that the rest of +/// the tree went unchecked. +#[test] +fn a_language_only_manifest_warns_that_nothing_else_is_gated() { + let dir = polyglot_tree( + "paths = [\"branchy.c\", \"branchy.rs\"]\n\ + [thresholds.lang.c]\n\ + cyclomatic = 100\n", + ); + + let assert = cli(dir.path()).arg("check").assert().success(); + let stderr = String::from_utf8(assert.get_output().stderr.clone()).expect("utf8 stderr"); + assert!( + stderr.contains("no global [thresholds] table: only c is gated"), + "the run must say the rest of the tree is ungated: {stderr}" + ); + assert!( + offenders(&stderr).is_empty(), + "nothing breaches a limit of 100: {stderr}" + ); +} From 11daa14f7bb7d3168abf76d6b792a06c52a867fc Mon Sep 17 00:00:00 2001 From: Elijah Zupancic Date: Sat, 1 Aug 2026 08:51:10 -0700 Subject: [PATCH 28/36] test(perf): merge 68 integration test binaries into 12 drivers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each `tests/*.rs` file is its own crate root, and each links the tree-sitter runtime plus every grammar. Thirty-one at the workspace root and thirty-seven under the CLI made linking — not compilation — the tail of every incremental `cargo test`, and ten of them held a single `#[test]` while linking a ~280 MB binary to run it. Group them into six themed directory targets per crate (`tests// main.rs` plus the former files as `mod`s): api, corpus, grammars, output_formats, parity, vcs at the root; check, cli_ux, diff, discovery, output, vcs under the CLI. Also declare `[profile.dev] debug = "line-tables-only"` — nothing in this repo's workflow reads full DWARF, and panic backtraces keep file:line — and document mold / lld in CONTRIBUTING.md as an opt-in local `.cargo/config.toml`, not a committed requirement. Test bodies are unchanged. The per-module edits are mechanical: `mod common;` becomes `use crate::common;` (51 files), a crate-level `#![cfg]` moves onto the `mod` declaration so the driver's `//!` doc stays ungated for the no-default-features and minimal-langs CI legs (9 files), `tests/vcs.rs` becomes `vcs/vcs_rank.rs` and `parser_reuse.rs` sheds its same-named inner module, both to avoid module inception, and the five `insta` snapshots move to `tests/output_formats/snapshots/` under the `output_formats__` prefix insta derives from `module_path!()`. `cargo nextest list --message-format json` before and after lists 4955 tests both times; canonicalising the merge-introduced module prefix away leaves exactly 34 differences, all 1:1 renames from those last two points. Measured here: test binaries 13.03 GB -> 1.74 GB, target/debug after a clean build 18.8 GB -> 4.9 GB, cold `nextest list` 982 -> 762 CPU-s, and a relink after touching `src/lib.rs` 88.5/91.5 -> 27.4/29.8 CPU-s. Fixes #1124 --- CONTRIBUTING.md | 27 ++ Cargo.toml | 17 + .../src/library/ast-traversal.md | 4 +- .../src/deprecations_tests.rs | 6 +- big-code-analysis-cli/src/lib_tests.rs | 4 +- big-code-analysis-cli/src/manifest_tests.rs | 4 +- .../src/threshold_suggestion.rs | 2 +- big-code-analysis-cli/src/vcs_command.rs | 2 +- big-code-analysis-cli/src/walk.rs | 2 +- .../tests/{ => check}/action_enforcement.rs | 2 +- .../tests/{ => check}/check_baseline.rs | 2 +- .../tests/{ => check}/check_exclude.rs | 2 +- .../tests/{ => check}/check_exit_codes.rs | 2 +- .../check_report_suppressed_scope.rs | 2 +- .../tests/{ => check}/check_suppression.rs | 2 +- .../tests/{ => check}/check_thresholds.rs | 2 +- .../tests/{ => check}/exemptions.rs | 2 +- big-code-analysis-cli/tests/check/main.rs | 23 ++ .../tests/{ => cli_ux}/cli_smoke.rs | 2 +- .../tests/{ => cli_ux}/deprecated_aliases.rs | 2 +- .../tests/{ => cli_ux}/flag_scoping.rs | 2 +- .../tests/{ => cli_ux}/help_text.rs | 2 +- .../tests/{ => cli_ux}/init.rs | 2 +- .../tests/{ => cli_ux}/list_metrics.rs | 2 +- big-code-analysis-cli/tests/cli_ux/main.rs | 17 + .../tests/{ => cli_ux}/manifest.rs | 2 +- .../tests/{ => diff}/diff_baseline.rs | 2 +- .../tests/{ => diff}/diff_since.rs | 2 +- big-code-analysis-cli/tests/diff/main.rs | 10 + .../tests/{ => discovery}/exclude_from.rs | 2 +- .../{ => discovery}/exclude_path_form.rs | 2 +- .../{ => discovery}/explicit_path_excludes.rs | 2 +- .../{ => discovery}/include_exclude_arity.rs | 2 +- .../tests/{ => discovery}/invalid_glob.rs | 2 +- big-code-analysis-cli/tests/discovery/main.rs | 30 ++ .../tests/{ => discovery}/paths_discovery.rs | 2 +- .../tests/{ => discovery}/read_failures.rs | 2 +- .../tests/{ => discovery}/skip_generated.rs | 2 +- .../walk_channel_completeness.rs | 2 +- .../tests/{ => discovery}/warning_flag.rs | 2 +- .../tests/{ => output}/color_output.rs | 2 +- .../tests/{ => output}/dump_headers.rs | 2 +- .../tests/{ => output}/format_smoke.rs | 2 +- .../tests/{ => output}/html_report.rs | 2 +- big-code-analysis-cli/tests/output/main.rs | 17 + .../tests/{ => output}/markdown_format.rs | 2 +- .../tests/{ => output}/metric_selection.rs | 2 +- .../tests/{ => output}/output_unification.rs | 2 +- big-code-analysis-cli/tests/vcs/main.rs | 14 + .../tests/{ => vcs}/vcs_jit.rs | 2 +- .../tests/{vcs.rs => vcs/vcs_rank.rs} | 2 +- .../tests/{ => vcs}/vcs_trend.rs | 2 +- src/metrics/cyclomatic.rs | 2 +- src/node/parser_cache.rs | 6 +- src/vcs/bus_factor_tests.rs | 2 +- src/vcs/git/blame_tests.rs | 2 +- src/vcs/git/identity_tests.rs | 2 +- src/vcs/jit_tests.rs | 2 +- tests/README.md | 4 +- tests/{ => api}/ast_seam_test.rs | 0 .../{ => api}/book_ast_traversal_examples.rs | 1 - tests/{ => api}/book_library_examples.rs | 2 - tests/{ => api}/derive_eq_hash_ord.rs | 0 tests/api/main.rs | 29 ++ tests/api/parser_reuse.rs | 313 +++++++++++++++++ tests/{ => api}/suppression_test.rs | 0 tests/common/fixtures.rs | 2 +- tests/common/validators.rs | 6 +- tests/{ => corpus}/csharp_test.rs | 2 +- tests/{ => corpus}/deepspeech_test.rs | 2 +- tests/{ => corpus}/irules_test.rs | 0 tests/corpus/main.rs | 18 + tests/{ => corpus}/pdf_js_test.rs | 2 +- tests/{ => corpus}/php_test.rs | 2 +- tests/{ => corpus}/serde_test.rs | 2 +- tests/fixtures/README.md | 4 +- .../alterator_string_flattening.rs | 0 tests/{ => grammars}/c_grammar_metrics.rs | 0 tests/grammars/main.rs | 9 + .../{ => grammars}/mozcpp_grammar_metrics.rs | 2 +- tests/{ => output_formats}/checkstyle_test.rs | 2 +- tests/{ => output_formats}/csv_test.rs | 0 tests/output_formats/main.rs | 18 + tests/{ => output_formats}/sarif_test.rs | 2 +- ...ut_formats__csv_test__csv_cpp_widget.snap} | 2 +- ...ormats__csv_test__csv_python_greeter.snap} | 2 +- ..._formats__csv_test__csv_rust_counter.snap} | 2 +- ...ts__sarif_test__sarif_multi_offender.snap} | 2 +- ...ts__sarif_test__sarif_zero_offenders.snap} | 2 +- .../cognitive_cross_language_parity.rs | 2 +- tests/{ => parity}/cpp_mozcpp_parity.rs | 0 .../cyclomatic_cross_language_parity.rs | 0 .../exit_cross_language_parity.rs | 2 +- tests/parity/main.rs | 12 + .../nargs_cross_language_parity.rs | 2 +- .../{ => parity}/ops_metrics_space_parity.rs | 0 tests/parser_reuse.rs | 316 ------------------ tests/vcs/main.rs | 29 ++ tests/{ => vcs}/vcs_bus_factor.rs | 3 +- tests/{ => vcs}/vcs_cache.rs | 3 +- tests/{ => vcs}/vcs_file_types.rs | 3 +- tests/{ => vcs}/vcs_history.rs | 3 +- tests/{ => vcs}/vcs_jit.rs | 3 +- tests/{ => vcs}/vcs_per_function.rs | 3 +- tests/{ => vcs}/vcs_trend.rs | 3 +- 105 files changed, 671 insertions(+), 414 deletions(-) rename big-code-analysis-cli/tests/{ => check}/action_enforcement.rs (99%) rename big-code-analysis-cli/tests/{ => check}/check_baseline.rs (99%) rename big-code-analysis-cli/tests/{ => check}/check_exclude.rs (99%) rename big-code-analysis-cli/tests/{ => check}/check_exit_codes.rs (99%) rename big-code-analysis-cli/tests/{ => check}/check_report_suppressed_scope.rs (99%) rename big-code-analysis-cli/tests/{ => check}/check_suppression.rs (99%) rename big-code-analysis-cli/tests/{ => check}/check_thresholds.rs (99%) rename big-code-analysis-cli/tests/{ => check}/exemptions.rs (99%) create mode 100644 big-code-analysis-cli/tests/check/main.rs rename big-code-analysis-cli/tests/{ => cli_ux}/cli_smoke.rs (99%) rename big-code-analysis-cli/tests/{ => cli_ux}/deprecated_aliases.rs (99%) rename big-code-analysis-cli/tests/{ => cli_ux}/flag_scoping.rs (99%) rename big-code-analysis-cli/tests/{ => cli_ux}/help_text.rs (99%) rename big-code-analysis-cli/tests/{ => cli_ux}/init.rs (99%) rename big-code-analysis-cli/tests/{ => cli_ux}/list_metrics.rs (98%) create mode 100644 big-code-analysis-cli/tests/cli_ux/main.rs rename big-code-analysis-cli/tests/{ => cli_ux}/manifest.rs (99%) rename big-code-analysis-cli/tests/{ => diff}/diff_baseline.rs (99%) rename big-code-analysis-cli/tests/{ => diff}/diff_since.rs (99%) create mode 100644 big-code-analysis-cli/tests/diff/main.rs rename big-code-analysis-cli/tests/{ => discovery}/exclude_from.rs (99%) rename big-code-analysis-cli/tests/{ => discovery}/exclude_path_form.rs (99%) rename big-code-analysis-cli/tests/{ => discovery}/explicit_path_excludes.rs (99%) rename big-code-analysis-cli/tests/{ => discovery}/include_exclude_arity.rs (99%) rename big-code-analysis-cli/tests/{ => discovery}/invalid_glob.rs (98%) create mode 100644 big-code-analysis-cli/tests/discovery/main.rs rename big-code-analysis-cli/tests/{ => discovery}/paths_discovery.rs (99%) rename big-code-analysis-cli/tests/{ => discovery}/read_failures.rs (99%) rename big-code-analysis-cli/tests/{ => discovery}/skip_generated.rs (99%) rename big-code-analysis-cli/tests/{ => discovery}/walk_channel_completeness.rs (99%) rename big-code-analysis-cli/tests/{ => discovery}/warning_flag.rs (99%) rename big-code-analysis-cli/tests/{ => output}/color_output.rs (99%) rename big-code-analysis-cli/tests/{ => output}/dump_headers.rs (99%) rename big-code-analysis-cli/tests/{ => output}/format_smoke.rs (99%) rename big-code-analysis-cli/tests/{ => output}/html_report.rs (99%) create mode 100644 big-code-analysis-cli/tests/output/main.rs rename big-code-analysis-cli/tests/{ => output}/markdown_format.rs (99%) rename big-code-analysis-cli/tests/{ => output}/metric_selection.rs (99%) rename big-code-analysis-cli/tests/{ => output}/output_unification.rs (99%) create mode 100644 big-code-analysis-cli/tests/vcs/main.rs rename big-code-analysis-cli/tests/{ => vcs}/vcs_jit.rs (99%) rename big-code-analysis-cli/tests/{vcs.rs => vcs/vcs_rank.rs} (99%) rename big-code-analysis-cli/tests/{ => vcs}/vcs_trend.rs (99%) rename tests/{ => api}/ast_seam_test.rs (100%) rename tests/{ => api}/book_ast_traversal_examples.rs (99%) rename tests/{ => api}/book_library_examples.rs (99%) rename tests/{ => api}/derive_eq_hash_ord.rs (100%) create mode 100644 tests/api/main.rs create mode 100644 tests/api/parser_reuse.rs rename tests/{ => api}/suppression_test.rs (100%) rename tests/{ => corpus}/csharp_test.rs (95%) rename tests/{ => corpus}/deepspeech_test.rs (98%) rename tests/{ => corpus}/irules_test.rs (100%) create mode 100644 tests/corpus/main.rs rename tests/{ => corpus}/pdf_js_test.rs (99%) rename tests/{ => corpus}/php_test.rs (95%) rename tests/{ => corpus}/serde_test.rs (89%) rename tests/{ => grammars}/alterator_string_flattening.rs (100%) rename tests/{ => grammars}/c_grammar_metrics.rs (100%) create mode 100644 tests/grammars/main.rs rename tests/{ => grammars}/mozcpp_grammar_metrics.rs (99%) rename tests/{ => output_formats}/checkstyle_test.rs (99%) rename tests/{ => output_formats}/csv_test.rs (100%) create mode 100644 tests/output_formats/main.rs rename tests/{ => output_formats}/sarif_test.rs (99%) rename tests/{snapshots/csv_test__csv_cpp_widget.snap => output_formats/snapshots/output_formats__csv_test__csv_cpp_widget.snap} (99%) rename tests/{snapshots/csv_test__csv_python_greeter.snap => output_formats/snapshots/output_formats__csv_test__csv_python_greeter.snap} (98%) rename tests/{snapshots/csv_test__csv_rust_counter.snap => output_formats/snapshots/output_formats__csv_test__csv_rust_counter.snap} (98%) rename tests/{snapshots/sarif_test__sarif_multi_offender.snap => output_formats/snapshots/output_formats__sarif_test__sarif_multi_offender.snap} (98%) rename tests/{snapshots/sarif_test__sarif_zero_offenders.snap => output_formats/snapshots/output_formats__sarif_test__sarif_zero_offenders.snap} (87%) rename tests/{ => parity}/cognitive_cross_language_parity.rs (99%) rename tests/{ => parity}/cpp_mozcpp_parity.rs (100%) rename tests/{ => parity}/cyclomatic_cross_language_parity.rs (100%) rename tests/{ => parity}/exit_cross_language_parity.rs (99%) create mode 100644 tests/parity/main.rs rename tests/{ => parity}/nargs_cross_language_parity.rs (98%) rename tests/{ => parity}/ops_metrics_space_parity.rs (100%) delete mode 100644 tests/parser_reuse.rs create mode 100644 tests/vcs/main.rs rename tests/{ => vcs}/vcs_bus_factor.rs (99%) rename tests/{ => vcs}/vcs_cache.rs (99%) rename tests/{ => vcs}/vcs_file_types.rs (99%) rename tests/{ => vcs}/vcs_history.rs (99%) rename tests/{ => vcs}/vcs_jit.rs (99%) rename tests/{ => vcs}/vcs_per_function.rs (99%) rename tests/{ => vcs}/vcs_trend.rs (99%) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5a2ad51a8..a597f5782 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -41,6 +41,33 @@ The two binaries shipped with the workspace are: - `bca-web`, the REST API server (`big-code-analysis-web`): `cargo run -p big-code-analysis-web --`. +### Optional: a faster linker + +Every test binary in this workspace statically links the tree-sitter +runtime and all twenty-odd grammars, so linking — not compiling — is the +tail of an incremental `cargo test` after a one-line edit. GNU `ld`, +still the default on most Linux toolchains, is single-threaded and is +usually the slowest part of that. + +This is deliberately not configured in the repository: which linker is +installed, and which one works, varies per machine, and a committed +`[target.*] rustflags` that names a missing linker breaks the build for +everyone who does not have it. Install one yourself and add it to a +**local, untracked** `.cargo/config.toml`: + +```toml +# mold — https://github.com/rui314/mold (apt: mold, brew: mold) +[target.x86_64-unknown-linux-gnu] +rustflags = ["-C", "link-arg=-fuse-ld=mold"] +``` + +For LLVM's `lld` (`apt install lld`, or bundled with a Homebrew LLVM), +substitute `-fuse-ld=lld`. On macOS the platform linker is already +parallel and neither is needed. Note that `.cargo/config.toml` *is* +tracked here — it carries the `cargo mutants` and `cargo xtask` +aliases — so add the stanza without committing it, or put it in your +user-level `~/.cargo/config.toml` instead. + ## Local validation gate `make pre-commit` is the canonical entry point for the full validation diff --git a/Cargo.toml b/Cargo.toml index 8f8700d7e..aab3ad2bc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -377,6 +377,23 @@ serde_yaml = "^0.9" ciborium = "^0.2" toml = "^1.1" +# Cargo's dev/test default is `debug = 2`, which emits full DWARF — +# variable locations, type descriptions, the lot — for every crate in the +# graph, and then makes the linker copy all of it into every one of the +# workspace's test binaries. `line-tables-only` keeps exactly what a +# panic backtrace reads (function names plus file:line) and drops the +# rest, which is the part nothing in this repo's workflow consumes: no +# gate, no CI job, and no `make` target runs a debugger. Measured here on +# a full `--all-features` test build (#1124). +# +# `split-debuginfo` is deliberately left at its platform default. On +# Linux, `"unpacked"` would leave the remaining line tables in the `.o` +# files and record paths instead — but at `line-tables-only` there is +# little left to split, and it makes a binary stop symbolizing the +# moment its object files are cleaned. +[profile.dev] +debug = "line-tables-only" + # Optimize dependencies in dev/test builds. The `"*"` glob excludes # workspace members, so the crates under `[workspace].members` stay at # opt-level 0 — quick to rebuild and to step through. It does cover the diff --git a/big-code-analysis-book/src/library/ast-traversal.md b/big-code-analysis-book/src/library/ast-traversal.md index 02123354e..be34a1a49 100644 --- a/big-code-analysis-book/src/library/ast-traversal.md +++ b/big-code-analysis-book/src/library/ast-traversal.md @@ -88,10 +88,10 @@ The pattern is: visit, descend, climb back up while there is no next sibling, repeat. Every example in this chapter is a thin wrapper around this walker — the code fences below are marked `ignore` because they assume `walk_preorder` is already in scope; the matching set of tests -in [`tests/book_ast_traversal_examples.rs`][tests] keeps them +in [`tests/api/book_ast_traversal_examples.rs`][tests] keeps them honest, so a refactor that broke an example would fail `cargo test`. -[tests]: https://github.com/dekobon/big-code-analysis/blob/main/tests/book_ast_traversal_examples.rs +[tests]: https://github.com/dekobon/big-code-analysis/blob/main/tests/api/book_ast_traversal_examples.rs [ts_cursor]: https://docs.rs/tree-sitter/*/tree_sitter/struct.TreeCursor.html diff --git a/big-code-analysis-cli/src/deprecations_tests.rs b/big-code-analysis-cli/src/deprecations_tests.rs index 63504a685..94f12e810 100644 --- a/big-code-analysis-cli/src/deprecations_tests.rs +++ b/big-code-analysis-cli/src/deprecations_tests.rs @@ -1,11 +1,11 @@ //! Unit tests for the argv-scan deprecation detector (#646). These cover //! the spelling-detection logic in isolation; the integration suite -//! (`tests/deprecated_aliases.rs`) asserts the end-to-end stderr text and +//! (`tests/cli_ux/deprecated_aliases.rs`) asserts the end-to-end stderr text and //! that canonical spellings stay silent. use super::{DEPRECATED_FLAG_ALIASES, is_flag_spelling, subcommand_used, top_subcommand}; -/// The integration suite (`tests/deprecated_aliases.rs`) asserts the +/// The integration suite (`tests/cli_ux/deprecated_aliases.rs`) asserts the /// deprecation warning for every flag-alias row by name. This guard pins /// the table's size so adding a row without a matching warning test is a /// visible failure here (#832) — the silent-breakage #646 was created to @@ -16,7 +16,7 @@ fn deprecated_flag_alias_table_size_is_pinned() { DEPRECATED_FLAG_ALIASES.len(), 9, "DEPRECATED_FLAG_ALIASES changed size; add/remove the matching \ - per-alias warning test in tests/deprecated_aliases.rs and update \ + per-alias warning test in tests/cli_ux/deprecated_aliases.rs and update \ this count", ); } diff --git a/big-code-analysis-cli/src/lib_tests.rs b/big-code-analysis-cli/src/lib_tests.rs index 930ccb27c..7b11b1c91 100644 --- a/big-code-analysis-cli/src/lib_tests.rs +++ b/big-code-analysis-cli/src/lib_tests.rs @@ -470,7 +470,7 @@ fn check_rejects_per_file_format_as_output_format() { // Note: runtime rejection of `ops -O csv` is covered by // `ops_rejects_csv_format_at_runtime` in -// tests/action_enforcement.rs, which spawns the binary so the +// tests/check/action_enforcement.rs, which spawns the binary so the // dispatcher's die() can be observed. #[test] @@ -1067,7 +1067,7 @@ impl std::io::Write for FlushFailingSink { /// error surfaces from a `write_all`), which is why the hole survived /// #1132 and why it is pinned here rather than end-to-end. `bca vcs` /// had the identical hole on a path that *could* produce one, and -/// `tests/read_failures.rs` covers that half. +/// `tests/discovery/read_failures.rs` covers that half. /// /// Deleting the `out.flush()` makes this the only failing test in the /// workspace — verified. diff --git a/big-code-analysis-cli/src/manifest_tests.rs b/big-code-analysis-cli/src/manifest_tests.rs index f38bb7ec0..e38e18bc3 100644 --- a/big-code-analysis-cli/src/manifest_tests.rs +++ b/big-code-analysis-cli/src/manifest_tests.rs @@ -2,7 +2,7 @@ //! //! Discovery (which reads the process working directory) and the //! end-to-end CLI precedence are exercised by the integration tests in -//! `tests/manifest.rs`; these cover the pure transforms in isolation. +//! `tests/cli_ux/manifest.rs`; these cover the pure transforms in isolation. use super::*; @@ -15,7 +15,7 @@ fn manifest(raw: RawManifest) -> Manifest { raw, // These transforms never read disk text, so default the job-count // key to the canonical spelling; the alias-attribution path is - // covered by the integration tests in `tests/manifest.rs`. + // covered by the integration tests in `tests/cli_ux/manifest.rs`. jobs_key: Some("jobs"), } } diff --git a/big-code-analysis-cli/src/threshold_suggestion.rs b/big-code-analysis-cli/src/threshold_suggestion.rs index a0b413724..0da7b6c0b 100644 --- a/big-code-analysis-cli/src/threshold_suggestion.rs +++ b/big-code-analysis-cli/src/threshold_suggestion.rs @@ -3,7 +3,7 @@ //! //! Split from `thresholds.rs` to keep that file under the bca self-scan //! caps. Behaviour is exercised end-to-end via the integration tests in -//! `tests/check_thresholds.rs` and via dedicated unit tests in +//! `tests/check/check_thresholds.rs` and via dedicated unit tests in //! `thresholds_tests.rs`. /// Maximum number of "did you mean?" candidates listed in a single diff --git a/big-code-analysis-cli/src/vcs_command.rs b/big-code-analysis-cli/src/vcs_command.rs index 8f4d03722..9268b6f79 100644 --- a/big-code-analysis-cli/src/vcs_command.rs +++ b/big-code-analysis-cli/src/vcs_command.rs @@ -1103,7 +1103,7 @@ fn outer(x: i32) -> i32 { // Windows, `/` on Unix); the emitted git path must always be // forward-slash so the JSON / CSV / table output is byte-identical // cross-platform. On Windows this guards the `src\work.rs` - // regression that failed `tests/vcs.rs` on windows-latest. + // regression that failed `tests/vcs/vcs_rank.rs` on windows-latest. let rel: PathBuf = ["src", "work.rs"].iter().collect(); assert_eq!(path_to_string(&rel).as_deref(), Some("src/work.rs")); } diff --git a/big-code-analysis-cli/src/walk.rs b/big-code-analysis-cli/src/walk.rs index 763e79757..762532463 100644 --- a/big-code-analysis-cli/src/walk.rs +++ b/big-code-analysis-cli/src/walk.rs @@ -223,7 +223,7 @@ pub(crate) struct ResolvedFiles { /// the tally to every variant would make a stray `.gitignore` typo fail /// a build — pinned by /// `malformed_parent_gitignore_warns_but_still_exits_zero` in -/// `tests/read_failures.rs`. +/// `tests/discovery/read_failures.rs`. /// /// `Error::Loop` is not a concern here: `follow_links` is off, so the /// walker never runs its symlink-loop check. diff --git a/big-code-analysis-cli/tests/action_enforcement.rs b/big-code-analysis-cli/tests/check/action_enforcement.rs similarity index 99% rename from big-code-analysis-cli/tests/action_enforcement.rs rename to big-code-analysis-cli/tests/check/action_enforcement.rs index 48065f3e1..22d33bed5 100644 --- a/big-code-analysis-cli/tests/action_enforcement.rs +++ b/big-code-analysis-cli/tests/check/action_enforcement.rs @@ -2,7 +2,7 @@ use assert_cmd::Command; use predicates::prelude::*; -mod common; +use crate::common; fn cli() -> Command { common::bca_command() diff --git a/big-code-analysis-cli/tests/check_baseline.rs b/big-code-analysis-cli/tests/check/check_baseline.rs similarity index 99% rename from big-code-analysis-cli/tests/check_baseline.rs rename to big-code-analysis-cli/tests/check/check_baseline.rs index 1c25f44f4..5f17ca9ba 100644 --- a/big-code-analysis-cli/tests/check_baseline.rs +++ b/big-code-analysis-cli/tests/check/check_baseline.rs @@ -13,7 +13,7 @@ use assert_cmd::Command; use predicates::prelude::*; use tempfile::TempDir; -mod common; +use crate::common; /// Hermetic `bca` builder: anchors the process cwd at `dir` (a /// `tempfile::tempdir()` with no `.git` ancestor) so `bca check` cannot diff --git a/big-code-analysis-cli/tests/check_exclude.rs b/big-code-analysis-cli/tests/check/check_exclude.rs similarity index 99% rename from big-code-analysis-cli/tests/check_exclude.rs rename to big-code-analysis-cli/tests/check/check_exclude.rs index a6c3f351c..9d744aac4 100644 --- a/big-code-analysis-cli/tests/check_exclude.rs +++ b/big-code-analysis-cli/tests/check/check_exclude.rs @@ -13,7 +13,7 @@ use assert_cmd::Command; use predicates::prelude::*; use tempfile::TempDir; -mod common; +use crate::common; /// Hermetic `bca` builder: anchors the process cwd at `dir` (a /// `tempfile::tempdir()` with no `.git` ancestor) so `bca check` cannot diff --git a/big-code-analysis-cli/tests/check_exit_codes.rs b/big-code-analysis-cli/tests/check/check_exit_codes.rs similarity index 99% rename from big-code-analysis-cli/tests/check_exit_codes.rs rename to big-code-analysis-cli/tests/check/check_exit_codes.rs index e44ca4028..765f0ff6d 100644 --- a/big-code-analysis-cli/tests/check_exit_codes.rs +++ b/big-code-analysis-cli/tests/check/check_exit_codes.rs @@ -20,7 +20,7 @@ use assert_cmd::Command; use predicates::prelude::*; use tempfile::TempDir; -mod common; +use crate::common; /// Hermetic `bca` builder: anchors the process cwd at `dir` (a /// `tempfile::tempdir()` with no `.git` ancestor) so `bca check` cannot diff --git a/big-code-analysis-cli/tests/check_report_suppressed_scope.rs b/big-code-analysis-cli/tests/check/check_report_suppressed_scope.rs similarity index 99% rename from big-code-analysis-cli/tests/check_report_suppressed_scope.rs rename to big-code-analysis-cli/tests/check/check_report_suppressed_scope.rs index c011ad94a..3dda30f19 100644 --- a/big-code-analysis-cli/tests/check_report_suppressed_scope.rs +++ b/big-code-analysis-cli/tests/check/check_report_suppressed_scope.rs @@ -16,7 +16,7 @@ use std::process::Command as StdCommand; use assert_cmd::Command; -mod common; +use crate::common; fn cli() -> Command { common::bca_command() diff --git a/big-code-analysis-cli/tests/check_suppression.rs b/big-code-analysis-cli/tests/check/check_suppression.rs similarity index 99% rename from big-code-analysis-cli/tests/check_suppression.rs rename to big-code-analysis-cli/tests/check/check_suppression.rs index a051dca85..761adbc24 100644 --- a/big-code-analysis-cli/tests/check_suppression.rs +++ b/big-code-analysis-cli/tests/check/check_suppression.rs @@ -13,7 +13,7 @@ use assert_cmd::Command; use predicates::prelude::*; use tempfile::TempDir; -mod common; +use crate::common; /// Hermetic `bca` builder: anchors the process cwd at `dir` (a /// `tempfile::tempdir()` with no `.git` ancestor) so `bca check` cannot diff --git a/big-code-analysis-cli/tests/check_thresholds.rs b/big-code-analysis-cli/tests/check/check_thresholds.rs similarity index 99% rename from big-code-analysis-cli/tests/check_thresholds.rs rename to big-code-analysis-cli/tests/check/check_thresholds.rs index 30ea6cbae..ef150327c 100644 --- a/big-code-analysis-cli/tests/check_thresholds.rs +++ b/big-code-analysis-cli/tests/check/check_thresholds.rs @@ -12,7 +12,7 @@ use assert_cmd::Command; use predicates::prelude::*; use tempfile::TempDir; -mod common; +use crate::common; /// Hermetic `bca` builder: anchors the process cwd at `dir` (a /// `tempfile::tempdir()` with no `.git` ancestor) so `bca check` cannot diff --git a/big-code-analysis-cli/tests/exemptions.rs b/big-code-analysis-cli/tests/check/exemptions.rs similarity index 99% rename from big-code-analysis-cli/tests/exemptions.rs rename to big-code-analysis-cli/tests/check/exemptions.rs index b85aa97ab..207328778 100644 --- a/big-code-analysis-cli/tests/exemptions.rs +++ b/big-code-analysis-cli/tests/check/exemptions.rs @@ -13,7 +13,7 @@ use predicates::prelude::*; use serde_json::Value; use tempfile::TempDir; -mod common; +use crate::common; fn cli() -> Command { common::bca_command() diff --git a/big-code-analysis-cli/tests/check/main.rs b/big-code-analysis-cli/tests/check/main.rs new file mode 100644 index 000000000..99e67eec4 --- /dev/null +++ b/big-code-analysis-cli/tests/check/main.rs @@ -0,0 +1,23 @@ +//! `bca check` driver: the threshold engine, the baseline filter, +//! exclusions, exemptions, in-source suppression markers, the report +//! scope, the exit-code contract, and `--action` enforcement. +//! +//! Each module below was its own `tests/*.rs` crate root until #1124. +//! An integration binary here statically links the tree-sitter runtime +//! and every grammar, so thirty-seven of them made linking — not +//! compilation — the tail of every incremental `cargo test`. Grouping +//! by subcommand pays that link cost six times instead. Test bodies are +//! unchanged; the only per-module edit is `mod common;` becoming +//! `use crate::common;`. + +#[path = "../common/mod.rs"] +mod common; + +mod action_enforcement; +mod check_baseline; +mod check_exclude; +mod check_exit_codes; +mod check_report_suppressed_scope; +mod check_suppression; +mod check_thresholds; +mod exemptions; diff --git a/big-code-analysis-cli/tests/cli_smoke.rs b/big-code-analysis-cli/tests/cli_ux/cli_smoke.rs similarity index 99% rename from big-code-analysis-cli/tests/cli_smoke.rs rename to big-code-analysis-cli/tests/cli_ux/cli_smoke.rs index a035557a2..cfcbcce61 100644 --- a/big-code-analysis-cli/tests/cli_smoke.rs +++ b/big-code-analysis-cli/tests/cli_ux/cli_smoke.rs @@ -9,7 +9,7 @@ use assert_cmd::Command; use predicates::prelude::*; use tempfile::TempDir; -mod common; +use crate::common; fn cli() -> Command { common::bca_command() diff --git a/big-code-analysis-cli/tests/deprecated_aliases.rs b/big-code-analysis-cli/tests/cli_ux/deprecated_aliases.rs similarity index 99% rename from big-code-analysis-cli/tests/deprecated_aliases.rs rename to big-code-analysis-cli/tests/cli_ux/deprecated_aliases.rs index 8b288979f..a181c782b 100644 --- a/big-code-analysis-cli/tests/deprecated_aliases.rs +++ b/big-code-analysis-cli/tests/cli_ux/deprecated_aliases.rs @@ -10,7 +10,7 @@ use assert_cmd::Command; use predicates::prelude::*; use tempfile::TempDir; -mod common; +use crate::common; fn cli(dir: &TempDir) -> Command { common::cli_in(dir.path()) diff --git a/big-code-analysis-cli/tests/flag_scoping.rs b/big-code-analysis-cli/tests/cli_ux/flag_scoping.rs similarity index 99% rename from big-code-analysis-cli/tests/flag_scoping.rs rename to big-code-analysis-cli/tests/cli_ux/flag_scoping.rs index 9c787ec9e..bb18f73a5 100644 --- a/big-code-analysis-cli/tests/flag_scoping.rs +++ b/big-code-analysis-cli/tests/cli_ux/flag_scoping.rs @@ -14,7 +14,7 @@ use std::fs; use assert_cmd::Command; use predicates::prelude::*; -mod common; +use crate::common; fn cli() -> Command { common::bca_command() diff --git a/big-code-analysis-cli/tests/help_text.rs b/big-code-analysis-cli/tests/cli_ux/help_text.rs similarity index 99% rename from big-code-analysis-cli/tests/help_text.rs rename to big-code-analysis-cli/tests/cli_ux/help_text.rs index 205a04bf2..2a7e76528 100644 --- a/big-code-analysis-cli/tests/help_text.rs +++ b/big-code-analysis-cli/tests/cli_ux/help_text.rs @@ -10,7 +10,7 @@ use assert_cmd::Command; use predicates::prelude::*; -mod common; +use crate::common; fn cli() -> Command { common::bca_command() diff --git a/big-code-analysis-cli/tests/init.rs b/big-code-analysis-cli/tests/cli_ux/init.rs similarity index 99% rename from big-code-analysis-cli/tests/init.rs rename to big-code-analysis-cli/tests/cli_ux/init.rs index 79b973cae..12f8803d3 100644 --- a/big-code-analysis-cli/tests/init.rs +++ b/big-code-analysis-cli/tests/cli_ux/init.rs @@ -13,7 +13,7 @@ use assert_cmd::Command; use predicates::prelude::*; use tempfile::TempDir; -mod common; +use crate::common; fn cli() -> Command { common::bca_command() diff --git a/big-code-analysis-cli/tests/list_metrics.rs b/big-code-analysis-cli/tests/cli_ux/list_metrics.rs similarity index 98% rename from big-code-analysis-cli/tests/list_metrics.rs rename to big-code-analysis-cli/tests/cli_ux/list_metrics.rs index fa85e10e4..f1a827bea 100644 --- a/big-code-analysis-cli/tests/list_metrics.rs +++ b/big-code-analysis-cli/tests/cli_ux/list_metrics.rs @@ -2,7 +2,7 @@ use assert_cmd::Command; use predicates::prelude::*; -mod common; +use crate::common; fn cli() -> Command { common::bca_command() diff --git a/big-code-analysis-cli/tests/cli_ux/main.rs b/big-code-analysis-cli/tests/cli_ux/main.rs new file mode 100644 index 000000000..550b654db --- /dev/null +++ b/big-code-analysis-cli/tests/cli_ux/main.rs @@ -0,0 +1,17 @@ +//! Command-surface driver: the smoke tests over every subcommand, help +//! text, flag scoping, deprecated aliases, `bca init`, `bca +//! list-metrics`, and `bca.toml` manifest discovery and merging. +//! +//! Grouped into one binary by #1124 — see +//! `big-code-analysis-cli/tests/check/main.rs` for the rationale. + +#[path = "../common/mod.rs"] +mod common; + +mod cli_smoke; +mod deprecated_aliases; +mod flag_scoping; +mod help_text; +mod init; +mod list_metrics; +mod manifest; diff --git a/big-code-analysis-cli/tests/manifest.rs b/big-code-analysis-cli/tests/cli_ux/manifest.rs similarity index 99% rename from big-code-analysis-cli/tests/manifest.rs rename to big-code-analysis-cli/tests/cli_ux/manifest.rs index 4cd931b22..4dc0917e4 100644 --- a/big-code-analysis-cli/tests/manifest.rs +++ b/big-code-analysis-cli/tests/cli_ux/manifest.rs @@ -12,7 +12,7 @@ use assert_cmd::Command; use predicates::prelude::*; use tempfile::TempDir; -mod common; +use crate::common; fn cli() -> Command { common::bca_command() diff --git a/big-code-analysis-cli/tests/diff_baseline.rs b/big-code-analysis-cli/tests/diff/diff_baseline.rs similarity index 99% rename from big-code-analysis-cli/tests/diff_baseline.rs rename to big-code-analysis-cli/tests/diff/diff_baseline.rs index 7fd825862..881f24127 100644 --- a/big-code-analysis-cli/tests/diff_baseline.rs +++ b/big-code-analysis-cli/tests/diff/diff_baseline.rs @@ -10,7 +10,7 @@ use assert_cmd::Command; use predicates::prelude::*; use tempfile::TempDir; -mod common; +use crate::common; fn cli() -> Command { common::bca_command() diff --git a/big-code-analysis-cli/tests/diff_since.rs b/big-code-analysis-cli/tests/diff/diff_since.rs similarity index 99% rename from big-code-analysis-cli/tests/diff_since.rs rename to big-code-analysis-cli/tests/diff/diff_since.rs index 8996f33de..ac60dcb08 100644 --- a/big-code-analysis-cli/tests/diff_since.rs +++ b/big-code-analysis-cli/tests/diff/diff_since.rs @@ -19,7 +19,7 @@ use assert_cmd::Command; use predicates::prelude::*; use tempfile::TempDir; -mod common; +use crate::common; fn cli() -> Command { common::bca_command() diff --git a/big-code-analysis-cli/tests/diff/main.rs b/big-code-analysis-cli/tests/diff/main.rs new file mode 100644 index 000000000..6c461ad4b --- /dev/null +++ b/big-code-analysis-cli/tests/diff/main.rs @@ -0,0 +1,10 @@ +//! `bca diff` / `bca diff-baseline` driver. +//! +//! Grouped into one binary by #1124 — see +//! `big-code-analysis-cli/tests/check/main.rs` for the rationale. + +#[path = "../common/mod.rs"] +mod common; + +mod diff_baseline; +mod diff_since; diff --git a/big-code-analysis-cli/tests/exclude_from.rs b/big-code-analysis-cli/tests/discovery/exclude_from.rs similarity index 99% rename from big-code-analysis-cli/tests/exclude_from.rs rename to big-code-analysis-cli/tests/discovery/exclude_from.rs index eca4dada7..5b86155a0 100644 --- a/big-code-analysis-cli/tests/exclude_from.rs +++ b/big-code-analysis-cli/tests/discovery/exclude_from.rs @@ -8,7 +8,7 @@ use assert_cmd::Command; use predicates::prelude::*; use tempfile::TempDir; -mod common; +use crate::common; fn cli(env_dir: &Path) -> Command { let mut cmd = common::bca_command(); diff --git a/big-code-analysis-cli/tests/exclude_path_form.rs b/big-code-analysis-cli/tests/discovery/exclude_path_form.rs similarity index 99% rename from big-code-analysis-cli/tests/exclude_path_form.rs rename to big-code-analysis-cli/tests/discovery/exclude_path_form.rs index fecb01cc8..53555900f 100644 --- a/big-code-analysis-cli/tests/exclude_path_form.rs +++ b/big-code-analysis-cli/tests/discovery/exclude_path_form.rs @@ -15,7 +15,7 @@ use std::path::{Path, PathBuf}; use assert_cmd::Command; use tempfile::TempDir; -mod common; +use crate::common; fn cli(env_dir: &Path) -> Command { let mut cmd = common::bca_command(); diff --git a/big-code-analysis-cli/tests/explicit_path_excludes.rs b/big-code-analysis-cli/tests/discovery/explicit_path_excludes.rs similarity index 99% rename from big-code-analysis-cli/tests/explicit_path_excludes.rs rename to big-code-analysis-cli/tests/discovery/explicit_path_excludes.rs index e516ecac1..ed6d04873 100644 --- a/big-code-analysis-cli/tests/explicit_path_excludes.rs +++ b/big-code-analysis-cli/tests/discovery/explicit_path_excludes.rs @@ -24,7 +24,7 @@ use assert_cmd::Command; use predicates::prelude::*; use tempfile::TempDir; -mod common; +use crate::common; fn cli(dir: &Path) -> Command { common::cli_in(dir) diff --git a/big-code-analysis-cli/tests/include_exclude_arity.rs b/big-code-analysis-cli/tests/discovery/include_exclude_arity.rs similarity index 99% rename from big-code-analysis-cli/tests/include_exclude_arity.rs rename to big-code-analysis-cli/tests/discovery/include_exclude_arity.rs index b837cdfed..b162a1801 100644 --- a/big-code-analysis-cli/tests/include_exclude_arity.rs +++ b/big-code-analysis-cli/tests/discovery/include_exclude_arity.rs @@ -14,7 +14,7 @@ use std::path::Path; use assert_cmd::Command; use tempfile::TempDir; -mod common; +use crate::common; fn cli(dir: &Path) -> Command { let mut cmd = common::bca_command(); diff --git a/big-code-analysis-cli/tests/invalid_glob.rs b/big-code-analysis-cli/tests/discovery/invalid_glob.rs similarity index 98% rename from big-code-analysis-cli/tests/invalid_glob.rs rename to big-code-analysis-cli/tests/discovery/invalid_glob.rs index 4bd19181c..86e38bbdf 100644 --- a/big-code-analysis-cli/tests/invalid_glob.rs +++ b/big-code-analysis-cli/tests/discovery/invalid_glob.rs @@ -2,7 +2,7 @@ use assert_cmd::Command; use predicates::prelude::*; -mod common; +use crate::common; fn cli() -> Command { common::bca_command() diff --git a/big-code-analysis-cli/tests/discovery/main.rs b/big-code-analysis-cli/tests/discovery/main.rs new file mode 100644 index 000000000..7375e07f9 --- /dev/null +++ b/big-code-analysis-cli/tests/discovery/main.rs @@ -0,0 +1,30 @@ +//! Path-discovery driver: how the walk resolves `--paths`, honours +//! include / exclude globs in all their spellings, skips generated +//! files, reports unreadable inputs, and drains its worker channel. +//! +//! Grouped into one binary by #1124 — see +//! `big-code-analysis-cli/tests/check/main.rs` for the rationale. +//! +//! `warning_flag` and `skip_generated` are the workspace's only two +//! users of `common::CwdGuard`, which mutates the *process* working +//! directory to prove the hermetic command builders ignore it. They +//! are deliberately in the same driver so that hazard stays confined to +//! one binary, exactly as wide as it was when they were two. It is +//! inert under `cargo nextest` (a process per test, which is what +//! `make test` and CI run) and bounded to a single spawn under the +//! `cargo test` fallback, where the guard's own mutex already +//! serializes it against its peer. + +#[path = "../common/mod.rs"] +mod common; + +mod exclude_from; +mod exclude_path_form; +mod explicit_path_excludes; +mod include_exclude_arity; +mod invalid_glob; +mod paths_discovery; +mod read_failures; +mod skip_generated; +mod walk_channel_completeness; +mod warning_flag; diff --git a/big-code-analysis-cli/tests/paths_discovery.rs b/big-code-analysis-cli/tests/discovery/paths_discovery.rs similarity index 99% rename from big-code-analysis-cli/tests/paths_discovery.rs rename to big-code-analysis-cli/tests/discovery/paths_discovery.rs index 5a8d8b1c9..8b347c50b 100644 --- a/big-code-analysis-cli/tests/paths_discovery.rs +++ b/big-code-analysis-cli/tests/discovery/paths_discovery.rs @@ -7,7 +7,7 @@ use assert_cmd::Command; use predicates::prelude::*; use tempfile::TempDir; -mod common; +use crate::common; fn cli(env_dir: &Path) -> Command { let mut cmd = common::bca_command(); diff --git a/big-code-analysis-cli/tests/read_failures.rs b/big-code-analysis-cli/tests/discovery/read_failures.rs similarity index 99% rename from big-code-analysis-cli/tests/read_failures.rs rename to big-code-analysis-cli/tests/discovery/read_failures.rs index 1843aaf94..3efc88dec 100644 --- a/big-code-analysis-cli/tests/read_failures.rs +++ b/big-code-analysis-cli/tests/discovery/read_failures.rs @@ -51,7 +51,7 @@ //! survive on Windows and the workspace `missing_docs` lint stays quiet //! there. -mod common; +use crate::common; #[cfg(unix)] mod unix { diff --git a/big-code-analysis-cli/tests/skip_generated.rs b/big-code-analysis-cli/tests/discovery/skip_generated.rs similarity index 99% rename from big-code-analysis-cli/tests/skip_generated.rs rename to big-code-analysis-cli/tests/discovery/skip_generated.rs index 00e83f4ff..04de437bc 100644 --- a/big-code-analysis-cli/tests/skip_generated.rs +++ b/big-code-analysis-cli/tests/discovery/skip_generated.rs @@ -5,7 +5,7 @@ use assert_cmd::Command; use predicates::prelude::*; use tempfile::TempDir; -mod common; +use crate::common; /// Hermetic `bca` builder: anchors the process cwd at `dir` (a `.git`-free /// fixture tempdir) so `bca metrics` cannot climb to the repo's own diff --git a/big-code-analysis-cli/tests/walk_channel_completeness.rs b/big-code-analysis-cli/tests/discovery/walk_channel_completeness.rs similarity index 99% rename from big-code-analysis-cli/tests/walk_channel_completeness.rs rename to big-code-analysis-cli/tests/discovery/walk_channel_completeness.rs index 5e610bfcf..393a8f51c 100644 --- a/big-code-analysis-cli/tests/walk_channel_completeness.rs +++ b/big-code-analysis-cli/tests/discovery/walk_channel_completeness.rs @@ -24,7 +24,7 @@ use std::path::Path; use assert_cmd::Command; use tempfile::TempDir; -mod common; +use crate::common; fn cli(dir: &Path) -> Command { common::cli_in(dir) diff --git a/big-code-analysis-cli/tests/warning_flag.rs b/big-code-analysis-cli/tests/discovery/warning_flag.rs similarity index 99% rename from big-code-analysis-cli/tests/warning_flag.rs rename to big-code-analysis-cli/tests/discovery/warning_flag.rs index 61cbf6afb..648c5d001 100644 --- a/big-code-analysis-cli/tests/warning_flag.rs +++ b/big-code-analysis-cli/tests/discovery/warning_flag.rs @@ -3,7 +3,7 @@ use assert_cmd::Command; use predicates::prelude::*; use tempfile::{NamedTempFile, TempDir}; -mod common; +use crate::common; /// Hermetic `bca` builder rooted at a `.git`-free tempdir, returned /// alongside its guard. These tests analyse an absolute `--paths` diff --git a/big-code-analysis-cli/tests/color_output.rs b/big-code-analysis-cli/tests/output/color_output.rs similarity index 99% rename from big-code-analysis-cli/tests/color_output.rs rename to big-code-analysis-cli/tests/output/color_output.rs index d5267b86e..3c4667eb9 100644 --- a/big-code-analysis-cli/tests/color_output.rs +++ b/big-code-analysis-cli/tests/output/color_output.rs @@ -12,7 +12,7 @@ use assert_cmd::Command; use tempfile::TempDir; -mod common; +use crate::common; /// The byte that opens every ANSI escape sequence (`ESC`, `0x1b`). const ESC: u8 = 0x1b; diff --git a/big-code-analysis-cli/tests/dump_headers.rs b/big-code-analysis-cli/tests/output/dump_headers.rs similarity index 99% rename from big-code-analysis-cli/tests/dump_headers.rs rename to big-code-analysis-cli/tests/output/dump_headers.rs index fef7bf44b..81e06fa00 100644 --- a/big-code-analysis-cli/tests/dump_headers.rs +++ b/big-code-analysis-cli/tests/output/dump_headers.rs @@ -6,7 +6,7 @@ use std::fs; use predicates::prelude::*; use tempfile::TempDir; -mod common; +use crate::common; fn fixture() -> (TempDir, String, String) { let dir = TempDir::new().unwrap(); diff --git a/big-code-analysis-cli/tests/format_smoke.rs b/big-code-analysis-cli/tests/output/format_smoke.rs similarity index 99% rename from big-code-analysis-cli/tests/format_smoke.rs rename to big-code-analysis-cli/tests/output/format_smoke.rs index 7f8406181..0b7e7e699 100644 --- a/big-code-analysis-cli/tests/format_smoke.rs +++ b/big-code-analysis-cli/tests/output/format_smoke.rs @@ -34,7 +34,7 @@ use std::io::Write; use std::path::PathBuf; use tempfile::TempDir; -mod common; +use crate::common; use common::validators::{assert_checkstyle_well_formed_and_structural, validate_sarif}; /// Hermetic `bca` builder: anchors the process cwd at `dir` (a diff --git a/big-code-analysis-cli/tests/html_report.rs b/big-code-analysis-cli/tests/output/html_report.rs similarity index 99% rename from big-code-analysis-cli/tests/html_report.rs rename to big-code-analysis-cli/tests/output/html_report.rs index 19db52ef2..6e8790e0a 100644 --- a/big-code-analysis-cli/tests/html_report.rs +++ b/big-code-analysis-cli/tests/output/html_report.rs @@ -8,7 +8,7 @@ use assert_cmd::Command; use predicates::prelude::*; use tempfile::TempDir; -mod common; +use crate::common; use common::validators::assert_html_well_formed; fn cli() -> Command { diff --git a/big-code-analysis-cli/tests/output/main.rs b/big-code-analysis-cli/tests/output/main.rs new file mode 100644 index 000000000..d1344708d --- /dev/null +++ b/big-code-analysis-cli/tests/output/main.rs @@ -0,0 +1,17 @@ +//! Output-rendering driver: every emitted format (JSON/YAML/TOML/CBOR, +//! Markdown, HTML), the shared metric-selection and header plumbing, +//! and colour handling. +//! +//! Grouped into one binary by #1124 — see +//! `big-code-analysis-cli/tests/check/main.rs` for the rationale. + +#[path = "../common/mod.rs"] +mod common; + +mod color_output; +mod dump_headers; +mod format_smoke; +mod html_report; +mod markdown_format; +mod metric_selection; +mod output_unification; diff --git a/big-code-analysis-cli/tests/markdown_format.rs b/big-code-analysis-cli/tests/output/markdown_format.rs similarity index 99% rename from big-code-analysis-cli/tests/markdown_format.rs rename to big-code-analysis-cli/tests/output/markdown_format.rs index b90817dd8..a66a53d52 100644 --- a/big-code-analysis-cli/tests/markdown_format.rs +++ b/big-code-analysis-cli/tests/output/markdown_format.rs @@ -3,7 +3,7 @@ use assert_cmd::Command; use predicates::prelude::*; use tempfile::TempDir; -mod common; +use crate::common; fn cli() -> Command { common::bca_command() diff --git a/big-code-analysis-cli/tests/metric_selection.rs b/big-code-analysis-cli/tests/output/metric_selection.rs similarity index 99% rename from big-code-analysis-cli/tests/metric_selection.rs rename to big-code-analysis-cli/tests/output/metric_selection.rs index 632cc8d30..0be2e98f0 100644 --- a/big-code-analysis-cli/tests/metric_selection.rs +++ b/big-code-analysis-cli/tests/output/metric_selection.rs @@ -10,7 +10,7 @@ use std::collections::BTreeSet; use predicates::prelude::*; use tempfile::TempDir; -mod common; +use crate::common; /// Branchy Rust fixture so every metric family has a non-trivial value. const FIXTURE: &str = "fn f(x: u32) -> u32 { if x > 0 { x } else { 0 } }\n"; diff --git a/big-code-analysis-cli/tests/output_unification.rs b/big-code-analysis-cli/tests/output/output_unification.rs similarity index 99% rename from big-code-analysis-cli/tests/output_unification.rs rename to big-code-analysis-cli/tests/output/output_unification.rs index a50c672c1..2701bab73 100644 --- a/big-code-analysis-cli/tests/output_unification.rs +++ b/big-code-analysis-cli/tests/output/output_unification.rs @@ -16,7 +16,7 @@ use assert_cmd::Command; use predicates::prelude::*; use tempfile::TempDir; -mod common; +use crate::common; fn cli() -> Command { common::bca_command() diff --git a/big-code-analysis-cli/tests/vcs/main.rs b/big-code-analysis-cli/tests/vcs/main.rs new file mode 100644 index 000000000..0243427c2 --- /dev/null +++ b/big-code-analysis-cli/tests/vcs/main.rs @@ -0,0 +1,14 @@ +//! Change-history driver: `bca vcs` ranking, `--vcs-jit` risk scoring, +//! and `--vcs-trend`. +//! +//! Grouped into one binary by #1124 — see +//! `big-code-analysis-cli/tests/check/main.rs` for the rationale. The +//! ranking module is `vcs_rank` rather than `vcs` only because a module +//! named for the driver that contains it is module inception. + +#[path = "../common/mod.rs"] +mod common; + +mod vcs_jit; +mod vcs_rank; +mod vcs_trend; diff --git a/big-code-analysis-cli/tests/vcs_jit.rs b/big-code-analysis-cli/tests/vcs/vcs_jit.rs similarity index 99% rename from big-code-analysis-cli/tests/vcs_jit.rs rename to big-code-analysis-cli/tests/vcs/vcs_jit.rs index 4c6c15a65..2e4c261d0 100644 --- a/big-code-analysis-cli/tests/vcs_jit.rs +++ b/big-code-analysis-cli/tests/vcs/vcs_jit.rs @@ -12,7 +12,7 @@ use assert_cmd::Command; use predicates::prelude::*; use tempfile::TempDir; -mod common; +use crate::common; /// Build a one-commit git repo under a fresh tempdir and return it. fn one_commit_repo(message: &str) -> TempDir { diff --git a/big-code-analysis-cli/tests/vcs.rs b/big-code-analysis-cli/tests/vcs/vcs_rank.rs similarity index 99% rename from big-code-analysis-cli/tests/vcs.rs rename to big-code-analysis-cli/tests/vcs/vcs_rank.rs index 6d1503044..67fae7c9a 100644 --- a/big-code-analysis-cli/tests/vcs.rs +++ b/big-code-analysis-cli/tests/vcs/vcs_rank.rs @@ -14,7 +14,7 @@ use assert_cmd::Command; use predicates::prelude::*; use tempfile::TempDir; -mod common; +use crate::common; const DAY: i64 = 86_400; diff --git a/big-code-analysis-cli/tests/vcs_trend.rs b/big-code-analysis-cli/tests/vcs/vcs_trend.rs similarity index 99% rename from big-code-analysis-cli/tests/vcs_trend.rs rename to big-code-analysis-cli/tests/vcs/vcs_trend.rs index b0c7724e5..d1f3c5051 100644 --- a/big-code-analysis-cli/tests/vcs_trend.rs +++ b/big-code-analysis-cli/tests/vcs/vcs_trend.rs @@ -13,7 +13,7 @@ use assert_cmd::Command; use predicates::prelude::*; use tempfile::TempDir; -mod common; +use crate::common; /// Reference "now" the trend's most-recent point is pinned to via /// `--as-of`, so the sampled grid is fully reproducible. diff --git a/src/metrics/cyclomatic.rs b/src/metrics/cyclomatic.rs index 0a15a9fee..d2d637ca4 100644 --- a/src/metrics/cyclomatic.rs +++ b/src/metrics/cyclomatic.rs @@ -4594,7 +4594,7 @@ f() { /// Without the fix, this 2-arm case reports `cyclomatic_max == 3` /// (1 base + 2 arms); with the fix it reports `2` (1 base + 1 /// explicit arm), matching every other switch-bearing language - /// in `tests/cyclomatic_cross_language_parity.rs`. + /// in `tests/parity/cyclomatic_cross_language_parity.rs`. #[test] fn bash_case_bare_wildcard_excluded() { check_metrics::( diff --git a/src/node/parser_cache.rs b/src/node/parser_cache.rs index 6ffaa5fdc..a47a7009b 100644 --- a/src/node/parser_cache.rs +++ b/src/node/parser_cache.rs @@ -79,7 +79,7 @@ fn build_parser() -> Parser { } // Every test here parses Rust, so the module is gated on that grammar's -// feature the same way `tests/parser_reuse.rs` is: without it +// feature the same way `tests/api/parser_reuse.rs` is: without it // `RustCode::lang().get_ts_language()` returns `None` and the whole // module fails at `expect` rather than being skipped. CI's // `no-default-features` matrix leg only runs `cargo check`, so this was @@ -99,7 +99,7 @@ mod tests { /// The guard for the optimization itself, not for its output. /// Deleting the write-back in `parse_on_scratch_parser` leaves every - /// tree-comparison assertion in `tests/parser_reuse.rs` passing while + /// tree-comparison assertion in `tests/api/parser_reuse.rs` passing while /// reducing the cache to a no-op; only a construction count sees it. /// /// Runs on its own thread so the count starts from zero regardless of @@ -172,7 +172,7 @@ mod tests { /// The two tests above call `parse_on_scratch_parser` directly, so /// they say nothing about whether anything *reaches* it: reverting /// `Tree::new` to the pre-#1118 `Parser::new()`-per-file body leaves - /// all 3,143 lib tests and all 5 `tests/parser_reuse.rs` integration + /// all 3,143 lib tests and all 5 `tests/api/parser_reuse.rs` integration /// tests passing (measured). Driving the public `Ast::parse` seam and /// counting constructions is what fails there. #[test] diff --git a/src/vcs/bus_factor_tests.rs b/src/vcs/bus_factor_tests.rs index 4fc5127f2..c476eae41 100644 --- a/src/vcs/bus_factor_tests.rs +++ b/src/vcs/bus_factor_tests.rs @@ -1,7 +1,7 @@ //! Unit tests for the bus-factor aggregate. These exercise the pure DoA //! formula and greedy truck-factor algorithm on synthetic authorship //! distributions with *known* answers — no git repository involved (the -//! end-to-end backend path is covered by `tests/vcs_bus_factor.rs`). +//! end-to-end backend path is covered by `tests/vcs/vcs_bus_factor.rs`). use std::path::Path; diff --git a/src/vcs/git/blame_tests.rs b/src/vcs/git/blame_tests.rs index a0a4082c2..025aad60c 100644 --- a/src/vcs/git/blame_tests.rs +++ b/src/vcs/git/blame_tests.rs @@ -1,6 +1,6 @@ //! Unit tests for the pure line-span arithmetic that buckets blame //! entries into function spans. The git-backed end-to-end behaviour is -//! exercised by the integration fixture in `tests/vcs_history.rs`. +//! exercised by the integration fixture in `tests/vcs/vcs_history.rs`. use std::cell::Cell; diff --git a/src/vcs/git/identity_tests.rs b/src/vcs/git/identity_tests.rs index ba492e1f2..85e7dba5c 100644 --- a/src/vcs/git/identity_tests.rs +++ b/src/vcs/git/identity_tests.rs @@ -1,7 +1,7 @@ //! Unit tests for trailer-block-scoped co-author detection (issue #812). //! //! End-to-end participant counting (author + co-author + bot filtering) -//! is exercised against real commits in `tests/vcs_history.rs`; these +//! is exercised against real commits in `tests/vcs/vcs_history.rs`; these //! tests pin the pure trailer-block heuristic that decides which slice of //! a commit message the `Co-authored-by:` scan runs over. diff --git a/src/vcs/jit_tests.rs b/src/vcs/jit_tests.rs index b9d0356ae..7b4f5de56 100644 --- a/src/vcs/jit_tests.rs +++ b/src/vcs/jit_tests.rs @@ -2,7 +2,7 @@ //! vectors (no repository needed). Acceptance criterion #331: size, //! diffusion, and file-prior contributions are exercised here at the //! formula level; the end-to-end repo path is covered in -//! `tests/vcs_jit.rs`. +//! `tests/vcs/vcs_jit.rs`. // Exact-equality on f64 is intentional: the compared values are exact // literals (0.0) produced by the formula's own floor / zero terms. diff --git a/tests/README.md b/tests/README.md index 4e2d9f492..c7b6f9cbf 100644 --- a/tests/README.md +++ b/tests/README.md @@ -127,7 +127,7 @@ files the current `tree-sitter-*` grammar mis-parses. | Language | Submodule | Pinned at | Source files (post-exclude) | Snapshots | Grammar-bug excludes | |---|---|---|---|---|---| | C++ (DeepSpeech) | `mozilla/DeepSpeech` | `v0.10.0-alpha.3-137-gaa1d2853` | 869 (`*.cc`/`*.cpp`/`*.h`/`*.hh`) | 1047 | 7 files (→ [#83](https://github.com/dekobon/big-code-analysis/issues/83), tracked in [#86](https://github.com/dekobon/big-code-analysis/issues/86)) plus `tensorflow/**` and `kenlm/**` (vendored, ~8500+ files, no snapshot coverage) | -| JavaScript (pdf.js) | `mozilla/pdf.js` | `65c4a4b3f` | 384 (`*.js`) | 384 | **118** files (→ [#84](https://github.com/dekobon/big-code-analysis/issues/84)) — `tests/pdf_js_test.rs` is 143 lines, almost entirely this exclude list | +| JavaScript (pdf.js) | `mozilla/pdf.js` | `65c4a4b3f` | 384 (`*.js`) | 384 | **118** files (→ [#84](https://github.com/dekobon/big-code-analysis/issues/84)) — `tests/corpus/pdf_js_test.rs` is 143 lines, almost entirely this exclude list | | Rust (serde) | `serde-rs/serde` | `v1.0.159` | 172 (`*.rs`) | 172 | none — the only clean Pattern-A corpus | ### Pattern B: synthetic curated fixtures @@ -245,7 +245,7 @@ under `big-code-analysis-output`. ### A new output-format test -Mirror `tests/sarif_test.rs`: vendor the schema under +Mirror `tests/output_formats/sarif_test.rs`: vendor the schema under `tests/fixtures/`, document provenance in `tests/fixtures/README.md`, and validate every emitted document via `tests/common/validators.rs`. Keep the validator hermetic — no network access. diff --git a/tests/ast_seam_test.rs b/tests/api/ast_seam_test.rs similarity index 100% rename from tests/ast_seam_test.rs rename to tests/api/ast_seam_test.rs diff --git a/tests/book_ast_traversal_examples.rs b/tests/api/book_ast_traversal_examples.rs similarity index 99% rename from tests/book_ast_traversal_examples.rs rename to tests/api/book_ast_traversal_examples.rs index 9689fa793..9eb029de0 100644 --- a/tests/book_ast_traversal_examples.rs +++ b/tests/api/book_ast_traversal_examples.rs @@ -5,7 +5,6 @@ //! the book, mirror the change here; if a refactor breaks an example //! here, fix both. -#![cfg(feature = "rust")] #![allow(clippy::float_cmp)] use std::collections::HashMap; diff --git a/tests/book_library_examples.rs b/tests/api/book_library_examples.rs similarity index 99% rename from tests/book_library_examples.rs rename to tests/api/book_library_examples.rs index b179e9f9d..42f757251 100644 --- a/tests/book_library_examples.rs +++ b/tests/api/book_library_examples.rs @@ -7,8 +7,6 @@ //! both. (`ast-traversal.md` is pinned separately by //! `book_ast_traversal_examples.rs`.) -#![cfg(feature = "rust")] - use big_code_analysis::{FuncSpace, LANG, MetricsOptions, Source, SpaceKind, analyze}; /// `in-memory.md` — "Reading from a buffer". diff --git a/tests/derive_eq_hash_ord.rs b/tests/api/derive_eq_hash_ord.rs similarity index 100% rename from tests/derive_eq_hash_ord.rs rename to tests/api/derive_eq_hash_ord.rs diff --git a/tests/api/main.rs b/tests/api/main.rs new file mode 100644 index 000000000..f1327d7b3 --- /dev/null +++ b/tests/api/main.rs @@ -0,0 +1,29 @@ +//! Public-API integration driver: the `analyze` / `Ast` seam, the +//! book's runnable library examples, derived trait contracts, parser +//! reuse across languages, and in-source suppression markers. +//! +//! Each module below was its own `tests/*.rs` crate root until #1124. +//! One integration binary statically links the tree-sitter runtime and +//! every grammar, so thirty-one of them made linking — not compilation +//! — the tail of every incremental `cargo test`. Grouping by theme +//! keeps the crate roots readable while paying that link cost six times +//! instead of thirty-one. Test bodies are unchanged apart from two +//! mechanical edits: `mod common;` becomes `use crate::common;` where a +//! module needs the shared corpus harness, and a module that gated +//! itself with a crate-level `#![cfg]` carries the gate on its `mod` +//! declaration here instead, so this file's `//!` doc stays ungated for +//! the no-default-features and minimal-langs CI legs. + +mod ast_seam_test; +#[cfg(feature = "rust")] +mod book_ast_traversal_examples; +#[cfg(feature = "rust")] +mod book_library_examples; +mod derive_eq_hash_ord; +// The feature gate moved here from the `mod parser_reuse` wrapper the +// file used to carry: as a module it can be gated at the declaration, +// and keeping the wrapper would have nested `parser_reuse` inside +// itself. +#[cfg(all(feature = "rust", feature = "typescript"))] +mod parser_reuse; +mod suppression_test; diff --git a/tests/api/parser_reuse.rs b/tests/api/parser_reuse.rs new file mode 100644 index 000000000..b77b36006 --- /dev/null +++ b/tests/api/parser_reuse.rs @@ -0,0 +1,313 @@ +//! Integration tests for the per-thread parser reuse behind +//! `Tree::new` (#1118). +//! +//! `Tree::new` no longer builds a `tree_sitter::Parser` per file; it +//! borrows one from a thread-local slot, rebinds the grammar, parses, +//! and puts the parser back. Everything that can go wrong with that is +//! invisible in the metric values of a single file and only shows up +//! across a *sequence* of parses on one thread, so every test here +//! parses more than once and compares against a reference parser built +//! fresh for that one input: +//! +//! 1. **No stale grammar.** The slot caches the parser but not the +//! language bound to it, so `set_language` runs on every parse. +//! Alternating languages on one thread is what would catch a +//! regression that started skipping it. +//! 2. **No stale parse state.** A parser that just recovered from a +//! syntax error must produce the same tree for the next file as a +//! parser that has never been used. +//! 3. **Per-thread isolation, and trees that outlive their parser.** +//! A `tree_sitter::Tree` owns its subtrees and its own grammar +//! handle, so it must survive both the cached parser and the thread +//! that produced it. +//! 4. **No panic during thread-local teardown.** A parse issued from +//! another thread-local's destructor may find the parser slot +//! already destroyed; it must fall back to a fresh parser rather +//! than panicking, which `LocalKey::with` would. +//! +//! Language-specific tests are gated on their Cargo feature so the +//! minimal-langs CI entry (`--no-default-features --features +//! rust,typescript`) still compiles and runs. + +use std::sync::atomic::{AtomicBool, Ordering}; + +use big_code_analysis::{Ast, LANG, Source, tree_sitter}; + +const RUST_SRC: &str = r#" +fn classify(n: i32) -> &'static str { + if n < 0 { + "negative" + } else if n == 0 { + "zero" + } else { + "positive" + } +} + +struct Point { x: f64, y: f64 } + +impl Point { + fn norm(&self) -> f64 { + (self.x * self.x + self.y * self.y).sqrt() + } +} +"#; + +const TS_SRC: &str = r" +function classify(n: number): string { + if (n < 0) { + return 'negative'; + } else if (n === 0) { + return 'zero'; + } + return 'positive'; +} + +class Point { + constructor(readonly x: number, readonly y: number) {} + norm(): number { + return Math.sqrt(this.x * this.x + this.y * this.y); + } +} +"; + +/// Rust source the grammar cannot parse cleanly. Error recovery is +/// what leaves the most state behind on a parser, so this is the +/// worst thing to have parsed just before the file under test. +const BROKEN_SRC: &str = "fn oops( { let ] = ; if while }} impl for 42"; + +/// Parses `code` on a parser built for this one call, bypassing the +/// thread-local slot entirely. This is the oracle every assertion +/// below compares against — comparing two `Ast::parse` results to +/// each other would pass even if both were wrong. +fn reference_sexp(lang: LANG, code: &str) -> String { + let language = lang + .tree_sitter_language() + .expect("language feature enabled"); + let mut parser = tree_sitter::Parser::new(); + parser + .set_language(&language) + .expect("pinned grammar is compatible"); + let tree = parser + .parse(code.as_bytes(), None) + .expect("language is set, no cancellation"); + tree.root_node().to_sexp() +} + +/// Parses `code` through the public seam, which routes to the +/// thread-local parser. +fn cached_sexp(lang: LANG, code: &str) -> String { + let ast = Ast::parse(Source::new(lang, code.as_bytes())).expect("language feature enabled"); + ast.as_tree_sitter().root_node().to_sexp() +} + +/// The fixture a language is exercised with. Single source of truth: +/// the reference tree and the tree under test must come from the same +/// bytes, and selecting them at two separate sites is how they drift. +fn fixture(lang: LANG) -> &'static str { + if lang == LANG::Rust { RUST_SRC } else { TS_SRC } +} + +/// A tree is only evidence if it actually has structure in it — a +/// grammar that failed to bind would yield a tiny ERROR tree, and +/// every "identical to the reference" assertion would still hold if +/// the reference were equally broken. +fn assert_parsed_cleanly(sexp: &str, what: LANG) { + assert!( + !sexp.contains("ERROR") && !sexp.contains("MISSING"), + "{what}: fixture must parse without errors, got {sexp}" + ); + // Both fixtures define a function, and every grammar here names + // that node with a `function`-prefixed kind. A structural check + // beats a length threshold: it stays meaningful if the fixtures + // shrink, and it fails loudly if a fixture degenerates to a bare + // ERROR node whose reference would be equally broken. + assert!( + sexp.contains("function"), + "{what}: expected a function node in the tree, got {sexp}" + ); +} + +#[test] +fn cached_parser_matches_a_fresh_parser_per_language() { + for lang in [LANG::Rust, LANG::Typescript] { + let reference = reference_sexp(lang, fixture(lang)); + assert_parsed_cleanly(&reference, lang); + assert_eq!( + cached_sexp(lang, fixture(lang)), + reference, + "{lang}: cached parser must produce the reference tree" + ); + } +} + +/// The test that would catch a stale-grammar regression: if +/// `set_language` were skipped when the slot already held a parser, +/// the second language in each pair would be parsed under the first +/// language's grammar. +#[test] +fn alternating_languages_on_one_thread_stay_correct() { + let rust_reference = reference_sexp(LANG::Rust, fixture(LANG::Rust)); + let ts_reference = reference_sexp(LANG::Typescript, fixture(LANG::Typescript)); + assert_parsed_cleanly(&rust_reference, LANG::Rust); + assert_parsed_cleanly(&ts_reference, LANG::Typescript); + assert_ne!( + rust_reference, ts_reference, + "the two fixtures must be distinguishable, or alternating proves nothing" + ); + + // Several rounds: the first parse on this thread populates the + // slot, so a stale-grammar bug could only appear from the second + // parse onwards. + for round in 0..4 { + assert_eq!( + cached_sexp(LANG::Rust, RUST_SRC), + rust_reference, + "round {round}: rust after typescript" + ); + assert_eq!( + cached_sexp(LANG::Typescript, TS_SRC), + ts_reference, + "round {round}: typescript after rust" + ); + } +} + +/// The test that would catch parse state surviving between files: +/// a failed parse must not colour the next one. +#[test] +fn parse_state_does_not_survive_between_files() { + let reference = reference_sexp(LANG::Rust, fixture(LANG::Rust)); + assert_parsed_cleanly(&reference, LANG::Rust); + + // Seed the thread's parser with a parse that ends in error + // recovery, which is the state most likely to leak forward. + let broken = cached_sexp(LANG::Rust, BROKEN_SRC); + assert!( + broken.contains("ERROR"), + "the broken fixture must actually fail to parse, got {broken}" + ); + + for round in 0..4 { + assert_eq!( + cached_sexp(LANG::Rust, RUST_SRC), + reference, + "round {round}: clean parse after a failed one" + ); + // Re-dirty the parser before the next round so every + // iteration starts from recovered-from-error state. + let _ = cached_sexp(LANG::Rust, BROKEN_SRC); + } +} + +/// Each thread has its own slot, and the first parse on a thread +/// takes the build-a-parser branch while later ones take the reuse +/// branch. Both are exercised here, on threads that interleave +/// languages so no thread can rely on another's binding. +#[test] +fn threads_are_isolated_and_trees_outlive_their_thread() { + let rust_reference = reference_sexp(LANG::Rust, fixture(LANG::Rust)); + let ts_reference = reference_sexp(LANG::Typescript, fixture(LANG::Typescript)); + assert_parsed_cleanly(&rust_reference, LANG::Rust); + assert_parsed_cleanly(&ts_reference, LANG::Typescript); + + let mut handles = Vec::new(); + for id in 0..8 { + // Half the threads lead with Rust and half with TypeScript, + // so the language a thread sees first differs from its + // neighbours'. + let (first, second) = if id % 2 == 0 { + (LANG::Rust, LANG::Typescript) + } else { + (LANG::Typescript, LANG::Rust) + }; + handles.push(std::thread::spawn(move || { + let mut trees = Vec::new(); + for _ in 0..8 { + for lang in [first, second] { + let src = fixture(lang); + trees.push(( + lang, + Ast::parse(Source::new(lang, src.as_bytes())).expect("feature enabled"), + )); + } + } + // Returned across the join: every `Ast` here outlives + // both the thread's cached parser and the thread itself. + trees + })); + } + + for handle in handles { + let trees = handle.join().expect("worker thread must not panic"); + assert_eq!(trees.len(), 16); + // Read the trees only *after* the producing thread has been + // joined and torn down, so a tree that depended on its + // parser would be reading freed memory here. + for (lang, ast) in trees { + let expected = if lang == LANG::Rust { + &rust_reference + } else { + &ts_reference + }; + assert_eq!( + &ast.as_tree_sitter().root_node().to_sexp(), + expected, + "{lang}: tree must still be valid after its thread exited" + ); + } + } +} + +thread_local! { + /// Parses from its destructor, which runs during thread teardown + /// — possibly after the parser slot has already been destroyed. + static TEARDOWN_PROBE: ParseOnDrop = const { ParseOnDrop }; +} + +/// Set by `ParseOnDrop::drop`, checked after the thread is joined. +static TEARDOWN_PARSE_OK: AtomicBool = AtomicBool::new(false); + +struct ParseOnDrop; + +impl Drop for ParseOnDrop { + fn drop(&mut self) { + // Must not panic. Whether this takes the "slot already + // destroyed" fallback or still finds a live slot depends on + // the platform's thread-local destructor ordering, so the + // assertion is on the result, which holds either way. + // + // It does take the fallback on Linux/glibc: swapping the + // production `try_with` for `with` turns this test into + // `fatal runtime error: thread local panicked on drop, + // aborting` — an uncatchable SIGABRT, not a test failure. + // Recorded rather than asserted: a panic escaping a + // thread-local destructor aborts the process, which would + // report as a crashed binary instead of a named test + // failure. The check happens after `join` below. + let sexp = cached_sexp(LANG::Rust, RUST_SRC); + TEARDOWN_PARSE_OK.store(sexp.contains("function_item"), Ordering::SeqCst); + } +} + +/// A parse issued while thread-locals are being destroyed must not +/// panic, however the platform orders the destructors. +#[test] +fn parsing_during_thread_local_teardown_does_not_panic() { + std::thread::spawn(|| { + // Register the probe's destructor *before* the parser slot + // is initialised, so on a platform that runs destructors in + // reverse registration order the probe parses after the + // slot is gone. + TEARDOWN_PROBE.with(|_| ()); + let _ = cached_sexp(LANG::Rust, RUST_SRC); + }) + .join() + .expect("thread-local teardown must not panic"); + + assert!( + TEARDOWN_PARSE_OK.load(Ordering::SeqCst), + "the destructor's parse must have produced a real tree; \ + a `false` here means it ran but returned no function node" + ); +} diff --git a/tests/suppression_test.rs b/tests/api/suppression_test.rs similarity index 100% rename from tests/suppression_test.rs rename to tests/api/suppression_test.rs diff --git a/tests/common/fixtures.rs b/tests/common/fixtures.rs index 2c3d3fcef..b1d08eb57 100644 --- a/tests/common/fixtures.rs +++ b/tests/common/fixtures.rs @@ -4,7 +4,7 @@ //! that grew up around `OffenderRecord` while the output-format tests //! were being written. Used by: //! -//! - `tests/checkstyle_test.rs` (`rec`) +//! - `tests/output_formats/checkstyle_test.rs` (`rec`) //! //! Per-`mod tests` blocks inside `src/output/*.rs` carry their own //! near-identical builders. Those are intentionally not shared diff --git a/tests/common/validators.rs b/tests/common/validators.rs index da0d9ec0e..53f6b75e5 100644 --- a/tests/common/validators.rs +++ b/tests/common/validators.rs @@ -18,8 +18,8 @@ //! //! Reused across: //! -//! - `tests/sarif_test.rs` -//! - `tests/checkstyle_test.rs` +//! - `tests/output_formats/sarif_test.rs` +//! - `tests/output_formats/checkstyle_test.rs` //! //! The CLI crate has its own copy at //! `big-code-analysis-cli/tests/common/validators.rs` because Cargo @@ -74,7 +74,7 @@ pub fn validate_sarif(json_text: &str) -> Result<(), Vec> { /// Parse the vendored SARIF schema and return its top-level `$id` and /// `$schema` fields. Used by the schema-canary self-check test in -/// `tests/sarif_test.rs` to detect a refresh that vendored the wrong +/// `tests/output_formats/sarif_test.rs` to detect a refresh that vendored the wrong /// file. pub fn sarif_schema_metadata() -> (String, String) { let schema: serde_json::Value = diff --git a/tests/csharp_test.rs b/tests/corpus/csharp_test.rs similarity index 95% rename from tests/csharp_test.rs rename to tests/corpus/csharp_test.rs index d199e19f9..d2a4f31d0 100644 --- a/tests/csharp_test.rs +++ b/tests/corpus/csharp_test.rs @@ -1,5 +1,5 @@ #![allow(missing_docs)] -mod common; +use crate::common; use std::path::Path; diff --git a/tests/deepspeech_test.rs b/tests/corpus/deepspeech_test.rs similarity index 98% rename from tests/deepspeech_test.rs rename to tests/corpus/deepspeech_test.rs index 254e3a9d3..d36d9ce52 100644 --- a/tests/deepspeech_test.rs +++ b/tests/corpus/deepspeech_test.rs @@ -1,5 +1,5 @@ #![allow(missing_docs)] -mod common; +use crate::common; use common::compare_rca_output_with_files; diff --git a/tests/irules_test.rs b/tests/corpus/irules_test.rs similarity index 100% rename from tests/irules_test.rs rename to tests/corpus/irules_test.rs diff --git a/tests/corpus/main.rs b/tests/corpus/main.rs new file mode 100644 index 000000000..757be427b --- /dev/null +++ b/tests/corpus/main.rs @@ -0,0 +1,18 @@ +//! Real-world corpus driver: every suite that walks a checked-out +//! repository under `tests/repositories/` and compares each file's +//! metrics against the snapshots in the `big-code-analysis-output` +//! submodule. +//! +//! Grouped into one binary by #1124 — see `tests/api/main.rs` for the +//! rationale. These six modules held one `#[test]` each and linked a +//! ~280 MB binary apiece to run it. + +#[path = "../common/mod.rs"] +mod common; + +mod csharp_test; +mod deepspeech_test; +mod irules_test; +mod pdf_js_test; +mod php_test; +mod serde_test; diff --git a/tests/pdf_js_test.rs b/tests/corpus/pdf_js_test.rs similarity index 99% rename from tests/pdf_js_test.rs rename to tests/corpus/pdf_js_test.rs index fdc8fd13c..dba5b726f 100644 --- a/tests/pdf_js_test.rs +++ b/tests/corpus/pdf_js_test.rs @@ -10,7 +10,7 @@ clippy::too_many_lines )] -mod common; +use crate::common; use common::compare_rca_output_with_files; diff --git a/tests/php_test.rs b/tests/corpus/php_test.rs similarity index 95% rename from tests/php_test.rs rename to tests/corpus/php_test.rs index 392f89f15..c5c50d515 100644 --- a/tests/php_test.rs +++ b/tests/corpus/php_test.rs @@ -1,5 +1,5 @@ #![allow(missing_docs)] -mod common; +use crate::common; use std::path::Path; diff --git a/tests/serde_test.rs b/tests/corpus/serde_test.rs similarity index 89% rename from tests/serde_test.rs rename to tests/corpus/serde_test.rs index 5671ba416..896d3725d 100644 --- a/tests/serde_test.rs +++ b/tests/corpus/serde_test.rs @@ -1,5 +1,5 @@ #![allow(missing_docs)] -mod common; +use crate::common; use common::compare_rca_output_with_files; diff --git a/tests/fixtures/README.md b/tests/fixtures/README.md index 4648bb450..e2d15fff0 100644 --- a/tests/fixtures/README.md +++ b/tests/fixtures/README.md @@ -8,7 +8,7 @@ test time — so the suite stays offline and reproducible. ### `sarif-2.1.0.json` -The SARIF 2.1.0 JSON Schema (Draft-07). Used by `tests/sarif_test.rs` +The SARIF 2.1.0 JSON Schema (Draft-07). Used by `tests/output_formats/sarif_test.rs` via `include_str!` and the `jsonschema` crate to validate that every emitted SARIF document conforms to the spec. @@ -24,7 +24,7 @@ emitted SARIF document conforms to the spec. ### `checkstyle-report-1.0.0.xsd` The Checkstyle 4.3 XML output schema. Vendored as **documentation -only** — `tests/checkstyle_test.rs` mirrors the constraints in a +only** — `tests/output_formats/checkstyle_test.rs` mirrors the constraints in a `quick-xml`-driven structural walker rather than running an XSD validator (no mature pure-Rust XSD validator exists; using `libxml` would impose a `libxml2-dev` system dependency on every dev/CI box). diff --git a/tests/alterator_string_flattening.rs b/tests/grammars/alterator_string_flattening.rs similarity index 100% rename from tests/alterator_string_flattening.rs rename to tests/grammars/alterator_string_flattening.rs diff --git a/tests/c_grammar_metrics.rs b/tests/grammars/c_grammar_metrics.rs similarity index 100% rename from tests/c_grammar_metrics.rs rename to tests/grammars/c_grammar_metrics.rs diff --git a/tests/grammars/main.rs b/tests/grammars/main.rs new file mode 100644 index 000000000..e0c4f9ca5 --- /dev/null +++ b/tests/grammars/main.rs @@ -0,0 +1,9 @@ +//! Per-grammar metric driver: suites that pin one tree-sitter grammar's +//! parse trees against the metric impls written for it, plus the +//! alterator's string-flattening rules. +//! +//! Grouped into one binary by #1124 — see `tests/api/main.rs`. + +mod alterator_string_flattening; +mod c_grammar_metrics; +mod mozcpp_grammar_metrics; diff --git a/tests/mozcpp_grammar_metrics.rs b/tests/grammars/mozcpp_grammar_metrics.rs similarity index 99% rename from tests/mozcpp_grammar_metrics.rs rename to tests/grammars/mozcpp_grammar_metrics.rs index a0197b41b..33184b0b0 100644 --- a/tests/mozcpp_grammar_metrics.rs +++ b/tests/grammars/mozcpp_grammar_metrics.rs @@ -103,7 +103,7 @@ mod mozcpp_metrics { /// On non-Gecko C++, Mozcpp and the upstream-backed `Cpp` grammar must /// agree — the overlay only adds rules, it does not change how - /// ordinary C++ is measured. (Complements `tests/cpp_mozcpp_parity.rs`, + /// ordinary C++ is measured. (Complements `tests/parity/cpp_mozcpp_parity.rs`, /// here via a class so the `npm`/`npa`/`wmc` class arms are part of the /// comparison.) #[test] diff --git a/tests/checkstyle_test.rs b/tests/output_formats/checkstyle_test.rs similarity index 99% rename from tests/checkstyle_test.rs rename to tests/output_formats/checkstyle_test.rs index 6859a0d5f..8b8827e5f 100644 --- a/tests/checkstyle_test.rs +++ b/tests/output_formats/checkstyle_test.rs @@ -9,7 +9,7 @@ use big_code_analysis::{OffenderRecord, Severity, write_checkstyle}; -mod common; +use crate::common; use common::fixtures::rec; use common::validators::assert_checkstyle_well_formed_and_structural; diff --git a/tests/csv_test.rs b/tests/output_formats/csv_test.rs similarity index 100% rename from tests/csv_test.rs rename to tests/output_formats/csv_test.rs diff --git a/tests/output_formats/main.rs b/tests/output_formats/main.rs new file mode 100644 index 000000000..99b082b73 --- /dev/null +++ b/tests/output_formats/main.rs @@ -0,0 +1,18 @@ +//! Structured-output driver: the CSV, SARIF, and Checkstyle +//! serializers. +//! +//! Grouped into one binary by #1124 — see `tests/api/main.rs`. The +//! `insta` snapshots these modules own moved with them, from +//! `tests/snapshots/` to `tests/output_formats/snapshots/`: insta +//! resolves the snapshot directory from the asserting file's own +//! location. Their names are unchanged, because insta keys the +//! `__.snap` prefix on the *last* component of +//! `module_path!()` — still `csv_test` / `sarif_test` now that they are +//! modules rather than crate roots. + +#[path = "../common/mod.rs"] +mod common; + +mod checkstyle_test; +mod csv_test; +mod sarif_test; diff --git a/tests/sarif_test.rs b/tests/output_formats/sarif_test.rs similarity index 99% rename from tests/sarif_test.rs rename to tests/output_formats/sarif_test.rs index 2b2bb7115..cd158d445 100644 --- a/tests/sarif_test.rs +++ b/tests/output_formats/sarif_test.rs @@ -9,7 +9,7 @@ use std::path::PathBuf; use big_code_analysis::{OffenderRecord, Severity, write_sarif}; -mod common; +use crate::common; use common::validators::{sarif_schema_metadata, validate_sarif}; fn assert_valid_sarif(out: &str) { diff --git a/tests/snapshots/csv_test__csv_cpp_widget.snap b/tests/output_formats/snapshots/output_formats__csv_test__csv_cpp_widget.snap similarity index 99% rename from tests/snapshots/csv_test__csv_cpp_widget.snap rename to tests/output_formats/snapshots/output_formats__csv_test__csv_cpp_widget.snap index c403e41ea..fd7011b19 100644 --- a/tests/snapshots/csv_test__csv_cpp_widget.snap +++ b/tests/output_formats/snapshots/output_formats__csv_test__csv_cpp_widget.snap @@ -1,5 +1,5 @@ --- -source: tests/csv_test.rs +source: tests/output_formats/csv_test.rs expression: out --- path,space_name,space_kind,start_line,end_line,cognitive.sum,cognitive.average,cognitive.min,cognitive.max,cyclomatic.sum,cyclomatic.average,cyclomatic.min,cyclomatic.max,cyclomatic.modified.sum,cyclomatic.modified.average,cyclomatic.modified.min,cyclomatic.modified.max,halstead.unique_operators,halstead.total_operators,halstead.unique_operands,halstead.total_operands,halstead.length,halstead.estimated_program_length,halstead.purity_ratio,halstead.vocabulary,halstead.volume,halstead.difficulty,halstead.level,halstead.effort,halstead.time,halstead.bugs,loc.sloc,loc.ploc,loc.lloc,loc.cloc,loc.blank,loc.sloc_average,loc.ploc_average,loc.lloc_average,loc.cloc_average,loc.blank_average,loc.sloc_min,loc.sloc_max,loc.cloc_min,loc.cloc_max,loc.ploc_min,loc.ploc_max,loc.lloc_min,loc.lloc_max,loc.blank_min,loc.blank_max,nom.functions,nom.closures,nom.functions_average,nom.closures_average,nom.total,nom.average,nom.functions_min,nom.functions_max,nom.closures_min,nom.closures_max,nargs.function_args,nargs.closure_args,nargs.function_args_average,nargs.closure_args_average,nargs.total,nargs.average,nargs.function_args_min,nargs.function_args_max,nargs.closure_args_min,nargs.closure_args_max,nexits.sum,nexits.average,nexits.min,nexits.max,tokens.sum,tokens.average,tokens.min,tokens.max,abc.assignments,abc.branches,abc.conditions,abc.magnitude,abc.assignments_average,abc.branches_average,abc.conditions_average,abc.assignments_min,abc.assignments_max,abc.branches_min,abc.branches_max,abc.conditions_min,abc.conditions_max,wmc.class_wmc_sum,wmc.interface_wmc_sum,wmc.total,npm.class_npm_sum,npm.interface_npm_sum,npm.class_methods,npm.interface_methods,npm.class_coa,npm.interface_coa,npm.total,npm.total_methods,npm.coa,npa.class_npa_sum,npa.interface_npa_sum,npa.class_attributes,npa.interface_attributes,npa.class_cda,npa.interface_cda,npa.total,npa.total_attributes,npa.cda,mi.original,mi.sei,mi.visual_studio diff --git a/tests/snapshots/csv_test__csv_python_greeter.snap b/tests/output_formats/snapshots/output_formats__csv_test__csv_python_greeter.snap similarity index 98% rename from tests/snapshots/csv_test__csv_python_greeter.snap rename to tests/output_formats/snapshots/output_formats__csv_test__csv_python_greeter.snap index 73a1a2c6c..ef35ac278 100644 --- a/tests/snapshots/csv_test__csv_python_greeter.snap +++ b/tests/output_formats/snapshots/output_formats__csv_test__csv_python_greeter.snap @@ -1,5 +1,5 @@ --- -source: tests/csv_test.rs +source: tests/output_formats/csv_test.rs expression: out --- path,space_name,space_kind,start_line,end_line,cognitive.sum,cognitive.average,cognitive.min,cognitive.max,cyclomatic.sum,cyclomatic.average,cyclomatic.min,cyclomatic.max,cyclomatic.modified.sum,cyclomatic.modified.average,cyclomatic.modified.min,cyclomatic.modified.max,halstead.unique_operators,halstead.total_operators,halstead.unique_operands,halstead.total_operands,halstead.length,halstead.estimated_program_length,halstead.purity_ratio,halstead.vocabulary,halstead.volume,halstead.difficulty,halstead.level,halstead.effort,halstead.time,halstead.bugs,loc.sloc,loc.ploc,loc.lloc,loc.cloc,loc.blank,loc.sloc_average,loc.ploc_average,loc.lloc_average,loc.cloc_average,loc.blank_average,loc.sloc_min,loc.sloc_max,loc.cloc_min,loc.cloc_max,loc.ploc_min,loc.ploc_max,loc.lloc_min,loc.lloc_max,loc.blank_min,loc.blank_max,nom.functions,nom.closures,nom.functions_average,nom.closures_average,nom.total,nom.average,nom.functions_min,nom.functions_max,nom.closures_min,nom.closures_max,nargs.function_args,nargs.closure_args,nargs.function_args_average,nargs.closure_args_average,nargs.total,nargs.average,nargs.function_args_min,nargs.function_args_max,nargs.closure_args_min,nargs.closure_args_max,nexits.sum,nexits.average,nexits.min,nexits.max,tokens.sum,tokens.average,tokens.min,tokens.max,abc.assignments,abc.branches,abc.conditions,abc.magnitude,abc.assignments_average,abc.branches_average,abc.conditions_average,abc.assignments_min,abc.assignments_max,abc.branches_min,abc.branches_max,abc.conditions_min,abc.conditions_max,wmc.class_wmc_sum,wmc.interface_wmc_sum,wmc.total,npm.class_npm_sum,npm.interface_npm_sum,npm.class_methods,npm.interface_methods,npm.class_coa,npm.interface_coa,npm.total,npm.total_methods,npm.coa,npa.class_npa_sum,npa.interface_npa_sum,npa.class_attributes,npa.interface_attributes,npa.class_cda,npa.interface_cda,npa.total,npa.total_attributes,npa.cda,mi.original,mi.sei,mi.visual_studio diff --git a/tests/snapshots/csv_test__csv_rust_counter.snap b/tests/output_formats/snapshots/output_formats__csv_test__csv_rust_counter.snap similarity index 98% rename from tests/snapshots/csv_test__csv_rust_counter.snap rename to tests/output_formats/snapshots/output_formats__csv_test__csv_rust_counter.snap index b068f17d1..5608fbd45 100644 --- a/tests/snapshots/csv_test__csv_rust_counter.snap +++ b/tests/output_formats/snapshots/output_formats__csv_test__csv_rust_counter.snap @@ -1,5 +1,5 @@ --- -source: tests/csv_test.rs +source: tests/output_formats/csv_test.rs expression: out --- path,space_name,space_kind,start_line,end_line,cognitive.sum,cognitive.average,cognitive.min,cognitive.max,cyclomatic.sum,cyclomatic.average,cyclomatic.min,cyclomatic.max,cyclomatic.modified.sum,cyclomatic.modified.average,cyclomatic.modified.min,cyclomatic.modified.max,halstead.unique_operators,halstead.total_operators,halstead.unique_operands,halstead.total_operands,halstead.length,halstead.estimated_program_length,halstead.purity_ratio,halstead.vocabulary,halstead.volume,halstead.difficulty,halstead.level,halstead.effort,halstead.time,halstead.bugs,loc.sloc,loc.ploc,loc.lloc,loc.cloc,loc.blank,loc.sloc_average,loc.ploc_average,loc.lloc_average,loc.cloc_average,loc.blank_average,loc.sloc_min,loc.sloc_max,loc.cloc_min,loc.cloc_max,loc.ploc_min,loc.ploc_max,loc.lloc_min,loc.lloc_max,loc.blank_min,loc.blank_max,nom.functions,nom.closures,nom.functions_average,nom.closures_average,nom.total,nom.average,nom.functions_min,nom.functions_max,nom.closures_min,nom.closures_max,nargs.function_args,nargs.closure_args,nargs.function_args_average,nargs.closure_args_average,nargs.total,nargs.average,nargs.function_args_min,nargs.function_args_max,nargs.closure_args_min,nargs.closure_args_max,nexits.sum,nexits.average,nexits.min,nexits.max,tokens.sum,tokens.average,tokens.min,tokens.max,abc.assignments,abc.branches,abc.conditions,abc.magnitude,abc.assignments_average,abc.branches_average,abc.conditions_average,abc.assignments_min,abc.assignments_max,abc.branches_min,abc.branches_max,abc.conditions_min,abc.conditions_max,wmc.class_wmc_sum,wmc.interface_wmc_sum,wmc.total,npm.class_npm_sum,npm.interface_npm_sum,npm.class_methods,npm.interface_methods,npm.class_coa,npm.interface_coa,npm.total,npm.total_methods,npm.coa,npa.class_npa_sum,npa.interface_npa_sum,npa.class_attributes,npa.interface_attributes,npa.class_cda,npa.interface_cda,npa.total,npa.total_attributes,npa.cda,mi.original,mi.sei,mi.visual_studio diff --git a/tests/snapshots/sarif_test__sarif_multi_offender.snap b/tests/output_formats/snapshots/output_formats__sarif_test__sarif_multi_offender.snap similarity index 98% rename from tests/snapshots/sarif_test__sarif_multi_offender.snap rename to tests/output_formats/snapshots/output_formats__sarif_test__sarif_multi_offender.snap index 8ca46865d..4089677c5 100644 --- a/tests/snapshots/sarif_test__sarif_multi_offender.snap +++ b/tests/output_formats/snapshots/output_formats__sarif_test__sarif_multi_offender.snap @@ -1,5 +1,5 @@ --- -source: tests/sarif_test.rs +source: tests/output_formats/sarif_test.rs expression: out --- { diff --git a/tests/snapshots/sarif_test__sarif_zero_offenders.snap b/tests/output_formats/snapshots/output_formats__sarif_test__sarif_zero_offenders.snap similarity index 87% rename from tests/snapshots/sarif_test__sarif_zero_offenders.snap rename to tests/output_formats/snapshots/output_formats__sarif_test__sarif_zero_offenders.snap index b34f02e18..bf7459e05 100644 --- a/tests/snapshots/sarif_test__sarif_zero_offenders.snap +++ b/tests/output_formats/snapshots/output_formats__sarif_test__sarif_zero_offenders.snap @@ -1,5 +1,5 @@ --- -source: tests/sarif_test.rs +source: tests/output_formats/sarif_test.rs expression: out --- { diff --git a/tests/cognitive_cross_language_parity.rs b/tests/parity/cognitive_cross_language_parity.rs similarity index 99% rename from tests/cognitive_cross_language_parity.rs rename to tests/parity/cognitive_cross_language_parity.rs index ed3c8c9f5..f3e229787 100644 --- a/tests/cognitive_cross_language_parity.rs +++ b/tests/parity/cognitive_cross_language_parity.rs @@ -13,7 +13,7 @@ //! language we support — per-language snapshot suites cannot detect //! disagreement between languages. This is the cognitive-complexity //! companion to the standard-CCN parity tests in -//! `tests/cyclomatic_cross_language_parity.rs`. +//! `tests/parity/cyclomatic_cross_language_parity.rs`. //! //! The fixture is the canonical lesson-11 trigger: a function whose //! body is a single switch/match with one explicit arm plus a diff --git a/tests/cpp_mozcpp_parity.rs b/tests/parity/cpp_mozcpp_parity.rs similarity index 100% rename from tests/cpp_mozcpp_parity.rs rename to tests/parity/cpp_mozcpp_parity.rs diff --git a/tests/cyclomatic_cross_language_parity.rs b/tests/parity/cyclomatic_cross_language_parity.rs similarity index 100% rename from tests/cyclomatic_cross_language_parity.rs rename to tests/parity/cyclomatic_cross_language_parity.rs diff --git a/tests/exit_cross_language_parity.rs b/tests/parity/exit_cross_language_parity.rs similarity index 99% rename from tests/exit_cross_language_parity.rs rename to tests/parity/exit_cross_language_parity.rs index c300ef0e7..88973a5c0 100644 --- a/tests/exit_cross_language_parity.rs +++ b/tests/parity/exit_cross_language_parity.rs @@ -11,7 +11,7 @@ //! logical construct must produce the same metric value across every //! language we support — per-language snapshot suites cannot detect //! disagreement between languages. This file is the exit-metric -//! companion to `tests/cyclomatic_cross_language_parity.rs`. +//! companion to `tests/parity/cyclomatic_cross_language_parity.rs`. //! //! ## Why a single `return` fixture is not enough (#945) //! diff --git a/tests/parity/main.rs b/tests/parity/main.rs new file mode 100644 index 000000000..879eb059f --- /dev/null +++ b/tests/parity/main.rs @@ -0,0 +1,12 @@ +//! Cross-language parity driver: suites that feed the same construct to +//! every language that has one and assert the metrics agree, plus the +//! Cpp/Mozcpp grammar parity pin. +//! +//! Grouped into one binary by #1124 — see `tests/api/main.rs`. + +mod cognitive_cross_language_parity; +mod cpp_mozcpp_parity; +mod cyclomatic_cross_language_parity; +mod exit_cross_language_parity; +mod nargs_cross_language_parity; +mod ops_metrics_space_parity; diff --git a/tests/nargs_cross_language_parity.rs b/tests/parity/nargs_cross_language_parity.rs similarity index 98% rename from tests/nargs_cross_language_parity.rs rename to tests/parity/nargs_cross_language_parity.rs index 2df4affb2..94ff75292 100644 --- a/tests/nargs_cross_language_parity.rs +++ b/tests/parity/nargs_cross_language_parity.rs @@ -11,7 +11,7 @@ //! logical construct must produce the same metric value across every //! language we support — per-language snapshot suites cannot detect //! disagreement between languages. This file is the nargs companion -//! to `tests/cyclomatic_cross_language_parity.rs`. +//! to `tests/parity/cyclomatic_cross_language_parity.rs`. //! //! The fixture is the simplest possible: a function with exactly //! three formal parameters, body empty. Every supported language diff --git a/tests/ops_metrics_space_parity.rs b/tests/parity/ops_metrics_space_parity.rs similarity index 100% rename from tests/ops_metrics_space_parity.rs rename to tests/parity/ops_metrics_space_parity.rs diff --git a/tests/parser_reuse.rs b/tests/parser_reuse.rs deleted file mode 100644 index 5bf90da52..000000000 --- a/tests/parser_reuse.rs +++ /dev/null @@ -1,316 +0,0 @@ -//! Integration tests for the per-thread parser reuse behind -//! `Tree::new` (#1118). -//! -//! `Tree::new` no longer builds a `tree_sitter::Parser` per file; it -//! borrows one from a thread-local slot, rebinds the grammar, parses, -//! and puts the parser back. Everything that can go wrong with that is -//! invisible in the metric values of a single file and only shows up -//! across a *sequence* of parses on one thread, so every test here -//! parses more than once and compares against a reference parser built -//! fresh for that one input: -//! -//! 1. **No stale grammar.** The slot caches the parser but not the -//! language bound to it, so `set_language` runs on every parse. -//! Alternating languages on one thread is what would catch a -//! regression that started skipping it. -//! 2. **No stale parse state.** A parser that just recovered from a -//! syntax error must produce the same tree for the next file as a -//! parser that has never been used. -//! 3. **Per-thread isolation, and trees that outlive their parser.** -//! A `tree_sitter::Tree` owns its subtrees and its own grammar -//! handle, so it must survive both the cached parser and the thread -//! that produced it. -//! 4. **No panic during thread-local teardown.** A parse issued from -//! another thread-local's destructor may find the parser slot -//! already destroyed; it must fall back to a fresh parser rather -//! than panicking, which `LocalKey::with` would. -//! -//! Language-specific tests are gated on their Cargo feature so the -//! minimal-langs CI entry (`--no-default-features --features -//! rust,typescript`) still compiles and runs. - -#[cfg(all(feature = "rust", feature = "typescript"))] -mod parser_reuse { - use std::sync::atomic::{AtomicBool, Ordering}; - - use big_code_analysis::{Ast, LANG, Source, tree_sitter}; - - const RUST_SRC: &str = r#" -fn classify(n: i32) -> &'static str { - if n < 0 { - "negative" - } else if n == 0 { - "zero" - } else { - "positive" - } -} - -struct Point { x: f64, y: f64 } - -impl Point { - fn norm(&self) -> f64 { - (self.x * self.x + self.y * self.y).sqrt() - } -} -"#; - - const TS_SRC: &str = r" -function classify(n: number): string { - if (n < 0) { - return 'negative'; - } else if (n === 0) { - return 'zero'; - } - return 'positive'; -} - -class Point { - constructor(readonly x: number, readonly y: number) {} - norm(): number { - return Math.sqrt(this.x * this.x + this.y * this.y); - } -} -"; - - /// Rust source the grammar cannot parse cleanly. Error recovery is - /// what leaves the most state behind on a parser, so this is the - /// worst thing to have parsed just before the file under test. - const BROKEN_SRC: &str = "fn oops( { let ] = ; if while }} impl for 42"; - - /// Parses `code` on a parser built for this one call, bypassing the - /// thread-local slot entirely. This is the oracle every assertion - /// below compares against — comparing two `Ast::parse` results to - /// each other would pass even if both were wrong. - fn reference_sexp(lang: LANG, code: &str) -> String { - let language = lang - .tree_sitter_language() - .expect("language feature enabled"); - let mut parser = tree_sitter::Parser::new(); - parser - .set_language(&language) - .expect("pinned grammar is compatible"); - let tree = parser - .parse(code.as_bytes(), None) - .expect("language is set, no cancellation"); - tree.root_node().to_sexp() - } - - /// Parses `code` through the public seam, which routes to the - /// thread-local parser. - fn cached_sexp(lang: LANG, code: &str) -> String { - let ast = Ast::parse(Source::new(lang, code.as_bytes())).expect("language feature enabled"); - ast.as_tree_sitter().root_node().to_sexp() - } - - /// The fixture a language is exercised with. Single source of truth: - /// the reference tree and the tree under test must come from the same - /// bytes, and selecting them at two separate sites is how they drift. - fn fixture(lang: LANG) -> &'static str { - if lang == LANG::Rust { RUST_SRC } else { TS_SRC } - } - - /// A tree is only evidence if it actually has structure in it — a - /// grammar that failed to bind would yield a tiny ERROR tree, and - /// every "identical to the reference" assertion would still hold if - /// the reference were equally broken. - fn assert_parsed_cleanly(sexp: &str, what: LANG) { - assert!( - !sexp.contains("ERROR") && !sexp.contains("MISSING"), - "{what}: fixture must parse without errors, got {sexp}" - ); - // Both fixtures define a function, and every grammar here names - // that node with a `function`-prefixed kind. A structural check - // beats a length threshold: it stays meaningful if the fixtures - // shrink, and it fails loudly if a fixture degenerates to a bare - // ERROR node whose reference would be equally broken. - assert!( - sexp.contains("function"), - "{what}: expected a function node in the tree, got {sexp}" - ); - } - - #[test] - fn cached_parser_matches_a_fresh_parser_per_language() { - for lang in [LANG::Rust, LANG::Typescript] { - let reference = reference_sexp(lang, fixture(lang)); - assert_parsed_cleanly(&reference, lang); - assert_eq!( - cached_sexp(lang, fixture(lang)), - reference, - "{lang}: cached parser must produce the reference tree" - ); - } - } - - /// The test that would catch a stale-grammar regression: if - /// `set_language` were skipped when the slot already held a parser, - /// the second language in each pair would be parsed under the first - /// language's grammar. - #[test] - fn alternating_languages_on_one_thread_stay_correct() { - let rust_reference = reference_sexp(LANG::Rust, fixture(LANG::Rust)); - let ts_reference = reference_sexp(LANG::Typescript, fixture(LANG::Typescript)); - assert_parsed_cleanly(&rust_reference, LANG::Rust); - assert_parsed_cleanly(&ts_reference, LANG::Typescript); - assert_ne!( - rust_reference, ts_reference, - "the two fixtures must be distinguishable, or alternating proves nothing" - ); - - // Several rounds: the first parse on this thread populates the - // slot, so a stale-grammar bug could only appear from the second - // parse onwards. - for round in 0..4 { - assert_eq!( - cached_sexp(LANG::Rust, RUST_SRC), - rust_reference, - "round {round}: rust after typescript" - ); - assert_eq!( - cached_sexp(LANG::Typescript, TS_SRC), - ts_reference, - "round {round}: typescript after rust" - ); - } - } - - /// The test that would catch parse state surviving between files: - /// a failed parse must not colour the next one. - #[test] - fn parse_state_does_not_survive_between_files() { - let reference = reference_sexp(LANG::Rust, fixture(LANG::Rust)); - assert_parsed_cleanly(&reference, LANG::Rust); - - // Seed the thread's parser with a parse that ends in error - // recovery, which is the state most likely to leak forward. - let broken = cached_sexp(LANG::Rust, BROKEN_SRC); - assert!( - broken.contains("ERROR"), - "the broken fixture must actually fail to parse, got {broken}" - ); - - for round in 0..4 { - assert_eq!( - cached_sexp(LANG::Rust, RUST_SRC), - reference, - "round {round}: clean parse after a failed one" - ); - // Re-dirty the parser before the next round so every - // iteration starts from recovered-from-error state. - let _ = cached_sexp(LANG::Rust, BROKEN_SRC); - } - } - - /// Each thread has its own slot, and the first parse on a thread - /// takes the build-a-parser branch while later ones take the reuse - /// branch. Both are exercised here, on threads that interleave - /// languages so no thread can rely on another's binding. - #[test] - fn threads_are_isolated_and_trees_outlive_their_thread() { - let rust_reference = reference_sexp(LANG::Rust, fixture(LANG::Rust)); - let ts_reference = reference_sexp(LANG::Typescript, fixture(LANG::Typescript)); - assert_parsed_cleanly(&rust_reference, LANG::Rust); - assert_parsed_cleanly(&ts_reference, LANG::Typescript); - - let mut handles = Vec::new(); - for id in 0..8 { - // Half the threads lead with Rust and half with TypeScript, - // so the language a thread sees first differs from its - // neighbours'. - let (first, second) = if id % 2 == 0 { - (LANG::Rust, LANG::Typescript) - } else { - (LANG::Typescript, LANG::Rust) - }; - handles.push(std::thread::spawn(move || { - let mut trees = Vec::new(); - for _ in 0..8 { - for lang in [first, second] { - let src = fixture(lang); - trees.push(( - lang, - Ast::parse(Source::new(lang, src.as_bytes())).expect("feature enabled"), - )); - } - } - // Returned across the join: every `Ast` here outlives - // both the thread's cached parser and the thread itself. - trees - })); - } - - for handle in handles { - let trees = handle.join().expect("worker thread must not panic"); - assert_eq!(trees.len(), 16); - // Read the trees only *after* the producing thread has been - // joined and torn down, so a tree that depended on its - // parser would be reading freed memory here. - for (lang, ast) in trees { - let expected = if lang == LANG::Rust { - &rust_reference - } else { - &ts_reference - }; - assert_eq!( - &ast.as_tree_sitter().root_node().to_sexp(), - expected, - "{lang}: tree must still be valid after its thread exited" - ); - } - } - } - - thread_local! { - /// Parses from its destructor, which runs during thread teardown - /// — possibly after the parser slot has already been destroyed. - static TEARDOWN_PROBE: ParseOnDrop = const { ParseOnDrop }; - } - - /// Set by `ParseOnDrop::drop`, checked after the thread is joined. - static TEARDOWN_PARSE_OK: AtomicBool = AtomicBool::new(false); - - struct ParseOnDrop; - - impl Drop for ParseOnDrop { - fn drop(&mut self) { - // Must not panic. Whether this takes the "slot already - // destroyed" fallback or still finds a live slot depends on - // the platform's thread-local destructor ordering, so the - // assertion is on the result, which holds either way. - // - // It does take the fallback on Linux/glibc: swapping the - // production `try_with` for `with` turns this test into - // `fatal runtime error: thread local panicked on drop, - // aborting` — an uncatchable SIGABRT, not a test failure. - // Recorded rather than asserted: a panic escaping a - // thread-local destructor aborts the process, which would - // report as a crashed binary instead of a named test - // failure. The check happens after `join` below. - let sexp = cached_sexp(LANG::Rust, RUST_SRC); - TEARDOWN_PARSE_OK.store(sexp.contains("function_item"), Ordering::SeqCst); - } - } - - /// A parse issued while thread-locals are being destroyed must not - /// panic, however the platform orders the destructors. - #[test] - fn parsing_during_thread_local_teardown_does_not_panic() { - std::thread::spawn(|| { - // Register the probe's destructor *before* the parser slot - // is initialised, so on a platform that runs destructors in - // reverse registration order the probe parses after the - // slot is gone. - TEARDOWN_PROBE.with(|_| ()); - let _ = cached_sexp(LANG::Rust, RUST_SRC); - }) - .join() - .expect("thread-local teardown must not panic"); - - assert!( - TEARDOWN_PARSE_OK.load(Ordering::SeqCst), - "the destructor's parse must have produced a real tree; \ - a `false` here means it ran but returned no function node" - ); - } -} diff --git a/tests/vcs/main.rs b/tests/vcs/main.rs new file mode 100644 index 000000000..df3ed56ce --- /dev/null +++ b/tests/vcs/main.rs @@ -0,0 +1,29 @@ +//! Change-history (VCS) driver: bus factor, churn history, JIT risk, +//! per-function attribution, trend, file-type routing, and the +//! persistent cache. +//! +//! Grouped into one binary by #1124 — see `tests/api/main.rs`. Every +//! module here needs the `vcs-git` backend, so each carries the feature +//! gate on its `mod` declaration rather than as a crate-level +//! `#![cfg]`: this file's `//!` doc must stay ungated so the +//! no-default-features and minimal-langs CI legs do not see an +//! undocumented empty crate root. + +#[cfg(feature = "vcs-git")] +#[path = "../common/mod.rs"] +mod common; + +#[cfg(feature = "vcs-git")] +mod vcs_bus_factor; +#[cfg(feature = "vcs-git")] +mod vcs_cache; +#[cfg(feature = "vcs-git")] +mod vcs_file_types; +#[cfg(feature = "vcs-git")] +mod vcs_history; +#[cfg(feature = "vcs-git")] +mod vcs_jit; +#[cfg(feature = "vcs-git")] +mod vcs_per_function; +#[cfg(feature = "vcs-git")] +mod vcs_trend; diff --git a/tests/vcs_bus_factor.rs b/tests/vcs/vcs_bus_factor.rs similarity index 99% rename from tests/vcs_bus_factor.rs rename to tests/vcs/vcs_bus_factor.rs index 08c97c585..2db7cf12d 100644 --- a/tests/vcs_bus_factor.rs +++ b/tests/vcs/vcs_bus_factor.rs @@ -4,11 +4,10 @@ //! walk pins `as_of` to [`vcs_fixture::FIXED_NOW`], so the `DoA` inputs and //! the resulting bus factors are exact and reproducible. Gated behind the //! `vcs-git` backend feature. -#![cfg(feature = "vcs-git")] use big_code_analysis::vcs::{self, Options, build_history_index}; -mod common; +use crate::common; use common::vcs_fixture::{DAY, FIXED_NOW, Repo}; /// Options pinned to the fixture clock with the bus-factor aggregate on. diff --git a/tests/vcs_cache.rs b/tests/vcs/vcs_cache.rs similarity index 99% rename from tests/vcs_cache.rs rename to tests/vcs/vcs_cache.rs index de7a988d0..c5bbc7197 100644 --- a/tests/vcs_cache.rs +++ b/tests/vcs/vcs_cache.rs @@ -7,7 +7,6 @@ //! time. These tests pin that invariant against real, deterministic git //! repositories, plus the cache-file side effects (entry creation, //! supersession, clearing) the issue's acceptance criteria call for. -#![cfg(feature = "vcs-git")] // Exact-equality on the per-file `Stats` (which embed f64 signals) is the // point: a cache hit must be *bit-identical* to a fresh walk. #![allow(clippy::float_cmp)] @@ -19,7 +18,7 @@ use big_code_analysis::vcs::{ self, CacheConfig, Options, build_history_index, build_history_index_cached, cache, }; -mod common; +use crate::common; use common::vcs_fixture::{DAY, FIXED_NOW, Repo}; /// Options pinned to the fixture's reference time so windowing is stable diff --git a/tests/vcs_file_types.rs b/tests/vcs/vcs_file_types.rs similarity index 99% rename from tests/vcs_file_types.rs rename to tests/vcs/vcs_file_types.rs index 9484d3313..dc4dc9d70 100644 --- a/tests/vcs_file_types.rs +++ b/tests/vcs/vcs_file_types.rs @@ -5,7 +5,6 @@ //! Each walk is exercised against a real, deterministic git repository //! holding both source and non-source files, with `as_of` pinned to the //! fixture clock. Gated behind the `vcs-git` backend feature. -#![cfg(feature = "vcs-git")] use std::collections::BTreeSet; use std::path::{Path, PathBuf}; @@ -13,7 +12,7 @@ use std::path::{Path, PathBuf}; use big_code_analysis::get_language_for_file; use big_code_analysis::vcs::{self, FileTypeScope, Options, build_history_index}; -mod common; +use crate::common; use common::vcs_fixture::{DAY, FIXED_NOW, Repo}; /// Every tracked file the fixture repository carries: a mix of source diff --git a/tests/vcs_history.rs b/tests/vcs/vcs_history.rs similarity index 99% rename from tests/vcs_history.rs rename to tests/vcs/vcs_history.rs index ace8f2e3b..0952babee 100644 --- a/tests/vcs_history.rs +++ b/tests/vcs/vcs_history.rs @@ -5,7 +5,6 @@ //! walk pins `as_of` to [`vcs_fixture::FIXED_NOW`], so per-signal counts //! are exact and reproducible. The whole file is gated behind the //! `vcs-git` backend feature. -#![cfg(feature = "vcs-git")] // Exact-equality on f64 is intentional: the compared values are // exactly-representable literals (1.0) from exact integer ratios. #![allow(clippy::float_cmp)] @@ -17,7 +16,7 @@ use big_code_analysis::vcs::{ workdir_root, }; -mod common; +use crate::common; use common::vcs_fixture::{DAY, FIXED_NOW, Repo}; /// Options pinned to the fixture clock; tweak fields per test. diff --git a/tests/vcs_jit.rs b/tests/vcs/vcs_jit.rs similarity index 99% rename from tests/vcs_jit.rs rename to tests/vcs/vcs_jit.rs index 165b14e5e..931003be3 100644 --- a/tests/vcs_jit.rs +++ b/tests/vcs/vcs_jit.rs @@ -5,14 +5,13 @@ //! score pins `as_of` to [`vcs_fixture::FIXED_NOW`], so the per-commit //! features are exact and reproducible. Gated behind the `vcs-git` //! backend feature. -#![cfg(feature = "vcs-git")] // Exact-equality on f64 is intentional: the compared values are exact // literals (0.0) from the formula's zero terms. #![allow(clippy::float_cmp)] use big_code_analysis::vcs::{self, JitSource, Options, score_commit, score_diff}; -mod common; +use crate::common; use common::vcs_fixture::{DAY, FIXED_NOW, Repo}; /// Options pinned to the fixture clock; tweak fields per test. diff --git a/tests/vcs_per_function.rs b/tests/vcs/vcs_per_function.rs similarity index 99% rename from tests/vcs_per_function.rs rename to tests/vcs/vcs_per_function.rs index 59e2393dc..5e7f018d3 100644 --- a/tests/vcs_per_function.rs +++ b/tests/vcs/vcs_per_function.rs @@ -8,14 +8,13 @@ //! Every commit carries a fixed identity and UNIX timestamp and the //! engine pins `as_of` to [`FIXED_NOW`], so counts are exact. Gated //! behind the `vcs-git` backend feature. -#![cfg(feature = "vcs-git")] // Exact-equality on f64 is intentional: the compared ownership ratios // are exact integer fractions (1.0, 0.5) representable in binary f64. #![allow(clippy::float_cmp)] use big_code_analysis::vcs::{LineSpan, Options, PerFunctionBlame, Stats}; -mod common; +use crate::common; use common::vcs_fixture::{DAY, FIXED_NOW, Repo}; /// Options pinned to the fixture clock; tweak fields per test. diff --git a/tests/vcs_trend.rs b/tests/vcs/vcs_trend.rs similarity index 99% rename from tests/vcs_trend.rs rename to tests/vcs/vcs_trend.rs index a190a1001..c904b9ec8 100644 --- a/tests/vcs_trend.rs +++ b/tests/vcs/vcs_trend.rs @@ -5,7 +5,6 @@ //! trend pins its end anchor to [`vcs_fixture::FIXED_NOW`] via //! `Options::as_of`, so the sampled points and per-point counts are exact //! and reproducible. Gated behind the `vcs-git` backend feature. -#![cfg(feature = "vcs-git")] // Exact-equality on f64 is intentional here: the asserted scores are // compared for ordering/sign or for equality against values the same walk // produced. @@ -16,7 +15,7 @@ use std::path::{Path, PathBuf}; use big_code_analysis::vcs::{self, Options, build_trend}; use big_code_analysis::wire; -mod common; +use crate::common; use common::vcs_fixture::{DAY, FIXED_NOW, Repo}; /// Base options pinned to the fixture clock; the trend's most-recent point From c66cd872ad33e4480374d774c46e11261ce3e965 Mon Sep 17 00:00:00 2001 From: Elijah Zupancic Date: Sat, 1 Aug 2026 09:03:26 -0700 Subject: [PATCH 29/36] perf(test): compute one metric family per per-metric test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 2,335 `check_metrics` / `check_func_space` call sites under `src/metrics/` drove `metrics_inner` with `MetricsOptions::default()`, so a test asserting one family paid for thirteen — Halstead's per-node maps included. `test_support` gains `check_metrics_only` / `check_func_space_only` threading `MetricsOptions::default().with_only(...)`, plus two `macro_rules!` generators. Each per-metric `mod tests` declares its own `check_metrics` shim in place of the import it replaces, so the call sites are untouched and a test added later inherits the restriction. The handful that assert a *different* family get a wider shim of their own rather than widening the module's. Two of those were found the hard way: `python_tokens_distinct_from_ halstead` and its C++ sibling compare `tokens_sum()` against Halstead's `N1 + N2`, which reads 0 when Halstead is deselected — green, and testing nothing. The four `wmc.rs` tests reading `npm.class_nm_sum()` failed loudly; the two `tokens` ones would have shipped. Values are unchanged: no expected value moved, running every migrated test back on the full set fails 0 of 3,245, and the 1,639 whole-`Stats` snapshot assertions in these modules all match. The new `metric_selection_parity` test pins the property the migration rests on — for every metric in a selection's resolved closure, a restricted walk reproduces the full walk's per-space values — with a `with_only(&[])` run as the non-vacuity reference. Narrowing one walker gate to `contains(Npa) && contains(Npm)` fails only this test, 1 of 3,245; the existing `with_dependencies_pulls_in_*` tests cover a dropped *declared* dependency but not an undeclared coupling between gates. Single-threaded per-run minima, all-features lib test binary, under load average 30-45 on 16 cores: whole binary CPU 4.64 s -> 4.20 s, wall 5.16 s -> 4.42 s; the 2,317-test `metrics::` tranche 1.16 s -> 0.95 s CPU. #1113's 5-7x was release-mode whole-file analysis; these fixtures are a few lines each, where parse and space construction dominate. Fixes #1127 --- .claude/rules/testing.md | 2 +- src/metric_set.rs | 7 +- src/metrics/abc.rs | 10 +- src/metrics/cognitive.rs | 31 ++++-- src/metrics/cyclomatic.rs | 14 ++- src/metrics/halstead.rs | 4 +- src/metrics/loc.rs | 4 +- src/metrics/mi.rs | 8 +- src/metrics/nargs.rs | 7 +- src/metrics/nexits.rs | 7 +- src/metrics/nom.rs | 18 ++-- src/metrics/npa.rs | 5 +- src/metrics/npm.rs | 5 +- src/metrics/tokens.rs | 59 +++++++---- src/metrics/wmc.rs | 19 +++- src/spaces_tests.rs | 217 ++++++++++++++++++++++++++++++++++++++ src/test_support.rs | 150 ++++++++++++++++++++++---- tests/README.md | 13 ++- 18 files changed, 504 insertions(+), 76 deletions(-) diff --git a/.claude/rules/testing.md b/.claude/rules/testing.md index 1020d73dc..cea934126 100644 --- a/.claude/rules/testing.md +++ b/.claude/rules/testing.md @@ -227,7 +227,7 @@ they sit upstream of nearly every test in the workspace: | Path | Normalisation | | --- | --- | -| `check_metrics` → `test_support::check_func_space` | `trim_end().trim_matches('\n')`, then `push(b'\n')` | +| a module's `check_metrics` shim → `test_support::check_func_space_with` | `trim_end().trim_matches('\n')`, then `push(b'\n')` | | integration suites → `read_file_with_eol` → `normalize_line_endings` | unconditional `data.push(b'\n')` | Both guarantee a trailing newline, so **"a node ending at EOF" is diff --git a/src/metric_set.rs b/src/metric_set.rs index 3da5b2497..1533d2c73 100644 --- a/src/metric_set.rs +++ b/src/metric_set.rs @@ -159,7 +159,12 @@ impl Metric { /// Every [`Metric`] variant, in declaration order. Drives /// [`Metric::suppressible`] and any consumer that needs to iterate /// the full set without re-deriving it from `NAMES`. - const ALL: &'static [Self] = &[ + /// + /// `pub(crate)` so in-crate tests that must cover *every* metric — + /// notably `metric_selection_parity` in `src/spaces_tests.rs` — + /// enumerate this list rather than a hand-copied one that a new + /// variant would silently escape. + pub(crate) const ALL: &'static [Self] = &[ Self::Cognitive, Self::Cyclomatic, Self::Halstead, diff --git a/src/metrics/abc.rs b/src/metrics/abc.rs index 358e81072..7e56b3bfb 100644 --- a/src/metrics/abc.rs +++ b/src/metrics/abc.rs @@ -443,11 +443,19 @@ implement_metric_trait!(Abc, PreprocCode, CcommentCode); clippy::too_many_lines )] mod tests { - use crate::test_support::{check_func_space, check_metrics, metrics_verbatim}; + use crate::test_support::{ + check_func_space_only_shim, check_metrics_only_shim, metrics_verbatim, + }; use crate::traits::ParserTrait; use super::*; + check_metrics_only_shim!(check_metrics, Abc); + // Every `check_func_space` caller in this module is an ABC-versus- + // cyclomatic parity test (`abc.conditions() == cyclomatic() - 1` on + // the same space), so the func-space shim carries Cyclomatic too. + check_func_space_only_shim!(check_func_space, Abc, Cyclomatic); + // Walk the AST and return true iff any node has `kind_id == target`. // Used as a drift marker for hidden-rule kind ids: a passing // `!ast_has_kind_id(...)` assertion proves the kind is unreachable diff --git a/src/metrics/cognitive.rs b/src/metrics/cognitive.rs index fc1f581d7..0453b9c8e 100644 --- a/src/metrics/cognitive.rs +++ b/src/metrics/cognitive.rs @@ -663,10 +663,23 @@ implement_metric_trait!(Cognitive, PreprocCode, CcommentCode); clippy::too_many_lines )] mod tests { - use crate::test_support::{check_func_space, check_metrics, function_space}; + use crate::test_support::{ + check_func_space_only_shim, check_metrics_only_shim, function_space, + }; use super::*; + // Cognitive's dependency closure adds Nom, the divisor behind + // `cognitive_average`. + check_metrics_only_shim!(check_metrics, Cognitive); + check_func_space_only_shim!(check_func_space, Cognitive); + // The Python-comprehension tests (#417/#421) assert the cyclomatic + // count alongside the cognitive one, to show where the two metrics + // agree and where nesting makes them diverge. They are the only + // cross-metric assertions here, so they get their own shim rather + // than widening the module-wide selection. + check_metrics_only_shim!(check_cognitive_and_cyclomatic, Cognitive, Cyclomatic); + /// The walker must hand `is_else_if` the node's own parent at every /// AST depth. /// @@ -1187,7 +1200,7 @@ mod tests { // equivalent explicit `for`/`if` scored 3. // expected: for_in_clause +1 (nesting 0), if_clause +2 (1 base + // 1 nesting under the for) = 3 — equal to the explicit form below. - check_metrics::( + check_cognitive_and_cyclomatic::( "def f(xs): return [x for x in xs if x > 0]", "foo.py", @@ -1197,7 +1210,7 @@ mod tests { assert_eq!(metric.cyclomatic.cyclomatic_sum(), 4); }, ); - check_metrics::( + check_cognitive_and_cyclomatic::( "def g(xs): out = [] for x in xs: @@ -1219,7 +1232,7 @@ mod tests { fn python_comprehension_plain_no_filter() { // A comprehension with no `if` filter scores just the loop. // expected: for_in_clause +1 = 1. - check_metrics::( + check_cognitive_and_cyclomatic::( "def f(xs): return [x for x in xs]", "foo.py", @@ -1236,7 +1249,7 @@ mod tests { // Two `for` clauses are nested loops: the second nests under the // first, mirroring explicit nested `for` statements. // expected: for #1 +1 (nesting 0), for #2 +2 (1 base + 1 nesting) = 3. - check_metrics::( + check_cognitive_and_cyclomatic::( "def f(xs, ys): return [a for a in xs for b in ys]", "foo.py", @@ -1254,7 +1267,7 @@ mod tests { // Cognitive penalizes the nesting, so it exceeds cyclomatic here; the // two metrics legitimately diverge once filters multiply. // expected cognitive: for +1, if #1 +2, if #2 +2 = 5. - check_metrics::( + check_cognitive_and_cyclomatic::( "def f(xs): return [x for x in xs if a if b]", "foo.py", @@ -1277,7 +1290,7 @@ mod tests { "{x for x in xs if x > 0}", "(x for x in xs if x > 0)", ] { - check_metrics::( + check_cognitive_and_cyclomatic::( &format!("def f(xs):\n return {body}"), "foo.py", |metric| { @@ -9520,11 +9533,11 @@ end", // `Fn` closure) so the final `<` assertion compares the *actual* // values rather than restating constants. let chain_cog = Cell::new(-1.0); - crate::test_support::check_func_space::(chain, "chain.irule", |fs| { + check_func_space::(chain, "chain.irule", |fs| { chain_cog.set(fs.metrics.cognitive.cognitive_sum() as f64); }); let nested_cog = Cell::new(-1.0); - crate::test_support::check_func_space::(nested, "nested.irule", |fs| { + check_func_space::(nested, "nested.irule", |fs| { nested_cog.set(fs.metrics.cognitive.cognitive_sum() as f64); }); diff --git a/src/metrics/cyclomatic.rs b/src/metrics/cyclomatic.rs index 0a15a9fee..bbbc7365c 100644 --- a/src/metrics/cyclomatic.rs +++ b/src/metrics/cyclomatic.rs @@ -571,10 +571,18 @@ mod typescript; clippy::too_many_lines )] mod tests { - use crate::test_support::check_metrics; + use crate::test_support::check_metrics_only_shim; use super::*; + check_metrics_only_shim!(check_metrics, Cyclomatic); + // Two tests reconcile the cyclomatic per-function divisor against + // `nom.total()` (#512). Cyclomatic deliberately does *not* declare + // Nom as a dependency — `cyclomatic_average_per_function_without_nom_512` + // exists to pin that the divisor comes from the space kind, not from + // Nom — so those two ask for Nom explicitly. + check_metrics_only_shim!(check_cyclomatic_and_nom, Cyclomatic, Nom); + /// A `Stats::default()` that never sees an /// observation must not leak the `f64::MAX` sentinel for /// `cyclomatic_min` or `cyclomatic_modified_min`. Both getters @@ -616,7 +624,7 @@ mod tests { /// averages were two-thirds of these values (`6 / 4 == 1.5`). #[test] fn cyclomatic_average_is_per_function_512() { - check_metrics::( + check_cyclomatic_and_nom::( "class A { int f(int x) { return x > 0 ? 1 : 2; } int g(int x) { return x > 0 ? 1 : 2; } @@ -688,7 +696,7 @@ mod tests { /// to lambda space-handling is a deliberate, visible decision. #[test] fn cyclomatic_python_lambda_divisor_excludes_spaceless_closure() { - check_metrics::( + check_cyclomatic_and_nom::( "def f(x):\n return x if x > 0 else -x\ng = lambda y: y if y else 0\n", "p.py", |metric| { diff --git a/src/metrics/halstead.rs b/src/metrics/halstead.rs index bf8ed9bc1..d057db971 100644 --- a/src/metrics/halstead.rs +++ b/src/metrics/halstead.rs @@ -466,10 +466,12 @@ mod tests { use std::collections::HashSet; use std::path::PathBuf; - use crate::test_support::check_metrics; + use crate::test_support::check_metrics_only_shim; use super::*; + check_metrics_only_shim!(check_metrics, Halstead); + // Pins the lesson-4 invariant `n2 == len(dedupe(ops.operands))` by // running `operands_and_operators` (the text-keyed `--ops` store) // on the same source and comparing its deduplicated operand count diff --git a/src/metrics/loc.rs b/src/metrics/loc.rs index 27972e793..0dba3cd49 100644 --- a/src/metrics/loc.rs +++ b/src/metrics/loc.rs @@ -782,10 +782,12 @@ mod typescript; clippy::too_many_lines )] mod tests { - use crate::test_support::{check_metrics, metrics_verbatim, space_verbatim}; + use crate::test_support::{check_metrics_only_shim, metrics_verbatim, space_verbatim}; use super::*; + check_metrics_only_shim!(check_metrics, Loc); + /// A `Stats::default()` that never sees an observation must not leak /// the `usize::MAX` sentinel for any of the LOC `_min` accumulators /// (`sloc_min`, `ploc_min`, `lloc_min`, `cloc_min`, `blank_min`). diff --git a/src/metrics/mi.rs b/src/metrics/mi.rs index eaad19903..8f4cb7e6a 100644 --- a/src/metrics/mi.rs +++ b/src/metrics/mi.rs @@ -190,10 +190,16 @@ implement_metric_trait!( clippy::too_many_lines )] mod tests { - use crate::test_support::check_metrics; + use crate::test_support::check_metrics_only_shim; use super::*; + // Mi's dependency closure adds Loc + Cyclomatic + Halstead — the + // three inputs its formula consumes. No assertion here reads them + // directly; without them MI would be computed from zero-valued + // defaults. + check_metrics_only_shim!(check_metrics, Mi); + #[test] fn mi_empty_file() { check_metrics::("", "empty.py", |metric| { diff --git a/src/metrics/nargs.rs b/src/metrics/nargs.rs index 7903bb556..f06e3101a 100644 --- a/src/metrics/nargs.rs +++ b/src/metrics/nargs.rs @@ -727,10 +727,15 @@ impl NArgs for GroovyCode { clippy::too_many_lines )] mod tests { - use crate::test_support::check_metrics; + use crate::test_support::check_metrics_only_shim; use super::*; + // Nargs pulls Nom for its per-function average divisor, which is also + // what this module's `metric.nom.functions_sum()` / + // `closures_sum()` assertions read. + check_metrics_only_shim!(check_metrics, Nargs); + /// Regression for #227: a `Stats::default()` that never sees an /// observation must not leak the `usize::MAX` sentinel for /// `fn_args_min` or `closure_args_min`. Both getters collapse diff --git a/src/metrics/nexits.rs b/src/metrics/nexits.rs index 46f032536..ada18de2b 100644 --- a/src/metrics/nexits.rs +++ b/src/metrics/nexits.rs @@ -415,10 +415,15 @@ impl Exit for ElixirCode { clippy::too_many_lines )] mod tests { - use crate::test_support::check_metrics; + use crate::test_support::check_metrics_only_shim; use super::*; + // Nexits pulls Nom for its per-function average divisor, which is + // also what this module's one `metric.nom.functions_sum()` + // assertion reads. + check_metrics_only_shim!(check_metrics, Nexits); + /// A `Stats::default()` that never sees an /// observation must not leak the `usize::MAX` sentinel for /// `exit_min`. The getter collapses the sentinel to `0.0` so diff --git a/src/metrics/nom.rs b/src/metrics/nom.rs index b2dfbfcd6..c698de733 100644 --- a/src/metrics/nom.rs +++ b/src/metrics/nom.rs @@ -289,10 +289,16 @@ implement_metric_trait!( clippy::too_many_lines )] mod tests { - use crate::test_support::check_metrics; + use crate::test_support::check_metrics_only_shim; use super::*; + check_metrics_only_shim!(check_metrics, Nom); + // The C# indexer / property tests (#464, #472) assert that `nom`'s + // function count agrees with `npm`'s method count for the same + // member, so they need both families computed. + check_metrics_only_shim!(check_nom_and_npm, Nom, Npm); + /// Regression for #227: a `Stats::default()` that never sees an /// observation must not leak the `usize::MAX` sentinel for /// `functions_min` or `closures_min`. Both getters collapse the @@ -1093,7 +1099,7 @@ mod tests { // function space, triple-counting the indexer as 3 functions. The // correct count is 2 — the accessor count — matching the npm path // (`csharp_count_member`) which reports `class_methods == 2`. - check_metrics::( + check_nom_and_npm::( "class A { private int[] _d; public int this[int i] { get => _d[i]; set => _d[i] = value; } @@ -1133,7 +1139,7 @@ mod tests { // is_func_space outright would drop this to 0; the #464 fix gates // the entry on the absence of accessors so this form still counts // as 1, matching the npm `.max(1)` fallback. - check_metrics::( + check_nom_and_npm::( "class A { private int[] _d; public int this[int i] => _d[i]; @@ -1171,7 +1177,7 @@ mod tests { // must NOT open its own space on top of them, else it double-counts // (the property analogue of #464). The correct count is 2 — the // accessor count — matching the npm path which reports 2. - check_metrics::( + check_nom_and_npm::( "class A { private int _w; public int W { get => _w; set => _w = value; } @@ -1191,7 +1197,7 @@ mod tests { // An auto-property (`int Y { get; set; }`) still has two // `accessor_declaration` children, so it defers to them exactly like // a bodied property — the #472 gate must not change this count. - check_metrics::( + check_nom_and_npm::( "class A { public int Y { get; set; } }", @@ -1213,7 +1219,7 @@ mod tests { // before #472 (0 functions). The fix gates the entry on the // absence of accessors so it counts as 1, matching the npm // `.max(1)` fallback. - check_metrics::( + check_nom_and_npm::( "class A { private int _w; public int W => _w; diff --git a/src/metrics/npa.rs b/src/metrics/npa.rs index 8ff1d39b4..74783d486 100644 --- a/src/metrics/npa.rs +++ b/src/metrics/npa.rs @@ -579,11 +579,14 @@ implement_metric_trait!( )] mod tests { use crate::test_support::{ - assert_child_space_kind, check_func_space, check_metrics, child_space, + assert_child_space_kind, check_func_space_only_shim, check_metrics_only_shim, child_space, }; use super::*; + check_metrics_only_shim!(check_metrics, Npa); + check_func_space_only_shim!(check_func_space, Npa); + #[test] fn java_single_attributes() { check_metrics::( diff --git a/src/metrics/npm.rs b/src/metrics/npm.rs index 1b1268500..c5a8f9436 100644 --- a/src/metrics/npm.rs +++ b/src/metrics/npm.rs @@ -521,11 +521,14 @@ implement_metric_trait!( )] mod tests { use crate::test_support::{ - assert_child_space_kind, check_func_space, check_metrics, child_space, + assert_child_space_kind, check_func_space_only_shim, check_metrics_only_shim, child_space, }; use super::*; + check_metrics_only_shim!(check_metrics, Npm); + check_func_space_only_shim!(check_func_space, Npm); + #[test] fn java_constructors() { check_metrics::( diff --git a/src/metrics/tokens.rs b/src/metrics/tokens.rs index 7be00e752..3d37533f8 100644 --- a/src/metrics/tokens.rs +++ b/src/metrics/tokens.rs @@ -200,10 +200,17 @@ implement_metric_trait!( clippy::too_many_lines )] mod tests { - use crate::test_support::{check_metrics, metrics_verbatim}; + use crate::test_support::{check_metrics_only_shim, metrics_verbatim}; use super::*; + check_metrics_only_shim!(check_metrics, Tokens); + // `*_tokens_distinct_from_halstead` compares `tokens_sum()` against + // Halstead's `N1 + N2`. Deselecting Halstead leaves that side at 0, + // where `tokens_sum() > 0` holds for the wrong reason — so these two + // ask for Halstead rather than passing vacuously. + check_metrics_only_shim!(check_tokens_and_halstead, Tokens, Halstead); + /// `def foo(x): return x` → leaves: `def`, `foo`, `(`, `x`, `)`, /// `:`, `return`, `x` = 8 tokens, hand-counted. #[test] @@ -243,17 +250,21 @@ mod tests { /// reuse. #[test] fn python_tokens_distinct_from_halstead() { - check_metrics::("def foo(x): return (x + 1)", "foo.py", |metric| { - let halstead_total = - metric.halstead.total_operators() + metric.halstead.total_operands(); - assert!( - metric.tokens.tokens_sum() > halstead_total, - "expected tokens ({}) > halstead N1+N2 ({}); punctuation \ - like `(`, `)`, `:` should contribute to tokens but not Halstead", - metric.tokens.tokens_sum(), - halstead_total, - ); - }); + check_tokens_and_halstead::( + "def foo(x): return (x + 1)", + "foo.py", + |metric| { + let halstead_total = + metric.halstead.total_operators() + metric.halstead.total_operands(); + assert!( + metric.tokens.tokens_sum() > halstead_total, + "expected tokens ({}) > halstead N1+N2 ({}); punctuation \ + like `(`, `)`, `:` should contribute to tokens but not Halstead", + metric.tokens.tokens_sum(), + halstead_total, + ); + }, + ); } /// Inner functions get attributed to their innermost scope. For @@ -323,16 +334,20 @@ mod tests { /// `python_tokens_distinct_from_halstead`. #[test] fn cpp_tokens_distinct_from_halstead() { - check_metrics::("int foo(int x) { return (x + 1); }", "foo.cpp", |m| { - let halstead_total = m.halstead.total_operators() + m.halstead.total_operands(); - assert!( - m.tokens.tokens_sum() > halstead_total, - "expected tokens ({}) > halstead N1+N2 ({}); punctuation like \ - `(`, `)`, `{{`, `}}` and `;` should contribute to tokens but not Halstead", - m.tokens.tokens_sum(), - halstead_total, - ); - }); + check_tokens_and_halstead::( + "int foo(int x) { return (x + 1); }", + "foo.cpp", + |m| { + let halstead_total = m.halstead.total_operators() + m.halstead.total_operands(); + assert!( + m.tokens.tokens_sum() > halstead_total, + "expected tokens ({}) > halstead N1+N2 ({}); punctuation like \ + `(`, `)`, `{{`, `}}` and `;` should contribute to tokens but not Halstead", + m.tokens.tokens_sum(), + halstead_total, + ); + }, + ); } /// A C++ struct method and a free function each open their own diff --git a/src/metrics/wmc.rs b/src/metrics/wmc.rs index 02b4fa6a7..8a0158900 100644 --- a/src/metrics/wmc.rs +++ b/src/metrics/wmc.rs @@ -441,11 +441,20 @@ implement_metric_trait!( )] mod tests { use crate::test_support::{ - assert_child_space_kind, check_func_space, check_metrics, child_space, + assert_child_space_kind, check_func_space_only_shim, check_metrics_only_shim, child_space, }; use super::*; + // Wmc's dependency closure adds Cyclomatic + Nom — the per-method + // complexities it weights and the method count it needs to find them. + check_metrics_only_shim!(check_metrics, Wmc); + check_func_space_only_shim!(check_func_space, Wmc); + // The C# indexer / property tests (#464, #472) cross-check that the + // WMC accessor folding agrees with npm's method count for the same + // member, so they need Npm too. + check_metrics_only_shim!(check_wmc_and_npm, Wmc, Npm); + #[test] fn java_single_class() { check_metrics::( @@ -1548,7 +1557,7 @@ mod tests { // itself ALSO opened a method space, folding an extra entry on // top of get=1 + set=1 (`class_wmc_sum == 3`). The correct sum is // 2 — one unit of complexity per accessor — matching the npm path. - check_metrics::( + check_wmc_and_npm::( "class A { private int[] _d; public int this[int i] { get => _d[i]; set => _d[i] = value; } @@ -1571,7 +1580,7 @@ mod tests { // has no `accessor_declaration` child, so the #464 gate keeps the // IndexerDeclaration node itself opening a single method space — // it must stay at 1, not regress to 0 (mirrors npm `.max(1)`). - check_metrics::( + check_wmc_and_npm::( "class A { private int[] _d; public int this[int i] => _d[i]; @@ -1593,7 +1602,7 @@ mod tests { // enclosing class. The `property_declaration` node must NOT open an // extra method space on top of get=1 + set=1 (the property analogue // of the #464 double-count). The correct sum is 2. - check_metrics::( + check_wmc_and_npm::( "class A { private int _w; public int W { get => _w; set => _w = value; } @@ -1615,7 +1624,7 @@ mod tests { // `accessor_declaration` child, so the #472 gate lets the // PropertyDeclaration node itself open a single method space — it // must be 1, not 0 as before the fix (mirrors npm `.max(1)`). - check_metrics::( + check_wmc_and_npm::( "class A { private int _w; public int W => _w; diff --git a/src/spaces_tests.rs b/src/spaces_tests.rs index 22409aafc..60be63e3a 100644 --- a/src/spaces_tests.rs +++ b/src/spaces_tests.rs @@ -1581,6 +1581,223 @@ end } } +// --- #1127: a restricted selection must not move any value ------- +// +// The per-metric unit modules assert one family through +// `test_support::check_metrics_only`, which computes that family plus +// the dependencies `Metric::dependencies` declares — not all thirteen. +// Thousands of assertions therefore rest on one property the `with_only` +// tests above never state: for every selected metric, a restricted walk +// produces *exactly* the value the full walk does. Prove it here rather +// than inferring it from a green suite, which cannot distinguish "the +// value is unchanged" from "the value is wrong in both runs". +// +// It doubles as the guard on the gating itself. Measured, not assumed: +// narrowing one compute gate — `if selected.contains(Metric::Npa)` to +// `… && selected.contains(Metric::Npm)`, the shape a mistakenly-coupled +// metric would take — failed **only** this test out of the 3,245 in the +// lib target. Dropping a declared `Metric::dependencies` edge is +// already covered elsewhere (`with_dependencies_pulls_in_*`, +// `cognitive_only_pulls_nom_and_average_is_finite`); an *undeclared* +// coupling between the walker's per-metric gates was not. +mod metric_selection_parity { + use crate::{ + CodeMetrics, FuncSpace, LANG, Metric, MetricSet, MetricsOptions, Source, SpaceKind, analyze, + }; + use serde_json::Value; + + // Deliberately non-default for all thirteen metrics at once: public + // struct fields (npa), public inherent methods (npm, wmc, nom), a + // multi-clause `if` (cognitive, cyclomatic, abc, halstead, tokens), + // an early `return` (nexits), parameters (nargs), and a comment + // (loc). It is the fixture the non-vacuity assertion below leans on. + #[cfg(feature = "rust")] + const RUST: &str = "\ +pub struct Counter { + pub total: u32, + step: u32, +} + +impl Counter { + // Applies one step. + pub fn bump(&mut self, by: u32) -> u32 { + if by > 0 && self.step > 0 { + self.total += by * self.step; + return self.total; + } + self.total + } + + fn reset(&mut self) { + self.total = 0; + } +} + +fn choose(a: u32, b: u32) -> u32 { + let double = |x: u32| x * 2; + if a > b { double(a) } else { double(b) } +} +"; + + #[cfg(feature = "java")] + const JAVA: &str = "\ +public class Shape { + public int width; + private int height; + + public int area(int scale) { + if (scale > 0 && width > 0) { + return width * height * scale; + } + return 0; + } +} +"; + + #[cfg(feature = "python")] + const PYTHON: &str = "\ +class Bag: + def __init__(self, size): + self.size = size + + def take(self, n): + if n > self.size: + return 0 + while n > 0: + n -= 1 + return n +"; + + // The Java and Python entries widen the grammar coverage of the + // parity claim; only the Rust one is load-bearing for non-vacuity. + fn fixtures() -> Vec<(LANG, &'static str, &'static str)> { + vec![ + #[cfg(feature = "rust")] + (LANG::Rust, "counter.rs", RUST), + #[cfg(feature = "java")] + (LANG::Java, "Shape.java", JAVA), + #[cfg(feature = "python")] + (LANG::Python, "bag.py", PYTHON), + ] + } + + fn analyse(lang: LANG, filename: &str, source: &str, options: MetricsOptions) -> FuncSpace { + analyze( + Source::new(lang, source.as_bytes()).with_name(Some(filename.to_owned())), + options, + ) + .expect("analyze must yield a top-level space") + } + + fn analyse_only(lang: LANG, filename: &str, source: &str, metrics: &[Metric]) -> FuncSpace { + analyse( + lang, + filename, + source, + MetricsOptions::default().with_only(metrics), + ) + } + + // Serialization is the comparison vehicle because the per-metric + // `Stats` types implement `Serialize` but not `PartialEq`; it also + // compares every public field of each family rather than the + // handful an accessor exposes. + fn metric_json(metrics: &CodeMetrics, metric: Metric) -> Value { + // `Metric` is `#[non_exhaustive]`, which is inert in-crate, so + // this match is exhaustive without a wildcard on purpose: a new + // variant must be given an arm here or the parity claim would + // silently stop covering it. + let value = match metric { + Metric::Cognitive => serde_json::to_value(&metrics.cognitive), + Metric::Cyclomatic => serde_json::to_value(&metrics.cyclomatic), + Metric::Halstead => serde_json::to_value(&metrics.halstead), + Metric::Loc => serde_json::to_value(&metrics.loc), + Metric::Nom => serde_json::to_value(&metrics.nom), + Metric::Tokens => serde_json::to_value(&metrics.tokens), + Metric::Nargs => serde_json::to_value(&metrics.nargs), + Metric::Nexits => serde_json::to_value(&metrics.nexits), + Metric::Abc => serde_json::to_value(&metrics.abc), + Metric::Npm => serde_json::to_value(&metrics.npm), + Metric::Npa => serde_json::to_value(&metrics.npa), + Metric::Mi => serde_json::to_value(&metrics.mi), + Metric::Wmc => serde_json::to_value(&metrics.wmc), + }; + value.expect("every metric Stats serializes cleanly") + } + + // Keyed by (name, kind) so a shape divergence surfaces as a + // mismatched row rather than a silently misaligned comparison. + fn collect(space: &FuncSpace, metric: Metric) -> Vec<(Option, SpaceKind, Value)> { + let mut out = Vec::new(); + let mut stack = vec![space]; + while let Some(current) = stack.pop() { + out.push(( + current.name.clone(), + current.kind, + metric_json(¤t.metrics, metric), + )); + stack.extend(current.spaces.iter()); + } + out + } + + #[test] + // Gated on the language whose fixture makes the non-vacuity + // assertion satisfiable for all thirteen metrics. + #[cfg(feature = "rust")] + fn restricted_selection_reproduces_the_full_run() { + let fixtures = fixtures(); + crate::test_support::assert_fixtures_present(&fixtures); + + // A metric is "exercised" once some fixture gives it a value + // distinguishable from the uncomputed one. `with_only(&[])` + // computes nothing, so its output *is* the `Stats` default — + // which makes it the reference for "this parity assertion is + // comparing something". Without it, a metric that happened to + // stay at its default in every fixture would compare equal for + // the one reason that proves nothing. + let mut exercised: Vec = Vec::new(); + + for (lang, filename, source) in fixtures { + let full = analyse(lang, filename, source, MetricsOptions::default()); + let uncomputed = analyse_only(lang, filename, source, &[]); + + for &selected in Metric::ALL { + let pruned = analyse_only(lang, filename, source, &[selected]); + // Check every metric the selection *resolves* to, not + // just the one asked for. Roughly thirty migrated + // assertions read a dependency-pulled family rather than + // their module's own — `nargs.rs` asserts + // `metric.nom.functions_sum()` under `{Nargs, Nom}`, and + // `mi.rs` runs under `{Mi, Loc, Cyclomatic, Halstead}`. + // Comparing `Nom` only under `with_only(&[Nom])` would + // leave exactly those out. + let resolved = MetricSet::from_slice_with_deps(&[selected]); + for &metric in Metric::ALL.iter().filter(|&&m| resolved.contains(m)) { + let full_rows = collect(&full, metric); + assert_eq!( + full_rows, + collect(&pruned, metric), + "{lang:?}: with_only(&[{selected}]) must reproduce the full run's \ + {metric} values in every space" + ); + if full_rows != collect(&uncomputed, metric) { + exercised.push(metric); + } + } + } + } + + for &metric in Metric::ALL { + assert!( + exercised.contains(&metric), + "no fixture gives {metric} a non-default value, so its parity \ + assertion compares two uncomputed defaults" + ); + } + } +} + // Gated on `rust`: the happy-path tests parse a `.rs` fixture, so they // bind a live `Ast` — which is uninhabited when no grammar feature is // compiled in (`--no-default-features`), making the binding's tail diff --git a/src/test_support.rs b/src/test_support.rs index dde10563e..695174130 100644 --- a/src/test_support.rs +++ b/src/test_support.rs @@ -14,19 +14,20 @@ use crate::node::{Node, Tree}; use crate::spaces::metrics_inner; use crate::traits::LanguageInfo; use crate::{ - CodeMetrics, FuncSpace, LANG, MetricsOptions, ParserTrait, Source, SpaceKind, analyze, + CodeMetrics, FuncSpace, LANG, Metric, MetricsOptions, ParserTrait, Source, SpaceKind, analyze, }; -/// Parses `source` as `filename` and hands the resulting root -/// [`FuncSpace`] to `check`. +/// Parses `source` as `filename` under `options` and hands the resulting +/// root [`FuncSpace`] to `check`. /// /// The source is normalised the way [`crate::read_file_with_eol`] would /// normalise it on the way in: CRLF/CR collapse to LF, and the trailing /// newline is regularised to exactly one. Use [`metrics_verbatim`] when a /// test's input must reach the parser untouched. -pub(crate) fn check_func_space( +fn check_func_space_with( source: &str, filename: &str, + options: MetricsOptions, check: F, ) { let path = PathBuf::from(filename); @@ -34,33 +35,142 @@ pub(crate) fn check_func_space( let mut trimmed_bytes = normalized.trim_end().trim_matches('\n').as_bytes().to_vec(); trimmed_bytes.push(b'\n'); let parser = T::new(trimmed_bytes, &path, None); - let func_space = metrics_inner( - &parser, - path.to_str().map(str::to_owned), - MetricsOptions::default(), - ) - .expect("metrics_inner returns Some for a parsed source"); + let func_space = metrics_inner(&parser, path.to_str().map(str::to_owned), options) + .expect("metrics_inner returns Some for a parsed source"); check(func_space); } +/// Parses `source` as `filename` with **every** metric selected and hands +/// the resulting root [`FuncSpace`] to `check`. +/// +/// Prefer [`check_func_space_only`] whenever the assertions name a known, +/// bounded set of metrics: computing all thirteen families to inspect one +/// is what made the unit suite pay for ~15 walks per assertion (#1127). +/// This full-set variant remains for tests whose subject *is* the whole +/// surface. +pub(crate) fn check_func_space( + source: &str, + filename: &str, + check: F, +) { + check_func_space_with::(source, filename, MetricsOptions::default(), check); +} + +/// [`check_func_space`], restricted to `metrics` and the dependencies +/// [`MetricsOptions::with_only`] resolves for them. +/// +/// The space *tree* is metric-independent — `is_func_space` and +/// `get_space_kind` run regardless of the selection — so structural +/// assertions (`assert_child_space_kind`, `child_space`, nesting) hold +/// identically under either variant. +pub(crate) fn check_func_space_only( + source: &str, + filename: &str, + metrics: &[Metric], + check: F, +) { + check_func_space_with::( + source, + filename, + MetricsOptions::default().with_only(metrics), + check, + ); +} + /// Parses `source` as `filename` and hands the root space's -/// [`CodeMetrics`] to `check`. -pub(crate) fn check_metrics(source: &str, filename: &str, check: fn(CodeMetrics)) { - check_func_space::(source, filename, |func_space| { +/// [`CodeMetrics`] to `check`, computing only `metrics` and the +/// dependencies [`MetricsOptions::with_only`] resolves for them. +/// +/// There is deliberately no full-set counterpart: every caller is a +/// per-metric test module asserting one family, and the removed variant +/// is what made each of those ~2 300 assertions pay for thirteen metric +/// walks (#1127). Reach for [`check_func_space`] if a test's subject +/// really is the whole surface. +/// +/// Values are identical to the full-set run for every selected metric — +/// `metric_selection_parity` in `src/spaces_tests.rs` pins that across +/// each metric and a multi-language fixture set, so a migrated test +/// asserting the same numbers is asserting the same thing. +pub(crate) fn check_metrics_only( + source: &str, + filename: &str, + metrics: &[Metric], + check: fn(CodeMetrics), +) { + check_func_space_only::(source, filename, metrics, |func_space| { check(func_space.metrics.clone()); }); } +/// Defines a module-local `check_metrics`-shaped shim bound to a fixed +/// metric list. +/// +/// Every per-metric module under `src/metrics/` asserts one family +/// across hundreds of call sites. Rather than repeat the metric list at +/// each one, each module invokes this once at the top of its `mod tests` +/// — in place of the `use crate::test_support::check_metrics;` it +/// replaces, so the shim is the only `check_metrics` in scope there and +/// there is nothing to confuse it with. +/// +/// Emits `fn $name(source, filename, check)`, delegating +/// to [`check_metrics_only`]: +/// +/// ```ignore +/// check_metrics_only_shim!(check_metrics, Abc); +/// check_metrics_only_shim!(check_cognitive_and_cyclomatic, Cognitive, Cyclomatic); +/// ``` +macro_rules! check_metrics_only_shim { + ($name:ident, $($metric:ident),+ $(,)?) => { + fn $name( + source: &str, + filename: &str, + check: fn($crate::CodeMetrics), + ) { + $crate::test_support::check_metrics_only::( + source, + filename, + &[$($crate::Metric::$metric),+], + check, + ); + } + }; +} + +/// [`check_metrics_only_shim`]'s `FuncSpace` counterpart: emits +/// `fn $name(source, filename, check)` +/// delegating to [`check_func_space_only`]. +macro_rules! check_func_space_only_shim { + ($name:ident, $($metric:ident),+ $(,)?) => { + fn $name( + source: &str, + filename: &str, + check: F, + ) { + $crate::test_support::check_func_space_only::( + source, + filename, + &[$($crate::Metric::$metric),+], + check, + ); + } + }; +} + +pub(crate) use {check_func_space_only_shim, check_metrics_only_shim}; + /// Analyses `source` **byte-for-byte** and returns its metrics. /// -/// Use this, not [`check_metrics`], when a test's input must reach the -/// parser unaltered: `check_func_space` normalises CRLF and trims then -/// re-appends a trailing newline, so a construct ending at EOF is -/// unreachable through it and such a test passes vacuously (#1051). It -/// also returns a value, which `check_metrics`' bare `fn` callback -/// cannot. Restrict `options` in timing-sensitive tests so an unrelated -/// metric's cost cannot dominate and misattribute a regression. +/// Use this, not [`check_metrics_only`], when a test's input must reach +/// the parser unaltered: [`check_func_space_with`] normalises CRLF and +/// trims then re-appends a trailing newline, so a construct ending at +/// EOF is unreachable through it and such a test passes vacuously +/// (#1051). It also returns a value, which the `check_metrics`-shaped +/// helpers' bare `fn` callback cannot. `options` is caller-supplied +/// rather than fixed; prefer restricting it, as the per-metric modules +/// do (#1127). Most existing callers still pass +/// `MetricsOptions::default()` — that is a leftover, not a pattern to +/// copy. #[track_caller] pub(crate) fn metrics_verbatim(lang: LANG, source: &[u8], options: MetricsOptions) -> CodeMetrics { // `FuncSpace` has an iterative `Drop` impl (#1056), so the field diff --git a/tests/README.md b/tests/README.md index 4e2d9f492..c74c55e65 100644 --- a/tests/README.md +++ b/tests/README.md @@ -211,7 +211,18 @@ C++/JS/Rust. ### A per-metric unit test Add to `src/metrics/.rs#[cfg(test)] mod tests`. Function name -is `_`. Use `check_metrics::`. +is `_`. Use `check_metrics::` +— the module-local shim each `mod tests` declares with +`check_metrics_only_shim!`, which computes that module's metric plus +the dependencies `Metric::dependencies` resolves, not all thirteen +families ([#1127](https://github.com/dekobon/big-code-analysis/issues/1127)). + +If the assertion also reads a *different* metric family, do not widen +that shim: declare a second one next to it +(`check_metrics_only_shim!(check_nom_and_npm, Nom, Npm);`) and call it +from those tests alone. Asserting a deselected family is the failure +mode to watch for — it reads as zero, so a comparison like +`tokens_sum() > halstead_total` keeps passing while testing nothing. Every `insta::assert_json_snapshot!` call must be **anchored** — see `AGENTS.md` "snapshot-anchor policy". Either inline the expected block, From 6de2a18f151f1e6112f99d5fe6c29ef916d97330 Mon Sep 17 00:00:00 2001 From: Elijah Zupancic Date: Sat, 1 Aug 2026 09:05:19 -0700 Subject: [PATCH 30/36] test(cli): serve the shared CLI fixtures from one directory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `check_thresholds`, `check_baseline` and `init` each carried their own copy of `TRIVIAL_RUST`, and the first two of `BRANCHY_RUST`, then wrote the same bytes into a fresh `TempDir` in every test. Hoist them into `tests/common/fixtures.rs` and point the twenty-two `check_thresholds` tests whose tempdir existed only to hold a fixture and anchor a hermetic cwd at the shared directory instead. Two departures from the issue's plan, both measured: The fixture directory is a deterministic, content-addressed path under `std::env::temp_dir()`, not the proposed `LazyLock`. A `TempDir` inside a `static` is never dropped, so that shape leaks a directory per test process — and under nextest, which runs a process per test, it also shares nothing. Hashing the fixture bytes into the directory name makes the cache self-invalidating; publishing each file by rename makes concurrent creation safe. `manifest.rs` keeps a fixture of its own, renamed `FOUR_BRANCH_RUST`. Its `BRANCHY_RUST` was *not* byte-identical to the other two: it is a four-branch function against their five, and the limits that module sets sit between the two. The issue's "per-test variance is the manifest/cwd, not the sources" holds for three of the four files, not all four. Verified by perturbation: pointing `branchy_rs()` at the trivial fixture fails ten of the converted tests and nothing else, so the converted sites do read the shared file and would notice a wrong one. CLI slice, three runs each, minima (CPU is the stable figure under this machine's contention): 3.89 s wall / 17.02 CPU-s before, 2.91 s wall / 15.17 CPU-s after. The issue's second item is not actionable as written. There is no in-process entry point for an integration test to migrate to: `big_code_analysis_cli` exports only `Cli` and `run`, and `run` is documented as terminating the calling process rather than returning. Every case the suite covers — exit codes, stderr text, env scrubbing, cwd discovery, output plumbing — lives on the process boundary, so all 483 spawns stay. Its premise is also stale: 483 spawns of a representative `bca check` measure 3.0 CPU-s here, not the 30-60 s the issue estimated before #1120 moved the suite to nextest. Fixes #1126 --- .../tests/check/check_baseline.rs | 25 +- .../tests/check/check_thresholds.rs | 275 ++++++++---------- big-code-analysis-cli/tests/cli_ux/init.rs | 17 +- .../tests/cli_ux/manifest.rs | 13 +- .../tests/common/fixtures.rs | 133 +++++++++ big-code-analysis-cli/tests/common/mod.rs | 3 + 6 files changed, 271 insertions(+), 195 deletions(-) create mode 100644 big-code-analysis-cli/tests/common/fixtures.rs diff --git a/big-code-analysis-cli/tests/check/check_baseline.rs b/big-code-analysis-cli/tests/check/check_baseline.rs index 5f17ca9ba..52c803950 100644 --- a/big-code-analysis-cli/tests/check/check_baseline.rs +++ b/big-code-analysis-cli/tests/check/check_baseline.rs @@ -14,6 +14,7 @@ use predicates::prelude::*; use tempfile::TempDir; use crate::common; +use crate::common::fixtures::{BRANCHY_RUST, TRIVIAL_RUST}; /// Hermetic `bca` builder: anchors the process cwd at `dir` (a /// `tempfile::tempdir()` with no `.git` ancestor) so `bca check` cannot @@ -23,24 +24,6 @@ fn cli(dir: &Path) -> Command { common::cli_in(dir) } -/// Rust function with cyclomatic complexity > 1: each branch -/// contributes to the count. Five branches → cyclomatic == 5. -const BRANCHY_RUST: &str = r#" -pub fn classify(n: i32) -> &'static str { - if n < 0 { - "neg" - } else if n == 0 { - "zero" - } else if n < 10 { - "small" - } else if n < 100 { - "medium" - } else { - "large" - } -} -"#; - /// Heavier-branching variant for "regressed function" cases: seven /// branches, so cyclomatic > 5 even after baselining at 5. const WORSER_RUST: &str = r#" @@ -63,12 +46,6 @@ pub fn classify(n: i32) -> &'static str { } "#; -const TRIVIAL_RUST: &str = " -pub fn add(a: i32, b: i32) -> i32 { - a + b -} -"; - fn write_fixture(dir: &TempDir, name: &str, body: &str) -> String { let path = dir.path().join(name); fs::write(&path, body).expect("write fixture"); diff --git a/big-code-analysis-cli/tests/check/check_thresholds.rs b/big-code-analysis-cli/tests/check/check_thresholds.rs index ef150327c..4aa762a2a 100644 --- a/big-code-analysis-cli/tests/check/check_thresholds.rs +++ b/big-code-analysis-cli/tests/check/check_thresholds.rs @@ -13,42 +13,22 @@ use predicates::prelude::*; use tempfile::TempDir; use crate::common; +use crate::common::fixtures; +use crate::common::fixtures::{BRANCHY_RUST, TRIVIAL_RUST}; /// Hermetic `bca` builder: anchors the process cwd at `dir` (a /// `tempfile::tempdir()` with no `.git` ancestor) so `bca check` cannot /// auto-discover the repo's own `bca.toml` / `.bca-baseline.toml` and /// filter or scale the inline fixtures against repo state (#491). +/// +/// Tests here that only need one of the shared fixtures and never write +/// into their working directory use `fixtures::cli_shared()` instead, +/// which is the same builder anchored at the process-wide fixture dir +/// (#1126). fn cli(dir: &Path) -> Command { common::cli_in(dir) } -/// Rust function with cyclomatic complexity > 1: each branch contributes -/// to the count. Used by tests that need a guaranteed violation when -/// `cyclomatic` is given a tight limit. -const BRANCHY_RUST: &str = r#" -pub fn classify(n: i32) -> &'static str { - if n < 0 { - "neg" - } else if n == 0 { - "zero" - } else if n < 10 { - "small" - } else if n < 100 { - "medium" - } else { - "large" - } -} -"#; - -/// Rust function with cyclomatic == 1 (no branches). Threshold-clean for -/// any reasonable cyclomatic limit. -const TRIVIAL_RUST: &str = " -pub fn add(a: i32, b: i32) -> i32 { - a + b -} -"; - fn write_fixture(dir: &TempDir, name: &str, body: &str) -> String { let path = dir.path().join(name); fs::write(&path, body).expect("write fixture"); @@ -57,11 +37,14 @@ fn write_fixture(dir: &TempDir, name: &str, body: &str) -> String { #[test] fn check_clean_exits_zero_with_no_offenders() { - let dir = TempDir::new().unwrap(); - let path = write_fixture(&dir, "trivial.rs", TRIVIAL_RUST); - - cli(dir.path()) - .args(["check", "--paths", &path, "--threshold", "cyclomatic=10"]) + fixtures::cli_shared() + .args([ + "check", + "--paths", + fixtures::trivial_rs(), + "--threshold", + "cyclomatic=10", + ]) .assert() .success() .stderr(predicate::str::is_empty()); @@ -69,17 +52,20 @@ fn check_clean_exits_zero_with_no_offenders() { #[test] fn check_violation_exits_two_with_stable_stderr() { - let dir = TempDir::new().unwrap(); - let path = write_fixture(&dir, "branchy.rs", BRANCHY_RUST); - - cli(dir.path()) - .args(["check", "--paths", &path, "--threshold", "cyclomatic=1"]) + fixtures::cli_shared() + .args([ + "check", + "--paths", + fixtures::branchy_rs(), + "--threshold", + "cyclomatic=1", + ]) .assert() .code(2) // The classify function exceeds cyclomatic=1; the offender line // must mention the file, function name, metric, and limit in the // documented format. - .stderr(predicate::str::contains(&path)) + .stderr(predicate::str::contains(fixtures::branchy_rs())) .stderr(predicate::str::contains("classify")) .stderr(predicate::str::contains("cyclomatic")) .stderr(predicate::str::contains("(limit 1)")); @@ -241,14 +227,11 @@ impl Drop for ArtifactEnvGuard { #[test] fn check_no_fail_keeps_exit_zero_but_still_reports() { - let dir = TempDir::new().unwrap(); - let path = write_fixture(&dir, "branchy.rs", BRANCHY_RUST); - - cli(dir.path()) + fixtures::cli_shared() .args([ "check", "--paths", - &path, + fixtures::branchy_rs(), "--threshold", "cyclomatic=1", "--no-fail", @@ -261,11 +244,14 @@ fn check_no_fail_keeps_exit_zero_but_still_reports() { #[test] fn check_unknown_metric_exits_one_with_clear_error() { - let dir = TempDir::new().unwrap(); - let path = write_fixture(&dir, "trivial.rs", TRIVIAL_RUST); - - cli(dir.path()) - .args(["check", "--paths", &path, "--threshold", "not_a_metric=1"]) + fixtures::cli_shared() + .args([ + "check", + "--paths", + fixtures::trivial_rs(), + "--threshold", + "not_a_metric=1", + ]) .assert() // Exit 1 (tool error), not 2 (threshold exceeded). This is the // pivot that lets CI distinguish "metric regression" from @@ -276,16 +262,13 @@ fn check_unknown_metric_exits_one_with_clear_error() { #[test] fn check_requires_at_least_one_threshold() { - let dir = TempDir::new().unwrap(); - let path = write_fixture(&dir, "trivial.rs", TRIVIAL_RUST); - - // `cli()` already anchors the cwd at a `.git`-free tempdir, so - // discovery never reaches the repo's root `bca.toml` (whose - // `[thresholds]` table would otherwise satisfy the "at least one - // threshold" check). `--no-config` is an explicit belt-and-suspenders - // guard on top of that cwd anchor. - cli(dir.path()) - .args(["check", "--no-config", "--paths", &path]) + // `fixtures::cli_shared()` anchors the cwd at the shared `.git`-free + // fixture tempdir, so discovery never reaches the repo's root + // `bca.toml` (whose `[thresholds]` table would otherwise satisfy the + // "at least one threshold" check). `--no-config` is an explicit + // belt-and-suspenders guard on top of that cwd anchor. + fixtures::cli_shared() + .args(["check", "--no-config", "--paths", fixtures::trivial_rs()]) .assert() .code(1) .stderr(predicate::str::contains("no thresholds configured")); @@ -295,11 +278,14 @@ fn check_requires_at_least_one_threshold() { /// hint pointing at the canonical name. Regression for #381. #[test] fn check_unknown_metric_close_typo_suggests_correction() { - let dir = TempDir::new().unwrap(); - let path = write_fixture(&dir, "trivial.rs", TRIVIAL_RUST); - - cli(dir.path()) - .args(["check", "--paths", &path, "--threshold", "cyclic=15"]) + fixtures::cli_shared() + .args([ + "check", + "--paths", + fixtures::trivial_rs(), + "--threshold", + "cyclic=15", + ]) .assert() .code(1) .stderr(predicate::str::contains("did you mean")) @@ -311,11 +297,14 @@ fn check_unknown_metric_close_typo_suggests_correction() { /// name as one string rather than splitting on `.`. #[test] fn check_unknown_metric_dotted_typo_suggests_correction() { - let dir = TempDir::new().unwrap(); - let path = write_fixture(&dir, "trivial.rs", TRIVIAL_RUST); - - cli(dir.path()) - .args(["check", "--paths", &path, "--threshold", "halstead.efort=1"]) + fixtures::cli_shared() + .args([ + "check", + "--paths", + fixtures::trivial_rs(), + "--threshold", + "halstead.efort=1", + ]) .assert() .code(1) .stderr(predicate::str::contains("did you mean")) @@ -327,11 +316,14 @@ fn check_unknown_metric_dotted_typo_suggests_correction() { /// short or unrelated inputs would point users at unrelated metrics. #[test] fn check_unknown_metric_unrelated_input_omits_suggestion() { - let dir = TempDir::new().unwrap(); - let path = write_fixture(&dir, "trivial.rs", TRIVIAL_RUST); - - cli(dir.path()) - .args(["check", "--paths", &path, "--threshold", "xyznonexistent=1"]) + fixtures::cli_shared() + .args([ + "check", + "--paths", + fixtures::trivial_rs(), + "--threshold", + "xyznonexistent=1", + ]) .assert() .code(1) .stderr(predicate::str::contains("unknown threshold metric")) @@ -427,17 +419,14 @@ fn check_cli_threshold_overrides_config() { #[test] fn check_emits_one_line_per_metric_per_function() { - let dir = TempDir::new().unwrap(); - let path = write_fixture(&dir, "branchy.rs", BRANCHY_RUST); - // Two thresholds tight enough that the same function violates both. // The contract is one line per (function, metric), so we expect at // least two lines for `classify` — one for each metric. - let assert = cli(dir.path()) + let assert = fixtures::cli_shared() .args([ "check", "--paths", - &path, + fixtures::branchy_rs(), "--threshold", "cyclomatic=1", "--threshold", @@ -474,16 +463,13 @@ fn check_uses_file_sentinel_for_top_level_space() { // `` in the function slot so file-level violations on // aggregating metrics like `loc.sloc` are visually distinct // and the path doesn't repeat. - let dir = TempDir::new().unwrap(); - let path = write_fixture(&dir, "branchy.rs", BRANCHY_RUST); - - // `cli()` already anchors the cwd at a `.git`-free tempdir, so - // discovery never reaches the repo's root `bca.toml` (whose - // `baseline` key would otherwise prefix the violation line with a - // `[new]` tag and break the `starts_with(path)` assertion below). - // `--no-config` is an explicit belt-and-suspenders guard on top of - // that cwd anchor. - let assert = cli(dir.path()) + // `fixtures::cli_shared()` anchors the cwd at the shared `.git`-free + // fixture tempdir, so discovery never reaches the repo's root + // `bca.toml` (whose `baseline` key would otherwise prefix the + // violation line with a `[new]` tag and break the `starts_with` + // assertion below). `--no-config` is an explicit belt-and-suspenders + // guard on top of that cwd anchor. + let assert = fixtures::cli_shared() // loc.sloc aggregates source lines at the file level, so a // threshold of 1 is guaranteed to fire there for any // non-trivial fixture. @@ -491,7 +477,7 @@ fn check_uses_file_sentinel_for_top_level_space() { "check", "--no-config", "--paths", - &path, + fixtures::branchy_rs(), "--threshold", "loc.sloc=1", ]) @@ -511,10 +497,10 @@ fn check_uses_file_sentinel_for_top_level_space() { // slot is the sentinel, not the path. let line = file_lines[0]; assert!( - line.starts_with(&path), + line.starts_with(fixtures::branchy_rs()), "file-level line must start with the path; got {line:?}", ); - let path_count = line.matches(path.as_str()).count(); + let path_count = line.matches(fixtures::branchy_rs()).count(); assert_eq!( path_count, 1, "file path should appear once (location only), not as the function name; line was {line:?}", @@ -862,17 +848,14 @@ pub fn outer() -> i32 { #[test] fn check_recognised_after_single_value_exclude() { - let dir = TempDir::new().unwrap(); - let path = write_fixture(&dir, "trivial.rs", TRIVIAL_RUST); - // No separator between the single `--exclude` value and `check`. // Post-#601 `--exclude` binds exactly one glob, so `check` is parsed // as the subcommand. Pre-#601 this errored with a missing . - cli(dir.path()) + fixtures::cli_shared() .args([ "check", "--paths", - &path, + fixtures::trivial_rs(), "--exclude", "./nothing/**", "--threshold", @@ -884,18 +867,15 @@ fn check_recognised_after_single_value_exclude() { #[test] fn check_runs_with_num_jobs_separator() { - let dir = TempDir::new().unwrap(); - let path = write_fixture(&dir, "trivial.rs", TRIVIAL_RUST); - // The historical argv shape with `--num-jobs` interposed between the // exclude value and the subcommand. The separator is redundant // post-#601 but must remain harmless — this is the exact shape the // Pages workflow uses. - cli(dir.path()) + fixtures::cli_shared() .args([ "check", "--paths", - &path, + fixtures::trivial_rs(), "--exclude", "./nothing/**", "--num-jobs", @@ -919,11 +899,14 @@ pub fn pick(n: i32) -> &'static str { #[test] fn summary_footer_emitted_by_default() { - let dir = TempDir::new().unwrap(); - let path = write_fixture(&dir, "branchy.rs", BRANCHY_RUST); - - cli(dir.path()) - .args(["check", "--paths", &path, "--threshold", "cyclomatic=1"]) + fixtures::cli_shared() + .args([ + "check", + "--paths", + fixtures::branchy_rs(), + "--threshold", + "cyclomatic=1", + ]) .assert() .code(2) .stderr(predicate::str::contains("--- summary ---")) @@ -932,14 +915,11 @@ fn summary_footer_emitted_by_default() { #[test] fn summary_footer_suppressed_by_no_summary() { - let dir = TempDir::new().unwrap(); - let path = write_fixture(&dir, "branchy.rs", BRANCHY_RUST); - - cli(dir.path()) + fixtures::cli_shared() .args([ "check", "--paths", - &path, + fixtures::branchy_rs(), "--threshold", "cyclomatic=1", "--no-summary", @@ -953,11 +933,14 @@ fn summary_footer_suppressed_by_no_summary() { fn summary_skipped_for_clean_run() { // No violations → no footer. Clean stderr stays empty so CI // tooling that asserts on "no output ⇒ clean" keeps working. - let dir = TempDir::new().unwrap(); - let path = write_fixture(&dir, "trivial.rs", TRIVIAL_RUST); - - cli(dir.path()) - .args(["check", "--paths", &path, "--threshold", "cyclomatic=10"]) + fixtures::cli_shared() + .args([ + "check", + "--paths", + fixtures::trivial_rs(), + "--threshold", + "cyclomatic=10", + ]) .assert() .success() .stderr(predicate::str::is_empty()); @@ -1068,14 +1051,11 @@ fn summary_worst_metric_uses_max_ratio() { // cyclomatic = 5 vs 1 → ratio 5 (worst, max ratio) // loc.sloc = ~12 vs 11 → ratio ~1.09 // The footer must cite cyclomatic as worst. - let dir = TempDir::new().unwrap(); - let path = write_fixture(&dir, "branchy.rs", BRANCHY_RUST); - - cli(dir.path()) + fixtures::cli_shared() .args([ "check", "--paths", - &path, + fixtures::branchy_rs(), "--threshold", "cyclomatic=1", "--threshold", @@ -1401,20 +1381,18 @@ fn check_headroom_does_not_scale_cli_threshold_override() { /// rather than silently appearing to take effect. #[test] fn check_soft_tier_without_config_warns_and_noops() { - let dir = TempDir::new().unwrap(); - let path = write_fixture(&dir, "branchy.rs", BRANCHY_RUST); - - // `cli()` already anchors the cwd at a `.git`-free tempdir, so - // discovery never reaches the repo's root `bca.toml` (whose - // `[thresholds]` table would otherwise give the soft tier something - // to scale, suppressing the "no effect" note). `--no-config` is an - // explicit belt-and-suspenders guard on top of that cwd anchor. - cli(dir.path()) + // `fixtures::cli_shared()` anchors the cwd at the shared `.git`-free + // fixture tempdir, so discovery never reaches the repo's root + // `bca.toml` (whose `[thresholds]` table would otherwise give the + // soft tier something to scale, suppressing the "no effect" note). + // `--no-config` is an explicit belt-and-suspenders guard on top of + // that cwd anchor. + fixtures::cli_shared() .args([ "check", "--no-config", "--paths", - &path, + fixtures::branchy_rs(), "--tier=soft=0.5", "--threshold", "cyclomatic=100", @@ -1646,15 +1624,12 @@ fn check_soft_table_scale_without_hard_base_errors() { /// on-only flag could not express (#683). #[test] fn github_annotations_never_suppresses_under_gha() { - let dir = TempDir::new().unwrap(); - let path = write_fixture(&dir, "branchy.rs", BRANCHY_RUST); - - cli(dir.path()) + fixtures::cli_shared() .env("GITHUB_ACTIONS", "true") .args([ "check", "--paths", - &path, + fixtures::branchy_rs(), "--threshold", "cyclomatic=1", "--github-annotations=never", @@ -1668,14 +1643,11 @@ fn github_annotations_never_suppresses_under_gha() { /// a GHA step (no `$GITHUB_ACTIONS`). #[test] fn github_annotations_always_forces_on_outside_gha() { - let dir = TempDir::new().unwrap(); - let path = write_fixture(&dir, "branchy.rs", BRANCHY_RUST); - - cli(dir.path()) + fixtures::cli_shared() .args([ "check", "--paths", - &path, + fixtures::branchy_rs(), "--threshold", "cyclomatic=1", "--github-annotations=always", @@ -1722,15 +1694,12 @@ fn summary_file_never_skips_append_under_gha() { /// at the canonical `--exit-codes=tiered` (#666). #[test] fn strict_exit_codes_alias_warns_but_honors() { - let dir = TempDir::new().unwrap(); - let path = write_fixture(&dir, "branchy.rs", BRANCHY_RUST); - - cli(dir.path()) + fixtures::cli_shared() .args([ "check", "--no-config", "--paths", - &path, + fixtures::branchy_rs(), "--threshold", "cyclomatic=1", "--strict-exit-codes", @@ -1906,15 +1875,12 @@ fn check_no_fail_does_not_mask_an_unreadable_input() { /// count, which a grammar bump legitimately moves. #[test] fn check_tokens_threshold_fires_under_narrowed_metric_selection() { - let dir = TempDir::new().unwrap(); - let path = write_fixture(&dir, "branchy.rs", BRANCHY_RUST); - - cli(dir.path()) + fixtures::cli_shared() .args([ "check", "--no-config", "--paths", - &path, + fixtures::branchy_rs(), "--threshold", "tokens=5", ]) @@ -1938,22 +1904,19 @@ fn check_tokens_threshold_fires_under_narrowed_metric_selection() { /// `mi.original = 0`, so they still matched and the test passed. #[test] fn check_mi_value_is_identical_whether_or_not_the_walk_is_narrowed() { - let dir = TempDir::new().unwrap(); - let path = write_fixture(&dir, "branchy.rs", BRANCHY_RUST); - let mi_line = |extra: &[&str]| -> String { let mut args = vec![ "check", "--no-config", "--paths", - &path, + fixtures::branchy_rs(), "--threshold", // A limit far above any real MI score, so the lower-is-worse // `mi.*` gate always reports the observed value. "mi.original=200", ]; args.extend_from_slice(extra); - let out = cli(dir.path()) + let out = fixtures::cli_shared() .args(args) .assert() .code(2) diff --git a/big-code-analysis-cli/tests/cli_ux/init.rs b/big-code-analysis-cli/tests/cli_ux/init.rs index 12f8803d3..e2b6787d5 100644 --- a/big-code-analysis-cli/tests/cli_ux/init.rs +++ b/big-code-analysis-cli/tests/cli_ux/init.rs @@ -14,22 +14,17 @@ use predicates::prelude::*; use tempfile::TempDir; use crate::common; +// `TRIVIAL_RUST` is non-empty so the baseline walk has something to +// traverse, and small enough that no threshold is exceeded, so the +// baseline file ends up empty (just the version preamble). That is +// fine — these tests care about file existence / shape, not entry +// count. +use crate::common::fixtures::TRIVIAL_RUST; fn cli() -> Command { common::bca_command() } -/// Trivial Rust source — non-empty so the baseline walk has something -/// to traverse. The body is small enough that no threshold is -/// exceeded, so the baseline file ends up empty (just the version -/// preamble). That is fine — the test cares about file existence / -/// shape, not entry count. -const TRIVIAL_RUST: &str = " -pub fn add(a: i32, b: i32) -> i32 { - a + b -} -"; - #[test] fn init_writes_canonical_files() { let dir = TempDir::new().unwrap(); diff --git a/big-code-analysis-cli/tests/cli_ux/manifest.rs b/big-code-analysis-cli/tests/cli_ux/manifest.rs index 4dc0917e4..c20db8867 100644 --- a/big-code-analysis-cli/tests/cli_ux/manifest.rs +++ b/big-code-analysis-cli/tests/cli_ux/manifest.rs @@ -21,7 +21,12 @@ fn cli() -> Command { /// `classify` has cyclomatic == 4 (three `else if`/`if` decision points /// plus one). Used by tests that need a guaranteed offender at a tight /// limit and a clean run at a loose one. -const BRANCHY_RUST: &str = r#" +/// +/// Deliberately *not* `common::fixtures::BRANCHY_RUST`, which is the +/// five-branch function: the limits these tests set sit between the two +/// (#1126). The name says four so a later dedup pass does not merge +/// them. +const FOUR_BRANCH_RUST: &str = r#" pub fn classify(n: i32) -> &'static str { if n < 0 { "neg" } else if n == 0 { "zero" } else if n < 10 { "small" } else { "big" } } @@ -49,9 +54,9 @@ fn fixture_with(manifest: &str, source_file: &str, source: &str) -> TempDir { dir } -/// Fixture repo whose only source file is the branchy [`BRANCHY_RUST`]. +/// Fixture repo whose only source file is the branchy [`FOUR_BRANCH_RUST`]. fn fixture(manifest: &str) -> TempDir { - fixture_with(manifest, "branchy.rs", BRANCHY_RUST) + fixture_with(manifest, "branchy.rs", FOUR_BRANCH_RUST) } /// Like [`fixture`], but the only source file is the `?`-using @@ -268,7 +273,7 @@ fn manifest_baseline_fuzzy_match_is_honored() { // Rename the function; the body is byte-identical. fs::write( dir.path().join("branchy.rs"), - BRANCHY_RUST.replace("fn classify", "fn categorize"), + FOUR_BRANCH_RUST.replace("fn classify", "fn categorize"), ) .unwrap(); diff --git a/big-code-analysis-cli/tests/common/fixtures.rs b/big-code-analysis-cli/tests/common/fixtures.rs new file mode 100644 index 000000000..2f30495fd --- /dev/null +++ b/big-code-analysis-cli/tests/common/fixtures.rs @@ -0,0 +1,133 @@ +//! Source fixtures shared across the CLI integration suite, written +//! once per machine rather than once per test (#1126). +//! +//! Three modules each carried their own byte-identical copy of +//! `TRIVIAL_RUST`, and two carried `BRANCHY_RUST`, then wrote them into +//! a fresh `TempDir` in every test. The bytes never vary — what varies +//! per test is the manifest, the flags, and the working directory — so +//! they live here instead. +//! +//! **The directory is deliberately *not* a `LazyLock`.** A +//! `TempDir` in a `static` is never dropped, so that shape leaks one +//! directory per test process — and under `cargo nextest`, which runs a +//! process per test, it also shares nothing: each process would build +//! its own. A deterministic path under [`std::env::temp_dir`] shares +//! across processes *and* across runs, and leaves one directory behind +//! instead of one per test. The name carries a hash of the bytes, so +//! editing a fixture below yields a new directory rather than a stale +//! hit, and each file is published by rename so two test processes +//! racing to create it cannot expose a half-written fixture. +//! +//! **The directory is read-only to callers.** It sits under the system +//! temp dir, so it has no `.git` and no `bca.toml` ancestor and +//! [`cli_shared`] is exactly as hermetic as `common::cli_in` on a +//! per-test dir (#491). What it does *not* have is emptiness: it holds +//! both fixtures. Use it only for a command seeded with an explicit +//! `--paths`, never for one that would walk its working directory, and +//! never write into it — a test that needs to create a manifest, a +//! baseline, or a second source file still builds its own `TempDir`. +//! +//! `manifest.rs` deliberately keeps a branchy fixture of its own: it is +//! a *four*-branch function, not this five-branch one, and the values +//! that module asserts depend on the difference. + +use std::fs; +use std::hash::{DefaultHasher, Hash, Hasher}; +use std::path::{Path, PathBuf}; +use std::sync::LazyLock; + +use assert_cmd::Command; + +/// Rust function with cyclomatic complexity > 1: each branch contributes +/// to the count. Five branches → cyclomatic == 5. Used by tests that +/// need a guaranteed violation when `cyclomatic` is given a tight limit. +#[allow(dead_code)] +pub const BRANCHY_RUST: &str = r#" +pub fn classify(n: i32) -> &'static str { + if n < 0 { + "neg" + } else if n == 0 { + "zero" + } else if n < 10 { + "small" + } else if n < 100 { + "medium" + } else { + "large" + } +} +"#; + +/// Rust function with cyclomatic == 1 (no branches). Threshold-clean for +/// any reasonable cyclomatic limit. +#[allow(dead_code)] +pub const TRIVIAL_RUST: &str = " +pub fn add(a: i32, b: i32) -> i32 { + a + b +} +"; + +/// The shared directory, plus its fixture paths as `String` so call +/// sites can drop them straight into `Command::args`. +struct Shared { + dir: PathBuf, + branchy: String, + trivial: String, +} + +/// Publish `body` at `dir/name`, tolerating a concurrent publisher. +/// +/// The write goes to a pid-suffixed sibling and is renamed into place, +/// which is atomic on every platform this suite runs on. Two processes +/// racing therefore either see no file or the whole file — never a +/// prefix — and the loser's rename simply replaces identical bytes. +fn publish(dir: &Path, name: &str, body: &str) -> String { + let final_path = dir.join(name); + let staging = dir.join(format!("{name}.{}.tmp", std::process::id())); + fs::write(&staging, body).expect("write staged fixture"); + fs::rename(&staging, &final_path).expect("publish fixture"); + final_path.to_str().expect("utf8 fixture path").to_owned() +} + +static SHARED: LazyLock = LazyLock::new(|| { + let mut hasher = DefaultHasher::new(); + BRANCHY_RUST.hash(&mut hasher); + TRIVIAL_RUST.hash(&mut hasher); + let dir = std::env::temp_dir().join(format!("bca-cli-fixtures-{:016x}", hasher.finish())); + fs::create_dir_all(&dir).expect("create shared fixture dir"); + + let branchy = publish(&dir, "branchy.rs", BRANCHY_RUST); + let trivial = publish(&dir, "trivial.rs", TRIVIAL_RUST); + Shared { + dir, + branchy, + trivial, + } +}); + +/// Absolute path to the shared five-branch fixture, named `branchy.rs` +/// so offender assertions can match on the basename. +#[allow(dead_code)] +pub fn branchy_rs() -> &'static str { + &SHARED.branchy +} + +/// Absolute path to the shared branchless fixture, named `trivial.rs`. +#[allow(dead_code)] +pub fn trivial_rs() -> &'static str { + &SHARED.trivial +} + +/// The shared directory itself, for a test that needs to name the +/// hermetic working directory separately from the command. +#[allow(dead_code)] +pub fn shared_dir() -> &'static Path { + &SHARED.dir +} + +/// A `bca` command anchored at the shared directory. See the module doc +/// for when this is *not* the right builder. +#[allow(dead_code)] +pub fn cli_shared() -> Command { + super::cli_in(shared_dir()) +} diff --git a/big-code-analysis-cli/tests/common/mod.rs b/big-code-analysis-cli/tests/common/mod.rs index c70a5009c..fec1889a3 100644 --- a/big-code-analysis-cli/tests/common/mod.rs +++ b/big-code-analysis-cli/tests/common/mod.rs @@ -23,6 +23,9 @@ use std::path::Path; use assert_cmd::Command; +#[allow(dead_code)] +pub mod fixtures; + #[allow(dead_code)] pub mod validators; From da267f06f8f4e4918f406a37de9930ed85084796 Mon Sep 17 00:00:00 2001 From: Elijah Zupancic Date: Sat, 1 Aug 2026 09:48:38 -0700 Subject: [PATCH 31/36] docs(rules): record that zsh does not word-split parameter expansions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Assistant tooling runs zsh, where an unquoted `$var` is a single word however many spaces it holds — unlike bash and POSIX sh. The command still runs, still exits 0, and still prints something, so the failure surfaces as a wrong number rather than an error. It cost two measurement loops during the #1090-#1151 batch. Re-measuring #1143's threshold offenders, a `SCOPE="-p a -p b"` scalar reached `bca` as one argument that matched no path, so all seven threshold rows reported 0 offenders — a coherent-looking "already compliant everywhere" that was pure artifact. The array form reported 19, 73 and 127. The rule pins the boundary rather than the folklore: zsh does not split *parameter expansions*, but it does split *command substitutions*, so `for f in $(rg -l x)` is fine and only the intermediate variable collapses. Every row of its comparison table was measured in both shells, including that `PIPESTATUS` expands to nothing under zsh while `$pipestatus[1]` reports correctly. Two siblings that bit the same measurement are recorded alongside it: `$?` after a pipeline reports the last stage, and `bca check` writes offenders to stderr, so `2>/dev/null` silently empties the result. --- .claude/rules/shell.md | 96 ++++++++++++++++++++++++++++++++++++++++++ AGENTS.md | 7 +++ 2 files changed, 103 insertions(+) create mode 100644 .claude/rules/shell.md diff --git a/.claude/rules/shell.md b/.claude/rules/shell.md new file mode 100644 index 000000000..7f30b208a --- /dev/null +++ b/.claude/rules/shell.md @@ -0,0 +1,96 @@ +# Shell Rule + +The `Bash` tool runs **zsh**, not bash (`echo $0` → `/usr/bin/zsh`). + +## zsh does not field-split an unquoted parameter expansion + +In POSIX sh and bash, `$var` unquoted is split on `IFS`. **In zsh it is +not.** The value arrives as a single word, however many spaces or +newlines it contains. + +This is the one zsh/bash divergence that reliably produces a *wrong +answer rather than an error*, because the command still runs, still +exits 0, and still prints a plausible result. + +Measured in this repository's shell: + +| expression | zsh | bash | +| --- | --- | --- | +| `FLAGS="-p a -p b"; cmd $FLAGS` | **1 argument** | 4 arguments | +| `FILES=$(cat two-lines); for f in $FILES` | **1 iteration** | 2 iterations | +| `for f in $(cat two-lines)` | 2 iterations | 2 iterations | +| `cmd "${ARR[@]}"` where `ARR=(-p a -p b)` | 4 arguments | 4 arguments | + +Note the third row. **Command substitution *is* split in zsh** — only +*parameter expansion* is not. So `for f in $(rg -l pattern)` behaves as +you expect, and the trap is specifically the intermediate variable: +assign the output first and the loop silently collapses to one +iteration. Do not "fix" the working form while chasing this. + +## What it cost here + +Two measurement loops during the #1090-#1151 batch, both of which +produced confident, uniform, entirely fabricated numbers. + +Re-measuring #1143's threshold offenders: + +```zsh +SCOPE="-p src -p big-code-analysis-cli/src -p big-code-analysis-web/src" +for spec in nargs=7 nargs=6 abc=50 cognitive=15; do + bca check --no-config --exclude-tests $SCOPE --threshold "$spec" … +done +``` + +`$SCOPE` reached `bca` as the single argument +`-p src -p big-code-analysis-cli/src -p big-code-analysis-web/src`, +which matched no path, so every row reported **0 offenders**. Seven +rows of zeros is a coherent-looking result — "the repo is already +compliant everywhere" — and it is the answer that would have shipped +had the number not been implausible enough to re-check. The array form +reported 19, 73 and 127. + +## How to apply + +- **Build argument lists as arrays, expand them quoted:** + + ```zsh + SCOPE=(-p src -p big-code-analysis-cli/src) + bca check "${SCOPE[@]}" --threshold cognitive=15 + ``` + + `"${ARR[@]}"` expands to one word per element in both shells. This is + the only spelling that is correct in zsh *and* bash, so prefer it even + in a script you think only zsh will run. + +- **Iterate lines with `while IFS= read -r`, never through a variable:** + + ```zsh + while IFS= read -r f; do …; done < list.txt + rg -l pattern | while IFS= read -r f; do …; done + ``` + + This also survives paths containing spaces, which the split forms do + not. + +- **When a loop must reuse a captured list, capture into an array:** + `FILES=("${(@f)$(cat list.txt)}")` splits on newlines only, or just + re-run the command inside the `for`. + +- **Sanity-check any measurement loop against a single hand-run case + before believing the table.** One `bca check -p src …` typed out in + full would have caught this immediately. A loop that emits a tidy + column of zeros deserves that check specifically, because zero is + what every one of these failure modes produces. + +## Two siblings worth knowing + +Both bit the same measurement in the same session, and both also yield +a plausible number rather than an error: + +- **`$?` after a pipeline is the *last* stage's status.** + `cmd | head` reports `head`'s success even when `cmd` failed. zsh + spells the per-stage array `$pipestatus` (1-indexed); `PIPESTATUS` + is bash-only and expands to nothing here. +- **`bca check` writes offenders to stderr.** `2>/dev/null` on a check + invocation discards the entire result and leaves an empty stdout that + reads as "no offenders". diff --git a/AGENTS.md b/AGENTS.md index 1c58b068c..e227ca61a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -132,6 +132,13 @@ and `cargo run -p big-code-analysis-web --`. ## Tool choice +- **Shell**: assistant tooling runs **zsh**, which does not field-split + an unquoted parameter expansion the way bash does — `cmd $FLAGS` + passes one argument, not several. The failure is silent and yields a + plausible result rather than an error, so it has already produced + fabricated measurement tables here. Build argument lists as arrays and + expand them `"${ARR[@]}"`. See + [`.claude/rules/shell.md`](.claude/rules/shell.md). - **Code search**: `rg` (ripgrep). Never `grep` via Bash. - **File search**: `fd` (or `fdfind` on Debian/Ubuntu). Never `find` via Bash. From b437570a85b59762b18a4fa79fb559cbc85ffbc2 Mon Sep 17 00:00:00 2001 From: Elijah Zupancic Date: Sat, 1 Aug 2026 11:47:05 -0700 Subject: [PATCH 32/36] perf(node): hoist cursors out of the last per-node walks, guard all six MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Search::first_occurrence`, `Search::act_on_node`, `bca dump`'s tree renderer, and the Python bindings' mirror of `Node::preorder` each still built a `TreeCursor` per visited node. All four now hold one for the walk, via `children_with` on the Rust side and a caller-supplied cursor in `push_children_for_preorder`. The two `Search` walks also drop their staging `Vec`: children go on in source order and the freshly-pushed tail is reversed in place, which is what `children.drain(..).rev()` did and copies each child once instead of twice. The counter that makes this testable guarded three of the six consumers, and the comment describing its coverage blamed module reach. That was not the boundary: `first_occurrence` and `act_on_node` sit in `node.rs` alongside the counter and were equally unguarded. Reverting all three unguarded sites to `children()` compiled clean and failed nothing. The real obstacle was that `observation::counter!` emitted a private module, so a guarded path in another module was structurally unassertable. `observed()` is now `pub(crate)`; `record()` stays `pub(super)`, since only the module owning a counted path should be able to bump it. All six sites now assert, each verified by reverting its own call site: 50 cursors over 50 nodes for the two `Search` walks, 12 over 34 for the dump. Also pins `first_occurrence`'s pre-order contract, which nothing covered — deleting the reversal that maintains it failed zero tests, while the same deletion in `act_on_node` failed one. The existing negative test cannot reach it: a match-everything predicate hits the root before any child is pushed. The order is load-bearing, not incidental; the C / C++ / Objective-C / mozcpp `get_func_space_name` declarator searches are `first_occurrence` calls, so a reversed sibling order changes which declarator names a function space. Three documentation corrections alongside: `output::dump` was listed among a *metric walk*'s child scans, which no `metrics()` call runs; `PyNodeWalk` declared its cursor after the `Py` that keeps the tree alive, making the new doc's claim false by drop order (harmless today, since `ts_tree_cursor_delete` only frees its own stack, but the field order now makes the claim true by construction); and the py module doc pinned `=0.26.9` where the workspace pins `=0.26.11`. --- CHANGELOG.md | 16 ++-- big-code-analysis-py/src/node.rs | 55 +++++++++--- docs/development/benchmarking.md | 23 +++-- src/node.rs | 139 +++++++++++++++++++++++-------- src/observation.rs | 16 +++- src/output/dump.rs | 49 ++++++++++- 6 files changed, 240 insertions(+), 58 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 841af16a4..50cb4f9fa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -243,13 +243,19 @@ for historical reference. ~0.02 s. No shipped behaviour changes. - Traversals that enumerate every node's children reuse one `TreeCursor` instead of building and freeing one per node, through - the new internal `Node::children_with` (#1112). `Node::preorder`, the - suppression-marker DFS, and Python's instance-attribute scan in - `metrics::npa::python` were the only per-node consumers; the last was - 92 % of the Python metric walk's child scans. Over 400 Python corpus + the new internal `Node::children_with` (#1112). The six per-node + consumers are `Node::preorder`, the suppression-marker DFS, the two + `Search` walks behind `bca find` and the function-space name lookup, + `bca dump`'s tree renderer, and Python's instance-attribute scan in + `metrics::npa::python` — the last being 92 % of the Python metric + walk's child scans. Over 400 Python corpus files that is 414,620 cursor allocations down to 33,328 (−92 %) and −2.4 % walk time. Other languages reach `children` on 3-6 % of nodes - (16 % for C#), where the effect is under 1 %. Metric values are + (16 % for C#), where the effect is under 1 %. The Python bindings' + mirror of that walk — `Node.walk()` and `Node.descendants_by_kind()` — + hoists a cursor the same way. Every one of the six is pinned by the + `child_scan_cursors` counter, so reverting one is a test failure + rather than a silent allocation per node. Metric values are unchanged. - Every file destination and terminal dump writes through an explicitly-flushed 64 KiB buffer, replacing the raw `File` and diff --git a/big-code-analysis-py/src/node.rs b/big-code-analysis-py/src/node.rs index 921a4c2f7..b660ef6b0 100644 --- a/big-code-analysis-py/src/node.rs +++ b/big-code-analysis-py/src/node.rs @@ -50,11 +50,15 @@ //! and silently break this module's soundness. Any such future change must //! revisit [`detach`]. //! -//! `tree_sitter::Tree` and `Node` are `Send + Sync` under the pinned -//! `=0.26.9`, so the pyclasses are sendable (no `unsendable`) and compose -//! with `ThreadPoolExecutor` fan-out like [`PyAst`] itself. +//! The same three invariants cover [`PyNodeWalk`]'s `TreeCursor<'static>`, +//! which is derived from an already-erased node and lives no longer than +//! the iterator's own keep-alive. +//! +//! `tree_sitter::Tree`, `Node`, and `TreeCursor` are `Send + Sync` under +//! the pinned `=0.26.11`, so the pyclasses are sendable (no `unsendable`) +//! and compose with `ThreadPoolExecutor` fan-out like [`PyAst`] itself. -use big_code_analysis::tree_sitter::Node as TsNode; +use big_code_analysis::tree_sitter::{Node as TsNode, TreeCursor}; use pyo3::prelude::*; use pyo3::types::{PyBytes, PyDict}; @@ -84,10 +88,20 @@ unsafe fn detach<'a>(node: TsNode<'a>) -> TsNode<'static> { /// temporary. Shared by the lazy [`PyNodeWalk`] and the eager /// [`PyNode::descendants_by_kind`], the same shape as the Rust /// `Node::preorder` iterator. -fn push_children_for_preorder(stack: &mut Vec>, node: TsNode<'static>) { +/// +/// The cursor is the caller's rather than one built here: `TreeCursor` +/// heap-allocates its stack and frees it on drop, so building one per +/// visited node costs a `malloc`/`free` pair per node. This mirrors what +/// #1112 did to the Rust `Node::preorder` via `Node::children_with`. +/// `tree_sitter::Node::children` reseats the cursor itself, so a caller +/// only has to hold it. +fn push_children_for_preorder( + stack: &mut Vec>, + cursor: &mut TreeCursor<'static>, + node: TsNode<'static>, +) { let first_child = stack.len(); - let mut cursor = node.walk(); - stack.extend(node.children(&mut cursor)); + stack.extend(node.children(cursor)); stack[first_child..].reverse(); } @@ -401,8 +415,9 @@ impl PyNode { /// the lazy surface. Mirrors the Rust `Node::preorder` (#728). fn walk(&self, py: Python<'_>) -> PyNodeWalk { PyNodeWalk { - ast: self.ast.clone_ref(py), + cursor: self.node.walk(), stack: vec![self.node], + ast: self.ast.clone_ref(py), } } @@ -419,13 +434,15 @@ impl PyNode { fn descendants_by_kind(&self, py: Python<'_>, kinds: Vec) -> Vec { let mut out = Vec::new(); let mut stack = vec![self.node]; + // One cursor for the whole subtree, not one per visited node. + let mut cursor = self.node.walk(); while let Some(node) = stack.pop() { // Only matches pay the `rewrap` (a keep-alive refcount bump), so // a selective filter does not allocate a handle per visited node. if kinds.iter().any(|k| k == node.kind()) { out.push(self.rewrap(py, node)); } - push_children_for_preorder(&mut stack, node); + push_children_for_preorder(&mut stack, &mut cursor, node); } out } @@ -476,10 +493,26 @@ impl PyNode { /// `Py`. Each `__next__` pops the next node, pushes its children /// (leftmost on top), and yields the popped node — pre-order, one node at a /// time, so traversal never materialises the whole subtree at once. +/// +/// The `TreeCursor` is held for the whole walk rather than built per +/// step, for [`push_children_for_preorder`]'s reason. It is branded +/// `'static` by the same erasure as the nodes it enumerates, and stays +/// valid for the same reason: `ast` keeps the owning parse alive. +/// +/// Field order is load-bearing. Rust drops fields in declaration order, +/// so `cursor` is declared *before* the `ast` that keeps its tree alive +/// — otherwise the keep-alive would be released first and the doc claim +/// above would be false by construction. Today's `Drop for TreeCursor` +/// only frees the cursor's own stack and never reads the tree, so the +/// current order is not unsound; this makes the stated invariant hold +/// regardless, and survives a future tree-sitter destructor that does +/// touch it. (`Vec` has no drop glue, so `cursor` is the only +/// field that raises the question.) #[pyclass(name = "NodeWalk", module = "big_code_analysis._native")] pub(crate) struct PyNodeWalk { - ast: Py, + cursor: TreeCursor<'static>, stack: Vec>, + ast: Py, } #[pymethods] @@ -490,7 +523,7 @@ impl PyNodeWalk { fn __next__(&mut self, py: Python<'_>) -> Option { let node = self.stack.pop()?; - push_children_for_preorder(&mut self.stack, node); + push_children_for_preorder(&mut self.stack, &mut self.cursor, node); // Each yielded node carries its own keep-alive `Py`: it may // outlive this iterator, so the per-node refcount bump is required // for soundness, not an optimisation to hoist out of the loop. diff --git a/docs/development/benchmarking.md b/docs/development/benchmarking.md index fa2f3d53b..7fbf61de0 100644 --- a/docs/development/benchmarking.md +++ b/docs/development/benchmarking.md @@ -263,11 +263,24 @@ slice, a full `metrics()` walk reaches `children` on: The predicates are a rounding error; one scan was not. Python's instance-attribute walk in `metrics::npa::python` visits every node of every method body, and was 381 k of that language's 417 k child scans — -92 %, and the only per-node `children` consumer in the crate outside -`Preorder` and the suppression DFS. `Node::children_with` lets those -three hoist one cursor out of their loop; the counter -`child_scan_cursors` in `src/node.rs` is what keeps them there, since -the change moves no metric value. +92 % of them. The metric walk's other per-node `children` consumers are +`Preorder` and the suppression DFS. Crate-wide there are three more — +`Search::first_occurrence`, `Search::act_on_node`, and `output::dump`'s +tree renderer — none of which a `metrics()` call runs, so they are +absent from the figures above. + +`Node::children_with` lets all six hoist one cursor out of their loop, +and the counter `child_scan_cursors` in `src/node.rs` is what keeps +them there, since the change moves no metric value. All six are +asserted: the walks reachable from `node.rs` in +`the_converted_traversals_scan_a_tree_on_one_cursor`, and the renderer +in `output::dump`'s own `dump_holds_one_cursor_for_the_whole_tree`. +The counter records in `Node::children` — the allocating form — so a +hoisted cursor records **zero** and a per-node one records once per +interior node; each assertion pins the exact zero, and each was +verified by reverting its call site and watching that test alone fail +(50 cursors over 50 nodes for the two `Search` walks, 12 over 34 for +the dump). Measured on 400 Python corpus files (694 k nodes), interleaved best-of-nine: **414 620 cursor allocations down to 33 328 (−92 %), walk diff --git a/src/node.rs b/src/node.rs index e73df734d..fb1495a52 100644 --- a/src/node.rs +++ b/src/node.rs @@ -38,10 +38,17 @@ crate::observation::counter!(node_resolved_sibling_lookups); // [`Node::children_with`] yields exactly what [`Node::children`] yields // — it exists to reuse one cursor across a traversal instead of // heap-allocating and freeing one per visited node, and no assertion on -// a metric value can tell the two apart. #1112 moved the three -// per-node traversals that could hoist a cursor onto it; the counter is -// what makes moving one back a test failure rather than a silent -// allocation per node. +// a metric value can tell the two apart. #1112 moved the per-node +// traversals that could hoist a cursor onto it; the counter is what +// makes moving one back a test failure rather than a silent allocation +// per node. +// +// All six consumers are guarded: `preorder`, `first_occurrence` and +// `act_on_node` here, `metrics::npa::python` and the suppression DFS +// through a `metrics()` / `suppression_markers` call, and +// `output::dump`'s renderer from that module's own tests. The accessor +// is `pub(crate)` (not `pub(super)`) precisely so the last one can be +// asserted from where it lives — see `crate::observation`. crate::observation::counter!(child_scan_cursors); /// A parsed source tree wrapping a [`tree_sitter::Tree`]. @@ -347,11 +354,16 @@ impl<'a> Node<'a> { /// on the corpus slice, a full metric walk reaches [`children`] on /// 3-6 % of nodes in C++, Rust, JavaScript, and Java, 16 % in C#, /// and 60 % in Python, where one scan — the instance-attribute walk - /// in `metrics::npa::python` — was 92 % of the total. Predicates + /// in `metrics::npa::python` — was 92 % of the total, and + /// [`preorder`] plus the suppression DFS are the rest. Crate-wide + /// there is one more per-node consumer, `output::dump`'s renderer, + /// which no metric walk runs and so is absent from that total. + /// Predicates /// that hold a bare `&Node` and scan one node's children keep /// [`children`]: threading a cursor to them would cross the - /// `Checker` / `Getter` trait surface to save a single allocation - /// per call. + /// `Checker` / `Getter` trait surface for one allocation per call. + /// + /// [`preorder`]: Self::preorder /// /// [`children`]: Self::children pub(crate) fn children_with<'c>(&self, cursor: &'c mut Cursor<'a>) -> ChildrenWith<'c, 'a> { @@ -818,7 +830,6 @@ impl<'a> Search<'a> for Node<'a> { fn first_occurrence(&self, pred: fn(u16) -> bool) -> Option> { let mut cursor = self.cursor(); let mut stack = Vec::new(); - let mut children = Vec::new(); stack.push(*self); @@ -826,18 +837,12 @@ impl<'a> Search<'a> for Node<'a> { if pred(node.kind_id()) { return Some(node); } - cursor.reset(&node); - if cursor.goto_first_child() { - loop { - children.push(cursor.node()); - if !cursor.goto_next_sibling() { - break; - } - } - for child in children.drain(..).rev() { - stack.push(child); - } - } + // Children go on in source order and the freshly-pushed tail + // is reversed in place, so the LIFO `stack` yields the + // leftmost child first — pre-order with no staging buffer. + let first_child = stack.len(); + stack.extend(node.children_with(&mut cursor)); + stack[first_child..].reverse(); } None @@ -846,7 +851,6 @@ impl<'a> Search<'a> for Node<'a> { fn act_on_node(&self, action: &mut dyn FnMut(&Node<'a>, Ancestors<'a, '_>)) { let mut cursor = self.cursor(); let mut stack = Vec::new(); - let mut children = Vec::new(); // Ancestor chain of the node being visited, root first. Kept by // the same truncate/push rule as the metric walk, so a predicate // the action applies can read an ancestor as a slice index @@ -869,18 +873,15 @@ impl<'a> Search<'a> for Node<'a> { chain.truncate(depth); action(&node, Ancestors::checked(&chain, &node)); chain.push(node); - cursor.reset(&node); - if cursor.goto_first_child() { - loop { - children.push(cursor.node()); - if !cursor.goto_next_sibling() { - break; - } - } - for child in children.drain(..).rev() { - stack.push((child, depth + 1)); - } - } + // Source order in, tail reversed in place, so the LIFO + // `stack` yields the leftmost child first — pre-order with + // no staging buffer. + let first_child = stack.len(); + stack.extend( + node.children_with(&mut cursor) + .map(|child| (child, depth + 1)), + ); + stack[first_child..].reverse(); } } @@ -1551,6 +1552,41 @@ mod tests { scans < nodes / 2, "the suppression scan built {scans} cursors over {nodes} nodes (#1112)" ); + + // The two `Search` walks. The counter records in `children()`, + // the allocating form, so a walk that hoists its cursor records + // nothing at all and a per-node one records once per interior + // node. Asserting the exact zero is what separates them; a bound + // like `< nodes / 2` would hold for either on a small fixture. + let tree = Tree::new::( + b"function f(a) { return { g: (b) => b + 1, h: [1, 2, 3] }; }\nf(2);\n", + ); + let root = tree.get_root(); + let nodes = root.preorder().count(); + assert!(nodes > 30, "fixture is too small to prove much"); + + // A predicate nothing matches, so the walk runs to exhaustion + // and every node's children are scanned. + let before = child_scan_cursors::observed(); + let missing = root.first_occurrence(|id| id == u16::MAX); + let scans = child_scan_cursors::observed() - before; + assert!(missing.is_none(), "the fixture has no such kind"); + assert_eq!( + scans, 0, + "first_occurrence built {scans} cursors over {nodes} nodes; it holds \ + one for the walk (#1112)" + ); + + let before = child_scan_cursors::observed(); + let mut seen = 0_usize; + root.act_on_node(&mut |_, _| seen += 1); + let scans = child_scan_cursors::observed() - before; + assert_eq!(seen, nodes, "act_on_node must visit every node"); + assert_eq!( + scans, 0, + "act_on_node built {scans} cursors over {nodes} nodes; it holds one \ + for the walk (#1112)" + ); } /// [`Node::parent_grandparent_match`] must answer `false` when @@ -1621,6 +1657,43 @@ mod tests { assert_eq!(found.id(), root.id(), "the search starts at the root"); } + /// [`Search::first_occurrence`] must answer in **pre-order** — the + /// leftmost match, not merely some match. + /// + /// Nothing pinned this. Deleting the `stack[first_child..].reverse()` + /// that maintains it failed zero of the suite's lib tests, while the + /// same deletion in the sibling [`Search::act_on_node`] failed one, + /// so only half the pair was guarded. + /// `first_occurrence_answers_none_when_nothing_matches` cannot cover + /// it: its match-everything predicate hits the root before any child + /// is pushed, so the child order never comes into play. + /// + /// The order is load-bearing rather than incidental — the C, C++, + /// Objective-C and mozcpp `get_func_space_name` declarator searches + /// are `first_occurrence` calls, and a reversed sibling order + /// changes *which* declarator names a function space. + /// + /// Measured on this fixture: `"main"` as written, `"b"` with the + /// reversal removed. + #[cfg(feature = "c")] + #[test] + fn first_occurrence_answers_the_leftmost_match() { + const SOURCE: &[u8] = b"int main() { int a; int b; }"; + + let tree = Tree::new::(SOURCE); + let identifier = tree + .get_root() + .first_occurrence(|id| id == crate::languages::language_c::C::Identifier as u16) + .expect("the fixture declares identifiers"); + + assert_eq!( + identifier.utf8_text(SOURCE), + Some("main"), + "first_occurrence must return the leftmost identifier in source \ + order, not the last child pushed" + ); + } + /// [`Node::children_with`] must yield exactly what /// [`Node::children`] yields — same nodes, same order, same /// `ExactSizeIterator` length at every step — for every node of a diff --git a/src/observation.rs b/src/observation.rs index f09b13332..691c135db 100644 --- a/src/observation.rs +++ b/src/observation.rs @@ -38,12 +38,16 @@ /// drift apart. macro_rules! counter { ($name:ident) => { - mod $name { + pub(crate) mod $name { thread_local! { static COUNT: ::std::cell::Cell = const { ::std::cell::Cell::new(0) }; } /// Records one occurrence on this thread. + /// + /// `pub(super)` on purpose: only the module that owns the + /// counted path may bump it, so a counter cannot drift into + /// meaning "whatever any caller felt like recording". #[inline] pub(super) fn record() { COUNT.with(|count| count.set(count.get() + 1)); @@ -51,8 +55,16 @@ macro_rules! counter { /// Occurrences recorded on this thread. Only this accessor /// is test-gated; see [`crate::observation`]. + /// + /// `pub(crate)`, unlike [`record`], because the guarded path + /// and the test that guards it need not share a module. That + /// asymmetry is the point: `child_scan_cursors` counts a + /// cursor hoist in `node`, but `output::dump` is one of the + /// walks that has to hold one, and a counter only reachable + /// from its own module silently leaves such a caller + /// unguarded. #[cfg(test)] - pub(super) fn observed() -> usize { + pub(crate) fn observed() -> usize { COUNT.with(::std::cell::Cell::get) } } diff --git a/src/output/dump.rs b/src/output/dump.rs index 7fcb4afc8..869b283de 100644 --- a/src/output/dump.rs +++ b/src/output/dump.rs @@ -191,6 +191,10 @@ fn start_connector(node: &Node) -> Connector { /// which is inherent to the tree drawing. fn dump_tree_helper<'a>(state: &mut DumpState, node: &Node<'a>, depth: i32) -> std::io::Result<()> { let mut prefix = String::new(); + // One cursor for the whole dump, not one per node: this visits every + // node in the file, and `Node::children` would build and free a + // `TreeCursor` at each (#1112, `Node::children_with`). + let mut cursor = node.cursor(); let mut stack: Vec> = vec![Frame { node: *node, prefix_len: 0, @@ -216,7 +220,7 @@ fn dump_tree_helper<'a>(state: &mut DumpState, node: &Node<'a>, depth: i32) -> s } // Leaves are roughly half the nodes and `child_count` is O(1), - // so check it before building a cursor for the child walk. + // so check it before reseating the cursor for the child walk. if frame.node.child_count() == 0 { continue; } @@ -224,7 +228,7 @@ fn dump_tree_helper<'a>(state: &mut DumpState, node: &Node<'a>, depth: i32) -> s prefix.push_str(pref_child); push_children( &mut stack, - frame.node.children(), + frame.node.children_with(&mut cursor), prefix.len(), frame.depth - 1, ); @@ -483,6 +487,47 @@ mod tests { sink.into_inner() } + /// The renderer visits every node in the file, so it must hold one + /// cursor for the whole dump rather than build one per interior + /// node (#1112). + /// + /// This lives here, not beside the other `child_scan_cursors` + /// assertions in `node.rs`, because `dump_tree_helper` is private to + /// this module — which is exactly why the guard was missing until + /// the counter's accessor was widened to `pub(crate)`. Reverting + /// `children_with` to `children` here compiled clean and failed + /// nothing. + /// + /// The counter records in `Node::children`, the allocating form, so + /// a hoisted cursor records **zero** and a per-node one records once + /// per interior node. The exact zero is the discriminator; a + /// fraction-of-nodes bound would hold for either on a small fixture. + #[test] + fn dump_holds_one_cursor_for_the_whole_tree() { + let parser = CppParser::new( + b"int f(int a) { if (a) { return a + 1; } return 0; }\n".to_vec(), + &PathBuf::from("f.cpp"), + None, + ); + let root = parser.root(); + let nodes = root.preorder().count(); + assert!(nodes > 20, "fixture is too small to prove much"); + + let before = crate::node::child_scan_cursors::observed(); + let rendered = render_range(parser.code(), &root, -1, None, None); + let scans = crate::node::child_scan_cursors::observed() - before; + + assert!( + rendered.contains("if_statement"), + "the fixture must actually render a tree" + ); + assert_eq!( + scans, 0, + "the dump built {scans} cursors over {nodes} nodes; it holds one for \ + the whole render (#1112)" + ); + } + /// [`render_raw`] as text, for the (usual) UTF-8 case. fn render_range( code: &[u8], From a2ded2204cb3335f020567fe459e94bcaa231ac5 Mon Sep 17 00:00:00 2001 From: Elijah Zupancic Date: Sat, 1 Aug 2026 11:47:18 -0700 Subject: [PATCH 33/36] refactor: fold the last staging-Vec child pushes into children_with MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pattern the previous commit removed from the two `Search` walks — a hand-rolled `goto_first_child` / `goto_next_sibling` loop over a scratch `Vec`, drained with `.rev()` — survived in five more places, including `spaces::compute::push_children`, which `metrics_inner` and `ops_inner` both walk the whole tree with. No allocation win: every one of these already hoisted its cursor. What changes is one pattern instead of five, one copy per child instead of two, and `push_children` shedding a caller-threaded scratch buffer plus the `debug_assert` that policed it. Three of the five deliberately do not reverse, and say so. `count` only tallies; `preproc` records directives with byte offsets and replays them in source order afterwards; `comment_rm` collects spans that `remove_from_code` replays in reverse byte order. Imposing an order there would imply a guarantee nothing relies on. `comment_rm` also keeps its `chain.push` conditional on children actually being pushed — dropping that guard would leave the chain one deep too far on every leaf and desync exactly the truncate/push bookkeeping `make chain-audit` checks. Verified beyond the usual gate, since this touches the two hottest walks and one of the five chain-threading walks: full workspace suite green with no snapshot drift, `make chain-audit` green, and all 24 `make bench-scaling` probes within their complexity bound. --- src/comment_rm.rs | 24 ++++++++++++++++-------- src/count.rs | 13 ++++--------- src/find.rs | 19 ++++++------------- src/ops.rs | 4 +--- src/preproc.rs | 14 +++++--------- src/spaces/compute.rs | 35 +++++++++++------------------------ 6 files changed, 43 insertions(+), 66 deletions(-) diff --git a/src/comment_rm.rs b/src/comment_rm.rs index f8510dee5..f77b51634 100644 --- a/src/comment_rm.rs +++ b/src/comment_rm.rs @@ -107,15 +107,23 @@ pub(crate) fn rm_comments(parser: &T) -> Option> { let lines = node.end_row() - node.start_row(); spans.push((node.start_byte(), node.end_byte(), lines)); } else { - cursor.reset(&node); - if cursor.goto_first_child() { + // No reversal: `remove_from_code` replays the collected + // spans in reverse byte order, so visit order is immaterial + // here and imposing one would imply a guarantee nothing + // relies on. + let first_child = stack.len(); + stack.extend( + node.children_with(&mut cursor) + .map(|child| (child, depth + 1)), + ); + if stack.len() > first_child { + // Only a node that actually pushed children joins the + // chain; a leaf never becomes an ancestor. Preserves the + // `if cursor.goto_first_child()` guard this replaced — + // dropping it would leave the chain one deep too far on + // every leaf and desync the truncate/push bookkeeping + // `make chain-audit` exists to check. chain.push(node); - loop { - stack.push((cursor.node(), depth + 1)); - if !cursor.goto_next_sibling() { - break; - } - } } } } diff --git a/src/count.rs b/src/count.rs index fb56bd185..0a3a5fea3 100644 --- a/src/count.rs +++ b/src/count.rs @@ -41,15 +41,10 @@ pub(crate) fn count(parser: &T, filters: &[String]) -> (usize, u if filters.any(&node) { good += 1; } - cursor.reset(&node); - if cursor.goto_first_child() { - loop { - stack.push(cursor.node()); - if !cursor.goto_next_sibling() { - break; - } - } - } + // No reversal: this walk only tallies, so visit order is + // immaterial and imposing one would imply a guarantee no caller + // relies on. Matches the previous push-in-source-order form. + stack.extend(node.children_with(&mut cursor)); } (good, total) } diff --git a/src/find.rs b/src/find.rs index c250919fa..0c609618e 100644 --- a/src/find.rs +++ b/src/find.rs @@ -39,7 +39,6 @@ pub(crate) fn find<'a, T: ParserTrait>( let mut cursor = node.cursor(); let mut stack = Vec::new(); let mut good = Vec::new(); - let mut children = Vec::new(); stack.push(node); @@ -47,18 +46,12 @@ pub(crate) fn find<'a, T: ParserTrait>( if filters.any(&node) { good.push(node); } - cursor.reset(&node); - if cursor.goto_first_child() { - loop { - children.push(cursor.node()); - if !cursor.goto_next_sibling() { - break; - } - } - for child in children.drain(..).rev() { - stack.push(child); - } - } + // Source order in, tail reversed in place, so the LIFO `stack` + // yields the leftmost child first — matches were already + // returned in source order and must stay that way. + let first_child = stack.len(); + stack.extend(node.children_with(&mut cursor)); + stack[first_child..].reverse(); } Ok(good) } diff --git a/src/ops.rs b/src/ops.rs index 5d52dae0f..388b880fb 100644 --- a/src/ops.rs +++ b/src/ops.rs @@ -310,7 +310,6 @@ pub(crate) fn ops_inner( let node = parser.root(); let mut cursor = node.cursor(); let mut stack = Vec::new(); - let mut children = Vec::new(); // Ancestor chain of the node currently being visited, root first, // maintained by the same truncate/push rule as // `spaces::compute::metrics_inner` (#1084). @@ -362,7 +361,7 @@ pub(crate) fn ops_inner( // State-independent — it only moves the cursor over child nodes — // so unlike the local `finalize` / `push_synthetic_unit_root` // mirrors (which differ by `State` payload) it is reused directly - // rather than duplicated. The `children.drain(..).rev()` ordering + // rather than duplicated. The source-order-then-reverse ordering // it encapsulates is load-bearing for suppression attribution. // The returned child slice is only useful to `metrics_inner`, // which seeds their cognitive nesting; `ops` just walks them. @@ -373,7 +372,6 @@ pub(crate) fn ops_inner( level: new_level, depth: depth + 1, }, - &mut children, &mut stack, ); } diff --git a/src/preproc.rs b/src/preproc.rs index 84909fda8..fbce9c3ae 100644 --- a/src/preproc.rs +++ b/src/preproc.rs @@ -663,15 +663,11 @@ pub(crate) fn preprocess_with_parser( /// pop in reverse; directive order is recovered from byte offsets in /// [`apply_macro_events`], so visit order does not affect the result. fn push_children<'a>(cursor: &mut Cursor<'a>, node: &Node<'a>, stack: &mut Vec>) { - cursor.reset(node); - if cursor.goto_first_child() { - loop { - stack.push(cursor.node()); - if !cursor.goto_next_sibling() { - break; - } - } - } + // No reversal, unlike the metric walk's namesake: directives are + // collected with their byte offsets and replayed in source order + // afterwards (see `macro_events`), so visit order does not matter + // here and imposing one would imply a guarantee nothing relies on. + stack.extend(node.children_with(cursor)); } /// Classify one node from the [`preprocess_with_parser`] walk: a diff --git a/src/spaces/compute.rs b/src/spaces/compute.rs index 496672f51..ae03dba2d 100644 --- a/src/spaces/compute.rs +++ b/src/spaces/compute.rs @@ -465,11 +465,10 @@ fn propagate_nesting_to_children( /// Pushes `node`'s direct children onto the traversal `stack`, each tagged /// with `tag`. /// -/// The `children.drain(..).rev()` ordering is load-bearing: it makes the -/// LIFO `stack` yield children in source order, which in turn governs -/// line-shared suppression attribution (issue #289). The `children` -/// scratch buffer is drained empty here so callers can reuse its -/// allocation across iterations. +/// The ordering is load-bearing: pushing in source order and reversing +/// the freshly-pushed tail makes the LIFO `stack` yield children in +/// source order, which in turn governs line-shared suppression +/// attribution (issue #289). /// /// `Tag` is generic because the two walkers carry different context down /// the tree: `ops` needs only the nesting level, while `metrics_inner` @@ -488,26 +487,16 @@ pub(crate) fn push_children<'a, 's, Tag: Copy>( cursor: &mut Cursor<'a>, node: &Node<'a>, tag: Tag, - children: &mut Vec<(Node<'a>, Tag)>, stack: &'s mut Vec<(Node<'a>, Tag)>, ) -> &'s [(Node<'a>, Tag)] { - debug_assert!( - children.is_empty(), - "scratch buffer must be left drained by the previous call" - ); + // Children go on in source order and the freshly-pushed tail is + // reversed in place, so the LIFO `stack` yields the leftmost child + // first. Equivalent to the `children.drain(..).rev()` this replaced, + // without the caller-threaded scratch buffer, and each child is + // copied once rather than twice. let first = stack.len(); - cursor.reset(node); - if cursor.goto_first_child() { - loop { - children.push((cursor.node(), tag)); - if !cursor.goto_next_sibling() { - break; - } - } - for child in children.drain(..).rev() { - stack.push(child); - } - } + stack.extend(node.children_with(cursor).map(|child| (child, tag))); + stack[first..].reverse(); &stack[first..] } @@ -534,7 +523,6 @@ pub(crate) fn metrics_inner( let node = parser.root(); let mut cursor = node.cursor(); let mut stack = Vec::new(); - let mut children = Vec::new(); // Ancestor chain of the node currently being visited, root first. // Maintained so per-node predicates can read an ancestor as a slice // index instead of through `Node::parent`, which `tree_sitter` @@ -697,7 +685,6 @@ pub(crate) fn metrics_inner( depth: depth + 1, in_comment: subtree_in_comment, }, - &mut children, &mut stack, ); From 7574d4c5a98a845afbafa600708e1649851247e2 Mon Sep 17 00:00:00 2001 From: Elijah Zupancic Date: Sat, 1 Aug 2026 12:26:27 -0700 Subject: [PATCH 34/36] docs(changelog): record the eight unlogged entries from the batch #1141 is a user-facing feature and #1106 a performance change; the other six are test and bench infrastructure. All were reported by their implementing agents and never consolidated. --- CHANGELOG.md | 70 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index bc1a53934..172268f9c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,22 @@ for historical reference. ### Added +- Per-language threshold overrides in `bca.toml` (#1141). A + `[thresholds.lang.]` table layers over the global `[thresholds]` + per metric, keyed by the same language slugs `--language` accepts, so + a polyglot repository can apply the per-language recommendations + #1140 published instead of picking one number and baselining the + difference. An unknown slug is a hard error with a did-you-mean hint; + a file whose language is not detected falls through to the global + table. The soft tier is derived from each language's *resolved* hard + limit rather than the global one — otherwise a loosened language's + soft threshold would sit below its hard threshold and exit code 5 + would become silently reachable for every function between them — and + a soft limit looser than its hard limit is now rejected outright. + `--print-effective-config` renders one fully resolved table per + overridden language. Additive manifest surface; not a `STABILITY.md` + event. + - `big_code_analysis::vcs::BlameSession`, a per-thread handle obtained from the new `PerFunctionBlame::session` (#1117). It carries the thread-local repository handle, its object cache, the parsed @@ -267,6 +283,44 @@ for historical reference. ### Performance +- `finalize` no longer re-derives a parent space's Halstead `Stats` and + MI after every child merges into it (#1106). The per-child pass was + three map traversals over the parent's accumulated vocabulary for a + result the parent's own finalize overwrites — `O(children x + vocabulary)`, quadratic in a file's function count. Only the WMC third + is load-bearing there (`wmc::Stats::merge` dispatches on the parent's + recorded `space_kind`), so only it survives in the pop arm. Metric + values are unchanged. On the widest corpus file (1,808 top-level + spaces) this is ~10% of the walk; `Limits::default` caps files at + 64 KiB, so the corpus average does not move. + +- The per-metric unit-test modules compute only the metric family they + assert plus its declared dependencies, instead of all thirteen + (#1127). Single-threaded per-run minima of the all-features lib test + binary: CPU 4.64 s to 4.20 s, and the 2,317-test `metrics::` tranche + alone 1.16 s to 0.95 s. Values are unchanged, pinned by a new + `metric_selection_parity` test asserting a restricted walk reproduces + the full walk's per-space values for every metric in the selection's + resolved closure. + +- The workspace's 68 integration test files are now 12 directory test + targets, and `[profile.dev]` sets `debug = "line-tables-only"` + (#1124). Test binaries drop from 13.03 GB to 1.74 GB, `target/debug` + from 18.8 GB to 4.9 GB, and a relink after a one-line `src/lib.rs` + edit from ~90 to ~28 CPU-seconds. No test bodies changed; the + before/after `cargo nextest list` sets were compared to prove nothing + was dropped. + +- The VCS per-function perf fixture builds 50 commits rather than 200, + with its wall-clock budget re-derived from 30 s to 8 s at the same + 57x headroom (#1125). Cuts 300 `git` spawns and roughly halves the + `vcs_per_function` binary. Its work-product assertion was tightened + from "some function has history" to exact per-function commit counts, + so a shrunk fixture cannot pass while covering less. + +- CLI integration fixtures are served from one shared, content-addressed + directory instead of being rewritten per test (#1126). + - `bca check` computes only the metric families its resolved thresholds read, instead of the whole suite (#1113). Over `tests/repositories/DeepSpeech` (12.7k files), median user CPU of five @@ -501,6 +555,22 @@ for historical reference. ### Fixed +- Corrected the inverted doc comment on `python_apply_boolean_operator`, + which described its ancestor walk as counting control constructs and + stopping at lambdas when `count_specific_ancestors`'s + `(ancestors, check, stop)` order makes it do the reverse (#1090). Adds + a test discriminating the previously-untested `ExpressionList` stop + arm through both routes that reach one under a lambda — a parenthesised + `yield` and an f-string interpolation. No metric values change. + +- `make bench-scaling` now measures two axes (#1133). `Probe` carries an + `Axis` (`Depth` or `Width`), and the new `nom/wide-attributed-fn` + probe sweeps one parent's child count so a walk that is linear in + nesting depth but quadratic in a parent's child count fails the gate — + the class #1100's rejected fix belonged to, which every existing probe + passed. Falsified against that fix: exponent 0.97 clean, 1.99 with it + reinstated, while all depth probes stayed green. + - `bca vcs`, `bca vcs commit`, and `bca vcs trend` exit `0` again when their consumer closes the pipe (`bca vcs … | head`). The `write_text` flush below made the resulting `EPIPE` visible, and those From 985b0ff3d739bd66e948c1452486e2e98fd08309 Mon Sep 17 00:00:00 2001 From: Elijah Zupancic Date: Sat, 1 Aug 2026 12:43:53 -0700 Subject: [PATCH 35/36] fix(test): gate two imports on their only consumer's cfg Both are dead under a non-default cfg and the workspace builds with `RUSTFLAGS=-D warnings`, so each failed a CI leg that a default local build cannot reach: - `MetricSet` in `spaces_tests.rs` is used only by the `#[cfg(feature = "rust")]` parity test, failing `features (no-default-features (lib))`. - `crate::common` in the CLI's `read_failures.rs` is re-exported only for its `#[cfg(unix)] mod unix`, failing `test (windows-latest)`. --- big-code-analysis-cli/tests/discovery/read_failures.rs | 4 ++++ src/spaces_tests.rs | 8 +++++--- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/big-code-analysis-cli/tests/discovery/read_failures.rs b/big-code-analysis-cli/tests/discovery/read_failures.rs index 3efc88dec..cf2c38266 100644 --- a/big-code-analysis-cli/tests/discovery/read_failures.rs +++ b/big-code-analysis-cli/tests/discovery/read_failures.rs @@ -51,6 +51,10 @@ //! survive on Windows and the workspace `missing_docs` lint stays quiet //! there. +// Re-exported for `mod unix` below, so it carries that module's gate: +// the workspace builds tests with `-D warnings`, and an ungated import +// is dead on Windows. +#[cfg(unix)] use crate::common; #[cfg(unix)] diff --git a/src/spaces_tests.rs b/src/spaces_tests.rs index 60be63e3a..0772c0cf1 100644 --- a/src/spaces_tests.rs +++ b/src/spaces_tests.rs @@ -1601,9 +1601,11 @@ end // `cognitive_only_pulls_nom_and_average_is_finite`); an *undeclared* // coupling between the walker's per-metric gates was not. mod metric_selection_parity { - use crate::{ - CodeMetrics, FuncSpace, LANG, Metric, MetricSet, MetricsOptions, Source, SpaceKind, analyze, - }; + use crate::{CodeMetrics, FuncSpace, LANG, Metric, MetricsOptions, Source, SpaceKind, analyze}; + // Only the `rust`-gated parity test resolves a selection, so an + // ungated import is dead under `--no-default-features`. + #[cfg(feature = "rust")] + use crate::MetricSet; use serde_json::Value; // Deliberately non-default for all thirteen metrics at once: public From 462bbacca92f8e62a505225b5652ebdd2501cb6a Mon Sep 17 00:00:00 2001 From: Elijah Zupancic Date: Sat, 1 Aug 2026 13:41:47 -0700 Subject: [PATCH 36/36] test(vcs): pin BlameSession's Debug contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Its hand-written `Debug` had no test: the impl exists so a config dump carries the memo count instead of the `gix::Repository` handle and the mailmap, and nothing held it to that. Both assertions are positive, and the count is seeded to 2 first because a fresh session reports 0 — the same value a constant would. Verified by perturbation: `finish_non_exhaustive` to `finish`, and the memo length to a constant, each fail this test alone. --- tests/vcs/vcs_per_function.rs | 48 +++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/tests/vcs/vcs_per_function.rs b/tests/vcs/vcs_per_function.rs index 5e7f018d3..31491cae9 100644 --- a/tests/vcs/vcs_per_function.rs +++ b/tests/vcs/vcs_per_function.rs @@ -559,3 +559,51 @@ fn session_memoises_commits_across_files() { "re-blaming a file must resolve nothing new" ); } + +/// `BlameSession`'s `Debug` is hand-written so a config dump carries the +/// memo count rather than the `gix::Repository` handle and the mailmap. +/// Both assertions are positive: the head pins that `engine` renders +/// through `PerFunctionBlame`'s own eliding `Debug` instead of as a raw +/// handle, and the tail pins that `commits_resolved` is the last field +/// emitted before `finish_non_exhaustive`'s `..` — which is what makes +/// the elision observable without asserting any field's absence. +/// +/// The count is seeded to 2 first: a fresh session reports 0, and 0 is +/// also what an impl reading the wrong field, or printing a constant, +/// would report. +#[test] +fn session_debug_reports_the_memo_count_and_elides_the_repository() { + let repo = Repo::init(); + repo.write("src/a.rs", TWO_FUNCS); + repo.commit("Ada", "ada@example.com", FIXED_NOW - 200 * DAY, "create a"); + repo.write( + "src/a.rs", + "fn first() {\n let x = 1;\n}\nfn second() {\n let y = 22;\n}\n", + ); + repo.commit("Alan", "alan@example.com", FIXED_NOW - 5 * DAY, "edit a"); + + let engine = + std::sync::Arc::new(PerFunctionBlame::open(repo.path(), opts()).expect("open engine")); + let mut session = engine.session(); + session + .per_function( + &repo.path().join("src/a.rs"), + &[LineSpan::new(1, 3), LineSpan::new(4, 6)], + ) + .expect("blame a"); + assert_eq!( + session.commits_resolved(), + 2, + "the fixture must seed a memo count distinguishable from the default" + ); + + let rendered = format!("{session:?}"); + assert!( + rendered.starts_with("BlameSession { engine: PerFunctionBlame {"), + "engine must render through PerFunctionBlame's eliding Debug: {rendered}" + ); + assert!( + rendered.ends_with("commits_resolved: 2, .. }"), + "the memo count must be the last field before the elision marker: {rendered}" + ); +}