diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 49f92093a..d3135710a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -321,8 +321,15 @@ jobs: # Must match `release-plz.yml`'s `cache-warm-linux` exactly or that job # writes an entry this one cannot read, which is the current failure # with extra steps. - key: ci - shared-key: ci + # The profile is part of the key: `[profile.*]` lives in the root + # `Cargo.toml`, which rust-cache does NOT consider (it reads + # `Cargo.lock` and `crates/batten/Cargo.toml` only). Without it a + # profile change leaves the key untouched, the restore reports + # `full match: true`, and every artifact it hands back was built + # under the old profile — measured 2026-09-01: 0 -> 121 `Compiling` + # lines. In `shared-key` rather than `key` because `key` is an + # if/else fallback rust-cache ignores whenever `shared-key` is set. + shared-key: ci-${{ hashFiles('Cargo.toml') }} # ASK WHETHER THIS DIFF CAN MOVE THE SLOW TIER (CLOUD-398). Measured # 2026-08-21: this job is ~13 of the ~14.7 billed minutes a non-Rust pull # request costs, `test:bats` is ~81% of it, and a one-file change to @@ -525,8 +532,8 @@ jobs: # discarded. Its own key costs one more entry and no contention. - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 with: - key: bats - shared-key: bats + # The profile is part of the key — see the `ci` job above. + shared-key: bats-${{ hashFiles('Cargo.toml') }} # THE SAME QUESTION THE `ci` JOB ASKS, and it must stay in-job rather than # becoming a `paths:` filter or a job `if:` — see this job's header and # `final`'s. `test:bats` is one of the six `slow`-tagged steps, so a diff @@ -632,7 +639,8 @@ jobs: # semver carries its own. - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 with: - key: perf + # The profile is part of the key — see the `ci` job above. + shared-key: perf-${{ hashFiles('Cargo.toml') }} # CLOUD-1331. The BASE arm is a pure function of the merge-base SHA, the # pinned toolchain and `[profile.release]`, and `main` advances only by # fast-forward to already-judged SHAs — so consecutive pull requests share a diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index 92c9aca61..601267b74 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -65,7 +65,8 @@ jobs: with: # Instrumented builds are a different profile from every other job's, # so a shared cache entry would thrash. - key: coverage + # The profile is part of the key too — see `ci.yml`'s `ci` job. + shared-key: coverage-${{ hashFiles('Cargo.toml') }} # Redirected, never piped. A pipeline hands the exit status to its LAST # stage, so `mise run coverage | tee ...` would report success whenever # `tee` succeeded — the false-green shape `mise run run-shape-guard` diff --git a/.github/workflows/fuzz.yml b/.github/workflows/fuzz.yml index 6e76e74a4..c4dd70382 100644 --- a/.github/workflows/fuzz.yml +++ b/.github/workflows/fuzz.yml @@ -77,7 +77,8 @@ jobs: with: # Sanitizer-instrumented nightly builds share no artifacts with any # other job's profile, so a shared cache entry would only thrash. - key: fuzz + # The profile is part of the key too — see `ci.yml`'s `ci` job. + shared-key: fuzz-${{ hashFiles('Cargo.toml') }} # The working corpus, carried between runs rather than committed. A fuzzer # that restarts from the seeds every week re-derives the same shallow # coverage every week and never gets past it; accumulating it in git diff --git a/.github/workflows/perf.yml b/.github/workflows/perf.yml index 3e8c1cf7e..3f99d207a 100644 --- a/.github/workflows/perf.yml +++ b/.github/workflows/perf.yml @@ -98,7 +98,8 @@ jobs: with: # A release profile, which no other job builds, so a shared cache entry # would thrash. - key: perf + # The profile is part of the key too — see `ci.yml`'s `ci` job. + shared-key: perf-${{ hashFiles('Cargo.toml') }} # Redirected into a file and read back, never piped into the gate. A # pipeline hands its exit status to the LAST stage, so `perf | # perf-assert` would discard exactly the measurement failure that must diff --git a/.github/workflows/release-plz.yml b/.github/workflows/release-plz.yml index ddf2d2626..a7a939e46 100644 --- a/.github/workflows/release-plz.yml +++ b/.github/workflows/release-plz.yml @@ -163,10 +163,11 @@ jobs: # `id` and the guard together. id: rust-cache with: - # Both spellings must match `ci.yml`'s job exactly or this writes an - # entry that job cannot read. - key: ci - shared-key: ci + # Must match `ci.yml`'s job exactly or this writes an entry that job + # cannot read. One spelling now, not two: `key` is an if/else + # fallback rust-cache ignores whenever `shared-key` is set, so the + # pair could drift while looking deliberate. + shared-key: ci-${{ hashFiles('Cargo.toml') }} # Compile only when there is nothing to restore. The evidence and the # failure directions are recorded on the Windows job below; the same # reasoning applies unchanged, and the same property refuses an unguarded @@ -196,11 +197,11 @@ jobs: # warm. `ci-local-parity` property 17 holds the pair together. id: rust-cache with: - # Both spellings must match `rust.yml`'s `windows` job exactly or this - # writes an entry that job cannot read, which is the current failure - # with extra steps. - key: windows - shared-key: windows + # Must match `rust.yml`'s `windows` job exactly or this writes an + # entry that job cannot read. One spelling now, not two — and that + # job carried `key` where this one carried both, an asymmetry that + # happened to match and could not be explained from the source. + shared-key: windows-${{ hashFiles('Cargo.toml') }} # COMPILE ONLY WHEN THERE IS NOTHING TO RESTORE, and the evidence for this # is that every warm cycle after the first was pure waste (CLOUD-840). # diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 292592405..19cff47a1 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -127,6 +127,9 @@ jobs: # tool it touches. See the ci job for why these lists are narrow. install_args: rust - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 + with: + # The profile is part of the key — see `ci.yml`'s `ci` job. + shared-key: cross-${{ hashFiles('Cargo.toml') }} - run: mise run cross-check env: # Only the triple cross-check type-checks. doctor's default pair would @@ -210,7 +213,8 @@ jobs: with: # A per-target key: a leg's artifacts are target-specific and would # otherwise thrash a shared entry if the second leg is restored. - key: ${{ matrix.target }} + # The profile is part of the key too — see `ci.yml`'s `ci` job. + shared-key: ${{ matrix.target }}-${{ hashFiles('Cargo.toml') }} - run: mise run darwin-link ${{ matrix.target }} env: # Exactly the target being linked; doctor's default would also fetch @@ -288,7 +292,8 @@ jobs: # carries its own. - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 with: - key: semver + # The profile is part of the key — see `ci.yml`'s `ci` job. + shared-key: semver-${{ hashFiles('Cargo.toml') }} - run: mise run semver # THE OS MATRIX, and the only job here that EXECUTES rather than type-checks @@ -435,33 +440,43 @@ jobs: install_args: rust hk aqua:jqlang/jq github:nextest-rs/nextest - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 with: - # A distinct key: this job's target dir is built by a different host - # toolchain than every other job's, so sharing an entry would thrash - # it — the same reason semver and perf carry their own. - key: windows - # AND A SHARED KEY, which is what lets a job in ANOTHER workflow fill - # this entry (CLOUD-840). rust-cache composes - # `v0-rust-{key}-{shared-key or job id}-{os}-{arch}-{hashes}`, read off - # two live entries rather than from the docs — so without this the job - # ID is part of the key and a warm job under any other name computes a - # different one and the two never meet. + # A DISTINCT KEY, because this job's target dir is built by a different + # host toolchain than every other job's, so sharing an entry would + # thrash it — the same reason semver and perf carry their own. And a + # SHARED one, which is what lets a job in ANOTHER workflow fill this + # entry (CLOUD-840): without it the job ID is part of the key, and a + # warm job under any other name computes a different one. + # + # `key` IS GONE, AND ITS ABSENCE IS THE POINT. This block carried both + # `key: windows` and `shared-key: windows`, and the pair read as + # deliberate. It was inert: rust-cache v2.9.2 `src/config.ts` takes + # `shared-key` in an if/else and never reads `key` when it is set — + # + # if (sharedKey) { key += `-${sharedKey}`; } + # else { const inputKey = core.getInput("key"); … } + # + # which is exactly what the two live entries recorded here already + # showed: `v0-rust-windows-windows-Windows_NT-x64-…` before the shared + # key was added (key + job id), `v0-rust-windows-Windows_NT-x64-…` + # after (shared key alone). One component, not two. The composition + # this comment used to give, `{key}-{shared-key or job id}`, was the + # inference drawn from those strings and it was wrong; the strings + # themselves were right. # - # IT REPLACES THE JOB COMPONENT RATHER THAN FILLING IT, and the first - # warm run is what proved it. This comment previously claimed the - # composed string stayed byte-identical because the value matches - # `key`; the two live entries say otherwise — - # `v0-rust-windows-windows-Windows_NT-x64-…` before, - # `v0-rust-windows-Windows_NT-x64-…` after. One component, not two. - # So adding this DID orphan every prior entry. Harmless, because those - # were per-PR and unreadable across pull requests anyway, and the - # migration is a single cold build on each side — but the claim was - # wrong and a comment that states a measured fact has to state the one - # that was measured. + # THE PROFILE IS PART OF THE KEY (2026-09-01). `[profile.*]` lives in + # the root `Cargo.toml`, which rust-cache does NOT consider — it reads + # `Cargo.lock` and `crates/batten/Cargo.toml` only. So `f54a9c05` + # changed the dev profile, left the key untouched, and every restore + # since has reported `full match: true` while handing back artifacts + # built under the old profile: this job went from 1 `Compiling` line to + # 288, and `ci` from 0 to 121. The warm jobs could not repair it + # either, since they compile only on `cache-hit != 'true'` and the key + # still hit. # # The warm side is `cache-warm-windows` in `release-plz.yml`. Both - # spellings must agree; that they are two places is the cost of the - # only trigger this repository is allowed to hang a warm job on. - shared-key: windows + # must agree; that they are two places is the cost of the only trigger + # this repository is allowed to hang a warm job on. + shared-key: windows-${{ hashFiles('Cargo.toml') }} # A JOB THAT DECLARES A TOOL MUST BE ABLE TO REACH IT, asserted before the # suite rather than discovered inside it. `ci-tools-check` holds these # names against `mise.toml`, which is a different question: it cannot know diff --git a/batten.toml b/batten.toml index 42c2dca1f..c7f4ee906 100644 --- a/batten.toml +++ b/batten.toml @@ -667,7 +667,7 @@ spent by then.""" [[fact]] name = "review-answered" returns = "json-array" -command = "gh api graphql -F id=$(gh pr view --json id --jq .id) -f query='query($id:ID!){node(id:$id){... on PullRequest{author{login} reviews(first:100){pageInfo{hasNextPage} nodes{author{login}}} reviewThreads(first:100){pageInfo{hasNextPage} nodes{id isResolved}}}}}' --jq '.data.node as $p | [($p.reviewThreads.nodes[] | select(.isResolved == false) | .id), (if ([$p.reviews.nodes[] | select(.author.login != $p.author.login)] | length) == 0 then $p.author.login else empty end), (if $p.reviewThreads.pageInfo.hasNextPage then true else empty end), (if $p.reviews.pageInfo.hasNextPage then true else empty end)]'" +command = "gh api graphql -F id=$(gh pr view --json id --jq .id) -f query='query($id:ID!){node(id:$id){... on PullRequest{author{login} reviews(first:100){pageInfo{hasNextPage} nodes{author{login}}} reviewThreads(first:100){pageInfo{hasNextPage} nodes{id isResolved}} comments(last:100){nodes{author{login} body}}}}}' --jq '.data.node as $p | [($p.reviewThreads.nodes[] | select(.isResolved == false) | .id), (if (([$p.reviews.nodes[] | select(.author.login != $p.author.login)] | length) == 0) and (([$p.comments.nodes[] | select(.author.login != $p.author.login) | select(.body | test(\"auto-generated comment: summarize by\")) | select(.body | test(\"rate limited by\") | not)] | length) == 0) then $p.author.login else empty end), (if $p.reviewThreads.pageInfo.hasNextPage then true else empty end), (if $p.reviews.pageInfo.hasNextPage then true else empty end)]'" # The DID-YOU-LOOK half. A `receipt` row is also what makes the fact reach Rego # at all: `required_checks_for` reads the receipt rows, and `agent_records` diff --git a/completions/batten.bash b/completions/batten.bash index f563a808b..c4bfca3e5 100644 --- a/completions/batten.bash +++ b/completions/batten.bash @@ -2574,12 +2574,16 @@ _batten() { return 0 ;; batten__subcmd__enforce) - opts="-J -q -v -y -h --json --strictness --fail-on-warning --config-from --config-in --silent --quiet --verbose --debug --trace --log-level --no-color --no-input --yes --help" + opts="-J -q -v -y -h --rule --json --strictness --fail-on-warning --config-from --config-in --silent --quiet --verbose --debug --trace --log-level --no-color --no-input --yes --help" if [[ ${cur} == -* || ${COMP_CWORD} -eq 2 ]] ; then COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 fi case "${prev}" in + --rule) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; --strictness) COMPREPLY=($(compgen -W "permissive standard strict" -- "${cur}")) return 0 diff --git a/completions/batten.fish b/completions/batten.fish index 44c3aa42c..ae869b6d3 100644 --- a/completions/batten.fish +++ b/completions/batten.fish @@ -107,6 +107,7 @@ complete -c batten -n "__fish_batten_using_subcommand check" -l no-color -d 'Nev complete -c batten -n "__fish_batten_using_subcommand check" -l no-input -d 'Never prompt; treat the run as unattended' complete -c batten -n "__fish_batten_using_subcommand check" -s y -l yes -d 'Confirm a destructive operation that would otherwise refuse' complete -c batten -n "__fish_batten_using_subcommand check" -s h -l help -d 'Print help (see more with \'--help\')' +complete -c batten -n "__fish_batten_using_subcommand enforce" -l rule -d 'Run only the declared rule with this id' -r complete -c batten -n "__fish_batten_using_subcommand enforce" -l strictness -d 'Raise how strictly gates apply (an override may only tighten policy)' -r -f -a "permissive\t'Advisory: findings are reported without failing the run' standard\t'The default: a finding is a violation' strict\t'Everything `Standard` fails on, plus anything advisory'" diff --git a/completions/batten.zsh b/completions/batten.zsh index 6ed1fc75f..47600f28c 100644 --- a/completions/batten.zsh +++ b/completions/batten.zsh @@ -87,6 +87,7 @@ trace\:"Add everything"))' \ ;; (enforce) _arguments "${_arguments_options[@]}" : \ +'--rule=[Run only the declared rule with this id]: :_default' \ '--strictness=[Raise how strictly gates apply (an override may only tighten policy)]: :((permissive\:"Advisory\: findings are reported without failing the run" standard\:"The default\: a finding is a violation" strict\:"Everything \`Standard\` fails on, plus anything advisory"))' \ diff --git a/crates/batten/src/cli.rs b/crates/batten/src/cli.rs index e82e7c3c0..882a6c364 100644 --- a/crates/batten/src/cli.rs +++ b/crates/batten/src/cli.rs @@ -78,6 +78,28 @@ pub struct CheckFlags { pub since: Option, } +/// `enforce`'s flags, which travel together for `CheckFlags`'s reason. +/// +/// A payload struct rather than fields on the variant, and the shape is a +/// REPAIR rather than symmetry for its own sake. `Enforce` carried its flags +/// inline, so adding `--rule` to it was `enum_struct_variant_field_added` — a +/// major break `semver` refused, on a verb whose flag set is the one most likely +/// to grow again. `#[non_exhaustive]` on the enum does not reach a variant's +/// fields; only a named struct does. Paying the break once here is what stops +/// the next flag paying it again. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +#[non_exhaustive] +pub struct EnforceFlags { + /// Emit findings as byte-stable JSON instead of pointer lines. + pub json: bool, + /// Run only the declared row with this id, or every applicable row. + /// + /// The narrowing `check` has carried since CLOUD-1051, extended to the + /// spawning verb for the one caller `check` cannot serve: a case whose + /// SUBJECT is a `kind = "command"` row. See `surface::ENFORCE_RULE`. + pub rule: Option, +} + /// The top-level subcommands. #[derive(Debug, Clone, PartialEq, Eq)] #[non_exhaustive] @@ -85,10 +107,7 @@ pub enum Command { /// Run the applicable read-only gates against the repository. Check(CheckFlags), /// Run every configured rule, including kinds that execute a configured command. - Enforce { - /// Emit findings as byte-stable JSON instead of pointer lines. - json: bool, - }, + Enforce(EnforceFlags), /// Inspect configuration. Config { /// The chosen sub-verb. @@ -1694,9 +1713,10 @@ fn command_of((name, matches): (&str, &ArgMatches)) -> Option { staged: flag(matches, "staged"), since: matches.get_one::("since").cloned(), })), - "enforce" => Some(Command::Enforce { + "enforce" => Some(Command::Enforce(EnforceFlags { json: flag(matches, "json"), - }), + rule: matches.get_one::("rule").cloned(), + })), "config" => config_of(matches).map(|command| Command::Config { command }), "lint" => lint_of(matches).map(|command| Command::Lint { command }), "spec" => matches diff --git a/crates/batten/src/lib.rs b/crates/batten/src/lib.rs index 863c9594f..516074888 100644 --- a/crates/batten/src/lib.rs +++ b/crates/batten/src/lib.rs @@ -181,13 +181,13 @@ pub fn run(cli: Cli, mode: Mode, out: &mut dyn Write, err: &mut dyn Write) -> Re // total — the workspace lints forbid panicking on a reachable path. None => Ok(ExitCode::Success), Some(Command::Check(flags)) => run_check(&flags, mode, &overrides, out, err), - Some(Command::Enforce { json }) => run_rules( + Some(Command::Enforce(flags)) => run_rules( out, err, mode, &overrides, rules::run_all_over, - RunRequest::spawning(json), + RunRequest::spawning(flags.json, flags.rule.as_deref()), ), Some(Command::Config { command }) => run_config(&command, &overrides, out), Some(Command::Spec { format }) => run_spec(format, out), @@ -10765,18 +10765,35 @@ impl<'a> RunRequest<'a> { } } - /// `enforce`'s request. Deliberately NOT narrowable: `--rule` exists so a - /// migrated gate keeps its task name on the read surface, and every caller - /// that needs it is a `check` caller. Offering it here too would be surface - /// nobody asked for, on the verb that spawns. - /// `enforce` is not scopable either, and for the same reason: the flags are - /// `check`'s, and a verb that spawns has no caller asking to spawn over a - /// narrowed set. - const fn spawning(json: bool) -> RunRequest<'static> { + /// `enforce`'s request, narrowable since 2026-09-01. + /// + /// THIS REVERSES A RECORDED DECISION AND THE CONDITION IT NAMED HAS FAILED. + /// This doc said `enforce` was "deliberately NOT narrowable", because + /// "every caller that needs it is a `check` caller" and offering it here + /// "would be surface nobody asked for, on the verb that spawns". Both halves + /// were true when written. The second is not any more. + /// + /// The caller is a case whose SUBJECT is a spawning row: + /// `the_committed_delegating_rule_spawns_nothing_when_its_glob_misses` + /// asserts that a `kind = "command"` row whose glob misses spawns nothing. + /// `check` must refuse that by construction, so the case cannot take the + /// read-surface narrowing, and without one it evaluates all 103 rows to + /// assert one — 206s of a 1482s suite on the Windows runner, which is the + /// critical path and bills at 2x. + /// + /// So this is not surface nobody asked for. It is the one shape the original + /// reasoning could not have covered, since a case about a spawning row is + /// exactly the case that cannot migrate to `check`. + /// + /// `enforce` is still not SCOPABLE, and that half stands unchanged: `--staged` + /// and `--since` are `check`'s, and no caller asks to spawn over a narrowed + /// file set. Narrowing WHICH ROWS run and narrowing WHICH FILES they select + /// against are orthogonal, and only the first has a caller here. + const fn spawning(json: bool, only: Option<&'a str>) -> RunRequest<'a> { RunRequest { surface: Surface::Spawning, json, - only: None, + only, scope: CheckScope::Tree, } } diff --git a/crates/batten/src/surface.rs b/crates/batten/src/surface.rs index b156db0ed..ac65e011f 100644 --- a/crates/batten/src/surface.rs +++ b/crates/batten/src/surface.rs @@ -872,6 +872,47 @@ const CHECK_RULE: FlagDecl = FlagDecl { value: ValueDecl::Str, }; +/// `--rule ` on `enforce`: the same narrowing, on the verb that spawns. +/// +/// # This reverses a recorded decision, and that decision named its condition +/// +/// `RunRequest::spawning` said `enforce` was "deliberately NOT narrowable", +/// because "every caller that needs it is a `check` caller" and offering it here +/// "would be surface nobody asked for, on the verb that spawns". The reasoning +/// was sound and the condition it rested on has since failed: a caller exists. +/// +/// `the_committed_delegating_rule_spawns_nothing_when_its_glob_misses` asserts a +/// property OF a `kind = "command"` row — that a glob miss spawns nothing — so +/// `check` must refuse it by construction (`V-SPAWN-ON-READ-VERB`, and the verb +/// is the thing that is wrong there, not the rule). It therefore cannot take the +/// `check` narrowing, and without one it evaluates all 103 rows to assert one. +/// Measured on the Windows runner, 2026-09-01: 206s of a 1482s suite, on the +/// job that is the critical path and bills at 2x. +/// +/// So the surface is no longer unasked-for. It is asked for by the one shape the +/// original reasoning could not have covered — a case whose SUBJECT is a +/// spawning row. +/// +/// # Everything `CHECK_RULE` refuses, this refuses identically +/// +/// A `--rule` naming no declared row is a usage error, never a clean run. That +/// property matters more here, not less: a narrowed spawn that silently selected +/// nothing would report "the gate passed" from a gate that never ran, on the +/// surface that is allowed to execute configured commands. +const ENFORCE_RULE: FlagDecl = FlagDecl { + id: "rule", + long: Some("rule"), + short: None, + help: "Run only the declared rule with this id", + env: EnvDecl::None, + global: false, + positional: false, + required: false, + hidden: false, + rung: Rung::None, + value: ValueDecl::Str, +}; + /// `--staged` on `check`: judge the index rather than the whole tree (CLOUD-519). /// /// A pre-commit hook, or an agent's mediated call, re-reads every file in the @@ -1808,7 +1849,7 @@ pub const SURFACE: &[CommandDecl] = &[ about: "Run every configured rule, including kinds that execute a configured command", data_channel: true, effect: Effect::Unclassified, - flags: &[JSON], + flags: &[ENFORCE_RULE, JSON], }, // `exec` runs a command the caller names, so it is the second process-spawning // verb after `enforce` and takes the same conservative reading. It is a diff --git a/crates/batten/tests/it/cli.rs b/crates/batten/tests/it/cli.rs index 73d299f87..eb56b8f19 100644 --- a/crates/batten/tests/it/cli.rs +++ b/crates/batten/tests/it/cli.rs @@ -6148,8 +6148,21 @@ fn the_committed_delegating_rule_spawns_nothing_when_its_glob_misses() { let marker = format!("{} HEAD\n", "<".repeat(7)); fs::write(dir.join("notes.txt"), marker).expect("write out-of-glob source"); + // `enforce --rule`, not a bare `enforce`. The property is about ONE row — + // `no-conflict-markers`, `kind = "command"`, `glob = "crates/**"` — and the + // narrowing is what makes the assertion say so. Unnarrowed, exit 0 and an + // empty stdout also claimed that none of the other 103 rows fires on this + // fixture, which is incidental to the property and cost 206s of a 1482s + // suite on the Windows runner. + // + // The discriminator above is untouched by the narrowing: exit 0 is still + // reachable only if this row's glob selected nothing and no process was + // spawned. `check --rule` is not available here and that is the point — the + // SUBJECT is a spawning kind, so the read-effect verb must refuse it. let output = batten() .arg("enforce") + .arg("--rule") + .arg("no-conflict-markers") .current_dir(&dir) .state_home(&home) .env_remove("BATTEN_STRICTNESS") @@ -11664,11 +11677,26 @@ fn a_tracked_instruction_may_not_prescribe_the_denied_commit_identity() { ) .expect("write fixture instruction"); - // `enforce`, not `check`: the committed ruleset carries a spawning kind that - // the read-effect verb refuses outright. Every sibling test over the - // committed bytes takes the same verb for the same reason. + // `check --rule`, not `enforce`. The unnarrowed read-effect verb does refuse + // this config outright — it carries a spawning kind — but `--rule` selects + // the row BEFORE that refusal is reached, and `no-denied-identity-prescribed` + // is not one of the three `kind = "command"` rows. `mise.toml` already relies + // on this against these same committed bytes: `check --rule prose-only`, + // `--rule filed-here`, `--rule memory-graph`. + // + // This comment used to say `enforce`, "and every sibling test over the + // committed bytes takes the same verb for the same reason". True of an + // unnarrowed `check` and over-general as written: it sent the whole family + // through the 103-rule ruleset to assert one row. Measured on Windows CI, + // this case alone was 305s of a 1482s suite. + // + // The narrowing is not a weakening. Both arms survive, the clean one + // included, and the asserted stdout is now the output of the rule under test + // rather than of all 103 happening to produce that line. let output = batten() - .arg("enforce") + .arg("check") + .arg("--rule") + .arg("no-denied-identity-prescribed") .current_dir(&dirty) .state_home(&home) .env_remove("BATTEN_STRICTNESS") @@ -11704,7 +11732,9 @@ fn a_tracked_instruction_may_not_prescribe_the_denied_commit_identity() { .expect("write fixture instruction"); let output = batten() - .arg("enforce") + .arg("check") + .arg("--rule") + .arg("no-denied-identity-prescribed") .current_dir(&clean) .state_home(&home) .env_remove("BATTEN_STRICTNESS") diff --git a/crates/batten/tests/it/snapshots/it__snapshots__golden_json_schema.snap b/crates/batten/tests/it/snapshots/it__snapshots__golden_json_schema.snap index 97b25bc16..740471bb1 100644 --- a/crates/batten/tests/it/snapshots/it__snapshots__golden_json_schema.snap +++ b/crates/batten/tests/it/snapshots/it__snapshots__golden_json_schema.snap @@ -869,6 +869,14 @@ expression: stdout_of(&output) "takes_value": false, "positional": false, "help": "Emit byte-stable JSON instead of pointer lines" + }, + { + "name": "rule", + "short": null, + "long": "rule", + "takes_value": true, + "positional": false, + "help": "Run only the declared rule with this id" } ], "subcommands": [] diff --git a/man/batten-enforce.1 b/man/batten-enforce.1 index 2116cc68f..261613734 100644 --- a/man/batten-enforce.1 +++ b/man/batten-enforce.1 @@ -4,11 +4,14 @@ .SH NAME batten\-enforce \- Run every configured rule, including kinds that execute a configured command .SH SYNOPSIS -\fBbatten enforce\fR [\fB\-J\fR|\fB\-\-json\fR] [\fB\-h\fR|\fB\-\-help\fR] +\fBbatten enforce\fR [\fB\-\-rule\fR] [\fB\-J\fR|\fB\-\-json\fR] [\fB\-h\fR|\fB\-\-help\fR] .SH DESCRIPTION Run every configured rule, including kinds that execute a configured command .SH OPTIONS .TP +\fB\-\-rule\fR +Run only the declared rule with this id +.TP \fB\-J\fR, \fB\-\-json\fR Emit byte\-stable JSON instead of pointer lines .TP