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
10 changes: 5 additions & 5 deletions .bca-baseline.toml
Original file line number Diff line number Diff line change
Expand Up @@ -339,7 +339,7 @@ value = 66737.04848699087
path = "big-code-analysis-cli/src/thresholds.rs"
qualified = "<file>"
metric = "loc.ploc"
value = 554.0
value = 562.0

[[entry]]
path = "big-code-analysis-cli/src/thresholds.rs"
Expand Down Expand Up @@ -855,13 +855,13 @@ value = 15.0
path = "src/metrics/abc/go.rs"
qualified = "GoCode::compute"
metric = "cyclomatic"
value = 15.0
value = 17.0

[[entry]]
path = "src/metrics/abc/go.rs"
qualified = "GoCode::compute"
metric = "halstead.effort"
value = 50933.14135135399
value = 55577.39635901512

[[entry]]
path = "src/metrics/abc/groovy.rs"
Expand Down Expand Up @@ -1257,7 +1257,7 @@ value = 6.0
path = "src/vcs/git/cached.rs"
qualified = "build_cached"
metric = "halstead.effort"
value = 76740.04063247392
value = 73179.83359417088

[[entry]]
path = "src/vcs/git/cached.rs"
Expand Down Expand Up @@ -1315,7 +1315,7 @@ value = 8.0

[[entry]]
path = "src/vcs/git/history.rs"
qualified = "diff_collect::<anon@L217>"
qualified = "diff_collect::<anon@L213>"
metric = "nexits"
value = 6.0

Expand Down
115 changes: 115 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,113 @@ for historical reference.

### Fixed

- The `enums` code generator minted collision-breaking names
(`Foo` → `Foo2`) without registering them, so a minted suffix could
silently duplicate a node kind whose own sanitized name is literally
`base` + digits. `tree-sitter-php` is one aliased rule away from
exactly that — it already emits `cast_type_token1` through
`cast_type_token12` — and the generator would have exited `0`, leaving
the duplicate to surface as a rustc duplicate-variant error in the
parent crate during a grammar bump, where it reads as upstream
breakage. Minted names are now probed against, and registered in, the
taken-name set. No shipped artifact changes: no current grammar
reaches the collision, so every generated file is byte-identical
(#1237).
- `bca check --baseline` rendered a malformed, double-signed
`[regr +-29%]` tag for every `mi.*` regression. `Baseline::classify`
has been direction-aware since #827 — for the lower-is-worse `mi.*`
family a *drop* below the recorded value is the regression — but the
tag formatter still computed `(value - recorded) / recorded` and
prefixed a literal `+`, so the percentage carried its own minus sign.
Anything grepping the documented `[regr +N%]` shape mis-parsed it. The
magnitude is now measured in the metric's own direction, keeping one
tag shape across every metric; the higher-is-worse rendering is
unchanged (#1242).
- The VCS bot-author filter was case-sensitive despite
`DEFAULT_BOT_PATTERN`'s doc promising the opposite since the option
shipped. `BotFilter::new` compiled with a plain `Regex`, and
`push_if_human` matches the raw signature bytes before any
lowercasing, so `Dependabot[bot]` was counted as a human author while
its lowercase twin was excluded — diluting ownership and raising file
risk on the same repository depending only on how a bot capitalised
itself. A user pattern behaves as written now too:
`--bot-pattern renovate` matches `Renovate Bot`. Prefix `(?-i)` to
restore exact matching. `CACHE_SCHEMA_VERSION` moves 1 → 2, since the
cache fingerprint hashes only the pattern string and an event log
walked under the old matcher would otherwise replay with its excluded
set intact; the bump costs one cold walk (#1265).
- The VCS window-boundary computation was non-saturating in two of its
six sites — `build_cached`, the *default* path for `bca vcs`, and
`BlameWalk`'s constructor. Both operands are attacker-adjacent: the
reference time is an `--as-of` value or a committer timestamp read out
of an object header, and the window length is user-supplied up to
roughly `i64::MAX` through `--long-window`. A plain `-` therefore
panicked in a debug build and, in release, wrapped to a boundary in
the far *future*, silently reversing the pure-hit check, the candidate
filter, `prune_and_dedup`, and the `walk_long_boundary` persisted for
future runs. All six sites now route through one
`vcs::options::window_boundary` helper that documents and pins the
saturation invariant once. `days_between` carried the same hazard
under a `.max(0)` clamp that a wrapped (positive) delta defeats, and
saturates too (#1271).
- A Bash arithmetic ternary — the only ternary form Bash has —
contributed nothing to cyclomatic or cognitive complexity, so
`local m=$(( a > b ? a : b ))` reported cyclomatic 1 (base only) and
cognitive 0, where the same construct scores 2 / 1 in every sibling
that has a ternary. It is now a decision point in both cyclomatic
tiers and a nesting construct for cognitive, matching the C-family
`ConditionalExpression`. Both arithmetic contexts are covered — the
`$(( … ))` expansion and the bare `(( … ))` statement. Bash
`cyclomatic`, `cognitive` and the `mi` family derived from them shift
for files using arithmetic ternaries (#1268).
- Go ABC scored zero assignments for a `var` declaration with an
initializer. `var x = 5` and `x := 5` are the same binding spelled two
ways, but only the latter is a `short_var_declaration`; the former is a
`var_spec`, which no arm matched. An initialized `var_spec` — typed or
not, standalone or inside a grouped `var ( … )` block — is now one
assignment, matching what Rust, Java, C, C++, C#, JS, Lua and PHP
already count for the same construct (each measured). `const` stays
excluded, as does an uninitialized `var z int`. Go `abc` values rise
by one per initialized `var` spec (#1278).
- Constructor delegation scored zero ABC branches in Java, C# and
Kotlin. `super(…)` / `this(…)` parses as `explicit_constructor_invocation`
in Java, `: base(…)` / `: this(…)` as `constructor_initializer` in C#,
and `: super(…)` as `constructor_delegation_call` in Kotlin — none of
which is the call kind each language's branch counter matched, so a
delegation contributed nothing where Groovy scored one for identical
source. Each is now one branch, per Fitzpatrick's "one per function
call". Calls in a delegation's argument list are separate nodes and
still count on their own, so `super(f())` is two branches, not three.
Java, C# and Kotlin `abc` values rise by one per delegating
constructor (#1279).
- Ruby ABC counted the `<` of a superclass clause as a comparison
condition, so every subclass declaration scored a phantom condition —
`class Foo < Bar` with a single assignment inside reported
`conditions = 1` for a file with no conditional in it. `LT` / `GT` are
now counted only under a `binary` parent, the positive polarity Rust,
Go, C, C++, Java, Groovy, C#, Kotlin and the JS family already use.
The same gate stops counting the `<` that names an operator method
(`def <(other)`). Real comparisons, `<=>`, the `<<` shovel and heredoc
openers are unaffected. Ruby `abc` values drop by one per subclass
declaration and per operator-method definition (#1280). The gate now
covers the sibling comparison and equality tokens too — `==`, `!=`,
`===`, `<=`, `>=`, `<=>`, `=~`, `!~` are each equally definable as an
operator method, so `def ==(other)` scored the same phantom condition
`def <(other)` did.
- Bash ABC counted every I/O redirection as a condition. `>` and `<`
spell a redirect as well as a comparison, and the grammar parents the
redirect under `file_redirect`, so `echo hi > out.txt` reported
`conditions = 1` for a line with no test in it — the Bash instance of
the #1280 polarity, missed by that issue's cross-language sweep. Both
tokens now require a `binary_expression` parent; comparisons inside
`[[ … ]]` and `(( … ))` are unaffected. Bash `abc` values drop by one
per redirection (#1280).
- Bash ABC scored no condition for the arithmetic ternary, so
`local m=$(( a > b ? a : b ))` reported 1 where the equivalent C
`int m = a > b ? a : b;` reports 2. #1268 brought Bash's only ternary
form into cyclomatic and cognitive; ABC now counts it too, matching
the C-family `ConditionalExpression`. Bash `abc` values rise by one
per arithmetic ternary (#1268).
- `fix_includes` returned its diagnostics in a different order on every
run for identical input, because both producers push while iterating a
`HashMap` — `files` in `build_include_graph`, `nodes` in
Expand Down Expand Up @@ -261,6 +368,14 @@ for historical reference.

### Changed

- The `enums` generator's `sanitize_string` / `get_token_names` lost their
`escape: bool` parameter. No production caller passed `true`: #862
established that the JSON generator, the last one, was double-escaping
by mistake. Removing the flag deletes the dead double-backslash branch,
its test, and the `JSON_TOKEN_ESCAPE` constant that existed only to
document why the answer must be `false` — making the #862 bug
unrepresentable rather than one flipped boolean away. Internal build
tool only; generated output is byte-identical (#1241).
- The workspace-excluded `enums` crate declares the workspace lint posture
(`clippy::pedantic`, `missing_docs`) in its own manifest.
`[workspace.lints]` reaches members only, so `make enums-check` had been
Expand Down
6 changes: 5 additions & 1 deletion big-code-analysis-book/src/commands/vcs.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,11 @@ standalone page, pass [`bca report --vcs`](report.md), which appends a
Author identities are canonicalised through the repository `.mailmap`
and counted by lowercased email; `Co-authored-by:` trailers add
participants. Bot identities (`dependabot[bot]`, `renovate[bot]`,
`github-actions[bot]`, …) are excluded by default. Binary files and
`github-actions[bot]`, …) are excluded by default. The pattern —
the built-in one or your own `--bot-pattern` — is matched
case-insensitively as a substring against both the author name and the
email, so `--bot-pattern renovate` catches `Renovate Bot`; prefix
`(?-i)` to match case exactly. Binary files and
symlinks are skipped; an untracked file has no record at all (distinct
from a tracked file with zero in-window activity).

Expand Down
6 changes: 5 additions & 1 deletion big-code-analysis-book/src/recipes/baselines.md
Original file line number Diff line number Diff line change
Expand Up @@ -199,7 +199,11 @@ Tag prefixes:
written. See [Matching](#how-matching-works) for the resolution
order.
- `[regr +N%]` — the baseline contains a recorded value and the
current value is `N%` higher. Cases:
current value is `N%` worse. "Worse" is measured in the metric's own
direction, so for the lower-is-worse `mi.*` family a *drop* of `N%`
below the recorded value reads `+N%` like any other regression; the
tag has one shape across every metric, which is what makes it safe to
grep for. Cases:
- `[regr from 0]` when the recorded value is `0.0` and a non-zero
percentage would divide by zero.
- `[regr +>9999%]` caps once the regression exceeds 100× the
Expand Down
11 changes: 7 additions & 4 deletions big-code-analysis-book/src/recipes/ci.md
Original file line number Diff line number Diff line change
Expand Up @@ -302,10 +302,13 @@ so a developer can tell at a glance whether they are looking at a
brand-new offender or a known one that has worsened:

- `[new]` — no baseline entry for this function / metric.
- `[regr +N%]` — current value exceeds the recorded baseline by `N`
percent. Special forms: `[regr from 0]` when the baseline value
was zero, `[regr +>9999%]` when the regression exceeds 100× the
baseline, `[regr NaN]` when the current value is NaN.
- `[regr +N%]` — current value is `N` percent worse than the recorded
baseline, measured in the metric's own direction (for the
lower-is-worse `mi.*` family that is a drop, still rendered `+N%`, so
the tag has one greppable shape). Special forms: `[regr from 0]` when
the baseline value was zero, `[regr +>9999%]` when the regression
exceeds 100× the baseline, `[regr NaN]` when the current value is
NaN.

The violation rows go to stdout; everything below goes to stderr.
See [Which stream](../commands/check.md#which-stream) for the full
Expand Down
14 changes: 9 additions & 5 deletions big-code-analysis-cli/src/baseline.rs
Original file line number Diff line number Diff line change
Expand Up @@ -489,11 +489,15 @@ impl Baseline {
// parser also rejects nan/inf). Negative is the same idea
// for the rendered-tag path: `format_regressed_tag` divides
// by `recorded` to compute `+N%`, and a negative `recorded`
// produces a double-signed `[regr +-N%]` tag. Metric
// extractors never emit negative values, so the only way
// an entry can be negative is hand-editing — silently
// drop and treat the entry as if missing (the violation
// will surface as `New`).
// flips the quotient's sign, producing a double-signed
// `[regr +-N%]` tag. Metric extractors never emit negative
// values, so the only way an entry can be negative is
// hand-editing — silently drop and treat the entry as if
// missing (the violation will surface as `New`). This filter
// was never the only route to that malformed tag, though:
// until #1242 an ordinary `mi.*` regression produced it from
// a perfectly well-formed baseline, because the numerator
// was computed in the higher-is-worse direction.
//
// `is_sign_negative()` catches `-0.0` too (which `< 0.0`
// misses under IEEE 754: `-0.0 < 0.0` is false). `-0.0`
Expand Down
4 changes: 3 additions & 1 deletion big-code-analysis-cli/src/cli_args/vcs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,9 @@ pub(crate) struct VcsArgs {
/// Do not exclude bot author identities.
#[clap(long, global = true)]
pub(crate) no_exclude_bots: bool,
/// Override the bot-author exclusion regex.
/// Override the bot-author exclusion regex. Matched
/// case-insensitively against both the author name and the email;
/// prefix `(?-i)` to match case exactly.
#[clap(long, global = true)]
pub(crate) bot_pattern: Option<String>,
/// Reference "now" for reproducible runs (RFC 3339, `@unix`, or any
Expand Down
31 changes: 25 additions & 6 deletions big-code-analysis-cli/src/thresholds.rs
Original file line number Diff line number Diff line change
Expand Up @@ -695,7 +695,10 @@ pub(crate) fn render_violation_line(v: &Violation, tag: Option<&Coverage>) -> St
None | Some(Coverage::Covered { .. }) => format!("{v}"),
Some(Coverage::New) => format!("[new] {v}"),
Some(Coverage::Regressed { recorded }) => {
format!("{} {v}", format_regressed_tag(*recorded, v.value))
format!(
"{} {v}",
format_regressed_tag(*recorded, v.value, v.lower_is_worse)
)
}
}
}
Expand All @@ -707,23 +710,39 @@ pub(crate) fn render_violation_line(v: &Violation, tag: Option<&Coverage>) -> St
/// - `pct > 9999` → `[regr +>9999%]` (cap; 100× the baseline is
/// already screaming-loud, exact number adds nothing).
/// - else → `[regr +N%]` with N rounded to nearest integer.
fn format_regressed_tag(recorded: f64, value: f64) -> String {
///
/// The magnitude is measured in the metric's own direction, so the tag
/// always reads "worse by N%" and log parsers see one shape across every
/// metric. For the lower-is-worse `mi.*` family that means inverting the
/// difference, matching how [`Violation::ratio`] already inverts. Before
/// #1242 this function assumed the higher-is-worse direction and printed
/// a literal `+` in front of what was then a negative percentage, so an
/// ordinary `mi.original` regression rendered the double-signed
/// `[regr +-29%]`.
fn format_regressed_tag(recorded: f64, value: f64, lower_is_worse: bool) -> String {
if value.is_nan() {
return "[regr NaN]".to_string();
}
if recorded == 0.0 {
return "[regr from 0]".to_string();
}
let pct = ((value - recorded) / recorded * 100.0).round();
let worse_by = if lower_is_worse {
recorded - value
} else {
value - recorded
};
let pct = (worse_by / recorded * 100.0).round();
if pct > 9999.0 {
return "[regr +>9999%]".to_string();
}
// `{:.0}` formats the rounded float with zero decimal digits, so
// we avoid an f64-to-int cast that clippy flags as possibly
// truncating. `pct` is finite (caller filtered NaN and zero
// `recorded`), bounded above by 9999 here, and bounded below by 0
// because the classifier only emits Regressed when
// `value > recorded`.
// `recorded`), bounded above by 9999 here, and non-negative because
// the classifier only emits Regressed when the value is worse than
// `recorded` *in the metric's direction* — which is the direction
// `worse_by` is now measured in. A regression too small to round to
// one percent renders `[regr +0%]`, as it always has.
format!("[regr +{pct:.0}%]")
}

Expand Down
34 changes: 34 additions & 0 deletions big-code-analysis-cli/src/thresholds_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -725,6 +725,40 @@ fn render_regressed_integer_percent() {
assert!(out.starts_with("[regr +50%] "), "got: {out}");
}

#[test]
fn render_regressed_lower_is_worse_tag_is_well_formed() {
// Regression for #1242. `Baseline::classify` is direction-aware, so
// for the lower-is-worse `mi.*` family it emits `Regressed` when the
// value *drops* below the recorded one. The tag formatter assumed
// the opposite direction and prefixed a literal `+` to what was then
// a negative percentage, rendering `[regr +-29%]` — a shape no log
// parser anchored on `[regr +N%]` can read. The magnitude is now
// measured in the metric's own direction, so it stays "worse by N%".
// Values are the issue's reproducer: `mi.original` recorded at
// 142.75 dropping to 101.03 is (142.75 - 101.03) / 142.75 ≈ 29%.
let mut v = sample_violation();
v.metric = "mi.original";
v.lower_is_worse = true;
v.value = 101.03;
v.limit = 160.0;
let out = render_violation_line(&v, Some(&Coverage::Regressed { recorded: 142.75 }));
assert!(out.starts_with("[regr +29%] "), "got: {out}");
assert!(!out.contains("+-"), "double-signed percentage: {out}");
}

#[test]
fn render_regressed_direction_is_not_a_blanket_sign_flip() {
// The companion to the test above: fixing the `mi.*` direction must
// not invert the higher-is-worse one. A cyclomatic rise from 20 to
// 30 is still `+50%`, not `-50%` — pinned here because a sign flip
// in the wrong branch would satisfy the test above on its own
// (#1242).
let v = sample_violation();
assert!(!v.lower_is_worse, "the fixture must be higher-is-worse");
let out = render_violation_line(&v, Some(&Coverage::Regressed { recorded: 20.0 }));
assert!(out.starts_with("[regr +50%] "), "got: {out}");
}

#[test]
fn render_regressed_rounds_half_to_nearest_even_or_away() {
// Halstead can produce values that round to a nearby integer;
Expand Down
Loading