Skip to content

test(features): gate every per-language test on the grammar it names - #1479

Merged
dekobon merged 8 commits into
mainfrom
fix/1472-gate-per-language-tests
Sep 18, 2026
Merged

dekobon merged 8 commits into
mainfrom
fix/1472-gate-per-language-tests

Conversation

@dekobon

@dekobon dekobon commented Sep 16, 2026

Copy link
Copy Markdown
Owner

Closes #1472, #1413, #1285, #1478.

Per-language tests named a concrete parser — check_metrics::<PythonParser>(…)
— without gating on that grammar. mk_langs! generates every *Parser alias,
*Code tag and LANG variant unconditionally, so such a test compiles with
python off and then panics in Tree::new. Measured on main: 3,025 tests
named a grammar without gating on it
, and cargo nextest run --no-default-features --features rust -p big-code-analysis failed in the
thousands. A partial-feature build could not be used to verify anything — the
same misleading-red class as the missing-corpora case #1171 fixed.

What landed

utils/check-test-lang-gates.py derives the marker an item needs from the
languages its body reaches, reading the language table out of the mk_langs!
invocation rather than keeping a copy. 3,500 gated items are checked on every
make pre-commit; --fix writes markers, --show prints the derivation.

The feature-matrix CI legs now run the suite instead of only compiling it
— every target but the three corpus-dependent binaries, whose fixtures live in
a submodule that job does not check out. That is the ratchet #1285 asked for,
and it is what makes the gate regress-testable.

The gate checks both directions. Too narrow is the silent one: too wide
panics on the leg that lacks the grammar, too narrow just drops the test and a
leg running two hundred fewer tests looks exactly like a green run. Sixteen
gates in the tree are deliberately wider than their bodies justify — a corpus
walk picking a language per file, a non-vacuity anchor, a mod declaration —
and each says so with a marker naming which features and why:

// test-lang-gates: hand-written(cpp) — the corpus walk picks a
//     language per file from its extension, so the glob list decides
//     it and nothing in the body names it
#[cfg(feature = "cpp")]

A gate that grows a feature nobody can account for fails, and so does a marker
that stops being load-bearing — otherwise the sixteen stop reading as a census.

--compare <ref> covers the residue. Everything above compares a marker
against the derivation; when the two agree and are both wrong, only the
previous revision says so — and that is computable statically. It scans the
tree at ref too (git archive, no cargo, no second build), derives which
single-language builds compile each test in each, and fails on a test that
still exists but stopped being built somewhere. The lint job runs it per PR
against the merge base. A deliberate narrowing looks identical and is waved
through with the gate-narrowing-intended label — not an in-source marker,
which would be permanently stale the moment this lands.

check-feature-gates.py (#1472 item 3) no longer misses a union-gated mod
nested inside an already-#[cfg(test)] parent — latent before this branch, and
about to stop being latent because the pass creates nested gated modules.

Two derivation rules, both verified by revert

A comparison is not a use. lang == LANG::Go asks which variant a value is;
the enum is generated unconditionally, so it parses nothing. Reading it as a
requirement is what conjoined feature = "go" onto containers_emit_npm_and_npa
and dropped the positive half of the #1197 contract from every build without Go.
Deliberately not extended to the later arms of an or-pattern — | LANG::X is
also how a dispatch table groups its arms, and excluding those cost eleven items
their whole C-family union.

A sweep still needs the parsers it hardcodes. is_enabled filtering earns a
row set only any(…); a parser named through a type parameter cannot be
skipped by any runtime filter, so it stays required even inside a sweep, and it
propagates through helpers exactly as needs does.

Defects this found

  • tests/api/main.rs gated mod parser_reuse on all(rust, typescript) when
    only three of its five tests need TypeScript. The other two had been dropped
    from every Rust-only build — before test(abc): the test module is ungated, so a minimal-feature build reports ~2,600 spurious failures #1472, then blessed by a marker written
    during it. That leg now runs two tests it did not before.
  • Six Ccomment / Preproc tests would have been gated out of every default
    build by an asymmetry between the root and ast crates' c-family-helpers
    features. Handled by feature_atom, which spells the atom per crate.

Verification

  • Derivation validated against the 167 hand-written gates first, before it
    was allowed to generate anything. If it could not reproduce what careful
    humans wrote over twelve months, the mechanical pass would have baked the
    error in 2,835 times.
  • cargo nextest list --all-features --workspace unchanged — the set, not
    the count. The only difference against the baseline is ten #[ignore]d
    entries the two listings count differently; nothing was lost.
  • All 15 CI feature-matrix legs green locally, 30/30 clippy and nextest.
  • 70 self-tests on the gate, including one per rule, per exclusion and per
    comparison spelling.
  • make pre-commit: BCA_GATE: pass.

Review notes

A fresh-context review of the over-gate check returned twelve findings, four of
which changed a verdict and three of which were invisible from the gate's own
output — it reported clean either way. The over-gating rule collapsed from a
rule-plus-two-exclusions into one; over_gated learned to skip pub items;
hardcoded learned to propagate through helpers; and --fix learned to write a
marker that satisfies the check that asked for it (it previously stacked four
#[cfg]s on a sweep-that-pins and exited 2). Five of the nineteen markers the
first cut accepted were artefacts of those defects — hence sixteen.

The last two commits are unrelated to the gate: a .claude/rules/shell.md entry
for a pgrep -f wait loop that matches its own command line and never exits,
which leaked ten stuck waiters during this work, plus the review-driven
correction to it.

Not covered

The diff is dominated by ~3,235 mechanical #[cfg] attribute lines across 65
.rs files. Worth a .git-blame-ignore-revs entry if this repo starts keeping
one.

@codecov

codecov Bot commented Sep 16, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 98.03%. Comparing base (35fe54c) to head (0c7a88b).
⚠️ Report is 8 commits behind head on main.

Additional details and impacted files

Impacted file tree graph

@@           Coverage Diff           @@
##             main    #1479   +/-   ##
=======================================
  Coverage   98.02%   98.03%           
=======================================
  Files         359      359           
  Lines       95916    95916           
  Branches    95485    95485           
=======================================
+ Hits        94022    94031    +9     
+ Misses       1213     1205    -8     
+ Partials      681      680    -1     
Flag Coverage Δ
python 100.00% <ø> (ø)
rust 98.02% <ø> (+<0.01%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
big-code-analysis-ast/src/alterator.rs 92.78% <ø> (ø)
big-code-analysis-ast/src/ast.rs 97.70% <ø> (ø)
big-code-analysis-ast/src/checker.rs 96.77% <ø> (ø)
big-code-analysis-ast/src/comment_rm.rs 95.48% <ø> (ø)
big-code-analysis-ast/src/getter.rs 96.07% <ø> (ø)
big-code-analysis-ast/src/langs.rs 95.26% <ø> (ø)
big-code-analysis-ast/src/node.rs 98.25% <ø> (ø)
big-code-analysis-ast/src/parser.rs 97.08% <ø> (ø)
src/from_path_error.rs 98.21% <ø> (ø)
src/metrics/abc.rs 99.68% <ø> (ø)
... and 21 more

... and 2 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@dekobon

dekobon commented Sep 16, 2026

Copy link
Copy Markdown
Owner Author

--compare failed, found a real bug, and then needed the label

Recording this rather than just applying the label, because a silently
waved-through check is the hole this gate exists to prevent.

It caught something

--compare ran in CI for the first time on this PR and reported two
every_*_in_every_language parity sweeps gated
any(c, c-family-helpers, cpp, mozcpp). Both walk the whole LANG
enum. They were running in four builds instead of twenty-three — and
had been since before this branch.

Neither other check could see it. over_gated compares a gate against
the derivation, so a gate faithfully mirroring a wrong derivation is
by construction never flagged, and an any(...) requires no single
feature for it to object to. That is the exact argument for --compare
existing, made on its first run.

Two derivation defects, both now fixed at the root:

  • Every alternative of a matches! is an identity test, not just
    the leading one. matches!(lang, LANG::Ccomment | LANG::Preproc),
    written to skip two languages, read as a requirement for one of
    them — and that single leak was the entire gate on both sweeps. This
    is not the or-pattern caveat, which is about match arms where |
    groups a dispatch table a sweep legitimately derives from.
  • A full-enum sweep needs any(<every language>), when it carries
    both halves of the .claude/rules/testing.md rule (an is_enabled()
    filter and a non-vacuity assertion) — those together mean it fails
    rather than skips with no language enabled. Conditioned on that,
    because Display / FromStr / slug sweeps walk the same enum, parse
    nothing, and belong on the --no-default-features leg.

Then it still failed, and the label is correct

With those fixed, --compare reports 3,001 tests that lost a build
since the merge base. That is the point of the PR: those tests were
ungated, so every probe build compiled them — and then they panicked
in Tree::new. "Stopped being built by --features go" means "stopped
panicking under --features go".

I did not take that on faith. Each of the 3,001 was checked against its
derived needs, asking whether the lost build could actually have run it:

  legitimate (lost only builds it could not have run in): 3001
  SUSPICIOUS (lost a build it could have run in)         : 0
  no derived needs / not found                          : 0

Independent corroboration: all 15 feature-matrix legs now run the
suite green where they previously only compiled it, and
cargo nextest list --all-features is byte-identical to main, so
nothing left the full build.

This is the bootstrap case — the PR that introduces the gate is the one
PR whose whole purpose is wholesale narrowing — so
gate-narrowing-intended is applied. Worth noting the label did not
exist until this PR needed it; the escape hatch was documented and
wired into the CI condition but never created, so the first legitimate
narrowing would have hit a blocking check whose documented remedy
silently never matched.

Fixed in 3557fd8c

  • 15 legs green, 30/30 clippy and nextest
  • nextest list --all-features unchanged
  • 73 self-tests, including one per new rule and the negative case
  • make pre-commit: BCA_GATE: pass

@dekobon dekobon added the gate-narrowing-intended check-test-lang-gates --compare: a test legitimately stopped being built by a language leg label Sep 16, 2026
dekobon added a commit that referenced this pull request Sep 16, 2026
The `--compare` escape hatch could never work. `github.event.pull_request
.labels` is a snapshot taken when the run was queued, so a label applied
in response to the failing run is invisible to it — and re-running the
job replays the same stale payload, so the remedy the failure message
documents had no way to take effect. Measured on #1479: the label was
applied, the job re-ran, and the step failed identically.

The step now queries the PR's labels through the API at run time, which
is correct regardless of ordering and survives a re-run. `lint` gains a
job-scoped `pull-requests: read` for it, matching the file's convention
that jobs needing more than the read-only default escalate explicitly.

Worth noting the mechanism had never been exercised: the label itself
did not exist until this PR needed it, and the condition that consumed
it was wrong. An escape hatch nobody has opened is a design sketch.
@dekobon

dekobon commented Sep 17, 2026

Copy link
Copy Markdown
Owner Author

Independent review (fable): 13 findings, 12 fixed in ed26071b, 1 filed as #1480

The blocker was real

The assert_eq! comparison rule matched with [^()]*?, which cannot
cross a parenthesis, so it stopped at the first call in the argument
list and never reached the variant:

assert_eq!(guess_language(buf, "run"), (Some(LANG::Ruby), "ruby"));

guess_language / get_from_ext / FromStr / name() are not
cfg-gated — they read the table mk_langs! generates unconditionally
— so 36 tests that parse nothing were gated on grammars they never
touch
. test_guess_language ran in one build of twenty-five.

Nothing on this branch could see it, and that is the uncomfortable part:
over_gated compares a gate against the derivation, so a marker
mirroring a wrong derivation is never flagged; and --compare, the one
check that would have caught it, is skipped here by the
gate-narrowing-intended label. The escape hatch hid a real
regression on its first use.

Worse, the triage I posted earlier in this PR — "3,001 lost builds, 0
suspicious" — was computed from the same derivation, so it was blind in
exactly the same way and reported these 36 as legitimate. With the fix
in, the same triage reads 2,967 lost, 0 suspicious, and the 34-test
difference is those tests returning to every build.

Also fixed, each with a regression test

  • A variant in receiver position reads metadata (LANG::Mozcpp .extensions()), except tree_sitter_language() / get_ts_language(),
    which return None with the feature off and are .expect()ed — the
    Tree::new panic one call earlier.
  • all(not(a), not(b)) made every feature read as over-declared:
    probing one at a time never finds the build such a gate admits.
  • #![cfg(...)] was invisible — all three readers tested #[ first.
  • cfg(not(test)) counted as a test scope.
  • => Some(LANG::X) was not a dispatch arm, so a -> Option<LANG>
    helper handed every language to each caller.
  • --compare's reference scan had no non-vacuity guard: an empty
    before printed "OK — no test lost a build".
  • UnicodeDecodeError escaped the handler and exited 1, which reads as
    "offenders found".

Docs: the quoted 3,025 ungated tests re-measures to ~2,950 and is now
marked approximate (it moves with the derivation rules themselves);
three claims about deriving gates for imports, which the tool has
never done, are gone; "merge base" is now "base branch head", which is
what base.sha is.

Two gates I removed and had to put back

Both were load-bearing, and one was caught only by the
--no-default-features leg:

  • from_path_tests picks its grammar from a "foo.rs" extension
    string — the documented blind spot.
  • ast_from_tree_sitter_returns_language_disabled_for_off_feature
    builds a real Rust tree while requiring JavaScript off.

Both now carry a hand-written marker. I also briefly wrote a marker
claiming one of them used tree_sitter_rust directly; it does not, and
that comment is gone along with the over-broad receiver rule that
prompted it.

Left open

#1480 — a sweep's exemption also covers a LANG it names outside the
loop. Pinning every literal was tried and reverted: it breaks 22
items and 8 self-tests, because a sweep's row table is itself written as
literals and those are skipped by the runtime filter. All 16 live
sweeps filter correctly; the boundary is documented in
.claude/rules/testing.md.

Verification

81 self-tests · 15 legs green (30/30 clippy and nextest) ·
nextest list --all-features byte-identical · BCA_GATE: pass

`mk_langs!` generates every `*Parser` alias, `*Code` tag and `LANG`
variant unconditionally; only `get_ts_language`'s arms are cfg'd. So
`check_metrics::<PythonParser>(…)` compiles with `python` off and then
panics in `Tree::new`. Nothing caught that: `make pre-commit` builds
only the default and `--all-features` flavours, and the feature-matrix
legs compiled the tests without running them.

`utils/check-test-lang-gates.py` derives the marker an item needs from
the languages its body reaches, reading the language table out of the
`mk_langs!` invocation rather than keeping a copy of it. `--fix` writes
the markers, `--show` prints the derivation.

It checks three directions, because each is blind to the next.

**Too narrow** — the item is admitted into a build lacking a grammar it
names, and panics. A test needs `all(…)` of what it names; a helper or
fixture table needs `any(…)` of its users; a sweep needs only `any(…)`
of the rows it iterates, since `is_enabled` skips the rest, but still
needs whatever parser it pins through a type parameter.

**Too wide** — the gate requires a feature the body never uses, keeping
the item out of builds it could have run in. Nothing else can see this:
too wide panics, too narrow just drops the test and the leg stays green.
Sixteen gates in the tree are deliberately wider than their bodies and
say so with a `// test-lang-gates: hand-written(<features>) — <why>`
marker; a marker that stops being load-bearing fails too, so the list
stays a census rather than accumulating.

**Neither** — when a marker and the derivation agree and are both wrong,
only history says so. `--compare <ref>` scans the tree at `ref` as well
(`git archive`, no cargo), derives which single-language builds compile
each test in each, and fails on one that still exists but stopped being
built somewhere.

The derivation rules that took measuring:

- A `LANG` being *compared* is an identity test, not a parse — the enum
  exists without its grammar. Covers `==`, `matches!` (every
  alternative, not just the first), `assert_eq!` (read with balanced
  parens, so a variant behind a call in the expected value is still a
  comparison) and receiver position, except the two methods that hand
  back the grammar itself.
- Deliberately *not* extended to the later arms of a `match`, where `|`
  groups a dispatch table a sweep legitimately derives from.
- A sweep over the whole enum that filters on `is_enabled` *and* asserts
  non-vacuity needs `any(<every language>)`: it fails rather than skips
  with none enabled, and its fixtures come from a `LANG`-parameterised
  helper whose arms are not attributed to callers.
- `c-family-helpers` is spelled differently in the two crates, and
  getting it wrong is silent.

81 self-tests, including the repository check that the derivation
reproduces all 167 gates humans had written by hand — which is what
earned it the right to generate the rest.

Refs #1472, #1413, #1478
Measured across `src/`, `big-code-analysis-ast/src/` and `tests/`: about
2,950 tests named a grammar without gating on it, so
`cargo nextest run --no-default-features --features rust -p
big-code-analysis` failed in the thousands and a partial-feature build
could not be used to verify anything — the misleading-red class #1171
fixed for the corpora.

The markers are derived and written by `check-test-lang-gates.py`, not
hand-placed, and sit on the attribute stack alongside `#[test]`. No test
changed its name, its assertions, or whether it runs under
`--all-features`: `cargo nextest list --all-features --workspace` is
byte-identical before and after, which is the safety net for a
mechanical pass this size and the reason per-`fn` attributes were used
rather than new wrapping modules, which would have renamed every test
path and destroyed it.

Four shapes the derivation cannot see carry a hand-written marker
instead, sixteen items in all: a corpus walk choosing a language per
file from a glob, a non-vacuity anchor, a `mod` declaration with no body
to read, and a grammar picked out of a `"foo.rs"` extension string.

Three source changes come with it:

- `suppression_test.rs`'s `analyze_lang` took the language from the
  path's extension, which hid it from the scan and from the reader; it
  now takes a `LANG` at the call site.
- The two library roots and the five integration-test crate roots relax
  `unused_imports` on partial builds only. Per-language gating makes
  "is this import live" a function of the enabled feature set, which no
  `cfg` on the import can express; `all-languages` is on by default and
  under `--all-features`, so the builds that matter still police it.
- `assert_csharp_fixture_spells` is gated to match its callers, the
  "also noticed" item in #1472.

One latent defect surfaced: six `Ccomment` / `Preproc` tests would have
been gated out of every default build by the root crate's
`c-family-helpers` not being what its ast-crate namesake enables.

Refs #1472, #1413
`check-feature-gates.py` classified a `mod` as test-bearing only when it
carried its own bare `cfg(test)`, so a union-gated `mod` inside an
already-`#[cfg(test)]` parent was never checked at all. Latent while
every subject was top-level, and about to stop being latent: gating the
per-language tests creates exactly those nested gated modules.

The naive repair breaks `subject_matcher`, which anchors on the bare
name — admitting any `mod` makes a subject called `tests` match every
`::tests::` path. Subjects now carry their full module path and match on
that.

Two smaller defects in the same scan, both found while testing it: a
brace on a later line made a declaration read as a body and pass
vacuously, and `splitlines()` disagreed with the line indices everywhere
else in the file, raising `IndexError` on a trailing form feed.

Fixes the third item of #1472.
The legs ran `cargo check --all-targets` and nothing else, so the
"minimal grammar set" guarantee from #252 was compile-only and a
regression that shows up solely in a partial build could land unseen.
They now run `cargo nextest run`, which is what makes the per-language
gating regress-testable rather than a one-off pass.

Three binaries are excluded by exact name: their fixtures live in the
`tests/repositories/` submodules this job does not check out. Exact
matchers, because `not binary(corpus)` is a substring match that
excluded nothing from the CLI package — 1,412 tests before and after —
and 19 of them read a real source file from the DeepSpeech corpus.

The `lint` job also gains `check-test-lang-gates.py --compare` against
the base branch head, the one direction the marker checks cannot reach.
It reads the `gate-narrowing-intended` label **live** rather than from
`github.event.pull_request.labels`: that payload is a snapshot from when
the run was queued, so a label applied in response to the failing run is
invisible to it, and a re-run replays the same stale copy — the
documented remedy could never have taken effect. The job escalates to
`pull-requests: read` for that, per this file's convention.

Closes #1285.
`check-test-lang-gates` and its self-tests join `make pre-commit` and
`make ci`, and the pre-commit hooks. Unlike `check-feature-gates`, both
arms go in: this one is a pure source scan with no cargo invocation, so
it costs about three seconds.

`check-test-lang-gates-compare` is deliberately left out of both. It
needs a base revision to mean anything and a working tree mid-edit has
none, so CI supplies the PR's base; by hand it is
`make check-test-lang-gates-compare COMPARE_REF=origin/main`. The
variable is spelled out rather than a bare `REF`, which `?=` would pick
up from an exported environment variable of that name.
`.claude/rules/testing.md` gains the per-test rule under the existing
fixture-table section: what the derivation reads, the four shapes it
cannot see and must be told about by marker, the two directions the gate
checks, and `--compare` for the third. Also the trust boundaries, which
matter more than the happy path — the sweep pin covers a parser named as
a *type* and nothing else (#1480), and the `allow(unused_imports)`
carve-out is scoped to partial builds so the full build still polices
every import.

`AGENTS.md` lists both gates and the out-of-band comparison; `CHANGELOG`
records the change under Unreleased. It is test- and CI-only, so nothing
for `STABILITY.md`.

The counts are given as approximate on purpose. They move with the
derivation rules themselves — every refinement that stops reading
something as a use lowers them — and an earlier draft of this section
quoted a figure taken before the comparison rules landed.
`pgrep -f` matches the full command line, so a loop passed as `-c` text
— which is how the assistant's shell tool issues everything — finds its
own argv:

    until ! pgrep -f 'make pre-commit' >/dev/null; do sleep 30; done

Ten of these accumulated in one session, seven waiting on a gate that
had written `BCA_GATE: pass` hours earlier. They match each other too,
so killing one does not release the rest. Silent in the way the rest of
this file is about: it reads as "still running", which is what the truth
looks like until it isn't.

Prefer a condition that is not a process — and bound it, because an
unbounded wait on a job that dies is the same hang by another route.
Where it must be a process, `[m]ake` breaks the self-match. `$$` does
not: the command substitution and subshell inherit the argv too, so
filtering one PID can never empty the list.

Every snippet in the section was run before being written down,
including one extracted back out of the committed file. The first draft
was not, and both of its unrun examples were wrong.

Unrelated to the rest of this branch; it is the tooling lesson from
doing the work.
@dekobon
dekobon force-pushed the fix/1472-gate-per-language-tests branch from ed26071 to 1cdeb63 Compare September 17, 2026 01:47
Lesson 6 said to derive an assertion from an external source rather
than from the code's own output, and framed it around snapshots. The
same mechanism cost the most on #1478: `over_gated` checked ~3,200
generated markers against the derivation that generated them, so it
could only catch hand edits; and a triage computed from that derivation
certified 34 over-gated tests as legitimate.

The Lesson paragraph now covers any expected value that is produced
rather than asserted, and asks for a named oracle outside the model. The
sub-example records which oracles actually found the defects — prior
hand-written gates, the previous revision, a fresh-context review — and
that the history comparison was the one check the narrowing label
switched off.
@dekobon
dekobon merged commit 0c7a88b into main Sep 18, 2026
54 checks passed
@dekobon
dekobon deleted the fix/1472-gate-per-language-tests branch September 18, 2026 17:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

gate-narrowing-intended check-test-lang-gates --compare: a test legitimately stopped being built by a language leg

Projects

None yet

Development

Successfully merging this pull request may close these issues.

test(abc): the test module is ungated, so a minimal-feature build reports ~2,600 spurious failures

1 participant