diff --git a/.bca-baseline.toml b/.bca-baseline.toml index 8e184ad8e..8169e0da6 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>" @@ -881,12 +887,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 +899,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 +921,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/.claude/rules/testing.md b/.claude/rules/testing.md index 0f2139c3b..a0d1c9a6e 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* diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9d6881c47..02b75fbf4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -497,7 +497,34 @@ 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 + - 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. + # + # `--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 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 4e1647205..df43e211e 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/.rustfmt-bail-baseline.txt b/.rustfmt-bail-baseline.txt index e0a280a13..9f2d36b97 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/AGENTS.md b/AGENTS.md index d9ece3d42..686f2c1c3 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 f99adf468..cc20a4b2f 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 @@ -135,6 +147,227 @@ 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` 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`, + 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, + 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* + 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`. + - **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: + 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. 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` 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 + 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 + `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` 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 + 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` 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 + 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` 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). + `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` 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, + 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 + `? 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 @@ -149,6 +382,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 @@ -202,6 +450,65 @@ 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, 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` 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 + 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` 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 + 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`. 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. + - **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 @@ -224,6 +531,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/Makefile b/Makefile index 0c8db0bc0..5c4557f18 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/big-code-analysis-ast/src/getter/groovy.rs b/big-code-analysis-ast/src/getter/groovy.rs index ad8ea1416..02aa49a50 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/big-code-analysis-ast/src/macros/kind_sets.rs b/big-code-analysis-ast/src/macros/kind_sets.rs index 09493e1d4..19a3ed93a 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: -// -// - `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). +// 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. +// +// `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,23 +148,72 @@ 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`, // `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 — `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 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 +// 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` / @@ -189,24 +233,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. -// -// 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). +// `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. +// +// 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` (`=~`, @@ -228,11 +271,18 @@ 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 - | $crate::Groovy::InstanceofExpression - | $crate::Groovy::MembershipExpression + | $crate::Groovy::SubscriptExpression + | $crate::Groovy::SafeSubscriptExpression + | $crate::Groovy::SafeNavigationExpression + | $crate::Groovy::SafeChainDotExpression + | $crate::Groovy::DirectFieldAccessExpression }; } @@ -243,6 +293,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)] @@ -254,10 +324,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 @@ -307,6 +398,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 @@ -332,6 +431,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 +457,7 @@ macro_rules! cpp_bool_terminal_kinds { | "char_literal" | "call_expression" | "message_expression" + | "available_expression" | "field_expression" | "subscript_expression" | "cast_expression" @@ -364,6 +482,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 { @@ -384,6 +541,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 @@ -415,16 +582,70 @@ 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. + // + // `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 | $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 | $crate::Python::Await + | $crate::Python::NamedExpression }; } @@ -458,7 +679,46 @@ 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 +// 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 +// 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 @@ -490,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 { @@ -524,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 }; } @@ -532,6 +827,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 { @@ -541,6 +844,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 @@ -600,12 +905,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 @@ -622,14 +957,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 @@ -650,11 +997,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 @@ -679,12 +1046,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 @@ -725,13 +1113,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 { @@ -741,8 +1134,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 }; } @@ -788,14 +1179,46 @@ 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` // 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 { @@ -817,7 +1240,18 @@ macro_rules! ruby_bool_terminal_kinds { | $crate::Ruby::Float | $crate::Ruby::Rational | $crate::Ruby::Complex - | $crate::Ruby::TestPattern + | $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 }; } @@ -845,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 { @@ -865,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/big-code-analysis-book/src/metrics.md b/big-code-analysis-book/src/metrics.md index a0c866ea4..73d05f8a3 100644 --- a/big-code-analysis-book/src/metrics.md +++ b/big-code-analysis-book/src/metrics.md @@ -151,10 +151,11 @@ 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). | +| 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, 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/big-code-analysis-book/src/recipes/baselines.md b/big-code-analysis-book/src/recipes/baselines.md index 62b436d17..6f4321c9f 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 02bd585de..78eaa42c4 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 9e7df4f8b..999ce20ad 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 aee228f3b..6889a995d 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 ac29814d6..e650d0620 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 221aa5543..81c25a457 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 ------------------------------------------------ diff --git a/codecov.yml b/codecov.yml index 23be30d85..d9ddcb935 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: diff --git a/docs/development/lessons_learned.md b/docs/development/lessons_learned.md index 4230fd5ab..072cd741a 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 diff --git a/src/metrics/abc.rs b/src/metrics/abc.rs index 936edd449..e9f0b6ac4 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 @@ -1645,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::( @@ -2400,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::( @@ -3227,6 +3195,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 @@ -3691,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). @@ -4089,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::( @@ -4120,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); }, ); } @@ -4355,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; } @@ -4406,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"); }); } @@ -4586,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, @@ -4635,6 +4680,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 @@ -6096,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 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. + // 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. + // + // #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 { @@ -7630,15 +7820,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); }, ); } @@ -9588,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); }, ); @@ -9684,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 @@ -12978,386 +13252,1822 @@ function f(int $a, int $b): int { "parenthesising the condition must not change the count" ); } -} - -/// A comment inside a ternary must not change its ABC conditions -/// (#1181). -/// -/// Two opposite defects, one cause: tree-sitter counts a comment among a -/// node's children, so it is the operand's previous sibling *and* it -/// shifts every positional index. -/// -/// * Languages whose seed asked "is my previous sibling `?` or `:`" -/// (C family, PHP, Perl, JS family) read the comment as "not a -/// ternary token", flipped the boolean-context seed on for a *branch* -/// slot, and **over**-counted: `a ? /*n*/ (b) : c` scored 3 where -/// `a ? (b) : c` scores 2. -/// * Languages whose branch walk read `child(2)` / `child(4)` (Java, -/// C#, Groovy) landed on the comment instead of the operand, never -/// inspected it, and **under**-counted: `a ? /*n*/ !b : c` scored 2 -/// where `a ? !b : c` scores 3. -/// -/// Both slots are now addressed by grammar field. The parenthesised -/// operand is the only input that discriminates the first defect and -/// the negated operand the only one that discriminates the second — -/// existing ternary fixtures use neither. -// Gated on the union of the features [`ternary_comment_invariance::cases`] -// has a row for, so a build enabling none of them — `--no-default-features -// --features go` — drops both tests rather than tripping their -// `checked > 0` guards (`.claude/rules/testing.md`, #1286). Both tests -// read the same table, so one gate on the module is exact for each. -#[cfg(test)] -#[cfg(any( - feature = "c", - feature = "cpp", - feature = "csharp", - feature = "groovy", - feature = "java", - feature = "javascript", - feature = "mozcpp", - feature = "mozjs", - feature = "objc", - feature = "perl", - feature = "php", - feature = "typescript" -))] -mod ternary_comment_invariance { - use crate::test_support::metrics_verbatim; - use crate::{LANG, MetricsOptions}; - fn conditions(lang: LANG, source: &str) -> u64 { - metrics_verbatim(lang, source.as_bytes(), MetricsOptions::default()) - .abc - .conditions_sum() - } + // #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. - /// `(base, with_comment)` for a parenthesised and a negated branch - /// operand, per language. - fn cases(lang: LANG) -> Option<[(String, String); 2]> { - // `{}` marks the consequence slot; `/*n*/` the inserted comment. - let (template, paren, negated, comment) = match lang { - LANG::Cpp | LANG::C | LANG::Objc | LANG::Mozcpp => { - ("int f(){ int x = a ? {} : c; }", "(b)", "!b", "/*n*/ ") + #[test] + fn rust_match_guard_scores_one_condition_however_spelled() { + let src = "fn is_even(n: i32) -> bool { + n == 0 } - LANG::Java => ( - "class K{ void f(){ int x = a ? {} : c; } }", - "(b)", - "!b", - "/*n*/ ", - ), - LANG::Csharp => ( - "class K{ void f(){ var x = a ? {} : c; } }", - "(b)", - "!b", - "/*n*/ ", - ), - LANG::Groovy => ("def f(){ def x = a ? {} : c }", "(b)", "!b", "/*n*/ "), - LANG::Javascript | LANG::Typescript | LANG::Tsx | LANG::Mozjs => { - ("function f(){ var x = a ? {} : c; }", "(b)", "!b", "/*n*/ ") + fn tok(x: i32) -> i32 { + match x { n if n > 5 => 1, _ => 0 } } - LANG::Php => ( - " ("sub f { my $x = $a ? {} : $c; }", "($b)", "!$b", "# n\n "), - _ => return None, - }; - let build = |operand: &str, with_comment: bool| { - let slot = if with_comment { - format!("{comment}{operand}") - } else { - operand.to_owned() - }; - template.replace("{}", &slot) - }; - Some([ - (build(paren, false), build(paren, true)), - (build(negated, false), build(negated, true)), - ]) - } - - #[test] - fn a_comment_before_a_branch_operand_changes_nothing() { - let mut checked = 0; - for lang in LANG::into_enum_iter() { - if !lang.is_enabled() { - continue; + fn call(x: i32) -> i32 { + match x { n if is_even(n) => 1, _ => 0 } } - let Some(pairs) = cases(lang) else { continue }; - checked += 1; - for (base, commented) in pairs { - assert_eq!( - conditions(lang, &commented), - conditions(lang, &base), - "{lang:?}: a comment changed the ABC conditions of a ternary\n \ - without: {base}\n with: {commented}" - ); + fn bare(x: i32, b: bool) -> i32 { + match x { _ if b => 1, _ => 0 } } - } - assert!( - checked > 0, - "no ternary language enabled; this test asserted nothing" + 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), + ], + ); + }); } - /// The absolute values the invariance test compares against, so a - /// regression that moved *both* sides equally still fails. + #[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. + // + // 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 + 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, and + // `tok` was abc 3 before the slot. + ("tok", 2, 3), + ("call", 2, 3), + ("bare", 2, 3), + ("none", 1, 2), + ], + ); + }); + } + + // 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. + // + // 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 + // `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 +end +"; + assert_fixture_spells::( + src, + "foo.ex", + &[ + (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"), + (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], + 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 + // 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" + ); + }); + } + + // 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*. + // + // 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 +/// (#1181). +/// +/// Two opposite defects, one cause: tree-sitter counts a comment among a +/// node's children, so it is the operand's previous sibling *and* it +/// shifts every positional index. +/// +/// * Languages whose seed asked "is my previous sibling `?` or `:`" +/// (C family, PHP, Perl, JS family) read the comment as "not a +/// ternary token", flipped the boolean-context seed on for a *branch* +/// slot, and **over**-counted: `a ? /*n*/ (b) : c` scored 3 where +/// `a ? (b) : c` scores 2. +/// * Languages whose branch walk read `child(2)` / `child(4)` (Java, +/// C#, Groovy) landed on the comment instead of the operand, never +/// inspected it, and **under**-counted: `a ? /*n*/ !b : c` scored 2 +/// where `a ? !b : c` scores 3. +/// +/// Both slots are now addressed by grammar field. The parenthesised +/// operand is the only input that discriminates the first defect and +/// the negated operand the only one that discriminates the second — +/// existing ternary fixtures use neither. +// Gated on the union of the features [`ternary_comment_invariance::cases`] +// has a row for, so a build enabling none of them — `--no-default-features +// --features go` — drops both tests rather than tripping their +// `checked > 0` guards (`.claude/rules/testing.md`, #1286). Both tests +// read the same table, so one gate on the module is exact for each. +#[cfg(test)] +#[cfg(any( + feature = "c", + feature = "cpp", + feature = "csharp", + feature = "groovy", + feature = "java", + feature = "javascript", + feature = "mozcpp", + feature = "mozjs", + feature = "objc", + feature = "perl", + feature = "php", + feature = "typescript" +))] +mod ternary_comment_invariance { + use crate::test_support::metrics_verbatim; + use crate::{LANG, MetricsOptions}; + + fn conditions(lang: LANG, source: &str) -> u64 { + metrics_verbatim(lang, source.as_bytes(), MetricsOptions::default()) + .abc + .conditions_sum() + } + + /// `(base, with_comment)` for a parenthesised and a negated branch + /// operand, per language. + fn cases(lang: LANG) -> Option<[(String, String); 2]> { + // `{}` marks the consequence slot; `/*n*/` the inserted comment. + let (template, paren, negated, comment) = match lang { + LANG::Cpp | LANG::C | LANG::Objc | LANG::Mozcpp => { + ("int f(){ int x = a ? {} : c; }", "(b)", "!b", "/*n*/ ") + } + LANG::Java => ( + "class K{ void f(){ int x = a ? {} : c; } }", + "(b)", + "!b", + "/*n*/ ", + ), + LANG::Csharp => ( + "class K{ void f(){ var x = a ? {} : c; } }", + "(b)", + "!b", + "/*n*/ ", + ), + LANG::Groovy => ("def f(){ def x = a ? {} : c }", "(b)", "!b", "/*n*/ "), + LANG::Javascript | LANG::Typescript | LANG::Tsx | LANG::Mozjs => { + ("function f(){ var x = a ? {} : c; }", "(b)", "!b", "/*n*/ ") + } + LANG::Php => ( + " ("sub f { my $x = $a ? {} : $c; }", "($b)", "!$b", "# n\n "), + _ => return None, + }; + let build = |operand: &str, with_comment: bool| { + let slot = if with_comment { + format!("{comment}{operand}") + } else { + operand.to_owned() + }; + template.replace("{}", &slot) + }; + Some([ + (build(paren, false), build(paren, true)), + (build(negated, false), build(negated, true)), + ]) + } + + #[test] + fn a_comment_before_a_branch_operand_changes_nothing() { + let mut checked = 0; + for lang in LANG::into_enum_iter() { + if !lang.is_enabled() { + continue; + } + let Some(pairs) = cases(lang) else { continue }; + checked += 1; + for (base, commented) in pairs { + assert_eq!( + conditions(lang, &commented), + conditions(lang, &base), + "{lang:?}: a comment changed the ABC conditions of a ternary\n \ + without: {base}\n with: {commented}" + ); + } + } + assert!( + checked > 0, + "no ternary language enabled; this test asserted nothing" + ); + } + + /// The absolute values the invariance test compares against, so a + /// regression that moved *both* sides equally still fails. + /// + /// expected: `a ? (b) : c` counts the `?` marker plus the condition + /// `a` in boolean context = 2. Negating the consequence adds one + /// more, since `!b` establishes boolean content for that slot = 3. + #[test] + fn the_baseline_values_are_two_and_three() { + let mut checked = 0; + for lang in LANG::into_enum_iter() { + if !lang.is_enabled() { + continue; + } + let Some([(paren, _), (negated, _)]) = cases(lang) else { + continue; + }; + assert_eq!( + conditions(lang, &paren), + 2, + "{lang:?}: parenthesised branch" + ); + assert_eq!(conditions(lang, &negated), 3, "{lang:?}: negated branch"); + checked += 1; + } + assert!( + checked > 0, + "no ternary language enabled; this test asserted nothing" + ); + } +} + +/// A keyword negation must score like its symbolic twin (#1182). +/// +/// `not b` and `!b` are the same negation — they differ in precedence, +/// not in meaning, and ABC counts the negation rather than the parse. +/// Ruby and Perl tested only the `!` token, so `if not b` scored 0 +/// against `if !b`'s 1, and a `not` ternary scored 2 against the `!` +/// form's 4. +/// +/// Lua and Elixir were checked in the same sweep and were already +/// correct: Lua's only negation keyword *is* `not` and it was the token +/// being tested, and Elixir reaches the same count by another path. +/// They are exercised here so a future edit cannot regress them +/// silently. Python counts `not` through its own dispatcher arm and has +/// no `!` spelling to compare against. +// Gated on the union of every row below — the three `pairs` languages +// plus Lua, whose test is separate — so a build enabling none of them +// drops the module instead of failing its guards +// (`.claude/rules/testing.md`, #1286 / #1411). The three tests do *not* +// share a row set, so each carries its own narrower gate: a Lua-only +// build must not be asked to run the Ruby/Perl baselines. +#[cfg(test)] +#[cfg(any( + feature = "elixir", + feature = "lua", + feature = "perl", + feature = "ruby" +))] +mod keyword_negation_parity { + use crate::test_support::metrics_verbatim; + use crate::{LANG, MetricsOptions}; + + fn conditions(lang: LANG, source: &str) -> u64 { + metrics_verbatim(lang, source.as_bytes(), MetricsOptions::default()) + .abc + .conditions_sum() + } + + /// `(bang_form, keyword_form)` pairs that must score identically. + #[cfg(any(feature = "elixir", feature = "perl", feature = "ruby"))] + fn pairs(lang: LANG) -> Option> { + let build = + |t: &str| -> (String, String) { (t.replace("{NOT}", "!"), t.replace("{NOT}", "not ")) }; + let templates: &[&str] = match lang { + LANG::Ruby => &[ + "def f(b)\n if {NOT}b\n 1\n end\nend\n", + "def f(a, b, c)\n x = a ? ({NOT}b) : ({NOT}c)\nend\n", + "def f(a, b, c)\n x = a ? b : ({NOT}c)\nend\n", + ], + LANG::Perl => &[ + "sub f { if ({NOT}$b) { 1; } }", + "sub f { my $x = $a ? ({NOT}$b) : ({NOT}$c); }", + "sub f { my $x = $a ? $b : ({NOT}$c); }", + ], + // Already correct before #1182; pinned so they stay that way. + LANG::Elixir => &["def f(b) do\n if {NOT}b do\n 1\n end\nend\n"], + _ => return None, + }; + Some(templates.iter().map(|t| build(t)).collect()) + } + + #[test] + #[cfg(any(feature = "elixir", feature = "perl", feature = "ruby"))] + fn the_not_keyword_scores_like_bang() { + let mut checked = 0; + for lang in LANG::into_enum_iter() { + if !lang.is_enabled() { + continue; + } + let Some(pairs) = pairs(lang) else { continue }; + checked += 1; + for (bang, keyword) in pairs { + assert_eq!( + conditions(lang, &keyword), + conditions(lang, &bang), + "{lang:?}: `not` and `!` scored differently\n bang: {bang}\n keyword: {keyword}" + ); + } + } + assert!( + checked > 0, + "no language enabled; this test asserted nothing" + ); + } + + /// The absolute values, so a regression that moved both spellings + /// equally still fails. + /// + /// expected: `if !b` is one condition — the negated bare operand. + /// `a ? (!b) : (!c)` is four: the `?` marker, the condition `a` in + /// boolean context, and one per negated branch operand. + #[test] + #[cfg(any(feature = "perl", feature = "ruby"))] + fn the_baseline_values_are_one_and_four() { + let mut checked = 0; + for (lang, guard, ternary) in [ + ( + LANG::Ruby, + "def f(b)\n if not b\n 1\n end\nend\n", + "def f(a, b, c)\n x = a ? (not b) : (not c)\nend\n", + ), + ( + LANG::Perl, + "sub f { if (not $b) { 1; } }", + "sub f { my $x = $a ? (not $b) : (not $c); }", + ), + ] { + if !lang.is_enabled() { + continue; + } + assert_eq!(conditions(lang, guard), 1, "{lang:?}: `if not b`"); + assert_eq!(conditions(lang, ternary), 4, "{lang:?}: `not` ternary"); + checked += 1; + } + assert!( + checked > 0, + "no language enabled; this test asserted nothing" + ); + } + + /// Lua's only negation keyword is `not`, so it has no `!` twin to + /// compare against — its guard is that the keyword counts at all. + #[test] + #[cfg(feature = "lua")] + fn lua_counts_its_only_negation_keyword() { + // Was a silent `if !is_enabled() { return; }`, which made the + // whole test a no-op rather than absent whenever the runtime + // check and the feature disagreed. The `cfg` above is the + // absence half; this is the loud residual half (#1286). + assert!( + LANG::Lua.is_enabled(), + "compiled under `feature = \"lua\"` but `LANG::Lua` reports disabled; \ + this test asserted nothing" + ); + assert_eq!( + conditions( + LANG::Lua, + "function f(b)\n if not b then return 1 end\nend\n" + ), + 1 + ); + } +} + +/// A numeric literal used as a bare `&&` / `||` operand is a Fitzpatrick +/// Rule 9 unary condition in every truthy-valued language (#1379). +/// +/// Ruby's terminal-bool set named `integer` and none of `float` / +/// `rational` / `complex`, Elixir's named `integer` and not `float`, and +/// Perl's named no numeric kind at all — so `a && 1.0` scored one +/// condition where `a && 1` scored two, and in Perl even `$a && 1` +/// scored one. Python had both kinds since #772 and is the control the +/// other three were brought level with. +/// +/// #1410 closed the six sets #1379 deferred: PHP, Groovy, and the +/// name-keyed C / C++ / Mozcpp / Objective-C set, where the omission +/// was visible *within* one language — `if (true)` scored one condition +/// and `if (1)` none, because `"true"` was in the set and +/// `number_literal` was not. +/// +/// The headline claim of each case is a *comparison*: a numeric operand +/// must score exactly what an identifier operand scores in the same slot. +/// That is what discriminates the defect — with the kind missing from the +/// set the numeric form drops while the identifier form does not — and it +/// gives a failure message naming both spellings. +/// `every_operand_scores_its_recorded_values` then pins the absolute +/// numbers, so a regression moving *both* sides equally still fails, and +/// carries the cross-metric anchor `.claude/rules/grammar-dispatch.md` §8 +/// asks for. +/// +/// Two slots per language, because the sets feed two independent walker +/// paths (grammar-dispatch §11) and Perl's defect showed in both: the +/// operands of a `&&` chain, and the predicate of an `if`. A fixture of +/// only the first leaves `perl_count_condition` and `ruby_count_condition` +/// untested. +/// +/// The recorded cyclomatic figure is the same 3 for both slots — one file +/// space, one function space, one decision — while conditions differ (2 +/// for the chain, 1 for the predicate, since ABC scores an `if` through +/// its predicate rather than the keyword). So §8's `conditions == +/// cyclomatic - 1` identity is chain-specific arithmetic, not a law; what +/// generalises is that cyclomatic must not move when only the operand +/// spelling does, which is what makes a `conditions` move unambiguously +/// ABC's. +/// +/// `for_each_case` guards the two ways this table could decay into +/// asserting nothing: an emptied `numerics` slice, and a template that +/// loses its `{}` slot (which would make every comparison `x == x`). +/// Both were reachable in the first draft and neither failed a test. +#[cfg(test)] +#[cfg(any( + feature = "ruby", + feature = "elixir", + feature = "perl", + feature = "python", + feature = "lua", + feature = "javascript", + feature = "php", + feature = "groovy", + feature = "c", + feature = "cpp", + feature = "mozcpp", + feature = "objc" +))] +mod numeric_bool_operands { + use crate::test_support::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); + + /// A language's two slots, its identifier baseline operand, the + /// truthy literal operands that must score the same, and how many of + /// those there should be. Every row but the C family's lists numeric + /// literals only; that one adds `char_literal`, which is a second + /// integral-literal kind rather than a second numeric spelling. + /// + /// The count is not bookkeeping. `for_each_case` counts *languages*, + /// so trimming a row's operand list back to `&["1"]` — which is + /// exactly the pre-#1379 fixture — left the whole module green when + /// measured. Pinning the length makes that a deliberate two-line + /// edit instead of a silent one. + type Case = ([Slot; 2], &'static str, &'static [&'static str], usize); + + 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() + } + + /// `([chain_slot, condition_slot], identifier, numerics)` per language. + /// + /// `{}` is the right-hand operand of a two-operand short-circuit chain + /// in the first slot and the whole `if` predicate in the second, so + /// every fixture differs from its own baseline in exactly one token. + /// + /// Each row lists **one spelling per numeric kind the grammar emits**, + /// which is the unit that matters: #1379's misses were sibling rules + /// under a shared supertype, not aliases of one rule. + /// + /// - Ruby: `integer` / `float` / `rational` / `complex`. `1r` parses + /// to `rational(integer)`, `2i` to `complex(integer)` and `1ri` to + /// `complex(rational(integer))` — verified by `bca dump` — and the + /// walker cannot descend into any wrapper, so listing the wrapper + /// is the only way each scores at all. Radix prefixes and `_` + /// separators fold into `integer`. + /// - Perl: all five members of the hidden `_numeric_literals` choice. + /// `0xff` and `1.5e10` are here because the first cut of #1379 + /// named only `integer` and `floating_point` and left them scoring + /// 1 against an identifier's 2. `017` is *not* a sixth row — it + /// lexes as `integer`, and the `octal` kind is unreachable. + /// - Elixir: `integer` / `float` / `char`. `?a` is the codepoint 97, + /// a numeric literal wearing a sigil. + /// - PHP: `integer` / `float`. Every radix prefix, `_` separator and + /// exponent folds into those two, and the `float` *type* keyword + /// `Float2` is a third id rendering to the same name that must + /// stay out of the set (#1410). + /// - Groovy: one consolidated `number_literal`, type suffixes + /// (`1L`, `1.5f`, `1G`) included. + /// - C, C++, Mozcpp and Objective-C: one consolidated + /// `number_literal`, plus `char_literal` — not a numeric spelling + /// but a second integral-literal *kind*, since `'c'` has type + /// `int` in C and `char` in C++ and is truthy for the same reason. + /// All four share one name-keyed set and so run the same fixture, + /// which is the only coverage `Mozcpp` can have: it owns no file + /// extension, and `metrics_verbatim` is the one entry point that + /// reaches a `LANG` without going through one. + /// + /// Python, Lua and JavaScript were already correct and ride along as + /// controls — they are what the first three were measured against, + /// and a future edit that breaks them fails here too. Each folds + /// every radix into one `Number` / `integer` kind, checked by + /// measurement. + fn cases(lang: LANG) -> Option { + Some(match lang { + LANG::Ruby => ( + [ + ("def f(a)\n a && {}\nend\n", 2, 3), + ("def f\n if {}\n 1\n end\nend\n", 1, 3), + ], + "b", + &["1", "1.0", "1r", "2i", "1ri"], + 5, + ), + LANG::Elixir => ( + [ + ("def f(a) do\n a && {}\nend\n", 2, 3), + ("def f() do\n if {} do\n 1\n end\nend\n", 1, 3), + ], + "b", + &["1", "1.0", "?a"], + 3, + ), + LANG::Perl => ( + [ + ("sub f {\n my $x = $a && {};\n}\n", 2, 3), + ("sub f {\n if ({}) { 1; }\n}\n", 1, 3), + ], + "$b", + &["1", "1.0", "1.5e10", "0xff"], + 4, + ), + LANG::Python => ( + [ + ("def f(a):\n return a and {}\n", 2, 3), + ("def f():\n if {}:\n return 1\n", 1, 3), + ], + "b", + &["1", "1.0", "0xff", "1j"], + 4, + ), + 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", + &["1", "1.0", "0xff"], + 3, + ), + LANG::Javascript => ( + [ + ("function f(a) {\n return a && {};\n}\n", 2, 3), + ("function f() {\n if ({}) { return 1; }\n}\n", 1, 3), + ], + "b", + &["1", "1.0", "0xff"], + 3, + ), + LANG::Php => ( + [ + (" ( + [ + ("def f(a) {\n return a && {}\n}\n", 2, 3), + ("def f() {\n if ({}) { return 1 }\n}\n", 1, 3), + ], + "b", + &["1"], + 1, + ), + LANG::C | LANG::Cpp | LANG::Mozcpp | LANG::Objc => ( + [ + ("int f(int a) {\n return a && {};\n}\n", 2, 3), + ("int f() {\n if ({}) { return 1; }\n}\n", 1, 3), + ], + "b", + &["1", "'c'"], + 2, + ), + _ => return None, + }) + } + + /// Runs `check` once per enabled language that has a case, having + /// first established that the case can still assert something. + /// + /// Three guards, all of which had to be added after the first draft + /// shipped without them and a measured perturbation of each left the + /// whole module green: /// - /// expected: `a ? (b) : c` counts the `?` marker plus the condition - /// `a` in boolean context = 2. Negating the consequence adds one - /// more, since `!b` establishes boolean content for that slot = 3. - #[test] - fn the_baseline_values_are_two_and_three() { + /// - **`checked > 0`** is the non-vacuity half of the rule in + /// `.claude/rules/testing.md`. The `#[cfg(any(feature = …))]` on + /// this module makes the tests *absent* when no truthy-valued + /// 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 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 + /// `x == x`. + /// + /// Sharing one driver means no test can lose any of the three. + 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([(paren, _), (negated, _)]) = cases(lang) else { + 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!( - conditions(lang, &paren), - 2, - "{lang:?}: parenthesised branch" + numerics.len(), + expected_kinds, + "{lang:?}: the numeric-operand list no longer covers one spelling \ + per grammar numeric kind" ); - assert_eq!(conditions(lang, &negated), 3, "{lang:?}: negated branch"); - checked += 1; - } - assert!( - checked > 0, - "no ternary language enabled; this test asserted nothing" - ); - } -} - -/// A keyword negation must score like its symbolic twin (#1182). -/// -/// `not b` and `!b` are the same negation — they differ in precedence, -/// not in meaning, and ABC counts the negation rather than the parse. -/// Ruby and Perl tested only the `!` token, so `if not b` scored 0 -/// against `if !b`'s 1, and a `not` ternary scored 2 against the `!` -/// form's 4. -/// -/// Lua and Elixir were checked in the same sweep and were already -/// correct: Lua's only negation keyword *is* `not` and it was the token -/// being tested, and Elixir reaches the same count by another path. -/// They are exercised here so a future edit cannot regress them -/// silently. Python counts `not` through its own dispatcher arm and has -/// no `!` spelling to compare against. -// Gated on the union of every row below — the three `pairs` languages -// plus Lua, whose test is separate — so a build enabling none of them -// drops the module instead of failing its guards -// (`.claude/rules/testing.md`, #1286 / #1411). The three tests do *not* -// share a row set, so each carries its own narrower gate: a Lua-only -// build must not be asked to run the Ruby/Perl baselines. -#[cfg(test)] -#[cfg(any( - feature = "elixir", - feature = "lua", - feature = "perl", - feature = "ruby" -))] -mod keyword_negation_parity { - use crate::test_support::metrics_verbatim; - use crate::{LANG, MetricsOptions}; - - fn conditions(lang: LANG, source: &str) -> u64 { - metrics_verbatim(lang, source.as_bytes(), MetricsOptions::default()) - .abc - .conditions_sum() - } - - /// `(bang_form, keyword_form)` pairs that must score identically. - #[cfg(any(feature = "elixir", feature = "perl", feature = "ruby"))] - fn pairs(lang: LANG) -> Option> { - let build = - |t: &str| -> (String, String) { (t.replace("{NOT}", "!"), t.replace("{NOT}", "not ")) }; - let templates: &[&str] = match lang { - LANG::Ruby => &[ - "def f(b)\n if {NOT}b\n 1\n end\nend\n", - "def f(a, b, c)\n x = a ? ({NOT}b) : ({NOT}c)\nend\n", - "def f(a, b, c)\n x = a ? b : ({NOT}c)\nend\n", - ], - LANG::Perl => &[ - "sub f { if ({NOT}$b) { 1; } }", - "sub f { my $x = $a ? ({NOT}$b) : ({NOT}$c); }", - "sub f { my $x = $a ? $b : ({NOT}$c); }", - ], - // Already correct before #1182; pinned so they stay that way. - LANG::Elixir => &["def f(b) do\n if {NOT}b do\n 1\n end\nend\n"], - _ => return None, - }; - Some(templates.iter().map(|t| build(t)).collect()) - } - - #[test] - #[cfg(any(feature = "elixir", feature = "perl", feature = "ruby"))] - fn the_not_keyword_scores_like_bang() { - let mut checked = 0; - for lang in LANG::into_enum_iter() { - if !lang.is_enabled() { - continue; - } - let Some(pairs) = pairs(lang) else { continue }; - checked += 1; - for (bang, keyword) in pairs { - assert_eq!( - conditions(lang, &keyword), - conditions(lang, &bang), - "{lang:?}: `not` and `!` scored differently\n bang: {bang}\n keyword: {keyword}" + for (template, _, _) in slots { + assert!( + template.contains("{}"), + "{lang:?}: template lost its `{{}}` operand slot: {template}" ); } + check(lang, case); + checked += 1; } assert!( checked > 0, - "no language enabled; this test asserted nothing" + "no truthy-valued language enabled; this test asserted nothing" ); } - /// The absolute values, so a regression that moved both spellings - /// equally still fails. - /// - /// expected: `if !b` is one condition — the negated bare operand. - /// `a ? (!b) : (!c)` is four: the `?` marker, the condition `a` in - /// boolean context, and one per negated branch operand. #[test] - #[cfg(any(feature = "perl", feature = "ruby"))] - fn the_baseline_values_are_one_and_four() { - let mut checked = 0; - for (lang, guard, ternary) in [ - ( - LANG::Ruby, - "def f(b)\n if not b\n 1\n end\nend\n", - "def f(a, b, c)\n x = a ? (not b) : (not c)\nend\n", - ), - ( - LANG::Perl, - "sub f { if (not $b) { 1; } }", - "sub f { my $x = $a ? (not $b) : (not $c); }", - ), - ] { - if !lang.is_enabled() { - continue; + fn a_numeric_operand_scores_like_an_identifier_operand() { + for_each_case(|lang, (slots, identifier, numerics, _)| { + for (template, _, _) in slots { + let baseline = conditions(lang, &template.replace("{}", identifier)); + for numeric in numerics { + let source = template.replace("{}", numeric); + let scored = conditions(lang, &source); + assert_eq!( + scored, baseline, + "{lang:?}: `{numeric}` scored {scored} unary conditions against \ + `{identifier}`'s {baseline}\n source: {source}" + ); + } } - assert_eq!(conditions(lang, guard), 1, "{lang:?}: `if not b`"); - assert_eq!(conditions(lang, ternary), 4, "{lang:?}: `not` ternary"); - checked += 1; - } - assert!( - checked > 0, - "no language enabled; this test asserted nothing" - ); + }); } - /// Lua's only negation keyword is `not`, so it has no `!` twin to - /// compare against — its guard is that the keyword counts at all. + /// The absolute anchor under the comparison above: every operand + /// spelling must produce the slot's recorded `conditions`, and must + /// leave `cyclomatic` alone. The second half is what rules out a + /// regression that moved both metrics together. #[test] - #[cfg(feature = "lua")] - fn lua_counts_its_only_negation_keyword() { - // Was a silent `if !is_enabled() { return; }`, which made the - // whole test a no-op rather than absent whenever the runtime - // check and the feature disagreed. The `cfg` above is the - // absence half; this is the loud residual half (#1286). - assert!( - LANG::Lua.is_enabled(), - "compiled under `feature = \"lua\"` but `LANG::Lua` reports disabled; \ - this test asserted nothing" - ); - assert_eq!( - conditions( - LANG::Lua, - "function f(b)\n if not b then return 1 end\nend\n" - ), - 1 - ); + fn every_operand_scores_its_recorded_values() { + for_each_case(|lang, (slots, identifier, numerics, _)| { + for (template, expected_conditions, expected_cyclomatic) in slots { + for operand in std::iter::once(identifier).chain(numerics.iter().copied()) { + 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}" + ); + } + } + }); } } -/// A numeric literal used as a bare `&&` / `||` operand is a Fitzpatrick -/// Rule 9 unary condition in every truthy-valued language (#1379). +/// A relational construct with its own grammar production must score +/// like an identifier in the same boolean slot (#1449, #1461). /// -/// Ruby's terminal-bool set named `integer` and none of `float` / -/// `rational` / `complex`, Elixir's named `integer` and not `float`, and -/// Perl's named no numeric kind at all — so `a && 1.0` scored one -/// condition where `a && 1` scored two, and in Perl even `$a && 1` -/// scored one. Python had both kinds since #772 and is the control the -/// other three were brought level with. +/// Every language here spells at least one boolean test as a dedicated +/// node rather than as a `binary_expression`, so no comparison-token +/// arm ever sees it. A kind missing from `_bool_terminal_kinds!()` +/// scores **zero**, silently, and zero is indistinguishable from a +/// construct that legitimately scores zero — which is why the headline +/// claim of each case is a *comparison* against an identifier control +/// in the identical slot rather than an absolute number. /// -/// #1410 closed the six sets #1379 deferred: PHP, Groovy, and the -/// name-keyed C / C++ / Mozcpp / Objective-C set, where the omission -/// was visible *within* one language — `if (true)` scored one condition -/// and `if (1)` none, because `"true"` was in the set and -/// `number_literal` was not. +/// The constructs, and the route each fix took: /// -/// The headline claim of each case is a *comparison*: a numeric operand -/// must score exactly what an identifier operand scores in the same slot. -/// That is what discriminates the defect — with the kind missing from the -/// set the numeric form drops while the identifier form does not — and it -/// gives a failure message naming both spellings. -/// `every_operand_scores_its_recorded_values` then pins the absolute -/// numbers, so a regression moving *both* sides equally still fails, and -/// carries the cross-metric anchor `.claude/rules/grammar-dispatch.md` §8 -/// asks for. +/// - **Groovy** `a in l` / `a !in l` (`membership_expression`) joins +/// the terminal set. The other four — `a === b` / `a !== b` +/// (`identity_expression`) and `s =~ /p/` / `s ==~ /p/` +/// (`regex_find_expression`, `regex_match_expression`) — are counted +/// as operator tokens in `groovy_count_token_condition` instead, so +/// they also score outside a boolean slot as `==` already did. Both +/// macro and arm carry the reasoning; the rule that matters here is +/// that a construct takes exactly one of the two routes, never both +/// (`.claude/rules/grammar-dispatch.md` §5). +/// - **Perl** `/^#/` (`pattern_matcher`) and `m{^#}` +/// (`pattern_matcher_m`), the two spellings of a match against the +/// implicit `$_`. They are sibling rules, not aliases, so both are +/// listed — a fixture carrying only the first would have left the +/// `m{}` half at zero and read as covered. +/// - **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 +/// catches `cfg!` / `dbg!` / `todo!`, and the one corpus snapshot it +/// moves is a `cfg!` rather than a `matches!`. That breadth is what +/// #1461 item 2 exists to decide, so it is left to that issue rather +/// than settled by inclusion here. /// /// Two slots per language, because the sets feed two independent walker -/// paths (grammar-dispatch §11) and Perl's defect showed in both: the +/// paths (§11) and every construct above measured short in **both**: the /// operands of a `&&` chain, and the predicate of an `if`. A fixture of -/// only the first leaves `perl_count_condition` and `ruby_count_condition` -/// untested. +/// only one leaves the other untested. /// -/// The recorded cyclomatic figure is the same 3 for both slots — one file -/// space, one function space, one decision — while conditions differ (2 -/// for the chain, 1 for the predicate, since ABC scores an `if` through -/// its predicate rather than the keyword). So §8's `conditions == -/// cyclomatic - 1` identity is chain-specific arithmetic, not a law; what -/// generalises is that cyclomatic must not move when only the operand -/// spelling does, which is what makes a `conditions` move unambiguously -/// ABC's. +/// Each row also carries the construct's negated spelling. That is not +/// redundancy: `_inspect_container` is a *third* consumer of every +/// terminal set, reached only after a `(…)` or `!…` wrapper is peeled, +/// and it is the consumer that decides whether the peeled operand sits +/// in boolean context at all. Without a negated row, Groovy's and +/// Rust's `inspect_container` arms were the site no fixture here +/// exercised. /// -/// `for_each_case` guards the two ways this table could decay into -/// asserting nothing: an emptied `numerics` slice, and a template that -/// loses its `{}` slot (which would make every comparison `x == x`). -/// Both were reachable in the first draft and neither failed a test. +/// Even with it, no language exercises all three consumers through +/// these two slots, and which one is missed differs by language — +/// Perl's `perl_count_condition` (reached only from a ternary or a +/// C-style `for` header) and Ruby's `ruby_count_unary_conditions` +/// (unreachable here because `in` binds looser than `&&`, so the chain +/// slot must parenthesise and routes through `inspect_container` +/// instead). Both are covered elsewhere in this file; the gap is +/// recorded rather than papered over, because a reader comparing the +/// slot count to the consumer count will otherwise assume it is three. +/// +/// The `cyclomatic` half of `every_construct_scores_its_recorded_values` +/// is what rules out a regression that moved both metrics together: +/// cyclomatic must not move when only the operand spelling does, which +/// is what makes a `conditions` move unambiguously ABC's. The recorded +/// figures differ per slot (Rust's chain slot carries the extra `&&` +/// decision), so each slot pins its own pair rather than sharing one. +// Gated on the union of the three languages with rows, so a build +// enabling none of them drops the module instead of failing its +// guards (`.claude/rules/testing.md`, #1286 / #1411). #[cfg(test)] #[cfg(any( - feature = "ruby", - feature = "elixir", - feature = "perl", - feature = "python", - feature = "lua", - feature = "javascript", - feature = "php", feature = "groovy", - feature = "c", - feature = "cpp", - feature = "mozcpp", + feature = "perl", + feature = "ruby", feature = "objc" ))] -mod numeric_bool_operands { +mod own_production_bool_constructs { use crate::test_support::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. + /// One fixture shape: a source template with a `{}` slot for the + /// whole boolean expression, and the `abc.conditions_sum` / + /// `cyclomatic_sum` every spelling in that slot must produce. type Slot = (&'static str, u64, u64); - /// A language's two slots, its identifier baseline operand, the - /// truthy literal operands that must score the same, and how many of - /// those there should be. Every row but the C family's lists numeric - /// literals only; that one adds `char_literal`, which is a second - /// integral-literal kind rather than a second numeric spelling. + /// A language's two slots, its identifier control, the constructs + /// 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 operand list back to `&["1"]` — which is - /// exactly the pre-#1379 fixture — left the whole module green when - /// measured. Pinning the length makes that a deliberate two-line - /// edit instead of a silent one. + /// so trimming Groovy's list back to a single construct — or + /// dropping Perl's `m{}` half, which is a separate grammar rule from + /// its `//` half — leaves the module green with that coverage + /// deleted. Pinning the length makes such a trim a deliberate + /// two-line edit instead of a silent one. type Case = ([Slot; 2], &'static str, &'static [&'static str], usize); fn conditions(lang: LANG, source: &str) -> u64 { @@ -13372,131 +15082,88 @@ mod numeric_bool_operands { .cyclomatic_sum() } - /// `([chain_slot, condition_slot], identifier, numerics)` per language. - /// - /// `{}` is the right-hand operand of a two-operand short-circuit chain - /// in the first slot and the whole `if` predicate in the second, so - /// every fixture differs from its own baseline in exactly one token. - /// - /// Each row lists **one spelling per numeric kind the grammar emits**, - /// which is the unit that matters: #1379's misses were sibling rules - /// under a shared supertype, not aliases of one rule. + /// `([chain_slot, predicate_slot], identifier, constructs)` per + /// language. /// - /// - Ruby: `integer` / `float` / `rational` / `complex`. `1r` parses - /// to `rational(integer)`, `2i` to `complex(integer)` and `1ri` to - /// `complex(rational(integer))` — verified by `bca dump` — and the - /// walker cannot descend into any wrapper, so listing the wrapper - /// is the only way each scores at all. Radix prefixes and `_` - /// separators fold into `integer`. - /// - Perl: all five members of the hidden `_numeric_literals` choice. - /// `0xff` and `1.5e10` are here because the first cut of #1379 - /// named only `integer` and `floating_point` and left them scoring - /// 1 against an identifier's 2. `017` is *not* a sixth row — it - /// lexes as `integer`, and the `octal` kind is unreachable. - /// - Elixir: `integer` / `float` / `char`. `?a` is the codepoint 97, - /// a numeric literal wearing a sigil. - /// - PHP: `integer` / `float`. Every radix prefix, `_` separator and - /// exponent folds into those two, and the `float` *type* keyword - /// `Float2` is a third id rendering to the same name that must - /// stay out of the set (#1410). - /// - Groovy: one consolidated `number_literal`, type suffixes - /// (`1L`, `1.5f`, `1G`) included. - /// - C, C++, Mozcpp and Objective-C: one consolidated - /// `number_literal`, plus `char_literal` — not a numeric spelling - /// but a second integral-literal *kind*, since `'c'` has type - /// `int` in C and `char` in C++ and is truthy for the same reason. - /// All four share one name-keyed set and so run the same fixture, - /// which is the only coverage `Mozcpp` can have: it owns no file - /// extension, and `metrics_verbatim` is the one entry point that - /// reaches a `LANG` without going through one. + /// `{}` is the left-hand operand of a two-operand short-circuit + /// chain in the first slot and the whole `if` predicate in the + /// second, so every fixture differs from its own control in exactly + /// the construct under test. /// - /// Python, Lua and JavaScript were already correct and ride along as - /// controls — they are what the first three were measured against, - /// and a future edit that breaks them fails here too. Each folds - /// every radix into one `Number` / `integer` kind, checked by - /// measurement. + /// Ruby's chain slot parenthesises the slot because `in` binds + /// looser than `&&` there; the control is parenthesised identically, + /// so the parens cancel out of the comparison and + /// `ruby_inspect_container` unwraps them for both sides alike. fn cases(lang: LANG) -> Option { Some(match lang { - LANG::Ruby => ( + LANG::Groovy => ( [ - ("def f(a)\n a && {}\nend\n", 2, 3), - ("def f\n if {}\n 1\n end\nend\n", 1, 3), + ("def f(a, b, l, s) {\n return {} && b\n}\n", 2, 3), + ("def f(a, b, l, s) {\n if ({}) { return 1 }\n}\n", 1, 3), ], "b", - &["1", "1.0", "1r", "2i", "1ri"], - 5, - ), - LANG::Elixir => ( - [ - ("def f(a) do\n a && {}\nend\n", 2, 3), - ("def f() do\n if {} do\n 1\n end\nend\n", 1, 3), + &[ + "a in l", + "a !in l", + "a === b", + "a !== b", + "s =~ /p/", + "s ==~ /p/", + "!(a in l)", + // The indexing / navigation kinds added by #1466. + // These two are the cyclomatic-neutral ones, so + // they belong in this module's same-as-the-control + // 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]", + "a.@b", ], - "b", - &["1", "1.0", "?a"], - 3, + 9, ), LANG::Perl => ( [ - ("sub f {\n my $x = $a && {};\n}\n", 2, 3), + ("sub f {\n my $x = {} && $b;\n}\n", 2, 3), ("sub f {\n if ({}) { 1; }\n}\n", 1, 3), ], - "$b", - &["1", "1.0", "1.5e10", "0xff"], - 4, - ), - LANG::Python => ( - [ - ("def f(a):\n return a and {}\n", 2, 3), - ("def f():\n if {}:\n return 1\n", 1, 3), - ], - "b", - &["1", "1.0", "0xff", "1j"], - 4, - ), - 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", - &["1", "1.0", "0xff"], - 3, - ), - LANG::Javascript => ( - [ - ("function f(a) {\n return a && {};\n}\n", 2, 3), - ("function f() {\n if ({}) { return 1; }\n}\n", 1, 3), - ], - "b", - &["1", "1.0", "0xff"], + "$a", + &["/^#/", "m{^#}", "!/^#/"], 3, ), - LANG::Php => ( + LANG::Ruby => ( [ - (" ( - [ - ("def f(a) {\n return a && {}\n}\n", 2, 3), - ("def f() {\n if ({}) { return 1 }\n}\n", 1, 3), - ], - "b", - &["1"], - 1, - ), - LANG::C | LANG::Cpp | LANG::Mozcpp | LANG::Objc => ( + // 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 a && {};\n}\n", 2, 3), + ("int f(int a) {\n return {} && b;\n}\n", 2, 3), ("int f() {\n if ({}) { return 1; }\n}\n", 1, 3), ], - "b", - &["1", "'c'"], - 2, + "a", + &[ + "@available(iOS 13.0, *)", + "__builtin_available(macOS 10.15, *)", + "!@available(iOS 13.0, *)", + ], + 3, ), _ => return None, }) @@ -13505,45 +15172,43 @@ mod numeric_bool_operands { /// Runs `check` once per enabled language that has a case, having /// first established that the case can still assert something. /// - /// Three guards, all of which had to be added after the first draft - /// shipped without them and a measured perturbation of each left the - /// whole module green: - /// - /// - **`checked > 0`** is the non-vacuity half of the rule in - /// `.claude/rules/testing.md`. The `#[cfg(any(feature = …))]` on - /// this module makes the tests *absent* when no truthy-valued - /// 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. - /// - **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 - /// `x == x`. - /// - /// Sharing one driver means no test can lose any of the three. + /// 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, 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() { if !lang.is_enabled() { continue; } - let Some(case @ (slots, _, numerics, expected_kinds)) = cases(lang) else { + let Some(case @ (slots, _, constructs, expected_constructs)) = cases(lang) else { continue; }; + // Both halves are needed, and the second does not imply the + // first. The length check alone only asserts that the list + // and its recorded count *agree*, so emptying a row and + // 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. + assert!( + !constructs.is_empty(), + "{lang:?}: the construct list is empty; this language asserted nothing" + ); assert_eq!( - numerics.len(), - expected_kinds, - "{lang:?}: the numeric-operand list no longer covers one spelling \ - per grammar numeric kind" + constructs.len(), + expected_constructs, + "{lang:?}: the construct list no longer covers every own-production \ + boolean spelling this language was fixed for" ); for (template, _, _) in slots { assert!( template.contains("{}"), - "{lang:?}: template lost its `{{}}` operand slot: {template}" + "{lang:?}: template lost its `{{}}` slot: {template}" ); } check(lang, case); @@ -13551,21 +15216,22 @@ mod numeric_bool_operands { } assert!( checked > 0, - "no truthy-valued language enabled; this test asserted nothing" + "no language with an own-production boolean construct enabled; \ + this test asserted nothing" ); } #[test] - fn a_numeric_operand_scores_like_an_identifier_operand() { - for_each_case(|lang, (slots, identifier, numerics, _)| { + fn an_own_production_construct_scores_like_an_identifier() { + for_each_case(|lang, (slots, identifier, constructs, _)| { for (template, _, _) in slots { let baseline = conditions(lang, &template.replace("{}", identifier)); - for numeric in numerics { - let source = template.replace("{}", numeric); + for construct in constructs { + let source = template.replace("{}", construct); let scored = conditions(lang, &source); assert_eq!( scored, baseline, - "{lang:?}: `{numeric}` scored {scored} unary conditions against \ + "{lang:?}: `{construct}` scored {scored} conditions against \ `{identifier}`'s {baseline}\n source: {source}" ); } @@ -13573,25 +15239,265 @@ mod numeric_bool_operands { }); } - /// The absolute anchor under the comparison above: every operand - /// spelling must produce the slot's recorded `conditions`, and must - /// leave `cyclomatic` alone. The second half is what rules out a - /// regression that moved both metrics together. + /// 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`), `??.` (`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 + /// 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, 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", "l?[0]"] { + 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] - fn every_operand_scores_its_recorded_values() { - for_each_case(|lang, (slots, identifier, numerics, _)| { + #[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** + /// `PatternMatcherM` on the stated grounds that `/^#/` and `m{^#}` + /// are sibling rules rather than aliases of one. Nothing above pins + /// that: if a grammar bump collapsed `m{}` onto `pattern_matcher`, + /// both fixtures would keep scoring through the surviving entry, + /// the `PatternMatcherM` arm would become dead code, and every test + /// in this module would stay green — the silent-drift shape lesson + /// 34 and grammar-dispatch §2 exist to catch. + /// + /// Asserting the ids differ is not enough on its own, since two + /// distinct enum variants prove nothing about what the parser + /// emits. So each spelling is parsed and required to produce its + /// own kind and not the other's. + #[test] + #[cfg(feature = "perl")] + fn the_two_perl_match_spellings_are_distinct_kinds() { + use crate::{ParserTrait, Perl, PerlParser}; + use big_code_analysis_ast::test_support::ast_has_kind_id; + + assert_ne!( + Perl::PatternMatcher as u16, + Perl::PatternMatcherM as u16, + "the two spellings share one kind id; one terminal-set entry is dead" + ); + + for (src, present, absent) in [ + ( + "sub f { if (/^#/) { 1; } }", + ("pattern_matcher", Perl::PatternMatcher as u16), + ("pattern_matcher_m", Perl::PatternMatcherM as u16), + ), + ( + "sub f { if (m{^#}) { 1; } }", + ("pattern_matcher_m", Perl::PatternMatcherM as u16), + ("pattern_matcher", Perl::PatternMatcher as u16), + ), + ] { + let parser = PerlParser::new( + src.as_bytes().to_vec(), + &std::path::PathBuf::from("f.pl"), + None, + ); + let (present_name, present_id) = present; + let (absent_name, absent_id) = absent; + assert!( + ast_has_kind_id(&parser, present_id), + "`{src}` no longer emits `{present_name}`" + ); + assert!( + !ast_has_kind_id(&parser, absent_id), + "`{src}` now also emits `{absent_name}`; the two spellings have \ + collapsed and one `perl_bool_terminal_kinds!()` entry is dead" + ); + } + } + + /// `"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. + #[test] + fn every_construct_scores_its_recorded_values() { + for_each_case(|lang, (slots, identifier, constructs, _)| { for (template, expected_conditions, expected_cyclomatic) in slots { - for operand in std::iter::once(identifier).chain(numerics.iter().copied()) { - let source = template.replace("{}", operand); + for spelling in std::iter::once(identifier).chain(constructs.iter().copied()) { + let source = template.replace("{}", spelling); assert_eq!( conditions(lang, &source), expected_conditions, - "{lang:?}: `{operand}` conditions\n source: {source}" + "{lang:?}: `{spelling}` conditions\n source: {source}" ); assert_eq!( cyclomatic_sum(lang, &source), expected_cyclomatic, - "{lang:?}: `{operand}` cyclomatic_sum\n source: {source}" + "{lang:?}: `{spelling}` cyclomatic_sum\n source: {source}" ); } } @@ -13599,97 +15505,267 @@ mod numeric_bool_operands { } } -/// A relational construct with its own grammar production must score -/// like an identifier in the same boolean slot (#1449, #1461). -/// -/// Every language here spells at least one boolean test as a dedicated -/// node rather than as a `binary_expression`, so no comparison-token -/// arm ever sees it. A kind missing from `_bool_terminal_kinds!()` -/// scores **zero**, silently, and zero is indistinguishable from a -/// construct that legitimately scores zero — which is why the headline -/// claim of each case is a *comparison* against an identifier control -/// in the identical slot rather than an absolute number. -/// -/// The constructs, and the route each fix took: -/// -/// - **Groovy** `a in l` / `a !in l` (`membership_expression`) joins -/// the terminal set. The other four — `a === b` / `a !== b` -/// (`identity_expression`) and `s =~ /p/` / `s ==~ /p/` -/// (`regex_find_expression`, `regex_match_expression`) — are counted -/// as operator tokens in `groovy_count_token_condition` instead, so -/// they also score outside a boolean slot as `==` already did. Both -/// macro and arm carry the reasoning; the rule that matters here is -/// that a construct takes exactly one of the two routes, never both -/// (`.claude/rules/grammar-dispatch.md` §5). -/// - **Perl** `/^#/` (`pattern_matcher`) and `m{^#}` -/// (`pattern_matcher_m`), the two spellings of a match against the -/// implicit `$_`. They are sibling rules, not aliases, so both are -/// listed — a fixture carrying only the first would have left the -/// `m{}` half at zero and read as covered. -/// - **Ruby** `a in Integer` (`test_pattern`), the one-line pattern -/// test. Decided against `match_pattern` (`expr => pat`), which -/// raises rather than yielding a boolean. -/// -/// Rust's `matches!(…)` measures short in the same way, and is -/// deliberately **not** here: its fix is `macro_invocation`, which also -/// catches `cfg!` / `dbg!` / `todo!`, and the one corpus snapshot it -/// moves is a `cfg!` rather than a `matches!`. That breadth is what -/// #1461 item 2 exists to decide, so it is left to that issue rather -/// than settled by inclusion here. -/// -/// Two slots per language, because the sets feed two independent walker -/// paths (§11) and every construct above measured short in **both**: the -/// operands of a `&&` chain, and the predicate of an `if`. A fixture of -/// only one leaves the other untested. +/// A Perl statement modifier must score like the block form it is +/// shorthand for (#1464). /// -/// Each row also carries the construct's negated spelling. That is not -/// redundancy: `_inspect_container` is a *third* consumer of every -/// terminal set, reached only after a `(…)` or `!…` wrapper is peeled, -/// and it is the consumer that decides whether the peeled operand sits -/// in boolean context at all. Without a negated row, Groovy's and -/// Rust's `inspect_container` arms were the site no fixture here -/// exercised. +/// `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. /// -/// Even with it, no language exercises all three consumers through -/// these two slots, and which one is missed differs by language — -/// Perl's `perl_count_condition` (reached only from a ternary or a -/// C-style `for` header) and Ruby's `ruby_count_unary_conditions` -/// (unreachable here because `in` binds looser than `&&`, so the chain -/// slot must parenthesise and routes through `inspect_container` -/// instead). Both are covered elsewhere in this file; the gap is -/// recorded rather than papered over, because a reader comparing the -/// slot count to the consumer count will otherwise assume it is three. +/// 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" + ); + } +} + +/// A non-numeric literal in a boolean operand slot must score like an +/// identifier in the same slot (#1462). /// -/// The `cyclomatic` half of `every_construct_scores_its_recorded_values` -/// is what rules out a regression that moved both metrics together: -/// cyclomatic must not move when only the operand spelling does, which -/// is what makes a `conditions` move unambiguously ABC's. The recorded -/// figures differ per slot (Rust's chain slot carries the extra `&&` -/// decision), so each slot pins its own pair rather than sharing one. -// Gated on the union of the three languages with rows, so a build -// enabling none of them drops the module instead of failing its -// guards (`.claude/rules/testing.md`, #1286 / #1411). +/// 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 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: +/// +/// - **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 = "groovy", feature = "perl", feature = "ruby"))] -mod own_production_bool_constructs { - use crate::test_support::metrics_verbatim; +#[cfg(any( + feature = "javascript", + feature = "mozjs", + feature = "typescript", + feature = "python", + feature = "lua", + feature = "php", + feature = "groovy", + feature = "perl", + feature = "ruby", + feature = "elixir" +))] +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 `{}` slot for the - /// whole boolean expression, and the `abc.conditions_sum` / - /// `cyclomatic_sum` every spelling in that slot must produce. + /// 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); - /// A language's two slots, its identifier control, the constructs - /// that must score the same, and how many of those there should be. + /// 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 Groovy's list back to a single construct — or - /// dropping Perl's `m{}` half, which is a separate grammar rule from - /// its `//` half — leaves the module green with that coverage - /// deleted. Pinning the length makes such a trim a deliberate - /// two-line edit instead of a silent one. - type Case = ([Slot; 2], &'static str, &'static [&'static str], usize); + /// 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()) @@ -13703,100 +15779,288 @@ mod own_production_bool_constructs { .cyclomatic_sum() } - /// `([chain_slot, predicate_slot], identifier, constructs)` per - /// language. + /// 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); + } + #[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"), + } + } + + /// `([chain_slot, condition_slot], identifier, literals)` per + /// language, one spelling per literal *kind* the grammar emits. /// - /// `{}` is the left-hand operand of a two-operand short-circuit - /// chain in the first slot and the whole `if` predicate in the - /// second, so every fixture differs from its own control in exactly - /// the construct under test. + /// - 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`. + /// - 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`. /// - /// Ruby's chain slot parenthesises the slot because `in` binds - /// looser than `&&` there; the control is parenthesised identically, - /// so the parens cancel out of the comparison and - /// `ruby_inspect_container` unwraps them for both sides alike. + /// 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. + /// + /// 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), + 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, b, l, s) {\n return {} && b\n}\n", 2, 3), - ("def f(a, b, l, s) {\n if ({}) { return 1 }\n}\n", 1, 3), + ("def f(a) {\n return a && {}\n}\n", 2, 3), + ("def f() {\n if ({}) { return 1 }\n}\n", 1, 3), ], "b", &[ - "a in l", - "a !in l", - "a === b", - "a !== b", - "s =~ /p/", - "s ==~ /p/", - "!(a in l)", + ("\"s\"", crate::Groovy::StringLiteral as u16), + ("null", crate::Groovy::NullLiteral as u16), + ("[]", crate::Groovy::ListLiteral as u16), + ("[:]", crate::Groovy::MapLiteral as u16), ], - 7, + 4, ), LANG::Perl => ( [ - ("sub f {\n my $x = {} && $b;\n}\n", 2, 3), - ("sub f {\n if ({}) { 1; }\n}\n", 1, 3), + ( + "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, + ), ], - "$a", - &["/^#/", "m{^#}", "!/^#/"], - 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)\n ({}) && b\nend\n", 2, 3), - ("def f(a)\n if {}\n 1\n end\nend\n", 1, 3), + ("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), ], - "a", - &["a in Integer", "!(a in Integer)"], - 2, + "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, }) } /// 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`, - /// 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. + /// first established that the case can still assert something. The + /// 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, 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() { if !lang.is_enabled() { continue; } - let Some(case @ (slots, _, constructs, expected_constructs)) = cases(lang) else { + let Some(case @ (slots, _, literals, expected_kinds)) = cases(lang) else { continue; }; - // Both halves are needed, and the second does not imply the - // first. The length check alone only asserts that the list - // and its recorded count *agree*, so emptying a row and - // 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. + // 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!( - !constructs.is_empty(), - "{lang:?}: the construct list is empty; this language asserted nothing" + !literals.is_empty(), + "{lang:?}: the literal-operand list is empty; this language asserted nothing" ); assert_eq!( - constructs.len(), - expected_constructs, - "{lang:?}: the construct list no longer covers every own-production \ - boolean spelling this language was fixed for" + 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 `{{}}` slot: {template}" + "{lang:?}: template lost its `{{}}` operand slot: {template}" ); } check(lang, case); @@ -13804,22 +16068,21 @@ mod own_production_bool_constructs { } assert!( checked > 0, - "no language with an own-production boolean construct enabled; \ - this test asserted nothing" + "no truthy-valued language enabled; this test asserted nothing" ); } #[test] - fn an_own_production_construct_scores_like_an_identifier() { - for_each_case(|lang, (slots, identifier, constructs, _)| { + 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 construct in constructs { - let source = template.replace("{}", construct); + for (literal, _) in literals { + let source = template.replace("{}", literal); let scored = conditions(lang, &source); assert_eq!( scored, baseline, - "{lang:?}: `{construct}` scored {scored} conditions against \ + "{lang:?}: `{literal}` scored {scored} unary conditions against \ `{identifier}`'s {baseline}\n source: {source}" ); } @@ -13827,85 +16090,148 @@ mod own_production_bool_constructs { }); } - /// The two Perl spellings must remain two distinct grammar kinds. - /// - /// `perl_bool_terminal_kinds!()` lists `PatternMatcher` **and** - /// `PatternMatcherM` on the stated grounds that `/^#/` and `m{^#}` - /// are sibling rules rather than aliases of one. Nothing above pins - /// that: if a grammar bump collapsed `m{}` onto `pattern_matcher`, - /// both fixtures would keep scoring through the surviving entry, - /// the `PatternMatcherM` arm would become dead code, and every test - /// in this module would stay green — the silent-drift shape lesson - /// 34 and grammar-dispatch §2 exist to catch. - /// - /// Asserting the ids differ is not enough on its own, since two - /// distinct enum variants prove nothing about what the parser - /// emits. So each spelling is parsed and required to produce its - /// own kind and not the other's. - #[test] - #[cfg(feature = "perl")] - fn the_two_perl_match_spellings_are_distinct_kinds() { - use crate::{ParserTrait, Perl, PerlParser}; - use big_code_analysis_ast::test_support::ast_has_kind_id; - - assert_ne!( - Perl::PatternMatcher as u16, - Perl::PatternMatcherM as u16, - "the two spellings share one kind id; one terminal-set entry is dead" - ); - - for (src, present, absent) in [ - ( - "sub f { if (/^#/) { 1; } }", - ("pattern_matcher", Perl::PatternMatcher as u16), - ("pattern_matcher_m", Perl::PatternMatcherM as u16), - ), - ( - "sub f { if (m{^#}) { 1; } }", - ("pattern_matcher_m", Perl::PatternMatcherM as u16), - ("pattern_matcher", Perl::PatternMatcher as u16), - ), - ] { - let parser = PerlParser::new( - src.as_bytes().to_vec(), - &std::path::PathBuf::from("f.pl"), - None, - ); - let (present_name, present_id) = present; - let (absent_name, absent_id) = absent; - assert!( - ast_has_kind_id(&parser, present_id), - "`{src}` no longer emits `{present_name}`" - ); - assert!( - !ast_has_kind_id(&parser, absent_id), - "`{src}` now also emits `{absent_name}`; the two spellings have \ - collapsed and one `perl_bool_terminal_kinds!()` entry is dead" - ); - } - } - - /// The absolute anchor under the comparison above: every spelling - /// must produce the slot's recorded `conditions`, and must leave - /// `cyclomatic` alone. + /// 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_construct_scores_its_recorded_values() { - for_each_case(|lang, (slots, identifier, constructs, _)| { + fn every_literal_operand_scores_its_recorded_values() { + for_each_case(|lang, (slots, identifier, literals, _)| { for (template, expected_conditions, expected_cyclomatic) in slots { - for spelling in std::iter::once(identifier).chain(constructs.iter().copied()) { - let source = template.replace("{}", spelling); + 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:?}: `{spelling}` conditions\n source: {source}" + "{lang:?}: `{operand}` conditions\n source: {source}" ); assert_eq!( cyclomatic_sum(lang, &source), expected_cyclomatic, - "{lang:?}: `{spelling}` cyclomatic_sum\n source: {source}" + "{lang:?}: `{operand}` cyclomatic_sum\n source: {source}" ); } } }); } + + /// 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 + /// 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. +// 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::*; + + #[cfg(feature = "php")] + #[test] + fn php_hidden_string_supertype_is_unreachable() { + let src = " 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. +// +// 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::*; + + 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,40 +116,22 @@ 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!()` // 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). - if matches!(node_kind, csharp_bool_terminal_kinds!()) { + // `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.; } @@ -92,7 +154,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) { @@ -286,20 +350,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 @@ -323,7 +378,56 @@ 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 => { + // 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 + // 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* @@ -525,9 +629,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. // @@ -639,8 +744,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 +752,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); } } diff --git a/src/metrics/abc/elixir.rs b/src/metrics/abc/elixir.rs index 2942fc359..7650d5069 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::*; @@ -44,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; @@ -75,6 +85,49 @@ 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. +// +// 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). +// +// `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); + } +} + // 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. @@ -107,6 +160,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 +243,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,32 +277,79 @@ 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 => { - stats.conditions += 1.; + // 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. + // + // 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. + // + // 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); + } } - // 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 @@ -244,9 +374,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/abc/groovy.rs b/src/metrics/abc/groovy.rs index 2e1f2f1fb..8df727377 100644 --- a/src/metrics/abc/groovy.rs +++ b/src/metrics/abc/groovy.rs @@ -13,11 +13,65 @@ 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. +// +// 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::*; + + 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 +81,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)); - - 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(); + while let Some((operand, proves_boolean)) = groovy_wrapper_operand(&node) { + has_boolean_content |= proves_boolean; + node = operand; - // `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 +105,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) { @@ -243,14 +280,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 @@ -386,24 +452,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); } } diff --git a/src/metrics/abc/java.rs b/src/metrics/abc/java.rs index 70570aadd..174647e2d 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()), @@ -63,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.; @@ -91,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) { @@ -249,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 @@ -306,6 +333,39 @@ 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 / 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. + // + // 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 => { @@ -343,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 e7856cc24..d26850992 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/perl.rs b/src/metrics/abc/perl.rs index ecfdd99da..fc00077f9 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,72 @@ 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. +// +// 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; + }; + 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 +374,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 +531,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/metrics/abc/python.rs b/src/metrics/abc/python.rs index 8cec94de5..dfdad6f6d 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 e77b7dc1f..08e664b38 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); } @@ -252,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/src/metrics/abc/rust.rs b/src/metrics/abc/rust.rs index 53652916e..a789f52c4 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 2c2b34dea..95b8c9204 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() { @@ -2948,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 @@ -5382,16 +5503,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); }, ); } @@ -6723,15 +6847,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/csharp.rs b/src/metrics/cyclomatic/csharp.rs index 85912d9fd..561bf9e7b 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/metrics/cyclomatic/elixir.rs b/src/metrics/cyclomatic/elixir.rs index c4103909b..f15b0c4e4 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/groovy.rs b/src/metrics/cyclomatic/groovy.rs index 6c6371b35..9229fb2ed 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] ); diff --git a/src/metrics/cyclomatic/java.rs b/src/metrics/cyclomatic/java.rs index 0729e4eef..131259e32 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 e5b107116..2591d0836 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/halstead.rs b/src/metrics/halstead.rs index 71dba4990..b9f44393c 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/src/metrics/npa/shared.rs b/src/metrics/npa/shared.rs index b64369041..eeb72df2d 100644 --- a/src/metrics/npa/shared.rs +++ b/src/metrics/npa/shared.rs @@ -656,6 +656,127 @@ 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). +/// +/// 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>)> { + 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 +/// (`@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). +/// +/// 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. +/// +/// 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], + 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; the first + // ancestor above the chain of `when` operators is the position that + // decides. + let Some((mut operator, _)) = chain.next() else { + return false; + }; + 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 { + 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/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 f97243d96..33ce8b328 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 } diff --git a/src/test_support.rs b/src/test_support.rs index 168eafe44..a8a6ba4b9 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,63 @@ 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 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}; diff --git a/tests/parity/self_reference_operand_parity.rs b/tests/parity/self_reference_operand_parity.rs index 17df7d9b0..21ddd8578 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", diff --git a/tests/repositories/big-code-analysis-output b/tests/repositories/big-code-analysis-output index 81abf5931..062ae3439 160000 --- a/tests/repositories/big-code-analysis-output +++ b/tests/repositories/big-code-analysis-output @@ -1 +1 @@ -Subproject commit 81abf5931c9d54dfc43a109149209d14cdf60cac +Subproject commit 062ae3439ee405c20152a9845e8d7f27d994f632 diff --git a/utils/check-feature-gates-test.py b/utils/check-feature-gates-test.py new file mode 100644 index 000000000..cdd542bab --- /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 000000000..b46beb083 --- /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())