From 7f2a1feb304ac4b8b88f112e11aa7faa30d17b36 Mon Sep 17 00:00:00 2001 From: Elijah Zupancic Date: Mon, 14 Sep 2026 15:09:34 -0700 Subject: [PATCH 01/25] fix(getter/groovy): gate the Super arm on Wildcard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `super` is an operator only as a wildcard type bound (`List`), where it denotes no value and mirrors `? extends T`'s `extends`. In receiver position it names a value and is an operand. Groovy listed `Super` in an ungated operator alternation; Java has carried the parent gate since #1380. No metric moves. The pinned dekobon grammar spells `'super'` in the `wildcard` production and nowhere else, so every super-reference already parsed as a plain `identifier` and was already an operand. The gate removes that grammar accident: a bump routing a reference to the `super` token now classifies it correctly instead of billing it as an operator. The gate's operand branch is unreachable under the pin and left untested — only error recovery on invalid Groovy (`List`) detaches the token from its `wildcard`, and pinning that shape would make the grammar's over-permissiveness the contract. The existing node census is the drift marker for it. Splitting `Super` out of the shared alternation adds two arms to a match that already bails rustfmt on in-pattern section comments, so the rustfmt-bail baseline is ratcheted 5 -> 7. Fixes #1419 --- .rustfmt-bail-baseline.txt | 2 +- CHANGELOG.md | 11 +++++ big-code-analysis-ast/src/getter/groovy.rs | 44 ++++++++++++------- src/metrics/halstead.rs | 35 +++++++++++---- tests/parity/self_reference_operand_parity.rs | 23 +++++----- 5 files changed, 80 insertions(+), 35 deletions(-) diff --git a/.rustfmt-bail-baseline.txt b/.rustfmt-bail-baseline.txt index e0a280a1..9f2d36b9 100644 --- a/.rustfmt-bail-baseline.txt +++ b/.rustfmt-bail-baseline.txt @@ -129,7 +129,7 @@ big-code-analysis-ast/src/getter/cpp.rs 5 big-code-analysis-ast/src/getter/csharp.rs 8 big-code-analysis-ast/src/getter/elixir.rs 3 big-code-analysis-ast/src/getter/go.rs 1 -big-code-analysis-ast/src/getter/groovy.rs 5 +big-code-analysis-ast/src/getter/groovy.rs 7 big-code-analysis-ast/src/getter/irules.rs 6 big-code-analysis-ast/src/getter/java.rs 5 big-code-analysis-ast/src/getter/kotlin.rs 4 diff --git a/CHANGELOG.md b/CHANGELOG.md index f99adf46..4b1cdc69 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -135,6 +135,17 @@ for historical reference. ### Fixed +- **Groovy's Halstead `super` arm is gated on its `wildcard` parent, as + Java's is** (#1419). `super` is an operator only as a wildcard type + bound (`List`), where it denotes no value and mirrors + `? extends T`; in receiver position it names a value and is an + operand. No metric moves: the pinned dekobon grammar spells `super` in + the `wildcard` production alone, so every super-reference already + parsed as a plain `identifier` and was already an operand. The gate + removes the grammar accident, so a future grammar bump that routes a + reference to the `super` token classifies it correctly instead of + billing it as an operator. + - **ABC now counts a boolean test that the grammar gives its own production** (#1449). A construct spelled as a dedicated node rather than a `binary_expression` reaches no comparison-token arm, so Groovy's diff --git a/big-code-analysis-ast/src/getter/groovy.rs b/big-code-analysis-ast/src/getter/groovy.rs index ad8ea141..02aa49a5 100644 --- a/big-code-analysis-ast/src/getter/groovy.rs +++ b/big-code-analysis-ast/src/getter/groovy.rs @@ -129,27 +129,39 @@ impl Getter for GroovyCode { { TokenRole::Unknown } + // `super` is an operator only as the bound of a `wildcard` + // (`List`), where it denotes no value and mirrors + // `? extends T`'s `extends` — which the arm below bills as an + // operator. Everywhere else it names a receiver a `.`, `?.` + // or `::` acts on, so it is an operand. The parent alone + // separates the two, as #1380 settled for Java's + // identically-shaped arm. + // + // Forward compatibility, not a live fix: the pinned dekobon + // grammar spells `'super'` in the `wildcard` production and + // nowhere else, so every reference (`super(1)`, `super.h()`, + // `A.super.h()`, `super::h`, `super?.h()`) is already a plain + // `identifier` and an operand. Before the gate that agreement + // was a grammar accident — an ungated `Super` would bill a + // reference as an operator the moment a bump routed one here. + // + // The operand branch is consequently unreachable under the + // pin and deliberately untested: only error recovery on + // invalid Groovy (`List`) reaches it, and pinning + // that shape would make the grammar's present + // over-permissiveness the contract (grammar-dispatch section + // 6). `groovy_wildcard_super_bound_stays_an_operator` carries + // the node census that fails by name if a bump makes it + // reachable (#1419). + Super if ancestors.parent_has_kind(node, Wildcard as u16) => TokenRole::Operator, + Super => TokenRole::Operand, + // Control-flow + keyword operators (mirrors Java's set, // minus tokens that no longer exist in the dekobon grammar // — `This`, `VoidType`, `Throws2`). - // - // `Super` fires for exactly one production: the pinned grammar - // emits the `super` token only as the bound of a `wildcard` - // (`List`), where billing it as an operator mirrors - // `? extends T`'s `extends` and is the answer #1380 gated - // Java to (`java_wildcard_super_bound_stays_an_operator`). A - // super-*reference* — `super(1)`, `super.h()`, `A.super.h()`, - // `super::h`, `super?.h()` — is a plain `identifier`, so it - // is already an operand, and Groovy agrees with Java on both - // halves. The reference half holds by grammar accident: this - // arm has no parent gate, so a bump that routes a reference to - // `Groovy::Super` would bill it as an operator. - // `tests/parity/self_reference_operand_parity.rs` catches that - // flip, one crate away; #1419 tracks giving the arm Java's - // `Wildcard` parent gate, which removes the accident. If | Else | Switch | Case | Try | Catch | Throw | Throws | For | While | Continue | Break | Do | Finally | New | Return | Default | Abstract | Assert | Instanceof - | Extends | Final | Implements | Transient | Synchronized | Super | Def | In | As + | Extends | Final | Implements | Transient | Synchronized | Def | In | As // Separators / brackets. | SEMI | COMMA | COLONCOLON | DOT | DASHGT | LBRACE | LBRACK | LPAREN // Java-compatible operators (arithmetic, bitwise, comparison, assignment). diff --git a/src/metrics/halstead.rs b/src/metrics/halstead.rs index 71dba499..b9f44393 100644 --- a/src/metrics/halstead.rs +++ b/src/metrics/halstead.rs @@ -2520,14 +2520,19 @@ mod tests { ); } - // Groovy's `Super` operator arm, pinned from both sides. The pinned + // Groovy's `Super` arm, pinned from all three sides. The pinned // grammar emits the `super` token only as a `wildcard` bound, where it // is an operator exactly as in Java above; every super-*reference* is - // a plain `identifier`, and so an operand. The node census is the - // positive pin #1419 wants rather than an unreachability one — an - // unreachability pin fails on any wildcard — so a grammar bump that - // routes a reference to this kind fails here by name instead of - // silently billing it as an operator through the ungated arm. + // a plain `identifier`, and so an operand. + // + // Since #1419 the arm carries Java's `Wildcard` parent gate, so the + // bound half below exercises the gate's operator branch — inverting + // the gate fails it. The gate's operand branch is unreachable under + // the pin (only error recovery on invalid Groovy, `List`, + // detaches a `super` token from its `wildcard`), so it is + // deliberately untested rather than pinned against an invalid + // fixture. The node census is the drift marker for it: a grammar bump + // that routes a reference to `Groovy::Super` fails here by name. #[test] fn groovy_wildcard_super_bound_stays_an_operator() { let bounds = @@ -2558,8 +2563,9 @@ mod tests { assert_eq!( chain.last().map(Node::kind_id), Some(Groovy::Wildcard as u16), - "groovy: a `super` token outside a wildcard bound reaches the \ - ungated operator arm (#1419)" + "groovy: a `super` token outside a wildcard bound now reaches \ + the gate's operand branch, which the pinned grammar made \ + unreachable — re-check the arm against the new grammar (#1419)" ); } }); @@ -2568,6 +2574,19 @@ mod tests { "the fixture's one wildcard bound must still spell `super`, and its \ `super.h()` reference must not" ); + + // The reference direction, on valid input the gate must leave + // alone: every super-reference form the grammar accepts stays an + // operand, so splitting `Super` out of the operator alternation + // cannot have moved one. `this` rides along for the same reason + // Java's test carries it. + let refs = "class A extends B {\n A() { super(1) }\n \ + def g() { return super.h() }\n \ + def i() { return A.super.h() }\n \ + def j() { return super::h }\n \ + def k() { return super?.h() }\n \ + def l() { return this.x }\n}"; + assert_keywords_are_operands_only::(refs, "foo.groovy", &["super", "this"]); } #[test] diff --git a/tests/parity/self_reference_operand_parity.rs b/tests/parity/self_reference_operand_parity.rs index 17df7d9b..21ddd857 100644 --- a/tests/parity/self_reference_operand_parity.rs +++ b/tests/parity/self_reference_operand_parity.rs @@ -134,16 +134,19 @@ fn fixture(lang: LANG) -> Option<(&'static str, &'static str, &'static [&'static "cs", &["this", "base"], ), - // Grammar accident, and the interesting one: `getter/groovy.rs` - // lists `Super` among its operators with no parent gate, but the - // pinned grammar emits `Groovy::Super` only as a `wildcard` bound - // (`? super T`, a declarator use kept an operator as in Java and - // left out of this fixture). In receiver position `this` and - // `super` are a plain `identifier` — verified by dump for - // `super(1)`, `super.h()`, `A.super.h()` and `super::h` — so the - // arm never sees a reference, and Groovy is an operand language - // in fact. This row is what notices if a bump ever routes a - // reference to that kind (#1419). + // Grammar accident, and the interesting one: the pinned grammar + // emits `Groovy::Super` only as a `wildcard` bound (`? super T`, + // a declarator use kept an operator as in Java and left out of + // this fixture). In receiver position `this` and `super` are a + // plain `identifier` — verified by dump for `super(1)`, + // `super.h()`, `A.super.h()`, `super::h` and `super?.h()` — so + // `getter/groovy.rs`'s `Super` arm never sees a reference, and + // Groovy is an operand language in fact. Since #1419 that arm + // carries Java's `Wildcard` parent gate, so a bump routing a + // reference to `Groovy::Super` would classify it correctly + // rather than billing it as an operator; this row is what + // notices if the grammar reclassifies the keyword some other + // way. LANG::Groovy => ( "class A extends B {\n def f() { return this.x }\n \ def g() { return super.h() }\n}\n", From aa649899de69d31245e6d1be7e317f355707ff82 Mon Sep 17 00:00:00 2001 From: Elijah Zupancic Date: Mon, 14 Sep 2026 15:26:32 -0700 Subject: [PATCH 02/25] feat(check): warn when a baseline entry goes stale MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Baseline::classify` answers `Covered` across the whole non-breaching side of a recorded value, so an offender that measured *better* than its record is indistinguishable from one sitting exactly on it. The entry keeps suppressing violations up to a value the tree no longer produces — gate headroom nobody chose, in which a later regression hides — and nothing reddens until someone regenerates the file for an unrelated reason. `filter_by_baseline` now folds every `Covered` classification into a `StaleTally` and emits one aggregated stderr warning naming the count and the worst entry by relative drift. Direction comes from `breaches_limit` with its arguments swapped, so the tally and the ratchet cannot disagree about which way `mi.*` improves. Exit codes and the kept set are unchanged. This closes only half the class: a function that stopped breaching its limit altogether produces no violation, so it never reaches the matcher and its entry stays invisible here. Catching that needs a scheduled regeneration diffed against the committed file. Fixes #1465 --- AGENTS.md | 12 ++ CHANGELOG.md | 12 ++ .../src/recipes/baselines.md | 43 ++++- big-code-analysis-cli/src/baseline.rs | 96 +++++++++++ big-code-analysis-cli/src/baseline_diff.rs | 12 +- big-code-analysis-cli/src/baseline_tests.rs | 152 ++++++++++++++++++ big-code-analysis-cli/src/commands/check.rs | 35 +++- big-code-analysis-cli/src/format_util.rs | 15 ++ .../tests/check/check_baseline.rs | 52 +++++- 9 files changed, 409 insertions(+), 20 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index d9ece3d4..686f2c1c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -421,6 +421,18 @@ purely procedural: do not bypass pre-commit, and refresh the baseline with `make self-scan-write-baseline-headroom` in the commit that moved the metric. A red gate on `main` traces directly to skipping this step. +**The same duty runs the other way**, and nothing can make it red. A +change that *lowers* a baselined metric leaves the recorded value +describing a tree that no longer exists, and the filter keeps +suppressing the offender all the way up to it — headroom nobody chose, +in which a later regression hides. Since #1465 `bca check --baseline` +warns on stderr when a covered offender has measured past its record +(below it, or above it for the lower-is-worse `mi.*` family), naming +the worst entry and the count. Refresh in the same commit, exactly as +for an increase. The warning covers only offenders still above their +limits: one that stopped breaching altogether produces no violation and +so leaves an entry that only a full regeneration finds. + The same rule governs **merges**. `.bca-baseline.toml` is marked `-merge` in `.gitattributes`, so git leaves it wholly conflicted rather than splicing two branches' entries together. That is deliberate: diff --git a/CHANGELOG.md b/CHANGELOG.md index 4b1cdc69..c16bfd41 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -89,6 +89,18 @@ for historical reference. omits `fuzz` (deliberately version `0.0.0`) and so would have missed one of the two lockfiles that motivated the change. +- `bca check --baseline` now warns on stderr when a covered offender has + measured *past* its recorded value — below it, or above it for the + lower-is-worse `mi.*` family (#1465). Such an entry describes a tree + that no longer exists, and the filter keeps suppressing the offender + all the way up to the stale value, which is gate headroom nobody + chose. One aggregated line per run names the count and the worst entry + by relative drift; an entry sitting exactly on its record stays + silent, and the gate's exit code is unchanged. This covers only + offenders still above their limits: one whose metric stopped breaching + altogether produces no violation at all, so its entry remains + undetectable here and only a full regeneration finds it. + ### Performance - The metric walk's cognitive nesting map no longer grows to one entry diff --git a/big-code-analysis-book/src/recipes/baselines.md b/big-code-analysis-book/src/recipes/baselines.md index 62b436d1..6f4321c9 100644 --- a/big-code-analysis-book/src/recipes/baselines.md +++ b/big-code-analysis-book/src/recipes/baselines.md @@ -110,6 +110,12 @@ A shrinking diff is the goal. Two `--write-baseline` runs over an unchanged tree produce byte-identical output, so spurious diffs only appear when actual offenders changed. +You do not have to guess when a refresh is due. A gated run whose +offenders have measured *better* than their recorded values says so on +stderr — see [the stale-entry +warning](#the-stale-entry-warning) — because the difference between +recorded and live is suppression nobody chose. + #### Before tightening a limit, price it at both tiers {#price-a-candidate-limit} Paying debt down invites tightening the limit that produced it, and the @@ -165,7 +171,10 @@ Map the buckets back to the old heuristics: `--write-baseline` after the function got worse. Treat the same as `added` — surface the change in review. - **`improved`.** A recorded offender got better without dropping out - of the baseline; harmless, and a good sign the refactor is working. + of the baseline — a good sign the refactor is working, and the + reason to land the refreshed file rather than leave it. Until it is + refreshed, the old entry keeps suppressing the offender up to a value + the tree no longer produces. For a PR bot, `bca diff-baseline --format markdown` emits a fenced block ready to drop into a sticky comment, and the @@ -306,6 +315,38 @@ either side is a `[thresholds.soft]`-table baseline (no single ratio to compare). To clear a genuine warning, refresh the baseline at the current tier with the matching `--write-baseline` recipe. +## The stale-entry warning {#the-stale-entry-warning} + +The ratchet suppresses a violation for as long as it has not *worsened* +past its recorded value, so everything on the improving side classifies +alike: a function measuring 5 against a recorded 7 is treated exactly +like one still measuring 7. The two points between them are gate +headroom nobody chose — the function can grow back to 7 and the gate +will not notice. + +A gated run reports that drift in one stderr line: + +```text +warning: 3 baseline entries improved past the recorded value (worst: +src/spaces/compute.rs::metrics_inner halstead.effort 119147.75 → +116715.61); that gap is gate headroom nobody chose, so refresh with +`--write-baseline`. … +``` + +It names one example rather than every entry, because the response to +any number of them is the same wholesale refresh. Direction follows the +metric: for the lower-is-worse `mi.*` family the stale direction is a +*rise* above the record. An entry sitting exactly on its recorded value +is silent, so a freshly written baseline never warns about itself. + +**It finds only half of the staleness.** A function that stopped +breaching its limit altogether produces no violation at all, so it never +reaches the baseline matcher and cannot be counted here. Its entry stays +in the file, inert and invisible, until someone regenerates. Closing +that half needs a scheduled `--write-baseline` run whose output is +diffed against the committed file; the warning covers only the offenders +still above their limits. + ## How matching works {#how-matching-works} Each entry is keyed on `(path, qualified_symbol, metric)` — the diff --git a/big-code-analysis-cli/src/baseline.rs b/big-code-analysis-cli/src/baseline.rs index 02bd585d..78eaa42c 100644 --- a/big-code-analysis-cli/src/baseline.rs +++ b/big-code-analysis-cli/src/baseline.rs @@ -33,6 +33,7 @@ use std::path::{Path, PathBuf}; use serde::{Deserialize, Serialize}; +use crate::format_util::{MetricScalar, counted, identity}; use crate::thresholds::{Violation, breaches_limit}; mod body_hash; @@ -713,6 +714,101 @@ pub(crate) enum Coverage { New, } +/// Running tally of baseline entries the code has *improved past* +/// (issue #1465). +/// +/// [`Baseline::classify`] answers `Covered` across the whole +/// non-breaching side of a recorded value, so a measurement that has +/// fallen below its record — risen above it, for the lower-is-worse +/// `mi.*` family — is indistinguishable from one sitting exactly on it. +/// The entry then keeps suppressing violations up to a value the tree no +/// longer produces, and nothing reddens the gate until someone +/// regenerates the file for an unrelated reason. +/// +/// The tally keeps a count and the single worst entry by relative drift +/// rather than every stale one: this repository's own baseline carries +/// over two hundred entries, and the response to any number of them is +/// the same wholesale refresh. +/// +/// **It cannot see the other half of the class.** An entry whose metric +/// stopped breaching its limit altogether produces no [`Violation`], so +/// it never reaches `classify` and never reaches this tally. +/// [`Self::warning`] says so in the emitted text; closing that half +/// needs a scheduled regeneration, not a per-run check. +#[derive(Debug, Default)] +pub(crate) struct StaleTally { + count: usize, + /// Relative drift and rendered identity of the worst entry so far. + /// Formatting only on a new maximum keeps the common all-covered run + /// allocation-free rather than building a string per violation. + worst: Option<(f64, String)>, +} + +impl StaleTally { + /// Fold in one [`Coverage::Covered`] classification. A violation + /// still at — or worse-ward of — its recorded value is not stale and + /// is ignored. + pub(crate) fn observe(&mut self, v: &Violation, recorded: f64) { + // "Improved past the record" is the recorded value breaching the + // live one as a limit: below for a higher-is-worse metric, above + // for the lower-is-worse `mi.*` family. Calling `breaches_limit` + // with the arguments swapped keeps this and the ratchet in + // `classify` on one direction rule rather than two that can + // drift apart. Equality breaches in neither direction, so an + // entry sitting exactly on its record stays silent; NaN compares + // false both ways and is likewise ignored, though `classify` + // routes it to `Regressed` before it can arrive here at all. + if !breaches_limit(recorded, v.value, v.lower_is_worse) { + return; + } + self.count += 1; + let drift = relative_drift(recorded, v.value); + if self.worst.as_ref().is_none_or(|(seen, _)| drift > *seen) { + let id = identity(v.path.display(), &v.function); + self.worst = Some(( + drift, + format!( + "{id} {} {} \u{2192} {}", + v.metric, + MetricScalar(recorded), + MetricScalar(v.value), + ), + )); + } + } + + /// The one-line stderr diagnostic, or `None` when nothing drifted. + /// Split from the emission site so a test can pin the exact wording + /// and the silent cases without a baseline file on disk. + pub(crate) fn warning(&self) -> Option { + let (_, worst) = self.worst.as_ref()?; + Some(format!( + "{} improved past the recorded value (worst: {worst}); that gap \ + is gate headroom nobody chose, so refresh with \ + `--write-baseline`. An entry whose metric stopped breaching its \ + limit produces no violation at all and cannot be counted here — \ + regenerating the baseline is what finds those.", + counted(self.count, "baseline entry", "baseline entries"), + )) + } +} + +/// How far an entry has drifted, as a fraction of the larger of the two +/// values, for picking the worst one to name. Relative rather than +/// absolute so a `halstead.effort` entry that moved by 2,000 does not +/// automatically outrank a `cyclomatic` one that halved. +/// +/// Dividing by the larger magnitude rather than by `recorded` keeps the +/// result in `[0, 1]`, so the key stays comparable across metrics of +/// wildly different scale and needs no special case for a zero record — +/// `mi.original` does bottom out there, and a record of zero gives away +/// all the headroom there is, which this ranks at `1.0`. Callers reach +/// this only once `breaches_limit` has confirmed the two values differ, +/// so the denominator is never zero. +fn relative_drift(recorded: f64, value: f64) -> f64 { + (recorded - value).abs() / recorded.abs().max(value.abs()) +} + pub(crate) fn from_violations( violations: Vec, anchor: &Path, diff --git a/big-code-analysis-cli/src/baseline_diff.rs b/big-code-analysis-cli/src/baseline_diff.rs index 9e7df4f8..999ce20a 100644 --- a/big-code-analysis-cli/src/baseline_diff.rs +++ b/big-code-analysis-cli/src/baseline_diff.rs @@ -28,7 +28,7 @@ use std::fmt::Write as _; use serde::Serialize; use crate::baseline::{BaselineIdentity, DiffEntry, cmp_identity}; -use crate::format_util::{MetricScalar, strip_path_prefix}; +use crate::format_util::{ID_SEP, MetricScalar, identity, strip_path_prefix}; /// An entry present in exactly one of the two baselines (`added` / /// `removed`). @@ -350,16 +350,6 @@ struct Summary { improved: usize, } -/// Separator between a row's path and its qualified symbol in the -/// rendered identity column. -const ID_SEP: &str = "::"; - -/// Display identity for a row: `path::qualified` (file-level metrics -/// carry the `` sentinel in `qualified`, e.g. `src/x.rs::`). -fn identity(path: &str, qualified: &str) -> String { - format!("{path}{ID_SEP}{qualified}") -} - /// Rendered width of [`identity`] without allocating the string — used /// only to size the alignment column. Counts `char`s, not bytes, to /// match `format!`'s `{: Option { + let mut t = StaleTally::default(); + for (recorded, v) in observations { + t.observe(v, *recorded); + } + t.warning() +} + +#[test] +fn stale_tally_is_silent_at_the_exact_recorded_value() { + // The boundary `classify_at_exact_baseline_is_covered` guards from + // the other side: an entry sitting exactly on its record still + // describes the tree, so warning about it would fire on every + // freshly written baseline. Pinned in both metric directions. + assert_eq!(tally(&[(9.0, v("a", "f", 1, "cyclomatic", 9.0))]), None); + assert_eq!( + tally(&[(60.0, v_low("a", "f", 1, "mi.original", 60.0))]), + None + ); +} + +#[test] +fn stale_tally_is_silent_when_the_value_worsened() { + // Defensive: `classify` routes a worsened value to `Regressed`, so + // this pair never reaches `observe` in production. The tally must + // still not read a rise as an improvement if it ever does. + assert_eq!(tally(&[(9.0, v("a", "f", 1, "cyclomatic", 12.0))]), None); + assert_eq!( + tally(&[(60.0, v_low("a", "f", 1, "mi.original", 45.0))]), + None + ); +} + +#[test] +fn stale_tally_is_silent_on_nan() { + // `classify` routes NaN to `Regressed` before `observe` sees it, but + // every `breaches_limit` comparison against NaN is false either way, + // so a NaN arriving here must not be counted as an improvement. + assert_eq!( + tally(&[(9.0, v("a", "f", 1, "cyclomatic", f64::NAN))]), + None + ); +} + +#[test] +fn stale_tally_warns_when_a_higher_is_worse_metric_fell() { + let msg = tally(&[(9.0, v("src/a.rs", "S::f", 1, "cyclomatic", 5.0))]) + .expect("a fall below the record is stale"); + assert!(msg.starts_with("1 baseline entry improved past"), "{msg}"); + assert!( + msg.contains("src/a.rs::S::f cyclomatic 9 \u{2192} 5"), + "{msg}" + ); +} + +#[test] +fn stale_tally_warns_when_a_lower_is_worse_metric_rose() { + // The `mi.*` mirror image: for a lower-is-worse metric the stale + // direction is *up*, so a shared-polarity implementation that only + // looked for a fall would report nothing here. + let msg = tally(&[(60.0, v_low("src/a.rs", "S::f", 1, "mi.original", 75.0))]) + .expect("a rise above an mi.* record is stale"); + assert!( + msg.contains("src/a.rs::S::f mi.original 60 \u{2192} 75"), + "{msg}" + ); +} + +#[test] +fn stale_tally_names_only_the_worst_entry_by_relative_drift() { + // Aggregation contract: one line for the whole run, naming the + // entry that drifted furthest *relative* to its record. The + // halstead entry moved by 2,000 in absolute terms and the + // cyclomatic one by 15, so an absolute ranking would name the + // wrong one. + let msg = tally(&[ + ( + 100_000.0, + v("src/a.rs", "big", 1, "halstead.effort", 98_000.0), + ), + (20.0, v("src/b.rs", "small", 1, "cyclomatic", 5.0)), + ]) + .expect("two stale entries"); + assert!(msg.starts_with("2 baseline entries improved past"), "{msg}"); + assert!( + msg.contains("src/b.rs::small cyclomatic 20 \u{2192} 5"), + "{msg}" + ); + // Exactly one `old → new` pair is rendered, whichever order the + // observations arrived in: the line names an example, not a list. + assert_eq!(msg.matches('\u{2192}').count(), 1, "{msg}"); +} + +#[test] +fn stale_tally_worst_is_order_independent() { + // The maximum-tracking in `observe` must not depend on the worst + // entry arriving last. + let worse = (20.0, v("src/b.rs", "small", 1, "cyclomatic", 5.0)); + let milder = ( + 100_000.0, + v("src/a.rs", "big", 1, "halstead.effort", 98_000.0), + ); + let forward = tally(&[worse.clone(), milder.clone()]).expect("two stale entries"); + let reversed = tally(&[milder, worse]).expect("two stale entries"); + assert_eq!(forward, reversed); + // Equal *and* right: an `observe` that simply kept the last entry + // it saw would also be order-dependent, but one that kept the first + // would agree with itself while naming the wrong entry. + assert!( + reversed.contains("src/b.rs::small cyclomatic 20 \u{2192} 5"), + "{reversed}" + ); +} + +#[test] +fn stale_tally_ranks_a_zero_record_worst() { + // A zero record is reachable: `mi.original` bottoms out at 0 for bad + // enough code, and the baseline loader drops only *negative* values. + // Such an entry gives away every point of headroom there is — the + // gate could never fire below zero — so it must rank above the + // cyclomatic entry's 0.75 rather than fall out of the ranking. That + // the key stays finite is a property of the denominator, which no + // assertion on the rendered message can observe; it is argued at + // `relative_drift` instead. + let msg = tally(&[ + (20.0, v("src/b.rs", "small", 1, "cyclomatic", 5.0)), + (0.0, v_low("src/a.rs", "floor", 1, "mi.original", 4.0)), + ]) + .expect("two stale entries"); + assert!(msg.starts_with("2 baseline entries improved past"), "{msg}"); + assert!( + msg.contains("src/a.rs::floor mi.original 0 \u{2192} 4"), + "{msg}" + ); +} + +#[test] +fn stale_tally_names_the_uncovered_half_of_the_class() { + // The warning must not read as if it closes baseline staleness: an + // entry whose metric stopped breaching its limit produces no + // `Violation`, so it never reaches `classify` and cannot be counted. + let msg = tally(&[(9.0, v("src/a.rs", "S::f", 1, "cyclomatic", 5.0))]).expect("stale"); + assert!( + msg.contains("stopped breaching its limit produces no violation"), + "{msg}" + ); +} + #[test] fn classify_different_path_is_new() { let b = baseline_with(vec![entry("a", "f", 1, "cyclomatic", 5.0)]); diff --git a/big-code-analysis-cli/src/commands/check.rs b/big-code-analysis-cli/src/commands/check.rs index aee228f3..6889a995 100644 --- a/big-code-analysis-cli/src/commands/check.rs +++ b/big-code-analysis-cli/src/commands/check.rs @@ -588,6 +588,11 @@ pub(crate) fn provenance_warning( /// stderr renderer can attach a `[new]` / `[regr +N%]` tag. Without /// `--baseline`, `Option` is `None` and the renderer emits /// the exact pre-tag line format byte-identically. +/// +/// Also the emission point for the two `--baseline` warnings: the +/// provenance mismatch above, and the [`baseline::StaleTally`] below, +/// which reports entries the code has improved past. Neither changes +/// what is kept or the gate's exit code. pub(crate) fn filter_by_baseline( violations: Vec, baseline_path: Option<&Path>, @@ -608,17 +613,33 @@ pub(crate) fn filter_by_baseline( warn(msg); } let before = violations.len(); + // Issue #1465: a covered offender whose value has moved past its + // recorded one no longer describes the tree, and the ratchet is + // silent about it in both directions. Tallied here, where `classify` + // has just resolved the recorded value and the violation still owns + // the live one, so nothing has to be re-keyed. + let mut stale = baseline::StaleTally::default(); let kept: Vec<_> = violations .into_iter() - .filter_map(|v| match baseline.classify(&v) { - // `--report-suppressed` keeps baseline-covered offenders (tagged - // `Covered`) so they can be surfaced as `external` suppressions - // in the document; the split in `run_check` keeps them out of the - // gate. The default path still drops them entirely. - Coverage::Covered { .. } if !keep_covered => None, - c => Some((v, Some(c))), + .filter_map(|v| { + let coverage = baseline.classify(&v); + if let Coverage::Covered { recorded } = coverage { + stale.observe(&v, recorded); + } + match coverage { + // `--report-suppressed` keeps baseline-covered offenders + // (tagged `Covered`) so they can be surfaced as `external` + // suppressions in the document; the split in `run_check` + // keeps them out of the gate. The default path still drops + // them entirely. + Coverage::Covered { .. } if !keep_covered => None, + c => Some((v, Some(c))), + } }) .collect(); + if let Some(msg) = stale.warning() { + warn(msg); + } let filtered = before - kept.len(); if filtered > 0 { eprintln!("bca: filtered {filtered} violations via baseline"); diff --git a/big-code-analysis-cli/src/format_util.rs b/big-code-analysis-cli/src/format_util.rs index ac29814d..e650d062 100644 --- a/big-code-analysis-cli/src/format_util.rs +++ b/big-code-analysis-cli/src/format_util.rs @@ -66,6 +66,21 @@ pub(crate) fn strip_path_prefix<'a>(path: &'a str, prefix: &str) -> &'a str { } } +/// Separator between a path and its qualified symbol in a rendered +/// offender identity. +pub(crate) const ID_SEP: &str = "::"; + +/// Display identity for one offender: `path::qualified` (file-level +/// metrics carry the `` sentinel in `qualified`, e.g. +/// `src/x.rs::`). Shared by `bca diff-baseline`'s rows and the +/// stale-baseline warning so both name an entry the same way. +/// +/// `path` is taken as `Display` so a caller holding a `Path` need not +/// render it to an intermediate `String` first. +pub(crate) fn identity(path: impl fmt::Display, qualified: &str) -> String { + format!("{path}{ID_SEP}{qualified}") +} + /// `3 files`, `1 ignored directory`: a count with the noun that agrees /// with it. The crate spells this rule in several report renderers; /// new sites should call this one. diff --git a/big-code-analysis-cli/tests/check/check_baseline.rs b/big-code-analysis-cli/tests/check/check_baseline.rs index 221aa554..81c25a45 100644 --- a/big-code-analysis-cli/tests/check/check_baseline.rs +++ b/big-code-analysis-cli/tests/check/check_baseline.rs @@ -276,7 +276,57 @@ fn improved_function_still_passes() { // mask a parse that produced no violation at all (#894). .stderr(predicate::str::contains( "filtered 1 violations via baseline", - )); + )) + // ...and, since #1465, says so: the entry now records a 7 the + // tree no longer produces, which is two points of suppression + // nobody chose. Warning only — the gate stays green. + .stderr(predicate::str::contains( + "1 baseline entry improved past the recorded value", + )) + .stderr(predicate::str::contains("cyclomatic 7 \u{2192} 5")); +} + +#[test] +fn unchanged_function_at_its_recorded_value_warns_nothing() { + // The silence half of #1465, and the boundary the warning must not + // cross: re-running against a baseline written from the same tree + // finds every entry exactly on its record, so a freshly written + // baseline must not warn about itself. + let dir = TempDir::new().unwrap(); + let src_path = write_file(&dir, "branchy.rs", WORSER_RUST); + let baseline = dir.path().join("baseline.toml"); + + cli(dir.path()) + .args([ + "check", + "--paths", + src_path.to_str().unwrap(), + "--threshold", + "cyclomatic=1", + "--write-baseline", + baseline.to_str().unwrap(), + ]) + .assert() + .success(); + + cli(dir.path()) + .args([ + "check", + "--paths", + src_path.to_str().unwrap(), + "--threshold", + "cyclomatic=1", + "--baseline", + baseline.to_str().unwrap(), + ]) + .assert() + .success() + // The offender is still covered and filtered — so the run did + // reach the tally rather than producing no violation at all. + .stderr(predicate::str::contains( + "filtered 1 violations via baseline", + )) + .stderr(predicate::str::contains("improved past the recorded value").not()); } // -- Identity & line drift ------------------------------------------------ From bf6252bc4e7f114fc5b2a40296b92c0f818b2850 Mon Sep 17 00:00:00 2001 From: Elijah Zupancic Date: Mon, 14 Sep 2026 15:49:26 -0700 Subject: [PATCH 03/25] fix(abc/objc): count `@available` as a condition Objective-C's runtime OS-version check parses to a dedicated `available_expression` node that reaches no comparison-token arm, so `if (@available(iOS 13.0, *))` scored zero ABC conditions where `if (a)` scores one. The same shortfall applied to a `&&` operand, a `while` / `for` condition, a ternary condition and a negated check. The node joins `cpp_bool_terminal_kinds!()`, the name-keyed set the C-family ABC walkers share. The wrapper is the entry rather than any child: one grammar rule spans both the `@available` and `__builtin_available` spellings and makes the `version` child optional, so only the wrapper is present for every form. The neighbouring `version_number` exclusion stands unchanged -- it is a fragment of this node's interior, never an operand. No C, C++ or Mozcpp grammar emits a node by that name, so the addition is inert for the other three languages sharing the set. That inertness was previously assumed; it is now pinned by a test that also asserts Objective-C still emits the kind, so the negative half cannot pass vacuously. Metric drift: Objective-C `abc.conditions` rises by one per `@available` / `__builtin_available` check in a boolean slot. The integration corpora contain no Objective-C, so no snapshot moves. Fixes #1457 --- CHANGELOG.md | 15 +++ big-code-analysis-ast/src/macros/kind_sets.rs | 19 ++++ src/metrics/abc.rs | 107 +++++++++++++++++- 3 files changed, 140 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c16bfd41..50107203 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -172,6 +172,21 @@ for historical reference. rises by one per occurrence of these constructs in a boolean slot, and by one per `===` / `!==` / `=~` / `==~` anywhere in Groovy. +- **Objective-C ABC counts an `@available` check** (#1457). The runtime + OS-version test `@available(iOS 13.0, *)` (and its `__builtin_available` + synonym) parses to a dedicated `available_expression` node that no + comparison-token arm sees, so `if (@available(iOS 13.0, *))` scored zero + conditions where `if (a)` scores one — and likewise as a `&&` operand, a + `while` or `for` condition, a ternary condition, and under a `!`. The + node joins the name-keyed terminal set the C-family ABC walkers share. + The wrapper is the entry rather than any child, because one grammar rule + covers both spellings and makes the version child optional, so it is the + only node present for every form. No C, C++ or Mozcpp grammar emits a + node by that name, so the addition is inert for the other three + languages that share the set — now pinned by a test. **Metric drift:** + Objective-C `abc.conditions` rises by one per `@available` / + `__builtin_available` check in a boolean slot. + - **Kotlin ABC counts a null-asserted or cast condition, and C# ABC counts `??`** (#1459). Both languages model a condition slot that delegates to a per-language helper, and both helpers contributed diff --git a/big-code-analysis-ast/src/macros/kind_sets.rs b/big-code-analysis-ast/src/macros/kind_sets.rs index 09493e1d..121a8cc8 100644 --- a/big-code-analysis-ast/src/macros/kind_sets.rs +++ b/big-code-analysis-ast/src/macros/kind_sets.rs @@ -332,6 +332,24 @@ macro_rules! cpp_bool_terminal_kinds { // grammar has a node by that name, so the arm is inert there. // Without it every `if ([a ok])` / `for (; [a ok]; )` scored zero // conditions where `if (ok())` scored one. + // `available_expression` is Objective-C's runtime OS-version check + // (`@available(iOS 13.0, *)`), in the same inert-elsewhere position + // as `message_expression`: no C / C++ / Mozcpp grammar has a node by + // that name. It is the one entry here that *is* a boolean rather + // than something contextually converted to one, which is why it + // belongs in a set otherwise justified by integer truthiness. + // Without it `if (@available(iOS 13.0, *))` scored zero conditions + // where `if (a)` scored one (#1457). + // + // The wrapper is the keeper, not any child (grammar-dispatch §6): + // tree-sitter-objc's one `available_expression` rule spans both + // spellings of the construct (`@available` and `__builtin_available` + // are alternatives of its leading token) and makes the `version` + // child optional, so `@available(iOS, *)` carries no numeric node at + // all. Only the wrapper is present for every spelling, and it is + // the node that occupies the operand slot. That is also why the + // neighbouring exclusion above stands: `version_number` is a + // fragment of this node's interior, never an operand itself. () => { "identifier" | "true" @@ -340,6 +358,7 @@ macro_rules! cpp_bool_terminal_kinds { | "char_literal" | "call_expression" | "message_expression" + | "available_expression" | "field_expression" | "subscript_expression" | "cast_expression" diff --git a/src/metrics/abc.rs b/src/metrics/abc.rs index 936edd44..1cdd4a54 100644 --- a/src/metrics/abc.rs +++ b/src/metrics/abc.rs @@ -13629,6 +13629,14 @@ mod numeric_bool_operands { /// - **Ruby** `a in Integer` (`test_pattern`), the one-line pattern /// test. Decided against `match_pattern` (`expr => pat`), which /// raises rather than yielding a boolean. +/// - **Objective-C** `@available(iOS 13.0, *)` and its +/// `__builtin_available` synonym (`available_expression`), the runtime +/// OS-version check. Not relational, but boolean by definition and +/// invisible to every comparison-token arm, so it measured short in +/// exactly the same way (#1457). Its entry lands in the *name*-keyed +/// `cpp_bool_terminal_kinds!()` that C, C++ and Mozcpp share, which is +/// why `the_c_family_grammars_do_not_emit_available_expression` below +/// pins the inertness the other three rely on. /// /// Rust's `matches!(…)` measures short in the same way, and is /// deliberately **not** here: its fix is `macro_invocation`, which also @@ -13670,7 +13678,12 @@ mod numeric_bool_operands { // enabling none of them drops the module instead of failing its // guards (`.claude/rules/testing.md`, #1286 / #1411). #[cfg(test)] -#[cfg(any(feature = "groovy", feature = "perl", feature = "ruby"))] +#[cfg(any( + feature = "groovy", + feature = "perl", + feature = "ruby", + feature = "objc" +))] mod own_production_bool_constructs { use crate::test_support::metrics_verbatim; use crate::{LANG, MetricsOptions}; @@ -13752,6 +13765,27 @@ mod own_production_bool_constructs { &["a in Integer", "!(a in Integer)"], 2, ), + // Objective-C's `@available` is not relational like the three + // above, but it reaches the terminal set by the same route: a + // dedicated `available_expression` production whose value is a + // boolean, seen by no comparison-token arm (#1457). Both + // spellings are listed because they are alternatives of one + // rule's leading token rather than two rules — so unlike + // Perl's `m{}` above they cannot drift apart, and the second + // row is here to keep that claim measured rather than assumed. + LANG::Objc => ( + [ + ("int f(int a) {\n return {} && b;\n}\n", 2, 3), + ("int f() {\n if ({}) { return 1; }\n}\n", 1, 3), + ], + "a", + &[ + "@available(iOS 13.0, *)", + "__builtin_available(macOS 10.15, *)", + "!@available(iOS 13.0, *)", + ], + 3, + ), _ => return None, }) } @@ -13885,6 +13919,77 @@ mod own_production_bool_constructs { } } + /// `"available_expression"` must stay an Objective-C-only kind. + /// + /// Alone among the terminal sets, `cpp_bool_terminal_kinds!()` keys + /// on node-kind *names* so that C, C++, Mozcpp and Objective-C can + /// share one list despite assigning different ids to the same kinds + /// (#720 / #732). The price is that every entry is live in all four: + /// the Objective-C rows above say nothing about what the addition + /// did to the other three, and a grammar bump that gave any of them + /// an `available_expression` node would start counting it with no + /// test anywhere noticing. + /// + /// The Objective-C half is not decoration. Without it a fixture that + /// stopped parsing — or a typo'd kind name — would leave the + /// negative assertions passing for the wrong reason. Both halves + /// were verified by perturbing the probed kind name: to a typo, + /// which fails the positive, and to `if_statement`, which fails the + /// negatives. + #[test] + #[cfg(all( + feature = "objc", + any(feature = "c", feature = "cpp", feature = "mozcpp") + ))] + fn the_c_family_grammars_do_not_emit_available_expression() { + use crate::ParserTrait; + + const SOURCE: &str = "int f(int a) {\n if (@available(iOS 13.0, *)) { return 1; }\n}\n"; + + fn emits(path: &str) -> bool { + let parser = P::new( + SOURCE.as_bytes().to_vec(), + &std::path::PathBuf::from(path), + None, + ); + parser + .root() + .preorder() + .any(|node| node.kind() == "available_expression") + } + + assert!( + emits::("f.m"), + "the fixture no longer parses to an `available_expression`; every \ + Objective-C row above is now measuring some other node" + ); + + // The siblings are `#[cfg]`-gated array *elements* rather than + // conditional pushes, so the `any(…)` half of this test's gate + // makes the list non-empty by construction: a build reaching + // this line always has at least one row. That is the same + // non-vacuity guarantee the `checked > 0` counters elsewhere in + // this module buy at runtime, moved to compile time because here + // the row set is fixed rather than iterated over `LANG`. + let c_family = [ + #[cfg(feature = "c")] + ("C", emits::("f.c")), + #[cfg(feature = "cpp")] + ("C++", emits::("f.cpp")), + #[cfg(feature = "mozcpp")] + ("Mozcpp", emits::("f.cpp")), + ]; + + for (language, emitted) in c_family { + assert!( + !emitted, + "{language} now emits `available_expression`, so the shared \ + `cpp_bool_terminal_kinds!()` entry is no longer inert there \ + and its ABC conditions have moved" + ); + } + } + /// The absolute anchor under the comparison above: every spelling /// must produce the slot's recorded `conditions`, and must leave /// `cyclomatic` alone. From 41e107fc15166d463aad7880c7d3d90228d0f2df Mon Sep 17 00:00:00 2001 From: Elijah Zupancic Date: Mon, 14 Sep 2026 16:06:28 -0700 Subject: [PATCH 04/25] fix(abc/groovy): count indexing and navigation predicates Five alternatives of the grammar's `_expression` rule were absent from `groovy_bool_terminal_kinds!()`, so `if (l[0])`, `if (l?[0])`, `if (a?.b)`, `if (a??.b)` and `if (a.@b)` scored zero ABC conditions where `if (a)` scores one, in both the `if` predicate slot and the `&&` / `||` chain slot. C# counts its `element_access_expression` and Kotlin its `index_expression` / `navigation_expression`, so this was a per-language asymmetry, not a policy difference. `a?.b` was the worst: Groovy cyclomatic counts `?.` as a decision, so ABC sat two below its own decision count on an idiomatic predicate. Counting the wrapper double-counts no token: ABC's condition-token arm lists no navigation operator, and `?[` is its own token rather than a bare `?` the ternary-gated arm could see. The condition slot also routed every `unary_expression` to a peel that handled only the `!` spelling, so it claimed `~a` / `-a` / `+a` and dropped them. It now asks `groovy_wrapper_operand` which wrappers the peel unwraps rather than restating the list, the same divergence #1459 fixed in Kotlin, and the peel reads its operand by grammar field, so `if (! /*c*/ a)` scores like `if (!a)` instead of reading the comment. Metric drift: Groovy `abc.conditions` and `abc.magnitude` rise by one per such expression in a boolean slot. No integration snapshot moves; no corpus carries a Groovy file. Fixes #1466 --- CHANGELOG.md | 23 ++++ big-code-analysis-ast/src/macros/kind_sets.rs | 47 +++++-- src/metrics/abc.rs | 120 ++++++++++++++++- src/metrics/abc/groovy.rs | 125 +++++++++++------- 4 files changed, 257 insertions(+), 58 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 50107203..aae9182a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -147,6 +147,29 @@ for historical reference. ### Fixed +- **Groovy ABC counts an indexing or navigation predicate** (#1466). + `if (l[0])`, `if (l?[0])`, `if (a?.b)`, `if (a??.b)` and `if (a.@b)` + each scored zero conditions where `if (a)` scores one, and likewise as + a `&&` / `||` operand — five alternatives of the grammar's + `_expression` rule that no comparison-token arm sees. C# already + counted its `element_access_expression` and Kotlin its + `index_expression` / `navigation_expression`, so this was a + per-language asymmetry rather than a policy difference. `a?.b` was the + worst of the five: Groovy cyclomatic counts `?.` as a decision, so ABC + sat *two* below its own decision count on an idiomatic predicate. + Counting the wrapper node double-counts nothing, because ABC's + condition-token arm lists no navigation operator. The condition slot + now also asks the operand peel which wrappers it unwraps instead of + restating the list — it had claimed every `unary_expression` while the + peel handled only the `!` spelling — and the peel reads that operand by + grammar field, so `if (! /*c*/ a)` scores like `if (!a)` instead of + reading the comment. **Metric drift:** Groovy `abc.conditions` and + `abc.magnitude` rise by one per indexing, safe-indexing, + safe-navigation, safe-chain-dot or direct-field-access expression + standing as a predicate or a `&&` / `||` operand, and per `!`-negated + predicate whose operand is preceded by a comment. No integration + snapshot moves: no corpus carries a Groovy file. + - **Groovy's Halstead `super` arm is gated on its `wildcard` parent, as Java's is** (#1419). `super` is an operator only as a wildcard type bound (`List`), where it denotes no value and mirrors diff --git a/big-code-analysis-ast/src/macros/kind_sets.rs b/big-code-analysis-ast/src/macros/kind_sets.rs index 121a8cc8..44fe8478 100644 --- a/big-code-analysis-ast/src/macros/kind_sets.rs +++ b/big-code-analysis-ast/src/macros/kind_sets.rs @@ -161,15 +161,39 @@ macro_rules! java_bool_terminal_kinds { // it — `subscript_expression`, `safe_subscript_expression`, // `safe_navigation_expression`, `safe_chain_dot_expression` and // `direct_field_access_expression`, all alternatives of `_expression` -// and all legal in a boolean slot. None is listed, so `if (l[0])`, -// `if (a?.b)` and `if (a.@b)` score zero where `if (a)` scores one, -// while C# scores `l[0]` through `ElementAccessExpression` and Kotlin -// scores both through `IndexExpression` / `NavigationExpression`. -// `a?.b` is the worst of them: Groovy cyclomatic counts `?.` as a -// decision, so ABC sits two below its own decision count on an -// idiomatic predicate. Tracked separately — an earlier revision of -// this comment claimed the analogue did not exist, which is the sort -// of claim that stops the next reader looking. +// and all legal in a boolean slot. All five join the set in #1466: +// until then `if (l[0])`, `if (a?.b)` and `if (a.@b)` scored zero +// where `if (a)` scored one, while C# scored `l[0]` through +// `ElementAccessExpression` and Kotlin scored both through +// `IndexExpression` / `NavigationExpression` — a per-language +// asymmetry rather than a policy difference. `a?.b` was the worst of +// them: Groovy cyclomatic counts `?.` as a decision, so ABC sat *two* +// below its own decision count on an idiomatic predicate. An earlier +// revision of this comment claimed the analogue did not exist, which +// is the sort of claim that stops the next reader looking. +// +// None of the five double-counts a token (§5). ABC's condition-token +// arm (`groovy_count_token_condition`) lists no navigation operator: +// `?.` (`QMARKDOT`), `??.` (`QMARKQMARKDOT`) and `?[` +// (`QMARKLBRACK` — its own token, verified with `bca dump`, not a +// bare `QMARK` that the ternary-gated arm could see) are cyclomatic +// decisions only. So listing the wrapper is the sole place each is +// scored. +// +// The remaining `_expression` alternatives are absent on purpose. The +// relational trio (`identity_expression`, `regex_find_expression`, +// `regex_match_expression`) comes through the token arm, see below; +// `binary_expression`, `ternary_expression`, `elvis_expression` and +// `switch_expression` are scored by their own operator token or +// nested condition; and the rest — `list_literal`, `map_literal`, +// `closure`, `object_creation_expression`, `range_expression`, +// `power_expression`, `update_expression`, `method_pointer_expression`, +// `method_reference_expression`, `spread_dot_expression`, +// `string_literal`, `null_literal` — are shapes whose Groovy-truth +// value is either constant or degenerate in a predicate slot, and +// none has a sibling-language precedent. `spread_dot_expression` +// (`a*.b`) is the closest call of those; it is recorded in #1466 +// rather than added blind. // // Groovy truth makes every non-zero number truthy, so `NumberLiteral` // is a unary condition here for the same reason Python's `Integer` / @@ -233,6 +257,11 @@ macro_rules! groovy_bool_terminal_kinds { | $crate::Groovy::ParenthesizedTypeCast | $crate::Groovy::InstanceofExpression | $crate::Groovy::MembershipExpression + | $crate::Groovy::SubscriptExpression + | $crate::Groovy::SafeSubscriptExpression + | $crate::Groovy::SafeNavigationExpression + | $crate::Groovy::SafeChainDotExpression + | $crate::Groovy::DirectFieldAccessExpression }; } diff --git a/src/metrics/abc.rs b/src/metrics/abc.rs index 1cdd4a54..b30a1b18 100644 --- a/src/metrics/abc.rs +++ b/src/metrics/abc.rs @@ -13744,8 +13744,20 @@ mod own_production_bool_constructs { "s =~ /p/", "s ==~ /p/", "!(a in l)", + // The indexing / navigation kinds added by #1466. + // These three are the cyclomatic-neutral ones, so + // they belong in this module's same-as-the-control + // shape. The two *safe-navigation* spellings + // (`a?.b`, `a??.b`) each add a cyclomatic decision + // and so cannot sit in a row whose contract is "the + // control's cyclomatic"; they get their own test, + // `groovy_safe_navigation_closes_the_two_below_gap`, + // which anchors them on that axis explicitly. + "l[0]", + "l?[0]", + "a.@b", ], - 7, + 10, ), LANG::Perl => ( [ @@ -13861,6 +13873,112 @@ mod own_production_bool_constructs { }); } + /// Groovy's two safe-navigation spellings, on the cyclomatic axis. + /// + /// They cannot ride the rows above, whose contract is "scores the + /// control's `conditions` *and* the control's `cyclomatic`": + /// `groovy_bool_terminal_kinds!()` does not move cyclomatic, but + /// `?.` (`QMARKDOT`) and `??.` (`QMARKQMARKDOT`) are already + /// cyclomatic decisions in their own right + /// (`src/metrics/cyclomatic/groovy.rs`), so each spelling scores the + /// control's conditions against the control's cyclomatic **plus + /// one**. That is the whole reason this pair was the worst case in + /// #1466: before the fix ABC sat *two* below its own decision count + /// on `if (a?.b)`, against one below for every other spelling. + /// + /// Asserting the offset rather than the bare conditions is what + /// makes this a §5 double-count guard as well. If a later change + /// added `QMARKDOT` to `groovy_count_token_condition`'s arm while + /// the wrapper stayed in the terminal set, `conditions` would go to + /// `control + 1` and this test — not a snapshot — would say so. + #[test] + #[cfg(feature = "groovy")] + fn groovy_safe_navigation_closes_the_two_below_gap() { + for (template, control_conditions, control_cyclomatic) in [ + ("def f(a, b) {\n return {} && b\n}\n", 2, 3), + ("def f(a, b) {\n if ({}) { return 1 }\n}\n", 1, 3), + ] { + let control = template.replace("{}", "b"); + assert_eq!(conditions(LANG::Groovy, &control), control_conditions); + assert_eq!(cyclomatic_sum(LANG::Groovy, &control), control_cyclomatic); + + for spelling in ["a?.b", "a??.b"] { + let source = template.replace("{}", spelling); + assert_eq!( + conditions(LANG::Groovy, &source), + control_conditions, + "`{spelling}` conditions\n source: {source}" + ); + assert_eq!( + cyclomatic_sum(LANG::Groovy, &source), + control_cyclomatic + 1, + "`{spelling}` cyclomatic_sum: the navigation operator's own \ + decision has moved\n source: {source}" + ); + } + } + } + + /// The `!` operand is read by field, so an `extra` cannot hide it. + /// + /// `groovy_wrapper_operand` took `child(1)` until #1466, which is + /// the `!` operand only when nothing sits between the two. A comment + /// is an `extra` and occupies that slot, so `if (! /*c*/ a)` scored + /// zero conditions where `if (!a)` scored one — the positional-read + /// class grammar-dispatch §3 is about, and the observable half of + /// the peel rewrite. + /// + /// `parenthesized_expression` names nothing in node-types.json, so + /// it keeps the positional read and keeps the bug; that half is + /// pinned here as a *measured* gap rather than left to be discovered + /// as a surprise. Kotlin records the identical pair + /// (`kotlin_wrapper_operand`). + #[test] + #[cfg(feature = "groovy")] + fn groovy_negation_operand_survives_an_interposed_comment() { + let template = "def f(a) {\n if ({}) { return 1 }\n}\n"; + + assert_eq!(conditions(LANG::Groovy, &template.replace("{}", "!a")), 1); + assert_eq!( + conditions(LANG::Groovy, &template.replace("{}", "! /*c*/ a")), + 1, + "the `!` operand is being read positionally again; a comment displaces it" + ); + + assert_eq!(conditions(LANG::Groovy, &template.replace("{}", "(a)")), 1); + assert_eq!( + conditions(LANG::Groovy, &template.replace("{}", "( /*c*/ a)")), + 0, + "`parenthesized_expression` now survives an interposed comment — if the \ + grammar gained a field for its inner expression, read it and delete this" + ); + } + + /// The arithmetic unary operators stay out of the boolean slot. + /// + /// `groovy_count_condition` routed every `unary_expression` to the + /// peel while the peel handled only the `!` spelling, so the arm + /// claimed `~a` / `-a` / `+a` and dropped them + /// (grammar-dispatch §7). #1466 made the arm ask the peel, which + /// removes the divergence without moving a number — so this test + /// cannot be verified by reverting the production change, and is + /// here to pin the *answer* those three spellings give against a + /// future peel that starts accepting them by accident. + #[test] + #[cfg(feature = "groovy")] + fn groovy_arithmetic_unary_is_not_a_condition() { + let template = "def f(a) {\n if ({}) { return 1 }\n}\n"; + + assert_eq!(conditions(LANG::Groovy, &template.replace("{}", "a")), 1); + for spelling in ["~a", "-a", "+a"] { + assert_eq!( + conditions(LANG::Groovy, &template.replace("{}", spelling)), + 0, + "`{spelling}` is arithmetic, not a boolean operand" + ); + } + } + /// The two Perl spellings must remain two distinct grammar kinds. /// /// `perl_bool_terminal_kinds!()` lists `PatternMatcher` **and** diff --git a/src/metrics/abc/groovy.rs b/src/metrics/abc/groovy.rs index 2e1f2f1f..9639cfc7 100644 --- a/src/metrics/abc/groovy.rs +++ b/src/metrics/abc/groovy.rs @@ -13,11 +13,57 @@ use super::{Abc, Stats}; use crate::macros::groovy_bool_terminal_kinds; use crate::*; +// One peel step for a Groovy boolean operand: given a wrapper node, +// returns the operand inside it plus whether the wrapper itself *proves* +// the operand sits in a boolean slot. `None` means the node is not a +// wrapper this walker unwraps, and `groovy_count_condition` asks exactly +// that rather than restating the kind list — the divergence #1459 fixed +// in Kotlin, where `unary_expression` was routed to the peel while the +// peel handled only its `!` spelling, so the slot read as covering a +// shape the peel dropped on the floor (`.claude/rules/grammar-dispatch.md` +// §7). +// +// The operand is read by grammar field (§3): `unary_expression` names +// `operator` and `operand`, so one read serves all four spellings and +// survives both a grammar re-order and an interposed `extra` +// (`if (! /*c*/ a)` scores, where the positional `child(1)` read scored +// the comment). `parenthesized_expression` names nothing — its only +// child in node-types.json is the unlabelled inner `_expression` — so it +// keeps the positional read, and with it the same comment bug Kotlin +// records: `if ( /*c*/ a)` still scores zero. Measured, not assumed. +// +// Kotlin, C# and Groovy now share this peel's *shape* and nothing else, +// deliberately. A common helper would have to be parameterised by the +// wrapper kind set, a per-wrapper operand accessor, a per-wrapper +// proves-boolean flag and the parent seed — every line of the body — +// to save a five-line `while let`, so the reuse worth having is the +// `Option<(Node, bool)>` signature, not a generic function. C#'s +// positional read over its aliased wrapper kinds is #1455's, not this +// change's. +fn groovy_wrapper_operand<'a>(node: &Node<'a>) -> Option<(Node<'a>, bool)> { + use Groovy::*; + + match node.kind_id().into() { + // `(expr)` — the inner expression follows the `(` token. + ParenthesizedExpression => Some((node.child(1)?, false)), + UnaryExpression => { + let operand = node.child_by_field_name("operand")?; + match node.child_by_field_name("operator")?.kind_id().into() { + BANG => Some((operand, true)), + // `~a`, `+a`, `-a`: bitwise / arithmetic, never a + // boolean slot's operand, so the peel declines rather + // than reaching a bare `identifier` and counting it. + _ => None, + } + } + _ => None, + } +} + fn groovy_inspect_container(container_node: &Node, parent: &Node, conditions: &mut f64) { use Groovy::*; let mut node = *container_node; - let mut node_kind = node.kind_id().into(); let mut has_boolean_content = match parent.kind_id().into() { BinaryExpression | IfStatement | WhileStatement | DoWhileStatement | ForStatement => true, @@ -27,32 +73,11 @@ fn groovy_inspect_container(container_node: &Node, parent: &Node, conditions: &m _ => false, }; - loop { - let is_parenthesised_exp = matches!(node_kind, ParenthesizedExpression); - let is_not_operator = matches!(node_kind, UnaryExpression) - && node - .child(0) - .is_some_and(|c| matches!(c.kind_id().into(), BANG)); + while let Some((operand, proves_boolean)) = groovy_wrapper_operand(&node) { + has_boolean_content |= proves_boolean; + node = operand; - if !is_parenthesised_exp && !is_not_operator { - break; - } - - if !has_boolean_content && is_not_operator { - has_boolean_content = true; - } - - let Some(child) = node.child(1) else { break }; - node = child; - node_kind = node.kind_id().into(); - - // `BooleanLiteral` is the dekobon tree-sitter-groovy - // grammar's named wrapper for `true` / `false` — see the - // doc comment on `groovy_count_condition`. The remaining - // bool-evaluating terminals (`FieldAccess`, `CastExpression`, - // `ParenthesizedTypeCast`, `InstanceofExpression`) mirror - // the C# fix in #372 (lesson #19). - if matches!(node_kind, groovy_bool_terminal_kinds!()) { + if matches!(node.kind_id().into(), groovy_bool_terminal_kinds!()) { if has_boolean_content { *conditions += 1.; } @@ -72,10 +97,14 @@ fn groovy_count_unary_conditions(list_node: &Node, conditions: &mut f64) { let node = cursor.node(); let node_kind = node.kind_id().into(); - // Terminal set mirrors `groovy_inspect_container` — - // bool-evaluating kinds (`FieldAccess`, `CastExpression`, - // `ParenthesizedTypeCast`, `InstanceofExpression`) added - // per issue #372 (lesson #19). + // `groovy_bool_terminal_kinds!()` is the same set + // `groovy_inspect_container` and `groovy_count_condition` + // consume; its member list and the rationale for each + // member live on the macro. This is the `&&` / `||` chain + // path — the other of the two structurally independent + // walkers that sum into `conditions`, so every terminal + // kind needs a fixture here as well as in the `if` + // predicate slot (grammar-dispatch §11). if matches!(node_kind, groovy_bool_terminal_kinds!()) && matches!(list_kind, BinaryExpression) { @@ -386,24 +415,24 @@ impl Abc for GroovyCode { } } +// Counts a Groovy `if` / `while` / `do-while` bare predicate as one +// condition — Fitzpatrick's "unary conditional expression". The member +// list of `groovy_bool_terminal_kinds!()` and the reason each kind is in +// or out live on the macro, beside the set itself, so there is one place +// to read rather than three to keep in step. +// +// A predicate wrapped in parentheses or a `!` negation is unwrapped by +// `groovy_inspect_container`. Which kinds those are is asked of +// `groovy_wrapper_operand` rather than restated here: the two spelled +// the list separately until #1466, and that is how `UnaryExpression` +// came to be routed to a peel that handled only its `!` spelling — the +// arm claimed `~a` / `-a` / `+a` while the peel dropped them +// (grammar-dispatch §7). This is the identical divergence #1459 fixed +// in Kotlin. fn groovy_count_condition(condition: &Node, parent: &Node, conditions: &mut f64) { - use Groovy::*; - // Terminal set mirrors the C# fix in #372 (lesson #19): - // `FieldAccess` (`obj.flag`), `CastExpression` (`v as Boolean` — the - // Groovy-idiomatic form), `ParenthesizedTypeCast` (`(boolean) v` — - // the Java-style form, which the dekobon Groovy grammar represents - // as its own kind rather than nesting `cast_expression` inside - // `parenthesized_expression`), and `InstanceofExpression` - // (`x instanceof Foo`) all evaluate to a boolean. The dekobon - // Groovy grammar has no `await` or `array_access` analogues, so - // those collapse out of the five-kind C# set. - match condition.kind_id().into() { - groovy_bool_terminal_kinds!() => { - *conditions += 1.; - } - ParenthesizedExpression | UnaryExpression => { - groovy_inspect_container(condition, parent, conditions); - } - _ => {} + if matches!(condition.kind_id().into(), groovy_bool_terminal_kinds!()) { + *conditions += 1.; + } else if groovy_wrapper_operand(condition).is_some() { + groovy_inspect_container(condition, parent, conditions); } } From 17ee482e0a027b944f05b068f84913c903c348be Mon Sep 17 00:00:00 2001 From: Elijah Zupancic Date: Mon, 14 Sep 2026 16:25:26 -0700 Subject: [PATCH 05/25] fix(abc/csharp): count the null-forgiving predicate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `postfix_unary_expression` was in neither `csharp_bool_terminal_kinds!()` nor the wrapper peel, so `if (b!)` scored zero ABC conditions where `if (b)` scores one, and likewise `if ((b!))`, `if (!b!)`, `if (b!!)` and every `&&` / `||` operand spelled with the suffix. The condition slot recognised the shape and silently scored nothing for it — the fifth instance of that class after Kotlin's `is` / `in`, bare parentheses, Kotlin's infix `and` and its postfix `!!` / `as`. With nullable reference types enabled the suffix is ordinary notation, so ABC sat one below C#'s own cyclomatic decision count on idiomatic predicates. The suffix is type-preserving, so it inherits the slot's verdict rather than proving booleanness, and the wrappers chain. `b++` and `b--` share the grammar production and stay excluded as arithmetic; their tokens are already ABC assignments. No arm counts the `!` itself, so nothing is double-counted. The peel moves into `csharp_wrapper_operand` in the shape Kotlin and Groovy already use, and `csharp_count_condition` asks it which wrappers it unwraps instead of restating the kind list — the divergence #1459 and #1466 fixed in those two, where the slot claimed a kind the peel then dropped. The operator is identified by membership and the operand read at `child(0)`, which no `extra` can precede because the node starts there, so `b /*c*/ !` scores like `b!`. The paren and prefix arms keep their positional reads: the C# grammar gives all three kinds an empty `fields` map, so `if (! /*c*/ b)` and `if (( /*c*/ b))` still score zero. That is #1455, partially addressed here only in that the new arm adds no instance of it; the two existing ones are now pinned as measured gaps. Metric drift: C# `abc.conditions` and `abc.magnitude` rise by one per null-forgiving expression standing as a predicate or a `&&` / `||` operand. No integration snapshot moves — every `postfix_unary_expression` in the corpus is an `i++` or an `n--`. Fixes #1463 --- CHANGELOG.md | 20 ++++++ src/metrics/abc.rs | 139 ++++++++++++++++++++++++++++++++++++++ src/metrics/abc/csharp.rs | 123 +++++++++++++++++++++++---------- 3 files changed, 247 insertions(+), 35 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index aae9182a..2d496d3f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -147,6 +147,26 @@ for historical reference. ### Fixed +- **C# ABC counts a null-forgiving predicate** (#1463). `if (b!)` scored + zero conditions where `if (b)` scores one, and likewise `if ((b!))`, + `if (!b!)`, `if (b!!)` and every `&&` / `||` operand spelled with the + suffix. `postfix_unary_expression` was in neither the terminal-operand + set nor the wrapper peel, so the slot recognised the shape and scored + nothing for it — the fifth instance of that class after Kotlin's + `is` / `in`, bare parentheses, Kotlin's infix `and` and its postfix + `!!` / `as`. With nullable reference types enabled the suffix is + ordinary notation, so ABC sat one below C#'s own cyclomatic decision + count on idiomatic predicates. The suffix is type-preserving, so it + scores exactly what its operand scores and the wrappers chain; `b++` + and `b--`, which share the grammar production, stay excluded as + arithmetic, and no token arm counts the `!` itself. The condition slot + now also asks the operand peel which wrappers it unwraps instead of + restating the list, the divergence #1459 and #1466 fixed in Kotlin and + Groovy. **Metric drift:** C# `abc.conditions` and `abc.magnitude` rise + by one per null-forgiving expression standing as a predicate or a + `&&` / `||` operand. No integration snapshot moves: every + `postfix_unary_expression` in the corpus is an `i++` or an `n--`. + - **Groovy ABC counts an indexing or navigation predicate** (#1466). `if (l[0])`, `if (l?[0])`, `if (a?.b)`, `if (a??.b)` and `if (a.@b)` each scored zero conditions where `if (a)` scores one, and likewise as diff --git a/src/metrics/abc.rs b/src/metrics/abc.rs index b30a1b18..5b414c98 100644 --- a/src/metrics/abc.rs +++ b/src/metrics/abc.rs @@ -4635,6 +4635,145 @@ mod tests { }); } + // The null-forgiving `b!` is transparent: it scores whatever the + // operand it wraps scores (#1463). + // + // `postfix_unary_expression` was in neither + // `csharp_bool_terminal_kinds!()` nor the wrapper peel, so every + // spelling below scored **zero** conditions against a cyclomatic + // decision of one, while the bare `b` control scored one. In a + // codebase with nullable reference types enabled the suffix is + // everywhere, so the gap is not an exotic corner. + // + // Both walker paths, per `.claude/rules/grammar-dispatch.md` §11: + // `p*` go through the `if` predicate slot + // (`csharp_count_condition`) and `c*` through the `&&` chain slot + // (`csharp_count_unary_conditions`), which reach + // `csharp_inspect_container` independently. A fixture of only one + // reads correct with the other path dead. + // + // The controls are load-bearing in both directions. `p` / `c` pin + // what the wrapper must score, so a regression fails on the pair + // rather than on an absolute number; and the cyclomatic column pins + // that only `conditions` moved — the suffix is not a decision in any + // language, so a fix that moved both together would be a different + // bug. + #[test] + fn csharp_null_forgiving_operand_scores_like_its_operand() { + let src = "class A { + int p(bool b) { if (b) { return 1; } return 0; } + int pn(bool b) { if (b!) { return 1; } return 0; } + int pp(bool b) { if ((b!)) { return 1; } return 0; } + int pd(bool b) { if (b!!) { return 1; } return 0; } + int pb(bool b) { if (!b!) { return 1; } return 0; } + int c(bool b) { if (b && b) { return 1; } return 0; } + int cn(bool b) { if (b! && b) { return 1; } return 0; } + int cp(bool b) { if ((b!) && b) { return 1; } return 0; } + }"; + assert_csharp_fixture_spells( + src, + &[ + // Seven, not five: `b!!` is two nested nodes, and each + // chain member carries one. Trimming a `!` out of any + // member fails here by name instead of turning that + // member into a silent copy of its control. + ( + Csharp::PostfixUnaryExpression as u16, + 7, + "the null-forgiving suffixes", + ), + // The `if (…)` parens are anonymous tokens rather than + // nodes in this grammar, so these two are `pp`'s and + // `cp`'s explicit ones — the wrappers that make the peel + // chain rather than peel once. + ( + Csharp::ParenthesizedExpression as u16, + 2, + "the explicit parenthesised spellings", + ), + (Csharp::PrefixUnaryExpression as u16, 1, "`pb`'s `!`"), + ], + ); + check_func_space::(src, "foo.cs", |space| { + assert_members_score( + &space.spaces[0], + &[ + ("p", 1, 2), + ("pn", 1, 2), + ("pp", 1, 2), + ("pd", 1, 2), + ("pb", 1, 2), + ("c", 2, 3), + ("cn", 2, 3), + ("cp", 2, 3), + ], + ); + }); + } + + // The null-forgiving operand is read by position, but from the end + // the grammar cannot move: a `postfix_unary_expression` *starts* at + // its operand, so `child(0)` is the operand whatever `extra` sits + // between it and the operator. The operator is identified by + // membership rather than by index for the same reason. + // + // The other two wrappers have no such luck and are pinned here as a + // *measured* gap rather than left to be discovered: the C# grammar + // gives `parenthesized_expression`, `prefix_unary_expression` and + // `postfix_unary_expression` an empty `fields` map in + // node-types.json, so the field read Kotlin's and Groovy's + // equivalents use is unavailable and `child(1)` is a comment + // whenever one is written there. That is #1455, which predates + // #1463 and is a separate change; what this test adds is that the + // new arm does not join it. + #[test] + fn csharp_null_forgiving_operand_survives_an_interposed_comment() { + let src = "class A { + int p(bool b) { if (b!) { return 1; } return 0; } + int pc(bool b) { if (b /*c*/ !) { return 1; } return 0; } + int n(bool b) { if (!b) { return 1; } return 0; } + int nc(bool b) { if (! /*c*/ b) { return 1; } return 0; } + int r(bool b) { if ((b)) { return 1; } return 0; } + int rc(bool b) { if (( /*c*/ b)) { return 1; } return 0; } + }"; + assert_csharp_fixture_spells( + src, + &[ + ( + Csharp::Comment as u16, + 3, + "the interposed comments — without them every row is its own control", + ), + (Csharp::PostfixUnaryExpression as u16, 2, "`p` and `pc`"), + (Csharp::PrefixUnaryExpression as u16, 2, "`n` and `nc`"), + ( + Csharp::ParenthesizedExpression as u16, + 2, + "`r` and `rc` — the `if` parens are anonymous tokens", + ), + ], + ); + check_func_space::(src, "foo.cs", |space| { + assert_members_score( + &space.spaces[0], + &[ + ("p", 1, 2), + // The row #1463 fixes and the only one of the three + // pairs that agrees with its control. + ("pc", 1, 2), + ("n", 1, 2), + // #1455: `child(1)` of the prefix wrapper is the + // comment. Delete this expectation, not the row, + // when that issue is fixed. + ("nc", 0, 2), + ("r", 1, 2), + // #1455 again, through the paren wrapper. + ("rc", 0, 2), + ], + ); + }); + } + // The statement `switch` reaches the same `when_clause` kind through // a different parent — `switch_section` rather than // `switch_expression_arm` — which makes it an independent path in diff --git a/src/metrics/abc/csharp.rs b/src/metrics/abc/csharp.rs index 36dbbed4..0be7d6de 100644 --- a/src/metrics/abc/csharp.rs +++ b/src/metrics/abc/csharp.rs @@ -15,11 +15,82 @@ use crate::macros::{ }; use crate::*; +// The operand a transparent wrapper wraps, and whether the wrapper +// itself proves that operand is boolean. `None` means the node is not a +// wrapper and the peel stops there. +// +// Only the prefix `!` proves booleanness: `!x` is boolean whatever `x` +// is, while parentheses and the null-forgiving postfix `x!` are +// type-preserving — `x!` is boolean exactly when the slot it sits in +// is — so they inherit the caller's verdict rather than setting it. +// +// `postfix_unary_expression` is the arm #1463 added, and the fifth +// instance of one class: a condition slot that recognises a wrapper +// kind list, and silently scores nothing for anything not on it. Before +// it, `if (b!)` scored zero conditions against a cyclomatic decision of +// one, while `if (b)` scored one — an asymmetry between two spellings +// of the same test, in the notation nullable-reference-types projects +// write everywhere. `!!` and `(b!)` nest through the same peel. +// +// The kind is one production for three operators (`++`, `--`, `!`), and +// only `!` is type-preserving. `b++` and `b--` are arithmetic, never a +// boolean slot's operand, so the peel declines rather than reaching a +// bare `identifier` and counting it — the same exclusion Groovy's +// `~a` / `+a` / `-a` and Kotlin's `-x` / `x++` take. Their tokens are +// already ABC *assignments* (`PLUSPLUS | DASHDASH`), so accepting them +// would also have scored one construct on two axes. +// +// No fixture pins that exclusion, deliberately (§6). `++` takes a +// numeric operand and yields one, so `if (i++)` and `i++ && b` are type +// errors the compiler rejects; only the grammar's over-permissiveness +// reaches the arm, and a test over input C# rejects would make that +// over-permissiveness the contract. Measured rather than reasoned: +// dropping the `is_child` guard entirely fails **none** of the 3,429 +// library tests. The guard is here because the exclusion is right, not +// because anything observable depends on it. +// +// No double count for the `!` itself (§5): no C# arm counts a `BANG` +// token — it reaches `csharp_count_token_condition`'s `_ => return +// false` and then `csharp_walk_for_conditions`, which matches no token +// kind. `is_child` rather than an index read because the operator is +// the node's *last* child and an `extra` may sit before it +// (`b /*c*/ !`); the operand is `child(0)`, which no extra can precede +// because the node starts there. +// +// The paren and prefix arms keep the positional reads they have always +// had. The C# grammar names nothing here — `parenthesized_expression`, +// `prefix_unary_expression` and `postfix_unary_expression` all carry an +// empty `fields` map in node-types.json — so the field read that +// Kotlin's and Groovy's equivalents use is unavailable, and with it the +// comment bug those reads dodge: `if (( /*c*/ b))` and `if (! /*c*/ b)` +// still score zero, because `child(1)` is the comment. Measured, not +// assumed. That is #1455, which predates this change and is recorded +// here rather than widened into it; the new arm adds no instance of it. +fn csharp_wrapper_operand<'a>(node: &Node<'a>) -> Option<(Node<'a>, bool)> { + use Csharp::*; + + match node.kind_id().into() { + // `(expr)` — the inner expression follows the `(` token. + csharp_paren_expr_kinds!() => Some((node.child(1)?, false)), + // `!expr` — the operand follows the operator token. Seven other + // prefix operators (`++ -- + - ~ & ^`) share this kind, as does + // the `*` of a pointer indirection the grammar aliases onto it; + // none is a boolean slot's operand. + csharp_prefix_unary_expr_kinds!() => match node.child(0)?.kind_id().into() { + BANG => Some((node.child(1)?, true)), + _ => None, + }, + // `expr!` — the null-forgiving operator. One kind id at the + // pinned `=0.23.5`, no numbered aliases (§1). + PostfixUnaryExpression if node.is_child(BANG as u16) => Some((node.child(0)?, false)), + _ => None, + } +} + fn csharp_inspect_container(container_node: &Node, parent: &Node, conditions: &mut f64) { use Csharp::*; let mut node = *container_node; - let mut node_kind = node.kind_id().into(); // Seed the boolean-context flag from the parent: known-boolean // contexts (loop / if / guard / binary expression) imply the @@ -36,32 +107,12 @@ fn csharp_inspect_container(container_node: &Node, parent: &Node, conditions: &m _ => false, }; - // Walk down through `(...)` and `!...` wrappers until we either hit - // the underlying operand or run out of nesting. The C# grammar - // aliases each of these kinds across multiple `kind_id`s - // (lesson #2): match every numbered variant. - loop { - let is_parens = matches!(node_kind, csharp_paren_expr_kinds!()); - let is_not = matches!(node_kind, csharp_prefix_unary_expr_kinds!()) - && node - .child(0) - .is_some_and(|c| matches!(c.kind_id().into(), BANG)); - - if !is_parens && !is_not { - break; - } - - // A `!` wrapper proves the contained value is boolean even - // when the parent context didn't (e.g. `return !x;`). - if !has_boolean_content && is_not { - has_boolean_content = true; - } - - // Both `parenthesized_expression` and `prefix_unary_expression` - // store their inner expression at child index 1. - let Some(child) = node.child(1) else { break }; - node = child; - node_kind = node.kind_id().into(); + // Walk down through the transparent wrappers until we either hit the + // underlying operand or run out of nesting. They chain: `(!b!)` + // peels three to one `identifier`. + while let Some((operand, proves_boolean)) = csharp_wrapper_operand(&node) { + has_boolean_content |= proves_boolean; + node = operand; // Found the innermost operand; count it if a boolean context // was established up the chain. The `csharp_bool_terminal_kinds!()` @@ -69,7 +120,7 @@ fn csharp_inspect_container(container_node: &Node, parent: &Node, conditions: &m // `BooleanLiteral` leaves, and the five bool-evaluating kinds // restored by #372 (member access / await / cast / is-pattern / // element access). - if matches!(node_kind, csharp_bool_terminal_kinds!()) { + if matches!(node.kind_id().into(), csharp_bool_terminal_kinds!()) { if has_boolean_content { *conditions += 1.; } @@ -639,8 +690,7 @@ impl Abc for CsharpCode { // C# mirror of `java_inspect_child` / `groovy_inspect_child`: passes // `node.child(idx)` to `csharp_inspect_container`, which is a no-op on -// kinds other than `csharp_paren_expr_kinds!()` / `!`-prefixed -// `csharp_prefix_unary_expr_kinds!()`. +// every kind `csharp_wrapper_operand` declines. fn csharp_inspect_child(node: &Node, idx: usize, conditions: &mut f64) { if let Some(child) = node.child(idx) { csharp_inspect_container(&child, node, conditions); @@ -648,12 +698,15 @@ fn csharp_inspect_child(node: &Node, idx: usize, conditions: &mut f64) { } fn csharp_count_condition(condition: &Node, parent: &Node, conditions: &mut f64) { - let kind = condition.kind_id().into(); - if matches!(kind, csharp_bool_terminal_kinds!()) { + if matches!(condition.kind_id().into(), csharp_bool_terminal_kinds!()) { *conditions += 1.; - } else if matches!(kind, csharp_paren_expr_kinds!()) - || matches!(kind, csharp_prefix_unary_expr_kinds!()) - { + } else if csharp_wrapper_operand(condition).is_some() { + // Asking the peel itself which kinds it unwraps, rather than + // restating the list here. The two spelled it separately until + // #1463, and either one gaining a wrapper kind the other did not + // would read as covered while the slot dropped it on the floor + // (`.claude/rules/grammar-dispatch.md` §7) — the shape that + // produced the Kotlin half of #1459. csharp_inspect_container(condition, parent, conditions); } } From b6c8747ef849bca23157409016ab1982e800b1cf Mon Sep 17 00:00:00 2001 From: Elijah Zupancic Date: Mon, 14 Sep 2026 16:48:24 -0700 Subject: [PATCH 06/25] fix(abc+cyclomatic/csharp): gate `case` on the switch arm MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tree-sitter-c-sharp emits the `case` keyword token (id 114) from two productions: `switch_section`, a real arm, and `goto_statement`, where `goto case 2;` is an unconditional jump to one. Both C# ABC and C# cyclomatic counted the bare token, so a method whose only difference from a control was a `goto case` read one condition and one decision higher while having exactly the same arms. Both arms now gate on a `switch_section` parent through one shared predicate, in allowlist polarity: a `grammar.json` sweep of the pinned 0.23.5 finds `"case"` in exactly those two rules, and naming the one decision parent means a grammar bump that grows a third fails closed. Neither `Case` nor `SwitchSection` carries a numeric-suffix alias, and `switch_section` is not a hidden rule. `goto default;` needed no equivalent gate: `Default` is a distinct token neither metric counts, being the switch's unconditional fallthrough. Cognitive is unchanged — it models the construct on the `goto_statement` node as an unstructured jump, so a `goto case` remains a jump there and merely stops also being an arm. The two arms move in one commit because they measure the same token, not because a global law binds them: `conditions == cyclomatic() - 1` is an opt-in fixture property, and gating either side alone broke none of the 3,429 library tests, since no fixture outside the cognitive suite spelled `goto case` at all. `assert_fixture_spells` and its C# binding move from the `abc` test module to `test_support` so the cyclomatic test can anchor on the same kind-id counts. Metric drift: C# `abc.conditions`, `abc.magnitude` and `cyclomatic` each fall by one per `goto case`. No integration snapshot moves — the 39-file C# corpus contains no `goto case`. Fixes #1450 Fixes #1451 --- CHANGELOG.md | 19 ++++++ src/metrics/abc.rs | 104 ++++++++++++++++++------------- src/metrics/abc/csharp.rs | 47 +++++++++----- src/metrics/cyclomatic.rs | 104 ++++++++++++++++++++++++++++++- src/metrics/cyclomatic/csharp.rs | 20 +++--- src/test_support.rs | 50 +++++++++++++++ 6 files changed, 276 insertions(+), 68 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2d496d3f..d33ad115 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -147,6 +147,25 @@ for historical reference. ### Fixed +- **C# `goto case` counted as a decision** (#1450, #1451). `goto case 2;` + spells the same `case` keyword token as a real `switch` arm — the + grammar emits it from a second production, `goto_statement` — so both + C# ABC and C# cyclomatic scored the jump as an arm. A method whose + only difference from a control was a `goto case` read one higher on + both metrics while having exactly the same arms. Neither metric counts + the `default` token, so `goto default;` was already free, by accident + rather than design: it is the switch's unconditional fallthrough + (#456, #469). Both arms are now gated on a `switch_section` parent + through one shared predicate, in allowlist polarity, so a grammar + bump that grows a third `case`-bearing production fails closed. + Cognitive is unaffected and unchanged: it models the construct on the + `goto_statement` node, +1 as an unstructured jump per SonarSource §B2, + so a `goto case` remains a jump there and merely stops also being an + arm. **Metric drift:** C# `abc.conditions`, `abc.magnitude` and + `cyclomatic` each fall by one per `goto case`; all three are gated + threshold metrics. No integration snapshot moves — the C# corpus + contains no `goto case`. + - **C# ABC counts a null-forgiving predicate** (#1463). `if (b!)` scored zero conditions where `if (b)` scores one, and likewise `if ((b!))`, `if (!b!)`, `if (b!!)` and every `&&` / `||` operand spelled with the diff --git a/src/metrics/abc.rs b/src/metrics/abc.rs index 5b414c98..f565e6c8 100644 --- a/src/metrics/abc.rs +++ b/src/metrics/abc.rs @@ -455,8 +455,8 @@ implement_metric_trait!(Abc, PreprocCode, CcommentCode); )] mod tests { use crate::test_support::{ - ast_has_kind_id, check_func_space_only_shim, check_metrics_only_shim, child_space, - metrics_verbatim, + assert_csharp_fixture_spells, assert_fixture_spells, ast_has_kind_id, + check_func_space_only_shim, check_metrics_only_shim, child_space, metrics_verbatim, }; use crate::traits::ParserTrait; @@ -564,48 +564,6 @@ mod tests { } } - // #1383's fix makes a relational pattern's operator score *zero*, so - // its tests have no second axis to anchor on: trimming `> 5` down to - // `5` leaves every assertion satisfied and the construct under test - // gone (`.claude/rules/testing.md`, "Perturb the fixture as well as - // the production line"). Asserting the kind ids are still present in - // the parsed fixture is the anchor that replaces it — editing a - // spelling out of the source now fails here by name instead of - // silently turning the method into a copy of the control. - // Counts rather than presence, because these fixtures carry several - // methods spelling the same construct: a bare `ast_has_kind_id` is - // still satisfied after one method loses its pattern, which is - // exactly the decay that turns that method into a silent duplicate - // of the control. Measured — with presence-only anchoring, rewriting - // `if (x is > 0)` to `if (x > 0)` in one method of five failed - // nothing. - #[track_caller] - fn assert_fixture_spells(src: &str, path: &str, kinds: &[(u16, usize, &str)]) { - // An empty list would make every following assertion vacuous, - // which is the anchor's own failure mode rather than a caller's. - assert!(!kinds.is_empty(), "anchor asserted nothing"); - let parser = P::new(src.as_bytes().to_vec(), std::path::Path::new(path), None); - for (kind, want, spelling) in kinds { - let found = parser - .root() - .preorder() - .filter(|n| n.kind_id() == *kind) - .count(); - assert_eq!( - found, *want, - "fixture has {found} of {spelling}, expected {want} — the construct under test was edited" - ); - } - } - - // The C# binding of the anchor above. Fourteen callers pass a `foo.cs` - // fixture, so the parser and path are fixed here rather than repeated - // at each one. - #[track_caller] - fn assert_csharp_fixture_spells(src: &str, kinds: &[(u16, usize, &str)]) { - assert_fixture_spells::(src, "foo.cs", kinds); - } - /// Regression for #227: a `Stats::default()` that never sees an /// observation must not leak the `f64::MAX` sentinel for /// `assignments_min`, `branches_min`, or `conditions_min`. All @@ -3227,6 +3185,64 @@ mod tests { ); } + /// Regression #1450 / #1451: `goto case 2;` spells the same `case` + /// keyword token (id 114) as a real arm, from a second production + /// (`goto_statement`), and scored an ABC condition it does not earn — + /// an unconditional jump to an arm is not a decision. + /// + /// `jmp` differs from `ctl` by exactly one statement, so the + /// `goto case` is the only thing that can separate their scores + /// (`.claude/rules/grammar-dispatch.md` §11 — an arm and a + /// `goto case` are independent paths into the same counter, and a + /// fixture carrying only arms cannot show the gate works). `dfl` is + /// the control that was already correct, by accident rather than + /// design: `goto default;` spells `Default`, which neither metric + /// counts because it is the switch's unconditional fallthrough + /// (#456, #469). + /// + /// [`assert_every_member_scores`] covers cyclomatic as well as ABC, + /// through its per-member §8 parity assertion, and would catch the + /// gate being removed from *either* side alone: dropping ABC's puts + /// `jmp` at conditions 3 against the asserted 2, and dropping + /// cyclomatic's leaves conditions 2 against a decision count of 3. + /// Removing both restores the parity at `3 == 4 - 1` and is caught by + /// the conditions value, which is why 2 is named rather than derived. + /// + /// The `Case` anchor is the second axis. Every member scores 2 from + /// its two arms alone, so deleting `goto case 2;` leaves every + /// assertion below satisfied and the construct under test gone; the + /// anchor's count of 7 — six arms plus the one jump — fails by name + /// instead. + #[test] + fn csharp_goto_case_is_not_a_condition() { + let src = "class A { + int ctl(int x) { switch (x) { case 1: return 1; case 2: return 2; default: return 0; } } + int jmp(int x) { switch (x) { case 1: goto case 2; case 2: return 2; default: return 0; } } + int dfl(int x) { switch (x) { case 1: goto default; case 2: return 2; default: return 0; } } + }"; + assert_csharp_fixture_spells( + src, + &[ + (Csharp::Case as u16, 7, "`case` tokens (6 arms + 1 jump)"), + (Csharp::SwitchSection as u16, 9, "switch arms"), + (Csharp::GotoStatement as u16, 2, "`goto` statements"), + ( + Csharp::Default as u16, + 4, + "`default` tokens (3 arms + 1 jump)", + ), + ], + ); + check_func_space::(src, "foo.cs", |space| { + assert_every_member_scores( + &space.spaces[0], + 3, + 2, + "one condition per `case` arm, none for a `goto` into one", + ); + }); + } + #[test] fn cpp_switch_default_not_a_condition() { // C++ (and plain C, which shares this grammar) already excluded diff --git a/src/metrics/abc/csharp.rs b/src/metrics/abc/csharp.rs index 0be7d6de..3daca458 100644 --- a/src/metrics/abc/csharp.rs +++ b/src/metrics/abc/csharp.rs @@ -337,20 +337,11 @@ fn csharp_count_token_condition<'a>( // is excluded, mirroring cyclomatic's `Case`-only count and the // expression-arm discard rule below (issues #456, #469). // - // These four stay ungated; `EQEQ` / `BANGEQ` shared the arm until - // #1420 and moved to the gated one below. `Else`, `Try` and - // `Catch` come from one production each (`if_statement`, - // `try_statement`, `catch_clause`), so there is nothing to gate on. - // `Case` comes from two — `switch_section` and `goto_statement` — - // and the second is over-counted: FIXME(#1450), `goto case 2;` - // scores a condition without being an arm. Left alone rather than - // missed: C# cyclomatic counts the same token - // (`src/metrics/cyclomatic/csharp.rs`), so gating it here alone - // would break the §8 parity `conditions == cyclomatic() - 1` that - // a two-arm-plus-`goto case` method currently satisfies at - // 3 == 4 - 1. Both arms have to move together. `goto default;` - // costs nothing already, because `Default` is excluded as the - // switch's unconditional fallthrough. + // These three stay ungated; `EQEQ` / `BANGEQ` shared the arm until + // #1420 and moved to the gated one below, and `Case` moved to its + // own gated arm in #1450 / #1451. `Else`, `Try` and `Catch` come + // from one production each (`if_statement`, `try_statement`, + // `catch_clause`), so there is nothing to gate on. // // `QMARKQMARK` joined them in #1459 and is ungated for the same // reason: `??` comes from `binary_expression` alone. It is a @@ -374,7 +365,33 @@ fn csharp_count_token_condition<'a>( // bare `QMARK` below and from the `??=` compound assignment // (`QMARKQMARKEQ`, counted as an assignment), and every // condition slot declines a `binary_expression` outright. - Else | Case | Try | Catch | QMARKQMARK => { + Else | Try | Catch | QMARKQMARK => { + stats.conditions += 1.; + } + // `case` comes from two productions — `switch_section`, a real + // arm, and `goto_statement`, where `goto case 2;` is an + // unconditional jump to one. Both spell the same token, so the + // jump scored a condition it does not earn: a method whose only + // difference from a control was a `goto case` read one condition + // *and* one cyclomatic decision higher (#1450 / #1451). The gate + // is shared with `src/metrics/cyclomatic/csharp.rs` and carries + // the grammar sweep and the allowlist rationale in its doc + // comment. + // + // The two arms moved in one change because they measure the same + // token, not because any global law binds them: `conditions == + // cyclomatic() - 1` is an opt-in fixture property asserted by two + // of `src/metrics/abc.rs`'s three helpers, and the third + // documents it as knowingly false in general. Gating one side + // alone broke no test in the suite — no fixture spelled `goto + // case` outside the cognitive tests — which is the reason to + // write the pair as one commit rather than trust the gate to + // notice. + // + // A gated-out `Case` falls through to + // `csharp_walk_for_conditions`, which has no `Case` arm, so the + // fall-through is a no-op. + Case if crate::metrics::cyclomatic::csharp_case_token_is_switch_arm(node, ancestors) => { stats.conditions += 1.; } // All six C# comparison tokens, counted only where they *apply* diff --git a/src/metrics/cyclomatic.rs b/src/metrics/cyclomatic.rs index 2c2b34de..87b9abcf 100644 --- a/src/metrics/cyclomatic.rs +++ b/src/metrics/cyclomatic.rs @@ -489,6 +489,45 @@ pub(crate) fn csharp_switch_expression_arm_is_bare_discard(node: &Node) -> bool !named.any(|c| c.kind_id() == WhenClause) } +/// Distinguishes the C# `case` keyword of a real `switch` arm from the +/// one inside `goto case 2;`, which spells the same token (id 114) from +/// a second production and is an unconditional jump, not a decision. +/// +/// A `grammar.json` sweep of tree-sitter-c-sharp 0.23.5 finds the +/// `"case"` string in exactly two rules — `switch_section` and +/// `goto_statement` — and only the first is an arm. `Csharp::Case` and +/// `Csharp::SwitchSection` each carry no numeric-suffix alias at this +/// pin (`.claude/rules/grammar-dispatch.md` §1), and `switch_section` +/// is not a hidden `_`-prefixed rule (§2), so the one-variant allowlist +/// is the whole answer. +/// +/// Allowlist polarity, matching the comparison-token gate in +/// `src/metrics/abc/csharp.rs`: naming the one decision parent means a +/// grammar bump that grows a third `case`-bearing production fails +/// *closed* — the new spelling stops counting rather than silently +/// counting as an arm. It also covers error recovery, where a token the +/// parser reparents under `{ERROR}` stops counting; nothing pins that, +/// because a fixture the language rejects would make the grammar's +/// present recovery the contract (§6). +/// +/// Both C# metrics that count the bare token gate on this — ABC's +/// `conditions` and cyclomatic's decision count — so `goto case` moves +/// them together. Cognitive models the construct on a different node +/// entirely (`GotoStatement`, +1 as an unstructured jump per +/// SonarSource §B2, `src/metrics/cognitive/csharp.rs`) and is +/// unaffected: a `goto case` remains a jump there, it merely stops +/// also being an arm here. +/// +/// `goto default;` needed no equivalent gate: `Default` is a distinct +/// token that neither metric counts, since it is the switch's +/// unconditional fallthrough (#456, #469). +pub(crate) fn csharp_case_token_is_switch_arm<'a>( + node: &Node<'a>, + ancestors: Ancestors<'a, '_>, +) -> bool { + ancestors.parent_has_kind(node, Csharp::SwitchSection as u16) +} + /// Detects Kotlin `when_entry` nodes that are `else -> …` arms — the /// analogue of the C-family `default:` arm. tree-sitter-kotlin-ng /// attaches a `condition` field to every case-style entry; the `else` @@ -571,7 +610,10 @@ mod typescript; clippy::too_many_lines )] mod tests { - use crate::test_support::{ast_has_kind_id, check_metrics_only_shim}; + use crate::test_support::{ + assert_csharp_fixture_spells, ast_has_kind_id, check_func_space_only, + check_metrics_only_shim, child_space, + }; use super::*; @@ -2015,6 +2057,66 @@ mod tests { ); } + /// Regression #1450 / #1451: `goto case 2;` spells the same `case` + /// keyword token (id 114) as a real arm, from a second production + /// (`goto_statement`), and scored a decision it does not earn — an + /// unconditional jump to an arm is not a branch point. + /// + /// Per member, never through `cyclomatic_sum()`: the sum cannot tell + /// the shipped `{3, 3, 3}` from the pre-fix `{3, 4, 3}` without also + /// pinning the member count, and the whole claim is that `jmp` reads + /// the same as `ctl`. The three methods are identical but for one + /// statement each, so `jmp`'s `goto case` is the only thing that can + /// separate them (`.claude/rules/grammar-dispatch.md` §11 — an arm + /// and a `goto case` are independent paths into the same counter, and + /// a fixture carrying only arms cannot show the gate works). + /// + /// `dfl` is the control that was already correct, by accident rather + /// than design: `goto default;` spells `Default`, which neither + /// metric counts because it is the switch's unconditional + /// fallthrough (#456, #469). + /// + /// The `Case` anchor is the second axis. Every member scores 3 from + /// its two arms alone, so deleting `goto case 2;` from the fixture + /// leaves every assertion below satisfied and the construct under + /// test gone; the anchor's count of 7 — six arms plus the one jump — + /// fails by name instead. + #[test] + fn csharp_goto_case_is_not_a_switch_arm() { + let src = "class A { + int ctl(int x) { switch (x) { case 1: return 1; case 2: return 2; default: return 0; } } + int jmp(int x) { switch (x) { case 1: goto case 2; case 2: return 2; default: return 0; } } + int dfl(int x) { switch (x) { case 1: goto default; case 2: return 2; default: return 0; } } + }"; + assert_csharp_fixture_spells( + src, + &[ + (Csharp::Case as u16, 7, "`case` tokens (6 arms + 1 jump)"), + (Csharp::SwitchSection as u16, 9, "switch arms"), + (Csharp::GotoStatement as u16, 2, "`goto` statements"), + ( + Csharp::Default as u16, + 4, + "`default` tokens (3 arms + 1 jump)", + ), + ], + ); + check_func_space_only::(src, "foo.cs", &[Metric::Cyclomatic], |space| { + let class = child_space(&space, "A"); + assert_eq!(class.spaces.len(), 3, "fixture moved, not the metric"); + for name in ["ctl", "jmp", "dfl"] { + let member = child_space(class, name); + // base 1 + one decision per `case` arm; the `default:` + // arm and both `goto` forms contribute nothing. + assert_eq!( + member.metrics.cyclomatic.cyclomatic(), + 3, + "{name}: two arms over a base of 1" + ); + } + }); + } + /// Modified CCN: C# switch statement with 2 cases counts as 1. #[test] fn csharp_switch_modified() { diff --git a/src/metrics/cyclomatic/csharp.rs b/src/metrics/cyclomatic/csharp.rs index 85912d9f..561bf9e7 100644 --- a/src/metrics/cyclomatic/csharp.rs +++ b/src/metrics/cyclomatic/csharp.rs @@ -12,7 +12,7 @@ impl Cyclomatic for CsharpCode { fn compute<'a>( node: &Node<'a>, _code: &'a [u8], - _ancestors: Ancestors<'a, '_>, + ancestors: Ancestors<'a, '_>, stats: &mut Stats, ) { use Csharp::*; @@ -22,13 +22,17 @@ impl Cyclomatic for CsharpCode { // keyword token is what is matched here; `default:` uses a // distinct `Default` token and is correctly excluded. // - // FIXME(#1450): `goto case 2;` spells the same token, so it - // scores a decision without being an arm. The ABC half of - // this is the matching arm in `src/metrics/abc/csharp.rs`; - // the two have to move together or the §8 parity - // `conditions == cyclomatic() - 1` breaks, which is why - // neither has been gated on its own. - Case => { + // Gated on the arm production because `goto case 2;` spells + // the same token from `goto_statement` and is an + // unconditional jump, not a decision — it scored one here and + // one ABC condition until #1450 / #1451, so a method whose + // only difference from a control was a `goto case` read one + // higher on both. The predicate is shared with + // `src/metrics/abc/csharp.rs` so the two move together; see + // its doc comment for the grammar sweep and the allowlist + // polarity. A gated-out `Case` matches nothing further in + // either file. + Case if csharp_case_token_is_switch_arm(node, ancestors) => { stats.cyclomatic += 1.; } // Standard-only: switch expression arms, except the bare diff --git a/src/test_support.rs b/src/test_support.rs index 168eafe4..6763adbc 100644 --- a/src/test_support.rs +++ b/src/test_support.rs @@ -11,6 +11,7 @@ use std::path::PathBuf; use crate::spaces::metrics_inner; +use crate::traits::ParserTrait; use crate::{ CodeMetrics, FuncSpace, LANG, Metric, MetricSuite, MetricsOptions, Source, SpaceKind, analyze, }; @@ -272,6 +273,55 @@ pub(crate) fn function_space<'a>(func_space: &'a FuncSpace, name: &str) -> &'a F } } +/// Asserts `src` parses to exactly `want` nodes of each given kind id. +/// +/// The anchor for a fixture whose construct under test contributes +/// *nothing* once the fix lands — a relational pattern's operator after +/// #1383, a `goto case`'s `case` token after #1450 — and so has no +/// second axis for the metric assertions to rest on. Trimming `> 5` down +/// to `5`, or deleting `goto case 2;`, leaves every metric assertion +/// satisfied and the construct gone (`.claude/rules/testing.md`, +/// "Perturb the fixture as well as the production line"). Editing a +/// spelling out of the source now fails here by name instead of silently +/// turning the method into a copy of its control. +/// +/// Counts rather than presence, because these fixtures carry several +/// methods spelling the same construct: a bare [`ast_has_kind_id`] is +/// still satisfied after one method loses its pattern, which is exactly +/// the decay that turns that method into a silent duplicate of the +/// control. Measured — with presence-only anchoring, rewriting +/// `if (x is > 0)` to `if (x > 0)` in one method of five failed nothing. +#[track_caller] +pub(crate) fn assert_fixture_spells( + src: &str, + path: &str, + kinds: &[(u16, usize, &str)], +) { + // An empty list would make every following assertion vacuous, which + // is the anchor's own failure mode rather than a caller's. + assert!(!kinds.is_empty(), "anchor asserted nothing"); + let parser = P::new(src.as_bytes().to_vec(), std::path::Path::new(path), None); + for (kind, want, spelling) in kinds { + let found = parser + .root() + .preorder() + .filter(|n| n.kind_id() == *kind) + .count(); + assert_eq!( + found, *want, + "fixture has {found} of {spelling}, expected {want} — the construct under test was edited" + ); + } +} + +/// The C# binding of [`assert_fixture_spells`]. Every caller passes a +/// `foo.cs` fixture, so the parser and path are fixed here rather than +/// repeated at each one. +#[track_caller] +pub(crate) fn assert_csharp_fixture_spells(src: &str, kinds: &[(u16, usize, &str)]) { + assert_fixture_spells::(src, "foo.cs", kinds); +} + // The parse-only helpers live beside the parse layer and are shared with // its own tests through the `test-support` feature. pub(crate) use big_code_analysis_ast::test_support::{ast_has_kind_id, for_each_node_with_chain}; From e82fc38eaa6b0c7ea48db3d14c1576d7ed408ace Mon Sep 17 00:00:00 2001 From: Elijah Zupancic Date: Mon, 14 Sep 2026 17:11:47 -0700 Subject: [PATCH 07/25] fix(abc): count Perl statement-modifier conditions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `return 1 if $x;` scored abc.conditions 0 where the block form `if ($x) { return 1; }` scores 1, and likewise for `unless`, `while` and `until`. The dispatcher had arms for the four block statements and none for the `*_simple_statement` nodes the grammar emits for the modifier spelling — the idiomatic Perl one, so the undercount was systematic on real code. Perl's cyclomatic dispatcher already counted all six modifier kinds, so this was a straight ABC undercount against Perl's own decision count rather than a disagreement between the two metrics. The slot is read by the grammar's `condition` field and routed through the shared condition classifier, so a compound predicate keeps its sub-structure: `return 1 if $x && $y;` scores 2, as `if ($x && $y)` already did. The `for` / `foreach` modifier is excluded — it iterates a list and has no boolean test, which the grammar records by naming that slot `list` rather than `condition`. Metric drift: Perl abc.conditions and abc.magnitude rise by one per `if` / `unless` / `while` / `until` statement modifier. Fixes #1464 --- CHANGELOG.md | 22 ++++++ src/metrics/abc.rs | 150 ++++++++++++++++++++++++++++++++++++++++ src/metrics/abc/perl.rs | 81 +++++++++++++++++++++- src/test_support.rs | 8 +++ 4 files changed, 259 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d33ad115..9fcec8d3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -147,6 +147,28 @@ for historical reference. ### Fixed +- **Perl ABC scored statement-modifier conditions zero** (#1464). + `return 1 if $x;` reported `abc.conditions` 0 where the block form + `if ($x) { return 1; }` reports 1, and the same for `unless`, `while` + and `until`. The dispatcher had arms for the four block statements + and none for the `*_simple_statement` nodes the grammar emits for the + modifier spelling — which is the idiomatic Perl one (`next unless + $ok;`), so the undercount was systematic on real code. Perl's + *cyclomatic* dispatcher already counted all six modifier kinds, so + this was a straight ABC undercount against Perl's own decision count + rather than a disagreement between the two metrics. The slot is read + by the grammar's `condition` field and goes through the same + condition classifier the block forms use, so a compound predicate + keeps its sub-structure: `return 1 if $x && $y;` scores 2, as + `if ($x && $y)` already did. The `for` / `foreach` modifier + (`print $_ for @list;`) is deliberately excluded — it iterates a list + and has no boolean test, which the grammar itself records by naming + that slot `list` rather than `condition`. **Metric drift:** Perl + `abc.conditions` and `abc.magnitude` rise by one per `if` / `unless` + / `while` / `until` statement modifier, plus whatever its predicate + contributes; both are gated threshold metrics. No integration + snapshot moves — the corpora contain no Perl. + - **C# `goto case` counted as a decision** (#1450, #1451). `goto case 2;` spells the same `case` keyword token as a real `switch` arm — the grammar emits it from a second production, `goto_statement` — so both diff --git a/src/metrics/abc.rs b/src/metrics/abc.rs index f565e6c8..ff5e2649 100644 --- a/src/metrics/abc.rs +++ b/src/metrics/abc.rs @@ -14287,3 +14287,153 @@ mod own_production_bool_constructs { }); } } + +/// A Perl statement modifier must score like the block form it is +/// shorthand for (#1464). +/// +/// `return 1 if $x;` and `if ($x) { return 1; }` are the same decision +/// written two ways, and the modifier is the idiomatic Perl spelling — +/// `next unless $ok;`, `warn "..." if $debug;`. Perl's *cyclomatic* +/// dispatcher already counted all six modifier kinds, so this was a +/// straight ABC undercount against Perl's own decision count rather +/// than a policy disagreement between the two metrics. +/// +/// Each pair is asserted equal **and** equal to a literal, so a +/// regression that zeroed both spellings still fails. The fixtures are +/// spell-anchored because the modifier is the only thing in them that +/// scores a condition: deleting `if $x` from the modifier fixture would +/// otherwise turn it into a silent copy of nothing at all +/// (`.claude/rules/testing.md`, "Perturb the fixture as well as the +/// production line"). +#[cfg(test)] +#[cfg(feature = "perl")] +mod perl_statement_modifier_parity { + use crate::test_support::{assert_perl_fixture_spells, metrics_verbatim}; + use crate::{LANG, MetricsOptions, Perl}; + + fn conditions(source: &str) -> u64 { + metrics_verbatim(LANG::Perl, source.as_bytes(), MetricsOptions::default()) + .abc + .conditions_sum() + } + + /// `(keyword, kind id, kind name)` — the four modifier keywords + /// whose grammar slot is a `condition`. `for` / `foreach` is the + /// fifth spelling and is covered by its own test below; `when` is + /// the sixth and has no fixture at all, being unreachable from + /// valid Perl (see `perl_walk_statement_modifier`). + const CONDITION_MODIFIERS: [(&str, u16, &str); 4] = [ + ("if", Perl::IfSimpleStatement as u16, "if_simple_statement"), + ( + "unless", + Perl::UnlessSimpleStatement as u16, + "unless_simple_statement", + ), + ( + "while", + Perl::WhileSimpleStatement as u16, + "while_simple_statement", + ), + ( + "until", + Perl::UntilSimpleStatement as u16, + "until_simple_statement", + ), + ]; + + #[test] + fn a_modifier_scores_like_its_block_form() { + assert!( + LANG::Perl.is_enabled(), + "compiled under `feature = \"perl\"` but `LANG::Perl` reports disabled; \ + this test asserted nothing" + ); + for (keyword, kind, name) in CONDITION_MODIFIERS { + let modifier = format!("sub f {{ my $x = shift; return 1 {keyword} $x; }}"); + let block = format!("sub f {{ my $x = shift; {keyword} ($x) {{ return 1; }} }}"); + assert_perl_fixture_spells(&modifier, &[(kind, 1, name)]); + assert_eq!( + conditions(&modifier), + conditions(&block), + "`{keyword}` modifier and block form disagree\n modifier: {modifier}\n block: {block}" + ); + assert_eq!( + conditions(&modifier), + 1, + "`{keyword}` modifier: the bare scalar predicate is one condition" + ); + } + } + + /// The modifier's `condition` field goes through the shared + /// condition classifier, not a flat `+1`, so a compound predicate + /// keeps its sub-structure — three shapes the block form already + /// scores above and below one. + #[test] + fn a_modifier_condition_keeps_its_substructure() { + for (predicate, expected) in [("!$x", 1), ("($x)", 1), ("$x && $x", 2), ("$x > 2", 1)] { + let modifier = format!("sub f {{ my $x = shift; return 1 if {predicate}; }}"); + let block = format!("sub f {{ my $x = shift; if ({predicate}) {{ return 1; }} }}"); + assert_perl_fixture_spells( + &modifier, + &[(Perl::IfSimpleStatement as u16, 1, "if_simple_statement")], + ); + assert_eq!( + conditions(&modifier), + expected, + "`if {predicate}` modifier\n source: {modifier}" + ); + assert_eq!( + conditions(&block), + expected, + "`if ({predicate})` block form\n source: {block}" + ); + } + } + + /// The `for` / `foreach` modifier iterates a list and is not a + /// boolean test — the grammar names its slot `list`, not + /// `condition`. Pinned against the `if` modifier so the zero reads + /// as a decision rather than as an arm nothing reaches. + /// + /// Measured: simply *adding* `ForSimpleStatement` to the dispatch + /// arm does not fail this test, because the walker reads the slot by + /// field name and `for_simple_statement` exposes no `condition` — + /// the exclusion is structural, not a listed omission. What it does + /// catch is a rework that reaches the slot some other way (a child + /// index, the `list` field) or a grammar that renames the field. + #[test] + fn the_for_modifier_is_not_a_condition() { + for keyword in ["for", "foreach"] { + let source = format!("sub f {{ my $x = shift; print 5 {keyword} @$x; }}"); + assert_perl_fixture_spells( + &source, + &[(Perl::ForSimpleStatement as u16, 1, "for_simple_statement")], + ); + let abc = + metrics_verbatim(LANG::Perl, source.as_bytes(), MetricsOptions::default()).abc; + assert_eq!(abc.conditions_sum(), 0, "`{keyword}` modifier: {source}"); + // The expected value *is* the default, so the zero above + // proves nothing on its own — a fixture that stopped + // parsing would satisfy it too. `my $x = shift` is the + // file's one assignment, and `shift` and `print 5` its two + // branches; all three must survive. + assert_eq!( + abc.assignments_sum(), + 1, + "`{keyword}`: fixture stopped scoring" + ); + assert_eq!( + abc.branches_sum(), + 2, + "`{keyword}`: fixture stopped scoring" + ); + } + assert_eq!( + conditions("sub f { my $x = shift; return 1 if $x; }"), + 1, + "control: the `if` modifier still counts, so the zero above is a \ + policy decision and not a dead arm" + ); + } +} diff --git a/src/metrics/abc/perl.rs b/src/metrics/abc/perl.rs index ecfdd99d..f8ba6147 100644 --- a/src/metrics/abc/perl.rs +++ b/src/metrics/abc/perl.rs @@ -53,14 +53,26 @@ use crate::*; // wrappers (`ScalarVariable`, `ArrayVariable`, `HashVariable` plus the // access shapes). fn perl_inspect_container(container_node: &Node, parent: &Node, conditions: &mut f64) { - // bca: suppress(cognitive) — wrapper-peeling state machine, clearest whole + // bca: suppress(cognitive, halstead) — wrapper-peeling state machine, clearest whole // See `cpp_inspect_container` for the shared rationale: one loop peels // `(...)` / `!...` layers while carrying a single boolean-context flag. + // `halstead` joined the marker in #1464: the five statement-modifier + // kinds added to the boolean-context seed list took `effort` past the + // 50000 limit. Every one of those operands is a distinct grammar enum + // variant in one flat `matches!`, so the number counts node kinds the + // parser can hand us, not reasoning a reader must do — the same + // artifact `PerlCode::compute` below already carries the marker for. use Perl as P; let mut node = *container_node; let mut node_kind = node.kind_id().into(); let parent_kind = parent.kind_id().into(); + // The `*SimpleStatement` kinds are the statement-modifier forms + // (`return 1 if $x;`), whose `condition` slot is as boolean as the + // block form's (issue #1464). `ForSimpleStatement` is absent for + // the same reason `ForStatement2` is: its slot is a list to + // iterate, not a predicate — the grammar even names that field + // `list` rather than `condition`. let mut has_boolean_content = matches!( parent_kind, P::BinaryExpression @@ -69,6 +81,11 @@ fn perl_inspect_container(container_node: &Node, parent: &Node, conditions: &mut | P::WhileStatement | P::UntilStatement | P::ForStatement1 + | P::IfSimpleStatement + | P::UnlessSimpleStatement + | P::WhileSimpleStatement + | P::UntilSimpleStatement + | P::WhenSimpleStatement ) || (matches!(parent_kind, P::TernaryExpression) && parent .child_by_field_name("condition") @@ -234,6 +251,55 @@ fn perl_walk_for_statement(node: &Node, conditions: &mut f64) { } } +// Phase-2B (issue #1464): the statement-modifier forms +// `EXPR if COND;` / `unless` / `while` / `until`. Each is its own node +// whose `condition` field is the predicate, so the slot is read by +// grammar FIELD rather than by child index (`.claude/rules/grammar- +// dispatch.md` §3). Perl's cyclomatic dispatcher already counts all +// six modifier kinds, so before this Perl scored `return 1 if $x;` +// zero conditions against `if ($x) { return 1; }`'s one — a straight +// undercount on the idiomatic spelling, not a metric disagreement. +// +// `for_simple_statement` — the sixth modifier kind — is deliberately +// absent: `print $_ for @list;` iterates a list and has no boolean +// test, and the grammar names its field `list`, not `condition`. That +// mirrors the block forms, where `ForStatement1` contributes only its +// C-style header *condition* and the `foreach` shape `ForStatement2` +// contributes nothing. +// +// `when_simple_statement` is listed for parity with the cyclomatic +// dispatcher, which counts all six, but it is untested and unreachable +// from valid Perl: `when` is a statement inside a `given` / `for` +// topicalizer, never a modifier, and `perl -c` rejects +// `print 6 when $x;`. Only error recovery can produce the node, so a +// fixture would pin the grammar's present over-permissiveness as the +// contract (`.claude/rules/grammar-dispatch.md` §6). +// +// `_if_simple` (`Perl::IfSimple`) is a hidden rule and gets no arm — +// the parser inlines it, emitting the `if` token directly beneath +// `if_simple_statement` (§2, verified with `bca dump`). +// +// The `condition` field holds an `_argument_choice`: either a +// `parenthesized_argument`, which `perl_inspect_container` already +// peels, or a bare `arguments` wrapper, which it does not. Peel the +// `arguments` layer here — via the same last-named-child rule the +// `Array` `(...)` wrapper uses, since a Perl comma list evaluates to +// its last element in the scalar context a condition imposes — and +// hand what it holds to the shared condition classifier. +fn perl_walk_statement_modifier(node: &Node, conditions: &mut f64) { + let Some(condition) = node.child_by_field_name("condition") else { + return; + }; + let slot = if matches!(condition.kind_id().into(), Perl::Arguments) { + perl_last_named_child(&condition) + } else { + Some(condition) + }; + if let Some(slot) = slot { + perl_count_condition(&slot, node, conditions); + } +} + fn perl_is_call_argument_parent(parent: Node) -> bool { use Perl as P; matches!( @@ -291,7 +357,8 @@ impl Abc for PerlCode { // and the cyclomatic count is the number of node kinds the // grammar can hand us, neither being reasoning a reader must // do. Adding the guarded `<` / `>` arm for #1297 took the - // count from 14 to 15; the arm is independent and + // count from 14 to 15, and the statement-modifier arm for + // #1464 from 15 to 16; each arm is independent and // self-describing like every other, and there is no semantic // boundary to split this lookup on. use Perl as P; @@ -447,6 +514,16 @@ impl Abc for PerlCode { P::ForStatement1 => { perl_walk_for_statement(node, &mut stats.conditions); } + // Statement modifiers — `return 1 if $x;`, `next unless $ok;` + // (issue #1464). See `perl_walk_statement_modifier` for why + // `for_simple_statement` is not in this list. + P::IfSimpleStatement + | P::UnlessSimpleStatement + | P::WhileSimpleStatement + | P::UntilSimpleStatement + | P::WhenSimpleStatement => { + perl_walk_statement_modifier(node, &mut stats.conditions); + } _ => {} } } diff --git a/src/test_support.rs b/src/test_support.rs index 6763adbc..a8a6ba4b 100644 --- a/src/test_support.rs +++ b/src/test_support.rs @@ -322,6 +322,14 @@ pub(crate) fn assert_csharp_fixture_spells(src: &str, kinds: &[(u16, usize, &str assert_fixture_spells::(src, "foo.cs", kinds); } +/// The Perl binding of [`assert_fixture_spells`], with the same +/// fixed-parser rationale as the C# one above. +#[cfg(feature = "perl")] +#[track_caller] +pub(crate) fn assert_perl_fixture_spells(src: &str, kinds: &[(u16, usize, &str)]) { + assert_fixture_spells::(src, "foo.pl", kinds); +} + // The parse-only helpers live beside the parse layer and are shared with // its own tests through the `test-support` feature. pub(crate) use big_code_analysis_ast::test_support::{ast_has_kind_id, for_each_node_with_chain}; From 04cefe5ceec336354f4307ade23e3a6b02e8e5db Mon Sep 17 00:00:00 2001 From: Elijah Zupancic Date: Mon, 14 Sep 2026 17:52:05 -0700 Subject: [PATCH 08/25] fix(metrics): count match guards in five more languages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #1422 made a C# `when` guard a cyclomatic decision and an ABC condition slot, and argued from Rust that C# was the outlier. That was half true: Rust's cyclomatic saw the guard through the `if` keyword token, but no sibling modelled the ABC half and Java had neither. Each language now scores a guard as a slot, so every spelling contributes exactly one and a compound guard keeps its sub-structure. Java 21's `guard` was referenced by neither metric; Rust's `match_pattern` guard and Python's `case … if g:` had the decision but not the condition; Ruby's `if_guard` / `unless_guard` had neither. Elixir is the inverse — it counted the `when` token as a condition with no cyclomatic arm behind it — and its arm is gated on the guard's position, because the language has no guard production and a typespec's binding clause spells the same token. Both metrics share that gate, so the typespec stops scoring a condition too. Also closes the opposite-direction gap: a bare guard (`_ if b`) scored nothing, one below the arm's own decision count. Groovy and Kotlin are unchanged and untested: neither pinned grammar has a guard production, and Kotlin 2.1 guard syntax does not parse, so pinning its numbers would make that limitation the contract. Metric drift: Java, Ruby and Elixir cyclomatic gains one per guard, and wmc / mi move with it; abc.conditions gains one per non-operator guard in Java, Rust, Python and Ruby, and falls by one per Elixir typespec `when`. Three serde snapshots move. Fixes #1454 --- .bca-baseline.toml | 14 +- CHANGELOG.md | 41 ++ big-code-analysis-book/src/metrics.md | 2 +- src/metrics/abc.rs | 454 +++++++++++++++++++- src/metrics/abc/elixir.rs | 75 ++-- src/metrics/abc/java.rs | 40 +- src/metrics/abc/python.rs | 52 ++- src/metrics/abc/ruby.rs | 34 +- src/metrics/abc/rust.rs | 64 ++- src/metrics/cyclomatic.rs | 22 +- src/metrics/cyclomatic/elixir.rs | 25 ++ src/metrics/cyclomatic/java.rs | 23 +- src/metrics/cyclomatic/ruby.rs | 27 +- src/metrics/npa/shared.rs | 58 +++ tests/repositories/big-code-analysis-output | 2 +- 15 files changed, 866 insertions(+), 67 deletions(-) diff --git a/.bca-baseline.toml b/.bca-baseline.toml index 8e184ad8..211beade 100644 --- a/.bca-baseline.toml +++ b/.bca-baseline.toml @@ -881,12 +881,6 @@ qualified = "options_from" metric = "nexits" value = 8.0 -[[entry]] -path = "src/metrics/abc/csharp.rs" -qualified = "csharp_inspect_container" -metric = "cognitive" -value = 15.0 - [[entry]] path = "src/metrics/abc/go.rs" qualified = "GoCode::compute" @@ -899,12 +893,6 @@ qualified = "GoCode::compute" metric = "halstead.effort" value = 49999.43426005393 -[[entry]] -path = "src/metrics/abc/groovy.rs" -qualified = "groovy_inspect_container" -metric = "cognitive" -value = 15.0 - [[entry]] path = "src/metrics/abc/java.rs" qualified = "java_inspect_container" @@ -927,7 +915,7 @@ value = 50110.01872952996 path = "src/metrics/abc/rust.rs" qualified = "RustCode::compute" metric = "cyclomatic" -value = 16.0 +value = 15.0 [[entry]] path = "src/metrics/cognitive/bash.rs" diff --git a/CHANGELOG.md b/CHANGELOG.md index 9fcec8d3..cd51b8d1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -324,6 +324,44 @@ for historical reference. **Metric drift:** C# `abc.branches` rises by one per type passing arguments to its base from a primary constructor. +- **A pattern-match guard now counts in Java, Rust, Python, Ruby and + Elixir** (#1454). #1422 made a C# `when` guard a cyclomatic decision + and an ABC condition slot, and argued from Rust that C# was the + outlier rather than the convention. That was half true: Rust's + *cyclomatic* counted the guard through the `if` keyword token inside + `match_pattern`, but no sibling modelled the ABC half, and Java had + neither. Each language now scores a guard as a slot — every spelling + contributes exactly one, a compound guard keeps its sub-structure — + and as a decision where it was not already one. Per language: Java 21's + `guard` on a pattern-switch label was referenced nowhere in either + metric; Rust's `match_pattern` guard and Python's `case … if g:` had + the decision but not the condition; Ruby's `if_guard` / `unless_guard` + on a `case … in` arm had neither; Elixir is the inverse case, having + counted the `when` token as a condition since #557 with no cyclomatic + arm behind it. The Elixir arm is gated on the guard's position, + because the language has no guard production — `x when g` is an + ordinary `binary_operator` — and a typespec's binding clause + (`@spec f(a) :: a when a: integer`) spells the same token; that gate + is shared by both metrics, so it also removes the condition the + typespec used to score against no decision anywhere. The same fix + closes the opposite-direction gap in the issue: a *bare* guard + (`match x { _ if b => … }`, `case _ if b:`) scored nothing at all, + one below the arm's own decision count. Groovy and Kotlin are + unchanged and untested: neither pinned grammar has a guard + production, and Kotlin 2.1 guard syntax does not parse at the pin, so + per `grammar-dispatch` §6 pinning its numbers would make the + grammar's present limitation the contract. + + **Metric drift:** Java, Ruby and Elixir `cyclomatic` (standard and + modified) gain one per guard, and `wmc` and `mi` move with it, so a + `wmc` or `mi` threshold can newly fire on an unedited file carrying + guarded arms. `abc.conditions` and `abc.magnitude` gain one per guard + in Java, Rust, Python and Ruby for any guard not already + operator-shaped. Elixir `abc.conditions` is unchanged for real guards + and falls by one per typespec `when`. Integration snapshots move for + three `serde` files (Rust); no Python, Ruby, Java or Elixir corpus + file carries a guard. + - **A C# `when` guard now counts as a decision in both cyclomatic complexity and ABC** (#1422). Cyclomatic had no arm for either guard spelling — `when_clause` on a switch arm or `case` section, and @@ -346,6 +384,9 @@ for historical reference. C# corpus snapshot records `class_wmc_sum` 27 → 29 and a matching fall in all three `mi` variants — so a `wmc` or `mi` threshold can newly fire on an unedited C# file carrying guarded arms. + #1454 carries the same rule to every other language whose grammar has a + guard production, so the C# scoping in this entry describes where the + rule started rather than where it applies. - **Kotlin no longer double-counts a subject-less `when` arm's comparison operator** (#1421). `when { x > 5 -> 1; x < 0 -> 2; else -> 0 }` diff --git a/big-code-analysis-book/src/metrics.md b/big-code-analysis-book/src/metrics.md index a0c866ea..9270dea2 100644 --- a/big-code-analysis-book/src/metrics.md +++ b/big-code-analysis-book/src/metrics.md @@ -151,7 +151,7 @@ application would over-count. | 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. The arithmetic ternary `$(( a ? b : c ))` therefore contributes nothing: it carries no branch keyword, so it falls outside the rule set rather than through a gap in it. | | 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. | -| C# | A `when` guard is a condition slot, scoring one however it is spelled | A guard on a switch arm (`when_clause`, shared by `switch_expression_arm` and `switch_section`) or on a `catch` (`catch_filter_clause`) is a branch: the pattern can match while the guard fails. Neither metric modelled it, and ABC scored whatever operator happened to sit inside, so `when x > 2` counted one and the equivalent `when IsEven(x)` counted none. The guard's expression is now scored exactly as an `if` condition is — one for a call, `is` test or bare identifier, one via the operator arm for a comparison — so every spelling agrees, and a compound guard (`when a > 1 && b < 2`) keeps its sub-structure rather than collapsing to one (#1422). | +| C#, Java, Rust, Python, Ruby, Elixir | A pattern-match guard is a condition slot, scoring one however it is spelled | A guard is a branch: the pattern can match while the guard fails. The guard's expression is scored exactly as an `if` condition is — one for a call, type test, attribute or bare identifier, one via the operator arm for a comparison — so every spelling agrees, and a compound guard (`when a > 1 && b < 2`) keeps its sub-structure rather than collapsing to one. Before this, ABC scored whatever operator happened to sit inside, so `when x > 2` counted one and the equivalent `when IsEven(x)` counted none. The spellings, per language: C# `when_clause` on a switch arm or `case` section and `catch_filter_clause` on a `catch` (#1422); Java 21's `guard` on a pattern-switch label, in both the arrow and colon forms; Rust's `match_pattern` guard; Python's `case … if g:`; Ruby's `if_guard` / `unless_guard` on a `case … in` arm; Elixir's `when` operator on a `stab_clause` head or a `def` / `defguard` head (#1454). The same change made the guard a **cyclomatic** decision wherever it was not already one — Java, Ruby and Elixir — and excluded Elixir's typespec `when` (`@spec f(a) :: a when a: integer`), which spells the same token as a guard but is type syntax. Groovy and Kotlin are absent because neither pinned grammar has a guard production at all: Kotlin 2.1 guard syntax does not parse, so there is nothing to classify (#1454). | | Kotlin | A subject-less `when` arm scores through the predicate slot; a subject-ful arm scores per entry | A subject-less arm's condition (`when { x > 5 -> … }`) is an ordinary boolean expression, compiled as an `if` predicate, so the comparison inside it is already counted by the operator arms and a blanket per-entry increment double-counted it. A subject-ful arm (`when (x) { in 1..2 -> … }`) lists a pattern rather than an independent boolean expression, so the implicit `subject == pattern` is a decision nothing in the source spells and the entry itself pays for it (#1421). | | C++, Objective-C, Mozcpp, Rust, Go, Java, Groovy, C#, Kotlin, TypeScript, TSX, JavaScript, Mozjs, Lua, Perl, Ruby, Bash, Elixir | A `<` or `>` that is not a comparison is not a condition | Every one of these grammars spells at least one non-comparison construct with the same bare `<` / `>` token a comparison uses, so the comparison rule is gated on the token's parent. What that excludes, per family: template and generic brackets in C++, Objective-C, Mozcpp, Rust, Go, Java, Groovy, C#, Kotlin, TypeScript and TSX (#1274); JSX tag delimiters in TypeScript, TSX, JavaScript and Mozjs; Lua 5.4 variable attributes (`local x = 1`); a C# comparison-operator overload's declared name (`operator <`); Kotlin's qualified super call (`super.g()`) (#1297); Perl's filehandle and lexical-handle readlines (``, `<$fh>` — but not ``, which the grammar lexes as one token); Ruby's superclass clause (`class Foo < Bar`) and comparison-operator method names (`def <(other)`), and Bash I/O redirection (`cmd > out`) (#1280); Elixir's sigil delimiters (`~s`) (#1256). C# additionally excludes the operator of a relational pattern (`x is > 0`, and the `> 5 =>` arm of a switch expression): the arm or `if` condition slot that owns the pattern already scores the decision, so counting the operator too charged a relational arm twice what the constant arm `5 => 1` scores (#1383). Its `>=` / `<=` spelling is excluded by the same rule through a separate token. The declared name of an operator overload is excluded on every spelling too: C# overloads six comparison operators and gives each a distinct token, so `operator <=`, `operator >=`, `operator ==` and `operator !=` join `operator <` and `operator >`, which alone were excluded before #1420. The exclusion holds wherever the pattern sits, so a pattern no arm or condition slot owns (`bool b = x is > 0;`, `return x is > 0;`) scores 0, one below the equivalent `x > 0`. A `when` guard is no longer such a place: since #1422 the guard is itself a condition slot, so `when n is > 5` reads level with `when n > 5` rather than one below it. PHP, Python, Tcl and iRules emit a bare `<` / `>` from no non-comparison production; C carries the same gate as its C-family siblings although, having no templates, it has nothing to exclude. The gate is a claim about the grammar's productions, not about every parse: where a grammar resolves a generic *call* into nested `binary_expression` nodes, as tree-sitter-kotlin-ng does for `id(a)`, no polarity can exclude it (#1394). | | Java, Groovy, C#, TypeScript, TSX | A `?` used as type syntax is not a ternary | In each of these grammars the ternary `?` and the type-syntax `?` are the *same* anonymous token, so the ternary rule above is gated on the token's parent. Java and Groovy exclude the wildcard bound `List` (#1274); C# excludes the nullable type `int? x` and the constraint `where T : class?`; TypeScript and TSX exclude optional parameters, properties, methods, class fields and tuple elements, and conditional types (`T extends U ? X : Y`, which the type checker resolves and erases before runtime, so it is no more a branch than the `<` / `>` already excluded) (#1275). Safe navigation is untouched: C#'s `a?.b` shares the same token and still counts, while the other languages spell theirs as a distinct one. | diff --git a/src/metrics/abc.rs b/src/metrics/abc.rs index ff5e2649..1b9f6e8c 100644 --- a/src/metrics/abc.rs +++ b/src/metrics/abc.rs @@ -7785,15 +7785,16 @@ function f(int $a, int $b): int { fn ruby_case_match_guarded_wildcard_is_a_condition() { // Regression for #977: a guarded wildcard arm `in _ if x` is not a // bare default and counts as one ABC condition, while the trailing - // bare `in _` adds none. The guard predicate here is a bare - // identifier (no comparison operator), so the single counted - // condition is the guarded `in_clause` itself. - // expected: 1 condition — the guarded `in _ if x` arm only. + // bare `in _` adds none. + // expected: 2 conditions — the guarded `in _ if x` arm, plus its + // guard slot. The guard predicate is a bare identifier, so before + // #1454 it contributed nothing and the total was 1; the slot is + // what makes `if x`, `if x.even?` and `if x > 0` agree. check_metrics::( "def f(x)\n case x\n in _ if x then :y\n in _ then :default\n end\nend\n", "foo.rb", |metric| { - assert_eq!(metric.abc.conditions_sum(), 1); + assert_eq!(metric.abc.conditions_sum(), 2); }, ); } @@ -13133,6 +13134,449 @@ function f(int $a, int $b): int { "parenthesising the condition must not change the count" ); } + + // #1454's five-language sweep of #1422's C# rule: a pattern-match + // guard is a condition *slot*, so every spelling of one contributes + // exactly one condition, and the guard is a cyclomatic decision the + // arm it guards does not already pay for. + // + // Each fixture is the §11 triple — an operator guard (`> 5`, whose + // comparison token already counted before the fix), a call guard, + // and a bare-identifier guard (the two that counted nothing) — + // plus a parenthesised guard, which is the only member that + // exercises the boolean-context seed, plus an unguarded control + // that must stay where it was. A single-spelling fixture cannot + // show the slot works: before the fix the operator member already + // read the right number. + // + // The controls are load-bearing twice over. `none` pins that the + // slot fires on the guard and not on the arm — a `+1` on every arm + // would satisfy every guarded row and break this one — and the + // helper (`is_even` / `isEven`) pins that the call-guard row is + // scoring its guard rather than its call, since a call is an ABC + // *branch* everywhere and a condition nowhere. + // + // `assert_members_score` rather than `assert_every_member_scores`: + // the guarded members and their control sit at different values, + // which is the comparison these tests exist to make. + + #[test] + fn rust_match_guard_scores_one_condition_however_spelled() { + let src = "fn is_even(n: i32) -> bool { + n == 0 + } + fn tok(x: i32) -> i32 { + match x { n if n > 5 => 1, _ => 0 } + } + fn call(x: i32) -> i32 { + match x { n if is_even(n) => 1, _ => 0 } + } + fn bare(x: i32, b: bool) -> i32 { + match x { _ if b => 1, _ => 0 } + } + fn paren(x: i32, b: bool) -> i32 { + match x { n if (b) => n, _ => 0 } + } + fn none(x: i32) -> i32 { + match x { 1 => 1, _ => 0 } + }"; + assert_fixture_spells::( + src, + "foo.rs", + &[ + // The guard's own `if` keyword, one per guarded member. + // It is also how Rust cyclomatic already saw the guard, + // which is why only the ABC half moved here. + (Rust::If as u16, 4, "match guards"), + (Rust::GT as u16, 1, "`tok`'s `>`"), + (Rust::CallExpression as u16, 1, "the `is_even` call"), + ( + Rust::ParenthesizedExpression as u16, + 1, + "`paren`'s parenthesised guard", + ), + // Two arms per member: the guarded one and its bare `_`. + (Rust::MatchPattern as u16, 10, "match arms"), + ], + ); + check_func_space::(src, "foo.rs", |space| { + assert_members_score( + &space, + &[ + ("is_even", 1, 1), + ("tok", 2, 3), + // Was 1 — the guard spelling nothing counted. + ("call", 2, 3), + ("bare", 2, 3), + ("paren", 2, 3), + ("none", 1, 2), + ], + ); + }); + } + + #[test] + fn java_pattern_switch_guard_scores_one_condition_however_spelled() { + let src = "class T { + static boolean isEven(int i) { return i == 0; } + int tok(Object o) { return switch (o) { case Integer i when i > 5 -> 1; default -> 0; }; } + int call(Object o) { return switch (o) { case Integer i when isEven(i) -> 1; default -> 0; }; } + int bare(Object o, boolean b) { return switch (o) { case Integer i when b -> 1; default -> 0; }; } + int paren(Object o, boolean b) { return switch (o) { case Integer i when (b) -> 1; default -> 0; }; } + int none(Object o) { return switch (o) { case Integer i -> 1; default -> 0; }; } + }"; + assert_fixture_spells::( + src, + "foo.java", + &[ + (Java::Guard as u16, 4, "`when` guards"), + (Java::GT as u16, 1, "`tok`'s `>`"), + (Java::MethodInvocation as u16, 1, "the `isEven` call"), + // Five `switch (o)` subjects plus `paren`'s `when (b)`. + // Java wraps a switch subject in the same kind, so this + // count moves if either is edited out. + ( + Java::ParenthesizedExpression as u16, + 6, + "the switch subjects and `paren`'s guard", + ), + ], + ); + check_func_space::(src, "foo.java", |space| { + assert_members_score( + &space.spaces[0], + &[ + ("isEven", 1, 1), + // Was 2 conditions against a *flat* cyclomatic of 2: + // Java had neither half of the rule, so the guard + // was invisible to cyclomatic and visible to ABC + // only through whatever operator it spelled. + ("tok", 2, 3), + ("call", 2, 3), + ("bare", 2, 3), + ("paren", 2, 3), + ("none", 1, 2), + ], + ); + }); + } + + #[test] + fn python_case_guard_scores_one_condition_however_spelled() { + let src = "def is_even(n): + return n == 0 +def tok(x): + match x: + case n if n > 5: + return 1 + case _: + return 0 +def call(x): + match x: + case n if is_even(x): + return 1 + case _: + return 0 +def bare(x, b): + match x: + case _ if b: + return 1 + case _: + return 0 +def paren(x, b): + match x: + case n if (b): + return n + case _: + return 0 +def none(x): + match x: + case 1: + return 1 + case _: + return 0 +"; + assert_fixture_spells::( + src, + "foo.py", + &[ + // The `case` guard clause. A comprehension filter is the + // same kind in a different role, and the `guard` field + // is what keeps it out of the slot — there is none in + // this fixture, and `python_comprehension_if_clause_is_not_a_case_guard` + // is where that separation is pinned. + (Python::IfClause as u16, 4, "`case` guards"), + ( + Python::ComparisonOperator as u16, + 2, + "`tok`'s `>` and `is_even`'s `==`", + ), + (Python::Call as u16, 1, "the `is_even` call"), + ( + Python::ParenthesizedExpression as u16, + 1, + "`paren`'s parenthesised guard", + ), + ], + ); + check_func_space::(src, "foo.py", |space| { + assert_members_score( + &space, + &[ + ("is_even", 1, 1), + ("tok", 2, 3), + ("call", 2, 3), + ("bare", 2, 3), + ("paren", 2, 3), + ("none", 1, 2), + ], + ); + }); + } + + // A comprehension's `if` filter is an `if_clause` too, and it is not + // a `case` guard: the slot reads `case_clause`'s `guard` field, so + // the two cannot be confused by construction. Python cyclomatic does + // count the filter (through the same `If` keyword token it counts a + // guard by), and ABC does not — a pre-existing divergence this + // change deliberately leaves where it found it. The test is here so + // that a later `IfClause` arm added without the field read fails + // loudly rather than moving comprehensions silently. + #[test] + fn python_comprehension_if_clause_is_not_a_case_guard() { + let src = "def m(xs): + return [x for x in xs if x] +"; + assert_fixture_spells::( + src, + "foo.py", + &[(Python::IfClause as u16, 1, "the comprehension filter")], + ); + check_func_space::(src, "foo.py", |space| { + assert_members_score(&space, &[("m", 0, 3)]); + }); + } + + #[test] + fn ruby_in_clause_guard_scores_one_condition_however_spelled() { + let src = "def is_even(x) + x == 0 +end +def tok(x) + case x + in [n] if n > 5 then 1 + in _ then 0 + end +end +def call(x) + case x + in [n] if n.even? then 1 + in _ then 0 + end +end +def bare(x, b) + case x + in [n] if b then 1 + in _ then 0 + end +end +def unguard(x, b) + case x + in [n] unless b then 1 + in _ then 0 + end +end +def paren(x, b) + case x + in [n] if (b) then 1 + in _ then 0 + end +end +def none(x) + case x + in [n] then 1 + in _ then 0 + end +end +"; + assert_fixture_spells::( + src, + "foo.rb", + &[ + (Ruby::IfGuard as u16, 4, "`if` guards"), + (Ruby::UnlessGuard as u16, 1, "the `unless` guard"), + (Ruby::GT as u16, 1, "`tok`'s `>`"), + ( + Ruby::ParenthesizedStatements as u16, + 1, + "`paren`'s parenthesised guard", + ), + ], + ); + // `Ruby::Guard` (210) is the hidden `_guard` supertype: the two + // dispatchers list it beside the concrete kinds so a grammar + // that starts emitting it keeps working, and this pins that it + // does not emit it today — otherwise the defensive arm is + // indistinguishable from a dead one + // (`.claude/rules/grammar-dispatch.md` §2). + let parser = RubyParser::new( + src.as_bytes().to_vec(), + &std::path::PathBuf::from("foo.rb"), + None, + ); + assert!( + !ast_has_kind_id(&parser, Ruby::Guard as u16), + "`_guard` stopped being hidden — the defensive arms now fire" + ); + check_func_space::(src, "foo.rb", |space| { + assert_members_score( + &space, + &[ + ("is_even", 1, 1), + // Was 2 / 2: Ruby had neither half either, and the + // arm's own condition supplied the 2 that made the + // operator spelling look correct. + ("tok", 2, 3), + ("call", 2, 3), + ("bare", 2, 3), + ("unguard", 2, 3), + ("paren", 2, 3), + ("none", 1, 2), + ], + ); + }); + } + + // Elixir is the inverse of the four above: ABC counted the `when` + // token from the start and cyclomatic had no arm at all, so a guard + // read as a condition with no decision behind it. #1454 adds the + // decision, which is why `tok` / `call` / `bare` move on the + // cyclomatic axis here and on the ABC axis everywhere else. + // + // `tok` sits one *above* its decision count because Elixir keeps the + // guard's sub-structure — `when n > 5` pays the `>` on top of the + // `when` — which is the same slot policy the other four follow and + // the reason `assert_members_score` does not assert §8 parity. + #[test] + fn elixir_guard_is_a_decision_however_spelled() { + let src = "defmodule T do + def is_even(x) do + x == 0 + end + def tok(x) do + case x do + n when n > 5 -> 1 + _ -> 0 + end + end + def call(x) do + case x do + n when is_integer(n) -> 1 + _ -> 0 + end + end + def bare(x, b) do + case x do + _n when b -> 1 + _ -> 0 + end + end + def none(x) do + case x do + 1 -> 1 + _ -> 0 + end + end +end +"; + assert_fixture_spells::( + src, + "foo.ex", + &[ + (Elixir::When as u16, 3, "`when` guards"), + (Elixir::GT as u16, 1, "`tok`'s `>`"), + ], + ); + check_func_space::(src, "foo.ex", |space| { + assert_members_score( + &space.spaces[0], + &[ + ("is_even", 1, 1), + // All three guarded members were cyclomatic 2 — + // level with `none` — before the decision arm. + ("tok", 3, 3), + ("call", 2, 3), + ("bare", 2, 3), + ("none", 1, 2), + ], + ); + }); + } + + // The gate that makes the Elixir arm safe. Elixir has no dedicated + // guard production, and a typespec's binding clause spells the same + // `when` token — so an ungated arm would have made type syntax a + // decision. It was already an ABC condition against no decision + // anywhere, which this removes. + // + // Both members carry the same `@spec`; only `guarded` carries a real + // head guard, so the difference between the two rows is the guard + // and nothing else. + #[test] + fn elixir_typespec_when_is_not_a_guard() { + let src = "defmodule T do + @spec plain(a) :: a when a: integer + def plain(x) do + x + end + @spec guarded(a) :: a when a: integer + def guarded(x) when is_integer(x) do + x + end +end +"; + assert_fixture_spells::( + src, + "foo.ex", + &[( + Elixir::When as u16, + 3, + "two typespec `when`s and one head guard", + )], + ); + check_func_space::(src, "foo.ex", |space| { + let module = &space.spaces[0]; + // Positional rather than by name: a `def` whose head carries + // a guard parses its name out of a `binary_operator` instead + // of a plain `Call` target, and the space comes back + // ``. That naming gap predates this change and is + // why `assert_members_score` cannot serve here. + let members: Vec<(u64, u64)> = module + .spaces + .iter() + .map(|m| { + ( + m.metrics.abc.conditions(), + m.metrics.cyclomatic.cyclomatic(), + ) + }) + .collect(); + assert_eq!( + members, + vec![(0, 1), (1, 2)], + "`plain` scores nothing; `guarded` scores its head guard \ + in both metrics" + ); + // The typespecs sit in the module body, outside either + // member, so their (non-)contribution has to be read off + // the container. Was 2 before the gate: one per `@spec`. + assert_eq!( + module.metrics.abc.conditions(), + 0, + "a typespec `when` is type syntax, not a guard" + ); + }); + } } /// A comment inside a ternary must not change its ABC conditions diff --git a/src/metrics/abc/elixir.rs b/src/metrics/abc/elixir.rs index 2942fc35..4647131f 100644 --- a/src/metrics/abc/elixir.rs +++ b/src/metrics/abc/elixir.rs @@ -10,6 +10,7 @@ )] use super::{Abc, Stats}; +use crate::lang_helpers::elixir::elixir_call_keyword; use crate::macros::elixir_bool_terminal_kinds; use crate::*; @@ -107,6 +108,38 @@ fn elixir_count_unary_conditions(list_node: &Node, conditions: &mut f64) { } } +// What an Elixir `Call` contributes. The classification is by keyword +// text rather than by kind, so it is a paragraph of policy rather than a +// dispatch arm — the same split `java_count_token_branch` and +// `csharp_count_token_assignment` make in the two largest sibling impls. +fn elixir_count_call(node: &Node, code: &[u8], stats: &mut Stats) { + let keyword = elixir_call_keyword(node, code); + let is_definition_or_directive = matches!( + keyword, + Some( + "def" + | "defp" + | "defmacro" + | "defmacrop" + | "defmodule" + | "defstruct" + | "defprotocol" + | "defimpl" + | "alias" + | "import" + | "require" + | "use" + ) + ); + if !is_definition_or_directive { + stats.branches += 1.; + } + // Keyword-shaped control-flow Calls also contribute one condition. + if matches!(keyword, Some("if" | "unless" | "case" | "cond" | "with")) { + stats.conditions += 1.; + } +} + impl Abc for ElixirCode { // Elixir's pattern-match `=` is a `BinaryOperator` whose middle // child is an `EQ` token. The same wrapper node also hosts `+=`- @@ -158,9 +191,7 @@ impl Abc for ElixirCode { // boolean ops, arithmetic) so the constant-time check // matters. E::BinaryOperator | E::BinaryOperator2 | E::BinaryOperator3 - if node - .child(1) - .is_some_and(|c| c.kind_id() == E::EQ as u16) => + if node.child(1).is_some_and(|c| c.kind_id() == E::EQ as u16) => { stats.assignments += 1.; } @@ -194,29 +225,25 @@ impl Abc for ElixirCode { // are intentionally different — both impls use the same // helper to look up the keyword, but apply different // policies on top. - E::Call => { - let keyword = crate::lang_helpers::elixir::elixir_call_keyword(node, code); - let is_definition_or_directive = matches!( - keyword, - Some( - "def" | "defp" | "defmacro" | "defmacrop" - | "defmodule" | "defstruct" | "defprotocol" | "defimpl" - | "alias" | "import" | "require" | "use" - ) - ); - if !is_definition_or_directive { - stats.branches += 1.; - } - // Keyword-shaped control-flow Calls also contribute - // one condition. - if matches!(keyword, Some("if" | "unless" | "case" | "cond" | "with")) { - stats.conditions += 1.; - } + E::Call => elixir_count_call(node, code, stats), + E::EQEQ | E::EQEQEQ | E::BANGEQ | E::BANGEQEQ | E::LTEQ | E::GTEQ => { + stats.conditions += 1.; } - E::EQEQ | E::EQEQEQ | E::BANGEQ | E::BANGEQEQ | E::LTEQ | E::GTEQ // Guard `when` token: introduces the guard clause of a - // function head or `case` arm. - | E::When => { + // function head or `case` / `fn` / `receive` arm. One + // condition per guard, whatever the guard spells, with its + // sub-structure (`when x > 2` also pays the `>`) left to the + // arms that own it — the condition-slot model #1422 gave C#, + // which Elixir already had here. + // + // What it lacked was the gate, added with #1454 and shared + // with the `Cyclomatic` impl that gained the matching + // decision (grammar-dispatch §7). Elixir has no dedicated + // guard production, and a typespec's binding clause + // (`@spec f(a) :: a when a: integer`) spells the same token: + // it scored a condition here against no decision anywhere, + // on type syntax that branches on nothing. + E::When if npa::elixir_when_is_guard(node, code, ancestors) => { stats.conditions += 1.; } // Counts `<` / `>` only as the operator token of a diff --git a/src/metrics/abc/java.rs b/src/metrics/abc/java.rs index 70570aad..3fd3d755 100644 --- a/src/metrics/abc/java.rs +++ b/src/metrics/abc/java.rs @@ -22,8 +22,14 @@ fn java_inspect_container(container_node: &Node, parent: &Node, conditions: &mut let mut node_kind = node.kind_id().into(); // Initializes the flag to true if the container is known to contain a boolean value + // `Guard` joined this list with #1454: a `case … when g ->` guard is + // a boolean slot exactly as an `if` condition is, so a parenthesised + // guard operand (`when (b)`) counts where the bare `when b` already + // did. let mut has_boolean_content = match parent.kind_id().into() { - BinaryExpression | IfStatement | WhileStatement | DoStatement | ForStatement => true, + BinaryExpression | IfStatement | WhileStatement | DoStatement | ForStatement | Guard => { + true + } TernaryExpression => parent .child_by_field_name("condition") .is_some_and(|condition| condition.id() == node.id()), @@ -306,6 +312,38 @@ fn java_walk_for_conditions<'a>(node: &Node<'a>, ancestors: Ancestors<'a, '_>, s ArgumentList => java_count_unary_conditions(node, conds), // Child 1: `if (cond) ...`, `while (cond) ...`, `return value;`. IfStatement | WhileStatement | ReturnStatement => java_inspect_child(node, 1, conds), + // The Java 21 pattern-switch guard (`case Integer i when g ->`), + // modelled as a condition slot exactly like the `if` / `while` + // slots above (#1454, transferring #1422's C# rule). Before + // this, a guard scored whatever operator happened to sit inside + // it: `when i > 5` counted one via the comparison-token arm + // while `when isEven(i)` and `when b` counted zero, so three + // semantically identical guards produced two different numbers. + // As a slot every spelling contributes exactly one — a call / + // field access / `instanceof` test / bare identifier through + // `java_bool_terminal_kinds!()`, a comparison through the token + // arm that already owns it — and a compound guard + // (`when a > 1 && b < 2`) keeps its sub-structure rather than + // collapsing to one. + // + // By role, not index (`.claude/rules/grammar-dispatch.md` §3): + // `guard` is `seq('when', expression)` and node-types.json gives + // it no field, so the expression is located as the clause's + // named child rather than at a fixed offset. Every named child, + // not the first: tree-sitter `extra`s are named and may precede + // it, so `when /*c*/ g` hands a `comment` to a first-child read + // and silently restores the spelling-dependence this removes. + // Java's only extras at this pin are `line_comment` and + // `block_comment`, neither of them a + // `java_bool_terminal_kinds!()` member or a paren / `!` wrapper, + // so passing them through the slot adds nothing and the loop + // cannot double count a clause that holds one expression by + // construction. + Guard => { + for guard in node.children().filter(Node::is_named) { + java_count_condition(&guard, node, conds); + } + } // Child 2: assignment / declarator RHS, lambda body // (`params -> body`). VariableDeclarator | AssignmentExpression | LambdaExpression => { diff --git a/src/metrics/abc/python.rs b/src/metrics/abc/python.rs index 8cec94de..dfdad6f6 100644 --- a/src/metrics/abc/python.rs +++ b/src/metrics/abc/python.rs @@ -60,9 +60,13 @@ fn python_inspect_container(container_node: &Node, parent: &Node, conditions: &m let mut node = *container_node; let mut node_kind = node.kind_id().into(); + // `IfClause` joined this list with #1454: a `case … if g:` guard is + // a boolean slot exactly as an `if` condition is, so a parenthesised + // guard operand (`case n if (b):`) counts where the bare + // `case n if b:` already did. let has_boolean_content = matches!( parent.kind_id().into(), - BooleanOperator | IfStatement | WhileStatement | ConditionalExpression + BooleanOperator | IfStatement | WhileStatement | ConditionalExpression | IfClause ); loop { @@ -145,6 +149,43 @@ fn python_count_ternary_condition(node: &Node, conditions: &mut f64) { } } +// The `case … if g:` guard of a `case_clause`, modelled as a condition +// slot exactly like the `if` / `while` slots (#1454, transferring +// #1422's C# rule). Before this, a guard scored whatever operator +// happened to sit inside it: `case n if n > 5:` counted one via the +// `comparison_operator` arm while `case n if is_even(n):` and +// `case _ if b:` counted zero, so three semantically identical guards +// produced two different numbers. As a slot every spelling contributes +// exactly one — a call / attribute / subscript / bare identifier through +// `python_bool_terminal_kinds!()`, a comparison or `not` through the arm +// that already owns it — and a compound guard (`case n if a > 1 and b:`) +// keeps its sub-structure rather than collapsing to one. +// +// By grammar FIELD, not index (`.claude/rules/grammar-dispatch.md` §3): +// `case_clause` names its guard `guard`, which is what keeps a +// comprehension's `if_clause` — the same kind, in a wholly different +// role — out of this slot. The clause itself carries no field for its +// expression (it is `seq('if', expression)`), so the operand is located +// as a named child rather than at a fixed offset, and *every* named +// child is passed: tree-sitter `extra`s are named and may precede it, +// so `case n if # why\n b:` hands a `comment` to a first-child read. +// Python's extras at this pin are `comment` and `line_continuation`, +// neither a `python_bool_terminal_kinds!()` member nor a +// `parenthesized_expression`, so passing them through adds nothing and +// the loop cannot double count a clause that holds one expression by +// construction. +// +// No double count (§5): cyclomatic reaches this guard through the `If` +// *keyword token* inside the `if_clause`, which no ABC arm matches. +fn python_count_case_guard(case_clause: &Node, conditions: &mut f64) { + let Some(guard) = case_clause.child_by_field_name("guard") else { + return; + }; + for operand in guard.children().filter(Node::is_named) { + python_count_condition(&operand, &guard, conditions); + } +} + fn python_inspect_child(node: &Node, idx: usize, conditions: &mut f64) { if let Some(child) = node.child(idx) { python_count_condition(&child, node, conditions); @@ -236,8 +277,17 @@ impl Abc for PythonCode { // on the `case_clause` — `case _ if g:` carries a guard // and still counts. The shared classifier lives in // `super::npa` next to `pattern_is_bare_underscore`. + // The guard is a further condition slot — see + // `python_count_case_guard`. It is counted inside this arm + // rather than from an `IfClause` arm of its own because a + // comprehension filter (`[x for x in xs if g]`) is the same + // `if_clause` kind, and the `guard` field reaches only the + // `case` one. A guarded clause always satisfies the gate + // (`python_case_clause_counts` returns `true` on sight of + // an `if_clause`), so no guard is lost to it. CaseClause if super::npa::python_case_clause_counts(node, UNDERSCORE as u16) => { stats.conditions += 1.; + python_count_case_guard(node, &mut stats.conditions); } // Fitzpatrick Rule 9 walker: each operand of an `and` / // `or` chain is one condition (issue #403). The `And` / diff --git a/src/metrics/abc/ruby.rs b/src/metrics/abc/ruby.rs index e77b7dc1..99e3f06e 100644 --- a/src/metrics/abc/ruby.rs +++ b/src/metrics/abc/ruby.rs @@ -67,9 +67,14 @@ fn ruby_inspect_container(container_node: &Node, parent: &Node, conditions: &mut // Both were live across the C family, PHP, Perl and the JS family // until #1181 moved them all onto this form; the cross-language // regression test is `ternary_comment_invariance` in `abc.rs`. + // The three guard kinds joined this list with #1454: a `case … in` + // arm's `if` / `unless` guard is a boolean slot exactly as an `if` + // predicate is, so a parenthesised guard operand (`in [x] if (b)`) + // counts where the bare `in [x] if b` already did. `Guard` is the + // hidden `_guard` supertype, listed defensively (§2). let mut has_boolean_content = matches!( parent_kind, - Binary | Binary2 | Binary3 | If | Unless | While | Until + Binary | Binary2 | Binary3 | If | Unless | While | Until | Guard | IfGuard | UnlessGuard ) || (matches!(parent_kind, Conditional) && parent .child_by_field_name("condition") @@ -224,8 +229,33 @@ impl Abc for RubyCode { // form (block and modifier) is one unary condition. The // `condition` field locates the predicate position- // independently across all eight node kinds (#696). + // + // The two `case … in` guard kinds share the arm: `if_guard` + // and `unless_guard` each expose their predicate through the + // same `condition` field, so a guard is a condition slot + // classified by exactly the code that classifies an `if` + // predicate (#1454, transferring #1422's C# rule). Before + // this, a guard scored whatever operator happened to sit + // inside it: `in [x] if x > 2` counted one via the + // comparison-token arm while `in [x] if x.even?` and + // `in [x] if b` counted zero, so three semantically + // identical guards produced two different numbers. As a slot + // every spelling contributes exactly one — a call / + // identifier / ivar / element reference through + // `ruby_bool_terminal_kinds!()`, a comparison through the + // token arm that already owns it — and a compound guard + // keeps its sub-structure rather than collapsing to one. + // + // `Guard` is the hidden `_guard` supertype (§2, lesson #34); + // it is listed for the same defensive reason + // `ruby_in_clause_counts` lists it. + // + // No double count (§5): the `if` / `unless` *keyword tokens* + // a guard contains are anonymous tokens distinct from the + // `If` / `Unless` statement kinds this arm matches, so a + // guard reaches the slot exactly once. If | Unless | While | Until | IfModifier | UnlessModifier | WhileModifier - | UntilModifier => { + | UntilModifier | Guard | IfGuard | UnlessGuard => { if let Some(cond) = node.child_by_field_name("condition") { ruby_count_condition(&cond, node, &mut stats.conditions); } diff --git a/src/metrics/abc/rust.rs b/src/metrics/abc/rust.rs index 53652916..a789f52c 100644 --- a/src/metrics/abc/rust.rs +++ b/src/metrics/abc/rust.rs @@ -35,9 +35,13 @@ fn rust_inspect_container(container_node: &Node, parent: &Node, conditions: &mut let mut node = *container_node; let mut node_kind = node.kind_id().into(); + // `MatchPattern` joined this list with #1454: a match guard + // (`n if g =>`) is a boolean slot exactly as an `if` condition is, + // so a parenthesised guard operand (`n if (b)`) counts where the + // bare `n if b` already did. let mut has_boolean_content = matches!( parent.kind_id().into(), - BinaryExpression | IfExpression | WhileExpression | LetChain | LetChain2 + BinaryExpression | IfExpression | WhileExpression | LetChain | LetChain2 | MatchPattern ); loop { @@ -83,6 +87,52 @@ fn rust_count_condition(condition: &Node, parent: &Node, conditions: &mut f64) { } } +// The conditions a `match_arm` contributes: the arm itself, plus its +// guard. +// +// The arm counts unless its pattern is a bare `_` — the C / Java +// `default:` equivalent, filtered here exactly as cyclomatic filters it. +// +// The guard is a condition slot, modelled like the `if` / `while` slots +// (#1454, transferring #1422's C# rule). Before this, a guard scored +// whatever operator happened to sit inside it: `n if n > 5` counted one +// via the comparison-token arm while `n if is_even(n)` and `_ if b` +// counted zero, so three semantically identical guards produced two +// different numbers. As a slot every spelling contributes exactly one — +// a call / field / index / bare identifier through +// `rust_bool_terminal_kinds!()`, a comparison through the token arm +// that already owns it — and a compound guard (`n if a > 1 && b`) keeps +// its sub-structure rather than collapsing to one. +// +// By grammar FIELD, not index (`.claude/rules/grammar-dispatch.md` §3): +// `match_pattern` names its guard `condition`, so a comment between the +// pattern and the `if` cannot shift the read the way it does for the +// positional sibling slots. The field is absent on an unguarded arm, +// which is what keeps the control at its old value. +// +// No double count (§5): cyclomatic reaches this guard through the `If` +// *keyword token* inside `match_pattern`, which no ABC arm matches, and +// the field's two non-expression types — `let_condition` (`n if let +// Some(v) = o`) and `let_chain` — are already owned by the +// `LetCondition` token arm and by the `&&` walker respectively. Neither +// is a `rust_bool_terminal_kinds!()` member, so routing them through +// the slot adds nothing. +fn rust_count_match_arm(node: &Node, conditions: &mut f64) { + let Some(pattern) = node.child_by_field_name("pattern") else { + // `pattern` is a required field, so this is unreachable at the + // pinned grammar; counting the arm keeps the pre-#1454 + // `is_some_and` polarity if error recovery ever produces one. + *conditions += 1.; + return; + }; + if !super::npa::pattern_is_bare_underscore(&pattern, Rust::UNDERSCORE as u16) { + *conditions += 1.; + } + if let Some(guard) = pattern.child_by_field_name("condition") { + rust_count_condition(&guard, &pattern, conditions); + } +} + fn rust_inspect_child(node: &Node, idx: usize, conditions: &mut f64) { if let Some(child) = node.child(idx) { rust_count_condition(&child, node, conditions); @@ -200,14 +250,10 @@ impl Abc for RustCode { // not throw off the detection. A guard (`_ if g`) adds a // second named child to `match_pattern` and so escapes // the bare-wildcard filter. - MatchArm | MatchArm2 => { - let is_bare_wildcard = node.child_by_field_name("pattern").is_some_and(|pat| { - super::npa::pattern_is_bare_underscore(&pat, UNDERSCORE as u16) - }); - if !is_bare_wildcard { - stats.conditions += 1.; - } - } + // + // The arm's guard is a further condition slot — see + // `rust_count_match_arm`. + MatchArm | MatchArm2 => rust_count_match_arm(node, &mut stats.conditions), // Fitzpatrick Rule 7: each operand of a `&&` / `||` chain // is one condition. The walker iterates immediate children // of the parent `binary_expression`; the per-`&&` / per-`||` diff --git a/src/metrics/cyclomatic.rs b/src/metrics/cyclomatic.rs index 87b9abcf..4d86b4e3 100644 --- a/src/metrics/cyclomatic.rs +++ b/src/metrics/cyclomatic.rs @@ -5484,16 +5484,19 @@ f() { // A guarded wildcard (`_ when g ->`) is a real decision — the // guard can fail, so control can fall through — and must keep // counting, matching Rust's `_ if guard` rule (issue #1272). - // standard = 3 entries + `1 ->` + `_ when x > 5 ->` = 5 (only the - // final bare `_ ->` is excluded); modified = 3 entries + case = 4. + // standard = 3 entries + `1 ->` + `_ when x > 5 ->` + the guard + // itself = 6 (only the final bare `_ ->` is excluded); modified = + // 3 entries + case + the guard = 5. The guard is the #1454 arm: it + // is a second way the arm can fail, and no container collapses it, + // so it counts in both tiers where the arm counts only in standard. #[test] fn elixir_case_guarded_wildcard_counts() { check_metrics::( "defmodule Foo do\n def classify(x) do\n case x do\n 1 -> :one\n _ when x > 5 -> :big\n _ -> :other\n end\n end\nend\n", "foo.ex", |metric| { - assert_eq!(metric.cyclomatic.cyclomatic_sum(), 5); - assert_eq!(metric.cyclomatic.cyclomatic_modified_sum(), 4); + assert_eq!(metric.cyclomatic.cyclomatic_sum(), 6); + assert_eq!(metric.cyclomatic.cyclomatic_modified_sum(), 5); }, ); } @@ -6825,15 +6828,18 @@ f() { // Regression for #977: a non-wildcard `in 1` arm and a guarded // wildcard `in _ if x > 0` arm each add one standard decision, // while the trailing bare `in _` default arm adds none. The - // `case_match` container stays a modified-only decision. + // `case_match` container stays a modified-only decision, but the + // guard itself is a decision in both tiers (#1454): nothing + // collapses it the way the container collapses its arms. // expected per function: standard = 1 (base) + `in 1` + `in _ if` - // = 3; modified = 1 (base) + 1 (case_match) = 2. + // + the `if` guard = 4; modified = 1 (base) + 1 (case_match) + + // the guard = 3. check_metrics::( "def f(x)\n case x\n in 1 then :one\n in _ if x > 0 then :positive\n in _ then :default\n end\nend\n", "foo.rb", |metric| { - assert_eq!(metric.cyclomatic.cyclomatic_max(), 3); - assert_eq!(metric.cyclomatic.cyclomatic_modified_max(), 2); + assert_eq!(metric.cyclomatic.cyclomatic_max(), 4); + assert_eq!(metric.cyclomatic.cyclomatic_modified_max(), 3); }, ); } diff --git a/src/metrics/cyclomatic/elixir.rs b/src/metrics/cyclomatic/elixir.rs index c4103909..f15b0c4e 100644 --- a/src/metrics/cyclomatic/elixir.rs +++ b/src/metrics/cyclomatic/elixir.rs @@ -200,6 +200,31 @@ impl Cyclomatic for ElixirCode { E::StabClause => { stats.cyclomatic += 1.; } + // A guard is a decision the construct it guards does not + // already pay for (#1454, transferring #1422's C# rule): a + // guarded clause fails two ways — the pattern does not + // match, or it matches and the guard is false — and a + // guarded function head is one alternative among the + // clauses. Both standard and modified, because no container + // collapses it: a `case`'s arms collapse into the container + // for modified, the guard on an arm does not, exactly as + // C#'s `when_clause` counts in both tiers. + // + // Elixir's ABC counted the `when` token from the start; it + // was cyclomatic that had no arm, so a guard read as a + // condition with no decision behind it. The #1422 order + // applies — fix cyclomatic, then re-derive ABC — and the + // re-derivation is that ABC's existing count is already the + // slot model (one per guard, sub-structure kept) and needs + // only this gate, which it now shares. + // + // No double count (§5): the token fires once per `when`, + // and the `binary_operator` that wraps it is not matched by + // any arm here. + E::When if crate::metrics::npa::elixir_when_is_guard(node, code, ancestors) => { + stats.cyclomatic += 1.; + stats.cyclomatic_modified += 1.; + } // Short-circuit booleans add a decision point in both // metrics. E::AMPAMP | E::PIPEPIPE | E::And | E::Or => { diff --git a/src/metrics/cyclomatic/java.rs b/src/metrics/cyclomatic/java.rs index 0729e4ee..131259e3 100644 --- a/src/metrics/cyclomatic/java.rs +++ b/src/metrics/cyclomatic/java.rs @@ -8,4 +8,25 @@ use super::*; -impl_cyclomatic_java_like!(JavaCode, Java, []); +// `Guard` is the Java 21 pattern-switch guard (`case Integer i when +// i > 5 ->`), and it is a decision the enclosing `case` does not +// already pay for (#1454, transferring #1422's C# rule): a guarded arm +// fails two ways — the pattern does not match, or it matches and the +// guard is false — while contributing one decision. The `switch_label` +// production is shared by the arrow and colon forms, so one arm covers +// both spellings. +// +// The clause *node* (`guard`, 184), not the `when` keyword token it +// contains. Both count once per guard at this pin — `Java::When` (76) +// is a plain keyword here, with none of the `_reserved_identifier` +// doubling that forced C#'s hand (`int when = 1;` emits an +// `identifier`, verified by `bca dump`, not inferred). The clause node +// is used anyway, because it is the construct the rule is about and it +// stays correct if a later grammar gains that alias. Neither kind +// carries a numeric-suffix alias (grammar-dispatch §1). +// +// No double count (§5): the guard's body is an ordinary expression, so +// the only keyword token inside it is whatever the guard itself spells, +// and `If` / `For` / `While` / `Catch` cannot appear in an expression +// position. +impl_cyclomatic_java_like!(JavaCode, Java, [Guard]); diff --git a/src/metrics/cyclomatic/ruby.rs b/src/metrics/cyclomatic/ruby.rs index e5b10711..2591d083 100644 --- a/src/metrics/cyclomatic/ruby.rs +++ b/src/metrics/cyclomatic/ruby.rs @@ -34,7 +34,32 @@ impl Cyclomatic for RubyCode { stats.cyclomatic_modified += 1.; } // Both standard and modified. - R::If + // + // `IfGuard` / `UnlessGuard` are the two guard spellings of a + // `case … in` pattern arm (`in [x] if x > 2`), and each is a + // decision the arm does not already pay for (#1454, + // transferring #1422's C# rule): a guarded arm fails two + // ways — the pattern does not match, or it matches and the + // guard is false. Unlike `InClause`, the guard is not + // collapsed by the `case` container, so it counts toward + // modified as well, exactly as C#'s `when_clause` does. + // + // `Guard` (210) is the hidden `_guard` supertype the parser + // never emits; it is listed defensively beside the two + // concrete kinds, as `ruby_in_clause_counts` already lists + // it (grammar-dispatch §2, lesson #34). Its hidden status is + // pinned by an `ast_has_kind_id` assertion in the + // `ruby_in_clause_guard_*` tests. + // + // No double count (§5): the `if` / `unless` *keyword tokens* + // inside a guard are anonymous tokens distinct from + // `R::If` (239) / `R::Unless` (240), which are the statement + // nodes — measured, not assumed: before this arm a guarded + // `in` arm scored exactly what its unguarded control did. + R::Guard + | R::IfGuard + | R::UnlessGuard + | R::If | R::Unless | R::Elsif | R::IfModifier diff --git a/src/metrics/npa/shared.rs b/src/metrics/npa/shared.rs index b6436904..69c8c5da 100644 --- a/src/metrics/npa/shared.rs +++ b/src/metrics/npa/shared.rs @@ -656,6 +656,64 @@ pub(crate) fn ruby_in_clause_counts(in_clause: &Node, source: &[u8]) -> bool { }) } +/// Whether a `when` operator token spells a real guard — a function +/// head's (`def f(x) when g do`) or a clause's (`x when g -> …`) — +/// rather than a typespec's `when` binding clause +/// (`@spec f(a) :: a when a: integer`), which is type syntax and no +/// decision at all. +/// +/// Shared by the `Cyclomatic` and `Abc` impls for `ElixirCode` so the +/// two cannot disagree about what a guard is (grammar-dispatch §7). +/// Elixir has no dedicated guard production — `x when g` is an ordinary +/// `binary_operator` — so the position it sits in is the only thing that +/// tells a guard from a typespec, and the allowlist below is that +/// position set at the pinned grammar: the `left` slot of a +/// `stab_clause` (`case` / `cond` / `fn` / `receive` / `with`'s `else` +/// / `try`'s handlers), or an argument of a definition Call that takes +/// a guarded head. +/// +/// `arguments` carries five kind aliases at this pin and +/// `binary_operator` three, so both are matched by rule name rather +/// than by enumerating ids (grammar-dispatch §1). +/// +/// Alternative guards (`when a when b`, valid but rare) parse +/// left-associatively into nested `when` operators, and only the +/// outermost reaches an anchor: the construct scores one, the same as +/// the single-alternative spelling. That is the slot model — the guard +/// is one decision however many alternatives it lists — and it is what +/// `ancestors` can answer in O(1) steps. +pub(crate) fn elixir_when_is_guard<'a>( + node: &Node<'a>, + code: &'a [u8], + ancestors: Ancestors<'a, '_>, +) -> bool { + use Elixir as E; + + const ARGUMENTS: &str = "arguments"; + + let mut chain = ancestors.iter(node); + // The token's parent is the `when` operator node itself; its parent + // is the position that decides. + let Some((operator, _)) = chain.next() else { + return false; + }; + let Some((parent, _)) = chain.next() else { + return false; + }; + if parent.kind_id() == E::StabClause as u16 { + return parent + .child_by_field_name("left") + .is_some_and(|left| left.id() == operator.id()); + } + parent.kind() == ARGUMENTS + && chain.next().is_some_and(|(call, _)| { + crate::lang_helpers::elixir::elixir_call_keyword(&call, code).is_some_and(|keyword| { + crate::lang_helpers::elixir::elixir_is_method_macro(keyword) + || matches!(keyword, "defguard" | "defguardp") + }) + }) +} + // A `visibility_modifier` node counts as public unless it has a direct // `Zelf` child — the structural signature of `pub(self)` / `pub(in self)`, // which restrict visibility to the current module (semantically private, diff --git a/tests/repositories/big-code-analysis-output b/tests/repositories/big-code-analysis-output index 81abf593..4b0a998e 160000 --- a/tests/repositories/big-code-analysis-output +++ b/tests/repositories/big-code-analysis-output @@ -1 +1 @@ -Subproject commit 81abf5931c9d54dfc43a109149209d14cdf60cac +Subproject commit 4b0a998e30855df71b5ab13718e6ac16d57932ac From 61a28712bca6df200e7f814dfd26d8a6bdfb51c1 Mon Sep 17 00:00:00 2001 From: Elijah Zupancic Date: Mon, 14 Sep 2026 18:27:54 -0700 Subject: [PATCH 09/25] fix(abc): count non-numeric literal bool operands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A literal in a boolean operand slot scored no ABC condition unless it was numeric: `x || "default"` reported conditions 1 against `x || y`'s 2, and `if ("s")` reported 0 against `if (b)`'s 1. #1410 closed the numeric half; the non-numeric literals were missing from every set the numerics were added to. Adds, each measured short against an identifier control in both walker paths (the chain operand and the `if` predicate) before landing: - JavaScript / Mozjs / TypeScript / Tsx: string, template_string, regex, null, undefined, object, array - Python: string, concatenated_string, none, list, set, tuple, dictionary, ellipsis - Lua: string, table_constructor - PHP: string, encapsed_string, heredoc, nowdoc, array_creation_expression, null, shell_command_expression, and cast_expression — PHP was the only set in the Java / C# / Groovy / PHP group naming no cast kind - Groovy: string_literal, null_literal, list_literal, map_literal A type keyword rendering to the same node-kind name as its literal stays out, extending the rule PHP's `float` keyword established. C#, Java, Kotlin, Rust and Go are unchanged: a bare literal in a boolean slot is a compile error there. The C family carries the same gap for string_literal and is deferred, being the one integer-truthy group with corpus exposure. The tests assert the kind_id each spelling parses to, not only the resulting count — a set naming the wrong alias of a multi-id kind keeps reporting the unfixed number, which no conditions comparison can see. Metric drift: abc.conditions / magnitude / value rise by one per non-numeric literal operand in a boolean slot. 85 of 384 pdf.js snapshots move, conditions-family only and all upward; no other corpus moves. Fixes #1462 --- CHANGELOG.md | 47 +++ big-code-analysis-ast/src/macros/kind_sets.rs | 263 +++++++++++- src/metrics/abc.rs | 390 ++++++++++++++++++ tests/repositories/big-code-analysis-output | 2 +- 4 files changed, 689 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cd51b8d1..13f15d18 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -147,6 +147,53 @@ for historical reference. ### Fixed +- **A non-numeric literal in a boolean operand slot scored no ABC + condition** (#1462). `x || "default"` reported `abc.conditions` 1 + against `x || y`'s 2, and `if ("s")` reported 0 against `if (b)`'s 1, + in all eight truthy-valued languages. #1410 had closed the same gap + for numeric literals; the non-numeric ones were never swept and were + missing from every set the numerics were added to. Every kind below + was measured a condition short of an identifier control in *both* + walker paths — the `&&` / `and` chain operand and the `if` predicate — + before being added, and the `kind_id` each spelling parses to is now + asserted rather than inferred, which is the half a conditions + comparison cannot check when a grammar spells one node kind under + several ids: + - **JavaScript, Mozjs, TypeScript, Tsx**: `string`, `template_string`, + `regex`, `null`, `undefined`, `object`, `array`. + - **Python**: `string`, `concatenated_string`, `none`, `list`, `set`, + `tuple`, `dictionary`, `ellipsis`. + - **Lua**: `string`, `table_constructor`. Lua is the sharpest case — + everything but `false` and `nil` is truthy, which is why + `cond and "a" or "b"` is the language's ternary, and it scored one + below `cond and a or b`. + - **PHP**: `string`, `encapsed_string`, `heredoc`, `nowdoc`, + `array_creation_expression`, `null`, `shell_command_expression`, and + `cast_expression` — the last closing a second finding of the same + survey, PHP having been the only set in the Java / C# / Groovy / PHP + group that named no cast kind, so `if ((bool)$x)` scored zero where + the other three scored one. + - **Groovy**: `string_literal` (which also covers the slashy `/re/`), + `null_literal`, `list_literal`, `map_literal`. + + A type keyword that renders to the same node-kind name as its literal + stays out, extending the rule PHP's `float` keyword established: + TypeScript's and Tsx's `string` / `object` annotation ids and PHP's + `string` / `null` ones are not values. C#, Java, Kotlin, Rust and Go + name no literal kind at all and are unchanged — a bare literal in a + boolean slot is a compile error there, so there is nothing to count. + The C family carries the same gap for `string_literal` and is + deliberately deferred: it is the one integer-truthy group with + integration-corpus exposure, so its snapshot delta wants its own + change. **Metric drift:** `abc.conditions`, `abc.magnitude` and + `abc.value` rise by one per non-numeric literal operand in a boolean + slot, in the eight languages listed; `abc` is a gated threshold + metric. Cyclomatic is unaffected. 85 of the 384 pdf.js JavaScript + integration snapshots move, all in the `conditions` family and all + upward; no other corpus moves, the DeepSpeech tree being entirely + C/C++ and the six-file PHP corpus carrying no literal in a boolean + slot. + - **Perl ABC scored statement-modifier conditions zero** (#1464). `return 1 if $x;` reported `abc.conditions` 0 where the block form `if ($x) { return 1; }` reports 1, and the same for `unless`, `while` diff --git a/big-code-analysis-ast/src/macros/kind_sets.rs b/big-code-analysis-ast/src/macros/kind_sets.rs index 44fe8478..ba053729 100644 --- a/big-code-analysis-ast/src/macros/kind_sets.rs +++ b/big-code-analysis-ast/src/macros/kind_sets.rs @@ -185,15 +185,39 @@ macro_rules! java_bool_terminal_kinds { // `regex_match_expression`) comes through the token arm, see below; // `binary_expression`, `ternary_expression`, `elvis_expression` and // `switch_expression` are scored by their own operator token or -// nested condition; and the rest — `list_literal`, `map_literal`, -// `closure`, `object_creation_expression`, `range_expression`, -// `power_expression`, `update_expression`, `method_pointer_expression`, -// `method_reference_expression`, `spread_dot_expression`, -// `string_literal`, `null_literal` — are shapes whose Groovy-truth -// value is either constant or degenerate in a predicate slot, and -// none has a sibling-language precedent. `spread_dot_expression` -// (`a*.b`) is the closest call of those; it is recorded in #1466 -// rather than added blind. +// nested condition; and the rest — `closure`, +// `object_creation_expression`, `range_expression`, `power_expression`, +// `update_expression`, `method_pointer_expression`, +// `method_reference_expression`, `spread_dot_expression` — are shapes +// whose Groovy-truth value is either constant or degenerate in a +// predicate slot. `spread_dot_expression` (`a*.b`) is the closest call +// of those; it is recorded in #1466 rather than added blind, as is +// `object_creation_expression`, which #1462 measured short and left +// alone because `new Foo()` is not a literal. +// +// Four of that list moved into the set in #1462: `string_literal`, +// `null_literal`, `list_literal` and `map_literal`. An earlier revision +// of this comment excluded them as "constant or degenerate … and none +// has a sibling-language precedent", and both halves of that stopped +// being true. The precedent now exists in every truthy-valued sibling — +// JavaScript's `string` / `null` / `object` / `array`, Python's +// `string` / `none` / `list` / `dictionary`, PHP's `string` / +// `array_creation_expression` / `null`, Lua's `string` / +// `table_constructor` — and constant-ness never was the test, since +// `BooleanLiteral` has been here since #403 and `NumberLiteral` since +// #1410. All four measured a condition short of a `b` control in both +// the `&&` chain and the `if` predicate. Landing only `string_literal`, +// which is the one #1466 flagged, would have left `if ([])` scoring +// zero beside `if ("s")` scoring one — the within-language asymmetry +// this issue exists to close, one kind narrower. +// +// One `string_literal` kind covers every spelling: `'s'`, `"s"`, the +// triple-quoted `"""s"""` and the slashy `/re/` all lex to it. The +// grammar's `SlashyString` variant is the hidden `_slashy_string` +// supertype the parser never emits (grammar-dispatch §2), which is why +// `Checker::is_string` names only `string_literal` and this set follows +// it (§7); `groovy_hidden_slashy_string_is_unreachable` in +// `metrics/abc.rs` pins that. // // Groovy truth makes every non-zero number truthy, so `NumberLiteral` // is a unary condition here for the same reason Python's `Integer` / @@ -252,6 +276,10 @@ macro_rules! groovy_bool_terminal_kinds { | $crate::Groovy::Identifier | $crate::Groovy::BooleanLiteral | $crate::Groovy::NumberLiteral + | $crate::Groovy::StringLiteral + | $crate::Groovy::NullLiteral + | $crate::Groovy::ListLiteral + | $crate::Groovy::MapLiteral | $crate::Groovy::FieldAccess | $crate::Groovy::CastExpression | $crate::Groovy::ParenthesizedTypeCast @@ -336,6 +364,14 @@ macro_rules! go_bool_terminal_kinds { // `escape_sequence` child, so the wrapper is the only node reachable // here and there is nothing to double-count. // +// `string_literal` / `concatenated_string` / `nullptr` are **not** +// here, and that is a deferral rather than a decision: `if ("s")` is +// legal C and always true, so by this set's own integer-truthiness +// argument they belong. #1462 added the equivalent kinds to the eight +// truthy-valued sets and left the C family out because it is the one +// group in that class with integration-corpus exposure (the DeepSpeech +// `native_client` tree), so the snapshot delta wants its own change. +// // Two neighbouring kinds are deliberately absent. C++'s // `user_defined_literal` (`1.0_km`) wraps a `number_literal` but // evaluates to whatever `operator""` returns, which need not be @@ -412,6 +448,45 @@ macro_rules! cpp_bool_terminal_kinds { // groups with `Int` / `Bool` / `String2` rather than with the `Integer` // / `Float` value operands (`getter/php.rs`). Listing it would be the // `Number2` mistake `typescript_bool_terminal_kinds!` records below. +// +// #1462 added the non-numeric literals and the cast, each measured a +// condition short of a `$b` control in both the `&&` chain and the `if` +// predicate: +// +// - `String` (368) is the single-quoted literal and `EncapsedString` +// (367) the interpolating double-quoted one — separate rules, not +// aliases. `Heredoc` (371) and `Nowdoc` (373) are the two block +// spellings. All four are what `Checker::is_string` already lists +// (grammar-dispatch §7). +// - `String3` (378) is the hidden `_string` supertype the parser never +// emits (grammar-dispatch §2) — listed defensively so a grammar that +// starts emitting it counts, and pinned as hidden by +// `php_hidden_string_supertype_is_unreachable` in `metrics/abc.rs`. +// `is_string` carries the same defensive arm. +// - The `Float2` rule keeps two neighbours out. `String2` (25) is the +// `string` *type* keyword of `function f(): string`, and `Null2` (55) +// the `null` type keyword PHP 8 allows in the same position; neither +// is a value. `is_string` does list `String2`, which is a separate +// question about `find string` rather than a precedent for this set. +// - `ArrayCreationExpression` (355) covers both `[]` and `array()`. +// - `Null` (377) is a falsy constant and counts for the reason `False` +// does — see `perl_bool_terminal_kinds!`. +// - `CastExpression` / `CastExpression2` (322, 323) close the second +// finding of #1462: PHP was the only set in the Java / C# / Groovy / +// PHP group naming no cast kind, so `if ((bool)$x)` scored zero where +// the other three scored one through `CastExpression` / +// `ParenthesizedTypeCast`. Two ids, both listed per lesson 2, though +// only 322 is reachable at this pin — every cast spelling the +// language has (`(bool)`, `(int)`, `(double)`, `(string)`, +// `(binary)`, `(array)`, `(object)`, `(unset)`) parses to it, so 323 +// is a defensive arm in the `Perl::Octal` sense. +// - `ShellCommandExpression` (`` `ls` ``) evaluates to the command's +// output, so it fills a boolean slot exactly as +// `FunctionCallExpression` does, and measured short beside it. +// +// None of these double counts (§5): the PHP ABC impl's condition arm +// lists comparison and logical *tokens* only, and no arm matches a +// literal, a cast, or a backtick. #[macro_export] #[doc(hidden)] macro_rules! php_bool_terminal_kinds { @@ -432,6 +507,16 @@ macro_rules! php_bool_terminal_kinds { | $crate::Php::Boolean | $crate::Php::Integer | $crate::Php::Float + | $crate::Php::String + | $crate::Php::String3 + | $crate::Php::EncapsedString + | $crate::Php::Heredoc + | $crate::Php::Nowdoc + | $crate::Php::ArrayCreationExpression + | $crate::Php::Null + | $crate::Php::CastExpression + | $crate::Php::CastExpression2 + | $crate::Php::ShellCommandExpression | $crate::Php::FunctionCallExpression | $crate::Php::MemberCallExpression | $crate::Php::ScopedCallExpression @@ -463,12 +548,52 @@ macro_rules! python_bool_terminal_kinds { // Mirrors the Lua `Number` fix (#772). Statically-typed languages // omit numerics (a bare int in a bool slot is a type error); a // dynamically-typed language must count them. + // + // The remaining seven literal kinds joined in #1462 on the same + // argument, each measured a condition short of an identifier + // control in both the `and`/`or` chain and the `if` predicate: + // + // - `String` covers every quoting, prefix and interpolation + // spelling — `'s'`, `"""s"""`, `f'x{a}'` and `b'x'` all lex to + // it, verified by reading ids off a parsed fixture. Its neighbour + // `ConcatenatedString` (`'a' 'b'`, the implicit-join form) is a + // separate rule, not an alias, and is listed for the reason + // #1379's Perl numerals were: the supertype's arm list is the + // unit to check, and an alias sweep comes back clean on both. + // Pairing them also matches `Checker::is_string`, which has + // listed exactly `String | ConcatenatedString` since #301 + // (grammar-dispatch §7). + // - `None` is a falsy constant, and counts for the same reason + // `False` has since #403 — see `perl_bool_terminal_kinds!` on + // why the slot, not the value, is what the set measures. + // - `List` / `Set` / `Tuple` / `Dictionary` are the four collection + // displays. `if items:` on a *name* already scored through + // `Identifier`; the literal spelling scored zero. + // - `Ellipsis` completes the set. `if ...:` is rare, but leaving + // the one remaining literal kind out would reproduce the same + // within-language asymmetry one kind narrower, which is the + // defect this issue is about rather than a smaller version of it. + // + // A collection literal holding an expression (`a and [x > 1]`) + // does not double count (§5): the walker never descends into the + // operand, and the inner comparison reaches `conditions` through + // the top-level `ComparisonOperator` arm — the same split that + // already governs `a and f(x > 1)`, where `Call` and the + // comparison each score once. () => { $crate::Python::Identifier | $crate::Python::True | $crate::Python::False + | $crate::Python::None | $crate::Python::Integer | $crate::Python::Float + | $crate::Python::String + | $crate::Python::ConcatenatedString + | $crate::Python::List + | $crate::Python::Set + | $crate::Python::Tuple + | $crate::Python::Dictionary + | $crate::Python::Ellipsis | $crate::Python::Call | $crate::Python::Attribute | $crate::Python::Subscript @@ -506,7 +631,28 @@ macro_rules! python_bool_terminal_kinds { // // The sets for C#, Java, Kotlin, Rust and Go deliberately name no // numeric kind: a bare number in a boolean slot is a compile error in -// those five, so there is nothing to count. +// those five, so there is nothing to count. **That reasoning extends to +// every other literal kind**, which is why #1462 left all five alone +// while adding strings, `null`, and collection literals to the eight +// truthy-valued sets: `if ("s")` and `if (null)` are compile errors in +// the same five for the same reason `if (1)` is. +// +// #1462 is also where the *value* of the literal stopped being the +// question. Every set here has listed `False` since #403 and several +// list `Nil` / `Null`, so the rule these sets encode is already "a +// literal **fills** the operand slot", not "a literal is truthy" — a +// falsy constant is a Fitzpatrick unary condition exactly as `false` +// is. The issue title says truthy because that is the idiom that +// exposed the gap (`x || "default"`), not because a `null` operand +// scores differently. +// +// The one exclusion that survives in a truthy-valued language is a +// kind that is not a **value**: a type keyword rendering to the same +// node-kind name as its literal. PHP's `Float2` records the original, +// and #1462 added four more — TypeScript's `String2` / `Object2`, +// Tsx's `String3` / `Object2`, and PHP's `String2` / `Null2`, each the +// annotation spelling (`a: string`, `function f(): null`) rather than +// a value. Each set names the ids so the next reader can check them. // // That rationale does **not** extend to the C family, which an earlier // revision of this comment wrongly grouped with them: C and C++ are @@ -580,6 +726,14 @@ macro_rules! perl_bool_terminal_kinds { // the language has no counterpart of the #1379 Ruby / Elixir / Perl gap. // The same holds for Tcl, iRules and the four JS-family sets, each // measured rather than read off the grammar. +// +// `String` and `TableConstructor` joined in #1462. Lua's truth rule is +// the strongest case in the workspace for counting them: everything but +// `false` and `nil` is truthy, which is why `cond and "a" or "b"` *is* +// the language's ternary — and it scored 1 where `cond and a or b` +// scored 2. One `string` kind covers all three spellings (`"s"`, `'s'` +// and the long-bracket `[[s]]`), verified by reading ids off a parsed +// fixture; `Checker::is_string` likewise lists only `String`. #[macro_export] #[doc(hidden)] macro_rules! lua_bool_terminal_kinds { @@ -589,6 +743,8 @@ macro_rules! lua_bool_terminal_kinds { | $crate::Lua::False | $crate::Lua::Nil | $crate::Lua::Number + | $crate::Lua::String + | $crate::Lua::TableConstructor | $crate::Lua::FunctionCall | $crate::Lua::DotIndexExpression | $crate::Lua::DotIndexExpression2 @@ -648,12 +804,42 @@ macro_rules! javascript_bool_terminal_kinds { // numeric-truthy operand: JS treats every non-zero number as // truthy, so `while (5)` / `x && 5` count their numeric literal // as a Fitzpatrick unary condition (#772, mirrors the Lua fix). + // + // The seven non-numeric literal kinds join it in #1462 — every one + // measured a condition short of the identifier control in both + // slots. `x || "default"` is the language's commonest truthy-default + // idiom and scored 1 against `x || y`'s 2. + // + // **Both `string` ids are listed, and that is per language.** The + // grammar declares `string` under two kind_ids here (196, 221) and + // the one an operand slot carries is `String2` (221) — but + // `Checker::is_string` already lists both for JavaScript, Mozjs and + // Tsx and only `String` for TypeScript, having made exactly this + // per-language alias decision (grammar-dispatch §7). Mirroring it + // keeps `find string` and ABC answering the same question about the + // same node; diverging would be the drift §7 exists to prevent. + // + // `String` (196) is a defensive arm, like Perl's `Octal`: at this + // grammar pin nothing emits it — an import specifier, an `export + // from` clause, a quoted object key and a JSX attribute value all + // parse to 221 — so removing it fails no test. It stays because the + // alias exists in the enum, a pin bump renumbers ids freely (#732), + // and the cost of a grammar that starts emitting it is a silent + // zero rather than a build error. () => { $crate::Javascript::Identifier | $crate::Javascript::Identifier2 | $crate::Javascript::True | $crate::Javascript::False | $crate::Javascript::Number + | $crate::Javascript::String + | $crate::Javascript::String2 + | $crate::Javascript::TemplateString + | $crate::Javascript::Regex + | $crate::Javascript::Null + | $crate::Javascript::Undefined + | $crate::Javascript::Object + | $crate::Javascript::Array | $crate::Javascript::CallExpression | $crate::Javascript::CallExpression2 | $crate::Javascript::NewExpression @@ -670,14 +856,26 @@ macro_rules! javascript_bool_terminal_kinds { macro_rules! mozjs_bool_terminal_kinds { // `AwaitExpression` (`await ready()`) is in the terminal set // mirroring the C# reference (lesson 19). `Number` is a - // numeric-truthy operand (#772, mirrors the Lua fix) — see - // `javascript_bool_terminal_kinds!`. + // numeric-truthy operand (#772, mirrors the Lua fix), and the seven + // non-numeric literal kinds joined in #1462 — see + // `javascript_bool_terminal_kinds!` for both. Mozjs renumbers every + // id (`string` is 222 here, 221 there) but its alias *shape* is + // JavaScript's: two `string` ids, and `Checker::is_string` lists + // both for this language too. () => { $crate::Mozjs::Identifier | $crate::Mozjs::Identifier2 | $crate::Mozjs::True | $crate::Mozjs::False | $crate::Mozjs::Number + | $crate::Mozjs::String + | $crate::Mozjs::String2 + | $crate::Mozjs::TemplateString + | $crate::Mozjs::Regex + | $crate::Mozjs::Null + | $crate::Mozjs::Undefined + | $crate::Mozjs::Object + | $crate::Mozjs::Array | $crate::Mozjs::CallExpression | $crate::Mozjs::CallExpression2 | $crate::Mozjs::NewExpression @@ -698,11 +896,31 @@ macro_rules! typescript_bool_terminal_kinds { // grammar's other `number` alias, `Number2` (id 133), is the // `predefined_type` keyword `number` in a type annotation — NOT a // value — so it is deliberately omitted from the terminal-bool set. + // + // The seven non-numeric literal kinds joined in #1462, and the + // `Number2` rule decides two of them here. TypeScript spells + // `string` under two ids and `object` under two: the *literals* are + // `String` (247) and `Object` (213), while `String2` (135) and + // `Object2` (137) are the `predefined_type` keywords of `a: string` + // / `a: object`. Only the literals are listed — which is also the + // split `Checker::is_string` already made, listing `String` alone + // for TypeScript where it lists both ids for JavaScript, Mozjs and + // Tsx (grammar-dispatch §7). Verified by parsing a fixture carrying + // both spellings and reading the ids, not by reading the grammar: + // all four render to the same node-kind string, so an alias sweep + // cannot tell them apart. () => { $crate::Typescript::Identifier | $crate::Typescript::True | $crate::Typescript::False | $crate::Typescript::Number + | $crate::Typescript::String + | $crate::Typescript::TemplateString + | $crate::Typescript::Regex + | $crate::Typescript::Null + | $crate::Typescript::Undefined + | $crate::Typescript::Object + | $crate::Typescript::Array | $crate::Typescript::CallExpression | $crate::Typescript::CallExpression2 | $crate::Typescript::CallExpression3 @@ -727,12 +945,33 @@ macro_rules! tsx_bool_terminal_kinds { // grammar's other `number` alias, `Number2` (id 139), is the // `predefined_type` keyword `number` in a type annotation — NOT a // value — so it is deliberately omitted from the terminal-bool set. + // + // The seven non-numeric literal kinds joined in #1462. Tsx is the + // reason this file has four JS macros rather than one: it spells + // `string` under **three** ids where TypeScript has two and + // JavaScript has two different ones. `String` (233) and `String2` + // (261) are both value literals — an operand slot carries 261, a + // JSX attribute value 233 — and `String3` (141) is the + // `predefined_type` keyword, the `Number2` case one kind over. + // `Object` (219) is the literal, `Object2` (143) the type keyword. + // Same split as `Checker::is_string`, which lists 233 and 261 and + // not 141 (grammar-dispatch §7). Measured, 233 turns out to be the + // same defensive-arm case as JavaScript's `String` (196): no + // position reaches it at this pin, JSX attribute values included. () => { $crate::Tsx::Identifier | $crate::Tsx::Identifier2 | $crate::Tsx::True | $crate::Tsx::False | $crate::Tsx::Number + | $crate::Tsx::String + | $crate::Tsx::String2 + | $crate::Tsx::TemplateString + | $crate::Tsx::Regex + | $crate::Tsx::Null + | $crate::Tsx::Undefined + | $crate::Tsx::Object + | $crate::Tsx::Array | $crate::Tsx::CallExpression | $crate::Tsx::CallExpression2 | $crate::Tsx::CallExpression3 diff --git a/src/metrics/abc.rs b/src/metrics/abc.rs index 1b9f6e8c..960c60d2 100644 --- a/src/metrics/abc.rs +++ b/src/metrics/abc.rs @@ -14881,3 +14881,393 @@ mod perl_statement_modifier_parity { ); } } + +/// A non-numeric literal in a boolean operand slot must score like an +/// identifier in the same slot (#1462). +/// +/// The sibling module above pins the *numeric* half, closed by #1410. +/// The non-numeric literals were never swept and were missing from +/// every set the numerics were added to — same mechanism, same silent +/// zero, same file. Measured before the fix, every row below scored +/// exactly one condition short of its identifier control, in **both** +/// slots: `x || "default"` — the language's commonest truthy-default +/// idiom — reported 1 against `x || y`'s 2. +/// +/// Scope is the eight truthy-valued sets. C#, Java, Kotlin, Rust and Go +/// name no literal kind at all and stay that way: a bare literal in a +/// boolean slot is a compile error there, so there is nothing to count. +/// The C family is integer-truthy and does carry the same gap for +/// `string_literal`, but it is the one group in that class with +/// integration-corpus exposure, so it is deferred rather than decided +/// (see `cpp_bool_terminal_kinds!`). +/// +/// Three things each row pins that a conditions comparison alone +/// cannot: +/// +/// - **The `kind_id` each spelling parses to.** This is the whole bug +/// class (`.claude/rules/grammar-dispatch.md` §1): the grammars here +/// emit `string` under two ids in JavaScript, Mozjs and Tsx and under +/// three in Tsx counting the type keyword, and listing the wrong one +/// compiles, runs, and returns the unfixed number. Asserting the id +/// turns that measurement into a standing claim rather than a note in +/// an issue. It is also the fixture anchor `.claude/rules/testing.md` +/// asks for — editing `"s"` to `1` in a row now fails by name instead +/// of quietly turning that row into a copy of the numeric module. +/// - **`cyclomatic` does not move.** Only the operand spelling changes +/// between a row and its control, so a `conditions` difference is +/// unambiguously ABC's. +/// - **Both walker paths.** The sets feed two structurally independent +/// consumers (§11) — the operands of a `&&` / `and` chain, and the +/// predicate of an `if` — and every row measured short in both. A +/// fixture of only one leaves the other path invisible. +/// +/// The JS-family rows go through `js_literals!` rather than four hand- +/// written arrays, because the four languages differ in exactly one +/// place — which `string` alias an operand slot carries — and making +/// that the macro's only argument states the asymmetry instead of +/// leaving a reader to diff four near-identical lists for it. +#[cfg(test)] +#[cfg(any( + feature = "javascript", + feature = "mozjs", + feature = "typescript", + feature = "python", + feature = "lua", + feature = "php", + feature = "groovy" +))] +mod literal_bool_operands { + use crate::test_support::{assert_fixture_spells, metrics_verbatim}; + use crate::{LANG, MetricsOptions}; + + /// One fixture shape: a source template with a `{}` operand slot, + /// and the `abc.conditions_sum` / `cyclomatic_sum` every spelling of + /// that operand must produce. + type Slot = (&'static str, u64, u64); + + /// One literal operand: its spelling, and the `kind_id` the grammar + /// must emit for it. + type Literal = (&'static str, u16); + + /// A language's two slots, its identifier baseline operand, the + /// literal operands that must score the same, and how many of those + /// there should be. + /// + /// The count is not bookkeeping. `for_each_case` counts *languages*, + /// so trimming a row's literal list — the pre-#1462 state is the + /// empty list — would leave the whole module green. + type Case = ([Slot; 2], &'static str, &'static [Literal], usize); + + /// The seven non-numeric literal kinds of a JS-family grammar, with + /// the `string` alias an operand slot carries passed in: `String2` + /// for JavaScript, Mozjs and Tsx, `String` for TypeScript. That one + /// argument is the entire difference between the four rows, and it + /// is the same split `Checker::is_string` makes (§7). + macro_rules! js_literals { + ($Lang:ident, $string:ident) => { + &[ + ("\"s\"", crate::$Lang::$string as u16), + ("`t`", crate::$Lang::TemplateString as u16), + ("/re/", crate::$Lang::Regex as u16), + ("null", crate::$Lang::Null as u16), + ("undefined", crate::$Lang::Undefined as u16), + ("{}", crate::$Lang::Object as u16), + ("[]", crate::$Lang::Array as u16), + ] + }; + } + + const JS_SLOTS: [Slot; 2] = [ + ("function f(a) {\n return a && {};\n}\n", 2, 3), + ("function f() {\n if ({}) { return 1; }\n}\n", 1, 3), + ]; + + fn conditions(lang: LANG, source: &str) -> u64 { + metrics_verbatim(lang, source.as_bytes(), MetricsOptions::default()) + .abc + .conditions_sum() + } + + fn cyclomatic_sum(lang: LANG, source: &str) -> u64 { + metrics_verbatim(lang, source.as_bytes(), MetricsOptions::default()) + .cyclomatic + .cyclomatic_sum() + } + + /// Asserts the fixture still spells the construct under test, by + /// parsing it with the language's own parser. Split out of `cases` + /// because only the parser types are feature-gated; the `kind_id` + /// enums are compiled unconditionally. + fn assert_spells(lang: LANG, source: &str, kinds: &[(u16, usize, &str)]) { + match lang { + #[cfg(feature = "javascript")] + LANG::Javascript => { + assert_fixture_spells::(source, "f.js", kinds); + } + #[cfg(feature = "mozjs")] + LANG::Mozjs => assert_fixture_spells::(source, "f.jsm", kinds), + #[cfg(feature = "typescript")] + LANG::Typescript => { + assert_fixture_spells::(source, "f.ts", kinds); + } + #[cfg(feature = "typescript")] + LANG::Tsx => assert_fixture_spells::(source, "f.tsx", kinds), + #[cfg(feature = "python")] + LANG::Python => assert_fixture_spells::(source, "f.py", kinds), + #[cfg(feature = "lua")] + LANG::Lua => assert_fixture_spells::(source, "f.lua", kinds), + #[cfg(feature = "php")] + LANG::Php => assert_fixture_spells::(source, "f.php", kinds), + #[cfg(feature = "groovy")] + LANG::Groovy => { + assert_fixture_spells::(source, "f.groovy", kinds); + } + other => panic!("{other:?} has a case row but no parser arm"), + } + } + + /// `([chain_slot, condition_slot], identifier, literals)` per + /// language, one spelling per literal *kind* the grammar emits. + /// + /// - JS family: `string`, `template_string`, `regex`, `null`, + /// `undefined`, `object`, `array`. TypeScript's `string` / `object` + /// type keywords (`a: string`) are different ids rendering to the + /// same names and are deliberately not in the sets, so a row that + /// accidentally named one would fail the id anchor here. + /// - Python: `string` covers `'s'`, `"""s"""`, `f'x'` and `b'x'`, + /// while the implicit-join `'a' 'b'` is the separate + /// `concatenated_string` rule — the sibling-rule-under-a-supertype + /// shape #1379's Perl numerals were, which an alias sweep cannot + /// see. Then `none`, the four collection displays, and `ellipsis`. + /// - Lua: one `string` kind for `"s"`, `'s'` and `[[s]]`, plus + /// `table_constructor`. Lua's is the strongest case of the eight: + /// everything but `false` and `nil` is truthy, which is why + /// `cond and "a" or "b"` is the language's ternary. + /// - PHP: `string` (single-quoted) and `encapsed_string` + /// (interpolating) are separate rules, as are `heredoc` and + /// `nowdoc`; then `array_creation_expression`, `null`, the + /// backtick `shell_command_expression`, and `cast_expression` — + /// the second finding of #1462, PHP being the only set in the + /// Java / C# / Groovy / PHP group that named no cast kind. + /// - Groovy: `string_literal` (which also covers the slashy `/re/`), + /// `null_literal`, `list_literal`, `map_literal`. + /// + /// Two shapes measured short here and are deliberately absent, + /// because neither is a literal: JavaScript's `this` and Groovy's + /// `object_creation_expression`. Both are recorded in #1462. + fn cases(lang: LANG) -> Option { + Some(match lang { + LANG::Javascript => (JS_SLOTS, "b", js_literals!(Javascript, String2), 7), + LANG::Mozjs => (JS_SLOTS, "b", js_literals!(Mozjs, String2), 7), + LANG::Typescript => (JS_SLOTS, "b", js_literals!(Typescript, String), 7), + LANG::Tsx => (JS_SLOTS, "b", js_literals!(Tsx, String2), 7), + LANG::Python => ( + [ + ("def f(a):\n return a and {}\n", 2, 3), + ("def f():\n if {}:\n return 1\n", 1, 3), + ], + "b", + &[ + ("'s'", crate::Python::String as u16), + ("'a' 'b'", crate::Python::ConcatenatedString as u16), + ("None", crate::Python::None as u16), + ("[]", crate::Python::List as u16), + ("{1}", crate::Python::Set as u16), + ("()", crate::Python::Tuple as u16), + ("{}", crate::Python::Dictionary as u16), + ("...", crate::Python::Ellipsis as u16), + ], + 8, + ), + LANG::Lua => ( + [ + ("function f(a)\n return a and {}\nend\n", 2, 3), + ("function f()\n if {} then return 1 end\nend\n", 1, 3), + ], + "b", + &[ + ("\"s\"", crate::Lua::String as u16), + ("{}", crate::Lua::TableConstructor as u16), + ], + 2, + ), + LANG::Php => ( + [ + (" ( + [ + ("def f(a) {\n return a && {}\n}\n", 2, 3), + ("def f() {\n if ({}) { return 1 }\n}\n", 1, 3), + ], + "b", + &[ + ("\"s\"", crate::Groovy::StringLiteral as u16), + ("null", crate::Groovy::NullLiteral as u16), + ("[]", crate::Groovy::ListLiteral as u16), + ("[:]", crate::Groovy::MapLiteral as u16), + ], + 4, + ), + _ => return None, + }) + } + + /// Runs `check` once per enabled language that has a case, having + /// first established that the case can still assert something. The + /// three guards are the sibling module's, for the same three ways + /// this table could decay into asserting nothing: no language + /// enabled, an emptied literal list, and a template that lost its + /// `{}` slot (which makes every comparison `x == x`). + fn for_each_case(check: impl Fn(LANG, Case)) { + let mut checked = 0; + for lang in LANG::into_enum_iter() { + if !lang.is_enabled() { + continue; + } + let Some(case @ (slots, _, literals, expected_kinds)) = cases(lang) else { + continue; + }; + assert_eq!( + literals.len(), + expected_kinds, + "{lang:?}: the literal-operand list no longer covers one spelling \ + per grammar literal kind" + ); + for (template, _, _) in slots { + assert!( + template.contains("{}"), + "{lang:?}: template lost its `{{}}` operand slot: {template}" + ); + } + check(lang, case); + checked += 1; + } + assert!( + checked > 0, + "no truthy-valued language enabled; this test asserted nothing" + ); + } + + #[test] + fn a_literal_operand_scores_like_an_identifier_operand() { + for_each_case(|lang, (slots, identifier, literals, _)| { + for (template, _, _) in slots { + let baseline = conditions(lang, &template.replace("{}", identifier)); + for (literal, _) in literals { + let source = template.replace("{}", literal); + let scored = conditions(lang, &source); + assert_eq!( + scored, baseline, + "{lang:?}: `{literal}` scored {scored} unary conditions against \ + `{identifier}`'s {baseline}\n source: {source}" + ); + } + } + }); + } + + /// The absolute anchor under the comparison above: every operand + /// spelling must produce the slot's recorded `conditions`, and must + /// leave `cyclomatic` alone. + #[test] + fn every_literal_operand_scores_its_recorded_values() { + for_each_case(|lang, (slots, identifier, literals, _)| { + for (template, expected_conditions, expected_cyclomatic) in slots { + let operands = std::iter::once(identifier) + .chain(literals.iter().map(|(spelling, _)| *spelling)); + for operand in operands { + let source = template.replace("{}", operand); + assert_eq!( + conditions(lang, &source), + expected_conditions, + "{lang:?}: `{operand}` conditions\n source: {source}" + ); + assert_eq!( + cyclomatic_sum(lang, &source), + expected_cyclomatic, + "{lang:?}: `{operand}` cyclomatic_sum\n source: {source}" + ); + } + } + }); + } + + /// Every spelling must parse to the `kind_id` its row names — the + /// §1 alias claim, which is the one thing a conditions comparison + /// cannot check: a row naming the wrong alias of a multi-id kind + /// would simply keep reporting the unfixed number. + #[test] + fn every_literal_spelling_parses_to_the_kind_its_row_names() { + for_each_case(|lang, (slots, _, literals, _)| { + for (template, _, _) in slots { + for (literal, kind) in literals { + let source = template.replace("{}", literal); + assert_spells(lang, &source, &[(*kind, 1, literal)]); + } + } + }); + } +} + +/// PHP's `String3` is the hidden `_string` supertype and Groovy's +/// `SlashyString` the hidden `_slashy_string` one — both listed in, or +/// deliberately omitted from, their terminal-bool sets on the strength +/// of being unreachable (`.claude/rules/grammar-dispatch.md` §2). A +/// grammar bump that promotes either changes ABC's answer silently, so +/// the unreachability is pinned rather than assumed. +#[cfg(test)] +mod hidden_literal_supertypes { + use crate::test_support::ast_has_kind_id; + use crate::*; + + #[cfg(feature = "php")] + #[test] + fn php_hidden_string_supertype_is_unreachable() { + let src = " Date: Mon, 14 Sep 2026 19:11:48 -0700 Subject: [PATCH 10/25] fix(abc): score relational operators by use, not by slot A construct a grammar spells as its own production -- C#'s two `is` tests, Java's and Groovy's `instanceof`, Groovy's `in`, Kotlin's `is` / `in`, Ruby's one-line `in` -- had no operator token to count, so it reached `stats.conditions` only through `_bool_terminal_kinds!()`. Every walker consults that set inside an `if` / `while` / ternary / `&&`-operand slot and nowhere else, while the comparison token beside it carried no such gate: `var b = x == 1;` scored 1 and `var b = x is int;` scored 0. Fitzpatrick Rule 5 scores a relational operator by use. The seven constructs move to their language's unconditional condition arm and leave the operand sets, which hold values rather than operators; listing them in both would score them twice. Groovy's spaceship `<=>` joins as a condition token, the spelling Ruby, PHP, C++ and Mozcpp already use for it. Two operands join the sets on the converse rule, counting in a boolean slot only: Rust's `macro_invocation` (`if matches!(x, Some(_))`, and `cfg!` alike -- the breadth is the decision, since these sets have never discriminated on return type) and Python's `named_expression`. The walrus is the one construct in the survey scoring on two ABC axes, which is correct: it binds a name and decides a branch, and the axes are independent measurements rather than a partition. C#'s `x is > 5` now reads level with the `x > 5` it is sugar for, closing the asymmetry #1383 recorded as a deliberate exception it had no way to remove. The pattern's own `>` still scores nothing, so the test is worth one condition rather than two. Measured before and after on every construct, inside and outside a boolean slot: no score inside a slot moves, so nothing is counted twice, and a `for (x in xs)` header stays at zero in all three languages whose membership keyword it shares. One of the 1,610 integration snapshots moves -- serde's `serde_derive/src/dummy.rs`, on `if cfg!(no_underscore_consts)`. The five structurally changed languages have no corpus exposure. Fixes #1461 --- CHANGELOG.md | 36 ++ big-code-analysis-ast/src/macros/kind_sets.rs | 187 ++++--- big-code-analysis-book/src/metrics.md | 3 +- src/metrics/abc.rs | 475 ++++++++++++++++-- src/metrics/abc/csharp.rs | 44 +- src/metrics/abc/groovy.rs | 45 +- src/metrics/abc/java.rs | 52 +- src/metrics/abc/kotlin.rs | 44 +- src/metrics/abc/ruby.rs | 21 +- tests/repositories/big-code-analysis-output | 2 +- 10 files changed, 762 insertions(+), 147 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 13f15d18..83ff2071 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -147,6 +147,42 @@ for historical reference. ### Fixed +- **A relational operator scored an ABC condition only inside a boolean + slot** (#1461). `var b = x == 1;` reported `abc.conditions` 1 while + `var b = x is int;` reported 0, and the same asymmetry held for + Java's and Groovy's `instanceof`, Groovy's `in`, Kotlin's `is` / `in` + and Ruby's one-line `in`. Each of those is a construct the grammar + spells as its own production rather than as a binary expression with + an operator token, so it reached the metric only through that + language's terminal-operand set — which every walker consults inside + an `if` / `while` / ternary / `&&`-operand slot and nowhere else. The + comparison token beside it carried no such gate. Fitzpatrick Rule 5 + scores a relational operator by *use*, so the seven constructs now + have an unconditional arm in their language's ABC walk and have left + the operand sets, which hold values rather than operators. Groovy's + spaceship `<=>` joins them as a condition token, the spelling Ruby, + PHP, C++ and Mozcpp already used for it. Two further gaps close as + consequences: C#'s `x is > 5` now reads level with the `x > 5` it is + sugar for, an asymmetry #1383 recorded as a deliberate exception it + had no way to remove, and Rust's `if matches!(x, Some(_))` and + Python's `if (n := g()):` — an operand and a macro rather than + operators, so both stay slot-scoped — now score the 1 their + identifier controls always scored. The Python walrus is the one + construct in the survey that pays on two ABC axes, which is correct: + it binds a name *and* decides a branch, and the axes are independent + measurements rather than a partition. **Metric drift:** + `abc.conditions`, `abc.magnitude` and `abc.value` rise by one per + relational operator written outside a boolean slot in C#, Java, + Groovy, Kotlin and Ruby, and by one per macro (Rust) or walrus + (Python) predicate inside one; `abc` is a gated threshold metric. + Cyclomatic is unaffected, and no score inside a boolean slot moves, + so a construct already counted is not counted twice. One of the 1,610 + integration snapshots moves — serde's `serde_derive/src/dummy.rs`, + on `if cfg!(no_underscore_consts)`. The five structurally changed + languages have no corpus exposure at all: no corpus carries a + `.groovy`, `.kt`, `.rb` or `.java` file, and the six-file C# corpus + spells no `is` test. + - **A non-numeric literal in a boolean operand slot scored no ABC condition** (#1462). `x || "default"` reported `abc.conditions` 1 against `x || y`'s 2, and `if ("s")` reported 0 against `if (b)`'s 1, diff --git a/big-code-analysis-ast/src/macros/kind_sets.rs b/big-code-analysis-ast/src/macros/kind_sets.rs index ba053729..42862d54 100644 --- a/big-code-analysis-ast/src/macros/kind_sets.rs +++ b/big-code-analysis-ast/src/macros/kind_sets.rs @@ -53,33 +53,24 @@ macro_rules! csharp_prefix_unary_expr_kinds { // the C# grammar. Anything in this set, when it appears in a known- // boolean context (if / while / do / for / ternary / binary), counts // as one condition. The set bundles `csharp_invocation_expr_kinds!()` -// with the bare `Identifier` / `BooleanLiteral` leaves *and* the six +// with the bare `Identifier` / `BooleanLiteral` leaves *and* the four // expression kinds whose evaluated value is implicitly boolean in any -// idiomatic codebase: +// idiomatic codebase: `MemberAccessExpression` (`cfg.Enabled`), +// `AwaitExpression` (`await CheckAsync()`), `CastExpression` +// (`(bool)v`) and `ElementAccessExpression` (`flags[0]`). Before #372 +// only invocation / identifier / boolean were recognised, so all four +// silently scored zero conditions in `if` / `while` / `do` / ternary +// contexts. // -// - `MemberAccessExpression` — `cfg.Enabled`, `Request.IsHttps` -// - `AwaitExpression` — `await CheckAsync()` -// - `CastExpression` — `(bool)v`, `(IDisposable)x is not null` -// - `IsPatternExpression` — `x is null`, `x is not Foo f` -// - `IsExpression` — `x is int`, the bare type test -// - `ElementAccessExpression` — `flags[0]`, `dict["key"]` -// -// Before #372 only the first three (invocation / identifier / -// boolean) were recognised, so all five kinds above silently scored -// zero conditions in `if` / `while` / `do` / ternary contexts. -// -// `IsExpression` (391) and `IsPatternExpression` (392) are distinct -// kinds, not aliases: the grammar emits the first for a bare type test -// (`x is int`) and the second only once a pattern is involved -// (`x is int y`, `x is null`, `x is not Foo`). Listing only the second -// scored `if (x is int)` zero conditions against a cyclomatic decision -// of one, while `if (x is int y)` scored one — an asymmetry between two -// spellings of the same test, and the C# half of the gap #1421 closed -// for Kotlin by adding `IsExpression | InExpression` there. It also -// left #1422's guard slot spelling-dependent in the one case that fix -// claims to have fixed: `when x is int` scored 1 where -// `when IsEven(x)` scored 2. No arm counts the `is` token itself, so -// there is nothing to double-count (§5). +// `IsExpression` (391) and `IsPatternExpression` (392) are the two +// type tests, distinct kinds rather than aliases: the grammar emits +// the first for a bare test (`x is int`) and the second once a pattern +// is involved (`x is int y`, `x is null`, `x is not Foo`). Both were +// listed here until #1461 moved them to an unconditional arm in +// `src/metrics/abc/csharp.rs` — see the operands-not-operators note on +// the Phase-2 block below. Slot-scoped, `var b = x is int;` scored +// zero beside `x == 1`'s one. Nothing counts the `is` token itself and +// the pair is disjoint, so exactly one arm fires per test (§5). #[macro_export] #[doc(hidden)] macro_rules! csharp_bool_terminal_kinds { @@ -92,8 +83,6 @@ macro_rules! csharp_bool_terminal_kinds { | $crate::Csharp::MemberAccessExpression | $crate::Csharp::AwaitExpression | $crate::Csharp::CastExpression - | $crate::Csharp::IsPatternExpression - | $crate::Csharp::IsExpression | $crate::Csharp::ElementAccessExpression }; } @@ -117,13 +106,20 @@ macro_rules! csharp_var_declarator_kinds { // Terminal-bool operand kinds recognised by ABC condition counting for // the Java grammar. Sister of `csharp_bool_terminal_kinds!()` — bundles // the four "bare boolean leaf" kinds (`MethodInvocation`, `Identifier`, -// `True`, `False`) with the four bool-evaluating expression kinds -// surfaced by #372 / lesson #19: +// `True`, `False`) with the bool-evaluating expression kinds surfaced +// by #372 / lesson #19: // // - `FieldAccess` — `cfg.flag` // - `CastExpression` — `(boolean) v` // - `ArrayAccess` — `flags[0]` -// - `InstanceofExpression` — `x instanceof Foo` +// +// `InstanceofExpression` (`x instanceof Foo`) was the fourth until +// #1461 moved it to an unconditional arm in `src/metrics/abc/java.rs`; +// slot-scoped it scored `boolean b = x instanceof String;` zero beside +// `x == 1`'s one. See the operands-not-operators note on the Phase-2 +// block below. One node covers both spellings (`x instanceof Foo` and +// the pattern form `x instanceof Foo f`), and no arm counts the +// `instanceof` token, so exactly one arm fires per test (§5). // // Used by `java_inspect_container`, `java_count_unary_conditions`, // `java_walk_ternary`, and the two branches of `java_walk_for_statement` @@ -140,7 +136,6 @@ macro_rules! java_bool_terminal_kinds { | $crate::Java::FieldAccess | $crate::Java::CastExpression | $crate::Java::ArrayAccess - | $crate::Java::InstanceofExpression }; } @@ -153,9 +148,9 @@ macro_rules! java_bool_terminal_kinds { // grammar represents it as its own kind rather than nesting // `cast_expression` inside `parenthesized_expression`). The set bundles // the bool-evaluating terminals added by #372 (`FieldAccess`, -// `CastExpression`, `ParenthesizedTypeCast`, `InstanceofExpression`); -// the dekobon Groovy grammar has no `await` analogue, so that one -// collapses out of the C# set. +// `CastExpression`, `ParenthesizedTypeCast`); the dekobon Groovy +// grammar has no `await` analogue, so that one collapses out of the C# +// set. // // It DOES have an indexing analogue, and four navigation kinds beside // it — `subscript_expression`, `safe_subscript_expression`, @@ -237,24 +232,23 @@ macro_rules! java_bool_terminal_kinds { // outside every test glob (`tests/corpus/deepspeech_test.rs` globs // `*.cc` / `*.cpp` / `*.h` / `*.hh`), so this fix moves no snapshot. // -// `membership_expression` (`a in l`, `a !in l`) is the Groovy spelling -// of Kotlin's `in_expression`, and joins the set for the same reason -// #1421 added that one: the grammar gives membership its own -// production rather than a `binary_expression`, so no comparison-token -// arm ever sees it and `if (a in l)` scored zero conditions against a -// cyclomatic decision of one. +// `membership_expression` (`a in l`, `a !in l`) and +// `instanceof_expression` (`a instanceof T`, `a !instanceof T`) are +// the two relational forms this grammar spells as their own +// production. Both were listed here until #1461 moved them to an +// unconditional arm in `src/metrics/abc/groovy.rs`, where a relational +// belongs; see the operands-not-operators note on the Phase-2 block +// below. Slot-scoped, `def b = a in l` scored zero beside +// `def b = a == 1`'s one. // -// It is the one Groovy relational form that has to come through the -// terminal set rather than through `groovy_count_token_condition`'s -// token arm, and a `grammar.json` sweep of dekobon-tree-sitter-groovy -// 0.2.2 says why on both halves: the `in` token is shared with -// `for_in_statement`, so an ungated token arm would score every -// `for (x in list)` header, and the `!in` spelling emits **no operator -// token at all** — `bca dump` shows `membership_expression` with two -// `identifier` children and nothing between them — so a token arm -// could not reach the negated form however it were gated. Listing the -// wrapper covers both spellings at once and double-counts neither, -// since neither token is counted anywhere (§5). +// Neither can be reached by the *token* spelling that `==` / `===` / +// `=~` use, and a `grammar.json` sweep of dekobon-tree-sitter-groovy +// 0.2.2 says why for membership on both halves: the `in` token is +// shared with `for_in_statement`, so an ungated token arm would score +// every `for (x in list)` header, and the `!in` spelling emits **no +// operator token at all** (`bca dump`). The node covers both spellings +// of each construct, and neither construct's token is counted +// anywhere, so exactly one arm fires per test (§5). // // The sibling relational productions `identity_expression` (`===`, // `!==`) and `regex_find_expression` / `regex_match_expression` (`=~`, @@ -283,8 +277,6 @@ macro_rules! groovy_bool_terminal_kinds { | $crate::Groovy::FieldAccess | $crate::Groovy::CastExpression | $crate::Groovy::ParenthesizedTypeCast - | $crate::Groovy::InstanceofExpression - | $crate::Groovy::MembershipExpression | $crate::Groovy::SubscriptExpression | $crate::Groovy::SafeSubscriptExpression | $crate::Groovy::SafeNavigationExpression @@ -300,6 +292,26 @@ macro_rules! groovy_bool_terminal_kinds { // per-language walker pair (`_inspect_container` + // `_count_unary_conditions`) consumes the same set in both // helpers, so hoisting to a macro removes the literal duplication. +// +// **These sets hold operands, never operators** (#1461). Membership is +// slot-scoped by construction — a kind here scores only where a walker +// consults the set, which is inside a boolean slot — and that is the +// right scope for a *value*, whose truthiness is interesting only +// because the slot reads it. It is the wrong scope for a **relational +// operator**, which Fitzpatrick Rule 5 scores by use: `a == b` counts +// wherever it is written, so `a is String` must too. The type-test and +// membership productions a grammar spells as their own node sat here +// until #1461 and so scored one inside a predicate and zero outside +// it, while the comparison token beside them scored in both. Each now +// has an unconditional arm in its language's ABC `compute` and is +// listed in neither place twice — which would score it twice +// (`.claude/rules/grammar-dispatch.md` §5). The five affected sets say +// which of their members moved. +// +// The line is the construct's *value*, not its type. A cast, a type +// assertion and an `@available` query all yield something the slot +// then reads as a boolean, so they stay; a type test yields the +// comparison's own result, so it does not. #[macro_export] #[doc(hidden)] @@ -311,10 +323,31 @@ macro_rules! rust_bool_terminal_kinds { // for `CastExpression`, `MemberAccessExpression`, and // `AwaitExpression` on the C# side. // + // `MacroInvocation` joins them in #1461. A macro in a boolean slot + // expands to a boolean expression — `matches!`, `cfg!`, a crate's + // own predicate macro — and `if matches!(x, Some(_))` scored zero + // conditions against a cyclomatic decision of one. + // + // **The arm's breadth is the decision, not a side effect.** It + // fires for every macro reaching a boolean slot, `dbg!` and `todo!` + // included, because these sets discriminate on *slot*, never on + // return type: `CallExpression` beside it counts whatever the call + // returns, and `kotlin_bool_terminal_kinds!` says so in as many + // words for `infix_expression`. A macro that cannot be a predicate + // will not compile in the slot. Measured on the serde corpus it + // moves one snapshot, on `if cfg!(no_underscore_consts)` — a + // `cfg!`, not a `matches!`. + // + // `src/metrics/abc/rust.rs` excludes macros from *Branches*, which + // is a different axis and not a contradiction: B counts dispatch to + // a function body, and a macro expands in place rather than + // dispatching. C counts what the predicate makes a reader decide, + // and a macro predicate makes them decide exactly as a call does. () => { $crate::Rust::Identifier | $crate::Rust::BooleanLiteral | $crate::Rust::CallExpression + | $crate::Rust::MacroInvocation | $crate::Rust::FieldExpression | $crate::Rust::IndexExpression | $crate::Rust::ScopedIdentifier @@ -580,6 +613,19 @@ macro_rules! python_bool_terminal_kinds { // the top-level `ComparisonOperator` arm — the same split that // already governs `a and f(x > 1)`, where `Call` and the // comparison each score once. + // + // `NamedExpression` — the walrus `if (n := g()):` — joins them in + // #1461. It is an operand like any other: the slot tests `g()`'s + // truth, and `if (n := g()):` scored zero conditions where the + // `if g():` it is a refactoring of scored one. + // + // **It also scores on the A axis, and that is not a double count** + // (`src/metrics/abc/python.rs` counts `:=` as an assignment). ABC's + // axes are independent measurements of the same source, not a + // partition of it, and the walrus genuinely both binds a name and + // decides a branch. §5's double count is one axis charged twice for + // one construct, which does not happen here — no other Python arm + // counts `named_expression`. () => { $crate::Python::Identifier | $crate::Python::True @@ -598,6 +644,7 @@ macro_rules! python_bool_terminal_kinds { | $crate::Python::Attribute | $crate::Python::Subscript | $crate::Python::Await + | $crate::Python::NamedExpression }; } @@ -1012,13 +1059,18 @@ macro_rules! tsx_bool_terminal_kinds { // `is_expression` (`a is String`, `a !is String`) and `in_expression` // (`a in 1..2`, `a !in 1..2`) are the two relational forms the grammar // spells as their own production rather than as a `binary_expression`, -// so the comparison-token arms never see them and nothing else in the -// Kotlin impl counts them. They are terminal for every consumer of this -// set — neither carries a nested chain link, and neither's own operator -// token (`is` / `!is` / `in` / `!in`) is counted anywhere — so listing -// them here scores each exactly once, as Fitzpatrick Rule 5 scores any -// other relational operator. Before #1421 `if (a is String)` scored -// zero conditions against a cyclomatic decision of one. +// so the comparison-token arms never see them. #1421 added them here +// and #1461 moved them to an unconditional arm in +// `src/metrics/abc/kotlin.rs`, slot-scoping having scored +// `val b = a is String` zero beside `val b = a == c`'s one — see the +// operands-not-operators note on the Phase-2 block below. +// +// Nothing else in the Kotlin impl counts either, and the node is the +// half to count rather than the token: `!is` / `!in` are their own +// spellings of the same production, and a bare `in` token is also the +// `for (x in xs)` header's. `bca dump` confirms a subject-ful `when` +// arm spells its patterns `range_test` / `type_test` rather than these +// two, so the entry's own count and this arm never both fire (§5). #[macro_export] #[doc(hidden)] macro_rules! kotlin_bool_terminal_kinds { @@ -1028,8 +1080,6 @@ macro_rules! kotlin_bool_terminal_kinds { | $crate::Kotlin::NavigationExpression | $crate::Kotlin::IndexExpression | $crate::Kotlin::ThisExpression - | $crate::Kotlin::IsExpression - | $crate::Kotlin::InExpression | $crate::Kotlin::InfixExpression }; } @@ -1075,9 +1125,15 @@ macro_rules! kotlin_bool_terminal_kinds { // which evaluates to a boolean. The grammar gives it its own // production, so the comparison-token arm in `metrics/abc/ruby.rs` // never sees it — that arm is gated on a `binary` parent and lists no -// `in` token — and `if a in Integer` scored zero conditions against a -// cyclomatic decision of one. Nothing counts the `in` token itself, so -// listing the wrapper scores it exactly once (§5). +// `in` token. It was listed here until #1461 moved it to an +// unconditional arm in that file, slot-scoping having scored +// `b = a in Integer` zero beside `b = a == 1`'s one. See the +// operands-not-operators note on the Phase-2 block below. +// +// Counting the node rather than the `in` token is what keeps it to one +// (§5): the same token heads `for x in xs` and the `in_clause` of a +// `case`/`in`, and `bca dump` shows all three as separate productions, +// so the `InClause` arm never sees a `test_pattern`. // // Its neighbour `match_pattern` (`expr => pat`, id 252) is **not** // here and must not be added: that spelling raises `NoMatchingPattern` @@ -1104,7 +1160,6 @@ macro_rules! ruby_bool_terminal_kinds { | $crate::Ruby::Float | $crate::Ruby::Rational | $crate::Ruby::Complex - | $crate::Ruby::TestPattern }; } diff --git a/big-code-analysis-book/src/metrics.md b/big-code-analysis-book/src/metrics.md index 9270dea2..8f79cc92 100644 --- a/big-code-analysis-book/src/metrics.md +++ b/big-code-analysis-book/src/metrics.md @@ -153,8 +153,9 @@ application would over-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. | | C#, Java, Rust, Python, Ruby, Elixir | A pattern-match guard is a condition slot, scoring one however it is spelled | A guard is a branch: the pattern can match while the guard fails. The guard's expression is scored exactly as an `if` condition is — one for a call, type test, attribute or bare identifier, one via the operator arm for a comparison — so every spelling agrees, and a compound guard (`when a > 1 && b < 2`) keeps its sub-structure rather than collapsing to one. Before this, ABC scored whatever operator happened to sit inside, so `when x > 2` counted one and the equivalent `when IsEven(x)` counted none. The spellings, per language: C# `when_clause` on a switch arm or `case` section and `catch_filter_clause` on a `catch` (#1422); Java 21's `guard` on a pattern-switch label, in both the arrow and colon forms; Rust's `match_pattern` guard; Python's `case … if g:`; Ruby's `if_guard` / `unless_guard` on a `case … in` arm; Elixir's `when` operator on a `stab_clause` head or a `def` / `defguard` head (#1454). The same change made the guard a **cyclomatic** decision wherever it was not already one — Java, Ruby and Elixir — and excluded Elixir's typespec `when` (`@spec f(a) :: a when a: integer`), which spells the same token as a guard but is type syntax. Groovy and Kotlin are absent because neither pinned grammar has a guard production at all: Kotlin 2.1 guard syntax does not parse, so there is nothing to classify (#1454). | | Kotlin | A subject-less `when` arm scores through the predicate slot; a subject-ful arm scores per entry | A subject-less arm's condition (`when { x > 5 -> … }`) is an ordinary boolean expression, compiled as an `if` predicate, so the comparison inside it is already counted by the operator arms and a blanket per-entry increment double-counted it. A subject-ful arm (`when (x) { in 1..2 -> … }`) lists a pattern rather than an independent boolean expression, so the implicit `subject == pattern` is a decision nothing in the source spells and the entry itself pays for it (#1421). | -| C++, Objective-C, Mozcpp, Rust, Go, Java, Groovy, C#, Kotlin, TypeScript, TSX, JavaScript, Mozjs, Lua, Perl, Ruby, Bash, Elixir | A `<` or `>` that is not a comparison is not a condition | Every one of these grammars spells at least one non-comparison construct with the same bare `<` / `>` token a comparison uses, so the comparison rule is gated on the token's parent. What that excludes, per family: template and generic brackets in C++, Objective-C, Mozcpp, Rust, Go, Java, Groovy, C#, Kotlin, TypeScript and TSX (#1274); JSX tag delimiters in TypeScript, TSX, JavaScript and Mozjs; Lua 5.4 variable attributes (`local x = 1`); a C# comparison-operator overload's declared name (`operator <`); Kotlin's qualified super call (`super.g()`) (#1297); Perl's filehandle and lexical-handle readlines (``, `<$fh>` — but not ``, which the grammar lexes as one token); Ruby's superclass clause (`class Foo < Bar`) and comparison-operator method names (`def <(other)`), and Bash I/O redirection (`cmd > out`) (#1280); Elixir's sigil delimiters (`~s`) (#1256). C# additionally excludes the operator of a relational pattern (`x is > 0`, and the `> 5 =>` arm of a switch expression): the arm or `if` condition slot that owns the pattern already scores the decision, so counting the operator too charged a relational arm twice what the constant arm `5 => 1` scores (#1383). Its `>=` / `<=` spelling is excluded by the same rule through a separate token. The declared name of an operator overload is excluded on every spelling too: C# overloads six comparison operators and gives each a distinct token, so `operator <=`, `operator >=`, `operator ==` and `operator !=` join `operator <` and `operator >`, which alone were excluded before #1420. The exclusion holds wherever the pattern sits, so a pattern no arm or condition slot owns (`bool b = x is > 0;`, `return x is > 0;`) scores 0, one below the equivalent `x > 0`. A `when` guard is no longer such a place: since #1422 the guard is itself a condition slot, so `when n is > 5` reads level with `when n > 5` rather than one below it. PHP, Python, Tcl and iRules emit a bare `<` / `>` from no non-comparison production; C carries the same gate as its C-family siblings although, having no templates, it has nothing to exclude. The gate is a claim about the grammar's productions, not about every parse: where a grammar resolves a generic *call* into nested `binary_expression` nodes, as tree-sitter-kotlin-ng does for `id(a)`, no polarity can exclude it (#1394). | +| C++, Objective-C, Mozcpp, Rust, Go, Java, Groovy, C#, Kotlin, TypeScript, TSX, JavaScript, Mozjs, Lua, Perl, Ruby, Bash, Elixir | A `<` or `>` that is not a comparison is not a condition | Every one of these grammars spells at least one non-comparison construct with the same bare `<` / `>` token a comparison uses, so the comparison rule is gated on the token's parent. What that excludes, per family: template and generic brackets in C++, Objective-C, Mozcpp, Rust, Go, Java, Groovy, C#, Kotlin, TypeScript and TSX (#1274); JSX tag delimiters in TypeScript, TSX, JavaScript and Mozjs; Lua 5.4 variable attributes (`local x = 1`); a C# comparison-operator overload's declared name (`operator <`); Kotlin's qualified super call (`super.g()`) (#1297); Perl's filehandle and lexical-handle readlines (``, `<$fh>` — but not ``, which the grammar lexes as one token); Ruby's superclass clause (`class Foo < Bar`) and comparison-operator method names (`def <(other)`), and Bash I/O redirection (`cmd > out`) (#1280); Elixir's sigil delimiters (`~s`) (#1256). C# additionally excludes the operator of a relational pattern (`x is > 0`, and the `> 5 =>` arm of a switch expression): the arm or `if` condition slot that owns the pattern already scores the decision, so counting the operator too charged a relational arm twice what the constant arm `5 => 1` scores (#1383). Its `>=` / `<=` spelling is excluded by the same rule through a separate token. The declared name of an operator overload is excluded on every spelling too: C# overloads six comparison operators and gives each a distinct token, so `operator <=`, `operator >=`, `operator ==` and `operator !=` join `operator <` and `operator >`, which alone were excluded before #1420. The exclusion is on the operator alone, not on the test that encloses it: since #1461 the `is` test itself is a condition wherever it is written, so `bool b = x is > 0;` and `return x is > 0;` each score 1 — level with the `x > 0` they are sugar for, and the same 1 a `when n is > 5` guard has scored since #1422 made the guard a condition slot. Counting the pattern's operator as well would make a relational arm worth twice the constant arm `5 => 1`. PHP, Python, Tcl and iRules emit a bare `<` / `>` from no non-comparison production; C carries the same gate as its C-family siblings although, having no templates, it has nothing to exclude. The gate is a claim about the grammar's productions, not about every parse: where a grammar resolves a generic *call* into nested `binary_expression` nodes, as tree-sitter-kotlin-ng does for `id(a)`, no polarity can exclude it (#1394). | | Java, Groovy, C#, TypeScript, TSX | A `?` used as type syntax is not a ternary | In each of these grammars the ternary `?` and the type-syntax `?` are the *same* anonymous token, so the ternary rule above is gated on the token's parent. Java and Groovy exclude the wildcard bound `List` (#1274); C# excludes the nullable type `int? x` and the constraint `where T : class?`; TypeScript and TSX exclude optional parameters, properties, methods, class fields and tuple elements, and conditional types (`T extends U ? X : Y`, which the type checker resolves and erases before runtime, so it is no more a branch than the `<` / `>` already excluded) (#1275). Safe navigation is untouched: C#'s `a?.b` shares the same token and still counts, while the other languages spell theirs as a distinct one. | +| C#, Java, Groovy, Kotlin, Ruby | A relational operator scores by use; a value-bearing operand scores in a boolean slot | These five grammars spell at least one relational construct as its own production rather than as a binary expression with an operator token: C#'s `x is int` and `x is null`, Java's and Groovy's `x instanceof T`, Groovy's `a in l`, Kotlin's `a is T` and `a in 1..2`, and Ruby's one-line `a in Integer`. Having no token to count, each was reached only through the language's terminal-operand set, which the walker consults inside an `if` / `while` / ternary / `&&`-operand slot and nowhere else — so `var b = x == 1;` scored 1 while `var b = x is int;` scored 0. Fitzpatrick's Rule 5 counts a relational operator wherever it appears, so each now counts wherever it appears and has left the operand set; being in both would score it twice. Groovy's spaceship `<=>` counts on the same rule, as it already did in Ruby, PHP, C++ and Mozcpp, although its result is an integer rather than a boolean — what Rule 5 measures is the comparison, not its type. The converse still holds for a construct whose *value* fills the slot: a cast, a Go type assertion, a Rust `matches!` / `cfg!` macro and a Python walrus are all operands and count only where a slot reads them as a predicate (#1461). | #### Worked example diff --git a/src/metrics/abc.rs b/src/metrics/abc.rs index 960c60d2..926de8de 100644 --- a/src/metrics/abc.rs +++ b/src/metrics/abc.rs @@ -1603,6 +1603,11 @@ mod tests { // counted. Java has no `await` or `is_pattern` analogues, // so the C# fix's five-kind set collapses to four here. // + // `instanceof` has since moved out of the terminal set to an + // unconditional arm (#1461), so it no longer depends on the + // slot to be counted. The total is unchanged, which is the + // point of that change rather than an accident of it. + // // expected: 4 conditions (one per `if`), 0 assignments, // 0 branches (no invocations). check_metrics::( @@ -2358,6 +2363,11 @@ mod tests { // `array_access` analogues, so the C# fix's five-kind set // collapses to four here (with the cast slot doubled). // + // `instanceof` has since moved out of the terminal set to an + // unconditional arm (#1461), so it no longer depends on the + // slot to be counted. The total is unchanged, which is the + // point of that change rather than an accident of it. + // // expected: 4 conditions (one per `if`), 0 assignments, // 0 branches (no invocations). check_metrics::( @@ -3707,6 +3717,11 @@ mod tests { // - `v is not null` — IsPatternExpression // - `flags[0]` — ElementAccessExpression // + // The `is` test has since moved out of the terminal set to an + // unconditional arm (#1461), so it no longer depends on the + // slot to be counted. The total is unchanged, which is the + // point of that change rather than an accident of it. + // // expected: 5 conditions (one per `if`), 0 assignments, // 1 branch (the single `c.Check()` invocation; the other // `if`-condition expressions are not invocations). @@ -4105,11 +4120,13 @@ mod tests { // The fixture carries the two overloads plus one `a < b` inside a // `binary_expression` and one `x is > 0 ? 2 : 3`, which the grammar // parses as a `relational_pattern` whose operand is the ternary (see - // the assertion). Every mis-aim lands on its own number: 5 - // with neither gate, 3 if `RelationalPattern` is readmitted to the - // allowlist (#1383 dropped it), 1 if the gate swallows - // `BinaryExpression` too (only the ternary `?` survives), 0 if the - // fixture stops parsing. + // the assertion). Every mis-aim lands on its own number: 6 + // with neither gate, 4 if `RelationalPattern` is readmitted to the + // allowlist (#1383 dropped it), 2 if the gate swallows + // `BinaryExpression` too (only the ternary `?` and the `is` test + // survive), 0 if the fixture stops parsing. Each is one higher than + // before #1461, which added the `is` test as a count no gate on the + // `<` / `>` token can reach. #[test] fn csharp_operator_declaration_is_not_a_condition() { check_func_space::( @@ -4136,15 +4153,19 @@ mod tests { "operator declaration {i} must score no condition" ); } - // 2, not 3, since #1383: the `a < b` comparison and the - // ternary `?`. tree-sitter-c-sharp 0.23.5 parses + // 3: the `a < b` comparison, the ternary `?`, and the + // `is` test. tree-sitter-c-sharp 0.23.5 parses // `x is > 0 ? 2 : 3` as `x is > (0 ? 2 : 3)` — the ternary // is the pattern's operand, so the pattern sits in no - // decision slot and its `>` scores nothing, while the - // ternary's condition is the literal `0`. C# itself binds - // it `(x is > 0) ? 2 : 3`; a grammar that agrees makes - // this 3, because that condition slot scores the `is` test. - assert_eq!(class.spaces[2].metrics.abc.conditions(), 2); + // decision slot and its `>` still scores nothing (#1383), + // while the ternary's condition is the literal `0`. + // The third count is the enclosing `is_pattern_expression` + // itself, which since #1461 scores by use rather than only + // inside a boolean slot. C# itself binds the source + // `(x is > 0) ? 2 : 3`; a grammar that agrees still reads + // 3 here, by the same three counts in a different + // arrangement. + assert_eq!(class.spaces[2].metrics.abc.conditions(), 3); }, ); } @@ -4371,24 +4392,28 @@ mod tests { // #1383's second, quieter effect, and the one its issue does not // mention: a relational pattern outside any decision slot went from // 1 to 0 as well, because the gate is on the operator's parent and - // not on what encloses the pattern. + // not on what encloses the pattern. That left `q` — the equivalent + // binary comparison `x > 5` in the same slot — scoring 1 against + // these four's 0, so a relational pattern read one *lower* than the + // comparison it is sugar for. #1383 recorded that as a deliberate + // exception to Fitzpatrick Rule 5 and kept it, because the only + // route it had was to readmit the pattern's `<` / `>` token, which + // would have scored the decision-slot case twice. + // + // #1461 closed it from the other end, and the test now pins the + // agreement rather than the exception. The enclosing + // `is_pattern_expression` scores by use, so `x is > 5` reads 1 + // wherever it is written — level with `q`, and level with the plain + // type test `x is int` (`t`, whose `is_expression` moved for the + // same reason). The pattern's own `>` still scores nothing, which + // is what keeps the whole test at 1 rather than 2: one relational + // operation, one condition. // - // That is the right side of the trade, but it is a trade and the - // numbers should be visible. It puts `x is > 5` in agreement with - // the plain type test `x is int` (`t`, always 0 — note the grammar - // spells that one `is_expression`, not a pattern at all) and with - // cyclomatic, where before the fix it disagreed with both. The - // price is `q`: - // the equivalent binary comparison `x > 5` still scores 1 in the - // same slot, so a relational pattern now reads one lower than the - // comparison it is sugar for. Fitzpatrick counts comparison - // operators wherever they appear, so `q` is the spec-faithful one - // and these four are the deliberate exception — kept because the - // decision-slot case is what the metric is for, and Option 2 in - // #1383 (count the operator, drop the arm) could not justify - // itself. - #[test] - fn csharp_relational_pattern_outside_a_decision_slot_scores_zero() { + // The four probes stay because they are four different enclosings — + // a declarator, a bare `return`, an argument, a type test — and the + // point was never that the pattern is special in one of them. + #[test] + fn csharp_relational_pattern_scores_one_wherever_it_is_written() { let src = "class A { static bool M(bool b) { return b; } bool p(int x) { bool b = x is > 5; return b; } @@ -4422,10 +4447,11 @@ mod tests { // spelling scores what, so a reordering of the fixture must // not silently re-point the assertions. for probe in ["p", "r", "s"] { - assert_eq!(by_name(probe), 0, "`{probe}`: pattern operator excluded"); + assert_eq!(by_name(probe), 1, "`{probe}`: the `is` test, once"); } - assert_eq!(by_name("t"), 0, "type pattern, the agreement target"); - assert_eq!(by_name("q"), 1, "a plain comparison still counts"); + assert_eq!(by_name("t"), 1, "the bare type test, the same once"); + assert_eq!(by_name("q"), 1, "the comparison it is sugar for"); + assert_eq!(by_name("M"), 0, "no relational operator anywhere"); }); } @@ -4602,7 +4628,10 @@ mod tests { // however it is spelled" — `when x is int` read 1 where // `when IsEven(x)` read 2, which is the spelling-dependence that // fix exists to remove. It is the C# half of the gap #1421 closed - // for Kotlin by adding `IsExpression | InExpression` there. + // for Kotlin by adding `IsExpression | InExpression` there. Both + // languages' type tests have since moved out of the terminal set to + // an unconditional arm (#1461), which leaves every number here + // unchanged — the slot no longer counts the test and the arm does. // // The plain `if` members are the control that keeps this honest: a // guard-only fixture could be satisfied by a guard-specific rule, @@ -6251,15 +6280,21 @@ function f(int $a, int $b): int { // nothing counted them: `if (a is String)` scored 0 against a // decision count of 1, and the same test in a subject-less `when` // scored 1 only because the entry's blanket `+1` happened to cover - // it. Removing that blanket without listing these two in - // `kotlin_bool_terminal_kinds!()` would have regressed `whenIs` and - // `whenIn` to 0, and `isAnd` — where the `is` test is one operand of - // a `&&` chain and so reaches the walker rather than the slot — from - // 2 to 1. + // it. Removing that blanket without counting these two somewhere + // would have regressed `whenIs` and `whenIn` to 0, and `isAnd` — + // where the `is` test is one operand of a `&&` chain and so reaches + // the walker rather than the slot — from 2 to 1. + // + // The `if` members are here because the fix was never `when`- + // specific: they are the call sites that prove it reaches the `if` / + // `while` predicate slot too. // - // The `if` members are here because the fix is in the shared - // terminal-kind set, not in the `when` arm: they are the call sites - // that prove it reaches the `if` / `while` predicate slot too. + // #1421 counted them through `kotlin_bool_terminal_kinds!()` and + // #1461 moved them to an unconditional arm, which leaves every + // number below unchanged — the slot no longer counts the test and + // the arm does. `kotlin_is_and_in_score_outside_a_boolean_slot` is + // the half this fixture cannot see, every member of it being inside + // a slot. #[test] fn kotlin_is_and_in_expressions_are_unary_conditions() { let src = "class K { @@ -13577,6 +13612,364 @@ end ); }); } + // #1461's structural half: a relational operator scores by *use*, + // a value-bearing operand scores in a boolean *slot*. + // + // Until this, the relational constructs a grammar spells as their + // own production — C#'s two `is` tests, Java's and Groovy's + // `instanceof`, Groovy's `in`, Kotlin's `is` / `in`, Ruby's + // one-line `in` — reached `stats.conditions` only through + // `_bool_terminal_kinds!()`, which every walker consults + // inside a boolean slot and nowhere else. The comparison token + // beside each of them carried no such gate, so one language scored + // two spellings of the same relational test differently by + // position: + // + // val b = a == c // 1 condition + // val b = a is String // 0 conditions + // + // Fitzpatrick Rule 5 scores a relational operator wherever it is + // written, so the asymmetry was in the mechanism. Each construct + // now has an unconditional arm in its language's `compute` and has + // left the terminal set; listing it in both would score it twice + // (`.claude/rules/grammar-dispatch.md` §5). + // + // Every fixture below carries the same four shapes, because they + // are structurally independent paths and covering one says nothing + // about the others (§11): + // + // - `out*` — the construct with no boolean slot above it. This is + // the case the change exists for, and the one no prior fixture + // covered: measured against the pre-change binary, every `out*` + // member here scored **0**. + // - `outCtl` — the same member spelling `==`. It read 1 before and + // after, so it is the anchor that makes `out*`'s 1 a claim about + // the construct rather than about the member shape + // (`.claude/rules/testing.md`, "Perturb the fixture as well as + // the production line"). + // - `in*` / `inAnd` — the predicate slot and the `&&`-chain walker, + // the two paths that already owned the count. They must be + // *unchanged*; a §5 double count surfaces here as a 2 against the + // control's 1, which is how #1459 caught the Kotlin `as?` + // collision. + // - `forIn` (Kotlin / Groovy / Ruby) — the `for … in …` header, + // which spells the same keyword from a different production and + // must stay at 0. It is the reason those three count the node and + // not the token. + + #[test] + fn csharp_is_tests_score_outside_a_boolean_slot() { + let src = "class A { + bool outIs(object x) => x is int; + bool outPat(object x) => x is null; + bool outCtl(int x) => x == 1; + int inIs(object x) { if (x is int) { return 1; } return 0; } + int inAnd(object x, bool b) { if (x is int && b) { return 1; } return 0; } + }"; + assert_csharp_fixture_spells( + src, + &[ + (Csharp::IsExpression as u16, 3, "the bare `is` type tests"), + ( + Csharp::IsPatternExpression as u16, + 1, + "`outPat`'s null pattern", + ), + (Csharp::EQEQ as u16, 1, "`outCtl`'s comparison"), + ], + ); + check_func_space::(src, "foo.cs", |space| { + assert_members_score( + &space.spaces[0], + &[ + // Both were 0, against `outCtl`'s 1 on the same + // expression-bodied shape. + ("outIs", 1, 1), + ("outPat", 1, 1), + ("outCtl", 1, 1), + // Unchanged: the slot no longer counts the test, the + // new arm does, and the total stays put. + ("inIs", 1, 2), + ("inAnd", 2, 3), + ], + ); + }); + } + + #[test] + fn java_instanceof_scores_outside_a_boolean_slot() { + let src = "class A { + boolean outOf(Object x) { return x instanceof String; } + boolean outCtl(int x) { return x == 1; } + int inOf(Object x) { if (x instanceof String) { return 1; } return 0; } + int inAnd(Object x, boolean b) { if (x instanceof String && b) { return 1; } return 0; } + }"; + assert_fixture_spells::( + src, + "foo.java", + &[ + ( + Java::InstanceofExpression as u16, + 3, + "the `instanceof` tests", + ), + (Java::EQEQ as u16, 1, "`outCtl`'s comparison"), + ], + ); + check_func_space::(src, "foo.java", |space| { + assert_members_score( + &space.spaces[0], + &[ + // Was 0, against `outCtl`'s 1 on the same + // `return`-a-predicate shape. + ("outOf", 1, 1), + ("outCtl", 1, 1), + ("inOf", 1, 2), + ("inAnd", 2, 3), + ], + ); + }); + } + + // Groovy carries item 4 as well as the structural pass. `<=>` + // yields -1 / 0 / 1 rather than a boolean, which is why it never + // belonged in the terminal set — that set holds operands — but it + // is a relational operator, and `LTEQGT` is already a condition + // token in Ruby, PHP, C++ and Mozcpp. Groovy was the outlier at 0. + #[test] + fn groovy_relational_productions_score_outside_a_boolean_slot() { + let src = "class A { + def outIs(x) { def b = x instanceof String; return b } + def outIn(x, l) { def b = x in l; return b } + def outShip(a, c) { def r = a <=> c; return r } + def outCtl(x) { def b = x == 1; return b } + def inIs(x) { if (x instanceof String) { return 1 }; return 0 } + def inIn(x, l) { if (x in l) { return 1 }; return 0 } + def forIn(l) { for (q in l) { }; return 0 } + }"; + assert_fixture_spells::( + src, + "foo.groovy", + &[ + ( + Groovy::InstanceofExpression as u16, + 2, + "the `instanceof` tests", + ), + (Groovy::MembershipExpression as u16, 2, "the `in` tests"), + (Groovy::SpaceshipExpression as u16, 1, "`outShip`'s `<=>`"), + ( + Groovy::ForInStatement as u16, + 1, + "`forIn`'s loop header, which spells the same `in`", + ), + (Groovy::EQEQ as u16, 1, "`outCtl`'s comparison"), + ], + ); + check_func_space::(src, "foo.groovy", |space| { + assert_members_score( + &space.spaces[0], + &[ + // All three were 0, against `outCtl`'s 1 on the + // identical declare-and-return shape. + ("outIs", 1, 1), + ("outIn", 1, 1), + ("outShip", 1, 1), + ("outCtl", 1, 1), + ("inIs", 1, 2), + ("inIn", 1, 2), + // The loop header is not a membership test and + // stays at 0 — counting the `in` *token* would have + // scored it. + ("forIn", 0, 2), + ], + ); + }); + } + + #[test] + fn kotlin_is_and_in_score_outside_a_boolean_slot() { + let src = "class K { + fun outIs(a: Any): Boolean { val b = a is String; return b } + fun outIn(a: Int): Boolean { val b = a in 1..2; return b } + fun outCtl(a: Int): Boolean { val b = a == 1; return b } + fun inIs(a: Any): Int { if (a is String) { return 1 }; return 0 } + fun forIn(l: List): Int { for (q in l) { }; return 0 } + }"; + assert_kotlin_class_members( + src, + &[ + (Kotlin::IsExpression as u16, 2, "the `is` tests"), + (Kotlin::InExpression as u16, 1, "`outIn`'s `in` test"), + ( + Kotlin::ForStatement as u16, + 1, + "`forIn`'s loop header, which spells the same `in`", + ), + (Kotlin::EQEQ as u16, 1, "`outCtl`'s comparison"), + ], + &[ + // Both were 0, against `outCtl`'s 1 on the identical + // `val`-and-return shape. + ("outIs", 1, 1), + ("outIn", 1, 1), + ("outCtl", 1, 1), + ("inIs", 1, 2), + ("forIn", 0, 2), + ], + ); + } + + #[test] + fn ruby_test_pattern_scores_outside_a_boolean_slot() { + let src = "def out_in(a) + b = a in Integer + b +end +def out_ctl(a) + b = a == 1 + b +end +def in_in(a) + if a in Integer + return 1 + end + 0 +end +def for_in(l) + for q in l do end + 0 +end +"; + assert_fixture_spells::( + src, + "foo.rb", + &[ + (Ruby::TestPattern as u16, 2, "the one-line `in` tests"), + ( + Ruby::For as u16, + 1, + "`for_in`'s loop header, which spells the same `in`", + ), + (Ruby::EQEQ as u16, 1, "`out_ctl`'s comparison"), + ], + ); + check_func_space::(src, "foo.rb", |space| { + assert_members_score( + &space, + &[ + // Was 0, against `out_ctl`'s 1 on the identical + // assign-and-return shape. + ("out_in", 1, 1), + ("out_ctl", 1, 1), + ("in_in", 1, 2), + ("for_in", 0, 2), + ], + ); + }); + } + + // #1461 item 1. The walrus is an *operand*, not an operator, so it + // joins `python_bool_terminal_kinds!()` and stays slot-scoped — + // `b = (n := g())` outside a predicate is a binding, not a + // decision. Inside one the slot tests `g()`'s truth, and + // `if (n := g()):` scored 0 where the `if g():` it is a + // refactoring of scored 1. + // + // `assignments` is asserted beside `conditions` because the walrus + // is the one construct in the survey that pays on two axes, and + // that is deliberate rather than a §5 double count: ABC's axes are + // independent measurements of the same source, and `if (n := g()):` + // genuinely both binds a name and decides a branch. Without this + // row a later reader has no way to tell the intent from an + // oversight. + #[test] + fn python_walrus_is_a_unary_condition() { + let src = "def in_slot(g): + if (n := g()): + return 1 + return 0 + +def chain(g, b): + return b and (n := g()) + +def ctrl(g): + if g(): + return 1 + return 0 +"; + assert_fixture_spells::( + src, + "foo.py", + &[(Python::NamedExpression as u16, 2, "the walrus bindings")], + ); + check_func_space::(src, "foo.py", |space| { + assert_members_score( + &space, + &[ + // Was 0, against `ctrl`'s 1 for the same predicate + // without the binding. + ("in_slot", 1, 2), + // The `and`-chain walker is the second, independent + // path (§11): was 1, the bare `b` operand alone. + ("chain", 2, 2), + ("ctrl", 1, 2), + ], + ); + for (name, assignments) in [("in_slot", 1u64), ("chain", 1), ("ctrl", 0)] { + assert_eq!( + child_space(&space, name).metrics.abc.assignments(), + assignments, + "{name}: the walrus also pays on the A axis, on purpose" + ); + } + }); + } + + // #1461 item 2. A macro in a boolean slot expands to a boolean + // expression, and `if matches!(x, Some(_))` scored 0 against a + // cyclomatic decision of 1. + // + // `cfg_slot` is in the fixture because the arm's *breadth* is the + // decision, not a side effect: these sets discriminate on slot and + // never on return type, so `cfg!` counts exactly as `matches!` + // does. It is also the construct the corpus actually moves — + // serde's one changed snapshot is `if cfg!(no_underscore_consts)`, + // not a `matches!` — so asserting only `matches!` would leave the + // measured case uncovered. + #[test] + fn rust_macro_invocation_is_a_unary_condition() { + let src = "fn in_slot(x: Option) -> u8 { if matches!(x, Some(_)) { 1 } else { 0 } } +fn cfg_slot() -> u8 { if cfg!(unix) { 1 } else { 0 } } +fn chain(x: Option, b: bool) -> bool { b && matches!(x, Some(_)) } +fn ctrl(x: Option) -> u8 { if x.is_some() { 1 } else { 0 } } +"; + assert_fixture_spells::( + src, + "foo.rs", + &[( + Rust::MacroInvocation as u16, + 3, + "the `matches!` / `cfg!` predicates", + )], + ); + check_func_space::(src, "foo.rs", |space| { + assert_members_score( + &space, + &[ + // Each was 1 — the `else` alone — against `ctrl`'s + // 2 for the same shape with a method call in the + // predicate. + ("in_slot", 2, 2), + ("cfg_slot", 2, 2), + // The `&&`-chain walker, the second independent + // path (§11): was 1, the bare `b` operand alone. + ("chain", 2, 2), + ("ctrl", 2, 2), + ], + ); + }); + } } /// A comment inside a ternary must not change its ABC conditions diff --git a/src/metrics/abc/csharp.rs b/src/metrics/abc/csharp.rs index 3daca458..c5a1ad29 100644 --- a/src/metrics/abc/csharp.rs +++ b/src/metrics/abc/csharp.rs @@ -117,9 +117,11 @@ fn csharp_inspect_container(container_node: &Node, parent: &Node, conditions: &m // Found the innermost operand; count it if a boolean context // was established up the chain. The `csharp_bool_terminal_kinds!()` // set bundles invocation aliases, the `Identifier` / - // `BooleanLiteral` leaves, and the five bool-evaluating kinds - // restored by #372 (member access / await / cast / is-pattern / - // element access). + // `BooleanLiteral` leaves, and the bool-evaluating kinds + // restored by #372 (member access / await / cast / element + // access). The two `is` tests left the set in #1461 for an + // unconditional arm, so a type-test operand contributes nothing + // here. if matches!(node.kind_id().into(), csharp_bool_terminal_kinds!()) { if has_boolean_content { *conditions += 1.; @@ -143,7 +145,9 @@ fn csharp_count_unary_conditions(list_node: &Node, conditions: &mut f64) { // `csharp_bool_terminal_kinds!()` bundles invocation aliases, // `Identifier`, `BooleanLiteral`, and the bool-evaluating // expression kinds restored by #372 (member access / await / - // cast / is-pattern / element access). + // cast / element access). An `is` operand contributes nothing + // here since #1461 — its own arm counts it wherever it + // appears, chain or no chain. if matches!(node_kind, csharp_bool_terminal_kinds!()) && matches!(list_kind, BinaryExpression) { @@ -365,7 +369,30 @@ fn csharp_count_token_condition<'a>( // bare `QMARK` below and from the `??=` compound assignment // (`QMARKQMARKEQ`, counted as an assignment), and every // condition slot declines a `binary_expression` outright. - Else | Try | Catch | QMARKQMARK => { + // C#'s two type tests join them in #1461, scored by use rather + // than by slot. They sat in `csharp_bool_terminal_kinds!()` until + // then, which counts only inside a boolean slot: `var b = x is + // int;` scored zero where the `var b = x == 1;` beside it + // scored one, because `EQEQ` is a token arm and `is` was not. + // Fitzpatrick Rule 5 scores a relational operator wherever it is + // written, so the asymmetry was in the mechanism, not the rule. + // + // Matched as nodes rather than as an `Is` token because the + // grammar splits the construct across two productions by + // pattern-ness, not by keyword: `x is int` is an + // `is_expression` and `x is null` / `x is not Foo` / `x is int + // n` are all `is_pattern_expression` (verified with `bca dump`). + // The two are disjoint alternatives, never nested, so exactly + // one fires per test (§5), and no arm counts the keyword. + // + // The guard slot added by #1422 is unaffected: a `when x is + // int` now reaches this arm instead of the slot's terminal-set + // test, and still totals one. + // + // They share the arm rather than sitting beside it because the + // arm's meaning is "this node is a condition, with nothing to + // gate on" — which is as true of a production as of a token. + Else | Try | Catch | QMARKQMARK | IsExpression | IsPatternExpression => { stats.conditions += 1.; } // `case` comes from two productions — `switch_section`, a real @@ -593,9 +620,10 @@ fn csharp_walk_for_conditions<'a>( // the comparison-token arm while `when IsEven(x)` counted zero, // so three semantically identical guards produced two different // numbers. As a slot every spelling contributes exactly one — - // a call / `is` test / bare identifier through - // `csharp_bool_terminal_kinds!()`, a comparison through the - // token arm that already owned it — and a compound guard + // a call / bare identifier through + // `csharp_bool_terminal_kinds!()`, a comparison or (since + // #1461) an `is` test through the arm that owns it — and a + // compound guard // (`when a > 1 && b < 2`) keeps its sub-structure rather than // collapsing to one. // diff --git a/src/metrics/abc/groovy.rs b/src/metrics/abc/groovy.rs index 9639cfc7..b9c01932 100644 --- a/src/metrics/abc/groovy.rs +++ b/src/metrics/abc/groovy.rs @@ -272,14 +272,43 @@ fn groovy_count_token_condition<'a>( // and Bash. `getter/groovy.rs` already classifies all four as // Halstead operators. // - // Groovy's membership (`in` / `!in`) is the one relational - // form that cannot come through here — see - // `groovy_bool_terminal_kinds!()` for why. The spaceship `<=>` - // is deliberately still absent: it yields -1 / 0 / 1 rather - // than a boolean, so whether it is a condition at all is the - // open question in FIXME(#1461) item 4, not an oversight here. - GTEQ | LTEQ | EQEQ | BANGEQ | EQEQEQ | BANGEQEQ | EQTILDE | EQEQTILDE | Else | Case - | Try | Catch | QMARKCOLON => { + // `LTEQGT` is the spaceship `<=>`, added in #1461. It yields + // -1 / 0 / 1 rather than a boolean, which is why it was not + // listed in `groovy_bool_terminal_kinds!()` — that set holds + // operands, and `<=>` is not one — but it *is* a relational + // operator, and Fitzpatrick Rule 5 counts those by use + // regardless of result type — and `<=>` is the whole of a + // three-way decision, not a fragment of one. Every sibling + // language with a spaceship already counted it: `LTEQGT` is a + // condition token in Ruby, PHP, C++ and Mozcpp, and Perl adds + // its word spelling `cmp` beside it. Groovy was the outlier, + // scoring `def r = a <=> b` zero against `a == b`'s one. The + // same dekobon-tree-sitter-groovy 0.2.2 `grammar.json` sweep + // finds `<=>` in `spaceship_expression` alone, so it is ungated + // for the reason `EQEQEQ` is, and the expression node itself is + // counted nowhere (§5). + // Groovy's two relational forms with no usable operator token + // join them in #1461, + // scored by use rather than by slot (#1461). Both sat in + // `groovy_bool_terminal_kinds!()` until then, which counts only + // inside a boolean slot: `def b = a in l` and + // `def b = a instanceof String` scored zero where the + // `def b = a == 1` beside them scored one. + // + // Matched as nodes rather than as tokens because neither + // construct has a token this arm could use: `in` is shared with + // the `for (x in l)` header, and the negated spellings `!in` / + // `!instanceof` emit no operator token at all — `bca dump` + // shows `membership_expression` with two `identifier` children + // and nothing between them. One node covers both spellings of + // each construct, and neither token is counted anywhere, so + // exactly one arm fires per test (§5). + // + // They share the arm rather than sitting beside it because the + // arm's meaning is "this node is a condition, with nothing to + // gate on" — which is as true of a production as of a token. + GTEQ | LTEQ | LTEQGT | EQEQ | BANGEQ | EQEQEQ | BANGEQEQ | EQTILDE | EQEQTILDE | Else + | Case | Try | Catch | QMARKCOLON | MembershipExpression | InstanceofExpression => { stats.conditions += 1.; } // As in Java: a bare `?` is either a ternary head or the head of diff --git a/src/metrics/abc/java.rs b/src/metrics/abc/java.rs index 3fd3d755..174647e2 100644 --- a/src/metrics/abc/java.rs +++ b/src/metrics/abc/java.rs @@ -69,10 +69,12 @@ fn java_inspect_container(container_node: &Node, parent: &Node, conditions: &mut // Stops the exploration when the content is found. The terminal // set includes `FieldAccess` (`obj.flag`), `CastExpression` - // (`(boolean)v`), `ArrayAccess` (`flags[0]`), and - // `InstanceofExpression` (`x instanceof Foo`) — every kind whose - // evaluated value is implicitly boolean in idiomatic Java, mirroring - // the C# fix in #372 (lesson #19). + // (`(boolean)v`) and `ArrayAccess` (`flags[0]`) — every kind + // whose evaluated value is implicitly boolean in idiomatic + // Java, mirroring the C# fix in #372 (lesson #19). + // `InstanceofExpression` was a fifth until #1461 moved it to + // an unconditional arm: it is an operator, so it scores by use + // rather than only where this walker looks. if matches!(node_kind, java_bool_terminal_kinds!()) { if has_boolean_content { *conditions += 1.; @@ -97,10 +99,12 @@ fn java_count_unary_conditions(list_node: &Node, conditions: &mut f64) { let node_kind = node.kind_id().into(); // Checks if the node is a unary condition. The terminal set - // includes `FieldAccess`, `CastExpression`, `ArrayAccess`, - // and `InstanceofExpression` so that bool-evaluating - // operands of `&&` / `||` chains are not silently zeroed - // out (mirrors the C# fix in #372; lesson #19). + // includes `FieldAccess`, `CastExpression` and `ArrayAccess` + // so that bool-evaluating operands of `&&` / `||` chains are + // not silently zeroed out (mirrors the C# fix in #372; + // lesson #19). An `instanceof` operand contributes nothing + // here since #1461 — its own arm counts it wherever it + // appears, chain or no chain. if matches!(node_kind, java_bool_terminal_kinds!()) && matches!(list_kind, BinaryExpression) { @@ -255,7 +259,24 @@ fn java_count_token_condition<'a>( ) -> bool { use Java::*; match node.kind_id().into() { - GTEQ | LTEQ | EQEQ | BANGEQ | Else | Case | Try | Catch => { + // `x instanceof Foo` joins them in #1461, scored by use rather + // than by slot. + // It sat in `java_bool_terminal_kinds!()` until then, which + // counts only inside a boolean slot: `boolean b = x instanceof + // String;` scored zero where the `boolean b = x == 1;` beside it + // scored one, because `EQEQ` is a token arm and `instanceof` was + // not. Fitzpatrick Rule 5 scores a relational operator wherever + // it is written. + // + // Matched as the node rather than as the `instanceof` token + // because one node spans both spellings — the plain test and + // Java 16's pattern form `x instanceof String s` — and no arm + // counts the keyword, so exactly one fires per test (§5). + // + // It shares the arm rather than sitting beside it because the + // arm's meaning is "this node is a condition, with nothing to + // gate on" — which is as true of a production as of a token. + GTEQ | LTEQ | EQEQ | BANGEQ | Else | Case | Try | Catch | InstanceofExpression => { stats.conditions += 1.; } // `?` opens a ternary, but tree-sitter-java also emits it bare @@ -320,9 +341,10 @@ fn java_walk_for_conditions<'a>(node: &Node<'a>, ancestors: Ancestors<'a, '_>, s // while `when isEven(i)` and `when b` counted zero, so three // semantically identical guards produced two different numbers. // As a slot every spelling contributes exactly one — a call / - // field access / `instanceof` test / bare identifier through - // `java_bool_terminal_kinds!()`, a comparison through the token - // arm that already owns it — and a compound guard + // field access / bare identifier through + // `java_bool_terminal_kinds!()`, a comparison or (since #1461) + // an `instanceof` test through the arm that owns + // it — and a compound guard // (`when a > 1 && b < 2`) keeps its sub-structure rather than // collapsing to one. // @@ -381,8 +403,10 @@ fn java_walk_ternary(node: &Node, stats: &mut Stats) { // chain, and anything else (a `binary_expression`, whose operator token // the dispatcher already counted) contributes nothing. The terminal set // mirrors `java_inspect_container` (issue #372 / lesson #19): -// FieldAccess / CastExpression / ArrayAccess / InstanceofExpression all -// evaluate to a boolean in idiomatic Java condition slots. Mirrors +// FieldAccess / CastExpression / ArrayAccess all evaluate to a boolean +// in idiomatic Java condition slots. An `instanceof` predicate is +// among the "anything else" since #1461 — its own arm counts it. +// Mirrors // `csharp_count_condition` / `groovy_count_condition`. fn java_count_condition(condition: &Node, parent: &Node, conditions: &mut f64) { use Java::*; diff --git a/src/metrics/abc/kotlin.rs b/src/metrics/abc/kotlin.rs index e7856cc2..d2685099 100644 --- a/src/metrics/abc/kotlin.rs +++ b/src/metrics/abc/kotlin.rs @@ -172,8 +172,9 @@ fn kotlin_inspect_container(container_node: &Node, parent: &Node, conditions: &m // operand is a nested `binary_expression`, absent from // `kotlin_bool_terminal_kinds!()`, and contributes nothing here because // its operator token was counted directly; bare identifiers / calls / -// member accesses, and the `is` / `in` tests no token arm sees, each add -// one. Inner chain links and `!` / paren wrappers are routed through +// member accesses each add one. An `is` / `in` operand is in the same +// position as a comparison since #1461 — its own arm counts it, here or +// anywhere. Inner chain links and `!` / paren wrappers are routed through // `kotlin_inspect_container`. fn kotlin_count_unary_conditions(list_node: &Node, conditions: &mut f64) { use Kotlin::*; @@ -209,7 +210,8 @@ fn kotlin_count_unary_conditions(list_node: &Node, conditions: &mut f64) { // field lookup is position-independent across all three forms. Since // #1421 it also scores a subject-less `when` entry's condition, which is // an `if` predicate in all but spelling. A bare terminal (`if (flag)`) -// counts directly, as does an `is` / `in` test that no token arm sees; a +// counts directly; an `is` / `in` test does not, since #1461 gave it an +// arm of its own that fires here and outside a predicate alike. A // comparison or boolean chain (`if (a == b)`, `if (a && b)`) is a nested // `binary_expression` already counted by the comparison-token and // `&&`/`||` walker arms, so it adds @@ -285,9 +287,9 @@ fn kotlin_enclosing_when_has_subject<'a>(entry: &Node<'a>, ancestors: Ancestors< // its condition already scored through the token arms, so // `when { x > 5 -> 1; x < 0 -> 2; else -> 0 }` reported 4 conditions // against a cyclomatic decision count of 2. The slot scores a bare -// terminal (`when { x -> … }`, `when { a is String -> … }`) directly and -// leaves a comparison or `&&` / `||` chain to the arms that already own -// it. Suppressing the operator instead would have been the wrong half to +// terminal (`when { x -> … }`) directly and leaves a comparison, an +// `is` / `in` test (#1461) or an `&&` / `||` chain to the arms that +// already own it. Suppressing the operator instead would have been the wrong half to // give way: a compound condition `when { a > 1 && b < 2 -> … }` needs // both its comparisons, and suppression collapses it to one. // @@ -417,8 +419,36 @@ impl Abc for KotlinCode { // Java / C# / C++ / Groovy already count both; Kotlin previously // counted only `CatchBlock`, so `try {} catch (e) {}` scored one // fewer condition here than in every sibling (#696). + // Kotlin's two relational forms with no usable operator + // token join them in #1461, scored by use rather than by + // slot. #1421 + // put them in `kotlin_bool_terminal_kinds!()`, which counts + // only inside a boolean slot, so `val b = a is String` + // scored zero where the `val b = a == c` beside it scored + // one — `EQEQ` is a token arm above and `is` was not. + // Fitzpatrick Rule 5 scores a relational operator wherever + // it is written. + // + // Matched as nodes rather than as tokens because neither + // construct has a token this arm could use: a bare `in` is + // also the `for (x in xs)` header's, and the negated + // spellings `!is` / `!in` are their own tokens again. One + // node covers every spelling of each. + // + // No double count with the `when` arms below (§5). A + // subject-ful entry pays its own condition, and `bca dump` + // shows its patterns spelled `range_test` / `type_test` — + // separate productions this arm never sees. A subject-less + // entry routes its condition through `kotlin_count_condition`, + // which since this change declines an `is` / `in` test the + // way it already declines a comparison, leaving it to here. + // + // They share the arm rather than sitting beside it because + // the arm's meaning is "this node is a condition, with + // nothing to gate on" — as true of a production as of a + // token. LTEQ | GTEQ | EQEQ | EQEQEQ | BANGEQ | BANGEQEQ | Try | CatchBlock | QMARKCOLON - | AsQMARK => { + | AsQMARK | IsExpression | InExpression => { stats.conditions += 1.; } // Phase-2B condition slot: the bare predicate of an diff --git a/src/metrics/abc/ruby.rs b/src/metrics/abc/ruby.rs index 99e3f06e..08e664b3 100644 --- a/src/metrics/abc/ruby.rs +++ b/src/metrics/abc/ruby.rs @@ -282,8 +282,27 @@ impl Abc for RubyCode { { stats.conditions += 1.; } + // Ruby 3.0's one-line pattern test (`a in Integer`) joins + // them in #1461, scored by use rather than by slot. It sat in + // `ruby_bool_terminal_kinds!()`, which counts only inside a + // boolean slot, so `b = a in Integer` scored zero where the + // `b = a == 1` beside it scored one — every comparison above + // is a token arm and `in` was not. Fitzpatrick Rule 5 scores + // a relational operator wherever it is written. + // + // Matched as the node rather than as the `in` token, which + // the language also spells in `for x in xs` and in the + // `in_clause` of a `case`/`in`. Those are separate + // productions (`bca dump`), so the `InClause` arm below + // never sees a `test_pattern` and exactly one arm fires per + // test (§5). + // + // It shares the arm rather than sitting beside it because + // the arm's meaning is "this node is a condition, with + // nothing to gate on" — as true of a production as of a + // token. Else | Elsif | When | QMARK | Rescue | RescueModifier | RescueModifier2 - | RescueModifier3 => { + | RescueModifier3 | TestPattern => { stats.conditions += 1.; } // A `case … in` pattern-match arm is a branch condition exactly diff --git a/tests/repositories/big-code-analysis-output b/tests/repositories/big-code-analysis-output index 42650711..062ae343 160000 --- a/tests/repositories/big-code-analysis-output +++ b/tests/repositories/big-code-analysis-output @@ -1 +1 @@ -Subproject commit 426507113bbc564404403213ad917549276d353f +Subproject commit 062ae3439ee405c20152a9845e8d7f27d994f632 From 61216676b3651b9f6d005a717c5c2778bd9f7534 Mon Sep 17 00:00:00 2001 From: Elijah Zupancic Date: Mon, 14 Sep 2026 19:58:10 -0700 Subject: [PATCH 11/25] fix(abc/elixir): make the match guard a condition slot A whole-branch review of the batch found that Elixir did not get the condition slot #1454 gave Java, Rust, Python, Ruby and C#. Its `when` arm added a flat one for the token on top of whatever the guard's sub-structure already scored, so `when n > 5` cost two where `when is_integer(n)` and `when n` cost one -- the spelling-dependence #1454 exists to remove, reproduced in one of the languages it fixed, and a grammar-dispatch section 5 double count with the `>` token arm. The guard expression now routes through `elixir_count_condition`, the sibling of `java_count_condition` / `ruby_count_condition`: a value-bearing guard scores in the slot, an operator-spelled one scores through the operator's own arm. All nine spellings the grammar allows land on one, and Elixir reads level with Java on every shape of the cross-language fixture. The typespec gate is untouched. Two arms the slot presumes existed did not, and both would have scored zero rather than one once the slot declined an operator: * `in` / `not in` are relational operators with no arm. #1461 moved exactly this class onto an unconditional arm in five languages and did not reach Elixir, so `a in b` scored zero against `a == b`'s one. They join the `<` / `>` parent gate, which already excludes an operator merely named (`&in/2`, `Kernel.in(a, b)`). * the keyword `not` was not recognised as a negation, only `!`, so `a && not b` scored one against `a && !b`'s two. `elixir_when_is_guard`'s doc called alternative guards left-associative; they are right-associative, the outermost holding the anchor. The conclusion it drew was right and is unchanged -- the construct scores one -- and `elixir_count_guard` peels the same nesting to reach the alternative that occupies the slot. Metric drift: Elixir abc.conditions falls by one per operator-spelled guard, and rises by one per `in` / `not in` and per `not`-negated boolean operand. Cyclomatic is unaffected. No corpus carries an Elixir file, and no integration snapshot moves; one anchored unit snapshot does. --- CHANGELOG.md | 30 ++- big-code-analysis-book/src/metrics.md | 2 +- src/metrics/abc.rs | 225 +++++++++++++++++- src/metrics/abc/elixir.rs | 138 +++++++++-- src/metrics/npa/shared.rs | 13 +- ...tests__elixir_guard_when_is_condition.snap | 8 +- 6 files changed, 371 insertions(+), 45 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 83ff2071..be9dc567 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -421,9 +421,17 @@ for historical reference. the decision but not the condition; Ruby's `if_guard` / `unless_guard` on a `case … in` arm had neither; Elixir is the inverse case, having counted the `when` token as a condition since #557 with no cyclomatic - arm behind it. The Elixir arm is gated on the guard's position, - because the language has no guard production — `x when g` is an - ordinary `binary_operator` — and a typespec's binding clause + arm behind it, and one that scored the `when` token flat rather than + through a slot, so `when n > 5` cost two where `when is_integer(n)` + cost one — the spelling-dependence this entry removes, reproduced in + the one language it was meant to fix. Elixir now routes the guard + expression through the same classifier its `&&` operands use, so all + of `when n > 5`, `when n == 5`, `when is_integer(n)`, `when n`, + `when (n)`, `when not n`, `when n in [1, 2]` and the multi-alternative + `when a when b` score exactly one. The Elixir arm is gated on the + guard's position, because the language has no guard production — + `x when g` is an ordinary `binary_operator` — and a typespec's + binding clause (`@spec f(a) :: a when a: integer`) spells the same token; that gate is shared by both metrics, so it also removes the condition the typespec used to score against no decision anywhere. The same fix @@ -440,10 +448,18 @@ for historical reference. `wmc` or `mi` threshold can newly fire on an unedited file carrying guarded arms. `abc.conditions` and `abc.magnitude` gain one per guard in Java, Rust, Python and Ruby for any guard not already - operator-shaped. Elixir `abc.conditions` is unchanged for real guards - and falls by one per typespec `when`. Integration snapshots move for - three `serde` files (Rust); no Python, Ruby, Java or Elixir corpus - file carries a guard. + operator-shaped. Elixir `abc.conditions` is unchanged for a guard that + was already scoring through its own operand, *falls* by one per + operator-spelled guard (`when n > 5`) and by one per typespec `when`. + Two Elixir arms move with the slot, because the slot presumes an + operator-spelled guard is owned by an operator arm and neither was: + `in` / `not in` become conditions wherever they are written, on the + Rule 5 grounds #1461 applied to the other five languages, so + `a in b` gains one and reads level with `a == b`; and the keyword + `not` now counts as a negation alongside `!` in a `&&` / `||` chain, + so `a && not b` gains one and reads level with `a && !b`. + Integration snapshots move for three `serde` files (Rust); no Python, + Ruby, Java or Elixir corpus file carries a guard. - **A C# `when` guard now counts as a decision in both cyclomatic complexity and ABC** (#1422). Cyclomatic had no arm for either guard diff --git a/big-code-analysis-book/src/metrics.md b/big-code-analysis-book/src/metrics.md index 8f79cc92..73d05f8a 100644 --- a/big-code-analysis-book/src/metrics.md +++ b/big-code-analysis-book/src/metrics.md @@ -155,7 +155,7 @@ application would over-count. | Kotlin | A subject-less `when` arm scores through the predicate slot; a subject-ful arm scores per entry | A subject-less arm's condition (`when { x > 5 -> … }`) is an ordinary boolean expression, compiled as an `if` predicate, so the comparison inside it is already counted by the operator arms and a blanket per-entry increment double-counted it. A subject-ful arm (`when (x) { in 1..2 -> … }`) lists a pattern rather than an independent boolean expression, so the implicit `subject == pattern` is a decision nothing in the source spells and the entry itself pays for it (#1421). | | C++, Objective-C, Mozcpp, Rust, Go, Java, Groovy, C#, Kotlin, TypeScript, TSX, JavaScript, Mozjs, Lua, Perl, Ruby, Bash, Elixir | A `<` or `>` that is not a comparison is not a condition | Every one of these grammars spells at least one non-comparison construct with the same bare `<` / `>` token a comparison uses, so the comparison rule is gated on the token's parent. What that excludes, per family: template and generic brackets in C++, Objective-C, Mozcpp, Rust, Go, Java, Groovy, C#, Kotlin, TypeScript and TSX (#1274); JSX tag delimiters in TypeScript, TSX, JavaScript and Mozjs; Lua 5.4 variable attributes (`local x = 1`); a C# comparison-operator overload's declared name (`operator <`); Kotlin's qualified super call (`super.g()`) (#1297); Perl's filehandle and lexical-handle readlines (``, `<$fh>` — but not ``, which the grammar lexes as one token); Ruby's superclass clause (`class Foo < Bar`) and comparison-operator method names (`def <(other)`), and Bash I/O redirection (`cmd > out`) (#1280); Elixir's sigil delimiters (`~s`) (#1256). C# additionally excludes the operator of a relational pattern (`x is > 0`, and the `> 5 =>` arm of a switch expression): the arm or `if` condition slot that owns the pattern already scores the decision, so counting the operator too charged a relational arm twice what the constant arm `5 => 1` scores (#1383). Its `>=` / `<=` spelling is excluded by the same rule through a separate token. The declared name of an operator overload is excluded on every spelling too: C# overloads six comparison operators and gives each a distinct token, so `operator <=`, `operator >=`, `operator ==` and `operator !=` join `operator <` and `operator >`, which alone were excluded before #1420. The exclusion is on the operator alone, not on the test that encloses it: since #1461 the `is` test itself is a condition wherever it is written, so `bool b = x is > 0;` and `return x is > 0;` each score 1 — level with the `x > 0` they are sugar for, and the same 1 a `when n is > 5` guard has scored since #1422 made the guard a condition slot. Counting the pattern's operator as well would make a relational arm worth twice the constant arm `5 => 1`. PHP, Python, Tcl and iRules emit a bare `<` / `>` from no non-comparison production; C carries the same gate as its C-family siblings although, having no templates, it has nothing to exclude. The gate is a claim about the grammar's productions, not about every parse: where a grammar resolves a generic *call* into nested `binary_expression` nodes, as tree-sitter-kotlin-ng does for `id(a)`, no polarity can exclude it (#1394). | | Java, Groovy, C#, TypeScript, TSX | A `?` used as type syntax is not a ternary | In each of these grammars the ternary `?` and the type-syntax `?` are the *same* anonymous token, so the ternary rule above is gated on the token's parent. Java and Groovy exclude the wildcard bound `List` (#1274); C# excludes the nullable type `int? x` and the constraint `where T : class?`; TypeScript and TSX exclude optional parameters, properties, methods, class fields and tuple elements, and conditional types (`T extends U ? X : Y`, which the type checker resolves and erases before runtime, so it is no more a branch than the `<` / `>` already excluded) (#1275). Safe navigation is untouched: C#'s `a?.b` shares the same token and still counts, while the other languages spell theirs as a distinct one. | -| C#, Java, Groovy, Kotlin, Ruby | A relational operator scores by use; a value-bearing operand scores in a boolean slot | These five grammars spell at least one relational construct as its own production rather than as a binary expression with an operator token: C#'s `x is int` and `x is null`, Java's and Groovy's `x instanceof T`, Groovy's `a in l`, Kotlin's `a is T` and `a in 1..2`, and Ruby's one-line `a in Integer`. Having no token to count, each was reached only through the language's terminal-operand set, which the walker consults inside an `if` / `while` / ternary / `&&`-operand slot and nowhere else — so `var b = x == 1;` scored 1 while `var b = x is int;` scored 0. Fitzpatrick's Rule 5 counts a relational operator wherever it appears, so each now counts wherever it appears and has left the operand set; being in both would score it twice. Groovy's spaceship `<=>` counts on the same rule, as it already did in Ruby, PHP, C++ and Mozcpp, although its result is an integer rather than a boolean — what Rule 5 measures is the comparison, not its type. The converse still holds for a construct whose *value* fills the slot: a cast, a Go type assertion, a Rust `matches!` / `cfg!` macro and a Python walrus are all operands and count only where a slot reads them as a predicate (#1461). | +| C#, Java, Groovy, Kotlin, Ruby, Elixir | A relational operator scores by use; a value-bearing operand scores in a boolean slot | Five of these grammars spell at least one relational construct as its own production rather than as a binary expression with an operator token: C#'s `x is int` and `x is null`, Java's and Groovy's `x instanceof T`, Groovy's `a in l`, Kotlin's `a is T` and `a in 1..2`, and Ruby's one-line `a in Integer`. Having no token to count, each was reached only through the language's terminal-operand set, which the walker consults inside an `if` / `while` / ternary / `&&`-operand slot and nowhere else — so `var b = x == 1;` scored 1 while `var b = x is int;` scored 0. Fitzpatrick's Rule 5 counts a relational operator wherever it appears, so each now counts wherever it appears and has left the operand set; being in both would score it twice. Groovy's spaceship `<=>` counts on the same rule, as it already did in Ruby, PHP, C++ and Mozcpp, although its result is an integer rather than a boolean — what Rule 5 measures is the comparison, not its type. Elixir reached the same 0 by the other route: its membership and type tests (`a in [1, 2]`, `rescue e in RuntimeError`) *do* carry an operator token, and simply had no arm matching it. They count by use on the same rule, gated on the token's parent so that an operator merely *named* (`&in/2`) stays excluded alongside `<` and `>`. The converse still holds for a construct whose *value* fills the slot: a cast, a Go type assertion, a Rust `matches!` / `cfg!` macro and a Python walrus are all operands and count only where a slot reads them as a predicate (#1461). | #### Worked example diff --git a/src/metrics/abc.rs b/src/metrics/abc.rs index 926de8de..59ba537e 100644 --- a/src/metrics/abc.rs +++ b/src/metrics/abc.rs @@ -9779,16 +9779,72 @@ function f(int $a, int $b): int { ); } - // Guard `when` clause counts as a condition. One `when` → +1. - // `def f(x) when x > 0` also has `>` → +1, totalling 2. + // #1461 moved every relational construct a grammar spells as its own + // production onto an unconditional arm — scored by use, Fitzpatrick + // Rule 5 — in C#, Java, Groovy, Kotlin and Ruby. Elixir's membership + // and type tests were the same class and were not in that sweep, so + // `a in b` scored zero where `a == b` scored one. The gap stayed + // invisible while the `when` arm paid a flat one for every guard, + // and surfaced the moment the guard became a slot: an + // operator-spelled guard is owned by its operator's arm, and `in` + // had none. + // + // `bare` / `negbare` are the by-use claim; `slot` is the §5 check + // that a boolean slot does not now score the same operator twice; + // `cmp` is the control the two membership rows are levelled against. + // `in` rides the `<` / `>` `binary_operator` gate rather than + // standing alone — it has the same three grammar positions — and + // `elixir_operator_identifier_is_not_a_condition` pins that gate. + #[test] + fn elixir_membership_is_a_condition_by_use() { + check_func_space::( + "defmodule Foo do\n\ + def bare(a, b), do: a in b\n\ + def negbare(a, b), do: a not in b\n\ + def slot(a, b) do\n\ + if a in b do\n\ + IO.puts(\"x\")\n\ + end\n\ + end\n\ + def cmp(a, b), do: a == b\n\ + end\n", + "foo.ex", + |space| { + assert_members_score( + &space.spaces[0], + &[ + // Each was 0 before the arm. + ("bare", 1, 1), + ("negbare", 1, 1), + // `if` (1) + `in` (1), exactly as `if a == b` + // scores. + ("slot", 2, 2), + ("cmp", 1, 1), + ], + ); + }, + ); + } + + // Guard `when` clause counts as a condition — exactly one, and here + // it is the `>` that supplies it. The guard is a condition *slot* + // (#1454), so an operator-spelled guard scores through the operator + // arm that owns it and the slot itself adds nothing; see + // `elixir_guard_is_a_decision_however_spelled` for the four-spelling + // agreement this is one row of. + // + // Was 2, on a flat `+1` for the `when` token laid on top of the `>`: + // the §5 double count a whole-branch review of the #1454 batch + // found. #[test] fn elixir_guard_when_is_condition() { check_metrics::( "defmodule Foo do\n def f(x) when x > 0 do\n :pos\n end\nend\n", "foo.ex", |metric| { - // when (+1) + > (+1) = 2 - assert_eq!(metric.abc.conditions_sum(), 2); + // the guard slot (+0, the guard is an operator + // application) + `>` (+1) = 1 + assert_eq!(metric.abc.conditions_sum(), 1); insta::assert_json_snapshot!(metric.abc); }, ); @@ -9875,6 +9931,33 @@ function f(int $a, int $b): int { ); } + // Elixir spells negation two ways and `elixir_inspect_container` + // recognised only `!`, so `a && not b` scored 3 where `a && !b` + // scored 4 — the `not` operand reached no terminal and vanished. + // Both forms now read the same. `not` is the stricter of the two (it + // raises on a non-boolean operand where `!` accepts any truthy + // value), so it is at least as good a proof that what it wraps is + // boolean, which is all the walker's flag claims. + // + // Both members in one fixture because the claim is that they agree: + // asserting either alone says nothing about the pair (§11), and a + // shared `check_metrics` total could not tell 3 + 4 from 4 + 3. + // + // `assert_members_score` rather than the parity-asserting sibling: + // a `&&` operand is an ABC condition with no cyclomatic decision + // behind it, so both members legitimately sit one above their + // decision count (`base 1 + if + &&` = 3). + #[test] + fn elixir_keyword_not_negates_like_bang() { + check_func_space::( + "defmodule Foo do\n def kw(a, b) do\n if a && not b do\n IO.puts(\"x\")\n end\n end\n def bang(a, b) do\n if a && !b do\n IO.puts(\"x\")\n end\n end\nend\n", + "foo.ex", + |space| { + assert_members_score(&space.spaces[0], &[("kw", 3, 3), ("bang", 3, 3)]); + }, + ); + } + #[test] fn elixir_comparison_operands_add_nothing() { // Isolation check: comparison operands of a `&&` chain are nested @@ -13488,10 +13571,14 @@ end // decision, which is why `tok` / `call` / `bare` move on the // cyclomatic axis here and on the ABC axis everywhere else. // - // `tok` sits one *above* its decision count because Elixir keeps the - // guard's sub-structure — `when n > 5` pays the `>` on top of the - // `when` — which is the same slot policy the other four follow and - // the reason `assert_members_score` does not assert §8 parity. + // Elixir was also the one language of the six that #1454 left + // *without* the slot. Its `when` arm added a flat one on top of + // whatever the guard's sub-structure already scored, so `tok` came + // back 3 against `call` and `bare`'s 2 — the spelling-dependence the + // batch exists to remove, and a `.claude/rules/grammar-dispatch.md` + // §5 double count with the `>` token arm. A whole-branch review of + // the batch found it; all three rows now read 2, level with the + // sibling fixtures above and with their own decision counts. #[test] fn elixir_guard_is_a_decision_however_spelled() { let src = "defmodule T do @@ -13538,8 +13625,9 @@ end &[ ("is_even", 1, 1), // All three guarded members were cyclomatic 2 — - // level with `none` — before the decision arm. - ("tok", 3, 3), + // level with `none` — before the decision arm, and + // `tok` was abc 3 before the slot. + ("tok", 2, 3), ("call", 2, 3), ("bare", 2, 3), ("none", 1, 2), @@ -13548,6 +13636,123 @@ end }); } + // The slot's whole claim, on every spelling the Elixir grammar gives + // a guard — and on `assert_every_member_scores`, which asserts §8 + // parity where the fixture above cannot (its unguarded control and + // its bare-comparison member legitimately sit off their decision + // counts, which is why that one takes a per-member table). + // + // Nine members, nine routes to the same 2: + // + // * `tok` / `eq` — the guard is an operator application, so the + // slot adds nothing and the `>` / `==` token arm supplies the + // one. These were 3 before the slot. + // * `call` / `bare` — `Call` and `Identifier` are + // `elixir_bool_terminal_kinds!()` members, so the slot supplies + // the one directly. + // * `paren` / `negated` — `block` and `unary_operator` are + // wrappers, peeled by `elixir_inspect_container`. `negated` + // covers the keyword `not`, which that walker did not recognise + // as a negation until this change and which would otherwise have + // scored zero here. + // * `membership` / `nonmembership` — `in` / `not in` are + // relational operators, given the by-use arm #1461 gave the + // other five languages. Without it these two would score zero, + // since the slot correctly declines an operator application. + // * `alternatives` — a multi-alternative guard is one guard, so + // `elixir_count_guard` peels the nested `when` and scores the + // last alternative once. + // + // §11: each bullet is an independent path, and no other member can + // stand in for it — deleting the `in` arm leaves `membership` and + // `nonmembership` alone failing, deleting the `not` recognition + // leaves `negated` alone failing. + #[test] + fn elixir_guard_scores_one_however_spelled() { + let src = "defmodule T do + def tok(x) do + case x do + n when n > 5 -> 1 + _ -> 0 + end + end + def eq(x) do + case x do + n when n == 5 -> 1 + _ -> 0 + end + end + def call(x) do + case x do + n when is_integer(n) -> 1 + _ -> 0 + end + end + def bare(x, b) do + case x do + _n when b -> 1 + _ -> 0 + end + end + def paren(x, b) do + case x do + _n when (b) -> 1 + _ -> 0 + end + end + def negated(x, b) do + case x do + _n when not b -> 1 + _ -> 0 + end + end + def membership(x) do + case x do + n when n in [1, 2] -> 1 + _ -> 0 + end + end + def nonmembership(x) do + case x do + n when n not in [1, 2] -> 1 + _ -> 0 + end + end + def alternatives(x) do + case x do + n when is_integer(n) when is_float(n) -> 1 + _ -> 0 + end + end +end +"; + assert_fixture_spells::( + src, + "foo.ex", + &[ + ( + Elixir::When as u16, + 10, + "`when` guards, `alternatives`'s two included", + ), + (Elixir::GT as u16, 1, "`tok`'s `>`"), + (Elixir::EQEQ as u16, 1, "`eq`'s `==`"), + (Elixir::Block as u16, 1, "`paren`'s parenthesised guard"), + (Elixir::Not as u16, 1, "`negated`'s `not`"), + (Elixir::In as u16, 1, "`membership`'s `in`"), + (Elixir::Notin as u16, 1, "`nonmembership`'s `not in`"), + ], + ); + check_func_space::(src, "foo.ex", |space| { + assert_every_member_scores( + &space.spaces[0], + 9, + 2, + "one for the `case` arm and one for the guard, however the guard is spelled", + ); + }); + } + // The gate that makes the Elixir arm safe. Elixir has no dedicated // guard production, and a typespec's binding clause spells the same // `when` token — so an ungated arm would have made type syntax a diff --git a/src/metrics/abc/elixir.rs b/src/metrics/abc/elixir.rs index 4647131f..6f95cd7e 100644 --- a/src/metrics/abc/elixir.rs +++ b/src/metrics/abc/elixir.rs @@ -45,8 +45,17 @@ fn elixir_inspect_container(container_node: &Node, parent: &Node, conditions: &m loop { let is_block = matches!(node_kind, E::Block); + // Both of Elixir's negations, not just `!`. The keyword `not` is + // the stricter of the two — it raises on a non-boolean operand, + // where `!` accepts any truthy value — so it is at least as good + // a proof that what it wraps is boolean. Listing only `BANG` + // scored `a && not b` one condition against `a && !b`'s two, and + // would have dropped `when not is_nil(y)` to zero once the guard + // became a slot below. let is_not = matches!(node_kind, E::UnaryOperator) - && node.child(0).is_some_and(|c| c.kind_id() == E::BANG as u16); + && node + .child(0) + .is_some_and(|c| matches!(c.kind_id().into(), E::BANG | E::Not)); if !is_block && !is_not { break; @@ -76,6 +85,55 @@ fn elixir_inspect_container(container_node: &Node, parent: &Node, conditions: &m } } +// The Elixir sibling of `java_count_condition` / `ruby_count_condition`: +// classifies one boolean *slot* — a position whose occupant is evaluated +// for truth — and adds at most one condition for it. +// +// A slot adds nothing for an operator-spelled occupant: `y > 5` is a +// `binary_operator`, absent from `elixir_bool_terminal_kinds!()`, and +// the `>` token arm in `compute` already owns that one. Counting it here +// too is the `.claude/rules/grammar-dispatch.md` §5 double count, and is +// what made an Elixir guard's score depend on its spelling. `Block` +// (`(y)`) and `UnaryOperator` (`!y`, `not y`) are wrappers rather than +// occupants, so they are peeled by `elixir_inspect_container`. +fn elixir_count_condition(condition: &Node, parent: &Node, conditions: &mut f64) { + use Elixir as E; + + let kind = condition.kind_id().into(); + if matches!(kind, elixir_bool_terminal_kinds!()) { + *conditions += 1.; + } else if matches!(kind, E::Block | E::UnaryOperator) { + elixir_inspect_container(condition, parent, conditions); + } +} + +// The guard slot of an anchored `when` operator, whose `right` field +// holds the guard. +// +// Alternative guards (`when a when b`, valid but rare) nest +// right-associatively, so an anchored operator's `right` can be a +// further `when`. The construct is one guard however many alternatives +// it lists — the contract `elixir_when_is_guard` publishes, and what +// the matching `Cyclomatic` arm counts — so the nesting is peeled and +// the last alternative occupies the single slot. Each step descends one +// level, so the walk is bounded by the guard's nesting depth. +fn elixir_count_guard(when_operator: &Node, conditions: &mut f64) { + use Elixir as E; + + let mut operator = *when_operator; + while let Some(right) = operator.child_by_field_name("right") { + let nests_another_alternative = right.kind() == BINARY_OPERATOR + && right + .child_by_field_name("operator") + .is_some_and(|op| op.kind_id() == E::When as u16); + if !nests_another_alternative { + elixir_count_condition(&right, &operator, conditions); + return; + } + operator = right; + } +} + // Counts each non-comparison operand of an Elixir `&&` / `||` chain once. // Comparison operands are nested `binary_operator` nodes (absent from // `elixir_bool_terminal_kinds!()`) and so contribute nothing. @@ -230,23 +288,67 @@ impl Abc for ElixirCode { stats.conditions += 1.; } // Guard `when` token: introduces the guard clause of a - // function head or `case` / `fn` / `receive` arm. One - // condition per guard, whatever the guard spells, with its - // sub-structure (`when x > 2` also pays the `>`) left to the - // arms that own it — the condition-slot model #1422 gave C#, - // which Elixir already had here. + // function head or `case` / `fn` / `receive` arm. The guard + // is a condition *slot*, so every spelling contributes + // exactly one — the condition-slot model #1422 gave C# and + // #1454 gave Java, Rust, Python and Ruby. + // + // Elixir was the one language of the six that did not get + // the slot. It added a flat one for the `when` token *on top + // of* whatever the guard's sub-structure already scored, so + // `when y > 5` cost two where `when is_integer(y)` and + // `when y` cost one — precisely the spelling-dependence the + // slot exists to remove, and a §5 double count with the `>` + // token arm below. Routing the guard expression through + // `elixir_count_condition` puts all four spellings at one: + // a value-bearing guard scores in the slot, an + // operator-spelled one scores through the operator's own + // arm. + // + // By grammar FIELD, not index (grammar-dispatch §3): Elixir + // has no `guard` production — `when` is a `binary_operator` + // whose `left` is the head being guarded and whose `right` + // is the guard — so a comment between the two cannot shift + // the read. That the operator *is* a `binary_operator` is + // also why `elixir_inspect_container` needs no new + // `has_boolean_content` seed for this slot the way its Java, + // Rust and Ruby siblings did: the seed list already opens + // with the three `BinaryOperator` aliases, so `when (y)` and + // `when !y` are proven boolean for free. // - // What it lacked was the gate, added with #1454 and shared - // with the `Cyclomatic` impl that gained the matching - // decision (grammar-dispatch §7). Elixir has no dedicated - // guard production, and a typespec's binding clause - // (`@spec f(a) :: a when a: integer`) spells the same token: - // it scored a condition here against no decision anywhere, - // on type syntax that branches on nothing. + // The gate is #1454's, shared with the `Cyclomatic` impl + // that carries the matching decision (§7). Elixir has no + // dedicated guard production, and a typespec's binding + // clause (`@spec f(a) :: a when a: integer`) spells the same + // token: it scored a condition here against no decision + // anywhere, on type syntax that branches on nothing. E::When if npa::elixir_when_is_guard(node, code, ancestors) => { - stats.conditions += 1.; + if let Some(operator) = ancestors.parent(node) { + elixir_count_guard(&operator, &mut stats.conditions); + } } - // Counts `<` / `>` only as the operator token of a + // `in` / `not in` are Elixir's membership and type tests + // (`x in [1, 2]`, `rescue e in RuntimeError`) — relational + // operators, which Fitzpatrick Rule 5 scores by use. #1461 + // moved exactly this class onto an unconditional arm in C#, + // Java, Groovy, Kotlin and Ruby but did not reach Elixir, so + // `x in y` scored zero where `x == y` scored one. The gap + // stayed invisible while the `when` arm above paid a flat + // one for every guard; as a slot, `when x in [1, 2]` would + // have fallen to zero, so the arm the slot model presumes — + // every operator-spelled guard is owned by an operator arm — + // has to exist for `in` as it already does for `>`. + // + // Sharing the `<` / `>` gate rather than standing alone + // because `in` has the same three grammar positions: a + // `grammar.json` sweep of the pinned tree-sitter-elixir + // finds it in `binary_operator`, in `operator_identifier` + // (`&in/2`) and in `_remote_dot` (`Kernel.in(a, b)`), the + // last two being how an operator is *named* rather than + // applied. `not in` lexes as one token and so has no inner + // `not` leaf to double count (§5). + // + // Counts all four only as the operator token of a // `binary_operator`, the allowlist polarity the rest of the // workspace moved to in #1274 and #1297. The previous // denylist excluded a sigil delimiter (`~s`, #1256) and @@ -271,9 +373,9 @@ impl Abc for ElixirCode { // (`.claude/rules/grammar-dispatch.md` §1, the same call // `QUOTED_CONTENT` makes in `src/metrics/loc/elixir.rs`). // The runtime cost that trade buys there is not paid here: - // the guard runs only for a `<` or `>` token, not for every - // node. - E::LT | E::GT + // the guard runs only for one of these four tokens, not for + // every node. + E::LT | E::GT | E::In | E::Notin if ancestors .parent(node) .is_some_and(|parent| parent.kind() == BINARY_OPERATOR) => diff --git a/src/metrics/npa/shared.rs b/src/metrics/npa/shared.rs index 69c8c5da..553775a8 100644 --- a/src/metrics/npa/shared.rs +++ b/src/metrics/npa/shared.rs @@ -677,11 +677,14 @@ pub(crate) fn ruby_in_clause_counts(in_clause: &Node, source: &[u8]) -> bool { /// than by enumerating ids (grammar-dispatch §1). /// /// Alternative guards (`when a when b`, valid but rare) parse -/// left-associatively into nested `when` operators, and only the -/// outermost reaches an anchor: the construct scores one, the same as -/// the single-alternative spelling. That is the slot model — the guard -/// is one decision however many alternatives it lists — and it is what -/// `ancestors` can answer in O(1) steps. +/// right-associatively into nested `when` operators — the *outermost* +/// is the one holding the anchor, and each further alternative hangs +/// off its predecessor's `right`. Only that outermost one is a guard +/// here: the construct scores one, the same as the single-alternative +/// spelling. That is the slot model — the guard is one decision however +/// many alternatives it lists — and it is what `ancestors` can answer +/// in O(1) steps. `abc::elixir_count_guard` peels the same nesting to +/// reach the alternative that occupies the slot. pub(crate) fn elixir_when_is_guard<'a>( node: &Node<'a>, code: &'a [u8], diff --git a/src/metrics/snapshots/big_code_analysis__metrics__abc__tests__elixir_guard_when_is_condition.snap b/src/metrics/snapshots/big_code_analysis__metrics__abc__tests__elixir_guard_when_is_condition.snap index f97243d9..33ce8b32 100644 --- a/src/metrics/snapshots/big_code_analysis__metrics__abc__tests__elixir_guard_when_is_condition.snap +++ b/src/metrics/snapshots/big_code_analysis__metrics__abc__tests__elixir_guard_when_is_condition.snap @@ -5,16 +5,16 @@ expression: metric.abc { "assignments": 0, "branches": 1, - "conditions": 2, - "magnitude": 2.23606797749979, + "conditions": 1, + "magnitude": 1.4142135623730951, "value": 0.0, "assignments_average": 0.0, "branches_average": 0.3333333333333333, - "conditions_average": 0.6666666666666666, + "conditions_average": 0.3333333333333333, "assignments_min": 0, "assignments_max": 0, "branches_min": 0, "branches_max": 1, "conditions_min": 0, - "conditions_max": 2 + "conditions_max": 1 } From a9100f3b69c566498274e1910d8d73be92cf6414 Mon Sep 17 00:00:00 2001 From: Elijah Zupancic Date: Mon, 14 Sep 2026 20:22:07 -0700 Subject: [PATCH 12/25] fix(abc): extend the literal sweep to Perl, Ruby and Elixir A whole-branch review of the batch found #1462's sweep incomplete while its own comment called it complete. It added strings, `null` and collection literals to eight sets and disclaimed only C#/Java/Kotlin/ Rust/Go (a bare literal is a compile error there) and the C family (deferred for corpus exposure). Perl, Ruby and Elixir are truthy-valued, appeared in neither list, and already carried `False` / `Nil` -- the precedent that same comment cites -- yet none got a string, collection or symbol literal. All three reproduced the gap the CHANGELOG describes: `a && "s"` scored 1 against `a && b`'s 2, and `if ("s")` 0 against `if (b)`'s 1. Each kind was measured a condition short of an identifier control in both walker paths before being listed, with its id read off `bca dump`: * Perl: the four string productions the grammar keeps separate, `heredoc_initializer`, `qx()` and backticks, `qw()` / `[...]` / `{...}`, `qr//`, and `special_literal`. `s///` and `tr///` stay out -- they edit `$_` and yield a count, so they are operations, not literals. `qr//` had been deferred beside them as "always true", which is the argument for counting it once the question became whether a literal fills the slot. * Ruby: `string`, `chained_string`, `heredoc_beginning`, `subshell`, `array` / `hash` / `%w[]` / `%i[]`, `regex`, `?a`, and both symbol productions. * Elixir: `string`, `charlist`, `sigil`, `quoted_atom` -- a separate production from `atom`, so `:"q a"` scored 0 where `:atom` scored 1 -- and `list` / `tuple` / `map` / `bitstring`. Tcl and iRules are the remaining truthy-valued languages and needed nothing: `quoted_word`, `braced_word_simple` and `number` already cover every literal an `expr {...}` operand can hold, measured rather than assumed. `literal_bool_operands` gains a row per language and the three features join its `cfg(any(...))` union, so a Perl-, Ruby- or Elixir-only build no longer compiles the module away and silently asserts nothing about them. A Perl or Ruby heredoc cannot live in that table -- its body follows the statement, so substituting one into a single-line template yields an unterminated literal -- and has its own test. The completeness claim in `kind_sets.rs` and the CHANGELOG now say eleven languages and name what each set gained. The Groovy back-reference that #1462 left pointing at a list it had emptied is corrected too. Metric drift: abc.conditions rises by one per non-numeric literal in a boolean operand slot in the three languages. Cyclomatic is unaffected. No corpus carries a .pl, .rb or .ex file and no integration snapshot moves. `kind_sets.rs` crosses the loc.sloc backstop on documentation volume alone (ploc 468 against a limit of 550) and takes a baseline entry. --- .bca-baseline.toml | 6 + CHANGELOG.md | 39 +++- big-code-analysis-ast/src/macros/kind_sets.rs | 139 +++++++++++- src/metrics/abc.rs | 199 +++++++++++++++++- 4 files changed, 355 insertions(+), 28 deletions(-) diff --git a/.bca-baseline.toml b/.bca-baseline.toml index 211beade..8169e0da 100644 --- a/.bca-baseline.toml +++ b/.bca-baseline.toml @@ -263,6 +263,12 @@ qualified = "RubyCode::get_op_type" metric = "halstead.effort" value = 120805.48245794396 +[[entry]] +path = "big-code-analysis-ast/src/macros/kind_sets.rs" +qualified = "" +metric = "loc.sloc" +value = 1357.0 + [[entry]] path = "big-code-analysis-ast/src/node.rs" qualified = "Node<'a>" diff --git a/CHANGELOG.md b/CHANGELOG.md index be9dc567..af3b2dfe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -186,7 +186,7 @@ for historical reference. - **A non-numeric literal in a boolean operand slot scored no ABC condition** (#1462). `x || "default"` reported `abc.conditions` 1 against `x || y`'s 2, and `if ("s")` reported 0 against `if (b)`'s 1, - in all eight truthy-valued languages. #1410 had closed the same gap + in all eleven truthy-valued languages. #1410 had closed the same gap for numeric literals; the non-numeric ones were never swept and were missing from every set the numerics were added to. Every kind below was measured a condition short of an identifier control in *both* @@ -211,6 +211,23 @@ for historical reference. the other three scored one. - **Groovy**: `string_literal` (which also covers the slashy `/re/`), `null_literal`, `list_literal`, `map_literal`. + - **Perl**: the four string productions the grammar keeps separate + (`'s'`, `q()`, `"s"`, `qq()`), `heredoc_initializer`, the two + command substitutions `qx()` and backticks, the `qw()` / `[…]` / + `{…}` collection literals, `qr//`, and `special_literal` + (`__FILE__` and its three siblings). `qr//` had been deferred as + "a compiled-pattern object that is always true", which is the + argument *for* counting it once the question became whether a + literal fills the slot; `s///` and `tr///` stay out, being + operations on `$_` rather than literals. + - **Ruby**: `string` (covering `"s"`, `'s'`, `%q()` and `%Q()`), + `chained_string`, `heredoc_beginning`, `subshell`, the four + collection literals `array` / `hash` / `%w[]` / `%i[]`, `regex`, + the one-character `?a`, and both symbol productions. + - **Elixir**: `string` (one kind for `"s"` and the `"""` heredoc), + `charlist`, `sigil`, `quoted_atom` — a separate production from + `atom`, so `:"q a"` scored zero while `:atom` scored one — and the + four collection literals `list` / `tuple` / `map` / `bitstring`. A type keyword that renders to the same node-kind name as its literal stays out, extending the rule PHP's `float` keyword established: @@ -221,14 +238,18 @@ for historical reference. The C family carries the same gap for `string_literal` and is deliberately deferred: it is the one integer-truthy group with integration-corpus exposure, so its snapshot delta wants its own - change. **Metric drift:** `abc.conditions`, `abc.magnitude` and - `abc.value` rise by one per non-numeric literal operand in a boolean - slot, in the eight languages listed; `abc` is a gated threshold - metric. Cyclomatic is unaffected. 85 of the 384 pdf.js JavaScript - integration snapshots move, all in the `conditions` family and all - upward; no other corpus moves, the DeepSpeech tree being entirely - C/C++ and the six-file PHP corpus carrying no literal in a boolean - slot. + change. Tcl and iRules are the remaining truthy-valued languages and + needed nothing: `quoted_word`, `braced_word_simple` and `number` + already cover every literal an `expr {…}` operand can hold, verified + by measurement rather than assumed. **Metric drift:** + `abc.conditions`, `abc.magnitude` and `abc.value` rise by one per + non-numeric literal operand in a boolean slot, in the eleven + languages listed; `abc` is a gated threshold metric. Cyclomatic is + unaffected. 85 of the 384 pdf.js JavaScript integration snapshots + move, all in the `conditions` family and all upward; no other corpus + moves, the DeepSpeech tree being entirely C/C++, the six-file PHP + corpus carrying no literal in a boolean slot, and no corpus carrying + a `.pl`, `.rb` or `.ex` file at all. - **Perl ABC scored statement-modifier conditions zero** (#1464). `return 1 if $x;` reported `abc.conditions` 0 where the block form diff --git a/big-code-analysis-ast/src/macros/kind_sets.rs b/big-code-analysis-ast/src/macros/kind_sets.rs index 42862d54..19a3ed93 100644 --- a/big-code-analysis-ast/src/macros/kind_sets.rs +++ b/big-code-analysis-ast/src/macros/kind_sets.rs @@ -190,7 +190,8 @@ macro_rules! java_bool_terminal_kinds { // `object_creation_expression`, which #1462 measured short and left // alone because `new Foo()` is not a literal. // -// Four of that list moved into the set in #1462: `string_literal`, +// Four more `_expression` alternatives moved into the set in #1462 and +// so are no longer named in the paragraph above: `string_literal`, // `null_literal`, `list_literal` and `map_literal`. An earlier revision // of this comment excluded them as "constant or degenerate … and none // has a sibling-language precedent", and both halves of that stopped @@ -680,10 +681,28 @@ macro_rules! python_bool_terminal_kinds { // numeric kind: a bare number in a boolean slot is a compile error in // those five, so there is nothing to count. **That reasoning extends to // every other literal kind**, which is why #1462 left all five alone -// while adding strings, `null`, and collection literals to the eight +// while adding strings, `null`, and collection literals to the // truthy-valued sets: `if ("s")` and `if (null)` are compile errors in // the same five for the same reason `if (1)` is. // +// #1462 landed that sweep across eight sets — the four JS-family ones +// plus Python, Lua, PHP and Groovy — and its own comment called that +// the complete truthy-valued set. It was not: **Perl, Ruby and Elixir** +// are truthy-valued too, appeared in neither the swept list nor the +// compile-error exemption, and already carried `False` / `Nil` — the +// very precedent the paragraph below cites. A whole-branch review of +// the batch measured the gap the CHANGELOG describes in all three: +// `$a && "s"` scored 1 against `$a && $b`'s 2, and `if ("s")` scored 0 +// against `if ($b)`'s 1. The three sets were swept on the same terms +// and each names its additions below. +// +// Tcl and iRules are the remaining truthy-valued languages and needed +// nothing: their `quoted_word`, `braced_word_simple` and `number` kinds +// already cover every literal spelling an `expr {…}` operand can take +// (measured, not assumed). A bare `simple_word` still scores zero, +// which is correct — an unquoted bareword is not a literal in `expr`, +// it is a syntax error. +// // #1462 is also where the *value* of the literal stopped being the // question. Every set here has listed `False` since #403 and several // list `Nil` / `Null`, so the rule these sets encode is already "a @@ -731,12 +750,35 @@ macro_rules! python_bool_terminal_kinds { // (`perl_count_unary_conditions`), so the pattern node is never // reached alongside its own `=~`. // -// Three further rules in the same grammar family are deliberately -// absent because whether they are boolean *tests* is a judgement call, -// not a dispatch gap: `substitution_pattern_s` (`s///`) and -// `transliteration_tr_or_y` (`tr///`) each evaluate to a count rather -// than a bool, and `regex_pattern_qr` (`qr//`) to a compiled-pattern -// object that is always true. All three measure zero conditions today. +// `substitution_pattern_s` (`s///`) and `transliteration_tr_or_y` +// (`tr///`) are deliberately absent: both edit `$_` and evaluate to a +// count, so they are operations rather than literals, and both measure +// zero today. `regex_pattern_qr` (`qr//`) sat beside them until #1462's +// sweep reached Perl, deferred as "a compiled-pattern object that is +// always true" — which is the argument *for* counting it once the +// question became whether a literal fills the slot rather than whether +// it is a boolean test. It is the JavaScript `regex` / Groovy slashy +// literal by another spelling, so it counts. +// +// The literal kinds that sweep added, each measured a condition short +// of a `$b` control in *both* the `&&`-chain and the `if` predicate +// slot, with every id read off `bca dump`: the four string productions +// the grammar keeps separate — `string_single_quoted` (329), +// `string_q_quoted` (330), `string_double_quoted` (331), +// `string_qq_quoted` (332), so listing one would have closed a quarter +// of the gap; `heredoc_initializer` (216), the `<<"EOT"` token that +// occupies the slot while the body is a statement node the walker never +// reaches (so no §5 double count); `command_qx_quoted` (333) and +// `backtick_quoted` (334), on PHP's `shell_command_expression` +// precedent; the collection literals `word_list_qw` (335), `array_ref` +// (357) and `hash_ref` (358), `(1, 2)` (`array`, 356) having already +// scored; `regex_pattern_qr` (338) per above; and `special_literal` +// (220) — `__FILE__` / `__LINE__` / `__PACKAGE__` / `__SUB__`, constants +// standing where a value stands. That rule also spells `__END__` / +// `__DATA__`, which end the compilation unit and so reach no operand +// slot to be excluded from. None of the twelve has a numeric-suffix +// alias in tree-sitter-perl 1.1.2 (§1, swept over the whole enum) and +// every one was observed emitted (§2). #[macro_export] #[doc(hidden)] macro_rules! perl_bool_terminal_kinds { @@ -765,6 +807,18 @@ macro_rules! perl_bool_terminal_kinds { | $crate::Perl::MethodInvocation | $crate::Perl::PatternMatcher | $crate::Perl::PatternMatcherM + | $crate::Perl::StringSingleQuoted + | $crate::Perl::StringQQuoted + | $crate::Perl::StringDoubleQuoted + | $crate::Perl::StringQqQuoted + | $crate::Perl::HeredocInitializer + | $crate::Perl::CommandQxQuoted + | $crate::Perl::BacktickQuoted + | $crate::Perl::WordListQw + | $crate::Perl::ArrayRef + | $crate::Perl::HashRef + | $crate::Perl::RegexPatternQr + | $crate::Perl::SpecialLiteral }; } @@ -1139,6 +1193,32 @@ macro_rules! kotlin_bool_terminal_kinds { // here and must not be added: that spelling raises `NoMatchingPattern` // on failure rather than yielding a boolean, so it is a destructuring // assignment, not a condition. +// +// The literal kinds #1462's sweep added, each measured a condition +// short of a `b` control in *both* the `&&`-chain and the `if` +// predicate slot, with every id read off `bca dump`: `string` (314), +// which covers `"s"`, `'s'`, `%q()` and `%Q()` alike; `chained_string` +// (312), the adjacent-literal concatenation `"a" "b"`, a sibling rule +// rather than an alias and so invisible to an alias sweep; +// `heredoc_beginning` (142), the `<<~TXT` token that occupies the slot +// while `heredoc_body` is a separate node the walker never reaches (so +// no §5 double count); the collection literals `array` (322), `hash` +// (323), `string_array` (316, `%w[]`) and `symbol_array` (317, `%i[]`); +// `regex` (319), which in a predicate is additionally an implicit match +// against `$_`; `subshell` (315), `` `ls` `` and `%x{}`, on PHP's +// `shell_command_expression` precedent; `character` (123), the +// one-character literal `?a`, the counterpart of the `Char` Elixir's +// set already named; and the two symbol productions `simple_symbol` +// (130) and `delimited_symbol` (318), which are to Ruby what `atom` is +// to Elixir. None has a numeric-suffix alias in tree-sitter-ruby 0.23.1 +// (§1) and every one was observed emitted (§2). +// +// `Nil2` (22) is **not** here and must not be added. `nil` parses as a +// `nil` *wrapper* (309, listed) around a `nil` keyword token (22), so +// listing both would score the literal twice (§5); the same shape holds +// for Elixir's `Nil` / `Nil2` below. `lambda` (325, `->{}`) and the +// range productions are absent as well — a closure and a range are not +// literals in the class this sweep covers. #[macro_export] #[doc(hidden)] macro_rules! ruby_bool_terminal_kinds { @@ -1160,6 +1240,18 @@ macro_rules! ruby_bool_terminal_kinds { | $crate::Ruby::Float | $crate::Ruby::Rational | $crate::Ruby::Complex + | $crate::Ruby::String + | $crate::Ruby::ChainedString + | $crate::Ruby::HeredocBeginning + | $crate::Ruby::Subshell + | $crate::Ruby::Array + | $crate::Ruby::Hash + | $crate::Ruby::StringArray + | $crate::Ruby::SymbolArray + | $crate::Ruby::Regex + | $crate::Ruby::Character + | $crate::Ruby::SimpleSymbol + | $crate::Ruby::DelimitedSymbol }; } @@ -1187,6 +1279,29 @@ macro_rules! ruby_bool_terminal_kinds { // string; `x && ?a` scored 1 against `x && b`'s 2 until it was listed. // Radix prefixes (`0x`, `0o`, `0b`) fold into `integer`, verified by // measurement. +// +// The non-numeric literal kinds #1462's sweep added, each measured a +// condition short of a `b` control in the `&&`-chain slot, with every +// id read off `bca dump`: `string` (153), one kind for the `"s"` and +// the `"""` heredoc spelling alike; `charlist` (154); the collection +// literals `list` (162), `tuple` (163), `map` (165) and `bitstring` +// (164), `map` also being what `%Foo{}` parses to (`struct`, 166, is +// its child, so listing `map` alone is right and cannot double count, +// §5); `sigil` (156), one kind for `~r//`, `~s()` and `~w()` alike; and +// `quoted_atom` (132), which is a **separate production** from `atom` +// (14) rather than an alias of it, so `:"quoted atom"` scored zero +// while `:atom` scored one. Elixir has no bare-truthy `if` predicate +// slot, so only the chain slot moves. None of the seven has a +// numeric-suffix alias in tree-sitter-elixir 0.3.5 (§1) and every one +// was observed emitted (§2). +// +// `Nil2` (13) and `Atom2` (131) are **not** here and must not be added. +// `nil` parses as a `nil` wrapper (130, listed) around a `nil` keyword +// token (13), so listing both would score the literal twice (§5), and +// `Atom2` is unreachable at this pin — `:atom` is `Atom` (14) and +// `:"q a"` is `quoted_atom`. The closures (`anonymous_function`, 203) +// and captures (`&Foo.bar/1`, a `unary_operator`) are absent on the +// same rule as Ruby's `lambda`: not literals. #[macro_export] #[doc(hidden)] macro_rules! elixir_bool_terminal_kinds { @@ -1207,6 +1322,14 @@ macro_rules! elixir_bool_terminal_kinds { | $crate::Elixir::Float | $crate::Elixir::Char | $crate::Elixir::AccessCall + | $crate::Elixir::String + | $crate::Elixir::Charlist + | $crate::Elixir::Sigil + | $crate::Elixir::QuotedAtom + | $crate::Elixir::List + | $crate::Elixir::Tuple + | $crate::Elixir::Map + | $crate::Elixir::Bitstring }; } diff --git a/src/metrics/abc.rs b/src/metrics/abc.rs index 59ba537e..8b0c9f41 100644 --- a/src/metrics/abc.rs +++ b/src/metrics/abc.rs @@ -15491,13 +15491,22 @@ mod perl_statement_modifier_parity { /// slots: `x || "default"` — the language's commonest truthy-default /// idiom — reported 1 against `x || y`'s 2. /// -/// Scope is the eight truthy-valued sets. C#, Java, Kotlin, Rust and Go -/// name no literal kind at all and stay that way: a bare literal in a -/// boolean slot is a compile error there, so there is nothing to count. -/// The C family is integer-truthy and does carry the same gap for -/// `string_literal`, but it is the one group in that class with -/// integration-corpus exposure, so it is deferred rather than decided -/// (see `cpp_bool_terminal_kinds!`). +/// Scope is the eleven truthy-valued sets. #1462 shipped eight of them +/// and called that complete; a whole-branch review of the batch found +/// Perl, Ruby and Elixir in neither the swept list nor the +/// compile-error exemption, each already carrying `False` / `Nil`, and +/// each reproducing the gap exactly. They were swept on the same terms +/// and are rows here. Tcl and iRules are truthy too and needed nothing: +/// their `quoted_word` / `braced_word_simple` / `number` kinds already +/// cover every literal an `expr {…}` operand can hold, so they have no +/// row rather than a vacuous one. +/// +/// C#, Java, Kotlin, Rust and Go name no literal kind at all and stay +/// that way: a bare literal in a boolean slot is a compile error there, +/// so there is nothing to count. The C family is integer-truthy and +/// does carry the same gap for `string_literal`, but it is the one +/// group in that class with integration-corpus exposure, so it is +/// deferred rather than decided (see `cpp_bool_terminal_kinds!`). /// /// Three things each row pins that a conditions comparison alone /// cannot: @@ -15532,7 +15541,10 @@ mod perl_statement_modifier_parity { feature = "python", feature = "lua", feature = "php", - feature = "groovy" + feature = "groovy", + feature = "perl", + feature = "ruby", + feature = "elixir" ))] mod literal_bool_operands { use crate::test_support::{assert_fixture_spells, metrics_verbatim}; @@ -15620,6 +15632,12 @@ mod literal_bool_operands { LANG::Groovy => { assert_fixture_spells::(source, "f.groovy", kinds); } + #[cfg(feature = "perl")] + LANG::Perl => assert_fixture_spells::(source, "f.pl", kinds), + #[cfg(feature = "ruby")] + LANG::Ruby => assert_fixture_spells::(source, "f.rb", kinds), + #[cfg(feature = "elixir")] + LANG::Elixir => assert_fixture_spells::(source, "f.ex", kinds), other => panic!("{other:?} has a case row but no parser arm"), } } @@ -15649,10 +15667,41 @@ mod literal_bool_operands { /// Java / C# / Groovy / PHP group that named no cast kind. /// - Groovy: `string_literal` (which also covers the slashy `/re/`), /// `null_literal`, `list_literal`, `map_literal`. + /// - Perl: the four string productions the grammar keeps separate + /// (`'s'`, `q()`, `"s"`, `qq()`), the two command substitutions + /// (`qx()`, backticks), the `qw()` / `[…]` / `{…}` collection + /// literals, `qr//`, and `special_literal` (`__FILE__`). + /// `heredoc_initializer` is the twelfth kind and cannot live in + /// this table — a Perl heredoc body follows the *statement*, not + /// the operand — so it has its own test below. + /// - Ruby: `string` (covering `"s"`, `'s'`, `%q()`, `%Q()`), + /// `chained_string` (the adjacent-literal join `"a" "b"`, a + /// sibling rule an alias sweep cannot see), `subshell`, the four + /// collection literals `array` / `hash` / `%w[]` / `%i[]`, + /// `regex`, the one-character `?a`, and both symbol productions. + /// `heredoc_beginning` is the twelfth and shares the Perl heredoc + /// test for the same reason. + /// - Elixir: `string` (one kind for `"s"` and the `"""` heredoc), + /// `charlist`, `sigil` (one kind for `~r//`, `~s()`, `~w()`), + /// `quoted_atom` — a separate production from `atom`, so + /// `:"q a"` scored zero while `:atom` scored one — and the four + /// collection literals `list` / `tuple` / `map` / `bitstring`. + /// + /// Elixir is the one row whose two slots are not two independent + /// consumers: the language has no bare-truthy `if` predicate slot + /// to route, since an Elixir `if` is a keyword-shaped `Call` scoring + /// one whatever its argument. Its second slot negates the operand + /// instead, which reaches the terminal set through + /// `elixir_inspect_container` rather than through the chain + /// walker's own check — a different path to the same set, which is + /// what the second slot exists to exercise. /// - /// Two shapes measured short here and are deliberately absent, - /// because neither is a literal: JavaScript's `this` and Groovy's - /// `object_creation_expression`. Both are recorded in #1462. + /// Shapes that measured short and are deliberately absent, none of + /// them a literal: JavaScript's `this` and Groovy's + /// `object_creation_expression` (recorded in #1462); Perl's + /// `s///` and `tr///` (operations on `$_` evaluating to a count), + /// `anonymous_function` and `array_dereference`; Ruby's `lambda`; + /// and Elixir's `anonymous_function` and `&f/1` capture. fn cases(lang: LANG) -> Option { Some(match lang { LANG::Javascript => (JS_SLOTS, "b", js_literals!(Javascript, String2), 7), @@ -15721,6 +15770,82 @@ mod literal_bool_operands { ], 4, ), + LANG::Perl => ( + [ + ( + "sub f {\n my ($a, $b) = @_;\n if ($a && {}) { print 1; }\n}\n", + 2, + 4, + ), + ( + "sub f {\n my ($a, $b) = @_;\n if ({}) { print 1; }\n}\n", + 1, + 3, + ), + ], + "$b", + &[ + ("'s'", crate::Perl::StringSingleQuoted as u16), + ("q(s)", crate::Perl::StringQQuoted as u16), + ("\"s\"", crate::Perl::StringDoubleQuoted as u16), + ("qq(s)", crate::Perl::StringQqQuoted as u16), + ("qx(ls)", crate::Perl::CommandQxQuoted as u16), + ("`ls`", crate::Perl::BacktickQuoted as u16), + ("qw(a b)", crate::Perl::WordListQw as u16), + ("[1, 2]", crate::Perl::ArrayRef as u16), + ("{ a => 1 }", crate::Perl::HashRef as u16), + ("qr/re/", crate::Perl::RegexPatternQr as u16), + ("__FILE__", crate::Perl::SpecialLiteral as u16), + ], + 11, + ), + LANG::Ruby => ( + [ + ("def f(a, b)\n if a && {} then 1 else 0 end\nend\n", 3, 4), + ("def f(a, b)\n if {} then 1 else 0 end\nend\n", 2, 3), + ], + "b", + &[ + ("\"s\"", crate::Ruby::String as u16), + ("\"a\" \"b\"", crate::Ruby::ChainedString as u16), + ("`ls`", crate::Ruby::Subshell as u16), + ("[1, 2]", crate::Ruby::Array as u16), + ("{ a: 1 }", crate::Ruby::Hash as u16), + ("%w[a b]", crate::Ruby::StringArray as u16), + ("%i[a b]", crate::Ruby::SymbolArray as u16), + ("/re/", crate::Ruby::Regex as u16), + ("?a", crate::Ruby::Character as u16), + (":sym", crate::Ruby::SimpleSymbol as u16), + (":\"sym\"", crate::Ruby::DelimitedSymbol as u16), + ], + 11, + ), + LANG::Elixir => ( + [ + ( + "defmodule M do\n def f(a, b) do\n if a && {} do\n a\n end\n end\nend\n", + 3, + 5, + ), + ( + "defmodule M do\n def f(a, b) do\n if a && !{} do\n a\n end\n end\nend\n", + 3, + 5, + ), + ], + "b", + &[ + ("\"s\"", crate::Elixir::String as u16), + ("'c'", crate::Elixir::Charlist as u16), + ("~r/re/", crate::Elixir::Sigil as u16), + (":\"q a\"", crate::Elixir::QuotedAtom as u16), + ("[1, 2]", crate::Elixir::List as u16), + ("{1, 2}", crate::Elixir::Tuple as u16), + ("%{a: 1}", crate::Elixir::Map as u16), + ("<<1>>", crate::Elixir::Bitstring as u16), + ], + 8, + ), _ => return None, }) } @@ -15805,6 +15930,58 @@ mod literal_bool_operands { }); } + /// The twelfth Perl and Ruby literal kind, which the table above + /// cannot hold: in both languages a heredoc body follows the + /// *statement*, so the operand slot carries only the introducer and + /// the rest of the literal lands on later lines. Substituting one + /// into a single-line template produces an unterminated heredoc and + /// an `ERROR` parse, not a measurement. + /// + /// That shape is also why exactly one kind is listed per language + /// (§5): `heredoc_initializer` / `heredoc_beginning` is the node in + /// the operand slot, while `heredoc_body_statement` / + /// `heredoc_body` is a sibling the walker never reaches. Listing + /// both would score one literal twice. + #[cfg(any(feature = "perl", feature = "ruby"))] + #[test] + fn a_heredoc_operand_scores_like_an_identifier_operand() { + #[cfg(feature = "perl")] + { + let heredoc = "sub f {\n my ($a, $b) = @_;\n if ($a && <<\"EOT\") { print 1; }\nhello\nEOT\n}\n"; + assert_spells( + LANG::Perl, + heredoc, + &[(crate::Perl::HeredocInitializer as u16, 1, "<<\"EOT\"")], + ); + assert_eq!( + conditions(LANG::Perl, heredoc), + conditions( + LANG::Perl, + "sub f {\n my ($a, $b) = @_;\n if ($a && $b) { print 1; }\n}\n" + ), + "Perl heredoc operand" + ); + } + #[cfg(feature = "ruby")] + { + let heredoc = + "def f(a, b)\n if a && <<~TXT then 1 else 0 end\n hello\n TXT\nend\n"; + assert_spells( + LANG::Ruby, + heredoc, + &[(crate::Ruby::HeredocBeginning as u16, 1, "<<~TXT")], + ); + assert_eq!( + conditions(LANG::Ruby, heredoc), + conditions( + LANG::Ruby, + "def f(a, b)\n if a && b then 1 else 0 end\nend\n" + ), + "Ruby heredoc operand" + ); + } + } + /// Every spelling must parse to the `kind_id` its row names — the /// §1 alias claim, which is the one thing a conditions comparison /// cannot check: a row naming the wrong alias of a multi-id kind From 8a2d7bdff2e2131f8c4bf2abc0e81868ba7702f2 Mon Sep 17 00:00:00 2001 From: Elijah Zupancic Date: Mon, 14 Sep 2026 21:59:55 -0700 Subject: [PATCH 13/25] docs(lessons): record three merges from the batch Extend lessons 23, 50 and 84 with sub-examples from the 2026-09-14 batch rather than adding new entries. Each is an instance of a mechanism those entries already name, and a new number would be a breaking change for the roughly sixty files that cite lessons by number. 23 gains the case where the compensation is not a constant but a helper that pins each value absolutely while asserting no relation between them, so a wrong value that is individually plausible reads exactly like a right one. 50 gains the audit owed prospectively when a change removes one of two paths summing into a field. 84 gains an in-source FIXME whose conclusion held but whose stated reason did not survive measurement. --- docs/development/lessons_learned.md | 52 +++++++++++++++++++++++++++-- 1 file changed, 50 insertions(+), 2 deletions(-) diff --git a/docs/development/lessons_learned.md b/docs/development/lessons_learned.md index 4230fd5a..072cd741 100644 --- a/docs/development/lessons_learned.md +++ b/docs/development/lessons_learned.md @@ -985,7 +985,12 @@ any future regression shifting the same metric by `±OFFSET` becomes invisible, and an explanatory comment is no substitute for a failing test, because reviewers skim comments and CI cannot. The rule generalises: anywhere a calibration constant compensates for a known -asymmetry, that test cannot catch bugs in the asymmetric path. +asymmetry, that test cannot catch bugs in the asymmetric path — and the +compensation need not be a constant. A helper that pins each value +absolutely but asserts no relation *between* them has the same blind +spot: a wrong value that is individually plausible reads exactly like a +right one, with no literal to grep for and a call site identical to +every other. **`PYTHON_ELSE_BUG_OFFSET` hid the Python over-count from the parity test designed to catch it** (#229, `a239cf6`). `if_else_if_else_chain_parity` @@ -1001,6 +1006,21 @@ fixed `has_ancestors` (renamed `parent_grandparent_match`, strictly checking both predicates), updated the sole call site, and removed the offset in the same commit. +**A helper that pinned absolutes recorded the wrong absolute** (#1454). +Of the five languages one commit gave a pattern-match guard, four got a +condition slot and Elixir got a flat `+1` on top of the guard's existing +sub-structure, so `when y > 5` scored 3 where every sibling scored 2 — +the spelling-dependence the change existed to remove. All five tests use +`assert_members_score`, which pins each member's pair absolutely, because +the fixtures put guarded members and their controls at different values +and `assert_every_member_scores` takes one expected value for all of +them. That choice is right; the cost is that it asserts no relation +*between* the two numbers, so Elixir's `("tok", 3, 3)` reads exactly like +its siblings' `("tok", 2, 3)` while violating +`conditions == cyclomatic() - 1`. Its own commit's four-stage review, a +green `make pre-commit`, 5,766 tests and 99.5% patch coverage all passed +over it; only reading the five legs against each other found it. + --- ## 24. A cross-cutting traversal feature must reach finalize and span-derived metrics @@ -1872,7 +1892,12 @@ into children, a single-arm `switch` for a container-vs-arm counter. Then test-via-revert each new arm independently and confirm it fails when that *one* arm is dropped. When auditing an existing metric, identify every independent path contributing to the field and ensure each has an -input no other path covers. +input no other path covers. The same audit is owed *prospectively* when +a change removes one of the paths: replacing an ad-hoc accumulator with +one that delegates makes the result depend on every construct the +delegate can now decline being owned somewhere else, and where it is +not, the score falls to zero — indistinguishable from a construct that +legitimately scores nothing. Both paths add into the same `Stats` field, so any fixture covered by *either* reads the right total and passes. The dead path is invisible @@ -1901,6 +1926,19 @@ the `BooleanLiteral` wrapper the grammar interposes for a condition, so other condition token fired in the same statement. Same root cause, different node shape, within one week. +**Removing the masking path exposed two arms that had never existed** +(#1454, and #1461 for the `in` half). Elixir's guard scored a flat `+1`, +which the fix replaced with a condition slot that classifies the guard +expression and declines what it does not recognise. Two constructs the +slot model presumes are owned elsewhere were not: `in` / `not in` had no +arm in either metric, and only `!` counted as negation, not the keyword +`not`. Inside a guard the `+1` supplied their count regardless, so +`when a in b` read correctly; outside one, `a in b` already scored 0 +against `a == b`'s 1 — visible to anyone who looked, and nobody did. +Both would have fallen to 0 in the guard too the moment the slot +declined them. The audit that catches this is done before the change, by +enumerating what the delegate can refuse. + --- ## 51. Hand-rolled match arms drift from their enum list without an integration coverage guard @@ -3247,6 +3285,16 @@ sibling fails 3 and 1. It survived two fresh-context reviews *because* it admitted the uncertainty: a hedge reads as a gap someone has already logged, not as a claim to test. The remedy was a `debug_assert_eq!` on the walk, not a fixture. +**A FIXME's reason ages independently of its conclusion** (issues #1450 +and #1451). Paired FIXMEs in C#'s ABC and cyclomatic arms each justified +leaving a `goto case` over-count by citing the parity +`conditions == cyclomatic() - 1`. Gating either side alone breaks +**zero** of 3,429 lib tests: that parity is opt-in fixture policy, and no +fixture outside the cognitive tests spelled `goto case`. The conclusion +held; the stated reason never did. `AGENTS.md`'s rule to re-verify a +deferred issue's premises covers in-source FIXMEs, which are trusted +*more* for sitting beside the code. + --- ## 85. Coverage measures execution, not discrimination From 682073698a9fa11f59d087360ccb9b0a5c842c46 Mon Sep 17 00:00:00 2001 From: Elijah Zupancic Date: Mon, 14 Sep 2026 22:00:00 -0700 Subject: [PATCH 14/25] docs(rules): add perturbation and gate guidance Three additions to the testing rules, all from the 2026-09-14 batch. Classify a build failure by `error[` or `could not compile`: cargo prints `error: test failed` on an ordinary red run, so a driver matching bare `error` reads every discriminating perturbation as a build break. A perturbation that passes is a finding, not a null result. Check whether the behaviour is enforced by the mechanism or merely absent from a list before strengthening anything, and correct the test's comment rather than inventing a fixture to justify it. A feature-gate check needs `-p big-code-analysis`. A workspace-wide run does not isolate features at all, because the CLI and web members pull the library with default features and cargo unifies them. Also wraps a line beginning `#1431`, which rumdl parsed as a heading. --- .claude/rules/testing.md | 35 ++++++++++++++++++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) diff --git a/.claude/rules/testing.md b/.claude/rules/testing.md index 0f2139c3..a0d1c9a6 100644 --- a/.claude/rules/testing.md +++ b/.claude/rules/testing.md @@ -96,6 +96,28 @@ perturbation then reports the integration guard as passing, which reads as a dead guard rather than a harness artifact (#1270). Pass `--no-fail-fast` in any multi-target perturbation run. +**Classify a build failure by `error[` or `could not compile`, never by +`error`.** `cargo test` prints `error: test failed` to stderr on an +ordinary red run, so a driver matching bare `error` reads every +discriminating perturbation as a build break. One sweep returned +BUILD-ERROR for six of seven cases that were in fact all working (#1466) +— a uniform non-answer of exactly the shape the uniform-34 and uniform-0 +tells above describe. + +**A perturbation that *passes* is a finding, not a null result.** It says +the assertion does not depend on the line you neutralised, and the usual +cause is that the test's stated claim is wrong rather than that the test +is weak. Check whether the behaviour is *enforced by the mechanism* or +merely *absent from a list* before strengthening anything: Perl's +statement-modifier walker keys on the grammar's `condition` field, so +adding `ForSimpleStatement` to its arm changes nothing — the `for` +modifier exposes no such field — and a comment claiming the test would +catch a widened arm was false until the sweep proved it (#1464). Correct +the comment; do not invent a fixture to justify it. The inverse error is +equally common: two candidate rankings in #1465 were merely *different +valid orderings* rather than defects, and the honest fix was to trim the +test comment's claim. + After restoring, `git status` / `git diff --stat` must show exactly the edits you intend — nothing extra, nothing missing. @@ -233,7 +255,7 @@ fixture rather than like the fix working. `loc/wide-cfg-test-mod` (`big-code-analysis-bench/src/shapes.rs`) read `sloc` under `exclude_tests` on a file of nothing but `#[cfg(test)] mod m {}` repeated. Its only non-zero row was the phantom attribute row -#1431 then removed, so the probe scored zero and tripped +that #1431 then removed, so the probe scored zero and tripped `probe_workload_is_exercised`. Left unnoticed it would have timed the walk's fixed overhead and reported an excellent exponent forever. The repair was to render a retained `fn p() {}` per item, so the reading @@ -428,6 +450,17 @@ single non-listed language (`--features go`) is the reproducer. Note the union is over *features*, not languages: `LANG::Tsx` rides `feature = "typescript"`, so a seven-row table can need only six. +**That verification needs `-p big-code-analysis`.** A workspace-wide +`cargo test --no-default-features --features X` does not isolate features +at all: `big-code-analysis-cli` and `big-code-analysis-web` depend on the +library with default features, and cargo unifies them, so everything is +silently re-enabled. Three different feature sets reported an identical +`4 passed; 3428 filtered out` before this was noticed (#1457) — the +uniform-number tell from the harness sections above. Only `-p` narrowed +it, and then the counts varied (0 / 2 / 3). A feature-gate check run +without `-p` proves nothing, and proves it while looking green; confirm +real isolation by watching the *filtered-out* count differ across sets. + ## Assert a whole-run invariant in the run, not in a fixture list When a change establishes an invariant that holds at the end of *every* From 0e15c49c9fdbf0b4776798c69d8a9d6e93a30f3c Mon Sep 17 00:00:00 2001 From: Elijah Zupancic Date: Mon, 14 Sep 2026 22:18:49 -0700 Subject: [PATCH 15/25] test(abc): gate hidden_literal_supertypes on its features MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The module's two tests are gated on `php` and `groovy` respectively, but the module itself was gated only on `test`, so a build enabling neither feature left `ast_has_kind_id` and the `crate::*` glob unused. CI sets `RUSTFLAGS: -D warnings` workflow-wide, so that is a hard compile failure on six feature-matrix legs: no-default-features, minimal-langs, lang-c, lang-go, lang-python and lang-tcl. `make pre-commit` cannot see this — it builds the default and all-features flavours only, and both enable php and groovy. --- src/metrics/abc.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/metrics/abc.rs b/src/metrics/abc.rs index 8b0c9f41..8e5860c6 100644 --- a/src/metrics/abc.rs +++ b/src/metrics/abc.rs @@ -16005,7 +16005,10 @@ mod literal_bool_operands { /// of being unreachable (`.claude/rules/grammar-dispatch.md` §2). A /// grammar bump that promotes either changes ABC's answer silently, so /// the unreachability is pinned rather than assumed. -#[cfg(test)] +// Gated on the union of the two features its tests name, so a build +// enabling neither drops the module rather than leaving its imports +// unused (`.claude/rules/testing.md`, #1286). +#[cfg(all(test, any(feature = "php", feature = "groovy")))] mod hidden_literal_supertypes { use crate::test_support::ast_has_kind_id; use crate::*; From 821f7ccea560f7bc19828424173d238eba193885 Mon Sep 17 00:00:00 2001 From: Elijah Zupancic Date: Tue, 15 Sep 2026 07:02:57 -0700 Subject: [PATCH 16/25] fix(abc): score an Elixir repeated guard as an or-chain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `def f(n) when is_integer(n) when is_float(n)` read identically to the single-guard `when is_integer(n)` on both axes, while the semantically equivalent `when is_integer(n) or is_float(n)` read one higher on each. Elixir defines repeated guards as an or-chain: each `when` expression is tried in turn and evaluation moves to the next when the previous is false or raises, so the two spellings carry the same decisions. The guards nest right-associatively (`head when (a when (b when c))`), and only the outermost sat on the anchor `elixir_when_is_guard` tests, so every nested `when` answered "not a guard" and contributed no decision. ABC then peeled the nesting and scored the last alternative alone. `elixir_when_is_guard` now climbs the enclosing `when` operators before asking the position question, which makes every token in the chain a guard; the climb stops at the first non-`when` ancestor, so a single guard pays no extra step. The shared `elixir_when_alternative` names the one alternative each token introduces — the nested operator's `left`, or its own `right` at the end of the chain — so the ABC slot stays one-per-token instead of counting the whole chain once per token (grammar-dispatch section 5). Both metrics therefore move together and the section 8 parity holds at every chain length. The typespec exclusion is unaffected: a `@spec f(a) :: a when a: integer` binding clause nests no `when`, so the climb is a no-op and the same anchor rejects it, with multiple bindings arriving as one `keywords` node. Refs PR #1476. --- CHANGELOG.md | 29 ++++++---- src/metrics/abc.rs | 117 +++++++++++++++++++++++++++++++++----- src/metrics/abc/elixir.rs | 33 ++++------- src/metrics/npa/shared.rs | 75 +++++++++++++++++++----- 4 files changed, 192 insertions(+), 62 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index af3b2dfe..22e44273 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -448,17 +448,20 @@ for historical reference. the one language it was meant to fix. Elixir now routes the guard expression through the same classifier its `&&` operands use, so all of `when n > 5`, `when n == 5`, `when is_integer(n)`, `when n`, - `when (n)`, `when not n`, `when n in [1, 2]` and the multi-alternative - `when a when b` score exactly one. The Elixir arm is gated on the - guard's position, because the language has no guard production — - `x when g` is an ordinary `binary_operator` — and a typespec's - binding clause - (`@spec f(a) :: a when a: integer`) spells the same token; that gate - is shared by both metrics, so it also removes the condition the - typespec used to score against no decision anywhere. The same fix - closes the opposite-direction gap in the issue: a *bare* guard - (`match x { _ if b => … }`, `case _ if b:`) scored nothing at all, - one below the arm's own decision count. Groovy and Kotlin are + `when (n)`, `when not n` and `when n in [1, 2]` score exactly one. A + *repeated* guard (`when a when b`) is the one spelling the slot does + not collapse: Elixir tries each alternative in turn, moving to the + next when the previous is false or raises, so it is an or-chain and + scores one alternative per `when` — level with `when a or b` on both + axes rather than level with a single guard. The Elixir arm is gated + on the guard's position, because the language has no guard production + — `x when g` is an ordinary `binary_operator` — and a typespec's + binding clause (`@spec f(a) :: a when a: integer`) spells the same + token; that gate is shared by both metrics, so it also removes the + condition the typespec used to score against no decision anywhere. + The same fix closes the opposite-direction gap in the issue: a *bare* + guard (`match x { _ if b => … }`, `case _ if b:`) scored nothing at + all, one below the arm's own decision count. Groovy and Kotlin are unchanged and untested: neither pinned grammar has a guard production, and Kotlin 2.1 guard syntax does not parse at the pin, so per `grammar-dispatch` §6 pinning its numbers would make the @@ -478,7 +481,9 @@ for historical reference. Rule 5 grounds #1461 applied to the other five languages, so `a in b` gains one and reads level with `a == b`; and the keyword `not` now counts as a negation alongside `!` in a `&&` / `||` chain, - so `a && not b` gains one and reads level with `a && !b`. + so `a && not b` gains one and reads level with `a && !b`. An Elixir + repeated guard gains one decision and one condition per alternative + past the first, where it previously scored as a single guard. Integration snapshots move for three `serde` files (Rust); no Python, Ruby, Java or Elixir corpus file carries a guard. diff --git a/src/metrics/abc.rs b/src/metrics/abc.rs index 8e5860c6..2a48bca8 100644 --- a/src/metrics/abc.rs +++ b/src/metrics/abc.rs @@ -13659,9 +13659,11 @@ end // relational operators, given the by-use arm #1461 gave the // other five languages. Without it these two would score zero, // since the slot correctly declines an operator application. - // * `alternatives` — a multi-alternative guard is one guard, so - // `elixir_count_guard` peels the nested `when` and scores the - // last alternative once. + // + // A repeated guard (`when a when b`) is deliberately absent: it is + // an or-chain and scores one per alternative, so it belongs with + // `elixir_repeated_guard_matches_the_or_chain` below rather than in + // a table whose every row reads 2. // // §11: each bullet is an independent path, and no other member can // stand in for it — deleting the `in` arm leaves `membership` and @@ -13718,23 +13720,13 @@ end _ -> 0 end end - def alternatives(x) do - case x do - n when is_integer(n) when is_float(n) -> 1 - _ -> 0 - end - end end "; assert_fixture_spells::( src, "foo.ex", &[ - ( - Elixir::When as u16, - 10, - "`when` guards, `alternatives`'s two included", - ), + (Elixir::When as u16, 8, "one `when` guard per member"), (Elixir::GT as u16, 1, "`tok`'s `>`"), (Elixir::EQEQ as u16, 1, "`eq`'s `==`"), (Elixir::Block as u16, 1, "`paren`'s parenthesised guard"), @@ -13746,13 +13738,108 @@ end check_func_space::(src, "foo.ex", |space| { assert_every_member_scores( &space.spaces[0], - 9, + 8, 2, "one for the `case` arm and one for the guard, however the guard is spelled", ); }); } + // Elixir's repeated guard is an or-chain, not a longer spelling of + // one guard: each `when` expression is tried in turn and evaluation + // moves to the next when the previous one is false *or raises*. So + // `when a when b` carries the alternatives `when a or b` does, and + // the two spellings have to score alike. + // + // They did not. The nesting is right-associative + // (`head when (a when (b when c))`), only the outermost `when` sat + // on the anchor `elixir_when_is_guard` tests, and the `Abc` slot + // peeled to the last alternative and scored that one — so a + // repeated guard read *identically to a single guard* on both axes + // while the `or` form read one higher per alternative. + // + // Asserted as an equality against the `or` spelling rather than + // against separate literals, so neither row can drift alone; the + // literals are here too, because a pair of equal wrong numbers + // would satisfy the equality on its own. + // + // Both anchors, because the climb has to reach each: a definition + // `Call`'s `arguments` (`repeated` / `ored`) and a `stab_clause`'s + // `left` (`clause` / `clause_or`). `single` is the control the + // repeated rows must sit *above*, which is the comparison that + // failed before. + #[test] + fn elixir_repeated_guard_matches_the_or_chain() { + let src = "defmodule T do + def repeated(n) when is_integer(n) when is_float(n) when is_atom(n) do + n + end + def ored(n) when is_integer(n) or is_float(n) or is_atom(n) do + n + end + def single(n) when is_integer(n) do + n + end + def clause(x) do + case x do + n when is_integer(n) when is_float(n) -> 1 + _ -> 0 + end + end + def clause_or(x) do + case x do + n when is_integer(n) or is_float(n) -> 1 + _ -> 0 + end + end +end +"; + assert_fixture_spells::( + src, + "foo.ex", + &[ + ( + Elixir::When as u16, + 8, + "three repeated, one `or`-spelled, one single, two \ + repeated and one `or`-spelled in a clause", + ), + (Elixir::Or as u16, 3, "the two `or` spellings' operators"), + ], + ); + check_func_space::(src, "foo.ex", |space| { + // Positional: a `def` whose head carries a guard parses its + // name out of a `binary_operator` rather than a plain + // `Call`, so `repeated`, `ored` and `single` all come back + // `` (the same naming gap + // `elixir_typespec_when_is_not_a_guard` reads around). + let members: Vec<(u64, u64)> = space.spaces[0] + .spaces + .iter() + .map(|m| { + ( + m.metrics.abc.conditions(), + m.metrics.cyclomatic.cyclomatic(), + ) + }) + .collect(); + assert_eq!( + members, + vec![(3, 4), (3, 4), (1, 2), (3, 4), (3, 4)], + "a repeated guard scores one alternative per `when`; the \ + single guard is the control one alternative lower" + ); + assert_eq!( + members[0], members[1], + "`when a when b when c` is the or-chain `when a or b or c`" + ); + assert_eq!( + members[3], members[4], + "and the same holds on a `stab_clause` anchor" + ); + }); + } + // The gate that makes the Elixir arm safe. Elixir has no dedicated // guard production, and a typespec's binding clause spells the same // `when` token — so an ungated arm would have made type syntax a diff --git a/src/metrics/abc/elixir.rs b/src/metrics/abc/elixir.rs index 6f95cd7e..b3c8cfe5 100644 --- a/src/metrics/abc/elixir.rs +++ b/src/metrics/abc/elixir.rs @@ -107,30 +107,19 @@ fn elixir_count_condition(condition: &Node, parent: &Node, conditions: &mut f64) } } -// The guard slot of an anchored `when` operator, whose `right` field -// holds the guard. +// The guard slot of an anchored `when` operator. // -// Alternative guards (`when a when b`, valid but rare) nest -// right-associatively, so an anchored operator's `right` can be a -// further `when`. The construct is one guard however many alternatives -// it lists — the contract `elixir_when_is_guard` publishes, and what -// the matching `Cyclomatic` arm counts — so the nesting is peeled and -// the last alternative occupies the single slot. Each step descends one -// level, so the walk is bounded by the guard's nesting depth. +// Repeated guards (`when a when b`) are an or-chain — Elixir tries each +// alternative in turn, moving on when the previous one is false or +// raises — so the construct carries one slot per alternative, level +// with the `when a or b` spelling. They nest right-associatively, and +// `elixir_when_is_guard` anchors every `when` token in the chain, so +// the slot this fills is the single alternative *this* token +// introduces: one per token, never the whole chain once per token +// (grammar-dispatch §5). fn elixir_count_guard(when_operator: &Node, conditions: &mut f64) { - use Elixir as E; - - let mut operator = *when_operator; - while let Some(right) = operator.child_by_field_name("right") { - let nests_another_alternative = right.kind() == BINARY_OPERATOR - && right - .child_by_field_name("operator") - .is_some_and(|op| op.kind_id() == E::When as u16); - if !nests_another_alternative { - elixir_count_condition(&right, &operator, conditions); - return; - } - operator = right; + if let Some((alternative, owner)) = npa::elixir_when_alternative(when_operator) { + elixir_count_condition(&alternative, &owner, conditions); } } diff --git a/src/metrics/npa/shared.rs b/src/metrics/npa/shared.rs index 553775a8..a9a53576 100644 --- a/src/metrics/npa/shared.rs +++ b/src/metrics/npa/shared.rs @@ -656,6 +656,42 @@ pub(crate) fn ruby_in_clause_counts(in_clause: &Node, source: &[u8]) -> bool { }) } +/// Whether `node` is a `when` `binary_operator` — the shape a repeated +/// guard's alternatives nest through. +/// +/// `binary_operator` carries three kind aliases at this pin so it is +/// matched by rule name rather than by enumerating ids +/// (grammar-dispatch §1); `when` has exactly one id. +fn elixir_is_when_operator(node: &Node) -> bool { + const BINARY_OPERATOR: &str = "binary_operator"; + + node.kind() == BINARY_OPERATOR + && node + .child_by_field_name("operator") + .is_some_and(|operator| operator.kind_id() == Elixir::When as u16) +} + +/// The one alternative a guard's `when` token introduces, paired with +/// the `binary_operator` whose field slot it occupies. +/// +/// `head when a when b` parses right-associatively as +/// `head when (a when b)`, so each `when` operator owns exactly one +/// alternative: the nested operator's `left` where its `right` nests a +/// further `when`, and its own `right` otherwise. One alternative per +/// token is what holds the `Abc` slot count level with the `Cyclomatic` +/// decision count at every chain length (§8), and what stops a chain of +/// n alternatives being counted once per token that can see it (§5). +pub(crate) fn elixir_when_alternative<'a>( + when_operator: &Node<'a>, +) -> Option<(Node<'a>, Node<'a>)> { + let right = when_operator.child_by_field_name("right")?; + if elixir_is_when_operator(&right) { + right.child_by_field_name("left").map(|left| (left, right)) + } else { + Some((right, *when_operator)) + } +} + /// Whether a `when` operator token spells a real guard — a function /// head's (`def f(x) when g do`) or a clause's (`x when g -> …`) — /// rather than a typespec's `when` binding clause @@ -676,15 +712,21 @@ pub(crate) fn ruby_in_clause_counts(in_clause: &Node, source: &[u8]) -> bool { /// `binary_operator` three, so both are matched by rule name rather /// than by enumerating ids (grammar-dispatch §1). /// -/// Alternative guards (`when a when b`, valid but rare) parse -/// right-associatively into nested `when` operators — the *outermost* -/// is the one holding the anchor, and each further alternative hangs -/// off its predecessor's `right`. Only that outermost one is a guard -/// here: the construct scores one, the same as the single-alternative -/// spelling. That is the slot model — the guard is one decision however -/// many alternatives it lists — and it is what `ancestors` can answer -/// in O(1) steps. `abc::elixir_count_guard` peels the same nesting to -/// reach the alternative that occupies the slot. +/// Repeated guards (`when a when b`) are an or-chain: Elixir tries each +/// `when` expression in turn and moves to the next when the previous one +/// is false *or raises*, so the construct carries one decision per +/// alternative, level with the `when a or b` spelling. They parse +/// right-associatively into nested `when` operators, and only the +/// outermost sits on the anchor — so a nested one is a guard too, and +/// the climb below walks the `when` operators between it and the anchor +/// before asking the position question. [`elixir_when_alternative`] +/// names the one alternative each token introduces, which is how the +/// matching `Abc` slot stays one-per-token rather than double counting +/// the chain (grammar-dispatch §5). +/// +/// The climb stops at the first non-`when` ancestor, so an ordinary +/// single guard pays no extra step and a chain pays one per alternative +/// it lists — a bound set by the guard, not by the tree's depth. pub(crate) fn elixir_when_is_guard<'a>( node: &Node<'a>, code: &'a [u8], @@ -695,12 +737,19 @@ pub(crate) fn elixir_when_is_guard<'a>( const ARGUMENTS: &str = "arguments"; let mut chain = ancestors.iter(node); - // The token's parent is the `when` operator node itself; its parent - // is the position that decides. - let Some((operator, _)) = chain.next() else { + // The token's parent is the `when` operator node itself; the first + // ancestor above the chain of `when` operators is the position that + // decides. + let Some((mut operator, _)) = chain.next() else { return false; }; - let Some((parent, _)) = chain.next() else { + let mut above = chain.next(); + while let Some((enclosing, _)) = above.filter(|(ancestor, _)| elixir_is_when_operator(ancestor)) + { + operator = enclosing; + above = chain.next(); + } + let Some((parent, _)) = above else { return false; }; if parent.kind_id() == E::StabClause as u16 { From f94a4476cf6f60044c24601d649b911e42594fef Mon Sep 17 00:00:00 2001 From: Elijah Zupancic Date: Tue, 15 Sep 2026 07:03:10 -0700 Subject: [PATCH 17/25] fix(cyclomatic): count Groovy's safe-indexing operator `?[` short-circuits on a null receiver exactly as `?.` and `??.` do, but Groovy cyclomatic listed only the two navigation tokens, so `l?[0]` read level with the unconditional `l[0]` while `l?.get(0)` read one higher. The arm matches the `?[` token rather than the `safe_subscript_expression` wrapper, the same granularity and the same reason as its two siblings: a chain nests one wrapper inside another, so the token counts each operator once where the wrapper would score `l?[0]?[1]` as one. `QMARKLBRACK` has a single kind id at the pin, and the wrapper node reaches no cyclomatic arm, so there is no double count. `own_production_bool_constructs` listed `l?[0]` among the constructs whose contract is "scores the control's cyclomatic". That row passed only because of this gap, so it would have rejected the fix; it moves to `groovy_safe_navigation_closes_the_two_below_gap` beside `a?.b` and `a??.b`, which assert the offset explicitly, and the row's recorded construct count follows it down. Fixes #1471. Refs PR #1476. --- CHANGELOG.md | 16 ++++++++++++ src/metrics/abc.rs | 43 +++++++++++++++++++------------- src/metrics/cyclomatic.rs | 19 ++++++++++++++ src/metrics/cyclomatic/groovy.rs | 12 ++++++++- 4 files changed, 71 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 22e44273..1bd41c16 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -335,6 +335,22 @@ for historical reference. predicate whose operand is preceded by a comment. No integration snapshot moves: no corpus carries a Groovy file. +- **Groovy's safe-indexing operator counts as a decision** (#1471). + `?[` short-circuits on a null receiver exactly as `?.` and `??.` do, + but Groovy cyclomatic had an arm for the two navigation spellings and + none for the subscript one — so `l?[0]` read level with the + unconditional `l[0]`, while `l?.get(0)` read one higher. The arm + matches the `?[` token rather than the `safe_subscript_expression` + wrapper, the same granularity and the same reason as its two + siblings: a chain (`l?[0]?[1]`) nests one wrapper inside another, so + the token counts each operator once where the wrapper would not. No + §5 double count — that wrapper reaches no cyclomatic arm, only ABC's + bool-terminal set. **Metric drift:** Groovy `cyclomatic` (standard + and modified) rises by one per `?[`, and `wmc` and `mi` move with it, + so a `wmc` or `mi` threshold can newly fire on an unedited file. ABC + is unaffected. No integration snapshot moves: no corpus carries a + Groovy file. + - **Groovy's Halstead `super` arm is gated on its `wildcard` parent, as Java's is** (#1419). `super` is an operator only as a wildcard type bound (`List`), where it denotes no value and mirrors diff --git a/src/metrics/abc.rs b/src/metrics/abc.rs index 2a48bca8..af891c13 100644 --- a/src/metrics/abc.rs +++ b/src/metrics/abc.rs @@ -15029,19 +15029,20 @@ mod own_production_bool_constructs { "s ==~ /p/", "!(a in l)", // The indexing / navigation kinds added by #1466. - // These three are the cyclomatic-neutral ones, so + // These two are the cyclomatic-neutral ones, so // they belong in this module's same-as-the-control - // shape. The two *safe-navigation* spellings - // (`a?.b`, `a??.b`) each add a cyclomatic decision - // and so cannot sit in a row whose contract is "the - // control's cyclomatic"; they get their own test, + // shape. The three *null-safe* spellings (`a?.b`, + // `a??.b`, `l?[0]`) each short-circuit on a null + // receiver and so add a cyclomatic decision, which + // a row whose contract is "the control's + // cyclomatic" cannot express; they get their own + // test, // `groovy_safe_navigation_closes_the_two_below_gap`, // which anchors them on that axis explicitly. "l[0]", - "l?[0]", "a.@b", ], - 10, + 9, ), LANG::Perl => ( [ @@ -15157,18 +15158,24 @@ mod own_production_bool_constructs { }); } - /// Groovy's two safe-navigation spellings, on the cyclomatic axis. + /// Groovy's three null-safe spellings, on the cyclomatic axis. /// /// They cannot ride the rows above, whose contract is "scores the /// control's `conditions` *and* the control's `cyclomatic`": /// `groovy_bool_terminal_kinds!()` does not move cyclomatic, but - /// `?.` (`QMARKDOT`) and `??.` (`QMARKQMARKDOT`) are already - /// cyclomatic decisions in their own right - /// (`src/metrics/cyclomatic/groovy.rs`), so each spelling scores the - /// control's conditions against the control's cyclomatic **plus - /// one**. That is the whole reason this pair was the worst case in - /// #1466: before the fix ABC sat *two* below its own decision count - /// on `if (a?.b)`, against one below for every other spelling. + /// `?.` (`QMARKDOT`), `??.` (`QMARKQMARKDOT`) and `?[` + /// (`QMARKLBRACK`) are already cyclomatic decisions in their own + /// right (`src/metrics/cyclomatic/groovy.rs`), so each spelling + /// scores the control's conditions against the control's cyclomatic + /// **plus one**. That is the whole reason this family was the worst + /// case in #1466: before the fix ABC sat *two* below its own + /// decision count on `if (a?.b)`, against one below for every other + /// spelling. + /// + /// `l?[0]` joined the family in #1471. It sat in the neutral rows + /// above until then, passing only because Groovy cyclomatic had no + /// `?[` arm — a fixture that would have rejected the fix for the + /// very gap it recorded. /// /// Asserting the offset rather than the bare conditions is what /// makes this a §5 double-count guard as well. If a later change @@ -15179,14 +15186,14 @@ mod own_production_bool_constructs { #[cfg(feature = "groovy")] fn groovy_safe_navigation_closes_the_two_below_gap() { for (template, control_conditions, control_cyclomatic) in [ - ("def f(a, b) {\n return {} && b\n}\n", 2, 3), - ("def f(a, b) {\n if ({}) { return 1 }\n}\n", 1, 3), + ("def f(a, b, l) {\n return {} && b\n}\n", 2, 3), + ("def f(a, b, l) {\n if ({}) { return 1 }\n}\n", 1, 3), ] { let control = template.replace("{}", "b"); assert_eq!(conditions(LANG::Groovy, &control), control_conditions); assert_eq!(cyclomatic_sum(LANG::Groovy, &control), control_cyclomatic); - for spelling in ["a?.b", "a??.b"] { + for spelling in ["a?.b", "a??.b", "l?[0]"] { let source = template.replace("{}", spelling); assert_eq!( conditions(LANG::Groovy, &source), diff --git a/src/metrics/cyclomatic.rs b/src/metrics/cyclomatic.rs index 4d86b4e3..95b8c920 100644 --- a/src/metrics/cyclomatic.rs +++ b/src/metrics/cyclomatic.rs @@ -3050,6 +3050,25 @@ mod tests { }); } + #[test] + fn groovy_safe_subscript_cyclomatic() { + // Issue #1471: `?[` short-circuits on a null receiver exactly as + // `?.` does, and had no arm — so `l?[0]` read level with the + // unconditional `l[0]`. Chained, because the arm matches the + // token and not the `safe_subscript_expression` wrapper: a + // chain nests one wrapper inside another, so the wrapper + // spelling would score this 1 and only the token spelling + // scores it 2 (grammar-dispatch §5). + check_metrics::("def read(l){ return l?[0]?[1] }", "foo.groovy", |metric| { + // unit(1) + fn(base 1 + ?[ 1 + ?[ 1) = sum 4, max 3. + let s = &metric.cyclomatic; + assert_eq!(s.cyclomatic_sum(), 4); + assert_eq!(s.cyclomatic_max(), 3); + assert_eq!(s.cyclomatic_modified_sum(), 4); + assert_eq!(s.cyclomatic_modified_max(), 3); + }); + } + #[test] fn groovy_safe_chain_dot_cyclomatic() { // Issue #452: Groovy's `??.` (QMARKQMARKDOT, the spread-safe diff --git a/src/metrics/cyclomatic/groovy.rs b/src/metrics/cyclomatic/groovy.rs index 6c6371b3..9229fb2e 100644 --- a/src/metrics/cyclomatic/groovy.rs +++ b/src/metrics/cyclomatic/groovy.rs @@ -26,8 +26,18 @@ use super::*; // +2). Matching the wrapper nodes instead would miscount nested // chains; the token is the single granularity that fires once per // textual operator, paralleling Kotlin/TS which match `QMARKDOT`. +// - Safe indexing `?[` (`QMARKLBRACK`): the same short-circuit on a +// null receiver, spelled for a subscript instead of a member access +// (#1471). It was the one member of that family with no arm, so +// `l?[0]` read level with the unconditional `l[0]` while `l?.get(0)` +// read one higher. The token granularity is the same choice for the +// same reason: the grammar emits one `?[` per operator inside a +// `safe_subscript_expression`, which nests for a chain (`l?[0]?[1]` +// is one wrapper inside another), so the token counts each operator +// once and the wrapper would not. No §5 double count — that wrapper +// node reaches no cyclomatic arm; it is an ABC bool-terminal only. impl_cyclomatic_java_like!( GroovyCode, Groovy, - [Assert, QMARKCOLON, QMARKDOT, QMARKQMARKDOT] + [Assert, QMARKCOLON, QMARKDOT, QMARKQMARKDOT, QMARKLBRACK] ); From 4e4703fc2490deacc293241879b150c0bfeebe86 Mon Sep 17 00:00:00 2001 From: Elijah Zupancic Date: Tue, 15 Sep 2026 07:03:18 -0700 Subject: [PATCH 18/25] test(abc): reject an emptied operand row in both sweeps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `literal_bool_operands::for_each_case` documented three guards against the table decaying into asserting nothing, but only two were written. A row with `literals: &[]` and `expected_kinds: 0` satisfies the length check, runs its inner loops zero times, and still increments `checked` — which counts languages, not spellings — so that language asserted nothing while the non-vacuity guard read satisfied. Measured: emptying the Elixir row left all four tests in the module green. `numeric_bool_operands` carries the same gap, and its own doc comment names emptying as one of the shapes it catches, so both drivers get the assertion rather than only the one that was reported. Refs PR #1476. --- src/metrics/abc.rs | 50 +++++++++++++++++++++++++++++++++------------- 1 file changed, 36 insertions(+), 14 deletions(-) diff --git a/src/metrics/abc.rs b/src/metrics/abc.rs index af891c13..6872ce59 100644 --- a/src/metrics/abc.rs +++ b/src/metrics/abc.rs @@ -14799,10 +14799,13 @@ mod numeric_bool_operands { /// language is compiled in; this catches the residual case where /// the runtime `is_enabled()` check stops agreeing with the feature /// it compiled under. - /// - **the `numerics` list keeps its recorded length** — `checked` - /// counts *languages*, so trimming a row's numeric list back to - /// `&["1"]` (the pre-#1379 fixture) or emptying it left every test - /// passing when measured, with that language's coverage deleted. + /// - **the `numerics` list is non-empty and keeps its recorded + /// length** — `checked` counts *languages*, so trimming a row's + /// numeric list back to `&["1"]` (the pre-#1379 fixture) or + /// emptying it left every test passing when measured, with that + /// language's coverage deleted. Two assertions, because the + /// length check passes an emptied row whose count was zeroed with + /// it. /// - **every template keeps its `{}`** — without the slot, /// `str::replace` is a no-op, baseline and candidate are computed /// from the same string, and the comparison degenerates to @@ -14818,6 +14821,15 @@ mod numeric_bool_operands { let Some(case @ (slots, _, numerics, expected_kinds)) = cases(lang) else { continue; }; + // Emptying a row is the half the length check below cannot + // see: `&[]` against an `expected_kinds` of 0 agrees with + // itself, the inner loops run zero times, and `checked` — + // which counts languages, not spellings — still reads + // satisfied. + assert!( + !numerics.is_empty(), + "{lang:?}: the numeric-operand list is empty; this language asserted nothing" + ); assert_eq!( numerics.len(), expected_kinds, @@ -15090,12 +15102,13 @@ mod own_production_bool_constructs { /// Runs `check` once per enabled language that has a case, having /// first established that the case can still assert something. /// - /// The three guards mirror `numeric_bool_operands::for_each_case`, + /// The four guards mirror `numeric_bool_operands::for_each_case`, /// where each was added only after a measured perturbation of it /// left that module green: `checked > 0` for the runtime half of - /// the feature gate, the recorded construct count so a trimmed row - /// cannot vanish silently, and the `{}` slot so `str::replace` does - /// not degenerate into comparing a string with itself. + /// the feature gate, a non-empty construct list and its recorded + /// count so a trimmed row cannot vanish silently, and the `{}` slot + /// so `str::replace` does not degenerate into comparing a string + /// with itself. fn for_each_case(check: impl Fn(LANG, Case)) { let mut checked = 0; for lang in LANG::into_enum_iter() { @@ -15111,9 +15124,7 @@ mod own_production_bool_constructs { // setting its count to 0 — the shape a careless "the test // failed, fix the number" edit takes — deleted that // language's coverage with both tests still green when - // measured. `numeric_bool_operands` above has the same - // weakness and is left alone here, being out of this - // change's scope. + // measured. assert!( !constructs.is_empty(), "{lang:?}: the construct list is empty; this language asserted nothing" @@ -15946,10 +15957,11 @@ mod literal_bool_operands { /// Runs `check` once per enabled language that has a case, having /// first established that the case can still assert something. The - /// three guards are the sibling module's, for the same three ways + /// four guards are the sibling module's, for the same four ways /// this table could decay into asserting nothing: no language - /// enabled, an emptied literal list, and a template that lost its - /// `{}` slot (which makes every comparison `x == x`). + /// enabled, an emptied literal list, a list that no longer covers + /// every kind, and a template that lost its `{}` slot (which makes + /// every comparison `x == x`). fn for_each_case(check: impl Fn(LANG, Case)) { let mut checked = 0; for lang in LANG::into_enum_iter() { @@ -15959,6 +15971,16 @@ mod literal_bool_operands { let Some(case @ (slots, _, literals, expected_kinds)) = cases(lang) else { continue; }; + // The length check does not imply this one: it asserts only + // that the list and its recorded count *agree*, which an + // emptied row with `expected_kinds` set to 0 satisfies — + // the shape a "the test failed, fix the number" edit takes. + // `checked` counts languages, so that row still increments + // it and the non-vacuity guard below reads satisfied. + assert!( + !literals.is_empty(), + "{lang:?}: the literal-operand list is empty; this language asserted nothing" + ); assert_eq!( literals.len(), expected_kinds, From 69f4ad00afe070e6688e384e44f6062bbfd7848a Mon Sep 17 00:00:00 2001 From: Elijah Zupancic Date: Tue, 15 Sep 2026 07:03:26 -0700 Subject: [PATCH 19/25] docs(changelog): describe ABC drift by the axis that moves MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Seven Unreleased entries said `abc.conditions` and `abc.magnitude` rise or fall "by one". Only the conditions count does: magnitude and value are sqrt(A^2 + B^2 + C^2) over the whole vector, so their deltas depend on what the vector already held — the Elixir snapshot this branch moved went conditions 2 -> 1 with magnitude 2.236 -> 1.414. Two of the seven also called `abc.conditions` and `abc.magnitude` gated threshold metrics. Neither is a threshold name: `bca check` accepts `abc`, which extracts the per-space magnitude. Refs PR #1476. --- CHANGELOG.md | 60 +++++++++++++++++++++++++++++----------------------- 1 file changed, 33 insertions(+), 27 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1bd41c16..cc20a4b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -171,10 +171,14 @@ for historical reference. construct in the survey that pays on two ABC axes, which is correct: it binds a name *and* decides a branch, and the axes are independent measurements rather than a partition. **Metric drift:** - `abc.conditions`, `abc.magnitude` and `abc.value` rise by one per - relational operator written outside a boolean slot in C#, Java, - Groovy, Kotlin and Ruby, and by one per macro (Rust) or walrus - (Python) predicate inside one; `abc` is a gated threshold metric. + `abc.conditions` rises by one per relational operator written outside + a boolean slot in C#, Java, Groovy, Kotlin and Ruby, and by one per + macro (Rust) or walrus (Python) predicate inside one. Only the + conditions count moves by one: `abc.magnitude` and `abc.value` are + `sqrt(A² + B² + C²)` over the whole vector, so how far they move + depends on what that vector already held. The gated threshold metric + is `abc`, which reads the magnitude — `abc.conditions` and + `abc.magnitude` are not threshold names. Cyclomatic is unaffected, and no score inside a boolean slot moves, so a construct already counted is not counted twice. One of the 1,610 integration snapshots moves — serde's `serde_derive/src/dummy.rs`, @@ -242,10 +246,10 @@ for historical reference. needed nothing: `quoted_word`, `braced_word_simple` and `number` already cover every literal an `expr {…}` operand can hold, verified by measurement rather than assumed. **Metric drift:** - `abc.conditions`, `abc.magnitude` and `abc.value` rise by one per - non-numeric literal operand in a boolean slot, in the eleven - languages listed; `abc` is a gated threshold metric. Cyclomatic is - unaffected. 85 of the 384 pdf.js JavaScript integration snapshots + `abc.conditions` rises by one per non-numeric literal operand in a + boolean slot, in the eleven languages listed, and the derived + `abc.magnitude` and `abc.value` move with it; `abc` is the gated + threshold metric. Cyclomatic is unaffected. 85 of the 384 pdf.js JavaScript integration snapshots move, all in the `conditions` family and all upward; no other corpus moves, the DeepSpeech tree being entirely C/C++, the six-file PHP corpus carrying no literal in a boolean slot, and no corpus carrying @@ -268,10 +272,10 @@ for historical reference. (`print $_ for @list;`) is deliberately excluded — it iterates a list and has no boolean test, which the grammar itself records by naming that slot `list` rather than `condition`. **Metric drift:** Perl - `abc.conditions` and `abc.magnitude` rise by one per `if` / `unless` - / `while` / `until` statement modifier, plus whatever its predicate - contributes; both are gated threshold metrics. No integration - snapshot moves — the corpora contain no Perl. + `abc.conditions` rises by one per `if` / `unless` / `while` / `until` + statement modifier, plus whatever its predicate contributes, and the + derived `abc.magnitude` and `abc.value` move with it; `abc` is the + gated threshold metric. No integration snapshot moves — the corpora contain no Perl. - **C# `goto case` counted as a decision** (#1450, #1451). `goto case 2;` spells the same `case` keyword token as a real `switch` arm — the @@ -287,9 +291,10 @@ for historical reference. Cognitive is unaffected and unchanged: it models the construct on the `goto_statement` node, +1 as an unstructured jump per SonarSource §B2, so a `goto case` remains a jump there and merely stops also being an - arm. **Metric drift:** C# `abc.conditions`, `abc.magnitude` and - `cyclomatic` each fall by one per `goto case`; all three are gated - threshold metrics. No integration snapshot moves — the C# corpus + arm. **Metric drift:** C# `abc.conditions` and `cyclomatic` each fall + by one per `goto case`, and the derived `abc.magnitude` and + `abc.value` move with the conditions count; `abc` and `cyclomatic` + are the gated threshold metrics. No integration snapshot moves — the C# corpus contains no `goto case`. - **C# ABC counts a null-forgiving predicate** (#1463). `if (b!)` scored @@ -307,9 +312,10 @@ for historical reference. arithmetic, and no token arm counts the `!` itself. The condition slot now also asks the operand peel which wrappers it unwraps instead of restating the list, the divergence #1459 and #1466 fixed in Kotlin and - Groovy. **Metric drift:** C# `abc.conditions` and `abc.magnitude` rise - by one per null-forgiving expression standing as a predicate or a - `&&` / `||` operand. No integration snapshot moves: every + Groovy. **Metric drift:** C# `abc.conditions` rises by one per + null-forgiving expression standing as a predicate or a `&&` / `||` + operand, and the derived `abc.magnitude` and `abc.value` move with + it. No integration snapshot moves: every `postfix_unary_expression` in the corpus is an `i++` or an `n--`. - **Groovy ABC counts an indexing or navigation predicate** (#1466). @@ -328,12 +334,12 @@ for historical reference. restating the list — it had claimed every `unary_expression` while the peel handled only the `!` spelling — and the peel reads that operand by grammar field, so `if (! /*c*/ a)` scores like `if (!a)` instead of - reading the comment. **Metric drift:** Groovy `abc.conditions` and - `abc.magnitude` rise by one per indexing, safe-indexing, - safe-navigation, safe-chain-dot or direct-field-access expression - standing as a predicate or a `&&` / `||` operand, and per `!`-negated - predicate whose operand is preceded by a comment. No integration - snapshot moves: no corpus carries a Groovy file. + reading the comment. **Metric drift:** Groovy `abc.conditions` rises + by one per indexing, safe-indexing, safe-navigation, safe-chain-dot + or direct-field-access expression standing as a predicate or a `&&` / + `||` operand, and per `!`-negated predicate whose operand is preceded + by a comment; the derived `abc.magnitude` and `abc.value` move with + it. No integration snapshot moves: no corpus carries a Groovy file. - **Groovy's safe-indexing operator counts as a decision** (#1471). `?[` short-circuits on a null receiver exactly as `?.` and `??.` do, @@ -486,9 +492,9 @@ for historical reference. **Metric drift:** Java, Ruby and Elixir `cyclomatic` (standard and modified) gain one per guard, and `wmc` and `mi` move with it, so a `wmc` or `mi` threshold can newly fire on an unedited file carrying - guarded arms. `abc.conditions` and `abc.magnitude` gain one per guard - in Java, Rust, Python and Ruby for any guard not already - operator-shaped. Elixir `abc.conditions` is unchanged for a guard that + guarded arms. `abc.conditions` gains one per guard in Java, Rust, + Python and Ruby for any guard not already operator-shaped, and the + derived `abc.magnitude` and `abc.value` move with it. Elixir `abc.conditions` is unchanged for a guard that was already scoring through its own operand, *falls* by one per operator-spelled guard (`when n > 5`) and by one per typespec `when`. Two Elixir arms move with the slot, because the slot presumes an From b6d9bde244a2c07bd21c83463db9010eb0bc7f9e Mon Sep 17 00:00:00 2001 From: Elijah Zupancic Date: Tue, 15 Sep 2026 08:19:36 -0700 Subject: [PATCH 20/25] test(abc): cover the defguard anchor in the Elixir guard gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `elixir_when_is_guard` accepts a definition `Call`'s `arguments` anchor when the keyword is a method macro *or* one of `defguard` / `defguardp`. `elixir_is_method_macro` spells only `def` / `defp` / `defmacro` / `defmacrop`, so the second disjunct is its own path, and no fixture in the suite spelled `defguard` — deleting the `matches!` failed zero of the 3,460 lib tests. `defguard` is where a guard is most plainly a decision, so an ungated head would have scored it zero on both axes while the `def f(x) when g` spelling it expands into scores one. Asserted on the module rather than a member, since `defguard` opens no function space; the guard bodies are calls rather than comparisons so neither can supply a condition of its own, and `plain` pins the module's rows to the two guards and nothing else. Raises patch coverage on PR #1476. --- src/metrics/abc.rs | 70 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) diff --git a/src/metrics/abc.rs b/src/metrics/abc.rs index 6872ce59..e9f0b6ac 100644 --- a/src/metrics/abc.rs +++ b/src/metrics/abc.rs @@ -13904,6 +13904,76 @@ end ); }); } + + // The other half of that gate's allowlist. `elixir_when_is_guard` + // accepts a definition `Call`'s `arguments` anchor when the call's + // keyword is a method macro *or* one of `defguard` / `defguardp`, + // and the second disjunct is its own path: `elixir_is_method_macro` + // spells only `def` / `defp` / `defmacro` / `defmacrop`, so a + // `defguard` head reaches the anchor test solely through the + // `matches!`. Nothing in the suite spelled `defguard` before this, + // which left the disjunct scored by no fixture at all. + // + // `defguard` is where a guard is most obviously a decision — the + // macro exists to name one — so an ungated head would have scored + // the construct zero on both axes while the `def f(x) when g` + // spelling it expands into scores one. + // + // Read off the *module*, not off a member: `defguard` is not a + // method macro, so it opens no function space and its guard lands + // in the container's body. `plain` is the control that pins the + // module's rows to the two guards and nothing else, and the guard + // bodies are calls (`is_integer/1`, `is_atom/1`) rather than + // comparisons so neither can supply a condition of its own. + #[test] + fn elixir_defguard_head_is_a_guard() { + let src = "defmodule T do + defguard is_int(x) when is_integer(x) + defguardp is_at(x) when is_atom(x) + def plain(x) do + x + end +end +"; + assert_fixture_spells::( + src, + "foo.ex", + &[( + Elixir::When as u16, + 2, + "one per `defguard` / `defguardp` head", + )], + ); + check_func_space::(src, "foo.ex", |space| { + let module = &space.spaces[0]; + assert_eq!( + ( + module.metrics.abc.conditions(), + module.metrics.cyclomatic.cyclomatic() + ), + (2, 3), + "each of `defguard` and `defguardp` carries one guard, on \ + both axes (cyclomatic counts from its base of 1)" + ); + let members: Vec<(u64, u64)> = module + .spaces + .iter() + .map(|m| { + ( + m.metrics.abc.conditions(), + m.metrics.cyclomatic.cyclomatic(), + ) + }) + .collect(); + assert_eq!( + members, + vec![(0, 1)], + "`defguard` opens no function space, so `plain` is the \ + module's only member and scores nothing" + ); + }); + } + // #1461's structural half: a relational operator scores by *use*, // a value-bearing operand scores in a boolean *slot*. // From e485ecf7f2c946d6468a98821d9d0b1261ee006f Mon Sep 17 00:00:00 2001 From: Elijah Zupancic Date: Tue, 15 Sep 2026 08:19:50 -0700 Subject: [PATCH 21/25] docs(abc): mark the infallible-lookup branches unreachable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every `None` arm named here is an `Option` on a lookup the pinned grammar cannot fail: a required field, or a wrapper's operator-plus- operand child pair. Each is spelled `?` or `let ... else` rather than `expect` because AGENTS.md bans the latter outside tests, which leaves a branch no fixture can take and nothing on the page saying so. Say so, with the evidence and the reason not to chase it: - csharp/groovy wrapper operands: `unary_expression` declares `operand` and `operator` required, and a parenthesized expression is `(` expr `)`. Only error recovery reaches the arms (`bool b = !;` parses to a one-child `prefix_unary_expression`), and that is invalid source, so pinning its numbers would make the grammar's present over-permissiveness the contract. - elixir: `binary_operator` declares `right` required; and the `When` arm's parent lookup cannot miss, because the gate it sits behind already walked that ancestor chain and returns false when it is empty. Not re-plumbed out of the predicate, whose job is to be one boolean the Abc and Cyclomatic impls share. - npa/shared: a `when` token's chain always holds its own operator, and that operator always sits under something, because the Elixir root is `source`. - perl: `condition` is required on all five modifier productions, and an `arguments` node cannot be childless. The `else` arm takes a `parenthesized_argument`, which the parser emits in this slot only for the empty spelling `EXPR if ();` — valid Perl, but an empty wrapper peels to nothing, so that arm and a bare `None` score alike. Measured by perturbation: no test moves either way. A test there would pin a value neither branch decides. Comment-only; no behaviour change. Documents the uncoverable tail of PR #1476's patch coverage. --- src/metrics/abc/csharp.rs | 9 +++++++++ src/metrics/abc/elixir.rs | 12 ++++++++++++ src/metrics/abc/groovy.rs | 8 ++++++++ src/metrics/abc/perl.rs | 17 +++++++++++++++++ src/metrics/npa/shared.rs | 11 +++++++++++ 5 files changed, 57 insertions(+) diff --git a/src/metrics/abc/csharp.rs b/src/metrics/abc/csharp.rs index c5a1ad29..64fe1f46 100644 --- a/src/metrics/abc/csharp.rs +++ b/src/metrics/abc/csharp.rs @@ -66,6 +66,15 @@ use crate::*; // still score zero, because `child(1)` is the comment. Measured, not // assumed. That is #1455, which predates this change and is recorded // here rather than widened into it; the new arm adds no instance of it. +// +// Every `?` below is infallible for well-formed C# — each wrapper is +// the operator token plus its operand, so `child(0)` and `child(1)` +// both exist — and is spelled as an `Option` because `AGENTS.md` bans +// `expect` outside tests. Do not try to cover the `None` arms: only +// error recovery reaches them (`bool b = !;` parses to a one-child +// `prefix_unary_expression`, verified with `bca dump`), and that is +// invalid C#, so pinning its numbers would make the grammar's present +// over-permissiveness the contract (§6). fn csharp_wrapper_operand<'a>(node: &Node<'a>) -> Option<(Node<'a>, bool)> { use Csharp::*; diff --git a/src/metrics/abc/elixir.rs b/src/metrics/abc/elixir.rs index b3c8cfe5..7650d506 100644 --- a/src/metrics/abc/elixir.rs +++ b/src/metrics/abc/elixir.rs @@ -117,6 +117,11 @@ fn elixir_count_condition(condition: &Node, parent: &Node, conditions: &mut f64) // the slot this fills is the single alternative *this* token // introduces: one per token, never the whole chain once per token // (grammar-dispatch §5). +// +// `elixir_when_alternative` answers `None` only when a `when` +// `binary_operator` has no `right` child, which the grammar declares +// required — so the `if let`'s else is unreachable at the pin rather +// than untested. fn elixir_count_guard(when_operator: &Node, conditions: &mut f64) { if let Some((alternative, owner)) = npa::elixir_when_alternative(when_operator) { elixir_count_condition(&alternative, &owner, conditions); @@ -311,6 +316,13 @@ impl Abc for ElixirCode { // clause (`@spec f(a) :: a when a: integer`) spells the same // token: it scored a condition here against no decision // anywhere, on type syntax that branches on nothing. + // + // The `if let` cannot take its else: the gate already walked + // this token's ancestor chain and returns false when it is + // empty, so reaching the body proves the parent exists. It + // is not re-plumbed out of the predicate because that + // predicate's whole job (§7) is to be one boolean the `Abc` + // and `Cyclomatic` impls share. E::When if npa::elixir_when_is_guard(node, code, ancestors) => { if let Some(operator) = ancestors.parent(node) { elixir_count_guard(&operator, &mut stats.conditions); diff --git a/src/metrics/abc/groovy.rs b/src/metrics/abc/groovy.rs index b9c01932..8df72737 100644 --- a/src/metrics/abc/groovy.rs +++ b/src/metrics/abc/groovy.rs @@ -40,6 +40,14 @@ use crate::*; // `Option<(Node, bool)>` signature, not a generic function. C#'s // positional read over its aliased wrapper kinds is #1455's, not this // change's. +// +// Every `?` below is infallible at the pinned grammar and is spelled +// that way because `AGENTS.md` bans `expect` outside tests — do not try +// to cover the `None` arms. `unary_expression` declares `operand` and +// `operator` as required fields, and a `parenthesized_expression` is +// `(` expr `)`, so `child(1)` exists. Only error recovery on invalid +// Groovy can produce a shorter node, and pinning that would make the +// grammar's present over-permissiveness the contract (§6). fn groovy_wrapper_operand<'a>(node: &Node<'a>) -> Option<(Node<'a>, bool)> { use Groovy::*; diff --git a/src/metrics/abc/perl.rs b/src/metrics/abc/perl.rs index f8ba6147..fc00077f 100644 --- a/src/metrics/abc/perl.rs +++ b/src/metrics/abc/perl.rs @@ -286,6 +286,23 @@ fn perl_walk_for_statement(node: &Node, conditions: &mut f64) { // `Array` `(...)` wrapper uses, since a Perl comma list evaluates to // its last element in the scalar context a condition imposes — and // hand what it holds to the shared condition classifier. +// +// Three of the four paths below are unreachable or unobservable at the +// pinned grammar, and are spelled as `Option` rather than as an +// `expect` because `AGENTS.md` bans the latter outside tests. Do not +// try to cover them: +// +// - `condition` is a required field on all five modifier productions +// (node-types.json), so the early `return` needs error recovery. +// - The `else` arm takes a `parenthesized_argument`, which the parser +// emits in this slot only for the *empty* spelling `EXPR if ();` +// (valid Perl; anything with content resolves to `arguments` +// wrapping an `array`). An empty wrapper peels to nothing, so that +// arm and a bare `None` score alike — measured by perturbation, not +// assumed. A test would pin a value neither branch decides. +// - `perl_last_named_child` returns `None` only for an `arguments` +// node with no named child, which the grammar's comma-separated +// one-or-more list cannot produce. fn perl_walk_statement_modifier(node: &Node, conditions: &mut f64) { let Some(condition) = node.child_by_field_name("condition") else { return; diff --git a/src/metrics/npa/shared.rs b/src/metrics/npa/shared.rs index a9a53576..eeb72df2 100644 --- a/src/metrics/npa/shared.rs +++ b/src/metrics/npa/shared.rs @@ -681,6 +681,10 @@ fn elixir_is_when_operator(node: &Node) -> bool { /// token is what holds the `Abc` slot count level with the `Cyclomatic` /// decision count at every chain length (§8), and what stops a chain of /// n alternatives being counted once per token that can see it (§5). +/// +/// The `?` is infallible at the pinned grammar — `binary_operator` +/// declares `right` required — and is spelled as an `Option` because +/// `AGENTS.md` bans `expect` outside tests. pub(crate) fn elixir_when_alternative<'a>( when_operator: &Node<'a>, ) -> Option<(Node<'a>, Node<'a>)> { @@ -727,6 +731,13 @@ pub(crate) fn elixir_when_alternative<'a>( /// The climb stops at the first non-`when` ancestor, so an ordinary /// single guard pays no extra step and a chain pays one per alternative /// it lists — a bound set by the guard, not by the tree's depth. +/// +/// Both `return false` guards below are unreachable at the pin, not +/// untested: a `when` *token*'s chain always holds at least the +/// `binary_operator` it belongs to, and that operator always sits under +/// something, because the Elixir root is `source` and no `when` +/// operator can be it. They stay as defensive `else` arms because +/// `AGENTS.md` bans the `expect` that would replace them. pub(crate) fn elixir_when_is_guard<'a>( node: &Node<'a>, code: &'a [u8], From a958d392ba7ab9d9a04375194740f975717fcf11 Mon Sep 17 00:00:00 2001 From: Elijah Zupancic Date: Tue, 15 Sep 2026 08:22:21 -0700 Subject: [PATCH 22/25] ci(codecov): set a reachable patch-coverage floor `patch.default.target` was `auto`, which targets the project figure and is unreachable by construction for a grammar-dispatch patch. `AGENTS.md` bans `unwrap` / `expect` in non-test code, so every infallible node lookup is spelled `?` and each mints a `None` region no valid input can reach; partials were 12 of the 19 misses on PR #1476. The comment also records a second trap: Codecov drops a file whose diff GitHub's API refuses to send, so a heavily-edited file is absent from the patch rather than counted as missing. That removed the largest changed file from the denominator on #1476, which is why the published patch figure covered 303 lines and not the ~1,840 the branch touches. --- codecov.yml | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/codecov.yml b/codecov.yml index 23be30d8..d9ddcb93 100644 --- a/codecov.yml +++ b/codecov.yml @@ -13,8 +13,30 @@ coverage: project: default: informational: true + # A fixed floor rather than `auto` (which targets the project figure). + # `auto` is unreachable by construction for a grammar-dispatch patch: + # `AGENTS.md` bans `unwrap` / `expect` in non-test code, so every + # infallible node lookup is spelled `?` — `node.child(1)?` on a + # `parenthesized_expression`, `child_by_field_name("operand")?` on a + # `unary_expression` — and each mints a `None` region no valid input can + # reach. Partials dominate the miss list for that reason (12 of 19 on + # PR #1476). Raising the floor is the fix; adding an `expect` to win the + # region would trade an uncoverable branch for a panic in a library that + # parses untrusted source. + # + # Second thing to know before reading any patch number here: Codecov + # silently drops a file whose diff GitHub's API refuses to send (over + # ~3,000 changed lines), so a heavily-edited file is *absent* from the + # patch rather than counted as missing. The tell is `<ø>` in the + # per-file table instead of a percentage. On PR #1476 that removed + # `src/metrics/abc.rs` — the largest changed file — from the + # denominator entirely (283/303 lines). Production changes to a file of + # that size are invisible to patch coverage; judge them on the covered- + # region delta instead. patch: default: + target: 90% + threshold: 2% informational: true comment: From 7de897d690b3d38e598a85102fd3a02cd2c388f1 Mon Sep 17 00:00:00 2001 From: Elijah Zupancic Date: Tue, 15 Sep 2026 08:59:51 -0700 Subject: [PATCH 23/25] docs(ci): say why feature-matrix runs no tests The `feature-matrix` step comment explains why it runs clippy rather than `cargo check` but says nothing about the suite, so readers have assumed the legs exercise behaviour. They verify compilation only. Running the suite there is not currently possible: per-language tests are not feature-gated (30 of 3,482 `#[test]` fns in `src/`), and `Node` panics by contract on a grammar that is not compiled in, so `--no-default-features --features rust` fails 2,642 of 3,238 tests. Refs #1472 --- .github/workflows/ci.yml | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9d6881c4..54307665 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -497,6 +497,16 @@ jobs: # The `-- -D warnings` is belt-and-braces against that env var # being narrowed later. All ten pre-existing legs were verified # against the stricter command before it landed. + # + # These legs verify that the workspace *compiles* across feature + # combinations. They deliberately do not run the suite — that runs + # under `default` and `--all-features` in the `test` job — because + # running it here would fail in the thousands rather than report + # anything. Per-language tests are not feature-gated (30 of 3,482 + # `#[test]` fns in `src/`), and `Node` panics by contract on a + # grammar that is not compiled in, so `--no-default-features + # --features rust` fails 2,642 of 3,238 tests. Gating them is + # tracked in #1472. - run: cargo clippy --all-targets ${{ matrix.flags }} --locked -- -D warnings deny: From b8de5cc23b74181d9a011757c1e8118b68963eb4 Mon Sep 17 00:00:00 2001 From: Elijah Zupancic Date: Tue, 15 Sep 2026 09:00:06 -0700 Subject: [PATCH 24/25] ci(feature-matrix): gate union-gated test reachability MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `.claude/rules/testing.md` requires a test whose case list is built from `#[cfg(feature = …)]` rows to carry `#[cfg(any(feature = …))]` naming the union, so a build enabling none of them drops the subject instead of tripping its own `checked > 0` guard. `cargo clippy --all-targets` over a partial feature set catches the compile half of that. It cannot see the runtime half: a subject whose union gate is present but defeated — by a non-feature disjunct, or by a gate that does not reach the item — is a valid build that warns about nothing and fails only when that leg runs the test, which is how #1220 and PR #1221 shipped. `utils/check-feature-gates.py` scans `src/` for `mod` and `#[test] fn` items carrying such a gate (39 today, 34 of them test-bearing), resolves the leg's feature closure from the root manifest, and asks `cargo nextest list` whether any subject disjoint from that closure is still in the build. It runs as a step in each `feature-matrix` leg, where the flags it needs are already to hand. It is deliberately out of `make pre-commit`: it needs a test-binary build per feature set, and the question is only interesting for a set that enables none of some subject's rows. `make check-feature-gates FLAGS=…` is the manual spelling; the self-tests, which stub the cargo call, do run in the local gate. Refs #1472 --- .github/workflows/ci.yml | 12 + .pre-commit-config.yaml | 12 + Makefile | 35 +- utils/check-feature-gates-test.py | 395 ++++++++++++++++++ utils/check-feature-gates.py | 647 ++++++++++++++++++++++++++++++ 5 files changed, 1097 insertions(+), 4 deletions(-) create mode 100644 utils/check-feature-gates-test.py create mode 100755 utils/check-feature-gates.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 54307665..a2d96efc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -508,6 +508,18 @@ jobs: # --features rust` fails 2,642 of 3,238 tests. Gating them is # tracked in #1472. - run: cargo clippy --all-targets ${{ matrix.flags }} --locked -- -D warnings + - uses: taiki-e/install-action@fa23953489c080190314742a9b907f8e97c6767c # v2.87.10 + with: + tool: cargo-nextest + # The one thing a partial feature set breaks that the clippy run + # above cannot see (#1472): a test gated on the union of its rows + # must be *absent* when none of those features is enabled, not + # present and tripping its own `checked > 0` non-vacuity guard. + # That is a valid build, so nothing warns — it surfaces as a test + # failure on whichever leg enables none of the rows, which is how + # #1220 and PR #1221 shipped. `nextest list` answers it without + # running a test. + - run: ./utils/check-feature-gates.py ${{ matrix.flags }} deny: name: cargo-deny diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 4e164720..df43e211 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -417,6 +417,18 @@ repos: entry: python3 -m unittest -q utils/check-diagnostic-prefix-test.py pass_filenames: false + # Self-tests for the feature-gate reachability gate (#1472). Only + # the self-tests run here: the gate itself needs a test-binary + # build per feature set and lives in the `feature-matrix` CI job. + # Its scanner is the same hazard as the one above — a scan that + # stops matching reports a clean tree. + - id: check-feature-gates-test + name: check-feature-gates-test + language: system + files: '^utils/check-feature-gates(-test)?\.py$' + entry: python3 -m unittest -q utils/check-feature-gates-test.py + pass_filenames: false + # Safety-doc pin gate — the module doc of # big-code-analysis-py/src/node.rs is the canonical soundness # argument for this workspace's only sanctioned `unsafe` block, diff --git a/Makefile b/Makefile index 0c8db0bc..5c4557f1 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 worktree-setup worktree-setup-test 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 snapshot-anchors-test rustfmt-bail rustfmt-bail-test grammar-marker-sync grammar-marker-sync-test check-versions check-excluded-manifests check-excluded-manifests-test check-ruff-lockstep check-ruff-lockstep-test check-publish-metadata check-publish-metadata-test check-manpage-assets check-manpage-drift-test check-diagnostic-prefix check-diagnostic-prefix-test check-safety-doc-pin check-safety-doc-pin-test gate-status-test check-tools-test 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 fuzz-check fuzz-smoke fuzz-replay fuzz-run fuzz-tmin _check-find _pc-all _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-snapshot-anchors-test _pc-rustfmt-bail _pc-rustfmt-bail-test _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-ruff-lockstep _pc-check-ruff-lockstep-test _pc-check-publish-metadata _pc-check-publish-metadata-test _pc-check-manpage-assets _pc-check-manpage-drift-test _pc-check-diagnostic-prefix _pc-check-diagnostic-prefix-test _pc-check-safety-doc-pin _pc-check-safety-doc-pin-test _pc-worktree-setup-test _pc-gate-status-test _pc-check-tools-test _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-all _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-snapshot-anchors-test _ci-rustfmt-bail _ci-rustfmt-bail-test _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-ruff-lockstep _ci-check-ruff-lockstep-test _ci-check-publish-metadata _ci-check-publish-metadata-test _ci-check-manpage-assets _ci-check-manpage-drift-test _ci-check-diagnostic-prefix _ci-check-diagnostic-prefix-test _ci-check-safety-doc-pin _ci-check-safety-doc-pin-test _ci-worktree-setup-test _ci-gate-status-test _ci-check-tools-test _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 worktree-setup worktree-setup-test 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 snapshot-anchors-test rustfmt-bail rustfmt-bail-test grammar-marker-sync grammar-marker-sync-test check-versions check-excluded-manifests check-excluded-manifests-test check-ruff-lockstep check-ruff-lockstep-test check-publish-metadata check-publish-metadata-test check-manpage-assets check-manpage-drift-test check-diagnostic-prefix check-diagnostic-prefix-test check-feature-gates check-feature-gates-test check-safety-doc-pin check-safety-doc-pin-test gate-status-test check-tools-test 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 fuzz-check fuzz-smoke fuzz-replay fuzz-run fuzz-tmin _check-find _pc-all _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-snapshot-anchors-test _pc-rustfmt-bail _pc-rustfmt-bail-test _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-ruff-lockstep _pc-check-ruff-lockstep-test _pc-check-publish-metadata _pc-check-publish-metadata-test _pc-check-manpage-assets _pc-check-manpage-drift-test _pc-check-diagnostic-prefix _pc-check-diagnostic-prefix-test _pc-check-feature-gates-test _pc-check-safety-doc-pin _pc-check-safety-doc-pin-test _pc-worktree-setup-test _pc-gate-status-test _pc-check-tools-test _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-all _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-snapshot-anchors-test _ci-rustfmt-bail _ci-rustfmt-bail-test _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-ruff-lockstep _ci-check-ruff-lockstep-test _ci-check-publish-metadata _ci-check-publish-metadata-test _ci-check-manpage-assets _ci-check-manpage-drift-test _ci-check-diagnostic-prefix _ci-check-diagnostic-prefix-test _ci-check-feature-gates-test _ci-check-safety-doc-pin _ci-check-safety-doc-pin-test _ci-worktree-setup-test _ci-gate-status-test _ci-check-tools-test _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: @@ -146,6 +146,8 @@ help: @echo " check-manpage-drift-test Self-tests for the man-page drift gate" @echo " check-diagnostic-prefix Block capitalised Warning:/Error:/Note: literals" @echo " check-diagnostic-prefix-test Self-tests for the diagnostic-prefix gate" + @echo " check-feature-gates Assert union-gated tests are absent under a disjoint feature set" + @echo " check-feature-gates-test Self-tests for the feature-gates gate" @echo " check-safety-doc-pin Assert node.rs's unsafe soundness doc cites the live tree-sitter pin" @echo " check-safety-doc-pin-test Self-tests for the safety-doc-pin gate" @echo " worktree-setup-test Self-tests for the worktree-setup submodule classifier" @@ -637,6 +639,24 @@ check-diagnostic-prefix-test: @echo "Running check-diagnostic-prefix self-tests..." @(cd $(BASE_DIR) && python3 -m unittest -q utils/check-diagnostic-prefix-test.py) +# Feature-gate reachability (#1472). Deliberately NOT in `pre-commit` / +# `ci`: it needs a test-binary build per feature set, and the answer is +# only interesting for a feature set that enables none of some subject's +# rows — which is what the `feature-matrix` job in ci.yml already builds +# ten of. This target is the manual spelling; pass a leg's flags, e.g. +# make check-feature-gates FLAGS="--no-default-features --features go -p big-code-analysis" +check-feature-gates: + @echo "Checking union-gated test reachability..." + @(cd $(BASE_DIR) && python3 utils/check-feature-gates.py $(FLAGS)) + +# Self-tests for the feature-gates gate. Pure Python with the cargo call +# stubbed, so this one *is* cheap enough for the parallel gate — and a +# scanner that silently stops matching reports a clean tree, which is +# precisely why it needs its own arm. +check-feature-gates-test: + @echo "Running check-feature-gates self-tests..." + @(cd $(BASE_DIR) && python3 -m unittest -q utils/check-feature-gates-test.py) + # Safety-doc pin gate (#1057). The module doc of # big-code-analysis-py/src/node.rs is the canonical soundness argument # for this workspace's only sanctioned `unsafe` block, and it reasons @@ -1372,7 +1392,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-snapshot-anchors-test _ci-rustfmt-bail _ci-rustfmt-bail-test _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-ruff-lockstep _ci-check-ruff-lockstep-test _ci-check-publish-metadata _ci-check-publish-metadata-test _ci-check-manpage-assets _ci-check-manpage-drift-test _ci-check-diagnostic-prefix _ci-check-diagnostic-prefix-test _ci-check-safety-doc-pin _ci-check-safety-doc-pin-test _ci-worktree-setup-test _ci-gate-status-test _ci-check-tools-test _ci-enums-check _ci-enums-codegen-drift _ci-enums-codegen-drift-test + _ci-actionlint _ci-snapshot-anchors _ci-snapshot-anchors-test _ci-rustfmt-bail _ci-rustfmt-bail-test _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-ruff-lockstep _ci-check-ruff-lockstep-test _ci-check-publish-metadata _ci-check-publish-metadata-test _ci-check-manpage-assets _ci-check-manpage-drift-test _ci-check-diagnostic-prefix _ci-check-diagnostic-prefix-test _ci-check-feature-gates-test _ci-check-safety-doc-pin _ci-check-safety-doc-pin-test _ci-worktree-setup-test _ci-gate-status-test _ci-check-tools-test _ci-enums-check _ci-enums-codegen-drift _ci-enums-codegen-drift-test # --------------------------------------------------------------------------- # Maintenance @@ -1544,7 +1564,7 @@ _pc-all: $(MAKE) -j --output-sync=target \ _pc-test \ _pc-shellcheck _pc-markdown-lint _pc-toml-lint _pc-makefile-check \ - _pc-actionlint _pc-snapshot-anchors _pc-snapshot-anchors-test _pc-rustfmt-bail _pc-rustfmt-bail-test _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-ruff-lockstep _pc-check-ruff-lockstep-test _pc-check-publish-metadata _pc-check-publish-metadata-test _pc-check-manpage-assets _pc-check-manpage-drift-test _pc-check-diagnostic-prefix _pc-check-diagnostic-prefix-test _pc-check-safety-doc-pin _pc-check-safety-doc-pin-test _pc-worktree-setup-test _pc-gate-status-test _pc-check-tools-test _pc-enums-check _pc-enums-codegen-drift _pc-enums-codegen-drift-test \ + _pc-actionlint _pc-snapshot-anchors _pc-snapshot-anchors-test _pc-rustfmt-bail _pc-rustfmt-bail-test _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-ruff-lockstep _pc-check-ruff-lockstep-test _pc-check-publish-metadata _pc-check-publish-metadata-test _pc-check-manpage-assets _pc-check-manpage-drift-test _pc-check-diagnostic-prefix _pc-check-diagnostic-prefix-test _pc-check-feature-gates-test _pc-check-safety-doc-pin _pc-check-safety-doc-pin-test _pc-worktree-setup-test _pc-gate-status-test _pc-check-tools-test _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 @@ -1554,7 +1574,7 @@ _ci-all: $(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-snapshot-anchors-test _ci-rustfmt-bail _ci-rustfmt-bail-test _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-ruff-lockstep _ci-check-ruff-lockstep-test _ci-check-publish-metadata _ci-check-publish-metadata-test _ci-check-manpage-assets _ci-check-manpage-drift-test _ci-check-diagnostic-prefix _ci-check-diagnostic-prefix-test _ci-check-safety-doc-pin _ci-check-safety-doc-pin-test _ci-worktree-setup-test _ci-gate-status-test _ci-check-tools-test _ci-enums-check _ci-enums-codegen-drift _ci-enums-codegen-drift-test \ + _ci-actionlint _ci-snapshot-anchors _ci-snapshot-anchors-test _ci-rustfmt-bail _ci-rustfmt-bail-test _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-ruff-lockstep _ci-check-ruff-lockstep-test _ci-check-publish-metadata _ci-check-publish-metadata-test _ci-check-manpage-assets _ci-check-manpage-drift-test _ci-check-diagnostic-prefix _ci-check-diagnostic-prefix-test _ci-check-feature-gates-test _ci-check-safety-doc-pin _ci-check-safety-doc-pin-test _ci-worktree-setup-test _ci-gate-status-test _ci-check-tools-test _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 # --------------------------------------------------------------------------- @@ -1595,6 +1615,7 @@ _ci-all: # ├── _pc-check-diagnostic-prefix # ├── _pc-check-diagnostic-prefix-test # ├── _pc-check-safety-doc-pin +# ├── _pc-check-feature-gates-test # ├── _pc-check-safety-doc-pin-test # ├── _pc-worktree-setup-test # ├── _pc-gate-status-test @@ -1722,6 +1743,9 @@ _pc-check-diagnostic-prefix-test: _pc-fmt _pc-check-safety-doc-pin: _pc-fmt $(MAKE) check-safety-doc-pin +_pc-check-feature-gates-test: _pc-fmt + $(MAKE) check-feature-gates-test + _pc-check-safety-doc-pin-test: _pc-fmt $(MAKE) check-safety-doc-pin-test @@ -1925,6 +1949,9 @@ _ci-check-diagnostic-prefix-test: _ci-check-safety-doc-pin: $(MAKE) check-safety-doc-pin +_ci-check-feature-gates-test: + $(MAKE) check-feature-gates-test + _ci-check-safety-doc-pin-test: $(MAKE) check-safety-doc-pin-test diff --git a/utils/check-feature-gates-test.py b/utils/check-feature-gates-test.py new file mode 100644 index 00000000..cdd542ba --- /dev/null +++ b/utils/check-feature-gates-test.py @@ -0,0 +1,395 @@ +#!/usr/bin/env python3 +"""Tests for check-feature-gates.py. + +Three kinds of test, matching the sibling gates' pattern: + +* Unit tests for the subject scanner over fixture Rust text — the + single-line and multi-line ``#[cfg(any(feature = …))]`` spellings, the + ``all(test, any(…))`` nesting, and the shapes that must *not* be + recorded (a gate quoted inside a fixture string, a commented-out one, + an inverted ``not(any(…))``). +* Unit tests for cargo flag parsing and the feature closure, which is + what stops ``default = ["all-languages"]`` from reading as "one + feature enabled". +* ``main()`` tests pinning both directions with ``list_tests`` stubbed, + so the self-test needs no cargo and does not rot when the real + subjects change: a correctly-gated subject passes, and a subject whose + union gate is defeated — so the build still contains it — fails. + +Fixture text throughout, never the live tree, with one exception: a +smoke test asserting the scanner still finds subjects in ``src/``. That +one is the gate's own non-vacuity guard, and a gate that silently stops +finding anything is the defect this whole file exists to prevent. + +Run with: + python3 -m unittest -q utils/check-feature-gates-test.py +""" + +from __future__ import annotations + +import contextlib +import importlib.util +import io +import pathlib +import sys +import types +import unittest + +UTILS_DIR = pathlib.Path(__file__).resolve().parent +REPO_ROOT = UTILS_DIR.parent +SCRIPT_SRC = UTILS_DIR / "check-feature-gates.py" + + +def _load_module() -> types.ModuleType: + spec = importlib.util.spec_from_file_location("check_feature_gates", SCRIPT_SRC) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + # Registered before execution because `@dataclass` resolves a + # field's forward reference through `sys.modules[cls.__module__]`; + # an unregistered module makes that lookup `None` and the decorator + # raises at import time. + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +gate = _load_module() + + +# A correctly-gated module: the union names both features its two tests +# use, so a build enabling neither drops it. This is the shape #1220 and +# PR #1221 established and the one the gate must leave alone. +WELL_GATED = """\ +#[cfg(all(test, any(feature = "php", feature = "groovy")))] +mod hidden_literal_supertypes { + #[test] + #[cfg(feature = "php")] + fn php_hidden_string_supertype_is_unreachable() {} +} +""" + +# The same module with the union gate defeated by a non-feature +# disjunct. `cfg(test)` is true in every test build, so the `any(…)` +# gates nothing and the module is present however few languages are +# compiled in — while its `checked > 0` guard still has no rows to +# count. Nothing in `cargo clippy --all-targets -- -D warnings` says a +# word about this; it is the runtime half of #1472. +DEFEATED_UNION = """\ +#[cfg(all(test, any(test, feature = "php", feature = "groovy")))] +mod hidden_literal_supertypes { + #[test] + fn php_hidden_string_supertype_is_unreachable() {} +} +""" + +# The `hidden_literal_supertypes` bug as it actually shipped: the module +# carries no union at all. Pinned as a *known blind spot* — removing the +# gate removes the subject, so a scan of declared gates cannot see it. +# The compile half (unused imports under `-D warnings`) is what caught +# it, and that is clippy's job, not this gate's. +UNGATED_MODULE = """\ +#[cfg(test)] +mod hidden_literal_supertypes { + #[test] + #[cfg(feature = "php")] + fn php_hidden_string_supertype_is_unreachable() {} +} +""" + + +class ScannerTest(unittest.TestCase): + def test_single_line_union_on_a_test_fn(self) -> None: + source = """\ +#[test] +#[cfg(any(feature = "perl", feature = "ruby"))] +fn the_baseline_values_are_one_and_four() {} +""" + (subject,) = gate.scan_source(source, "x.rs") + self.assertEqual(subject.kind, "fn") + self.assertEqual(subject.name, "the_baseline_values_are_one_and_four") + self.assertEqual(subject.features, frozenset({"perl", "ruby"})) + self.assertTrue(subject.is_test_fn) + self.assertTrue(subject.carries_tests) + + def test_multi_line_union_on_a_module(self) -> None: + """The spelling most subjects actually use. + + A single-line `rg` over the tree finds 20 gates; the tree holds + 39, because the wide unions wrap. A scanner that only read one + line would silently check half the subjects. + """ + source = """\ +#[cfg(test)] +#[cfg(any( + feature = "java", + feature = "javascript", + feature = "kotlin" +))] +mod nameless_construct_boundaries { +} +""" + (subject,) = gate.scan_source(source, "x.rs") + self.assertEqual(subject.kind, "mod") + self.assertEqual( + subject.features, frozenset({"java", "javascript", "kotlin"}) + ) + self.assertTrue(subject.carries_tests) + + def test_all_test_any_nesting_is_recognised(self) -> None: + (subject,) = gate.scan_source(WELL_GATED, "x.rs") + self.assertEqual(subject.features, frozenset({"php", "groovy"})) + self.assertTrue(subject.is_cfg_test) + + def test_intervening_attributes_and_comments_do_not_break_the_run(self) -> None: + source = """\ +#[cfg(any(feature = "bash", feature = "php"))] +// A comment between the gate and the item is legal Rust. +#[allow(clippy::needless_pass_by_value)] +fn assert_four_code_rows() {} +""" + (subject,) = gate.scan_source(source, "x.rs") + self.assertEqual(subject.name, "assert_four_code_rows") + self.assertEqual(subject.features, frozenset({"bash", "php"})) + + def test_a_helper_fn_is_scanned_but_not_checked(self) -> None: + """No `#[test]`, so no test name exists for nextest to report. + + Its absence under a disjoint feature set is a compile-time + property the leg's clippy run already covers. + """ + source = """\ +#[cfg(any(feature = "mozjs", feature = "typescript"))] +fn assert_class_static_block_space(lang: LANG) {} +""" + (subject,) = gate.scan_source(source, "x.rs") + self.assertFalse(subject.carries_tests) + + def test_a_gate_inside_a_raw_string_fixture_is_not_a_subject(self) -> None: + """Rust fixtures in this tree are raw strings of real Rust. + + `src/spaces_tests.rs` embeds a `cfg(any(feature = …))` as test + input; recorded as a subject it would be permanently + unsatisfiable, since no build can make a string literal absent. + """ + source = '''\ +const SOURCE: &str = r#" +#[cfg(any(feature = "kotlin", feature = "java"))] +mod embedded_example {} +"#; +''' + self.assertEqual(gate.scan_source(source, "x.rs"), []) + + def test_a_gate_inside_a_block_comment_does_not_gate_the_code_below(self) -> None: + """The comment closes on the attribute's own line. + + That is what makes this shape discriminating: the item below is + live code outside every span, so only checking the *item* for a + comment span is not enough — the attribute has to be checked + too, or a commented-out gate is attributed to a real function + that carries none. + """ + source = """\ +/* stale gate, kept for reference: +#[cfg(any(feature = "kotlin", feature = "java"))] */ +fn real_fn() {} +""" + self.assertEqual(gate.scan_source(source, "x.rs"), []) + + def test_a_commented_out_gate_is_not_a_subject(self) -> None: + source = """\ +// #[cfg(any(feature = "a", feature = "b"))] +// fn documented_example() {} +fn real_fn() {} +""" + self.assertEqual(gate.scan_source(source, "x.rs"), []) + + def test_an_inverted_union_is_not_a_subject(self) -> None: + """`not(any(…))` is present precisely when none is enabled. + + That is the opposite claim, so recording it would turn a + deliberate fallback into a permanent failure. + """ + source = """\ +#[test] +#[cfg(not(any(feature = "perl", feature = "ruby")))] +fn the_fallback_path() {} +""" + self.assertEqual(gate.scan_source(source, "x.rs"), []) + + def test_an_ungated_module_is_a_known_blind_spot(self) -> None: + """The shipped `hidden_literal_supertypes` shape, pinned as absent. + + Deleting a gate deletes the subject, so a scan of declared gates + has nothing to find. Asserted rather than left implicit so the + next reader does not assume coverage this gate does not have. + """ + self.assertEqual(gate.scan_source(UNGATED_MODULE, "x.rs"), []) + + +class FlagParsingTest(unittest.TestCase): + def test_feature_spellings(self) -> None: + for argv, expected in [ + (["--features", "a,b"], {"a", "b"}), + (["--features=a,b"], {"a", "b"}), + (["-F", "a b"], {"a", "b"}), + (["--features", "a", "--features", "b"], {"a", "b"}), + ]: + with self.subTest(argv=argv): + self.assertEqual(set(gate.parse_build_flags(argv).requested), expected) + + def test_package_and_default_suppression(self) -> None: + flags = gate.parse_build_flags( + ["--no-default-features", "--features", "go", "-p", "big-code-analysis"] + ) + self.assertEqual(flags.package, "big-code-analysis") + self.assertTrue(flags.no_default_features) + + def test_default_features_resolve_transitively(self) -> None: + """`default = ["all-languages"]` must expand to the languages. + + Without the closure every subject would read as disjoint under + the default leg and the gate would fail the whole matrix. + """ + table = { + "default": ["all-languages"], + "all-languages": ["go", "rust", "dep:gix", "other-crate/feat"], + "go": [], + "rust": [], + } + enabled = gate.resolve_features(table, gate.parse_build_flags([])) + self.assertIn("go", enabled) + self.assertIn("rust", enabled) + # `dep:` and `crate/feature` entries name nothing this crate's + # own `cfg(feature = …)` can test. + self.assertNotIn("dep:gix", enabled) + self.assertNotIn("other-crate/feat", enabled) + + none = gate.resolve_features( + table, gate.parse_build_flags(["--no-default-features"]) + ) + self.assertEqual(none, set()) + + def test_all_features_enables_every_declared_feature(self) -> None: + table = {"default": [], "go": [], "rust": []} + enabled = gate.resolve_features(table, gate.parse_build_flags(["--all-features"])) + self.assertEqual(enabled, {"default", "go", "rust"}) + + +class SubjectMatcherTest(unittest.TestCase): + def test_the_match_is_on_whole_path_segments(self) -> None: + """A prefix must not match, or a rename silently widens the check.""" + subject = gate.scan_source( + '#[test]\n#[cfg(any(feature = "go", feature = "rust"))]\nfn a_type() {}\n', + "x.rs", + )[0] + matcher = gate.subject_matcher(subject) + self.assertTrue(matcher.search("metrics::container_scope_tests::a_type")) + self.assertTrue(matcher.search("a_type::inner")) + self.assertFalse(matcher.search("metrics::a_type_declared_inside")) + + +class MainTest(unittest.TestCase): + """Both directions, with the build stubbed out. + + `list_tests` is the only part that shells out to cargo; replacing it + lets the self-test pin the decision rather than the toolchain. + """ + + def _run( + self, argv: list[str], source: str, listed: list[str] + ) -> tuple[int, str, str]: + subjects = gate.scan_source(source, "fixture.rs") + out, err = io.StringIO(), io.StringIO() + original_scan, original_list = gate.scan_tree, gate.list_tests + gate.scan_tree = lambda _root: subjects + gate.list_tests = lambda _flags: listed + try: + with ( + contextlib.redirect_stdout(out), + contextlib.redirect_stderr(err), + ): + code = gate.main(argv) + finally: + gate.scan_tree, gate.list_tests = original_scan, original_list + return code, out.getvalue(), err.getvalue() + + LEG = ["--no-default-features", "--features", "go", "-p", "big-code-analysis"] + + def test_a_correctly_gated_subject_passes(self) -> None: + code, out, err = self._run(self.LEG, WELL_GATED, []) + self.assertEqual(code, 0, err) + self.assertIn("feature-gates: OK", out) + + def test_a_defeated_union_gate_fails(self) -> None: + code, _, err = self._run( + self.LEG, + DEFEATED_UNION, + ["metrics::abc::hidden_literal_supertypes::php_hidden_string_supertype"], + ) + self.assertEqual(code, 1) + self.assertIn("hidden_literal_supertypes", err) + self.assertIn("none enabled here", err) + + def test_an_enabled_row_means_nothing_to_verify(self) -> None: + """A leg enabling one of the union's features skips the subject. + + Pinned because the subject *is* present there, and treating + presence as the failure would red-X every default leg. + """ + leg = ["--no-default-features", "--features", "php", "-p", "big-code-analysis"] + code, out, err = self._run(leg, WELL_GATED, ["x::hidden_literal_supertypes::y"]) + self.assertEqual(code, 0, err) + self.assertIn("nothing to verify", out) + + def test_a_scan_that_finds_nothing_is_a_failure(self) -> None: + """The gate's own non-vacuity guard. + + A scanner broken by a formatting change would otherwise pass + every leg while checking nothing — the exact shape of the defect + it exists to prevent. + """ + code, _, err = self._run(self.LEG, "fn plain() {}\n", []) + self.assertEqual(code, 2) + self.assertIn("no `#[cfg(any(feature", err) + + def test_another_package_is_skipped_rather_than_passed_silently(self) -> None: + code, out, err = self._run( + ["--no-default-features", "--features", "go", "-p", "big-code-analysis-ast"], + WELL_GATED, + ["x::hidden_literal_supertypes::y"], + ) + self.assertEqual(code, 0, err) + self.assertIn("skipped", out) + + +class RepositoryTest(unittest.TestCase): + def test_the_scanner_finds_subjects_in_the_real_tree(self) -> None: + subjects = gate.scan_tree(gate.SRC_DIR) + self.assertTrue(subjects, "no union-gated subjects found under src/") + self.assertTrue( + [s for s in subjects if s.carries_tests], + "no subject carries tests, so no leg can check anything", + ) + + def test_the_default_leg_enables_every_subject(self) -> None: + """A full build must leave the gate nothing to verify. + + If it did not, the `features (default (lib))` leg would fail on + a subject no build can ever satisfy. + """ + table = gate.manifest_features(REPO_ROOT / "Cargo.toml") + enabled = gate.resolve_features(table, gate.parse_build_flags([])) + disjoint = [ + s + for s in gate.scan_tree(gate.SRC_DIR) + if s.carries_tests and not (s.features & enabled) + ] + self.assertEqual( + [s.describe() for s in disjoint], + [], + "a subject names no feature the default build enables", + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/utils/check-feature-gates.py b/utils/check-feature-gates.py new file mode 100755 index 00000000..b46beb08 --- /dev/null +++ b/utils/check-feature-gates.py @@ -0,0 +1,647 @@ +#!/usr/bin/env python3 +"""check-feature-gates + +Verify that every *union-gated* test subject in ``src/`` is **absent**, +not merely non-vacuous, under a feature set that enables none of the +features its rows name. + +``.claude/rules/testing.md`` ("Gate a feature-gated fixture table on the +union of its rows") requires two halves on any test whose case list is +built from ``#[cfg(feature = …)]`` rows: + +* ``#[cfg(any(feature = "a", feature = "b", …))]`` on the **item**, so + a build enabling none of them drops the subject entirely, and +* a non-vacuity guard inside it (``assert_fixtures_present``, a + ``checked > 0`` counter) covering the residual case where a runtime + ``is_enabled()`` check stops agreeing with the feature it compiled + under. + +Omitting the first half turns the second into a spurious *failure* on +any feature subset that enables none of the rows. That has shipped three +times — #1220, PR #1221, and ``hidden_literal_supertypes`` (a module +gated on ``test`` alone while both its tests were gated on +``php``/``groovy``). ``cargo clippy --all-targets`` over a partial +feature set catches the compile half of this class; nothing catches the +runtime half, because a module that compiles but has no enabled rows is +a perfectly valid *build*. + +This gate closes that. Given the cargo feature flags of a build, it: + +1. scans ``src/**/*.rs`` for ``#[cfg(… any(feature = …) …)]`` on a + ``mod`` or a ``#[test] fn``; +2. computes which of those subjects are **disjoint** from the enabled + feature set; +3. asks ``cargo nextest list`` what the build actually contains, and + fails if any disjoint subject is still there. + +Run it with the same flags as the build under test, e.g.:: + + ./utils/check-feature-gates.py --no-default-features --features go \\ + -p big-code-analysis + +It is wired into the ``feature-matrix`` job in +``.github/workflows/ci.yml`` and deliberately *not* into +``make pre-commit``: it needs a test-binary build per feature set, which +would dominate the local gate. +""" + +from __future__ import annotations + +import argparse +import pathlib +import re +import subprocess +import sys +import tomllib +from dataclasses import dataclass + +# `parents[1]`, not `parent`: these gates live in `utils/` but every path +# they read is anchored at the repository root, so the script works from +# any cwd. +REPO_ROOT = pathlib.Path(__file__).resolve().parents[1] +SRC_DIR = REPO_ROOT / "src" + +# The package whose `src/` this gate scans. A leg targeting any other +# member never builds these subjects, so the check has nothing to say +# about it. +SUBJECT_PACKAGE = "big-code-analysis" + +# `pub`, `pub(crate)`, and the `async`/`const`/`unsafe`/`extern "C"` +# qualifiers may all precede the keyword; the capture groups are the +# item kind and its name. +ITEM_RE = re.compile( + r"^(?:pub\s*(?:\([^)]*\)\s*)?)?" + r"(?:default\s+)?(?:const\s+)?(?:async\s+)?(?:unsafe\s+)?" + r'(?:extern\s+"[^"]*"\s+)?' + r"(mod|fn)\s+([A-Za-z_][A-Za-z0-9_]*)" +) +# `#[test]`, plus the namespaced spellings (`#[tokio::test]`). +TEST_ATTR_RE = re.compile(r"#\[\s*(?:[A-Za-z_][A-Za-z0-9_]*::)*test\s*\]") +FEATURE_RE = re.compile(r'feature\s*=\s*"([^"]+)"') +# A `feature = "x"` predicate, used to blank the feature names out before +# looking for a bare `test` predicate — otherwise a language named +# `test` would be indistinguishable from `cfg(test)`. +FEATURE_PREDICATE_RE = re.compile(r'feature\s*=\s*"[^"]*"') +BARE_TEST_RE = re.compile(r"\btest\b") + + +class ScanError(Exception): + """A malformed input the scanner refuses to guess about.""" + + +# --------------------------------------------------------------------------- +# Rust literal / comment lexing +# +# Ported from `check-snapshot-anchors.py`, which needs it for the same +# reason: a `#[cfg(any(feature = …))]` quoted inside a fixture string or +# sitting in a `//` comment is not a live attribute, and counting one +# produces a spurious failure. The gate scripts are hyphen-named and so +# not importable; every one of them carries its own copy. Fix a lexing +# bug here and check the siblings. +# --------------------------------------------------------------------------- + + +def char_literal_end(source: str, i: int) -> int | None: + """End index (exclusive) of the char literal at ``i``, else ``None``. + + Rust spells lifetimes (``'a``), anonymous lifetimes (``'_``) and loop + labels (``'outer:``) with the same leading quote and no terminator, + so returning ``None`` for those is what keeps a lifetime from opening + a span that swallows the rest of the file. + """ + n = len(source) + j = i + 1 + if j >= n: + return None + if source[j] == "\\": + j += 1 + if j >= n: + return None + if source[j] == "u": + close = source.find("}", j) + if close == -1: + return None + j = close + 1 + elif source[j] == "x": + j += 3 + else: + j += 1 + else: + j += 1 + return j + 1 if j < n and source[j] == "'" else None + + +def raw_string_end(source: str, i: int) -> int | None: + """End index (exclusive) of the raw string at ``i``, else ``None``. + + Covers ``r"…"``, ``r#"…"#`` and the byte-string spellings. A plain + ``b"…"`` needs no special case: the ``b`` is an ordinary character + and the ``"`` opens a regular literal. + """ + n = len(source) + j = i + if source[j] == "b" and j + 1 < n and source[j + 1] == "r": + j += 1 + if j >= n or source[j] != "r": + return None + j += 1 + hashes = 0 + while j < n and source[j] == "#": + hashes += 1 + j += 1 + if j >= n or source[j] != '"': + return None + close = '"' + ("#" * hashes) + end = source.find(close, j + 1) + return n if end == -1 else end + len(close) + + +def regular_string_end(source: str, i: int) -> int: + """End index (exclusive) of the ``"``-delimited literal at ``i``.""" + n = len(source) + j = i + 1 + while j < n: + if source[j] == "\\" and j + 1 < n: + j += 2 + continue + if source[j] == '"': + break + j += 1 + return j + 1 + + +def dead_spans(source: str) -> list[tuple[int, int]]: + """Index ranges holding comments and string / char literals. + + One walk, string literals consumed before comment openers are tested, + so a ``//`` inside a string and a ``"`` inside a comment are both + read correctly. + """ + spans: list[tuple[int, int]] = [] + i = 0 + n = len(source) + while i < n: + ch = source[i] + if ch == "/" and i + 1 < n and source[i + 1] == "/": + nl = source.find("\n", i) + end = n if nl == -1 else nl + spans.append((i, end)) + i = end + continue + if ch == "/" and i + 1 < n and source[i + 1] == "*": + start = i + depth = 1 + i += 2 + while i < n and depth > 0: + if source[i] == "/" and i + 1 < n and source[i + 1] == "*": + depth += 1 + i += 2 + continue + if source[i] == "*" and i + 1 < n and source[i + 1] == "/": + depth -= 1 + i += 2 + continue + i += 1 + spans.append((start, i)) + continue + if ch in "rb": + stop = raw_string_end(source, i) + if stop is not None: + spans.append((i, stop)) + i = stop + continue + if ch == "'": + stop = char_literal_end(source, i) + if stop is not None: + spans.append((i, stop)) + i = stop + continue + i += 1 + continue + if ch == '"': + stop = regular_string_end(source, i) + spans.append((i, stop)) + i = stop + continue + i += 1 + return spans + + +def _in_any_span(idx: int, spans: list[tuple[int, int]]) -> bool: + return any(start <= idx < end for start, end in spans) + + +# --------------------------------------------------------------------------- +# Subject discovery +# --------------------------------------------------------------------------- + + +@dataclass(frozen=True) +class Subject: + """A ``mod`` or ``fn`` carrying a ``cfg(any(feature = …))`` gate.""" + + path: str + line: int + kind: str + name: str + features: frozenset[str] + #: ``#[test]`` (or a namespaced spelling) sits on the same item. + is_test_fn: bool + #: Some ``cfg`` in the run also requires the bare ``test`` predicate. + is_cfg_test: bool + + @property + def carries_tests(self) -> bool: + """Whether a build can be asked whether this subject is present. + + A ``#[cfg(test)] mod`` is a container of tests and a ``#[test] + fn`` is one. A bare helper ``fn`` gated on a union carries no + test name, so ``nextest`` has nothing to report about it — its + absence under a disjoint feature set is a pure compile-time + property, which the leg's ``cargo clippy --all-targets`` already + covers (an unused helper is ``dead_code``, a missing one is + ``E0425``). + """ + return (self.kind == "mod" and self.is_cfg_test) or ( + self.kind == "fn" and self.is_test_fn + ) + + def describe(self) -> str: + return f"{self.path}:{self.line} {self.kind} {self.name}" + + +def _balanced_group(text: str, open_idx: int) -> str | None: + """Contents of the parenthesised group whose ``(`` is at ``open_idx``.""" + depth = 0 + for i in range(open_idx, len(text)): + if text[i] == "(": + depth += 1 + elif text[i] == ")": + depth -= 1 + if depth == 0: + return text[open_idx + 1 : i] + return None + + +def union_features(attr: str) -> frozenset[str]: + """Features named inside an ``any(...)`` group of a ``cfg`` attribute. + + Returns the empty set when the attribute is not a ``cfg``, holds no + ``any(...)``, or inverts one with ``not(any(...))`` — an inverted + gate makes the subject present precisely when none of the features is + enabled, which is the opposite claim and not this gate's business. + """ + if "cfg" not in attr: + return frozenset() + features: set[str] = set() + for match in re.finditer(r"\bany\s*\(", attr): + before = attr[: match.start()].rstrip() + if before.endswith("not("): + continue + group = _balanced_group(attr, match.end() - 1) + if group is None: + continue + features.update(FEATURE_RE.findall(group)) + return frozenset(features) + + +def _cfg_requires_test(attr: str) -> bool: + """Whether ``attr`` is a ``cfg`` with a bare ``test`` predicate.""" + if "cfg" not in attr: + return False + return bool(BARE_TEST_RE.search(FEATURE_PREDICATE_RE.sub("", attr))) + + +def scan_source(source: str, path: str) -> list[Subject]: + """Union-gated subjects declared in one Rust source file. + + Attributes accumulate until a code line is reached; that line must be + the item they attach to. Blank lines and comments between an + attribute and its item are legal Rust and do not break the run. + """ + spans = dead_spans(source) + lines = source.splitlines() + # Start-of-line character offsets, so a `#[` inside a fixture string + # can be told from a real attribute. + offsets: list[int] = [] + pos = 0 + for line in lines: + offsets.append(pos) + pos += len(line) + 1 + + subjects: list[Subject] = [] + attrs: list[str] = [] + pending: list[str] = [] # partial multi-line attribute + index = 0 + while index < len(lines): + raw = lines[index] + stripped = raw.strip() + line_no = index + 1 + index += 1 + + if pending: + pending.append(stripped) + joined = " ".join(pending) + if joined.count("[") <= joined.count("]"): + attrs.append(joined) + pending = [] + continue + + if not stripped or stripped.startswith("//"): + continue + + if stripped.startswith("#[") or stripped.startswith("#!["): + hash_idx = offsets[line_no - 1] + (len(raw) - len(raw.lstrip())) + if _in_any_span(hash_idx, spans): + continue + if stripped.count("[") > stripped.count("]"): + pending = [stripped] + else: + attrs.append(stripped) + continue + + if attrs: + item = ITEM_RE.match(stripped) + code_idx = offsets[line_no - 1] + (len(raw) - len(raw.lstrip())) + if item is not None and not _in_any_span(code_idx, spans): + features: set[str] = set() + for attr in attrs: + features.update(union_features(attr)) + if features: + subjects.append( + Subject( + path=path, + line=line_no, + kind=item.group(1), + name=item.group(2), + features=frozenset(features), + is_test_fn=any( + TEST_ATTR_RE.search(a) for a in attrs + ), + is_cfg_test=any(_cfg_requires_test(a) for a in attrs), + ) + ) + attrs = [] + return subjects + + +def scan_tree(root: pathlib.Path) -> list[Subject]: + subjects: list[Subject] = [] + for path in sorted(root.rglob("*.rs")): + if not path.is_file(): + continue + rel = path.relative_to(REPO_ROOT).as_posix() + subjects.extend(scan_source(path.read_text(encoding="utf-8"), rel)) + return subjects + + +# --------------------------------------------------------------------------- +# Cargo feature resolution +# --------------------------------------------------------------------------- + + +@dataclass(frozen=True) +class BuildFlags: + package: str | None + no_default_features: bool + all_features: bool + requested: frozenset[str] + + +def parse_build_flags(argv: list[str]) -> BuildFlags: + """Read the cargo feature selection out of a leg's flag list.""" + package: str | None = None + no_default = False + all_features = False + requested: set[str] = set() + + index = 0 + while index < len(argv): + arg = argv[index] + index += 1 + if arg == "--no-default-features": + no_default = True + elif arg == "--all-features": + all_features = True + elif arg in ("--features", "-F"): + if index >= len(argv): + raise ScanError(f"{arg} needs a value") + requested.update(re.split(r"[,\s]+", argv[index].strip())) + index += 1 + elif arg.startswith("--features="): + requested.update(re.split(r"[,\s]+", arg.partition("=")[2].strip())) + elif arg in ("-p", "--package"): + if index >= len(argv): + raise ScanError(f"{arg} needs a value") + package = argv[index] + index += 1 + elif arg.startswith("--package="): + package = arg.partition("=")[2] + requested.discard("") + return BuildFlags(package, no_default, all_features, frozenset(requested)) + + +def manifest_features(manifest: pathlib.Path) -> dict[str, list[str]]: + with manifest.open("rb") as handle: + data = tomllib.load(handle) + table = data.get("features", {}) + return {name: list(values) for name, values in table.items()} + + +def resolve_features(table: dict[str, list[str]], flags: BuildFlags) -> set[str]: + """The transitive feature closure a build with ``flags`` enables. + + ``default = ["all-languages"]`` and ``all-languages = [22 languages]`` + means the answer is never the literal flag list; without the closure + every subject would read as disjoint under the default leg. + """ + if flags.all_features: + return set(table) + seeds = set(flags.requested) + if not flags.no_default_features: + seeds.update(table.get("default", [])) + + enabled: set[str] = set() + queue = list(seeds) + while queue: + name = queue.pop() + # `dep:foo` activates an optional dependency and `crate/feat` + # forwards to another package; neither names a feature of this + # one, so neither can satisfy a `cfg(feature = …)` here. + if name.startswith("dep:") or "/" in name or name in enabled: + continue + enabled.add(name) + queue.extend(table.get(name, [])) + return enabled + + +# --------------------------------------------------------------------------- +# What the build actually contains +# --------------------------------------------------------------------------- + + +def list_tests(flags: list[str]) -> list[str]: + """Every test name ``cargo nextest`` reports for this feature set. + + One listing rather than one ``-E test(/subject/)`` invocation per + subject: the build is shared and the filterset would only be + re-applying, in nextest, a name match this script can do directly. + ``-T oneline`` prints `` `` per line. + """ + command = ["cargo", "nextest", "list", "-T", "oneline", *flags] + try: + result = subprocess.run( + command, + cwd=REPO_ROOT, + capture_output=True, + text=True, + check=False, + ) + except FileNotFoundError as exc: # pragma: no cover - environment probe + raise ScanError( + "cargo-nextest not found; install it with " + "`cargo install cargo-nextest --locked`" + ) from exc + if result.returncode != 0: + raise ScanError( + f"`{' '.join(command)}` failed with exit {result.returncode}:\n" + f"{result.stderr.strip()}" + ) + names: list[str] = [] + for line in result.stdout.splitlines(): + _, _, name = line.strip().partition(" ") + if name: + names.append(name.strip()) + return names + + +def subject_matcher(subject: Subject) -> re.Pattern[str]: + """Match a nextest test name belonging to ``subject``. + + Test names are ``::``-separated module paths, so the subject is a + whole path segment — anchoring on the separators keeps + ``a_type_declared_inside_a_function`` from matching + ``a_type_declared_inside_a_function_reaches_the_root_rollup``. + """ + return re.compile(rf"(?:^|::){re.escape(subject.name)}(?:::|$)") + + +# --------------------------------------------------------------------------- +# Entry point +# --------------------------------------------------------------------------- + + +def _print_subjects(subjects: list[Subject]) -> None: + for subject in subjects: + kind = "checked" if subject.carries_tests else "compile-time only" + features = ", ".join(sorted(subject.features)) + print(f"{subject.describe()} [{kind}] any({features})") + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser( + description=__doc__.splitlines()[0], + epilog=( + "Any argument not listed above is passed through to cargo, so " + "this takes a CI leg's flags verbatim." + ), + ) + parser.add_argument( + "--show", + action="store_true", + help="Print the discovered union-gated subjects and exit 0.", + ) + args, cargo_flags = parser.parse_known_args(argv) + + subjects = scan_tree(SRC_DIR) + if args.show: + _print_subjects(subjects) + return 0 + + # Non-vacuity, first half: a scan that finds nothing has either lost + # its parser to a formatting change or been pointed at the wrong + # tree. Either way it would pass every leg while asserting nothing, + # which is the exact defect this gate exists to prevent. + if not subjects: + sys.stderr.write( + f"error: no `#[cfg(any(feature = …))]` subjects found under {SRC_DIR}\n" + " the scanner is broken or the tree is wrong; this gate\n" + " cannot pass by finding nothing to check.\n" + ) + return 2 + + try: + flags = parse_build_flags(cargo_flags) + except ScanError as exc: + sys.stderr.write(f"error: {exc}\n") + return 2 + + if flags.package is not None and flags.package != SUBJECT_PACKAGE: + print( + f"feature-gates: skipped — subjects live in `{SUBJECT_PACKAGE}`, " + f"this build targets `{flags.package}`." + ) + return 0 + + try: + enabled = resolve_features(manifest_features(REPO_ROOT / "Cargo.toml"), flags) + except (OSError, tomllib.TOMLDecodeError) as exc: + sys.stderr.write(f"error: reading root Cargo.toml: {exc}\n") + return 2 + + checkable = [s for s in subjects if s.carries_tests] + disjoint = [s for s in checkable if not (s.features & enabled)] + + if not disjoint: + print( + f"feature-gates: OK — {len(checkable)} test subject(s), every one " + "with an enabled row under this feature set (nothing to verify)." + ) + return 0 + + try: + names = list_tests(cargo_flags) + except ScanError as exc: + sys.stderr.write(f"error: {exc}\n") + return 2 + + offenders: list[tuple[Subject, list[str]]] = [] + for subject in disjoint: + matcher = subject_matcher(subject) + hits = [name for name in names if matcher.search(name)] + if hits: + offenders.append((subject, hits)) + + if offenders: + sys.stderr.write( + "error: a union-gated test subject is present under a feature set\n" + " that enables none of the features its rows name\n\n" + ) + for subject, hits in offenders: + sys.stderr.write(f" {subject.describe()}\n") + sys.stderr.write( + f" gated on any({', '.join(sorted(subject.features))}) " + "— none enabled here\n" + ) + for hit in hits[:5]: + sys.stderr.write(f" still listed: {hit}\n") + if len(hits) > 5: + sys.stderr.write(f" … and {len(hits) - 5} more\n") + sys.stderr.write( + "\nPut `#[cfg(any(feature = \"a\", feature = \"b\", …))]` on the item\n" + "itself, naming the union of the features its rows use, so the\n" + "subject is *absent* rather than tripping its own non-vacuity\n" + "guard. See `.claude/rules/testing.md`, \"Gate a feature-gated\n" + "fixture table on the union of its rows\" (#1220, #1472).\n" + ) + return 1 + + print( + f"feature-gates: OK — {len(disjoint)} of {len(checkable)} test subject(s) " + f"enable no row here, and none is present in the build." + ) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) From 38a2e4719ec0bc5361ae1fbb16a4e8a92289b97b Mon Sep 17 00:00:00 2001 From: Elijah Zupancic Date: Tue, 15 Sep 2026 10:25:08 -0700 Subject: [PATCH 25/25] ci(feature-matrix): pass --locked to the feature-gate step The script forwards every unrecognised argument to `cargo nextest list`, which builds the test binaries. Without `--locked` it was the only cargo invocation in the job permitted to rewrite `Cargo.lock` rather than fail on a stale one; the clippy step directly above it already carried the flag. --- .github/workflows/ci.yml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a2d96efc..02b75fbf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -519,7 +519,12 @@ jobs: # failure on whichever leg enables none of the rows, which is how # #1220 and PR #1221 shipped. `nextest list` answers it without # running a test. - - run: ./utils/check-feature-gates.py ${{ matrix.flags }} + # + # `--locked` for the same reason the clippy run above carries it: + # the script forwards every unrecognised argument to `cargo nextest + # list`, which would otherwise be the one cargo invocation in this + # job allowed to rewrite `Cargo.lock` instead of failing on it. + - run: ./utils/check-feature-gates.py ${{ matrix.flags }} --locked deny: name: cargo-deny