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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions .bca-baseline.toml
Original file line number Diff line number Diff line change
Expand Up @@ -728,14 +728,14 @@ value = 16.0
[[entry]]
path = "src/metrics/cognitive.rs"
qualified = "tcl_switch_decision_arms"
start_line = 448
start_line = 454
metric = "nargs"
value = 7.0

[[entry]]
path = "src/metrics/cognitive.rs"
qualified = "tcl_switch_decision_arms"
start_line = 448
start_line = 454
metric = "nexits"
value = 6.0

Expand Down Expand Up @@ -938,16 +938,16 @@ value = 8.0
[[entry]]
path = "src/spaces/compute.rs"
qualified = "compute_per_node"
start_line = 217
start_line = 218
metric = "halstead.effort"
value = 61523.95778223676

[[entry]]
path = "src/spaces/compute.rs"
qualified = "metrics_inner"
start_line = 386
start_line = 446
metric = "halstead.effort"
value = 122486.96209573474
value = 142419.45851755128

[[entry]]
path = "src/suppression.rs"
Expand All @@ -961,7 +961,7 @@ path = "src/tools.rs"
qualified = "<file>"
start_line = 1
metric = "loc.sloc"
value = 767.0
value = 779.0

[[entry]]
path = "src/tools.rs"
Expand Down
54 changes: 47 additions & 7 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,40 @@ for historical reference.

### Fixed

- The `cognitive` metric's nesting lookup is no longer quadratic in
nesting depth (#1062). It recovered each node's inherited nesting via
`node.parent()`, which is `O(depth)` — tree-sitter stores no parent
pointer — making the lookup `O(nodes × depth)`. The walker now hands
each node its inherited nesting directly, so the lookup is `O(1)`.
Cognitive values are unchanged. On shapes that exercise only this path,
whole-file analysis is now linear: nested parentheses at depths
8000 / 16000 / 32000 / 64000 take 13 / 23 / 45 / 88 ms, so a 128 KB
file completes in under a tenth of a second.

The walker also no longer pre-seeds that map with the root. Nothing
read the seed — the lookup already falls back to a default — but it was
the map's only entry whenever `cognitive` is deselected, so a
metric-subset run (`--metrics loc`, `bca check` with a threshold
subset) allocated a hash table per file for one unread entry. The two
grammars whose cognitive impl is a no-op, `preproc` and `ccomment`, now
build no map at all rather than one entry per AST node.

**Deeply nested code is still superlinear in general** — this fixes one
of several `Node::parent` call sites, and *not* the one users hit most.
`Checker::is_else_if` calls `parent()` (or `previous_sibling()`, which
is implemented via `parent()`) for every `if`, across 13 languages, so
nested and `else if`-chained conditionals remain quadratic: a 4000-deep
C `if` chain takes ~3.3 s at depth 8000 where the equivalent `while`
chain takes 44 ms. Also outstanding: `Node::count_specific_ancestors`
(used by `loc` for C-family, Java, C#, Go, Groovy, Objective-C, and
twice per node by Python's boolean-operator handling),
`cognitive::increment_function_depth` (4000 nested Rust `fn`s, 55 KB →
~1.9 s), and Elixir's `is_inside_quote_block`, which runs for every
node behind no metric selection and so cannot be deselected (4000
nested `quote` blocks, 52 KB → ~2.7 s). All tracked in #1062, which
stays open; a shared parent lookup owned by the walker would retire
them together.

- Deeply nested source no longer costs quadratic time in the `tokens`
metric (#1052). `Tokens` decided whether a leaf sat inside a comment by
walking that leaf's ancestor chain — and `Node::parent` is itself
Expand Down Expand Up @@ -116,13 +150,19 @@ for historical reference.

### Security

- Removed a remotely-triggerable CPU-exhaustion vector in the `tokens`
metric — a few kilobytes of deeply nested source could pin a core for
minutes against the unauthenticated `bca-web` endpoints, whose parse
deadline frees the client but cannot cancel the blocking task. See the
`tokens` entry under **Fixed** (#1052) for detail and for the residual
superlinear paths that remain (#1062); operators analysing untrusted
input should still bound request concurrency.
- Narrowed a remotely-triggerable CPU-exhaustion vector: a few kilobytes
of deeply nested source could pin a core for minutes against the
unauthenticated `bca-web` endpoints, whose parse deadline frees the
client but cannot cancel the blocking task. Two of the quadratic paths
are gone — the `tokens` ancestor walk (#1052) and the `cognitive`
nesting lookup (#1062); see those entries under **Fixed**.

**This is not closed.** `Checker::is_else_if` still calls
`Node::parent` per `if` across 13 languages, so deeply nested or
`else if`-chained conditionals — ordinary generated and state-machine
code, not just adversarial input — remain quadratic, along with three
further ancestor walks. All tracked in #1062. Operators analysing
untrusted input must still bound request concurrency and input size.
- Cleared the two RUSTSEC advisories behind the OpenSSF Scorecard
Vulnerabilities alert: `anyhow` `1.0.102` → `1.0.103` (unsound
`Error::downcast_mut()`, RUSTSEC-2026-0190) and `memmap2` `0.9.10`
Expand Down
2 changes: 1 addition & 1 deletion src/macros/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ macro_rules! implement_metric_trait {
_node: &Node<'a>,
_code: &'a [u8],
_stats: &mut Stats,
_nesting_map: &mut HashMap<usize, (usize, usize, usize)>,
_nesting_map: &mut crate::spaces::NestingMap,
) {}
}
)+
Expand Down
186 changes: 173 additions & 13 deletions src/metrics/cognitive.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
clippy::cast_sign_loss
)]

use std::collections::HashMap;
use crate::spaces::{Nesting, NestingMap};

use std::fmt;

Expand Down Expand Up @@ -169,7 +169,7 @@ where
node: &Node<'a>,
code: &'a [u8],
stats: &mut Stats,
nesting_map: &mut HashMap<usize, (usize, usize, usize)>,
nesting_map: &mut NestingMap,
);
}

Expand Down Expand Up @@ -251,14 +251,16 @@ fn increment_branch_extension(stats: &mut Stats) {
stats.boolean_seq.reset();
}

fn get_nesting_from_map(
node: &Node,
nesting_map: &HashMap<usize, (usize, usize, usize)>,
) -> (usize, usize, usize) {
node.parent()
.and_then(|parent| nesting_map.get(&parent.id()))
.copied()
.unwrap_or((0, 0, 0))
/// Returns the [`Nesting`] `node` inherits from its parent.
///
/// The map is keyed so that a node's own slot holds what it *inherits*:
/// the walker seeds each child's slot from its parent's slot after the
/// parent's `compute` has run (see `propagate_nesting_to_children` in
/// `spaces::compute`). Reading `node.parent()` here instead would cost
/// `O(depth)` per node — `Node::parent` walks down from the root — which
/// made this metric quadratic in nesting depth (#1062).
fn get_nesting_from_map(node: &Node, nesting_map: &NestingMap) -> Nesting {
nesting_map.get(&node.id()).copied().unwrap_or_default()
}

fn increment_function_depth<T: PartialEq + From<u16>>(depth: &mut usize, node: &Node, stops: &[T]) {
Expand Down Expand Up @@ -304,10 +306,14 @@ macro_rules! js_cognitive {
node: &Node<'a>,
_code: &'a [u8],
stats: &mut Stats,
nesting_map: &mut HashMap<usize, (usize, usize, usize)>,
nesting_map: &mut NestingMap,
) {
use $lang::*;
let (mut nesting, mut depth, mut lambda) = get_nesting_from_map(node, nesting_map);
let Nesting {
conditional: mut nesting,
function_depth: mut depth,
mut lambda,
} = get_nesting_from_map(node, nesting_map);

match node.kind_id().into() {
IfStatement if !Self::is_else_if(node) => {
Expand Down Expand Up @@ -363,7 +369,10 @@ macro_rules! js_cognitive {
}
_ => {}
}
nesting_map.insert(node.id(), (nesting, depth, lambda));
nesting_map.insert(
node.id(),
Nesting { conditional: nesting, function_depth: depth, lambda },
);
}
};
}
Expand Down Expand Up @@ -9381,4 +9390,155 @@ end",
nested_sum.get(),
);
}

/// Pins that `function_depth` and `lambda` are distinguishable.
///
/// They are summed symmetrically almost everywhere, so most inputs
/// cannot tell them apart. The one asymmetric operation in the whole
/// cognitive family is the JS `FunctionDeclaration` arm's
/// `lambda = 0`, which clears one field alone.
///
/// Whether that separates them depends on the *parity* of the
/// ancestor chain between the innermost arrow and the
/// `function_declaration`, because a swap at the write site
/// transposes the pair at every node on the way down. The plain
/// `arrow -> statement_block -> function_declaration` chain has odd
/// parity and stays at the same total either way; a second chained
/// arrow flips it. Hence the doubled arrow here — without it this
/// test passes with the two fields transposed and guards nothing.
#[test]
fn javascript_function_depth_and_lambda_are_distinguishable() {
// expected: the inner `function` resets lambda, so only its own
// function-depth level survives and the `if` costs 1.
check_metrics::<JavascriptParser>(
"const f = () => () => { function inner() { if (a) { } } };",
"nest.js",
|metric| {
assert_eq!(metric.cognitive.cognitive_sum(), 1);
},
);
}

/// The `ArrowFunction` arm's own `lambda += 1`, pinned separately:
/// with no `function_declaration` between the arrow and the `if`,
/// nothing resets lambda, so the arrow's level reaches the `if`.
#[test]
fn javascript_arrow_contributes_lambda_nesting() {
// expected: 1 for the `if`, +1 for the enclosing arrow level.
check_metrics::<JavascriptParser>(
"const f = () => { if (a) { } };",
"arrow.js",
|metric| {
assert_eq!(metric.cognitive.cognitive_sum(), 2);
},
);
}

/// Deeply nested input must stay tractable (#1062).
///
/// `get_nesting_from_map` used to recover a node's inherited nesting
/// via `node.parent()`, which is `O(depth)` — tree-sitter stores no
/// parent pointer — making the metric `O(nodes × depth)`. The walker
/// now seeds each child's slot from its parent's, so the lookup is
/// `O(1)`.
///
/// Uses `while`, deliberately, **not** `if`: `Checker::is_else_if`
/// calls `node.parent()` for every `if_statement`, so nested `if`s
/// are still quadratic for reasons this fix does not touch. Anchoring
/// the assertion to that shape would measure the unfixed path — at
/// nesting depth 8000 a release build takes 3.3 s of `if` versus
/// 44 ms of `while`.
///
/// Asserts a *ratio* rather than a wall-clock budget. The pre-fix code
/// produced identical values, only quadratically, so a value assertion
/// alone would let a regression hang CI instead of failing it — but an
/// absolute budget is calibrated to one machine and this suite also
/// runs under `cargo llvm-cov` and on shared Windows / macOS runners.
/// Doubling the depth costs ~2x when the lookup is `O(1)` and ~4x when
/// it is `O(depth)`, and that ratio is independent of host speed.
/// The wall-clock half of this test is opt-in via
/// `BCA_ASSERT_SCALING=1`. The value assertions above it always run.
///
/// A timing assertion has now produced a false failure in three
/// different environments: `windows-latest` in CI (10.9 s against an
/// 8 s absolute budget), a local `make pre-commit` running clippy and
/// rustdoc alongside the suite (5.6x), and `cargo llvm-cov`, whose
/// instrumentation skewed even a best-of-three ratio to 3.5x. The
/// `coverage` job runs in CI, so leaving it armed reds the build on a
/// measurement artefact rather than a regression. Complexity guards
/// belong in a benchmark harness (#1068), not the unit suite.
#[test]
fn cognitive_deep_nesting_is_tractable() {
// Restricted to `Cognitive` — which pulls in `Nom` as a declared
// dependency, so this narrows the work rather than isolating it.
fn cognitive_of(source: &str) -> (u64, std::time::Duration) {
let started = std::time::Instant::now();
let sum = crate::tools::metrics_verbatim(
crate::LANG::C,
source.as_bytes(),
crate::MetricsOptions::default().with_only(&[crate::Metric::Cognitive]),
)
.cognitive
.cognitive_sum();
(sum, started.elapsed())
}

// Each level adds its own nesting penalty, so cognitive grows as
// 1 + 2 + … + n. Asserting it pins that nesting is still inherited
// correctly at depth, independently of the timing below.
let nested_whiles = |n: usize| -> String {
format!(
"int main(){{ {} 1; {} }}\n",
"while (a) { ".repeat(n),
"} ".repeat(n)
)
};
let expected = |n: u64| n * (n + 1) / 2;

assert_eq!(cognitive_of(&nested_whiles(3)).0, expected(3), "1 + 2 + 3");

// Best-of-three per depth. A single pair of timings is not
// robust enough here: the two measurements are sequential, so a
// load spike between them skews the ratio on its own. This test
// did flake once inside `make pre-commit`, which runs clippy,
// rustdoc and the Python stages alongside the suite — 70 ms vs
// 394 ms, a 5.6x reading on code that is linear. Contention is
// bursty, so the minimum of a few runs sheds it while a genuine
// O(depth) regression, which is present in every run, survives.
let best_of_three = |n: usize| -> (u64, std::time::Duration) {
let mut sum = 0;
let mut best = std::time::Duration::MAX;
for _ in 0..3 {
let (s, elapsed) = cognitive_of(&nested_whiles(n));
sum = s;
best = best.min(elapsed);
}
(sum, best)
};

let base = 2_000;
let (shallow_sum, shallow_time) = best_of_three(base);
let (deep_sum, deep_time) = best_of_three(base * 2);
assert_eq!(shallow_sum, expected(base as u64));
assert_eq!(deep_sum, expected(base as u64 * 2));

// Wall clock is only asserted when this test is run explicitly.
// See the `#[ignore]` rationale on the attribute below: three
// separate environments have produced a false reading here, and
// a red build from a timing artefact costs more than this guard
// is worth inside the default suite.
if std::env::var_os("BCA_ASSERT_SCALING").is_none() {
return;
}

// Guard against a zero denominator on a very fast machine.
let shallow_ns = shallow_time.as_nanos().max(1);
let ratio = deep_time.as_nanos() as f64 / shallow_ns as f64;
assert!(
ratio < 3.0,
"doubling nesting depth cost {ratio:.1}x ({shallow_time:?} -> {deep_time:?}); \
an O(1) inherited lookup scales ~2x, the O(depth) `Node::parent` \
lookup this replaced scales ~4x (#1062)"
);
}
}
17 changes: 14 additions & 3 deletions src/metrics/cognitive/bash.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,15 @@ impl Cognitive for BashCode {
node: &Node<'a>,
_code: &'a [u8],
stats: &mut Stats,
nesting_map: &mut HashMap<usize, (usize, usize, usize)>,
nesting_map: &mut NestingMap,
) {
use Bash::*;

let (mut nesting, mut depth, lambda) = get_nesting_from_map(node, nesting_map);
let Nesting {
conditional: mut nesting,
function_depth: mut depth,
lambda,
} = get_nesting_from_map(node, nesting_map);

match node.kind_id().into() {
// `WhileStatement` covers both `while` and `until`; `ForStatement`
Expand Down Expand Up @@ -51,6 +55,13 @@ impl Cognitive for BashCode {
}
_ => {}
}
nesting_map.insert(node.id(), (nesting, depth, lambda));
nesting_map.insert(
node.id(),
Nesting {
conditional: nesting,
function_depth: depth,
lambda,
},
);
}
}
Loading