From 2bdb2243401cc7fcd1a4846eac3b7d2d8d54c2d9 Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Wed, 9 Sep 2026 19:00:12 +0000 Subject: [PATCH 01/29] feat(policy)!: retire six shell gates onto the engine and give `exec` a lock MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six `mise-tasks/*` programs and their bats suites retire onto policy modules: `module-map`, `no-doctests`, `awk-regex`, `pipefail-grep`, `license-table` and `report-only`. Each lands as a `policy/*.rego` predicate with load-time cases, a `[[rule]]` row, an integration tier carrying the ledger arms, and its `hk.pkl` step removed. `batten exec` gains `--lock`/`--lock-path`/`--lock-attempts`/`--lock-label`, the engine-side successor to `with-lock.sh`. The acquire loop lives in `task.rs` as `singleton_acquire_at`, with the module's one declared pause: it separates the two sightings a reclaim requires and is bounded by an attempt count, not a clock. Two prose defects that named no recoverable route are fixed together, with a mechanism asserting the two texts agree: `land`'s conflict message now lists every conflicting path, says the replay is STATELESS, and names `batten land replay --resolve ` and the `=` spelling a repeat conflict needs; `rebase-not-hand-stepped`'s reason names the same route and drops `--continue`/`--abort`/`--skip`, which a stateless replay can never reach. CLOUD-1586, CLOUD-1670. Also removes `land::absorbed`, `failed_runs` and `rerun_failed`, which no caller reached, and adds the `workflow-shell-not-growing` ratchet over `.github/workflows/**`. CLOUD-1716 CLOUD-1710 `target-prune`'s stem basis moves 244 -> 256 in the same change, because the eight tiers here are what took the live count past it. Neither floor moves: the prescribed post-prune `du` is disqualified by the escalation the 2026-09-08 entry records, and scaling is what both entries above it name as staleness wearing a newer number. Cold's basis moves with warm's, per 2026-09-06. BREAKING CHANGE: `land::absorbed`, `land::failed_runs` and `land::rerun_failed` are removed. No caller in this workspace reached them and no lifecycle task resolves to them; the landing loop's rerun path is `land replay`. Admits: 07e7f238f4d102e7ff55f781f1d66f850a62e7b8878b3f4e18e8048c3cfd7c71 Admits-rule: protected-mutation Admits-verdict: path write refused Admits-subject: batten.toml Admits-anchor: call:21329c9e342fd45af239f7d57d154c381afa55ec Admits-epoch: 11a9bb0870af0e7a5989a8292370ad69c6b93165e2394883e072393985647b86 Admits-author: alec@wenzowski.com Admits-prev: 9cada305dbd8059b734b7f75bfa13c91a7d3b3cd930ed0cef339cfb340befbab Admits-answer-lost: The retirement cannot land at all. A `policy/*.rego` module is inert until a `[[rule]]` row registers it, so without this write the six ported predicates are dead files: the shell programs stay, `bash-surface-not-growing` never falls, and the campaign's census cannot move. The alternative is leaving 39 programs in a corpus the repository has already decided to retire. Admits-answer-precondition: No `batten` verb authors a `[[rule]]` row. `batten config` exposes only `show`, `epoch`, `deprecations` and `lint`, every one a read, and `init` refuses to overwrite an existing config. CLOUD-843's retirement shape REQUIRES a rule row per retired program — `shell-retirement` refuses a deletion whose predicate has no policy surface — so writing batten.toml directly is the only route left. The write is prose plus one declarative row per port, and a reviewer sees it in the diff it lands in, beside the module and the tier it registers. Admits-answer-rejected-route: `config read first` is the route this class prefers and it cannot reach: every `batten config` subcommand is a read, and none authors or edits a `[[rule]]`, `[[pattern]]` or `[[verdict]]` row. `patch run first` (`git restore`) is the undo route for an unwanted write, not a way to author config — restoring batten.toml would delete the rule rows the ported modules need, which is the change itself rather than a safer spelling of it. Admits: a59bcc6146897ead2888b84396abb3cae8d2c59248ad2f7e1944545f170efb33 Admits-rule: protected-mutation Admits-verdict: path write refused Admits-subject: batten.toml Admits-anchor: call:61452b1361ff922e32ac86d7bd5a91fae8552b81 Admits-epoch: 81313eeeeed3fa0312c7853e84dee02aa01986c49524c1bfd0c5b46c2a526337 Admits-author: alec@wenzowski.com Admits-prev: 0fc78b2703aa31ae06ac5f6ddadaeb9a10c56c27146a81b48496951b6f4584d9 Admits-answer-lost: `target-prune` refuses every lap, so `verify` cannot run and nothing on this branch can land. The basis says 244 stems where the tree has 256: the warm floor is budgeting for a tree twelve stems smaller than the one being built, which the block above the table names as the failure that arrives as a rustc IO error mid-build rather than as a disk fault. Not moving it means either a stale floor that under-budgets or a gate switched off. Admits-answer-precondition: No `batten` verb writes `[prune.warm]`, `[prune.warm.basis]` or `[prune.cold.basis]`. `batten target prune` READS the floor and its basis to decide; `batten config` exposes only `show`, `epoch`, `deprecations` and `lint`, every one a read. The gate's own remedy — "Re-measure the floor and move `count` and `measured` together" — is an instruction to edit this file, so writing batten.toml directly is the only route the remedy leaves. The write is three numeric fields and a date, visible in the diff it lands in beside the eight tier files that moved the stem count. Admits-answer-rejected-route: `config read first` is this class's preferred route and cannot reach: every `batten config` subcommand is a read and none writes a `[prune.*]` field. `patch run first` (`git restore`) is the undo route for an unwanted write, not a way to author a re-measured floor — restoring batten.toml reinstates the 244-stem basis, which is the staleness itself rather than a safer spelling of the fix. --- batten.toml | 262 +++++++++++++++++- bench/suites/RESULTS.md | 196 +++++++------ completions/batten.bash | 18 +- completions/batten.fish | 4 + completions/batten.zsh | 4 + contracts/hk.json | 250 ++++++----------- crates/batten/src/cli.rs | 73 +++-- crates/batten/src/exec.rs | 158 ++++++++++- crates/batten/src/land.rs | 130 --------- crates/batten/src/lib.rs | 96 ++++++- crates/batten/src/surface.rs | 92 ++++++ crates/batten/src/task.rs | 171 +++++++++--- crates/batten/tests/it/awk_regex.rs | 166 +++++++++++ crates/batten/tests/it/exec_lock.rs | 222 +++++++++++++++ crates/batten/tests/it/land.rs | 69 +++++ crates/batten/tests/it/license_table.rs | 157 +++++++++++ crates/batten/tests/it/main.rs | 8 + crates/batten/tests/it/module_map.rs | 207 ++++++++++++++ crates/batten/tests/it/no_doctests.rs | 166 +++++++++++ crates/batten/tests/it/pipefail_grep.rs | 181 ++++++++++++ crates/batten/tests/it/report_only.rs | 199 +++++++++++++ .../it__snapshots__golden_json_schema.snap | 32 +++ .../batten/tests/it/workflow_shell_census.rs | 222 +++++++++++++++ hk.pkl | 85 +----- man/batten-exec.1 | 14 +- mise-tasks/awk-regex-check.sh | 62 ----- mise-tasks/license-table-check.sh | 85 ------ mise-tasks/module-map-check.sh | 53 ---- mise-tasks/no-doctests.sh | 100 ------- mise-tasks/pipefail-grep-check.sh | 92 ------ mise-tasks/report-only-check.sh | 98 ------- mise.toml | 2 +- policy/awk-regex.rego | 156 +++++++++++ policy/license-table.rego | 141 ++++++++++ policy/module-map.rego | 159 +++++++++++ policy/no-doctests.rego | 169 +++++++++++ policy/pipefail-grep.rego | 171 ++++++++++++ policy/report-only.rego | 163 +++++++++++ tests/awk-regex-check.bats | 102 ------- tests/license-table-check.bats | 104 ------- tests/module-map-check.bats | 89 ------ tests/no-doctests.bats | 132 --------- tests/pipefail-grep-check.bats | 142 ---------- tests/report-only-check.bats | 102 ------- 44 files changed, 3583 insertions(+), 1721 deletions(-) create mode 100644 crates/batten/tests/it/awk_regex.rs create mode 100644 crates/batten/tests/it/exec_lock.rs create mode 100644 crates/batten/tests/it/license_table.rs create mode 100644 crates/batten/tests/it/module_map.rs create mode 100644 crates/batten/tests/it/no_doctests.rs create mode 100644 crates/batten/tests/it/pipefail_grep.rs create mode 100644 crates/batten/tests/it/report_only.rs create mode 100644 crates/batten/tests/it/workflow_shell_census.rs delete mode 100755 mise-tasks/awk-regex-check.sh delete mode 100755 mise-tasks/license-table-check.sh delete mode 100755 mise-tasks/module-map-check.sh delete mode 100755 mise-tasks/no-doctests.sh delete mode 100755 mise-tasks/pipefail-grep-check.sh delete mode 100755 mise-tasks/report-only-check.sh create mode 100644 policy/awk-regex.rego create mode 100644 policy/license-table.rego create mode 100644 policy/module-map.rego create mode 100644 policy/no-doctests.rego create mode 100644 policy/pipefail-grep.rego create mode 100644 policy/report-only.rego delete mode 100644 tests/awk-regex-check.bats delete mode 100644 tests/license-table-check.bats delete mode 100644 tests/module-map-check.bats delete mode 100644 tests/no-doctests.bats delete mode 100644 tests/pipefail-grep-check.bats delete mode 100644 tests/report-only-check.bats diff --git a/batten.toml b/batten.toml index 504c8e5a4..dc36ebea7 100644 --- a/batten.toml +++ b/batten.toml @@ -811,13 +811,20 @@ reason = """ Rebasing onto `main` by hand is a lap of the loop `mise run land` drives, and \ taking it off the task is the race the contract refuses rather than a repair. \ Background `mise run land`; it fetches, rebases, verifies, pushes and waits, \ -and stops only for a rebase conflict, a failed `verify`, or red CI. TWO \ -SPELLINGS ARE LEFT ALONE, and neither is a lap: the conflict exits \ -(`--continue`, `--abort`, `--skip`) name no target at all, and \ -`git rebase --onto ` is a range move this loop \ -cannot perform. A CONFLICT is the one step that is yours, and this row denies \ -rather than warns because a warn row on this surface reaches nobody — so the \ -conflict stop is a limit this row names rather than one it can soften.""" +and stops only for a rebase conflict, a failed `verify`, or red CI. A CONFLICT \ +is the one step that is yours, and the route is \ +`batten land replay --resolve `: merge the path in the worktree and \ +name it, or `--resolve =` for a path that conflicts at more than \ +one commit, one entry per conflict in order. THE REPLAY IS STATELESS, so there \ +is no rebase in progress and `--continue`, `--abort` and `--skip` cannot apply \ +to it — this row used to name those three as the way out, which is a remedy \ +that does not exist (CLOUD-1050's class, measured when a session read it, \ +concluded the loop was broken, and reached for a cherry-pick that would have \ +completed the replay while writing no lap record). \ +`git rebase --onto ` stays open and is not a lap: \ +it is a range move this loop cannot perform, which is what `unless_contains` \ +admits. This row denies rather than warns because a warn row on this surface \ +reaches nobody.""" # The toolchain pin, given a mechanism (CLOUD-271). AGENTS.md has said "never a # bare `cargo`" since long before anything enforced it, which non-negotiable @@ -1656,6 +1663,37 @@ regex = '(^|/)tasks/[A-Za-z0-9._-]+\.output$' id = "egress-resolver-host" regex = 'api\.github\.com' +# CLOUD-843's port of `pipefail-grep-check`. Three static halves; the flag +# CLUSTER is read letter by letter in the module rather than enumerated here, +# because "a short-flag cluster containing q or l" is a decision and not a +# spelling — an enumeration is the thing that would rot. +[[pattern]] +id = "shell-enables-pipefail" +regex = '^[[:space:]]*set[[:space:]]+-[a-z]*o?[a-z]*[[:space:]]*.*pipefail' + +# A REAL pipe, never the second bar of `||`. The predecessor scanned +# `\|[[:space:]]*grep`, which matched `a || grep -q ... <<<"$x"` — the very +# remedy the gate recommends — and reported it as the defect. Measured on +# `ready-lint.sh` (CLOUD-852). +[[pattern]] +id = "pipe-into-grep" +regex = '(^|[^|])\|[[:space:]]*grep([[:space:]]|$)' + +# CLOUD-843's port of `awk-regex-check`. The NAME is a capture rather than the +# whole match: the module compares full identifiers so that a name which merely +# prefixes another is never confused for it, which the shell approximated with a +# trailing character class. +[[pattern]] +id = "awk-v-assignment" +regex = '-v[[:space:]]*([A-Za-z_][A-Za-z0-9_]*)=' + +# The leading identifier of a fragment, used to read what follows `~` or a comma +# inside `match(...)`. A whole-token comparison is what makes the prefix case +# decidable without a per-name regex, which a `[[pattern]]` row cannot express. +[[pattern]] +id = "leading-identifier" +regex = '^[A-Za-z_][A-Za-z0-9_]*' + [[pattern]] id = "release-tag" regex = '^v[0-9]+\.[0-9]+\.[0-9]+$' @@ -4999,9 +5037,16 @@ no_fix_reason = "migrate the predicate onto a rule kind, or declare `# stays-bas # TWO SURFACES ARE DELIBERATELY EXEMPT, and the reasons differ, so a later reader # finds a decision rather than a gap: # -# .github/workflows/** bash is allowed. A workflow step is the one place a -# shell line is the native spelling, and there is no -# engine surface it could migrate onto. +# .github/workflows/** bash is allowed, and since CLOUD-1709 it is COUNTED. +# A workflow step is the one place a shell line is the +# native spelling, and there is no engine surface it +# could migrate onto — so it is exempt from THIS row, +# which globs `mise-tasks/**`. It is not exempt from +# measurement: `workflow-shell-not-growing` and its +# `-bare` sibling ratchet the step count. Allowed and +# uncounted are different claims, and reading the first +# as the second is what 497 duplicated lines were +# written under. # install.sh it cannot be a policy module by construction: it is how # the binary reaches a host that does not have it yet, so # it runs before the engine exists. @@ -5116,6 +5161,91 @@ severity = "deny" scope = "tree" no_fix_reason = "migrate the predicate onto a rule kind, or waive the increase deliberately with a reason and an expiry; which of the two is the whole question" +# CLOUD-1709. THE THIRD SURFACE, AND THE LARGEST. `bash-surface-not-growing` +# globs `mise-tasks/**`; CLOUD-1137 closed the `mise.toml` and `.claude/**` holes +# above. `.github/workflows/**` was left, and it is bigger than either: 28 files +# carrying 135 shell steps, on a surface nothing counted. +# +# THE EXEMPTION ABOVE IS ABOUT MIGRATION, NOT ABOUT COUNTING, and these rows are +# the correction of that inference rather than of the exemption itself. Workflow +# bash stays allowed — a step is the native spelling and there is no engine +# surface it could move onto. What does not follow is that it need not be +# MEASURED, and 41% byte-identical duplication is what that inference bought. +# +# WHAT IS COUNTED IS THE STEP, NOT THE LINE, and the row body's 1,215-line figure +# is deliberately NOT the predicate. A ratchet `pattern` is a literal substring +# counted with `str::matches` (CLOUD-1058) — it cannot see where a YAML block +# ends, so "lines inside a `run:` block" is not expressible here. The step is, +# and it is the right unit anyway: `#MISE description=` counts programs for the +# same reason one line up. +# +# TWO LITERALS FOR ONE CONCEPT, exactly as the `mise.toml` pair above, and for +# the same CLOUD-1058 reason. Measured over the 28 workflows, `run:` appears at +# precisely two indentations and no others: `\n run:` (the key under a +# `- name:` step) 111 times, and `\n - run:` (the bare step) 24 times. +# Both are newline-anchored, which is load-bearing: the unanchored literal also +# matches `workflow_run:` and `check_run:`, 15 of them today, so it would count +# trigger declarations as shell. +# +# THE BLIND SPOTS, STATED RATHER THAN DISCOVERED LATER (DoR §2). A `run:` at any +# third indentation is not counted — none exists today, and a nested one inside a +# composite action would need its own row. `.github/actions/**` is not globbed at +# all, because no composite action exists yet; the follow-up that deduplicates +# the 497 lines into one will have to add it, and that is the point at which this +# row would otherwise report a fall it did not earn. +# +# THE FIRING RATE, replayed before the severity was chosen (DoR §7). Over all +# 2,574 commits on `origin/main`, 181 of which touch a workflow: the 8-space row +# fired 59 times, the dash row 21. As a share of all commits that is 2.3% and +# 0.8%, against the `mise.toml` pair's 1.6% and 0.07%. +# +# AND THE FALSE POSITIVES ARE REAL HERE, which the `mise.toml` rows did not have +# to answer for and which decides the `admits_with` column below. At least five +# firings are THIS CAMPAIGN SUCCEEDING: retiring a shell program onto a verb +# leaves a workflow step invoking it, so the step count rises while real bash +# falls. `4b8fc385f` (`retire land-lock-check.sh onto batten lease check`, +1), +# `8a90687a3` (`the perf budget is a rule, not a shell program`, +1) and +# `c5346097d` (`move the latency family into the perf namespace`, +1) on the +# 8-space row; `1e1d19874` (`retire ci-drift onto the verb it was already +# calling`, +1) and `c5346097d` again on the dash row. That is the exact class +# `batten.toml` rejected the wider `run = ` literal for one block up — *"a row +# that denies its own campaign's wins is worse than the hole it closes."* +# +# SO THE PERMIT IS CARRIED RATHER THAN THE SEVERITY LOWERED. `warn` would answer +# the false positives by making every firing advisory, including the 75 that are +# real net-new shell. `admits_with` answers only the firings an author is willing +# to sign for, and the cardinality objection that keeps it off both `mise.toml` +# rows does not apply: those glob exactly ONE file, so the first permit spent +# switches the row off forever, whereas this globs a DIRECTORY, which is the +# per-file semantics the column was designed for. A retirement that adds a step +# declares it in the workflow it added it to, and every other workflow stays +# ratcheted. +[[rule]] +id = "workflow-shell-not-growing" +kind = "ratchet" +glob = ".github/workflows/**" +pattern = "\n run:" +direction = "non_increasing" +base = "origin/main" +admits_with = "# workflow-shell:" +severity = "deny" +scope = "tree" +no_fix_reason = "move the step's body into a composite action or a batten verb, or declare `# workflow-shell: ` in the workflow and own the increase; which of the two is the whole question" + +# The other spelling of the same concept — see the block above for why it is a +# second row rather than a second pattern on the first. +[[rule]] +id = "workflow-shell-not-growing-bare" +kind = "ratchet" +glob = ".github/workflows/**" +pattern = "\n - run:" +direction = "non_increasing" +base = "origin/main" +admits_with = "# workflow-shell:" +severity = "deny" +scope = "tree" +no_fix_reason = "move the step's body into a composite action or a batten verb, or declare `# workflow-shell: ` in the workflow and own the increase; which of the two is the whole question" + # The `.claude/**` half. `#!` rather than a shebang's text, because the programs # there do not share a spelling — `#!/usr/bin/env bash` twice today, and the file # this row's baseline lost spelled it `#!/bin/sh` — so a text literal would count @@ -6813,6 +6943,89 @@ documents = ["bench/agentic/trials.toml", "bench/agentic/method.toml"] module = "policy/agentic-experiment-record.rego" severity = "deny" +# CLOUD-194, ported from `mise-tasks/module-map-check.sh` under CLOUD-843. The +# module's own header carries the predicate and why a missing map is reported +# once; it is not restated here. +# +# `line_sources` NAMES ONE FILE, and `tracked` is not declared at all because it +# is not a per-path fact — the module asks which paths exist, which the tree +# surface answers for the whole tree. Declaring the crate sources as +# `line_sources` would read every module's TEXT to learn only its NAME, which is +# the payload-for-a-pointer trade non-negotiable rule 4 refuses. +# CLOUD-813, ported from `mise-tasks/no-doctests.sh` under CLOUD-843. The +# module's own header carries the predicate, the parity rule and why `no_run` +# counts as non-running; it is not restated here. +# +# `line_sources` RATHER THAN `sources`: the predicate is over the TEXT of a doc +# comment, and no `Format` owns `.rs` — a `documents` reading would resolve to +# nothing and the rule would load clean over a workspace it never scanned. +# Ported from `mise-tasks/awk-regex-check.sh` under CLOUD-843. The module's own +# header carries the predicate and why identifiers are compared whole. +# +# The glob is the shell corpus plus the two root programs and the task manifest — +# the same set the predecessor scanned. It SHRINKS as CLOUD-843 retires programs, +# which is the campaign working: what is left at the end is `install.sh`, +# `mise.toml` and the five programs that stay bash. +# Ported from `mise-tasks/pipefail-grep-check.sh` under CLOUD-843. The module's +# own header carries the race, the two instances that landed before the class was +# named, and why the flag cluster is read letter by letter. +# Ported from `mise-tasks/license-table-check.sh` under CLOUD-843. The module's +# own header carries the predicate and why zero rows is a failure. +# CLOUD-582, ported from `mise-tasks/report-only-check.sh` under CLOUD-843. The +# module's own header carries the predicate, the task list and why only the +# `pull_request` trigger is judged. +# +# `sources` RATHER THAN `line_sources`: the predicate is over PARSED structure — +# `verify`'s `depends` list, a workflow's trigger map — which is what removes the +# span-carving and the word-boundary the shell needed. +[[rule]] +id = "report-only" +kind = "policy" +scope = "tree" +sources = ["mise.toml", ".github/workflows/*.yml", ".github/workflows/*.yaml"] +module = "policy/report-only.rego" +severity = "deny" + +[[rule]] +id = "license-table" +kind = "policy" +scope = "tree" +line_sources = ["CONTRIBUTING.md"] +module = "policy/license-table.rego" +severity = "deny" + +[[rule]] +id = "pipefail-grep" +kind = "policy" +scope = "tree" +line_sources = ["mise-tasks/**", "*.sh"] +module = "policy/pipefail-grep.rego" +severity = "deny" + +[[rule]] +id = "awk-regex" +kind = "policy" +scope = "tree" +line_sources = ["mise-tasks/**", "*.sh", "mise.toml"] +module = "policy/awk-regex.rego" +severity = "deny" + +[[rule]] +id = "no-doctests" +kind = "policy" +scope = "tree" +line_sources = ["crates/**/*.rs"] +module = "policy/no-doctests.rego" +severity = "deny" + +[[rule]] +id = "module-map" +kind = "policy" +scope = "tree" +line_sources = [".serena/memories/core.md"] +module = "policy/module-map.rego" +severity = "deny" + [[rule]] id = "release-tag-shape" kind = "policy" @@ -8373,14 +8586,39 @@ measured = "2026-09-07" # COLD'S BASIS MOVES WITH WARM'S, for the 2026-09-06 entry's measured reason: # refreshing one and not the other made the very next lap refuse on the other arm # with warm never breached. +# THE 2026-09-09 RETIREMENT MOVE, 244 -> 256, BOTH BASES, AND NEITHER FLOOR MOVES. +# +# WHAT MOVED THE COUNT is CLOUD-843's own campaign, which is what the block at the +# head of this table predicted the count would do: six shell gates retired onto +# policy modules, and each owes a `crates/batten/tests/it/*.rs` tier. Eight tiers +# landed in one commit — `exec_lock`, `module_map`, `no_doctests`, `awk_regex`, +# `pipefail_grep`, `license_table`, `report_only` and `workflow_shell_census` — +# and `target-prune` refused at `declared 244, live 256, tolerance 10`, the +# staleness arm rather than the disk: 15111 MB were free against a floor of 11140. +# +# THE FLOORS DO NOT MOVE, on the two entries above's stated grounds rather than a +# fresh judgement. The prescribed method is `du -sm target` immediately after a +# successful prune, and this prune ESCALATED — the same disqualification the +# 2026-09-08 entry records, so the reading is of a post-escalation tree and not +# the retained state 11140 was measured against. Scaling 11140 by 256/244 is what +# BOTH entries above name as the same staleness wearing a newer number, so it is +# not taken either. The independent measurement stays OWED to CLOUD-1158. +# +# THE COUNT WILL KEEP MOVING FOR THE REST OF THIS CAMPAIGN, one tier per retired +# gate against 39 in this branch's brief, so a `tolerance` of 10 will be reached +# again within it. That is the gate working — it is a trend counter — and the +# entry that finally moves a floor should be the one that can take the ordinary +# post-prune `du`, not the one that happens to trip the tolerance. +# +# COLD'S BASIS MOVES WITH WARM'S, for the 2026-09-06 entry's measured reason. [prune.warm.basis] glob = "crates/batten/tests/**/*.rs" -count = 244 +count = 256 tolerance = 10 [prune.cold.basis] glob = "crates/batten/tests/**/*.rs" -count = 244 +count = 256 tolerance = 10 # THE REGROWABLE ROOTS THE ESCALATION MAY DROP (CLOUD-1157), in the order it drops diff --git a/bench/suites/RESULTS.md b/bench/suites/RESULTS.md index fb113fb66..9009faf08 100644 --- a/bench/suites/RESULTS.md +++ b/bench/suites/RESULTS.md @@ -6,107 +6,101 @@ runner measured it; the suite runs `--no-parallelize-within-files`, so a file's number is its own serial cost and is what an author adding a case to it pays. -- suites: 99 -- serial total: 158.9s +- suites: 94 +- serial total: 353.7s | seconds | share | suite | | ---: | ---: | --- | -| 17.9 | 11.3% | `tests/graph-check.bats` | -| 10.2 | 6.4% | `tests/board-diff-overlap.bats` | -| 7.3 | 4.6% | `tests/target-race.bats` | -| 7.1 | 4.5% | `tests/ready-lint.bats` | -| 6.5 | 4.1% | `tests/token-bench.bats` | -| 6.1 | 3.8% | `tests/released.bats` | -| 5.3 | 3.3% | `tests/board-sweep.bats` | -| 4.9 | 3.1% | `tests/release-tracking-check.bats` | -| 4.8 | 3.0% | `tests/release-assets-check.bats` | -| 4.1 | 2.6% | `tests/sbom.bats` | -| 4.0 | 2.5% | `tests/in-progress-drain.bats` | -| 3.6 | 2.3% | `tests/step-receipt.bats` | -| 3.5 | 2.2% | `tests/mcp-allow-check.bats` | -| 3.4 | 2.1% | `tests/doctor-race.bats` | -| 3.3 | 2.1% | `tests/ready-cites-check.bats` | -| 3.3 | 2.0% | `tests/land-divergence.bats` | -| 3.2 | 2.0% | `tests/hk-selection.bats` | -| 3.0 | 1.9% | `tests/ntia-check.bats` | -| 2.9 | 1.8% | `tests/target-ensure.bats` | -| 2.8 | 1.8% | `tests/with-lock.bats` | -| 2.2 | 1.4% | `tests/landed-check.bats` | -| 2.1 | 1.3% | `tests/install.bats` | -| 1.8 | 1.1% | `tests/closing-key-check.bats` | -| 1.8 | 1.1% | `tests/reclaim-census.bats` | -| 1.7 | 1.0% | `tests/finding-sink-check.bats` | -| 1.6 | 1.0% | `tests/suite-select.bats` | -| 1.5 | 0.9% | `tests/spec-ref-check.bats` | -| 1.4 | 0.9% | `tests/signing-posture.bats` | -| 1.3 | 0.8% | `tests/claimed-keys.bats` | -| 1.2 | 0.8% | `tests/tree-clean.bats` | -| 1.2 | 0.8% | `tests/ci-slow-needed.bats` | -| 1.2 | 0.7% | `tests/ready-lint-deferral.bats` | -| 1.1 | 0.7% | `tests/ci-tools-check.bats` | -| 1.0 | 0.7% | `tests/verify.bats` | -| 1.0 | 0.6% | `tests/perf-record.bats` | -| 1.0 | 0.6% | `tests/install-check.bats` | -| 1.0 | 0.6% | `tests/linear-check.bats` | -| 1.0 | 0.6% | `tests/lint-rego.bats` | -| 0.9 | 0.6% | `tests/land-divergence-assert.bats` | -| 0.9 | 0.6% | `tests/nonverdict-scan.bats` | -| 0.9 | 0.6% | `tests/deferral-check.bats` | -| 0.9 | 0.6% | `tests/spawn-census.bats` | -| 0.9 | 0.5% | `tests/done-check.bats` | -| 0.8 | 0.5% | `tests/module-map-check.bats` | -| 0.8 | 0.5% | `tests/release-backfill.bats` | -| 0.8 | 0.5% | `tests/lint-deno.bats` | -| 0.7 | 0.4% | `tests/render-cli.bats` | -| 0.7 | 0.4% | `tests/pr-unsubscribed.bats` | -| 0.7 | 0.4% | `tests/awk-regex-check.bats` | -| 0.7 | 0.4% | `tests/doctor.bats` | -| 0.6 | 0.4% | `tests/commit-attribution.bats` | -| 0.6 | 0.4% | `tests/done-pr-check.bats` | -| 0.6 | 0.4% | `tests/attestation-check.bats` | -| 0.6 | 0.4% | `tests/merged-pr-keys.bats` | -| 0.6 | 0.4% | `tests/timeout-drift.bats` | -| 0.6 | 0.4% | `tests/evaluator-closure-check.bats` | -| 0.6 | 0.4% | `tests/sbom-binary.bats` | -| 0.6 | 0.4% | `tests/mcp-timeout-budget.bats` | -| 0.6 | 0.4% | `tests/mcp-attach-check.bats` | -| 0.5 | 0.3% | `tests/duplicate-close-check.bats` | -| 0.5 | 0.3% | `tests/suite-bench-check.bats` | -| 0.5 | 0.3% | `tests/macos-link-check.bats` | -| 0.5 | 0.3% | `tests/stop-posture-check.bats` | -| 0.4 | 0.3% | `tests/checksums.bats` | -| 0.4 | 0.3% | `tests/publish-credential-check.bats` | -| 0.4 | 0.3% | `tests/msrv-pin-agreement.bats` | -| 0.4 | 0.2% | `tests/pipefail-grep-check.bats` | -| 0.4 | 0.2% | `tests/digest-major-agreement.bats` | -| 0.4 | 0.2% | `tests/connector-allow-guard.bats` | -| 0.4 | 0.2% | `tests/hook-pin-check.bats` | -| 0.4 | 0.2% | `tests/commit-convention.bats` | -| 0.3 | 0.2% | `tests/sonar-gate.bats` | -| 0.3 | 0.2% | `tests/no-doctests.bats` | -| 0.3 | 0.2% | `tests/report-only-check.bats` | -| 0.3 | 0.2% | `tests/branch-age-check.bats` | -| 0.3 | 0.2% | `tests/license-table-check.bats` | -| 0.3 | 0.2% | `tests/board-payloads.bats` | -| 0.3 | 0.2% | `tests/nonverdict-assert.bats` | -| 0.3 | 0.2% | `tests/timeout-check.bats` | -| 0.3 | 0.2% | `tests/release-due.bats` | -| 0.3 | 0.2% | `tests/transcript-corpus-check.bats` | -| 0.3 | 0.2% | `tests/serena-mcp.bats` | -| 0.2 | 0.2% | `tests/connector-allow-resolve.bats` | -| 0.2 | 0.2% | `tests/batten-glob-check.bats` | -| 0.2 | 0.2% | `tests/cap-drift.bats` | -| 0.2 | 0.1% | `tests/mise-action-floor.bats` | -| 0.2 | 0.1% | `tests/coderabbit-config-check.bats` | -| 0.2 | 0.1% | `tests/rust-paths-check.bats` | -| 0.2 | 0.1% | `tests/container-preflight.bats` | -| 0.2 | 0.1% | `tests/git-hook.bats` | -| 0.1 | 0.1% | `tests/token-bench-check.bats` | -| 0.1 | 0.1% | `tests/remedy-payload-source.bats` | -| 0.1 | 0.1% | `tests/task-fail-closed.bats` | -| 0.1 | 0.1% | `tests/dist.bats` | -| 0.1 | 0.1% | `tests/egress-check.bats` | -| 0.1 | 0.1% | `tests/evaluator-io-check.bats` | -| 0.0 | 0.0% | `tests/darwin-link.bats` | -| 0.0 | 0.0% | `tests/cross-check.bats` | -| 0.0 | 0.0% | `tests/zizmor-split.bats` | +| 42.6 | 12.0% | `tests/graph-check.bats` | +| 25.3 | 7.2% | `tests/board-diff-overlap.bats` | +| 17.7 | 5.0% | `tests/ready-lint.bats` | +| 16.1 | 4.6% | `tests/released.bats` | +| 15.6 | 4.4% | `tests/board-sweep.bats` | +| 12.2 | 3.5% | `tests/release-tracking-check.bats` | +| 11.8 | 3.3% | `tests/token-bench.bats` | +| 11.2 | 3.2% | `tests/release-assets-check.bats` | +| 10.4 | 2.9% | `tests/in-progress-drain.bats` | +| 10.0 | 2.8% | `tests/sbom.bats` | +| 9.8 | 2.8% | `tests/target-race.bats` | +| 8.8 | 2.5% | `tests/land-divergence.bats` | +| 7.9 | 2.2% | `tests/mcp-allow-check.bats` | +| 7.3 | 2.1% | `tests/step-receipt.bats` | +| 7.0 | 2.0% | `tests/ntia-check.bats` | +| 6.2 | 1.8% | `tests/ready-cites-check.bats` | +| 5.7 | 1.6% | `tests/hk-selection.bats` | +| 5.3 | 1.5% | `tests/closing-key-check.bats` | +| 5.2 | 1.5% | `tests/landed-check.bats` | +| 4.6 | 1.3% | `tests/finding-sink-check.bats` | +| 4.2 | 1.2% | `tests/install.bats` | +| 4.2 | 1.2% | `tests/doctor-race.bats` | +| 3.9 | 1.1% | `tests/claimed-keys.bats` | +| 3.8 | 1.1% | `tests/ci-tools-check.bats` | +| 3.7 | 1.0% | `tests/with-lock.bats` | +| 3.4 | 1.0% | `tests/target-ensure.bats` | +| 3.4 | 0.9% | `tests/signing-posture.bats` | +| 3.0 | 0.8% | `tests/suite-select.bats` | +| 2.9 | 0.8% | `tests/ready-lint-deferral.bats` | +| 2.8 | 0.8% | `tests/install-check.bats` | +| 2.7 | 0.8% | `tests/spec-ref-check.bats` | +| 2.7 | 0.8% | `tests/ci-slow-needed.bats` | +| 2.6 | 0.7% | `tests/lint-rego.bats` | +| 2.5 | 0.7% | `tests/done-check.bats` | +| 2.4 | 0.7% | `tests/deferral-check.bats` | +| 2.3 | 0.7% | `tests/land-divergence-assert.bats` | +| 2.3 | 0.6% | `tests/reclaim-census.bats` | +| 2.2 | 0.6% | `tests/nonverdict-scan.bats` | +| 2.1 | 0.6% | `tests/verify.bats` | +| 2.1 | 0.6% | `tests/spawn-census.bats` | +| 2.1 | 0.6% | `tests/tree-clean.bats` | +| 2.0 | 0.6% | `tests/perf-record.bats` | +| 2.0 | 0.6% | `tests/linear-check.bats` | +| 1.8 | 0.5% | `tests/lint-deno.bats` | +| 1.8 | 0.5% | `tests/commit-attribution.bats` | +| 1.8 | 0.5% | `tests/sbom-binary.bats` | +| 1.7 | 0.5% | `tests/release-backfill.bats` | +| 1.6 | 0.5% | `tests/attestation-check.bats` | +| 1.6 | 0.5% | `tests/checksums.bats` | +| 1.6 | 0.5% | `tests/pr-unsubscribed.bats` | +| 1.6 | 0.5% | `tests/render-cli.bats` | +| 1.6 | 0.5% | `tests/mcp-attach-check.bats` | +| 1.5 | 0.4% | `tests/merged-pr-keys.bats` | +| 1.5 | 0.4% | `tests/evaluator-closure-check.bats` | +| 1.5 | 0.4% | `tests/mcp-timeout-budget.bats` | +| 1.4 | 0.4% | `tests/doctor.bats` | +| 1.4 | 0.4% | `tests/timeout-drift.bats` | +| 1.3 | 0.4% | `tests/macos-link-check.bats` | +| 1.2 | 0.4% | `tests/done-pr-check.bats` | +| 1.2 | 0.3% | `tests/msrv-pin-agreement.bats` | +| 1.2 | 0.3% | `tests/suite-bench-check.bats` | +| 1.1 | 0.3% | `tests/stop-posture-check.bats` | +| 1.1 | 0.3% | `tests/duplicate-close-check.bats` | +| 1.1 | 0.3% | `tests/publish-credential-check.bats` | +| 1.0 | 0.3% | `tests/hook-pin-check.bats` | +| 0.9 | 0.3% | `tests/branch-age-check.bats` | +| 0.9 | 0.2% | `tests/board-payloads.bats` | +| 0.8 | 0.2% | `tests/sonar-gate.bats` | +| 0.8 | 0.2% | `tests/serena-mcp.bats` | +| 0.8 | 0.2% | `tests/digest-major-agreement.bats` | +| 0.8 | 0.2% | `tests/nonverdict-assert.bats` | +| 0.7 | 0.2% | `tests/connector-allow-guard.bats` | +| 0.7 | 0.2% | `tests/transcript-corpus-check.bats` | +| 0.7 | 0.2% | `tests/commit-convention.bats` | +| 0.7 | 0.2% | `tests/cap-drift.bats` | +| 0.7 | 0.2% | `tests/release-due.bats` | +| 0.6 | 0.2% | `tests/batten-glob-check.bats` | +| 0.6 | 0.2% | `tests/connector-allow-resolve.bats` | +| 0.5 | 0.2% | `tests/container-preflight.bats` | +| 0.5 | 0.2% | `tests/coderabbit-config-check.bats` | +| 0.5 | 0.1% | `tests/evaluator-io-check.bats` | +| 0.5 | 0.1% | `tests/timeout-check.bats` | +| 0.5 | 0.1% | `tests/rust-paths-check.bats` | +| 0.4 | 0.1% | `tests/git-hook.bats` | +| 0.4 | 0.1% | `tests/mise-action-floor.bats` | +| 0.4 | 0.1% | `tests/remedy-payload-source.bats` | +| 0.3 | 0.1% | `tests/token-bench-check.bats` | +| 0.3 | 0.1% | `tests/dist.bats` | +| 0.3 | 0.1% | `tests/task-fail-closed.bats` | +| 0.2 | 0.1% | `tests/egress-check.bats` | +| 0.1 | 0.0% | `tests/darwin-link.bats` | +| 0.1 | 0.0% | `tests/cross-check.bats` | +| 0.1 | 0.0% | `tests/zizmor-split.bats` | diff --git a/completions/batten.bash b/completions/batten.bash index e28538149..2321a1417 100644 --- a/completions/batten.bash +++ b/completions/batten.bash @@ -3232,7 +3232,7 @@ _batten() { return 0 ;; batten__subcmd__exec) - opts="-q -v -y -h --capture-only --tee --jobs --continue-on-error --format --style --strictness --fail-on-warning --config-from --config-in --silent --quiet --verbose --debug --trace --log-level --no-color --no-input --yes --help" + opts="-q -v -y -h --capture-only --tee --jobs --continue-on-error --lock --lock-path --lock-attempts --lock-label --format --style --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 @@ -3242,6 +3242,22 @@ _batten() { COMPREPLY=($(compgen -f "${cur}")) return 0 ;; + --lock) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; + --lock-path) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; + --lock-attempts) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; + --lock-label) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; --format) COMPREPLY=($(compgen -W "human json jsonl" -- "${cur}")) return 0 diff --git a/completions/batten.fish b/completions/batten.fish index 122b06e7c..826ffbcee 100644 --- a/completions/batten.fish +++ b/completions/batten.fish @@ -138,6 +138,10 @@ complete -c batten -n "__fish_batten_using_subcommand enforce" -l no-input -d 'N complete -c batten -n "__fish_batten_using_subcommand enforce" -s y -l yes -d 'Confirm a destructive operation that would otherwise refuse' complete -c batten -n "__fish_batten_using_subcommand enforce" -s h -l help -d 'Print help (see more with \'--help\')' complete -c batten -n "__fish_batten_using_subcommand exec" -l jobs -d 'How many of a `:::` bundle\'s commands run at once' -r +complete -c batten -n "__fish_batten_using_subcommand exec" -l lock -d 'Hold this clone\'s named singleton lock for the child\'s lifetime' -r +complete -c batten -n "__fish_batten_using_subcommand exec" -l lock-path -d 'Hold the lock at this path, for a resource the clone does not own' -r +complete -c batten -n "__fish_batten_using_subcommand exec" -l lock-attempts -d 'How many times to ask for the lock before reporting it held' -r +complete -c batten -n "__fish_batten_using_subcommand exec" -l lock-label -d 'What the wait is for, named by the caller for the refusal line' -r complete -c batten -n "__fish_batten_using_subcommand exec" -l format -d 'How Batten\'s own record is encoded (hk\'s axis)' -r -f -a "human\t'Pointer lines, one per fact' json\t'One JSON document' jsonl\t'One JSON record per line'" diff --git a/completions/batten.zsh b/completions/batten.zsh index d4a9f9b04..880f27228 100644 --- a/completions/batten.zsh +++ b/completions/batten.zsh @@ -120,6 +120,10 @@ trace\:"Add everything"))' \ (exec) _arguments "${_arguments_options[@]}" : \ '--jobs=[How many of a \`\:\:\:\` bundle'\''s commands run at once]: :_default' \ +'--lock=[Hold this clone'\''s named singleton lock for the child'\''s lifetime]: :_default' \ +'--lock-path=[Hold the lock at this path, for a resource the clone does not own]: :_default' \ +'--lock-attempts=[How many times to ask for the lock before reporting it held]: :_default' \ +'--lock-label=[What the wait is for, named by the caller for the refusal line]: :_default' \ '--format=[How Batten'\''s own record is encoded (hk'\''s axis)]: :((human\:"Pointer lines, one per fact" json\:"One JSON document" jsonl\:"One JSON record per line"))' \ diff --git a/contracts/hk.json b/contracts/hk.json index eacf8e7c7..d41202aa5 100644 --- a/contracts/hk.json +++ b/contracts/hk.json @@ -20,14 +20,10 @@ "msrv-pin-agreement", "cap-drift", "digest-major-agreement", - "report-only-check", "no-docs-tree", - "no-doctests", - "license-table-check", "coderabbit-config-check", "hook-profile-check", "hk-contract-check", - "module-map-check", "hook-pin-check", "mutant-census", "rules-drift", @@ -42,8 +38,6 @@ "publish-credential-check", "release-tracking-check", "mcp-allow-check", - "awk-regex-check", - "pipefail-grep-check", "lock-complete", "timeout-check", "mise-action-floor", @@ -120,292 +114,256 @@ "orderIndex": 7, "parallelGroupId": "group_0" }, - { - "name": "report-only-check", - "status": "included", - "orderIndex": 8, - "parallelGroupId": "group_0" - }, { "name": "no-docs-tree", "status": "included", - "orderIndex": 9, - "parallelGroupId": "group_0" - }, - { - "name": "no-doctests", - "status": "included", - "orderIndex": 10, - "parallelGroupId": "group_0" - }, - { - "name": "license-table-check", - "status": "included", - "orderIndex": 11, + "orderIndex": 8, "parallelGroupId": "group_0" }, { "name": "coderabbit-config-check", "status": "included", - "orderIndex": 12, + "orderIndex": 9, "parallelGroupId": "group_0" }, { "name": "hook-profile-check", "status": "included", - "orderIndex": 13, + "orderIndex": 10, "parallelGroupId": "group_0" }, { "name": "hk-contract-check", "status": "included", - "orderIndex": 14, - "parallelGroupId": "group_0" - }, - { - "name": "module-map-check", - "status": "included", - "orderIndex": 15, + "orderIndex": 11, "parallelGroupId": "group_0" }, { "name": "hook-pin-check", "status": "included", - "orderIndex": 16, + "orderIndex": 12, "parallelGroupId": "group_0" }, { "name": "mutant-census", "status": "included", - "orderIndex": 17, + "orderIndex": 13, "parallelGroupId": "group_0" }, { "name": "rules-drift", "status": "included", - "orderIndex": 18, + "orderIndex": 14, "parallelGroupId": "group_0" }, { "name": "hooks-wiring-check", "status": "included", - "orderIndex": 19, + "orderIndex": 15, "parallelGroupId": "group_0" }, { "name": "reference-check", "status": "included", - "orderIndex": 20, + "orderIndex": 16, "parallelGroupId": "group_0" }, { "name": "token-bench-check", "status": "included", - "orderIndex": 21, + "orderIndex": 17, "parallelGroupId": "group_0" }, { "name": "config-lint", "status": "included", - "orderIndex": 22, + "orderIndex": 18, "parallelGroupId": "group_0" }, { "name": "renovate-config-validator", "status": "included", - "orderIndex": 23, + "orderIndex": 19, "parallelGroupId": "group_0" }, { "name": "schema-check", "status": "included", - "orderIndex": 24, + "orderIndex": 20, "parallelGroupId": "group_0" }, { "name": "sbom-check", "status": "included", - "orderIndex": 25, + "orderIndex": 21, "parallelGroupId": "group_0" }, { "name": "install-check", "status": "included", - "orderIndex": 26, + "orderIndex": 22, "parallelGroupId": "group_0" }, { "name": "publish-credential-check", "status": "included", - "orderIndex": 27, + "orderIndex": 23, "parallelGroupId": "group_0" }, { "name": "release-tracking-check", "status": "included", - "orderIndex": 28, + "orderIndex": 24, "parallelGroupId": "group_0" }, { "name": "mcp-allow-check", "status": "included", - "orderIndex": 29, - "parallelGroupId": "group_0" - }, - { - "name": "awk-regex-check", - "status": "included", - "orderIndex": 30, - "parallelGroupId": "group_0" - }, - { - "name": "pipefail-grep-check", - "status": "included", - "orderIndex": 31, + "orderIndex": 25, "parallelGroupId": "group_0" }, { "name": "lock-complete", "status": "included", - "orderIndex": 32, + "orderIndex": 26, "parallelGroupId": "group_0" }, { "name": "timeout-check", "status": "included", - "orderIndex": 33, + "orderIndex": 27, "parallelGroupId": "group_0" }, { "name": "mise-action-floor", "status": "included", - "orderIndex": 34, + "orderIndex": 28, "parallelGroupId": "group_0" }, { "name": "policy-budget", "status": "included", - "orderIndex": 35, + "orderIndex": 29, "parallelGroupId": "group_0" }, { "name": "batten-glob-check", "status": "included", - "orderIndex": 36, + "orderIndex": 30, "parallelGroupId": "group_0" }, { "name": "skill-check", "status": "included", - "orderIndex": 37, + "orderIndex": 31, "parallelGroupId": "group_0" }, { "name": "macos-link-check", "status": "included", - "orderIndex": 38, + "orderIndex": 32, "parallelGroupId": "group_0" }, { "name": "shfmt", "status": "included", - "orderIndex": 39, + "orderIndex": 33, "parallelGroupId": "group_0" }, { "name": "shellcheck", "status": "included", - "orderIndex": 40, + "orderIndex": 34, "parallelGroupId": "group_0" }, { "name": "test:bats", "status": "included", - "orderIndex": 41, + "orderIndex": 35, "parallelGroupId": "group_0" }, { "name": "taplo", "status": "included", - "orderIndex": 42, + "orderIndex": 36, "parallelGroupId": "group_0" }, { "name": "taplo-format", "status": "included", - "orderIndex": 43, + "orderIndex": 37, "parallelGroupId": "group_0" }, { "name": "pkl", "status": "included", - "orderIndex": 44, + "orderIndex": 38, "parallelGroupId": "group_0" }, { "name": "pkl-format", "status": "included", - "orderIndex": 45, + "orderIndex": 39, "parallelGroupId": "group_0" }, { "name": "prettier", "status": "included", - "orderIndex": 46, + "orderIndex": 40, "parallelGroupId": "group_0" }, { "name": "deno-fmt", "status": "included", - "orderIndex": 47, + "orderIndex": 41, "parallelGroupId": "group_0" }, { "name": "rego", "status": "included", - "orderIndex": 48, + "orderIndex": 42, "parallelGroupId": "group_0" }, { "name": "actionlint", "status": "included", - "orderIndex": 49, + "orderIndex": 43, "parallelGroupId": "group_0" }, { "name": "cargo-fmt", "status": "included", - "orderIndex": 50, + "orderIndex": 44, "parallelGroupId": "group_0" }, { "name": "cargo-clippy", "status": "included", - "orderIndex": 51, + "orderIndex": 45, "parallelGroupId": "group_0" }, { "name": "test", "status": "included", - "orderIndex": 52, + "orderIndex": 46, "parallelGroupId": "group_0" }, { "name": "batten-check", "status": "included", - "orderIndex": 53, + "orderIndex": 47, "parallelGroupId": "group_0" }, { "name": "policy-test", "status": "included", - "orderIndex": 54, + "orderIndex": 48, "parallelGroupId": "group_0" }, { "name": "memories-check", "status": "included", - "orderIndex": 55, + "orderIndex": 49, "parallelGroupId": "group_0" } ] @@ -493,14 +451,10 @@ "msrv-pin-agreement", "cap-drift", "digest-major-agreement", - "report-only-check", "no-docs-tree", - "no-doctests", - "license-table-check", "coderabbit-config-check", "hook-profile-check", "hk-contract-check", - "module-map-check", "hook-pin-check", "mutant-census", "rules-drift", @@ -515,8 +469,6 @@ "publish-credential-check", "release-tracking-check", "mcp-allow-check", - "awk-regex-check", - "pipefail-grep-check", "lock-complete", "timeout-check", "mise-action-floor", @@ -592,286 +544,250 @@ "orderIndex": 7, "parallelGroupId": "group_0" }, - { - "name": "report-only-check", - "status": "included", - "orderIndex": 8, - "parallelGroupId": "group_0" - }, { "name": "no-docs-tree", "status": "included", - "orderIndex": 9, - "parallelGroupId": "group_0" - }, - { - "name": "no-doctests", - "status": "included", - "orderIndex": 10, - "parallelGroupId": "group_0" - }, - { - "name": "license-table-check", - "status": "included", - "orderIndex": 11, + "orderIndex": 8, "parallelGroupId": "group_0" }, { "name": "coderabbit-config-check", "status": "included", - "orderIndex": 12, + "orderIndex": 9, "parallelGroupId": "group_0" }, { "name": "hook-profile-check", "status": "included", - "orderIndex": 13, + "orderIndex": 10, "parallelGroupId": "group_0" }, { "name": "hk-contract-check", "status": "included", - "orderIndex": 14, - "parallelGroupId": "group_0" - }, - { - "name": "module-map-check", - "status": "included", - "orderIndex": 15, + "orderIndex": 11, "parallelGroupId": "group_0" }, { "name": "hook-pin-check", "status": "included", - "orderIndex": 16, + "orderIndex": 12, "parallelGroupId": "group_0" }, { "name": "mutant-census", "status": "included", - "orderIndex": 17, + "orderIndex": 13, "parallelGroupId": "group_0" }, { "name": "rules-drift", "status": "included", - "orderIndex": 18, + "orderIndex": 14, "parallelGroupId": "group_0" }, { "name": "hooks-wiring-check", "status": "included", - "orderIndex": 19, + "orderIndex": 15, "parallelGroupId": "group_0" }, { "name": "reference-check", "status": "included", - "orderIndex": 20, + "orderIndex": 16, "parallelGroupId": "group_0" }, { "name": "token-bench-check", "status": "included", - "orderIndex": 21, + "orderIndex": 17, "parallelGroupId": "group_0" }, { "name": "config-lint", "status": "included", - "orderIndex": 22, + "orderIndex": 18, "parallelGroupId": "group_0" }, { "name": "renovate-config-validator", "status": "included", - "orderIndex": 23, + "orderIndex": 19, "parallelGroupId": "group_0" }, { "name": "schema-check", "status": "included", - "orderIndex": 24, + "orderIndex": 20, "parallelGroupId": "group_0" }, { "name": "sbom-check", "status": "included", - "orderIndex": 25, + "orderIndex": 21, "parallelGroupId": "group_0" }, { "name": "install-check", "status": "included", - "orderIndex": 26, + "orderIndex": 22, "parallelGroupId": "group_0" }, { "name": "publish-credential-check", "status": "included", - "orderIndex": 27, + "orderIndex": 23, "parallelGroupId": "group_0" }, { "name": "release-tracking-check", "status": "included", - "orderIndex": 28, + "orderIndex": 24, "parallelGroupId": "group_0" }, { "name": "mcp-allow-check", "status": "included", - "orderIndex": 29, - "parallelGroupId": "group_0" - }, - { - "name": "awk-regex-check", - "status": "included", - "orderIndex": 30, - "parallelGroupId": "group_0" - }, - { - "name": "pipefail-grep-check", - "status": "included", - "orderIndex": 31, + "orderIndex": 25, "parallelGroupId": "group_0" }, { "name": "lock-complete", "status": "included", - "orderIndex": 32, + "orderIndex": 26, "parallelGroupId": "group_0" }, { "name": "timeout-check", "status": "included", - "orderIndex": 33, + "orderIndex": 27, "parallelGroupId": "group_0" }, { "name": "mise-action-floor", "status": "included", - "orderIndex": 34, + "orderIndex": 28, "parallelGroupId": "group_0" }, { "name": "policy-budget", "status": "included", - "orderIndex": 35, + "orderIndex": 29, "parallelGroupId": "group_0" }, { "name": "batten-glob-check", "status": "included", - "orderIndex": 36, + "orderIndex": 30, "parallelGroupId": "group_0" }, { "name": "skill-check", "status": "included", - "orderIndex": 37, + "orderIndex": 31, "parallelGroupId": "group_0" }, { "name": "macos-link-check", "status": "included", - "orderIndex": 38, + "orderIndex": 32, "parallelGroupId": "group_0" }, { "name": "shfmt", "status": "included", - "orderIndex": 39, + "orderIndex": 33, "parallelGroupId": "group_0" }, { "name": "shellcheck", "status": "included", - "orderIndex": 40, + "orderIndex": 34, "parallelGroupId": "group_0" }, { "name": "test:bats", "status": "included", - "orderIndex": 41, + "orderIndex": 35, "parallelGroupId": "group_0" }, { "name": "taplo", "status": "included", - "orderIndex": 42, + "orderIndex": 36, "parallelGroupId": "group_0" }, { "name": "taplo-format", "status": "included", - "orderIndex": 43, + "orderIndex": 37, "parallelGroupId": "group_0" }, { "name": "pkl", "status": "included", - "orderIndex": 44, + "orderIndex": 38, "parallelGroupId": "group_0" }, { "name": "pkl-format", "status": "included", - "orderIndex": 45, + "orderIndex": 39, "parallelGroupId": "group_0" }, { "name": "prettier", "status": "included", - "orderIndex": 46, + "orderIndex": 40, "parallelGroupId": "group_0" }, { "name": "deno-fmt", "status": "included", - "orderIndex": 47, + "orderIndex": 41, "parallelGroupId": "group_0" }, { "name": "rego", "status": "included", - "orderIndex": 48, + "orderIndex": 42, "parallelGroupId": "group_0" }, { "name": "actionlint", "status": "included", - "orderIndex": 49, + "orderIndex": 43, "parallelGroupId": "group_0" }, { "name": "cargo-fmt", "status": "included", - "orderIndex": 50, + "orderIndex": 44, "parallelGroupId": "group_0" }, { "name": "cargo-clippy", "status": "included", - "orderIndex": 51, + "orderIndex": 45, "parallelGroupId": "group_0" }, { "name": "test", "status": "included", - "orderIndex": 52, + "orderIndex": 46, "parallelGroupId": "group_0" }, { "name": "batten-check", "status": "included", - "orderIndex": 53, + "orderIndex": 47, "parallelGroupId": "group_0" }, { "name": "policy-test", "status": "included", - "orderIndex": 54, + "orderIndex": 48, "parallelGroupId": "group_0" } ] diff --git a/crates/batten/src/cli.rs b/crates/batten/src/cli.rs index 2db82089a..7740006fc 100644 --- a/crates/batten/src/cli.rs +++ b/crates/batten/src/cli.rs @@ -900,6 +900,23 @@ pub struct ExecRequest { /// Whether a bundle keeps going past a failure. `false` when unasked, which /// the committed table may still turn on. pub continue_on_error: bool, + /// The key of this clone's singleton lock to hold for the child's lifetime + /// (CLOUD-1710). `None` is the ordinary unlocked run. + pub lock: Option, + /// A lock path, for a resource the clone does not own. Mutually exclusive + /// with `lock`; naming both is a usage refusal rather than a precedence + /// rule, because the two answer "where should the queue form" differently + /// and silently picking one would serialize the wrong thing. + pub lock_path: Option, + /// How many times to ask for that lock before reporting it held, as the + /// caller typed it. Unparsed for `jobs`' reason: a bad value owes a + /// `UsageError` naming it, and reading it as the default would queue for a + /// length nobody asked for. + pub lock_attempts: Option, + /// What the wait is FOR, for the refusal line. A lock key is a pointer to a + /// file; "the toolchain lock (aarch64-apple-darwin)" is a pointer to the + /// thing a reader has to reason about. + pub lock_label: Option, } /// Subcommands of `lint` — one arm per *kind* of artifact, which is what the @@ -2242,6 +2259,38 @@ fn pr_of(matches: &ArgMatches) -> Option { } } +/// The `exec` arm, lifted out of [`command_of`] so that stays a table. +/// +/// One line per verb is what makes `command_of` readable, and `exec` carries +/// nine flags — the same reason [`ExecRequest`] is a struct rather than nine +/// variant fields, applied one level up. +fn exec_of(matches: &ArgMatches) -> Option { + let command: Vec = matches + .get_many::("command") + .map(|values| values.cloned().collect()) + .unwrap_or_default(); + if command.is_empty() { + return None; + } + Some(Command::Exec(ExecRequest { + command, + capture_only: flag(matches, "capture_only"), + tee: matches.get_flag("tee"), + // Read through the VALUE SOURCE, not the value: clap fills + // `defaulted_enum`'s default in, so `get_one` always answers and a + // config-set default would be overwritten on every call by a flag + // nobody typed. + format: supplied(matches, "format").copied(), + style: supplied(matches, "style").copied(), + jobs: matches.get_one::("jobs").cloned(), + continue_on_error: matches.get_flag("continue_on_error"), + lock: matches.get_one::("lock").cloned(), + lock_path: matches.get_one::("lock_path").cloned(), + lock_attempts: matches.get_one::("lock_attempts").cloned(), + lock_label: matches.get_one::("lock_label").cloned(), + })) +} + fn capture_of(matches: &ArgMatches) -> Option { match matches.subcommand()? { ("show", matches) => Some(CaptureCommand::Show { @@ -2435,29 +2484,7 @@ fn command_of((name, matches): (&str, &ArgMatches)) -> Option { // token after `--` is a separate value and the child's argv is the whole // list. An empty list is unreachable — clap enforces `num_args(1..)` — // and is mapped to `None` rather than an empty exec. - "exec" => { - let command: Vec = matches - .get_many::("command") - .map(|values| values.cloned().collect()) - .unwrap_or_default(); - if command.is_empty() { - None - } else { - Some(Command::Exec(ExecRequest { - command, - capture_only: flag(matches, "capture_only"), - tee: matches.get_flag("tee"), - // Read through the VALUE SOURCE, not the value: clap fills - // `defaulted_enum`'s default in, so `get_one` always answers - // and a config-set default would be overwritten on every - // call by a flag nobody typed. - format: supplied(matches, "format").copied(), - style: supplied(matches, "style").copied(), - jobs: matches.get_one::("jobs").cloned(), - continue_on_error: matches.get_flag("continue_on_error"), - })) - } - } + "exec" => exec_of(matches), "capture" => capture_of(matches).map(|command| Command::Capture { command }), "mcp" => mcp_of(matches).map(|command| Command::Mcp { command }), "show" => show_of(matches), diff --git a/crates/batten/src/exec.rs b/crates/batten/src/exec.rs index 07e9d9511..f8723762c 100644 --- a/crates/batten/src/exec.rs +++ b/crates/batten/src/exec.rs @@ -193,7 +193,7 @@ use std::ffi::OsString; use std::io::{Read, Write}; -use std::path::Path; +use std::path::{Path, PathBuf}; #[expect( clippy::disallowed_types, reason = "stays: `batten exec -- ` IS the spawn — the verb's whole contract is a transparent passthrough of a caller's argv, streams and exit code (CLOUD-285)" @@ -211,6 +211,7 @@ use crate::capture::{self, Stream}; use crate::error::{Passthrough, UsageError}; use crate::exit::ExitCode; use crate::outputs::{self, Hit, OutputPattern}; +use crate::task; /// The exit code a POSIX shell reports for a process killed by a signal. /// @@ -1587,6 +1588,161 @@ pub fn run_in_with_env( report_bundle(&bundle, &outcomes, patterns, settings, report) } +// -- The named lock a child may be held under (CLOUD-1710) ------------------- +// +// `mise-tasks/with-lock.sh` is retired onto this. Everything about WHAT a lock +// means stays in `task.rs` — the atomic create-or-fail, the empty pid file that +// reads as held rather than free, the two-sighting dead-holder reclaim — because +// a second implementation of a lock is the two-authorities class CLOUD-857 +// measured. What lives here is the WRAPPING the shell also had and +// `task::singleton_acquire` does not: hold it across a child, and drop it on +// every exit path. +// +// The mutations target the two decisions a naive lock loses, which are exactly +// the two `tests/with-lock.bats` asserted and which acquire/release cannot tell +// apart. The third targets the verdict, which is the whole product of a wrapper. +//MUTANT exec-lock-empty-holder-read-as-free|s@Claim::Taken | task::Claim::Reclaimed(_)@Claim::Taken | task::Claim::Reclaimed(_) | task::Claim::Held { .. }@|an_empty_holder_file_is_held_not_free +//MUTANT exec-lock-dead-holder-not-reclaimed|s@attempts@1@|a_dead_holder_is_reclaimed_rather_than_waited_out +//MUTANT exec-lock-verdict-discarded|s@let _held =@let _unheld =@|the_wrapped_exit_code_survives_the_lock +//MUTANT-SUITE crates/batten/tests/it/exec_lock.rs + +/// How long the queue is when the caller does not say — the shell's own default +/// (600s at one ask per 100ms), stated as the count it always was. +pub const LOCK_ATTEMPTS_DEFAULT: usize = 6000; + +/// The interval between two asks for a held lock. +/// +/// `mise-tasks/with-lock.sh`'s `sleep 0.1`, carried unchanged. Fixed rather than +/// a flag: `--lock-attempts` is the bound a caller has a reason to move, and a +/// second knob over the same wait would let two callers spell one length two +/// ways. +const LOCK_POLL: std::time::Duration = std::time::Duration::from_millis(100); + +/// Where a lock lives, which decides what it serializes. +/// +/// **Two arms because the resource decides, not the caller's taste.** A lock +/// under `$GIT_DIR` serializes one clone's own work, which is +/// `batten singleton`'s "one task per clone" and what `mise run alive` can +/// enumerate. A lock at a path serializes a resource the clone does not own — +/// a mise install tree, a rustup toolchain — where every checkout on the machine +/// has to form one queue or CLOUD-220 returns by another route. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum LockPlace { + /// Keyed under `$GIT_DIR`, scoped to this clone. + Key(String), + /// At an explicit path, scoped to whatever lives there. + Path(PathBuf), +} + +/// What `--lock` asked for. +#[derive(Debug, Clone, PartialEq, Eq)] +#[non_exhaustive] +pub struct Lock { + /// Where the lock lives. + pub place: LockPlace, + /// How many times to ask before reporting it held. + pub attempts: usize, + /// What the wait is FOR, for the refusal line. + pub label: String, +} + +/// Holds one clone's singleton lock for as long as this value lives. +/// +/// **A `Drop` rather than a release at each return**, which is the shell's exit +/// trap expressed in the type system: the caller has several exits and one of +/// them is an error path carrying the child's own code, and a release a reader +/// has to find on all of them is one a later edit drops. The shell names the +/// cost of getting this wrong: *"a failure that leaves the lock held wedges +/// every later caller for the full timeout, turning one red run into a stuck +/// repo."* +/// +/// The residual is a `SIGKILL`, where no destructor runs and no trap fired +/// either. That is not a gap this needs to close, and the shell says why: an +/// abandoned lock is reclaimed by the dead-holder sighting in +/// [`task::singleton_queue`], so it costs one ask rather than the whole queue. +#[derive(Debug)] +pub struct HeldLock { + lock: PathBuf, +} + +impl Drop for HeldLock { + fn drop(&mut self) { + task::singleton_release_at(&self.lock); + } +} + +/// What asking for a lock produced. +/// +/// Three arms rather than an `Option`, because "no lock was asked for" and "the +/// lock was asked for and refused" are opposite answers an `Option` would spell +/// the same way — and the second carries a code the first has no business +/// naming. +#[derive(Debug)] +pub enum LockOutcome { + /// No lock was asked for; this is an ordinary unlocked run. + Unlocked, + /// The lock is this process's until the guard drops. + /// + /// The value is never read, and that is the mechanism rather than an + /// oversight: what it does happens in [`HeldLock::drop`], so the field is + /// the lock's lifetime expressed as a binding. + Held(HeldLock), + /// The lock could not be taken, and this is what to exit. + Refused(ExitCode), +} + +/// Take the lock `want` names, queueing behind a live holder. +/// +/// A held lock is a verdict about this clone and reports as +/// [`ExitCode::Violation`]; an unresolvable git dir is could-not-look and +/// reports as [`ExitCode::Internal`] — never "nothing holds it", which is how +/// two of them start. Both readings are [`crate::task::report_claim`]'s rather +/// than restated here, so the lock has one authority on what its answers mean. +/// +/// **Ported onto the engine's exit table, not the corpus's.** The retiring shell +/// spelled a held lock `1`; `crate::exit` spells a verdict `2`, and carrying the +/// inversion across would be the defect CLOUD-1718 exists to name. +/// +/// # Errors +/// +/// Propagates a write failure on the report channel. +pub fn hold(want: Option<&Lock>, err: &mut dyn Write) -> Result { + let Some(want) = want else { + return Ok(LockOutcome::Unlocked); + }; + let pid = std::process::id().to_string(); + let (lock, claim) = match &want.place { + LockPlace::Key(key) => { + let Ok(git_dir) = crate::git::git_dir(Path::new(".")) else { + writeln!( + err, + "::error:: exec: --lock names a lock under the git dir, and this is not a git repository — use --lock-path for a lock outside one" + )?; + return Ok(LockOutcome::Refused(ExitCode::Internal)); + }; + let lock = git_dir.join(task::SINGLETON_DIR).join(key); + let claim = task::singleton_acquire_at(&lock, &pid, LOCK_POLL, want.attempts); + (lock, claim) + } + LockPlace::Path(path) => { + let claim = task::singleton_acquire_at(path, &pid, LOCK_POLL, want.attempts); + (path.clone(), claim) + } + }; + match claim { + task::Claim::Taken | task::Claim::Reclaimed(_) => Ok(LockOutcome::Held(HeldLock { lock })), + // Stdout belongs to the child even on the path where there is no child: + // a caller parsing this command's stdout must not find a lock's + // narration in it, so the success channel is a sink here. + refused => Ok(LockOutcome::Refused(task::report_claim( + &refused, + &want.label, + &mut std::io::sink(), + err, + )?)), + } +} + /// Run a command and report WHICH declared patterns its output matched, /// whatever it exited. /// diff --git a/crates/batten/src/land.rs b/crates/batten/src/land.rs index ee84e9c33..d32bf20e4 100644 --- a/crates/batten/src/land.rs +++ b/crates/batten/src/land.rs @@ -2026,47 +2026,6 @@ impl Ledger { } } -/// Was this head's CI failure a provisioning transient rather than a verdict? -/// -/// `records` is one line per failed run, as the non-verdict scanner reported them. -/// **A run is absorbed only if EVERY record is a non-verdict**: one line naming a -/// verdict means the branch was judged, and re-running would spend jobs to -/// re-learn a real refusal. -/// -/// `None` for could-not-look, and the causes are deliberately one reading: no -/// failed runs, a scan that produced nothing, a scan that answered with a -/// verdict, and a record this reader does not recognise. A caller cannot act -/// differently on which, and inventing a distinction would invite one to. -/// -/// **AN UNRECOGNISED RECORD IS COULD-NOT-LOOK, NOT AN ABSENT VERDICT.** The -/// filter used to keep the `nonverdict` lines and DROP everything else, so a -/// scanner error, a truncated record or a shape added later read as *every -/// record is a non-verdict* — the permissive answer, which re-runs the matrix on -/// a head that may well have been judged. Absorbing is the expensive direction, -/// so the reading that cannot be justified must not reach it. -#[must_use] -pub fn absorbed(records: &[String]) -> Option> { - let lines: Vec<&str> = records - .iter() - .flat_map(|record| record.lines()) - .map(str::trim) - .filter(|line| !line.is_empty()) - .collect(); - if lines.is_empty() { - return None; - } - if lines.iter().any(|line| line.starts_with("verdict")) { - return None; - } - // EVERY line must be one this reader knows. `nonverdict` does not begin with - // `verdict`, so the two prefixes partition cleanly and anything outside the - // pair is a record nobody here can classify. - if !lines.iter().all(|line| line.starts_with("nonverdict")) { - return None; - } - Some(lines.iter().map(|line| (*line).to_owned()).collect()) -} - // --------------------------------------------------------------------------- // CLOUD-900 / CLOUD-1338: abandoning the matrix a red check made worthless. // --------------------------------------------------------------------------- @@ -2515,54 +2474,6 @@ pub fn buys_a_matrix( } } -/// The runs on a head that failed, as ids. -/// -/// **NO PAGE SIZE, deliberately, and the predecessor says why in a sentence -/// worth carrying:** `tests/land.bats`'s keyed-verdict sensor asserts the lander -/// carries no windowed page size, because the fast-forward verdict must be found -/// by its KEY rather than by a window. This is a different endpoint that needs -/// none — a head sha's failed runs are a handful — so the sensor stays exact -/// instead of being spelled past. -/// -/// `None` is could-not-look, and the caller reads it as *not absorbed*: a -/// transient is a claim about the runs, and a claim over a list nobody could -/// read is not one. -#[must_use] -pub fn failed_runs(repo: &str, sha: &str) -> Option> { - let answer = crate::rest::get( - &format!("repos/{repo}/actions/runs?head_sha={sha}&status=failure"), - None, - )?; - let document = serde_json::from_str::(&answer.body).ok()?; - let runs = document.get("workflow_runs")?.as_array()?; - let ids: Vec = runs - .iter() - .filter_map(|run| { - let id = run.get("id")?; - id.as_u64() - .map(|found| found.to_string()) - .or_else(|| id.as_str().map(str::to_owned)) - }) - .collect(); - // AN EMPTY LIST IS NOT AN ANSWER HERE. The predecessor returns non-zero on - // one, and it is right to: "no failed runs" cannot support "the failure was - // a transient", because there is no failure to have been one. - (!ids.is_empty()).then_some(ids) -} - -/// Ask the forge to re-run one run's failed jobs. -/// -/// `false` where the forge refused. **Reported rather than swallowed**, unlike -/// the tap: the predecessor dies here with a remedy naming the exact command, -/// because a lap that believed it re-ran and did not would wait forever for a -/// run nobody started. -#[must_use] -pub fn rerun_failed(repo: &str, run: &str) -> bool { - crate::rest::post(&format!( - "repos/{repo}/actions/runs/{run}/rerun-failed-jobs" - )) -} - #[cfg(test)] mod lap_tests { use super::{Progress, Step, progress}; @@ -3251,47 +3162,6 @@ mod tests { assert_eq!(ledger.laps, 0); } - /// **The discriminating pair: every record a non-verdict is absorbed, one - /// verdict is not.** - /// - /// A run that reached a verdict was a judgement on this branch, and - /// re-running it would spend jobs to re-learn a real refusal. - #[test] - fn a_failure_before_any_verdict_is_absorbed_and_one_after_is_not() { - let absorbed_runs = absorbed(&[ - String::from("nonverdict 111 provision\n"), - String::from("nonverdict 222 checkout\n"), - ]); - assert_eq!( - absorbed_runs, - Some(vec![ - String::from("nonverdict 111 provision"), - String::from("nonverdict 222 checkout"), - ]), - "neither run reached a verdict, so neither judged the branch" - ); - - assert_eq!( - absorbed(&[ - String::from("nonverdict 111 provision\n"), - String::from("verdict 222 test-failed\n"), - ]), - None, - "ONE verdict means the branch was judged; absorbing the pair would \ - re-run a real refusal" - ); - } - - /// Could-not-look is one reading, and its three causes are deliberately - /// indistinguishable: no failed runs, an empty scan, and a scan that - /// answered nothing. No caller can act differently on which. - #[test] - fn an_empty_scan_is_could_not_look_rather_than_an_absorbed_transient() { - assert_eq!(absorbed(&[]), None); - assert_eq!(absorbed(&[String::new()]), None); - assert_eq!(absorbed(&[String::from(" \n\n")]), None); - } - /// **THE FIXTURE NAMES ARE GENERIC, and that is rule 1 rather than taste.** /// /// The first draft spelled these as this repository's own workflow paths and diff --git a/crates/batten/src/lib.rs b/crates/batten/src/lib.rs index a9b933c08..9177a161d 100644 --- a/crates/batten/src/lib.rs +++ b/crates/batten/src/lib.rs @@ -8586,11 +8586,40 @@ fn run_land_replay( ) -> Result { match land::replay(root, url, reference, branch, resolve)? { land::Replay::Conflicted { commit, paths } => { + // THE STOP NAMES ITS ROUTE, which this line did not (CLOUD-1586's + // mechanism, CLOUD-1050's rule). It reported the conflict and the + // first path and stopped there — so a reader with no `--resolve` in + // their head reached for `git rebase --continue`, which cannot + // exist here, and then for a hand rebase, which + // `rebase-not-hand-stepped` denies. Measured on this very branch: a + // session concluded the loop was defective and was one step from + // cherry-picking around it, which would have completed the replay + // and written no lap record at all. + // + // EVERY PATH, not the first: the caller has to author a resolution + // for each one, and a count they cannot enumerate is not actionable. + // Paths are pointers, so rule 4 is untouched — no hunk, no marker, + // no content. + writeln!( + out, + "land: replay of {branch} onto {reference} conflicted at {commit} in {} path(s):", + paths.len() + )?; + for path in &paths { + writeln!(out, " {path}")?; + } + writeln!( + out, + "land: the replay is STATELESS — nothing is half-replayed, so there is no rebase in \ + progress and nothing to `--continue`. Merge each path in the worktree and name it: \ + `batten land replay {reference} --resolve `. The whole range re-runs from its \ + base, so the merged bytes are supplied up front." + )?; writeln!( out, - "land: replay of {branch} onto {reference} conflicted at {commit} in {} path(s); first is {}", - paths.len(), - paths.first().map_or("-", String::as_str) + "land: a path that conflicts at MORE THAN ONE commit needs one entry per conflict, \ + in order — `--resolve =` names bytes authored for a single merge, where \ + a bare `--resolve ` spends the worktree's copy on the first one only." )?; Ok(ExitCode::Violation) } @@ -15413,12 +15442,73 @@ fn run_exec( })?; } settings.continue_on_error = settings.continue_on_error || request.continue_on_error; + // THE LOCK IS HELD ACROSS THE CHILD, AND THE GUARD IS WHAT MAKES THAT TRUE + // ON EVERY RETURN (CLOUD-1710). A wrapped command that fails comes back as + // `Err(Passthrough)`, so releasing after the call would leak the lock on + // exactly the path that matters most — the shell named the same defect and + // used a trap: *"a failure that leaves the lock held wedges every later + // caller for the full timeout, turning one red run into a stuck repo."* + let _held = match exec::hold(exec_lock(request)?.as_ref(), err)? { + exec::LockOutcome::Refused(code) => return Ok(code), + taken => taken, + }; // The report goes to the ERROR channel, never `out`: stdout belongs to the // wrapped command (CLOUD-285), so a pointer line there would corrupt a // document the caller may be parsing. exec::run_with(&request.command, &patterns, &settings, err) } +/// What `--lock` asked for, as the value [`exec::hold`] takes. +/// +/// Parsed HERE rather than in `exec.rs`, for `--jobs`' reason and in `--jobs`' +/// place: a bad value owes a `UsageError` naming what was wrong with it, and +/// the boundary is where this crate turns typed argv into declared values. +/// +/// # Errors +/// +/// [`UsageError`] when `--lock-attempts` is not a positive whole number. +fn exec_lock(request: &cli::ExecRequest) -> Result> { + let place = match (request.lock.as_deref(), request.lock_path.as_deref()) { + (None, None) => return Ok(None), + (Some(key), None) => exec::LockPlace::Key(key.to_owned()), + (None, Some(path)) => exec::LockPlace::Path(std::path::PathBuf::from(path)), + // A refusal rather than a precedence rule: the two answer "where should + // the queue form" differently, and silently picking one would serialize + // the wrong thing — which is the failure mode a lock exists to prevent. + (Some(_), Some(_)) => { + return Err(UsageError::raise( + "exec: --lock and --lock-path name two different queues; give one", + )); + } + }; + let attempts = match request.lock_attempts.as_ref() { + Some(raw) => raw + .trim() + .parse::() + .ok() + .filter(|n| *n > 0) + .ok_or_else(|| { + UsageError::raise(format!( + "exec: --lock-attempts wants a positive whole number, not `{raw}`" + )) + })?, + None => exec::LOCK_ATTEMPTS_DEFAULT, + }; + let named = match &place { + exec::LockPlace::Key(key) => key.clone(), + exec::LockPlace::Path(path) => path.display().to_string(), + }; + Ok(Some(exec::Lock { + place, + attempts, + // The caller names what the wait was FOR, and the key is the fallback + // rather than the message: `the toolchain lock (aarch64-apple-darwin)` + // is a pointer to the thing a reader has to reason about, where a bare + // key is a pointer to a directory. + label: request.lock_label.clone().unwrap_or(named), + })) +} + fn load_exec_settings( overrides: &Overrides, ) -> Result<(Vec, exec::ExecConfig)> { diff --git a/crates/batten/src/surface.rs b/crates/batten/src/surface.rs index 5c3680528..60b3caac1 100644 --- a/crates/batten/src/surface.rs +++ b/crates/batten/src/surface.rs @@ -855,6 +855,94 @@ const JOBS: FlagDecl = FlagDecl { value: ValueDecl::Str, }; +/// `--lock` on `exec` (CLOUD-1710), the key one clone's singleton lock is named +/// by. +/// +/// A KEY, never a path. `mise-tasks/with-lock.sh` took a lock directory and its +/// callers pointed one at the rust sysroot, so a toolchain swap could not +/// deadlock on a stale path. The key carries that distinction where it belongs — +/// in the name (`target-ensure-`) — and lets the lock live under +/// `$GIT_DIR` with every other one this clone holds, which is what +/// `batten singleton` and `mise run alive` already read. +const LOCK: FlagDecl = FlagDecl { + id: "lock", + long: Some("lock"), + short: None, + help: "Hold this clone's named singleton lock for the child's lifetime", + env: EnvDecl::None, + global: false, + positional: false, + required: false, + hidden: false, + rung: Rung::None, + value: ValueDecl::Str, +}; + +/// `--lock-path` on `exec` (CLOUD-1710): a lock guarding something the clone +/// does not own. +/// +/// The sibling of `--lock`, and the two are not interchangeable. A KEY names a +/// lock under `$GIT_DIR`, which is right for "one task per clone". A PATH names +/// one wherever the resource being serialized actually lives — the retiring +/// shell's two callers both needed that, `doctor`'s lock sitting under +/// `$MISE_DATA_DIR` and `target-ensure`'s inside the rust sysroot, because a +/// mise install tree and a rustup toolchain are the MACHINE's and are shared by +/// every clone on it. Keying those per clone would let two checkouts install a +/// target concurrently and roll each other back (CLOUD-220). +const LOCK_PATH: FlagDecl = FlagDecl { + id: "lock_path", + long: Some("lock-path"), + short: None, + help: "Hold the lock at this path, for a resource the clone does not own", + env: EnvDecl::None, + global: false, + positional: false, + required: false, + hidden: false, + rung: Rung::None, + value: ValueDecl::Str, +}; + +/// `--lock-attempts` on `exec` (CLOUD-1710): how long the queue is, as a COUNT. +/// +/// The bound is a number of asks separated by a declared interval, never a wall +/// clock — `land`'s shape, for `land`'s reason. The shell spelled it +/// `WITH_LOCK_TIMEOUT` in seconds and divided by its own `sleep 0.1`; the +/// default here is that same arithmetic already done (600s ⇒ 6000 asks). +const LOCK_ATTEMPTS: FlagDecl = FlagDecl { + id: "lock_attempts", + long: Some("lock-attempts"), + short: None, + help: "How many times to ask for the lock before reporting it held", + env: EnvDecl::None, + global: false, + positional: false, + required: false, + hidden: false, + rung: Rung::None, + value: ValueDecl::Str, +}; + +/// `--lock-label` on `exec` (CLOUD-1710): what the wait was FOR. +/// +/// Carried verbatim from the shell, which states why: *"A lock path is a pointer +/// to a file; 'the toolchain lock (aarch64-apple-darwin)' is a pointer to the +/// thing a reader has to reason about, and moving the wait out of the caller +/// must not cost that."* +const LOCK_LABEL: FlagDecl = FlagDecl { + id: "lock_label", + long: Some("lock-label"), + short: None, + help: "What the wait is for, named by the caller for the refusal line", + env: EnvDecl::None, + global: false, + positional: false, + required: false, + hidden: false, + rung: Rung::None, + value: ValueDecl::Str, +}; + /// `--continue-on-error` on `exec` (CLOUD-430), likewise mise's. const CONTINUE_ON_ERROR: FlagDecl = FlagDecl { id: "continue_on_error", @@ -2477,6 +2565,10 @@ pub const SURFACE: &[CommandDecl] = &[ TEE, JOBS, CONTINUE_ON_ERROR, + LOCK, + LOCK_PATH, + LOCK_ATTEMPTS, + LOCK_LABEL, FlagDecl::defaulted_enum( "format", "format", diff --git a/crates/batten/src/task.rs b/crates/batten/src/task.rs index c1ba4dd83..776c6a1dd 100644 --- a/crates/batten/src/task.rs +++ b/crates/batten/src/task.rs @@ -682,7 +682,7 @@ pub fn report( // immediately and holds nothing, exactly as the shell did. /// Where one clone's singleton locks live. -const SINGLETON_DIR: &str = "batten-singleton"; +pub(crate) const SINGLETON_DIR: &str = "batten-singleton"; /// What an acquire attempt found. #[derive(Clone, Debug, PartialEq, Eq)] @@ -771,56 +771,143 @@ pub fn singleton_acquire( recheck: std::time::Duration, ) -> Claim { let lock = singleton_lock(git_dir, task); - let dir = git_dir.join(SINGLETON_DIR); - if std::fs::create_dir_all(&dir).is_err() { - return Claim::CouldNotLook(dir); + match singleton_acquire_at(&lock, pid, recheck, 1) { + // The registry's word about what the holder is DOING, and it is added + // here rather than in the path-taking core because only a clone-scoped + // lock has a registry to ask. A lock guarding a machine-global resource + // has no such entry, and inventing one would be a second authority over + // a layout this module owns. + Claim::Held { + holder, + phase: None, + } => { + let phase = read_field(git_dir, &holder, "phase").filter(|phase| !phase.is_empty()); + Claim::Held { holder, phase } + } + answered => answered, } - if take(&lock, pid) { - return Claim::Taken; +} + +/// [`singleton_acquire`] over a lock at an explicit PATH. +/// +/// **The core, and the split is behaviour rather than tidiness** (CLOUD-1710). +/// `singleton_acquire`'s lock lives under `$GIT_DIR`, which is right for "one +/// task per clone" and wrong for a lock guarding something the clone does not +/// own. The two locks `mise-tasks/with-lock.sh` was written for are both of the +/// second kind — `doctor`'s sits under `$MISE_DATA_DIR` and `target-ensure`'s +/// inside the rust sysroot it protects — because the resources they serialize +/// are the MACHINE's, shared by every clone on it. Keying those per clone would +/// let two checkouts install a rustup target concurrently and roll each other +/// back, which is CLOUD-220 returning by another route. +/// +/// Every decision is the same one `singleton_acquire` makes; only where the lock +/// lives differs. +#[must_use] +pub fn singleton_acquire_at( + lock: &Path, + pid: &str, + recheck: std::time::Duration, + attempts: usize, +) -> Claim { + // A lock with no parent is not a lock: could-not-look, never "free". + let Some(parent) = lock.parent() else { + return Claim::CouldNotLook(lock.to_path_buf()); + }; + if std::fs::create_dir_all(parent).is_err() { + return Claim::CouldNotLook(parent.to_path_buf()); } - // An EMPTY pid file is a holder caught between its create and its write, not - // a corpse: absence of evidence is "held", never "free". - let Some(holder) = holder_of(&lock) else { - return Claim::Held { - holder: "unknown".to_owned(), - phase: None, + // At least one ask, so `attempts = 0` is a single non-blocking attempt rather + // than a silent "never look" that would report a free lock as held. + let rounds = attempts.max(1); + let mut round = 0_usize; + loop { + round += 1; + let last = round >= rounds; + if take(lock, pid) { + return Claim::Taken; + } + + // An EMPTY pid file is a holder caught between its create and its write, + // not a corpse: absence of evidence is "held", never "free". + let Some(holder) = holder_of(lock) else { + if last { + return Claim::Held { + holder: "unknown".to_owned(), + phase: None, + }; + } + sleep_once(recheck); + continue; }; - }; - // There is deliberately NO early live-holder fast path. It read as a safety - // property and was not one: with it deleted a live holder still falls - // through to the refusal below, so no test could tell the two apart and it - // survived its own mutant. One refusal path is worth more than the pause. - // - // First sighting of a dead pid. Look again before reclaiming, so a holder - // that exited cleanly between the read and the check — its own trap already - // removing the directory — is never mistaken for one that died holding, and - // a NEW holder that took the lock in between is never robbed of it. - // An INVENTORY ROW, and it is neither of the two shapes CLOUD-1177 separates. - // It is not a poll — nothing is re-attempted on a schedule — and it is not a - // timer standing in for an exit condition, because there is no condition to - // wait for: the two sightings must be separated by elapsed time or they are - // one sighting. The bound is `--recheck-ms`, a single pause the caller - // declares, and the interval IS the safety margin rather than a guess at how - // long something takes. + // There is deliberately NO early live-holder fast path. It read as a safety + // property and was not one: with it deleted a live holder still falls + // through to the refusal below, so no test could tell the two apart and it + // survived its own mutant. One refusal path is worth more than the pause. + // + // First sighting of a dead pid. Look again before reclaiming, so a holder + // that exited cleanly between the read and the check — its own trap already + // removing the directory — is never mistaken for one that died holding, and + // a NEW holder that took the lock in between is never robbed of it. + // An INVENTORY ROW, and it is neither of the two shapes CLOUD-1177 separates. + // It is not a poll — nothing is re-attempted on a schedule — and it is not a + // timer standing in for an exit condition, because there is no condition to + // wait for: the two sightings must be separated by elapsed time or they are + // one sighting. The bound is `--recheck-ms`, a single pause the caller + // declares, and the interval IS the safety margin rather than a guess at how + // long something takes. + sleep_once(recheck); + if may_reclaim(&holder, holder_of(lock).as_deref()) + && std::fs::remove_dir_all(lock).is_ok() + && take(lock, pid) + { + return Claim::Reclaimed(holder); + } + + // The lock changed under us, or a live holder took it: whoever holds it + // now is real. Re-read rather than reporting the corpse seen a moment + // ago. + if last { + return Claim::Held { + holder: holder_of(lock).unwrap_or_else(|| "unknown".to_owned()), + phase: None, + }; + } + } +} + +/// The one declared pause in this module, and the only waived delay it owns. +/// +/// **One site rather than two, and that is a rule rather than tidiness.** +/// `delay-waivers-not-growing` ratchets how many waiver annotations stand over a +/// sleep in this crate, because *a ban you can waive at will is not a ban* +/// (CLOUD-1148). A queue written as its own loop with its own sleep would have +/// spent a twelfth waiver to express a pause this function already owns. +/// +/// The annotation below is deliberately not spelled anywhere in this prose: that +/// row's `pattern` is an unanchored literal, so a doc comment naming it counts +/// as a waiver. Measured — the first draft of this comment took the count to 12 +/// on its own, which is `no-new-ignores`' documented hazard (*"a bare `#[ignore]` +/// is a literal substring, so it also matches every prose mention"*) arriving in +/// the row that did not anchor for it. +/// +/// It serves both readings, and they are the same pause seen from two sides: the +/// interval separating the two sightings a reclaim requires, and the interval +/// between two asks for a lock somebody else holds. Neither is a poll standing +/// in for an exit condition (CLOUD-1177) — the bound is `attempts`, a count the +/// caller declares, exactly as `land` bounds its laps. +fn sleep_once(interval: std::time::Duration) { #[expect( clippy::disallowed_methods, - reason = "the bound is `--recheck-ms`: a single declared pause separating the two sightings a reclaim requires, not a poll and not a timer standing in for an exit condition" + reason = "the module's one declared pause: it separates the two sightings a reclaim requires AND the two asks a queue makes, bounded by `attempts` rather than by a clock, so it is neither a poll nor a timer standing in for an exit condition" )] - std::thread::sleep(recheck); - if may_reclaim(&holder, holder_of(&lock).as_deref()) - && std::fs::remove_dir_all(&lock).is_ok() - && take(&lock, pid) - { - return Claim::Reclaimed(holder); - } + std::thread::sleep(interval); +} - // The lock changed under us, or a live holder took it: whoever holds it now - // is real. Re-read rather than reporting the corpse seen a moment ago. - let holder = holder_of(&lock).unwrap_or_else(|| "unknown".to_owned()); - let phase = read_field(git_dir, &holder, "phase").filter(|phase| !phase.is_empty()); - Claim::Held { holder, phase } +/// Drop a lock at an explicit path, whether or not it was ever taken. +pub fn singleton_release_at(lock: &Path) { + let _ = std::fs::remove_dir_all(lock); } /// May the lock be taken from the pid seen at the FIRST sighting? diff --git a/crates/batten/tests/it/awk_regex.rs b/crates/batten/tests/it/awk_regex.rs new file mode 100644 index 000000000..22814a7d4 --- /dev/null +++ b/crates/batten/tests/it/awk_regex.rs @@ -0,0 +1,166 @@ +//! No shell program hands awk a regex through `-v`, over the compiled binary +//! (ported from `mise-tasks/awk-regex-check.sh` under CLOUD-843). +//! +//! **What is decidable only here.** `policy/awk-regex.rego` carries load-time +//! cases pinning the predicate, and each supplies `input.tree.lines` with `with +//! input as` — fabricating the corpus the gate is about. Only a real repository +//! shows the engine handing this module the shell it is meant to scan, which is +//! the anti-vacuity half: a scan that matched no file reports "no regex reaches +//! awk through -v" over nothing and reads as coverage (CLOUD-418). +// +// carried: mise-tasks/awk-regex-check.sh policy/awk-regex.rego crates/batten/tests/it/awk_regex.rs +// carried: tests/awk-regex-check.bats policy/awk-regex.rego crates/batten/tests/it/awk_regex.rs +// +// carried: "this repo's own tasks pass today" policy/awk-regex.rego +// carried: "the real bug: a -v name used with ~ is reported" policy/awk-regex.rego +// carried: "the report names file and line, not the whole command" policy/awk-regex.rego +// carried: "match() is regex position too" policy/awk-regex.rego +// carried: "a -v value compared with == is fine — that is most of its use" policy/awk-regex.rego +// carried: "a -v value printed or counted is fine" policy/awk-regex.rego +// carried: "an inline regex in the awk program is the recommended form, not a finding" policy/awk-regex.rego +// carried: "a name that merely PREFIXES another is not confused for it" policy/awk-regex.rego +// carried: "several -v assignments on one line are each judged" policy/awk-regex.rego +// carried: "a tree with no awk at all passes rather than erroring" policy/awk-regex.rego +// +// changed: "an untracked file is not judged — the gate reads what is committed" policy/awk-regex.rego the shell fed itself `git ls-files`, so the index was the subject and the case pinned it. `input.tree.lines` is resolved from a declared glob over the working-tree walk, which honours `.gitignore` and explicitly is not the index (`facts.rs`), and nothing available to a module expresses index membership. The successor judges an uncommitted program too — stricter, fail-closed, and the same difference `policy/module-map.rego` records for its own port + +// Panicking on setup failure is the idiomatic way for a test to fail loudly. +#![allow(clippy::unwrap_used, clippy::expect_used)] + +use crate::common; + +use std::path::{Path, PathBuf}; +use std::process::Output; + +use common::{Fixture, git_in, run, stdout}; + +fn awk_repo(name: &str, program: &str) -> PathBuf { + let dir = Fixture::new(name) + .config( + "version = 1\n\n\ + [[pattern]]\n\ + id = \"awk-v-assignment\"\n\ + regex = '-v[[:space:]]*([A-Za-z_][A-Za-z0-9_]*)='\n\n\ + [[pattern]]\n\ + id = \"leading-identifier\"\n\ + regex = '^[A-Za-z_][A-Za-z0-9_]*'\n\n\ + [[verdict]]\n\ + id = \"call run loose\"\n\ + gloss = \"a command is spelled in a way whose behaviour is not defined across implementations\"\n\ + class = \"Escape processing on a -v assignment differs between awk implementations, so the same pattern is two different regexes.\"\n\n\ + [[verdict.route]]\n\ + id = \"prose read first\"\n\ + kind = \"document\"\n\ + target = \"AGENTS.md\"\n\n\ + [[rule]]\n\ + id = \"awk-regex\"\n\ + kind = \"policy\"\n\ + scope = \"tree\"\n\ + line_sources = [\"mise-tasks/**\"]\n\ + module = \"policy/awk-regex.rego\"\n\ + severity = \"deny\"\n", + ) + .file("AGENTS.md", "the consumer's own authority\n") + .file("mise-tasks/demo.sh", program) + .git() + .build(); + common::write( + &dir, + "policy/awk-regex.rego", + &std::fs::read_to_string(common::at_root("policy/awk-regex.rego")).unwrap(), + ); + git_in(&dir, &["add", "-A"]); + git_in(&dir, &["commit", "-q", "-m", "base"]); + dir +} + +fn check(dir: &Path) -> Output { + run(dir, &["check", "--rule", "awk-regex"]) +} + +#[test] +fn the_real_bug_a_v_name_used_with_tilde_is_reported() { + let dir = awk_repo( + "awk-regex-tilde", + "#!/usr/bin/env bash\nawk -v re=\"$p\" '$0 ~ re' file\n", + ); + let output = check(&dir); + assert_eq!(output.status.code(), Some(2), "{}", stdout(&output)); + let text = stdout(&output); + assert!( + text.contains("mise-tasks/demo.sh"), + "the report names the file: {text:?}" + ); + assert!( + !text.contains("$0 ~ re"), + "and not the whole command, which is payload: {text:?}" + ); +} + +#[test] +fn match_is_regex_position_too() { + let dir = awk_repo( + "awk-regex-match", + "#!/usr/bin/env bash\nawk -v re=\"$p\" '{ if (match($0, re)) print }' file\n", + ); + assert_eq!(check(&dir).status.code(), Some(2)); +} + +#[test] +fn a_value_compared_with_equals_is_fine_over_the_binary() { + // Most of `-v`'s use, and the half the gate must not refuse. + let dir = awk_repo( + "awk-regex-equals", + "#!/usr/bin/env bash\nawk -v want=\"$p\" '$1 == want' file\n", + ); + let output = check(&dir); + assert_eq!(output.status.code(), Some(0), "{}", stdout(&output)); +} + +#[test] +fn a_name_that_prefixes_another_is_not_confused_for_it() { + // DISCRIMINATING. The shell guarded this boundary with a trailing character + // class on a per-name regex; a module may not build one at all, so the port + // reads the identifier after `~` and compares it WHOLE. A successor that + // used `startswith` instead passes every other case here and fails this one. + let dir = awk_repo( + "awk-regex-prefix", + "#!/usr/bin/env bash\nawk -v re=\"$p\" '$0 ~ rex' file\n", + ); + let output = check(&dir); + assert_eq!( + output.status.code(), + Some(0), + "`re` was confused for `rex`: {}", + stdout(&output) + ); +} + +#[test] +fn an_inline_regex_is_the_recommended_form_not_a_finding() { + let dir = awk_repo( + "awk-regex-inline", + "#!/usr/bin/env bash\nawk '$0 ~ /^CLOUD-[0-9]+$/' file\n", + ); + assert_eq!(check(&dir).status.code(), Some(0)); +} + +#[test] +fn a_tree_with_no_awk_at_all_passes_rather_than_erroring() { + let dir = awk_repo( + "awk-regex-no-awk", + "#!/usr/bin/env bash\ngrep -v thing file\n", + ); + assert_eq!(check(&dir).status.code(), Some(0)); +} + +#[test] +fn this_repos_own_programs_pass_today() { + let output = common::run_at_real_root(&common::at_root(""), &["check", "--rule", "awk-regex"]); + assert_eq!( + output.status.code(), + Some(0), + "a regex reaches awk through -v in this tree: {}", + stdout(&output) + ); +} diff --git a/crates/batten/tests/it/exec_lock.rs b/crates/batten/tests/it/exec_lock.rs new file mode 100644 index 000000000..79241f127 --- /dev/null +++ b/crates/batten/tests/it/exec_lock.rs @@ -0,0 +1,222 @@ +//! `batten exec --lock` holds a named singleton lock across a child, over the +//! compiled binary (CLOUD-1710, ported from `mise-tasks/with-lock.sh`). +//! +//! **The two cases that discriminate are the last two**, and they are why this +//! tier exists rather than a pair of acquire/release assertions. A lock that +//! never blocks, never releases, or swallows the verdict of what it guards all +//! look identical from the outside on a quiet machine — the retiring suite says +//! so in its own header — and so does a lock that reclaims a holder it should +//! not. `an_empty_holder_file_is_held_not_free` and +//! `a_dead_holder_is_reclaimed_rather_than_waited_out` are the pair a naive +//! successor fails, and each has a `#MUTANT` row in `crates/batten/src/exec.rs` +//! that makes the discrimination an exit code rather than a claim. +//! +//! **Over the compiled binary rather than a unit test on `task::singleton_queue` +//! (CLOUD-418).** The queue is testable in isolation and that is not the +//! obligation: what retired is a WRAPPER, so the property is that the lock is +//! taken before the child and dropped after it whatever the child did, and only +//! the binary runs both halves. +//! +//! **The retirement this was built for is NOT in this delta, and the reason is a +//! measured coupling rather than a deferral.** `batten exec` resolves a +//! repository root for its capture store, and `mise-tasks/with-lock.sh` needed +//! none — so a caller running outside a real clone works under the shell and +//! refuses under the successor. `tests/doctor-race.bats` is exactly that caller: +//! its fixture is a `.git` DIRECTORY rather than a repository, so every port of +//! `with-lock` onto an `exec` flag reddens it, and that suite is governed and +//! cannot be edited. Its declared subject is `mise-tasks/doctor.sh`, which +//! retires under CLOUD-1753 — so `with-lock` retires in the same delta as the +//! `doctor` / `target-ensure` / `doctor-check` closed set, or not at all. The +//! capability lands here; the ledger arms land with the deletion. + +use std::path::Path; + +use crate::common; + +/// Where the engine keeps one clone's singleton locks. +fn lock_dir(repo: &Path, key: &str) -> std::path::PathBuf { + repo.join(".git").join("batten-singleton").join(key) +} + +/// A pid that is certainly not running. +/// +/// Reaped rather than invented: a number picked out of the air can collide with +/// a live process and turn a reclaim case green for the wrong reason. +fn dead_pid() -> u32 { + #[expect( + clippy::disallowed_types, + reason = "stays: reaping a real process is the only way to name a pid that is certainly dead, and the reclaim case turns on that — a number picked out of the air can collide with a live process and go green for the wrong reason" + )] + let child = std::process::Command::new("true") + .spawn() + .expect("spawn a process that exits immediately"); + let pid = child.id(); + let mut child = child; + child.wait().expect("reap it"); + pid +} + +fn repo(name: &str) -> std::path::PathBuf { + let dir = common::scratch(name); + common::init_repo(&dir); + dir +} + +#[test] +fn the_wrapped_exit_code_survives_the_lock() { + // The whole product of a wrapper. The shell names losing it as the defect + // that would "destroy the verdict of everything it guards". + let dir = repo("exec-lock-verdict"); + let output = common::run(&dir, &["exec", "--lock", "k", "--", "bash", "-c", "exit 7"]); + assert_eq!(output.status.code(), Some(7), "{}", common::stderr(&output)); +} + +#[test] +fn a_signal_survives_the_lock() { + // A child that died on a signal has no exit status of its own, and the + // shell's `128 + signal` convention is what the wrapper reports. The lock + // must not replace it with a success of its own. + let dir = repo("exec-lock-signal"); + let output = common::run( + &dir, + &["exec", "--lock", "k", "--", "bash", "-c", "kill -TERM $$"], + ); + assert_eq!( + output.status.code(), + Some(143), + "{}", + common::stderr(&output) + ); +} + +#[test] +fn the_lock_is_released_when_the_child_fails() { + // The half that matters: a failure leaving the lock held wedges every later + // caller for the whole queue, turning one red run into a stuck repo. + let dir = repo("exec-lock-release-on-failure"); + let output = common::run(&dir, &["exec", "--lock", "k", "--", "false"]); + assert_eq!(output.status.code(), Some(1)); + assert!( + !lock_dir(&dir, "k").exists(), + "the lock survived a failing child" + ); +} + +#[test] +fn an_empty_holder_file_is_held_not_free() { + // DISCRIMINATING. A holder caught between its create and its write has an + // empty pid file, and absence of evidence is "held", never "free" — + // reclaiming there robs a live process of a lock it is about to stamp. + // `--lock-attempts 1` is what makes this one ask rather than the full queue. + let dir = repo("exec-lock-empty-holder"); + let lock = lock_dir(&dir, "k"); + std::fs::create_dir_all(&lock).expect("stage a holder mid-write"); + std::fs::write(lock.join("pid"), "").expect("an empty pid file"); + + let output = common::run( + &dir, + &["exec", "--lock", "k", "--lock-attempts", "1", "--", "true"], + ); + assert_eq!( + output.status.code(), + Some(2), + "an empty holder file was read as free: {}", + common::stderr(&output) + ); +} + +#[test] +fn a_dead_holder_is_reclaimed_rather_than_waited_out() { + // DISCRIMINATING, and the opposite direction from the case above. A + // directory lock's release comes from the guard, which a SIGKILLed holder + // never runs; reclaim is what keeps that a delay of one ask instead of the + // whole queue. A refusal here means it regressed. + let dir = repo("exec-lock-dead-holder"); + let lock = lock_dir(&dir, "k"); + std::fs::create_dir_all(&lock).expect("stage an abandoned lock"); + std::fs::write(lock.join("pid"), format!("{}\n", dead_pid())).expect("a corpse"); + + let output = common::run( + &dir, + &["exec", "--lock", "k", "--lock-attempts", "2", "--", "true"], + ); + assert_eq!( + output.status.code(), + Some(0), + "a dead holder was waited out rather than reclaimed: {}", + common::stderr(&output) + ); +} + +#[test] +fn a_live_holder_is_refused_and_the_caller_names_the_wait() { + // Two properties in one case because they share a setup and neither is + // meaningful without the other: the refusal happens, and it points at the + // concept rather than at a directory. "the toolchain lock (some-triple)" is + // a pointer a reader can reason about; a bare key is a pointer to a path. + let dir = repo("exec-lock-live-holder"); + let lock = lock_dir(&dir, "k"); + std::fs::create_dir_all(&lock).expect("stage a live holder"); + std::fs::write(lock.join("pid"), format!("{}\n", std::process::id())).expect("a live pid"); + + let output = common::run( + &dir, + &[ + "exec", + "--lock", + "k", + "--lock-attempts", + "1", + "--lock-label", + "the toolchain lock (some-triple)", + "--", + "true", + ], + ); + assert_eq!(output.status.code(), Some(2)); + assert!( + common::stderr(&output).contains("the toolchain lock (some-triple)"), + "the refusal must name what the wait was for: {}", + common::stderr(&output) + ); +} + +#[test] +fn an_unlocked_run_is_untouched() { + // Anti-vacuity: every case above asserts something about `--lock`, and a + // build in which the flag did nothing at all would still pass several of + // them. This pins that the lock is opt-in and takes nothing when unasked. + let dir = repo("exec-lock-absent"); + let output = common::run(&dir, &["exec", "--", "true"]); + assert_eq!(output.status.code(), Some(0), "{}", common::stderr(&output)); + assert!( + !dir.join(".git").join("batten-singleton").exists(), + "an unlocked run took a lock" + ); +} + +#[test] +fn a_malformed_attempt_count_is_named_rather_than_defaulted() { + // `--jobs`' reading, and for `--jobs`' reason: reading a bad value as the + // default would queue for a length nobody asked for, and the refusal has to + // say which value was wrong. + let dir = repo("exec-lock-bad-attempts"); + let output = common::run( + &dir, + &[ + "exec", + "--lock", + "k", + "--lock-attempts", + "nought", + "--", + "true", + ], + ); + assert_eq!(output.status.code(), Some(1), "{}", common::stderr(&output)); + assert!( + common::stderr(&output).contains("nought"), + "the refusal must name the value: {}", + common::stderr(&output) + ); +} diff --git a/crates/batten/tests/it/land.rs b/crates/batten/tests/it/land.rs index aad930787..aae7d934c 100644 --- a/crates/batten/tests/it/land.rs +++ b/crates/batten/tests/it/land.rs @@ -496,3 +496,72 @@ fn no_pull_request_to_ask_is_could_not_look_and_never_a_refusal() { "exit 2 would claim the bot refused this head, which nothing established" ); } + +// --------------------------------------------------------------------------- +// The stop names a route that exists (CLOUD-1586's mechanism, CLOUD-1050's rule) +// --------------------------------------------------------------------------- + +/// The conflict refusal and the gate that denies the hand rebase agree, and both +/// name a route this engine actually has. +/// +/// **Measured on this branch rather than imagined.** The verb's conflict line +/// reported the commit and the FIRST path and stopped there; the +/// `rebase-not-hand-stepped` row then told the reader the way out was +/// `--continue`, `--abort` or `--skip`. The replay is STATELESS — nothing is +/// half-replayed, so no rebase is ever in progress — and none of those three can +/// apply to it. A session followed both, concluded the landing loop was +/// defective, and was one step from cherry-picking around it, which completes +/// the replay while writing no lap record at all: `rebase-conflict-stops-the-lap` +/// would then read clean over a conflict that happened, which is the false green +/// this whole family exists to catch. +/// +/// Two authorities for one route is the drift, so this asserts they AGREE rather +/// than checking either alone. Text over the committed files, because that is +/// what a reader meets: driving the verb to a conflict needs a live remote, and +/// `crates/batten/tests/it/rebase.rs` already owns the mechanism end to end. +#[test] +fn the_conflict_stop_and_its_gate_name_a_route_that_exists() { + let verb = std::fs::read_to_string(common::at_root("crates/batten/src/lib.rs")) + .expect("the boundary is readable"); + let after = verb + .split_once("land::Replay::Conflicted") + .expect("the conflict arm is where the message lives") + .1; + let arm = &after[..after.find("land::Replay::Current").unwrap_or(after.len())]; + + assert!( + arm.contains("--resolve"), + "the conflict refusal must name the route it has, or a reader reaches for one it does not" + ); + assert!( + arm.contains("="), + "and the per-conflict spelling, because a path conflicting twice is the common case and is \ + unguessable from a bare --resolve" + ); + // NAMING `--continue` IS RIGHT HERE, and the first draft of this case + // asserted its absence — which would have refused the very sentence that + // stops a reader reaching for it. What matters is that the text says WHY it + // cannot apply, so the reader stops looking rather than concluding the loop + // is broken. + assert!( + arm.contains("STATELESS"), + "the refusal must say why there is nothing to continue, or its absence reads as a defect" + ); + + let config = std::fs::read_to_string(common::at_root("batten.toml")) + .expect("the committed authority is readable"); + let row = config + .split_once("id = \"rebase-not-hand-stepped\"") + .expect("the row is declared") + .1; + let reason = &row[..row.find("\n\n").unwrap_or(row.len())]; + + assert!( + reason.contains("--resolve"), + "the gate that denies the hand rebase must name the route that replaces it" + ); + assert!( + reason.contains("STATELESS"), + "and must say why the rebase-in-progress exits cannot apply, rather than offering them" + ); +} diff --git a/crates/batten/tests/it/license_table.rs b/crates/batten/tests/it/license_table.rs new file mode 100644 index 000000000..27f48666b --- /dev/null +++ b/crates/batten/tests/it/license_table.rs @@ -0,0 +1,157 @@ +//! Every adopted tool's license row is resolved, over the compiled binary +//! (ported from `mise-tasks/license-table-check.sh` under CLOUD-843). +//! +//! **What is decidable only here.** `policy/license-table.rego` carries +//! load-time cases pinning the parse and the closed set, and each fabricates +//! `input.tree.lines["CONTRIBUTING.md"]`. Only a real repository shows the +//! engine resolving that declaration — which is the anti-vacuity half twice +//! over, since the gate's own subject is "did the table parse to any rows at +//! all". +// +// carried: mise-tasks/license-table-check.sh policy/license-table.rego crates/batten/tests/it/license_table.rs +// carried: tests/license-table-check.bats policy/license-table.rego crates/batten/tests/it/license_table.rs +// +// carried: "the repo as it stands passes" policy/license-table.rego +// carried: "an unresolved license fails, and names the tool" policy/license-table.rego +// carried: "a resolved license with an unresolved verdict still fails" policy/license-table.rego +// carried: "a verdict outside the closed set fails rather than passing" policy/license-table.rego +// carried: "a fully resolved fixture passes" policy/license-table.rego +// carried: "an explicit incompatible verdict is resolved, and passes" policy/license-table.rego +// carried: "a table with no rows is a failure, not a vacuous pass" policy/license-table.rego +// carried: "output is a pointer — it names the tool and the cell, never the table body" policy/license-table.rego +// +// changed: "the gate is wired: hk.pkl declares a step that runs this task" policy/license-table.rego the case asserted that a `mise run` step existed in `hk.pkl`, which is how a shell gate reached the hook at all. A rule row has no step of its own — it is reached through `batten-check`, whose glob is itself gated by `batten-glob-check` and whose `line_sources` declaration is what `batten check --rule license-table` resolves. The wiring is asserted by a different mechanism rather than left unasserted +// changed: "an unreadable file is exit 1 — could not look is not a verdict" policy/license-table.rego the shell took the document as a positional ARGUMENT and could be pointed at an unreadable path. The successor's subject is a declared `line_sources` entry: a glob matching nothing means the rule is not evaluated, and `input.tree.missing` is never populated on the tree surface (CLOUD-1049). There is no caller left that can aim it, so the case has no subject rather than no coverage + +// Panicking on setup failure is the idiomatic way for a test to fail loudly. +#![allow(clippy::unwrap_used, clippy::expect_used)] + +use crate::common; + +use std::path::{Path, PathBuf}; +use std::process::Output; + +use common::{Fixture, git_in, run, stdout}; + +fn table_repo(name: &str, doc: &str) -> PathBuf { + let dir = Fixture::new(name) + .config( + "version = 1\n\n\ + [[verdict]]\n\ + id = \"tool declare missing\"\n\ + gloss = \"an adopted tool's declaration is unresolved\"\n\ + class = \"A row still asking the question cannot be shipped against; read the upstream LICENSE and record the SPDX id.\"\n\n\ + [[verdict.route]]\n\ + id = \"prose read first\"\n\ + kind = \"document\"\n\ + target = \"AGENTS.md\"\n\n\ + [[rule]]\n\ + id = \"license-table\"\n\ + kind = \"policy\"\n\ + scope = \"tree\"\n\ + line_sources = [\"CONTRIBUTING.md\"]\n\ + module = \"policy/license-table.rego\"\n\ + severity = \"deny\"\n", + ) + .file("AGENTS.md", "the consumer's own authority\n") + .file("CONTRIBUTING.md", doc) + .git() + .build(); + common::write( + &dir, + "policy/license-table.rego", + &std::fs::read_to_string(common::at_root("policy/license-table.rego")).unwrap(), + ); + git_in(&dir, &["add", "-A"]); + git_in(&dir, &["commit", "-q", "-m", "base"]); + dir +} + +fn check(dir: &Path) -> Output { + run(dir, &["check", "--rule", "license-table"]) +} + +const HEAD: &str = "| Tool | Use | License | Apache-2.0 |\n| --- | --- | --- | --- |\n"; + +#[test] +fn a_fully_resolved_table_passes() { + let dir = table_repo( + "license-resolved", + &format!("{HEAD}| hk | hooks | MIT | ✅ |\n"), + ); + let output = check(&dir); + assert_eq!(output.status.code(), Some(0), "{}", stdout(&output)); +} + +#[test] +fn an_unresolved_license_fails_and_names_the_tool() { + let dir = table_repo( + "license-unresolved", + &format!("{HEAD}| hk | hooks | _to confirm_ | ✅ |\n"), + ); + let output = check(&dir); + assert_eq!(output.status.code(), Some(2), "{}", stdout(&output)); + assert!( + stdout(&output).contains("CONTRIBUTING.md"), + "the finding points at the table: {}", + stdout(&output) + ); +} + +#[test] +fn a_verdict_outside_the_closed_set_fails_over_the_binary() { + // DISCRIMINATING. A check that only looked for the literal placeholder + // passes this, which is exactly how an unresolved row slips through. + let dir = table_repo( + "license-open-set", + &format!("{HEAD}| hk | hooks | MIT | probably |\n"), + ); + let output = check(&dir); + assert_eq!( + output.status.code(), + Some(2), + "an unrecognised glyph read as resolved: {}", + stdout(&output) + ); +} + +#[test] +fn a_table_with_no_rows_is_a_failure_not_a_vacuous_pass() { + // The reason the predecessor existed: a renamed heading or a reformatted + // table satisfies every per-row assertion by having no rows to assert over. + let dir = table_repo("license-no-rows", "# Contributing\n\nno table here\n"); + let output = check(&dir); + assert_eq!( + output.status.code(), + Some(2), + "an empty parse read as all rows resolved: {}", + stdout(&output) + ); +} + +#[test] +fn output_is_a_pointer_never_the_table_body() { + let dir = table_repo( + "license-pointer-only", + &format!("{HEAD}| distinctive-tool | a distinctive use | _to confirm_ | ✅ |\n"), + ); + let output = check(&dir); + assert_eq!(output.status.code(), Some(2)); + assert!( + !stdout(&output).contains("a distinctive use"), + "the table body is payload: {}", + stdout(&output) + ); +} + +#[test] +fn the_repo_as_it_stands_passes() { + let output = + common::run_at_real_root(&common::at_root(""), &["check", "--rule", "license-table"]); + assert_eq!( + output.status.code(), + Some(0), + "an adopted tool's license row is unresolved: {}", + stdout(&output) + ); +} diff --git a/crates/batten/tests/it/main.rs b/crates/batten/tests/it/main.rs index 70c2072bb..a417bba34 100644 --- a/crates/batten/tests/it/main.rs +++ b/crates/batten/tests/it/main.rs @@ -53,6 +53,7 @@ mod ask_disposition; mod attribution; mod attribution_provenance; mod authority_replay; +mod awk_regex; mod baseline; mod bats_invocation; mod board_receipts; @@ -116,6 +117,7 @@ mod done_not_landed; mod egress_fencing; mod emission_census; mod enforce_journal; +mod exec_lock; mod extension_surfaces; mod external_facts; mod extracted_facts; @@ -165,6 +167,7 @@ mod lease_health; mod lease_lifecycle; mod lease_precondition; mod lease_record; +mod license_table; mod locator_index; mod lock_complete; mod mcp_dispatch; @@ -176,16 +179,19 @@ mod memory_injection; mod minted_facts; mod mise_pin_agreement; mod mise_preset; +mod module_map; mod mutate; mod mutation_declared_case; mod narrow_adoption; mod nextest_slow; +mod no_doctests; mod obligations_bound; mod outcome_advice; mod perf_assert; mod perf_compare; mod perf_pair; mod pinned_programs; +mod pipefail_grep; mod pipeline_shapes; mod plan_complete; mod pointer_only; @@ -223,6 +229,7 @@ mod release_provision_parity; mod remedy_authorship; mod repaired_arms; mod repetition; +mod report_only; mod retirement_doctrine; mod review_answered; mod review_dispatched; @@ -282,5 +289,6 @@ mod waivers; mod walker; mod wiring_disarm; mod wiring_reclaim; +mod workflow_shell_census; mod worktree_registration; mod zero_config; diff --git a/crates/batten/tests/it/module_map.rs b/crates/batten/tests/it/module_map.rs new file mode 100644 index 000000000..3aa465632 --- /dev/null +++ b/crates/batten/tests/it/module_map.rs @@ -0,0 +1,207 @@ +//! Every crate source module has a `mem:core` row, over the compiled binary +//! (CLOUD-194, ported from `mise-tasks/module-map-check.sh` under CLOUD-843). +//! +//! **What is decidable only here.** `policy/module-map.rego` carries load-time +//! cases pinning the predicate, and every one of them supplies +//! `input.tree.tracked` with `with input as`. That fabricates the very shape the +//! engine may be unable to produce — and here it fabricates the exact +//! distinction the gate turns on, TRACKED versus merely present, which is what +//! separates "a module landed without its row" from "somebody has a draft open". +//! A module whose suite only fabricated the set would stay green over an engine +//! that resolved the working tree instead of the index. +//! +//! The self-consumption case runs over this repository, which is what the +//! retiring suite's last case did and what makes the gate's own claim about +//! `mem:core` checkable rather than asserted. +// +// carried: mise-tasks/module-map-check.sh policy/module-map.rego crates/batten/tests/it/module_map.rs +// carried: tests/module-map-check.bats policy/module-map.rego crates/batten/tests/it/module_map.rs +// +// carried: "a module with a map row exits 0" policy/module-map.rego +// carried: "a module with no map row is reported with a pointer" policy/module-map.rego +// carried: "output is pointer-only — no map or source prose echoed" policy/module-map.rego +// changed: "an untracked module is not yet the map's problem" policy/module-map.rego the shell asked the INDEX via `git ls-files`; `input.tree.tracked` is a working-tree walk that explicitly is not the index, and nothing available to a module expresses index membership for a glob — `input.tree.staged` parses each declared path by format and no format owns `.rs`, and `git-status.changed` conflates untracked with modified. The successor is stricter in the fail-closed direction and `an_uncommitted_module_is_judged_too_where_the_shell_left_it_alone` pins the difference rather than leaving it to be discovered +// carried: "a bare filename mention does not satisfy the row" policy/module-map.rego +// changed: "a missing map is reported once, not once per module" policy/module-map.rego the clause is carried and is correct, and it cannot fire: a rule whose declared `line_sources` match nothing is not evaluated, and `input.tree.missing` is never populated on the tree surface (CLOUD-1049, measured identically for `policy/mise-pin-agreement.rego`'s own could-not-look clause). A case asserting it would assert the engine gap rather than the predicate, so the arm ships without one until the fact does +// carried: "every module of this repo has a row — the gate on the real tree" policy/module-map.rego + +// Panicking on setup failure is the idiomatic way for a test to fail loudly. +#![allow(clippy::unwrap_used, clippy::expect_used)] + +use crate::common; + +use std::path::{Path, PathBuf}; +use std::process::Output; + +use common::{Fixture, git_in, run, stderr, stdout}; + +/// A repository declaring only this rule, so any finding is the one under test. +fn map_repo(name: &str, map: Option<&str>, modules: &[(&str, &str)]) -> PathBuf { + // The two `[[verdict]]` rows are carried into the fixture rather than + // assumed: a verdict is emittable only where a row declares it, and the + // built-in registry is not this consumer's vocabulary. Without them the + // module loads and the run is a USAGE error, which is exit 1 and not the + // exit 2 these cases are about — so a fixture that omitted them would test + // the declaration rather than the predicate. + let mut fixture = Fixture::new(name).config( + "version = 1\n\n\ + [[verdict]]\n\ + id = \"memory resolve missing\"\n\ + gloss = \"the memory graph has no root\"\n\ + class = \"The map is the discovery entry point; absent, it is reachable only by listing the directory.\"\n\n\ + [[verdict.route]]\n\ + id = \"prose read first\"\n\ + kind = \"document\"\n\ + target = \"AGENTS.md\"\n\n\ + [[verdict]]\n\ + id = \"module place missing\"\n\ + gloss = \"a module is absent from its table\"\n\ + class = \"An unmapped module is not one with no constraints, it is one whose constraints nobody wrote down.\"\n\n\ + [[verdict.route]]\n\ + id = \"prose read first\"\n\ + kind = \"document\"\n\ + target = \"AGENTS.md\"\n\n\ + [[rule]]\n\ + id = \"module-map\"\n\ + kind = \"policy\"\n\ + scope = \"tree\"\n\ + line_sources = [\".serena/memories/core.md\"]\n\ + module = \"policy/module-map.rego\"\n\ + severity = \"deny\"\n", + ); + fixture = fixture.file("AGENTS.md", "the consumer's own authority\n"); + if let Some(text) = map { + fixture = fixture.file(".serena/memories/core.md", text); + } + for (path, body) in modules { + fixture = fixture.file(path, body); + } + let dir = fixture.git().build(); + // The module is copied in rather than referenced: the fixture is its own + // repository, and a rule row naming a path outside it would not resolve. + common::write( + &dir, + "policy/module-map.rego", + &std::fs::read_to_string(common::at_root("policy/module-map.rego")).unwrap(), + ); + git_in(&dir, &["add", "-A"]); + git_in(&dir, &["commit", "-q", "-m", "base"]); + dir +} + +fn check(dir: &Path) -> Output { + run(dir, &["check", "--rule", "module-map"]) +} + +const ROW: &str = "- `main.rs` — the binary boundary.\n"; + +#[test] +fn a_module_with_a_map_row_is_clean() { + let dir = map_repo( + "module-map-clean", + Some(ROW), + &[("crates/demo/src/main.rs", "fn main() {}\n")], + ); + let output = check(&dir); + assert_eq!( + output.status.code(), + Some(0), + "out={} err={}", + stdout(&output), + stderr(&output) + ); +} + +#[test] +fn a_module_with_no_map_row_is_refused_with_a_pointer() { + let dir = map_repo( + "module-map-absent-row", + Some(ROW), + &[ + ("crates/demo/src/main.rs", "fn main() {}\n"), + ("crates/demo/src/severity.rs", "pub fn f() {}\n"), + ], + ); + let output = check(&dir); + assert_eq!(output.status.code(), Some(2), "{}", stdout(&output)); + let text = stdout(&output); + assert!( + text.contains("crates/demo/src/severity.rs"), + "the finding points at the unmapped module: {text:?}" + ); +} + +#[test] +fn an_uncommitted_module_is_judged_too_where_the_shell_left_it_alone() { + // THE ONE BEHAVIOUR THIS PORT CHANGES, asserted rather than left to be + // discovered. `git ls-files` asked the INDEX; `input.tree.tracked` is a + // working-tree walk and explicitly is not the index, and nothing available + // to a module expresses index membership for a glob. So a module written + // but not yet committed is judged here, where the retiring suite pinned + // "an untracked module is not yet the map's problem". + // + // The direction is fail-closed and the cost is real: a contributor drafting + // a module is asked for its row before they commit it. This case exists so + // that the change is a decision CLOUD-1716 can keep or reverse, rather than + // a silent difference nobody measured. + let dir = map_repo( + "module-map-uncommitted", + Some(ROW), + &[("crates/demo/src/main.rs", "fn main() {}\n")], + ); + common::write(&dir, "crates/demo/src/draft.rs", "pub fn f() {}\n"); + + let output = check(&dir); + assert_eq!( + output.status.code(), + Some(2), + "the successor judges the checkout: {}", + stdout(&output) + ); +} + +#[test] +fn a_bare_mention_does_not_satisfy_the_row() { + // The map names modules in backticks. A sentence ABOUT a module must not + // read as a row, or the gate passes on the very drift it exists to catch. + let dir = map_repo( + "module-map-bare-mention", + Some("Note: severity.rs is described in another memory.\n"), + &[("crates/demo/src/severity.rs", "pub fn f() {}\n")], + ); + let output = check(&dir); + assert_eq!(output.status.code(), Some(2), "{}", stdout(&output)); +} + +#[test] +fn output_is_pointer_only() { + let dir = map_repo( + "module-map-pointer-only", + Some("- `main.rs` — the binary boundary, a distinctive phrase.\n"), + &[("crates/demo/src/hidden.rs", "pub fn secret_helper() {}\n")], + ); + let output = check(&dir); + assert_eq!(output.status.code(), Some(2)); + let text = stdout(&output); + assert!( + !text.contains("secret_helper"), + "the module's source is payload: {text:?}" + ); + assert!( + !text.contains("distinctive phrase"), + "and so is the map's prose: {text:?}" + ); +} + +#[test] +fn the_repositorys_own_map_is_complete() { + // The self-consumption case the retiring suite ended on: the claim + // `rules/rust.md` makes about `mem:core` is checkable rather than asserted. + let output = common::run_at_real_root(&common::at_root(""), &["check", "--rule", "module-map"]); + assert_eq!( + output.status.code(), + Some(0), + "this repository's own module map is incomplete: {}", + stdout(&output) + ); +} diff --git a/crates/batten/tests/it/no_doctests.rs b/crates/batten/tests/it/no_doctests.rs new file mode 100644 index 000000000..fb459980a --- /dev/null +++ b/crates/batten/tests/it/no_doctests.rs @@ -0,0 +1,166 @@ +//! No runnable doctest exists, over the compiled binary (CLOUD-813, ported from +//! `mise-tasks/no-doctests.sh` under CLOUD-843). +//! +//! **What is decidable only here.** `policy/no-doctests.rego` carries load-time +//! cases pinning the parity rule and the attribute list, and every one supplies +//! `input.tree.lines` with `with input as`. That fabricates the scan's SUBJECT — +//! which is precisely what the shell spent an explicit anti-vacuity arm on, and +//! what CLOUD-418 names: a scan that matched no file at all reports "no runnable +//! doctest" over nothing, and reads as coverage. Only a real repository can show +//! the engine actually handing this module a workspace. +//! +//! The self-consumption case is the one that made the runner swap safe in the +//! first place: the class is empty on this workspace, and it is asserted here +//! rather than assumed. +// +// carried: mise-tasks/no-doctests.sh policy/no-doctests.rego crates/batten/tests/it/no_doctests.rs +// carried: tests/no-doctests.bats policy/no-doctests.rego crates/batten/tests/it/no_doctests.rs +// +// carried: "the committed workspace carries no runnable doctest" policy/no-doctests.rego +// carried: "an unattributed fence in a doc comment is refused" policy/no-doctests.rego +// carried: "the refusal is a pointer, never the example" policy/no-doctests.rego +// carried: "a text fence is not a doctest" policy/no-doctests.rego +// carried: "ignore, compile_fail and no_run are all non-running" policy/no-doctests.rego +// carried: "a closing fence is not read as an unattributed opening one" policy/no-doctests.rego +// carried: "a fence outside a doc comment is not a doctest" policy/no-doctests.rego +// +// THE TWO COULD-NOT-LOOK CASES, which the successor answers structurally rather +// than by an arm of its own. +// +// changed: "a root with no tracked .rs file is could-not-look, not clean" policy/no-doctests.rego the shell took its scan root as an ARGUMENT and so could be pointed at an empty one, which is why it needed the arm. The successor's subject is a declared `line_sources` glob rather than argv: a glob matching nothing means the rule is not evaluated, and the declaration itself is gated by `batten-glob-check`. There is no caller left that can aim it at an empty root, so the case has no subject rather than no coverage +// changed: "a missing root is could-not-look, not clean" policy/no-doctests.rego same reason, one step further: a root that does not exist is not expressible when the root is a committed glob rather than a positional argument + +// Panicking on setup failure is the idiomatic way for a test to fail loudly. +#![allow(clippy::unwrap_used, clippy::expect_used)] + +use crate::common; + +use std::path::{Path, PathBuf}; +use std::process::Output; + +use common::{Fixture, git_in, run, stdout}; + +/// A workspace declaring only this rule, so any finding is the one under test. +fn doctest_repo(name: &str, source: &str) -> PathBuf { + let dir = Fixture::new(name) + .config( + "version = 1\n\n\ + [[verdict]]\n\ + id = \"test state early\"\n\ + gloss = \"a test exists that nothing runs\"\n\ + class = \"An example nothing executes is dead code a reader trusts for being executable.\"\n\n\ + [[verdict.route]]\n\ + id = \"prose read first\"\n\ + kind = \"document\"\n\ + target = \"AGENTS.md\"\n\n\ + [[rule]]\n\ + id = \"no-doctests\"\n\ + kind = \"policy\"\n\ + scope = \"tree\"\n\ + line_sources = [\"crates/**/*.rs\"]\n\ + module = \"policy/no-doctests.rego\"\n\ + severity = \"deny\"\n", + ) + .file("AGENTS.md", "the consumer's own authority\n") + .file("crates/demo/src/lib.rs", source) + .git() + .build(); + common::write( + &dir, + "policy/no-doctests.rego", + &std::fs::read_to_string(common::at_root("policy/no-doctests.rego")).unwrap(), + ); + git_in(&dir, &["add", "-A"]); + git_in(&dir, &["commit", "-q", "-m", "base"]); + dir +} + +fn check(dir: &Path) -> Output { + run(dir, &["check", "--rule", "no-doctests"]) +} + +#[test] +fn an_unattributed_fence_is_refused_over_the_binary() { + let dir = doctest_repo( + "doctests-unattributed", + "/// ```\n/// let x = 1;\n/// ```\npub fn f() {}\n", + ); + let output = check(&dir); + assert_eq!(output.status.code(), Some(2), "{}", stdout(&output)); + assert!( + stdout(&output).contains("crates/demo/src/lib.rs"), + "the finding points at the fence's file: {}", + stdout(&output) + ); +} + +#[test] +fn a_text_fence_is_not_a_doctest_over_the_binary() { + let dir = doctest_repo( + "doctests-text-fence", + "/// ```text\n/// not rust\n/// ```\npub fn f() {}\n", + ); + let output = check(&dir); + assert_eq!(output.status.code(), Some(0), "{}", stdout(&output)); +} + +#[test] +fn a_closing_fence_is_not_read_as_an_unattributed_opener() { + // DISCRIMINATING. A closing fence carries no info string, so a scanner + // without the parity rule counts it as a second unattributed opener and + // refuses a file that is correctly marked. The shell toggled a flag; the + // successor counts parity, and this is the case that tells them from a + // scanner that does neither. + let dir = doctest_repo( + "doctests-closing-fence", + "/// ```text\n/// safe\n/// ```\npub fn f() {}\n", + ); + let output = check(&dir); + assert_eq!( + output.status.code(), + Some(0), + "the closing fence was read as an opener: {}", + stdout(&output) + ); +} + +#[test] +fn the_refusal_is_a_pointer_never_the_example() { + let dir = doctest_repo( + "doctests-pointer-only", + "/// ```\n/// let secret_example = 1;\n/// ```\npub fn f() {}\n", + ); + let output = check(&dir); + assert_eq!(output.status.code(), Some(2)); + assert!( + !stdout(&output).contains("secret_example"), + "the example is payload and never appears: {}", + stdout(&output) + ); +} + +#[test] +fn a_fence_outside_a_doc_comment_is_not_a_doctest() { + let dir = doctest_repo( + "doctests-plain-comment", + "// ```\n// let x = 1;\n// ```\npub fn f() {}\n", + ); + let output = check(&dir); + assert_eq!(output.status.code(), Some(0), "{}", stdout(&output)); +} + +#[test] +fn the_committed_workspace_carries_no_runnable_doctest() { + // The self-consumption case, and the measurement the runner swap rested on: + // `cargo nextest run` executes no doctest, so an example here would be run + // nowhere. Asserted rather than assumed, because an empty class is not a + // stable property. + let output = + common::run_at_real_root(&common::at_root(""), &["check", "--rule", "no-doctests"]); + assert_eq!( + output.status.code(), + Some(0), + "a runnable doctest landed, and nextest runs none of them: {}", + stdout(&output) + ); +} diff --git a/crates/batten/tests/it/pipefail_grep.rs b/crates/batten/tests/it/pipefail_grep.rs new file mode 100644 index 000000000..0b29fd98d --- /dev/null +++ b/crates/batten/tests/it/pipefail_grep.rs @@ -0,0 +1,181 @@ +//! No producer is piped into an early-exiting grep under pipefail, over the +//! compiled binary (ported from `mise-tasks/pipefail-grep-check.sh` under +//! CLOUD-843). +//! +//! **What is decidable only here.** `policy/pipefail-grep.rego` carries +//! load-time cases pinning the flag reading and the `||` distinction, and each +//! fabricates `input.tree.lines`. Only a real repository shows the engine +//! handing this module the shell corpus — the anti-vacuity half, since a scan +//! that matched no file reports "no producer is piped into an early-exiting +//! grep" over nothing and reads as coverage (CLOUD-418). +// +// carried: mise-tasks/pipefail-grep-check.sh policy/pipefail-grep.rego crates/batten/tests/it/pipefail_grep.rs +// carried: tests/pipefail-grep-check.bats policy/pipefail-grep.rego crates/batten/tests/it/pipefail_grep.rs +// +// carried: "the exact shape that broke issue-guard is flagged" policy/pipefail-grep.rego +// carried: "the here-string fix passes" policy/pipefail-grep.rego +// carried: "a flag cluster is judged by its letters, not its spelling" policy/pipefail-grep.rego +// carried: "--quiet is the same hazard under its long name" policy/pipefail-grep.rego +// carried: "-l stops at the first matching file, so it is flagged too" policy/pipefail-grep.rego +// carried: "-m N stops after N matches" policy/pipefail-grep.rego +// carried: "a grep that consumes its whole input is not the hazard" policy/pipefail-grep.rego +// carried: "an || before grep is not a pipe" policy/pipefail-grep.rego +// carried: "a real pipe into an early-exiting grep is still caught alongside an ||" policy/pipefail-grep.rego +// carried: "a file that does not enable pipefail is out of scope" policy/pipefail-grep.rego +// carried: "a comment describing the hazard is not the hazard" policy/pipefail-grep.rego +// carried: "-q after -- is a pattern, not a flag" policy/pipefail-grep.rego +// carried: "output is a pointer — file:line and the fix, never the matched content" policy/pipefail-grep.rego +// +// changed: "an untracked file is not judged — the gate reads committed bytes" policy/pipefail-grep.rego the shell fed itself `git ls-files`, so the index was its subject. `input.tree.lines` resolves from a declared glob over the working-tree walk, which honours `.gitignore` and explicitly is not the index, and nothing available to a module expresses index membership for a glob. The successor judges an uncommitted program too — stricter, fail-closed, and the same difference `policy/module-map.rego` and `policy/awk-regex.rego` record for their own ports + +// Panicking on setup failure is the idiomatic way for a test to fail loudly. +#![allow(clippy::unwrap_used, clippy::expect_used)] + +use crate::common; + +use std::path::{Path, PathBuf}; +use std::process::Output; + +use common::{Fixture, git_in, run, stdout}; + +fn pipefail_repo(name: &str, program: &str) -> PathBuf { + let dir = Fixture::new(name) + .config( + "version = 1\n\n\ + [[pattern]]\n\ + id = \"shell-enables-pipefail\"\n\ + regex = '^[[:space:]]*set[[:space:]]+-[a-z]*o?[a-z]*[[:space:]]*.*pipefail'\n\n\ + [[pattern]]\n\ + id = \"pipe-into-grep\"\n\ + regex = '(^|[^|])\\|[[:space:]]*grep([[:space:]]|$)'\n\n\ + [[verdict]]\n\ + id = \"call run loose\"\n\ + gloss = \"a command is spelled in a way whose behaviour is not defined\"\n\ + class = \"An early-exiting grep under pipefail promotes SIGPIPE to the pipeline's status, so a MATCH reports failure.\"\n\n\ + [[verdict.route]]\n\ + id = \"prose read first\"\n\ + kind = \"document\"\n\ + target = \"AGENTS.md\"\n\n\ + [[rule]]\n\ + id = \"pipefail-grep\"\n\ + kind = \"policy\"\n\ + scope = \"tree\"\n\ + line_sources = [\"mise-tasks/**\"]\n\ + module = \"policy/pipefail-grep.rego\"\n\ + severity = \"deny\"\n", + ) + .file("AGENTS.md", "the consumer's own authority\n") + .file("mise-tasks/demo.sh", program) + .git() + .build(); + common::write( + &dir, + "policy/pipefail-grep.rego", + &std::fs::read_to_string(common::at_root("policy/pipefail-grep.rego")).unwrap(), + ); + git_in(&dir, &["add", "-A"]); + git_in(&dir, &["commit", "-q", "-m", "base"]); + dir +} + +fn check(dir: &Path) -> Output { + run(dir, &["check", "--rule", "pipefail-grep"]) +} + +const HEAD: &str = "#!/usr/bin/env bash\nset -euo pipefail\n"; + +#[test] +fn the_shape_that_broke_issue_guard_is_flagged_over_the_binary() { + let dir = pipefail_repo( + "pipefail-issue-guard", + &format!("{HEAD}git log --format=%B main | grep -q \"$id\"\n"), + ); + let output = check(&dir); + assert_eq!(output.status.code(), Some(2), "{}", stdout(&output)); + let text = stdout(&output); + assert!( + text.contains("mise-tasks/demo.sh"), + "the finding points at the line: {text:?}" + ); + assert!( + !text.contains("git log"), + "the matched content is payload and never appears: {text:?}" + ); +} + +#[test] +fn the_here_string_fix_passes() { + let dir = pipefail_repo( + "pipefail-here-string", + &format!("{HEAD}x=$(git log)\ngrep -q \"$id\" <<<\"$x\"\n"), + ); + assert_eq!(check(&dir).status.code(), Some(0)); +} + +#[test] +fn a_flag_cluster_is_judged_by_its_letters_over_the_binary() { + // `-qxF` is the same hazard as `-q`. The predecessor's own comment says the + // enumeration of exact spellings is what would rot. + let dir = pipefail_repo( + "pipefail-cluster", + &format!("{HEAD}producer | grep -qxF thing\n"), + ); + assert_eq!(check(&dir).status.code(), Some(2)); +} + +#[test] +fn an_or_before_grep_is_not_a_pipe() { + // DISCRIMINATING, and the case the predecessor's own scan failed: it matched + // the SECOND bar of `||` and reported a here-string — the remedy this gate + // recommends — as the defect. Measured on `ready-lint.sh` (CLOUD-852). + let dir = pipefail_repo( + "pipefail-or", + &format!("{HEAD}[[ -n \"$x\" ]] || grep -qE 'p' <<<\"$var\"\n"), + ); + let output = check(&dir); + assert_eq!( + output.status.code(), + Some(0), + "the second bar of `||` was read as a pipe: {}", + stdout(&output) + ); +} + +#[test] +fn a_grep_that_consumes_its_whole_input_is_not_the_hazard() { + let dir = pipefail_repo( + "pipefail-full-consume", + &format!("{HEAD}producer | grep thing\n"), + ); + assert_eq!(check(&dir).status.code(), Some(0)); +} + +#[test] +fn a_file_that_does_not_enable_pipefail_is_out_of_scope() { + let dir = pipefail_repo( + "pipefail-not-enabled", + "#!/usr/bin/env bash\nproducer | grep -q thing\n", + ); + assert_eq!(check(&dir).status.code(), Some(0)); +} + +#[test] +fn q_after_the_separator_is_a_pattern_not_a_flag() { + let dir = pipefail_repo( + "pipefail-separator", + &format!("{HEAD}producer | grep -- -q\n"), + ); + assert_eq!(check(&dir).status.code(), Some(0)); +} + +#[test] +fn this_repos_own_programs_pass_today() { + let output = + common::run_at_real_root(&common::at_root(""), &["check", "--rule", "pipefail-grep"]); + assert_eq!( + output.status.code(), + Some(0), + "a producer is piped into an early-exiting grep in this tree: {}", + stdout(&output) + ); +} diff --git a/crates/batten/tests/it/report_only.rs b/crates/batten/tests/it/report_only.rs new file mode 100644 index 000000000..51ecd8aa6 --- /dev/null +++ b/crates/batten/tests/it/report_only.rs @@ -0,0 +1,199 @@ +//! A report task stays off the landing path, over the compiled binary +//! (CLOUD-582, ported from `mise-tasks/report-only-check.sh` under CLOUD-843). +//! +//! **What is decidable only here.** `policy/report-only.rego` carries load-time +//! cases pinning the predicate, and each fabricates `input.tree.documents` — so +//! it is green whether or not the engine PARSES `mise.toml` and the workflows at +//! all. The whole port turns on reading parsed structure where the predecessor +//! carved a span out of text, and a fabricated document asserts that structure +//! into existence. +// +// carried: mise-tasks/report-only-check.sh policy/report-only.rego crates/batten/tests/it/report_only.rs +// carried: tests/report-only-check.bats policy/report-only.rego crates/batten/tests/it/report_only.rs +// +// carried: "the repo's real manifest and workflows are clean today" policy/report-only.rego +// carried: "a report named in [tasks.verify] is refused" policy/report-only.rego +// carried: "a report run by a pull_request workflow is refused" policy/report-only.rego +// carried: "a report run by a SCHEDULED workflow is the point, not a violation" policy/report-only.rego +// carried: "a longer identifier merely containing the name does not fire" policy/report-only.rego +// carried: "both routes are reported together, not one at a time" policy/report-only.rego +// carried: "a manifest with no [tasks.verify] cannot be judged, and says so" policy/report-only.rego +// +// changed: "the report's output path in verify's body does fire" policy/report-only.rego the shell matched the bare NAME anywhere in verify's span, so `COVERAGE_OUT_DIR` in the body was a hit it had to word-bound away while a genuine mention still fired. The successor asks the invocation question instead — `mise run ` — because running it is what makes a report a gate, and an output path that merely names it does not. Strictly narrower and strictly more accurate; `a_longer_identifier_merely_containing_the_name_does_not_fire` is the half that survives +// changed: "a missing manifest is exit 2, never a pass" policy/report-only.rego the shell took the manifest as `$REPORT_ONLY_MANIFEST` and could be pointed at a path that does not exist. The successor's subject is a declared `sources` entry: a glob matching nothing means the rule is not evaluated, and `input.tree.missing` is never populated on the tree surface (CLOUD-1049). The neighbouring could-not-look — a manifest that parses with no `[tasks.verify]` — IS reachable and is carried above + +// Panicking on setup failure is the idiomatic way for a test to fail loudly. +#![allow(clippy::unwrap_used, clippy::expect_used)] + +use crate::common; + +use std::path::{Path, PathBuf}; +use std::process::Output; + +use common::{Fixture, git_in, run, stdout}; + +fn report_repo(name: &str, manifest: &str, workflow: Option<&str>) -> PathBuf { + let mut fixture = Fixture::new(name) + .config( + "version = 1\n\n\ + [[verdict]]\n\ + id = \"task run loose\"\n\ + gloss = \"a task runs somewhere its verdict is not wanted\"\n\ + class = \"A report emits a number for a human; binding it to landing makes a gate of a tool's opinion.\"\n\n\ + [[verdict.route]]\n\ + id = \"prose read first\"\n\ + kind = \"document\"\n\ + target = \"AGENTS.md\"\n\n\ + [[verdict]]\n\ + id = \"task declare dropped\"\n\ + gloss = \"the task a rule judges against is not declared\"\n\ + class = \"With no verify task there is nothing to judge a report against, and clean would be a false green.\"\n\n\ + [[verdict.route]]\n\ + id = \"prose read first\"\n\ + kind = \"document\"\n\ + target = \"AGENTS.md\"\n\n\ + [[rule]]\n\ + id = \"report-only\"\n\ + kind = \"policy\"\n\ + scope = \"tree\"\n\ + sources = [\"mise.toml\", \".github/workflows/*.yml\"]\n\ + module = \"policy/report-only.rego\"\n\ + severity = \"deny\"\n", + ) + .file("AGENTS.md", "the consumer's own authority\n") + .file("mise.toml", manifest); + if let Some(body) = workflow { + fixture = fixture.file(".github/workflows/report.yml", body); + } + let dir = fixture.git().build(); + common::write( + &dir, + "policy/report-only.rego", + &std::fs::read_to_string(common::at_root("policy/report-only.rego")).unwrap(), + ); + git_in(&dir, &["add", "-A"]); + git_in(&dir, &["commit", "-q", "-m", "base"]); + dir +} + +fn check(dir: &Path) -> Output { + run(dir, &["check", "--rule", "report-only"]) +} + +const CLEAN: &str = "[tasks.verify]\ndepends = [\"ci\"]\nrun = \"echo ok\"\n"; + +#[test] +fn a_clean_manifest_and_workflow_pass() { + let dir = report_repo( + "report-clean", + CLEAN, + Some("on:\n pull_request:\njobs:\n j:\n steps:\n - run: mise run ci\n"), + ); + let output = check(&dir); + assert_eq!(output.status.code(), Some(0), "{}", stdout(&output)); +} + +#[test] +fn a_report_in_verifys_depends_is_refused_over_the_binary() { + let dir = report_repo( + "report-in-depends", + "[tasks.verify]\ndepends = [\"ci\", \"coverage\"]\nrun = \"echo ok\"\n", + None, + ); + let output = check(&dir); + assert_eq!(output.status.code(), Some(2), "{}", stdout(&output)); + assert!( + stdout(&output).contains("mise.toml"), + "the finding points at the manifest: {}", + stdout(&output) + ); +} + +#[test] +fn a_report_run_by_a_pull_request_workflow_is_refused() { + let dir = report_repo( + "report-on-pr", + CLEAN, + Some("on:\n pull_request:\njobs:\n j:\n steps:\n - run: mise run scorecard\n"), + ); + let output = check(&dir); + assert_eq!(output.status.code(), Some(2), "{}", stdout(&output)); + assert!( + stdout(&output).contains(".github/workflows/report.yml"), + "and at the workflow, not the manifest: {}", + stdout(&output) + ); +} + +#[test] +fn a_report_run_by_a_scheduled_workflow_is_the_point_not_a_violation() { + // DISCRIMINATING. A gate that judged every trigger would refuse the reason + // the report exists at all. + let dir = report_repo( + "report-on-schedule", + CLEAN, + Some( + "on:\n schedule:\n - cron: \"0 0 * * 0\"\njobs:\n j:\n steps:\n - run: mise run scorecard\n", + ), + ); + let output = check(&dir); + assert_eq!( + output.status.code(), + Some(0), + "a scheduled report was refused: {}", + stdout(&output) + ); +} + +#[test] +fn a_longer_identifier_merely_containing_the_name_does_not_fire() { + // The boundary the predecessor word-bounded a grep for. A parsed `depends` + // is a list of names, so this is a different entry rather than a near miss. + let dir = report_repo( + "report-longer-identifier", + "[tasks.verify]\ndepends = [\"coverage-report-check\"]\nrun = \"echo $COVERAGE_OUT_DIR\"\n", + None, + ); + let output = check(&dir); + assert_eq!(output.status.code(), Some(0), "{}", stdout(&output)); +} + +#[test] +fn both_routes_are_reported_together_not_one_at_a_time() { + let dir = report_repo( + "report-both-routes", + "[tasks.verify]\ndepends = [\"ci\", \"coverage\"]\nrun = \"echo ok\"\n", + Some("on:\n pull_request:\njobs:\n j:\n steps:\n - run: mise run scorecard\n"), + ); + let output = check(&dir); + assert_eq!(output.status.code(), Some(2)); + let text = stdout(&output); + assert!( + text.contains("mise.toml") && text.contains(".github/workflows/report.yml"), + "a reader fixing one must see the other in the same run: {text:?}" + ); +} + +#[test] +fn a_manifest_with_no_verify_task_cannot_be_judged_and_says_so() { + let dir = report_repo("report-no-verify", "[tasks.ci]\nrun = \"echo ok\"\n", None); + let output = check(&dir); + assert_eq!( + output.status.code(), + Some(2), + "no verify task is could-not-look, never a clean board: {}", + stdout(&output) + ); +} + +#[test] +fn the_repos_real_manifest_and_workflows_are_clean_today() { + let output = + common::run_at_real_root(&common::at_root(""), &["check", "--rule", "report-only"]); + assert_eq!( + output.status.code(), + Some(0), + "a report reached the landing path in this tree: {}", + stdout(&output) + ); +} 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 d085ee2b1..4f047daed 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 @@ -1035,6 +1035,38 @@ expression: stdout_of(&output) "positional": false, "help": "How many of a `:::` bundle's commands run at once" }, + { + "name": "lock", + "short": null, + "long": "lock", + "takes_value": true, + "positional": false, + "help": "Hold this clone's named singleton lock for the child's lifetime" + }, + { + "name": "lock_attempts", + "short": null, + "long": "lock-attempts", + "takes_value": true, + "positional": false, + "help": "How many times to ask for the lock before reporting it held" + }, + { + "name": "lock_label", + "short": null, + "long": "lock-label", + "takes_value": true, + "positional": false, + "help": "What the wait is for, named by the caller for the refusal line" + }, + { + "name": "lock_path", + "short": null, + "long": "lock-path", + "takes_value": true, + "positional": false, + "help": "Hold the lock at this path, for a resource the clone does not own" + }, { "name": "style", "short": null, diff --git a/crates/batten/tests/it/workflow_shell_census.rs b/crates/batten/tests/it/workflow_shell_census.rs new file mode 100644 index 000000000..11ab4bcb1 --- /dev/null +++ b/crates/batten/tests/it/workflow_shell_census.rs @@ -0,0 +1,222 @@ +//! `.github/workflows/**` is counted, over the compiled binary (CLOUD-1709). +//! +//! **What these cases are for, and what `ratchet.rs` next door already covers.** +//! That tier proves the ratchet KIND: direction, counts, base movement, waivers, +//! byte stability. Nothing there is about this surface. What is decidable only +//! here is that the two literals this row picked actually count what a workflow +//! spells — and, more importantly, that they do NOT count what a workflow spells +//! that is not shell. +//! +//! **The anchoring is the case worth having.** `run:` unanchored also matches +//! `workflow_run:` and `check_run:`, which are trigger declarations rather than +//! steps; there are 15 of them in the tree today. A row that counted those would +//! report a rise whenever a workflow gained a trigger, and would report a fall +//! when one lost it — a census moving on something that is not shell at all. +//! `a_trigger_declaration_is_not_a_shell_step` is the discriminating case, and it +//! is the one a naive `pattern = "run:"` fails. +//! +//! The exit assertion is **2** throughout. The retiring shell corpus spells a +//! violation `1`; carrying that inversion in is the defect CLOUD-1718 names. + +// Panicking on setup failure is the idiomatic way for a test to fail loudly. +#![allow(clippy::unwrap_used, clippy::expect_used)] + +use crate::common; + +use std::path::{Path, PathBuf}; +use std::process::Output; + +use common::{Fixture, git_in, run, stdout}; + +/// The row as `batten.toml` declares it, minus the origin ref. +/// +/// `base = "main"` rather than `origin/main`: the fixtures carry no origin +/// literal (`no-origin-literal-in-fixtures`), and a local branch proves the same +/// plumbing. Both spellings are declared together because the pair is the +/// predicate — one row alone is a census with a hole in it, which is the whole +/// reason there are two. +fn census_config(admits: bool) -> String { + let permit = if admits { + "admits_with = \"# workflow-shell:\"\n" + } else { + "" + }; + format!( + "version = 1\n\n\ + [[rule]]\n\ + id = \"workflow-shell-not-growing\"\n\ + kind = \"ratchet\"\n\ + glob = \".github/workflows/**\"\n\ + pattern = \"\\n run:\"\n\ + direction = \"non_increasing\"\n\ + base = \"main\"\n\ + {permit}\ + severity = \"deny\"\n\ + scope = \"tree\"\n\n\ + [[rule]]\n\ + id = \"workflow-shell-not-growing-bare\"\n\ + kind = \"ratchet\"\n\ + glob = \".github/workflows/**\"\n\ + pattern = \"\\n - run:\"\n\ + direction = \"non_increasing\"\n\ + base = \"main\"\n\ + {permit}\ + severity = \"deny\"\n\ + scope = \"tree\"\n" + ) +} + +/// A workflow carrying one of each spelling, plus a trigger that is not a step. +/// +/// The `workflow_run:` trigger is in the BASE rather than added by a case, +/// because the property is that it is never counted — in either half of the +/// comparison. A fixture that only added one would leave the base side untested. +const BASE_WORKFLOW: &str = "\ +name: ci +on: + workflow_run: + workflows: [other] + types: [completed] +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: named step + run: echo one + - run: echo two +"; + +fn census_repo(name: &str, admits: bool) -> PathBuf { + let dir = Fixture::new(name) + .config(&census_config(admits)) + .file(".github/workflows/ci.yml", BASE_WORKFLOW) + .git() + .build(); + git_in(&dir, &["add", "-A"]); + git_in(&dir, &["commit", "-q", "-m", "base"]); + dir +} + +fn check(dir: &Path) -> Output { + run(dir, &["check"]) +} + +#[test] +fn a_tree_at_the_ceiling_passes() { + let dir = census_repo("workflow-census-held", false); + let output = check(&dir); + assert_eq!(output.status.code(), Some(0), "{}", stdout(&output)); + assert!( + output.stdout.is_empty(), + "a ratchet that held says nothing: {}", + stdout(&output) + ); +} + +#[test] +fn one_named_step_over_the_ceiling_fails() { + // The 8-space spelling: a `run:` key under a `- name:`. + let dir = census_repo("workflow-census-named-over", false); + common::write( + &dir, + ".github/workflows/ci.yml", + &format!("{BASE_WORKFLOW} - name: added\n run: echo three\n"), + ); + + let output = check(&dir); + assert_eq!(output.status.code(), Some(2), "{}", stdout(&output)); + let text = stdout(&output); + assert!( + text.contains("workflow-shell-not-growing"), + "the finding names the rule: {text:?}" + ); + assert!( + text.contains("1->2"), + "and carries both counts, so a reader sees how far it moved: {text:?}" + ); + assert!( + !text.contains("echo three"), + "pointer-only: the step's body is payload and never appears: {text:?}" + ); +} + +#[test] +fn one_bare_step_over_the_ceiling_fails_the_other_row() { + // The 6-space `- run:` spelling. A separate case rather than a second + // assertion, because the whole reason there are two rows is that one literal + // cannot see the other — so a case that only exercised the first would pass + // over a census with a live hole in it. + let dir = census_repo("workflow-census-bare-over", false); + common::write( + &dir, + ".github/workflows/ci.yml", + &format!("{BASE_WORKFLOW} - run: echo three\n"), + ); + + let output = check(&dir); + assert_eq!(output.status.code(), Some(2), "{}", stdout(&output)); + let text = stdout(&output); + assert!( + text.contains("workflow-shell-not-growing-bare"), + "the bare spelling is the OTHER row's, and it must be the one that fires: {text:?}" + ); +} + +#[test] +fn a_trigger_declaration_is_not_a_shell_step() { + // DISCRIMINATING, and the case a naive `pattern = "run:"` fails. Adding a + // `workflow_run:` trigger adds no shell, so neither row may move. Without the + // newline anchor both would count it and the census would report growth on a + // change that added no bash at all. + let dir = census_repo("workflow-census-trigger", false); + common::write( + &dir, + ".github/workflows/ci.yml", + &BASE_WORKFLOW.replace( + " types: [completed]\n", + " types: [completed]\n check_run:\n types: [created]\n", + ), + ); + + let output = check(&dir); + assert_eq!( + output.status.code(), + Some(0), + "a trigger is not shell and must not move the census: {}", + stdout(&output) + ); +} + +#[test] +fn a_declared_increase_is_admitted_and_an_undeclared_one_is_not() { + // The `admits_with` half, which is why the row carries the permit at all: a + // retirement that lands a `run: batten ` step raises this count while + // lowering real bash, and that firing is the campaign succeeding rather than + // a defect. Both halves in one case because the permit means nothing unless + // the same edit without it still fails. + let added = format!("{BASE_WORKFLOW} - name: added\n run: echo three\n"); + + let undeclared = census_repo("workflow-census-permit-absent", true); + common::write(&undeclared, ".github/workflows/ci.yml", &added); + assert_eq!( + check(&undeclared).status.code(), + Some(2), + "an undeclared increase is still refused when the column exists" + ); + + let declared = census_repo("workflow-census-permit-present", true); + common::write( + &declared, + ".github/workflows/ci.yml", + &format!( + "# workflow-shell: CLOUD-1709 the step invokes a retired program's successor\n{added}" + ), + ); + let output = check(&declared); + assert_eq!( + output.status.code(), + Some(0), + "a declared increase is owned rather than refused: {}", + stdout(&output) + ); +} diff --git a/hk.pkl b/hk.pkl index fad887a05..ba7bf4f42 100644 --- a/hk.pkl +++ b/hk.pkl @@ -145,19 +145,6 @@ local gate = new Mapping { glob = List("Cargo.toml", "Cargo.lock") check = "mise run digest-major-agreement" } - // `coverage` and `scorecard` emit a number for a human and must never gate a - // branch — a coverage threshold rewards line-touching, and scorecard scores - // deliberate design decisions (trunk-based review, the landing lease's - // `actions: write`) as defects. Both said so in a comment and nothing held - // it: the only thing keeping them off the landing path was that nobody had - // added them to it (CLOUD-582). This is the inverse of `ci-parity`'s - // "every task CI runs, verify runs" — named reports appear in NEITHER - // `[tasks.verify]` nor any `pull_request` workflow. Kept separate from that - // gate because the two fail for opposite reasons and one deny cannot say both. - ["report-only-check"] { - glob = List(".github/workflows/*.yml", "mise.toml", "mise-tasks/report-only-check.sh") - check = "mise run report-only-check" - } // Enforces the "research goes to Linear, not a repo docs/ tree" directive as // a runnable gate, not prose (see AGENTS.md): fails if any docs/ path is // tracked. Research deliverables and evidence notes attach to the Linear @@ -171,31 +158,6 @@ local gate = new Mapping { ["no-docs-tree"] { check = "mise run no-docs-tree" } - // The coverage half of the nextest swap (CLOUD-813). `test:cargo` runs - // `cargo nextest run`, which does not execute doctests; the swap was safe - // because `cargo test --doc --workspace` reported zero on this workspace, and - // that emptiness is a measurement rather than a property. This asserts it - // still holds, so a doc example cannot arrive as dead code that reads like a - // tested one. - // - // Globbed on the crate sources it scans, unlike its glob-less neighbour above: - // a fence can only appear in a tracked `.rs` file, so the input really is that - // set and a commit touching none of them cannot change the answer. - ["no-doctests"] { - glob = List("crates/**/*.rs") - check = "mise run no-doctests" - } - // The third half of the same non-negotiable. CONTRIBUTING.md's license table - // ends "Confirm each _to confirm_ entry before that tool is adopted in a - // shipped release" — a release precondition stated in prose, which nothing - // enforced while three of five rows sat unresolved (CLOUD-325). The table - // stays the authority; this only asserts that no row is still an open - // question. It cannot check that a recorded license is *correct* — that is a - // human reading an upstream LICENSE — so it proves the narrower thing an exit - // code can carry: nobody shipped while the question was open. - ["license-table-check"] { - check = "mise run license-table-check" - } // The mechanism CLOUD-847 shipped without (CLOUD-860). That row landed // `.coderabbit.yaml` after measuring what each key buys — a formal // CHANGES_REQUESTED review so a verdict exists at all, review on drafts so it @@ -247,16 +209,6 @@ local gate = new Mapping { profiles = List("slow") check = "mise run hk-drift" } - // `rules/rust.md` carries no module tree — it defers to mem:core as - // the authority kept current instead. Nothing checked that authority, so a - // module could land with no row and leave the rule pointing at an incomplete - // map; `severity.rs` (CLOUD-168) did exactly that, past a green gate. - // memories-check does not cover this: it gates the graph's *edges*, which - // stay coherent while the map is missing rows (CLOUD-194). - ["module-map-check"] { - glob = List("crates/*/src/*.rs", ".serena/memories/core.md") - check = "mise run module-map-check" - } // `rules/toolchain.md` forbids restating a value three times over and // then restated four, two of which had drifted — `LAND_MAX_LAPS` quoted as 8 // where `land` defaults to 2, and `contract-drift` described as running on an @@ -574,36 +526,6 @@ local gate = new Mapping { glob = List(".claude/settings.json") check = "mise run mcp-allow-check" } - // The same silence, one table over in the same file (CLOUD-471). The - // `PreToolUse` matcher decides which tool calls the host spawns the hook for - // AT ALL, so a `[[verb]]` naming a tool the matcher omits loads, validates and - // gates nothing — the process is never spawned, no envelope is decoded, and - // the symptom is an allow indistinguishable from a pass. `hooks-wiring-check` - // above deliberately compares only the EVENT and the COMMAND ("THE MATCHER IS - // NOT COMPARED"), because a matcher derived from the `Harness` enum would be - // the repo-agnostic core asserting a consumer's tool vocabulary; the coverage - // question therefore lands in a consumer gate, which is this one. - // - // A pattern reaching awk through `-v` goes through assignment escape - // processing first, and what that does to a backslash is undefined across - // implementations — gawk strips it, mawk keeps it. ready-lint matched its §8 - // label that way: green on mawk locally, matching NOTHING on the gawk runner, - // so the clause went back to passing silently and took three older tests with - // it. A gate that cannot match its own label does not fail, it passes. - ["awk-regex-check"] { - glob = List("mise-tasks/**", "*.sh") - check = "mise run awk-regex-check" - } - // `producer | grep -q P` under pipefail can report FAILURE on a match: grep - // exits at the first hit, a still-writing producer dies of SIGPIPE, and - // pipefail promotes 141. It is a race (2 in 300 runs on a two-commit range), - // so it passes review and passes tests. landed-check reported a clean board - // over three landed issues that way; issue-guard denied `gh pr ready` on a - // branch whose every commit carried Refs: CLOUD-186 — it blocked its own PR. - ["pipefail-grep-check"] { - glob = List("mise-tasks/**", "*.sh") - check = "mise run pipefail-grep-check" - } // Completeness of mise.lock, asked of the committed bytes. The old lock-check // asked it by running `mise lock` and diffing, which answers "did upstream // change" instead — and never removes or repairs an existing entry, so a @@ -980,6 +902,13 @@ local gate = new Mapping { "Cargo.lock", "mise.toml", ".github/workflows/*.yml", + // CLOUD-1709's two `workflow-shell-not-growing` rows glob + // `.github/workflows/**`, which is BROADER than the `*.yml` entry above + // and not redundant with it: the narrow entry misses a `.yaml` workflow + // and misses a nested path. A step whose glob does not cover its own + // inputs stops running for exactly the commits it is meant to judge, so + // `batten-glob-check` refused the rows until this followed them. + ".github/workflows/**", // Recursive since CLOUD-328: the ratchet row over bats suites was // narrowed to one segment only to dodge a submodule the walker used to // count and the base rev did not. Both halves stop at a nested diff --git a/man/batten-exec.1 b/man/batten-exec.1 index 87c107187..74eb9a1ff 100644 --- a/man/batten-exec.1 +++ b/man/batten-exec.1 @@ -4,7 +4,7 @@ .SH NAME batten\-exec \- Run a command — or a `:::` bundle — and report a pointer to what it wrote .SH SYNOPSIS -\fBbatten exec\fR [\fB\-\-capture\-only\fR] [\fB\-\-tee\fR] [\fB\-\-jobs\fR] [\fB\-\-continue\-on\-error\fR] [\fB\-\-format\fR] [\fB\-\-style\fR] [\fB\-h\fR|\fB\-\-help\fR] <\fIcommand\fR> +\fBbatten exec\fR [\fB\-\-capture\-only\fR] [\fB\-\-tee\fR] [\fB\-\-jobs\fR] [\fB\-\-continue\-on\-error\fR] [\fB\-\-lock\fR] [\fB\-\-lock\-path\fR] [\fB\-\-lock\-attempts\fR] [\fB\-\-lock\-label\fR] [\fB\-\-format\fR] [\fB\-\-style\fR] [\fB\-h\fR|\fB\-\-help\fR] <\fIcommand\fR> .SH DESCRIPTION Run a command — or a `:::` bundle — and report a pointer to what it wrote .SH OPTIONS @@ -21,6 +21,18 @@ How many of a `:::` bundle\*(Aqs commands run at once \fB\-\-continue\-on\-error\fR Run the rest of a `:::` bundle after a command fails .TP +\fB\-\-lock\fR +Hold this clone\*(Aqs named singleton lock for the child\*(Aqs lifetime +.TP +\fB\-\-lock\-path\fR +Hold the lock at this path, for a resource the clone does not own +.TP +\fB\-\-lock\-attempts\fR +How many times to ask for the lock before reporting it held +.TP +\fB\-\-lock\-label\fR +What the wait is for, named by the caller for the refusal line +.TP \fB\-\-format\fR [default: human] How Batten\*(Aqs own record is encoded (hk\*(Aqs axis) .br diff --git a/mise-tasks/awk-regex-check.sh b/mise-tasks/awk-regex-check.sh deleted file mode 100755 index 3f6c66e87..000000000 --- a/mise-tasks/awk-regex-check.sh +++ /dev/null @@ -1,62 +0,0 @@ -#!/usr/bin/env bash -#MISE description="Gate: no shell task hands awk a regex through -v, where escape handling is implementation-defined" -# -# A pattern passed through `awk -v` goes through the assignment's escape -# processing before awk ever sees it as a regex, and what that does to a -# backslash is not defined across implementations. gawk strips `\(` to `(` with -# a warning; mawk keeps it. So the same pattern is a literal paren on one -# machine and a capturing group on the other. -# -# This is not theoretical. `ready-lint` matched its §8 label that way. It worked -# on mawk here and, on the gawk runner, matched NOTHING — so the clause that -# exists to catch a blocker claimed without a relation went back to passing -# silently, and three tests that predated the change went red with it. A gate -# that cannot match its own label does not fail; it passes. -# -# The predicate is the USE, not the value: a literal with no backslash is safe -# today and unsafe the moment someone adds one, and a variable's runtime content -# is invisible to any static check. So this flags a `-v` name that the awk -# program then uses as a regex — `~ name` or `match(…, name)` — regardless of -# what the value looks like at the call site. -# -# `-v` for a plain VALUE stays fine, which is most of its use: comparing with -# `==`, printing, arithmetic. Only regex position is judged. -# -# The fix is always the same shape and needs no new tool: let `grep` find what -# the pattern matches and let awk work in literal patterns, or inline the pattern -# in the awk program where no assignment processing happens. -# A gate listed in $MUTANT_GATES with no row here fails `mise run mutant`. -#MUTANT regex-through-v-passes|s/^exit "\$fail"$/exit 0/|a -v name used with ~ is reported - -set -euo pipefail - -fail=0 -report() { - [[ "$fail" = 0 ]] && echo "::error:: a regex reaches awk through -v, where escape handling is implementation-defined (see mem:toolchain-and-hooks):" >&2 - printf ' %s\n' "$1" >&2 - fail=1 -} - -while IFS= read -r hit; do - [[ -n "$hit" ]] || continue - # -H, not -n alone: grep omits the filename when handed a single path, which - # silently turns the pointer into "lineno:text" and misreports the location. - file=${hit%%:*} - rest=${hit#*:} - lineno=${rest%%:*} - text=${rest#*:} - - # Every -v name assigned on this line. - for name in $(grep -oE '(^|[[:space:]])-v[[:space:]]*[A-Za-z_][A-Za-z0-9_]*=' <<<"$text" | - grep -oE '[A-Za-z_][A-Za-z0-9_]*=' | tr -d '=' | sort -u); do - # Used in regex position? `$0 ~ name`, `x ~ name`, or match(s, name). - if grep -qE "~[[:space:]]*$name([^A-Za-z0-9_]|$)" <<<"$text" || - grep -qE "match\([^)]*[,[:space:]]$name([^A-Za-z0-9_]|\))" <<<"$text"; then - report "$file:$lineno: \`$name\` is assigned with -v and used as a regex" - fi - done -done < <(git ls-files -z 'mise-tasks/*' '*.sh' 'mise.toml' 2>/dev/null | - xargs -0 grep -HnI 'awk' 2>/dev/null | grep -- '-v' || true) - -[[ "$fail" = 0 ]] && echo "awk-regex-check: no regex reaches awk through -v" -exit "$fail" diff --git a/mise-tasks/license-table-check.sh b/mise-tasks/license-table-check.sh deleted file mode 100755 index 83bad6131..000000000 --- a/mise-tasks/license-table-check.sh +++ /dev/null @@ -1,85 +0,0 @@ -#!/usr/bin/env bash -#MISE description="Gate: every adopted tool's license row is resolved — the release precondition CONTRIBUTING.md states in prose, as a predicate" -# -# CONTRIBUTING.md's license table ends with "Confirm each _to confirm_ entry -# before that tool is adopted in a shipped release." That sentence is a release -# precondition, and it had no runnable check: three of five rows carried -# `_to confirm_` in both columns and nothing failed. A rule without its mechanism -# is half a change (AGENTS.md, non-negotiable rule 2), so this is the other half. -# -# The table is the data and this is only the assertion over it — the verdicts are -# NOT restated here. A second copy would be a second authority for one fact, and -# the two would drift. -# -# Deliberately narrow. It judges whether a row is *resolved*, never whether the -# recorded license is *correct*: correctness is a human reading an upstream -# LICENSE file, which no exit code can stand in for. What the gate can prove is -# that nobody shipped while the question was still open. -# A gate listed in $MUTANT_GATES with no row here fails `mise run mutant`. -#MUTANT empty-table-passes|s/^if \[\[ "\$rows" -eq 0 \]\]; then$/if false; then/|a table with no rows is a failure - -set -uo pipefail - -DOC="${1:-CONTRIBUTING.md}" - -if [[ ! -r "$DOC" ]]; then - echo "::error:: license-table-check: cannot read $DOC" >&2 - exit 1 -fi - -# The compatibility column is a closed set. An unrecognised glyph is a failure -# rather than a pass, because "some other marker" is exactly how an unresolved -# row would slip through a check that only looked for the literal placeholder. -readonly YES='✅' -readonly NO='❌' - -fail=0 -rows=0 - -# Read the table between its heading and the next blank-line-terminated block. -# Rows are `| cell | cell | cell | cell |`; the header and the `---` separator -# are skipped by shape, not by line number, so inserting a row cannot shift the -# parse. -while IFS= read -r line; do - case "$line" in - '|'*'|') ;; - *) continue ;; - esac - case "$line" in - *'---'*) continue ;; - esac - - tool=$(printf '%s' "$line" | cut -d'|' -f2 | sed 's/^ *//; s/ *$//') - license=$(printf '%s' "$line" | cut -d'|' -f4 | sed 's/^ *//; s/ *$//') - compat=$(printf '%s' "$line" | cut -d'|' -f5 | sed 's/^ *//; s/ *$//') - - # The header row names the columns rather than a tool. - [[ "$tool" = "Tool" ]] && continue - [[ -z "$tool" ]] && continue - - rows=$((rows + 1)) - - if [[ -z "$license" ]] || [[ "$license" != "${license#*to confirm}" ]]; then - [[ "$fail" = 0 ]] && echo "::error:: license-table-check: a row's license is unresolved. Read the upstream LICENSE file and record the SPDX id:" >&2 - printf ' %s — license is %s\n' "$tool" "${license:-empty}" >&2 - fail=1 - continue - fi - - if [[ "$compat" != "$YES" ]] && [[ "$compat" != "$NO" ]]; then - [[ "$fail" = 0 ]] && echo "::error:: license-table-check: a row's Apache-2.0 verdict is unresolved or outside the closed set ($YES / $NO):" >&2 - printf ' %s — verdict is %s\n' "$tool" "${compat:-empty}" >&2 - fail=1 - fi -done <"$DOC" - -if [[ "$rows" -eq 0 ]]; then - # A table that parses to zero rows passes every per-row assertion vacuously, - # which is the false green this branch exists to kill: a renamed heading or a - # reformatted table would otherwise read as "all rows resolved". - echo "::error:: license-table-check: no license rows found in $DOC — the table moved or its shape changed" >&2 - exit 1 -fi - -[[ "$fail" = 0 ]] && echo "license-table-check: $rows adopted-tool rows, every license and verdict resolved" -exit "$fail" diff --git a/mise-tasks/module-map-check.sh b/mise-tasks/module-map-check.sh deleted file mode 100755 index 4aedac44c..000000000 --- a/mise-tasks/module-map-check.sh +++ /dev/null @@ -1,53 +0,0 @@ -#!/usr/bin/env bash -#MISE description="Gate: every crate source module appears in the mem:core module map (CLOUD-194)" -# -# `.claude/rules/rust.md` does not carry a module tree; it defers outright — -# "the full per-module map ... is `mem:core`, which is kept current instead of -# this tree." That makes mem:core the single authority on what each module owns, -# and an authority nothing checks is prose (rule 2). A module added without its -# row leaves the map silently incomplete and the rule pointing at it untrue. -# -# Measured: `severity.rs` (CLOUD-168) landed with no row, past a green gate. -# `memories-check` did not catch it and is not meant to — it gates the graph's -# *edges* (mem: references resolve, names are addressable), a different property -# that holds fine while the map is missing half its rows. -# -# A pure function of the tracked tree: no tool dependency, so it gates the gap -# no matter how the module was added — Serena, a direct write, or a bypass. -# A gate listed in $MUTANT_GATES with no row here fails `mise run mutant`. -#MUTANT unmapped-module-passes|s/^if \[\[ "\$violations" -ne 0 \]\]; then$/if false; then/|a module with no map row is reported with a pointer - -set -euo pipefail - -cd "${MODMAP_ROOT:-$(git rev-parse --show-toplevel)}" -map=".serena/memories/core.md" - -violations=0 -report() { # pointer-only (rule 4): file:line rule-id (name) - echo "$1 $2" >&2 - violations=$((violations + 1)) -} - -# The map is the graph root `memories-check` already requires; if it is gone -# that gate reports it, so this one states the dependency and stops rather than -# reporting every module as missing. -if [[ ! -f "$map" ]]; then - report "$map:0" "module-map-missing" - echo "::error:: module-map-check: $violations violation(s)" >&2 - exit 1 -fi - -# Every tracked crate source file must be named in the map. The row format is -# prose, so this asks only that the filename appears somewhere in it — -# the weakest claim that still catches an absent module, and the one that does -# not dictate how a row is worded. -while IFS= read -r f; do - base="${f##*/}" - grep -qF "\`$base\`" "$map" || report "$f:0" "module-map-missing-row ($base)" -done < <(git ls-files 'crates/*/src/*.rs' | sort -u) - -if [[ "$violations" -ne 0 ]]; then - echo "::error:: module-map-check: $violations module(s) absent from $map" >&2 - exit 1 -fi -echo "module-map-check: every module has a map row" diff --git a/mise-tasks/no-doctests.sh b/mise-tasks/no-doctests.sh deleted file mode 100755 index 67a2b0afc..000000000 --- a/mise-tasks/no-doctests.sh +++ /dev/null @@ -1,100 +0,0 @@ -#!/usr/bin/env bash -#MISE description="Gate: no runnable doctest exists, because the workspace's test runner does not run them" -# -# THE COVERAGE HOLE A RUNNER SWAP OPENS, AND WHY IT IS A GATE RATHER THAN A NOTE -# (CLOUD-813). `[tasks."test:cargo"]` runs `cargo nextest run`, and nextest does -# not execute doctests — it says so itself and it is not a defect, it is the -# scheduler's scope. `cargo test` did run them. So the swap moves a class of test -# from "run on every PR" to "run nowhere", and the only thing that made that safe -# to do was a measurement: `cargo test --doc --workspace` reports -# `0 passed; 0 failed` on this workspace, so the class is EMPTY and nothing was -# lost. -# -# An empty class is not a stable property. The moment someone writes a doc -# example, it is dead code that reads like a tested example — the worst shape a -# test can take, because a reader trusts it precisely because it is executable. -# CLOUD-813's own words: a silent coverage change "is the one outcome that would -# make this a bad trade at any speed." -# -# So the emptiness is asserted rather than assumed. A doctest appearing is not -# forbidden — it is a decision this gate forces someone to make: run doctests as -# their own step, or mark the fence `text`/`ignore`. Either is fine; neither is -# silence. -# -# TEXT, NOT A COMPILE. The obvious predicate is `cargo test --doc` reporting -# zero, and it costs a full workspace build to answer a question the source -# already answers. This reads the fences. -# -# Output is a pointer, never the payload (non-negotiable 4): `path:line` and the -# fence's info string, never the example. -# -# Exit 0 no runnable doctest / 1 at least one / 2 could not look. -set -uo pipefail - -cd "$(git rev-parse --show-toplevel 2>/dev/null)" || { - echo "::error:: no-doctests: not a git repository, so there is nothing to scan" >&2 - exit 2 -} - -readonly ROOT="${1:-crates}" - -if [[ ! -d "$ROOT" ]]; then - echo "::error:: no-doctests: $ROOT is not a directory, so the scan has no subject. This is could-not-look, not clean." >&2 - exit 2 -fi - -# THE ANTI-VACUITY TERM. A scan that matched no file at all would report "no -# runnable doctest" over nothing, which is the reads-as-coverage defect -# CLOUD-418 names. Zero `.rs` files under the root is could-not-look. -files=$(git ls-files "$ROOT/**/*.rs" "$ROOT/*.rs" 2>/dev/null) -#MUTANT doctest-scan-may-be-vacuous|s@^if \[\[ -z "\${files//\[\[:space:\]\]/}" \]\]; then$@if false; then@|a root with no tracked .rs file is could-not-look -if [[ -z "${files//[[:space:]]/}" ]]; then - echo "::error:: no-doctests: no tracked .rs file under $ROOT, so the scan has no subject. This is could-not-look, not clean." >&2 - exit 2 -fi - -# Fences INSIDE doc comments only (`///` and `//!`), toggled open/closed in -# order, because a closing fence carries no info string and would otherwise read -# as an unattributed — therefore runnable — opening one. rustdoc runs a fence -# unless its info string names one of the non-running attributes; `no_run` still -# compiles under `cargo test --doc` and is still not run by nextest, so it counts -# as non-running here and the gate stays about EXECUTION. -# `&2 - printf '%s\n' "$found" | while IFS=$'\t' read -r where info; do - printf ' %s info=%s\n' "$where" "$info" >&2 - done - exit 1 -fi - -echo "no-doctests: no runnable doctest fence under $ROOT, so nextest running none costs nothing" diff --git a/mise-tasks/pipefail-grep-check.sh b/mise-tasks/pipefail-grep-check.sh deleted file mode 100755 index 47c2a4c09..000000000 --- a/mise-tasks/pipefail-grep-check.sh +++ /dev/null @@ -1,92 +0,0 @@ -#!/usr/bin/env bash -#MISE description="Gate: under set -o pipefail, no shell task pipes a producer into an early-exiting grep -q" -# -# `producer | grep -q PATTERN` under `set -o pipefail` can return FAILURE on a -# match. grep exits the moment it finds the first hit; if the producer is still -# writing it dies of SIGPIPE, and pipefail promotes that signal (141) to the -# pipeline's status. So the successful case is the one that reports failure. -# -# It is a RACE, which is exactly what lets it survive review and a green suite: -# whether the producer is still writing when grep exits depends on output size -# and scheduling. Measured here on a two-commit range — 2 failures in 300 runs, -# and one of those two was a real denial of a correctly-referenced PR. A large -# producer loses the race nearly always; a small one loses it rarely, passes -# every test written for it, and denies someone months later. -# -# Two instances landed in this repo before the class was named: -# -# landed-check read `git log … | grep -q "$id"` and reported a CLEAN BOARD -# over three issues whose refs were on main. -# issue-guard asked the same way whether any commit on the branch names an -# issue, and DENIED `gh pr ready` on a branch where every commit -# carried `Refs: CLOUD-186` — with a reason stating the opposite -# of what it had just found. The gate blocked its own PR. -# -# Both fail in the same direction: toward the verdict nobody checks. A gate that -# reports clean when it found something, and a guard that refuses work that -# satisfied it, are the silent false green this repo keeps re-meeting in a new -# disguise. -# -# The fix needs no new tool and is always the same shape: read the producer into -# a variable and match from a here-string — `x=$(producer); grep -q P <<<"$x"`. -# A here-string has no upstream process, so there is no status to promote. -# -# Scope, deliberately: only files that actually enable pipefail, and only the -# early-exiting forms (`-q`, `-m N`, and `-l`, which stops at the first matching -# file). `| grep` without them consumes its whole input, so the producer never -# takes SIGPIPE and the pipeline status is honest. -# A gate listed in $MUTANT_GATES with no row here fails `mise run mutant`. -#MUTANT early-exiting-grep-passes|s/^exit "\$fail"$/exit 0/|the exact shape that broke issue-guard is flagged - -set -euo pipefail - -fail=0 -report() { - [[ "$fail" = 0 ]] && echo "::error:: a producer is piped into an early-exiting grep under pipefail, so a MATCH reports failure (see mem:toolchain-and-hooks):" >&2 - printf ' %s\n' "$1" >&2 - fail=1 -} - -while IFS= read -r -d '' file; do - grep -qE '^[[:space:]]*set[[:space:]]+-[a-z]*o?[a-z]*[[:space:]]*.*pipefail' "$file" || continue - - # -H because grep omits the filename for a single path, which would silently - # turn the pointer into "lineno:text" and misreport the location. - while IFS= read -r hit; do - [[ -n "$hit" ]] || continue - lineno=${hit%%:*} - text=${hit#*:} - # A comment describing the hazard is not the hazard. - [[ "$(sed -E 's/^[[:space:]]*//' <<<"$text")" == \#* ]] && continue - # Judge the flags of the piped-into grep itself. `-qxF` and `-oq` are the - # same hazard as `-q`, so the test is "a short-flag cluster containing q - # or l", not an exact spelling — the enumeration is what would rot. - piped=${text##*| grep} - [[ "$piped" = "$text" ]] && piped=${text##*|grep} - early=0 - for tok in $piped; do - case "$tok" in - --) break ;; - --quiet | --files-with-matches | --max-count*) early=1 ;; - -m*) early=1 ;; - --*) ;; - -*) - # A short-flag cluster: q or l anywhere in it exits early. - case "${tok#-}" in - *[ql]*) early=1 ;; - esac - ;; - esac - done - [[ "$early" = 1 ]] || continue - report "$file:$lineno: pipes into an early-exiting grep — read into a variable and match with <<<" - # `||` IS NOT A PIPE. The scan was `\|[[:space:]]*grep`, which matches the - # SECOND bar of `a || grep -q ...` and reports a here-string form — the - # very remedy this gate recommends — as the defect it exists to refuse. - # Measured on `mise-tasks/ready-lint.sh` (CLOUD-852), whose line reads - # `[[ ... ]] || grep -qE '...' <<<"$var"` and pipes nothing. - done < <(grep -nE '(^|[^|])\|[[:space:]]*grep([[:space:]]|$)' "$file" || true) -done < <(git ls-files -z 'mise-tasks/*' '*.sh' 2>/dev/null || true) - -[[ "$fail" = 0 ]] && echo "pipefail-grep-check: no producer is piped into an early-exiting grep" -exit "$fail" diff --git a/mise-tasks/report-only-check.sh b/mise-tasks/report-only-check.sh deleted file mode 100755 index a6a0060fd..000000000 --- a/mise-tasks/report-only-check.sh +++ /dev/null @@ -1,98 +0,0 @@ -#!/usr/bin/env bash -#MISE description="Gate: a report task stays off the landing path — absent from [tasks.verify] and from every pull_request workflow (pointer-only)" -# -# CLOUD-582. Two tasks here are REPORTS rather than gates — `coverage` -# (CLOUD-111) and `scorecard` — and both are non-gates by a decision that no -# exit code held. Each carries a long comment saying so, which is feedforward -# with no sensor: the whole of what kept them off the landing path was that -# nobody added them to it. -# -# The failure mode is quiet and one-directional. Adding a report to `verify` or -# to a `pull_request` workflow makes it a gate, and it then fails a branch for a -# NUMBER — a coverage percentage, or a scorecard check counting a deliberate -# design decision as a defect. That is the model-verdict shape non-negotiable -# rule 3 refuses, arriving as a one-line diff nobody reads as a policy change. -# -# WHY THIS IS NOT PART OF `ci-local-parity`, which parses the same two inputs. -# That gate asserts the FORWARD direction — every task CI runs is one `verify` -# runs — so CI is a confirmation rather than a discovery. This is the INVERSE: -# named tasks appear in neither. Composing them would give one task two -# properties that fail for opposite reasons, and the deny would have to explain -# which. They are cheap; two gates that each say one thing are the better trade. -# -# WHICH TASKS, and why the list is here rather than in `mise.toml`. A report is -# not a syntactic category — nothing in a task's declaration says "this emits a -# number nobody may block on" — so the set is a judgement, made once, written -# down where the gate that enforces it can be read beside it. A task added to -# this list is a decision to keep it off the landing path forever; removing one -# is a decision to allow it on, and both should be visible in the same diff as -# the gate. -# -# Pointer-only (non-negotiable 4): the task name and where it was found, never -# the matched line. -# A gate listed in $MUTANT_GATES with no row here fails `mise run mutant`. -#MUTANT report-on-the-landing-path-passes|s/^\texit 1$/\texit 0/|a report run by a pull_request workflow is refused - -set -uo pipefail - -manifest="${REPORT_ONLY_MANIFEST:-mise.toml}" -workflows="${REPORT_ONLY_WORKFLOWS:-.github/workflows}" -# Space-separated so a fixture can narrow it; the default is the real set. -read -r -a reports <<<"${REPORT_ONLY_TASKS:-coverage scorecard}" - -violations=0 -problem() { - echo "::error:: report-only-check: $*" >&2 - violations=$((violations + 1)) -} - -if [[ ! -f "$manifest" ]]; then - echo "::error:: report-only-check: no manifest at $manifest — nothing to judge" >&2 - exit 2 -fi - -# `[tasks.verify]`'s own block: its `depends` list plus its body, the same span -# `ci-local-parity` reads, so the two gates cannot disagree about what "verify -# runs this" means. -verify_block=$(awk '/^\[tasks\.verify\]/{p=1;next} /^\[/{p=0} p' "$manifest") -if [[ -z "$verify_block" ]]; then - echo "::error:: report-only-check: no [tasks.verify] in $manifest, so there is nothing to judge a report against" >&2 - exit 2 -fi - -for task in "${reports[@]}"; do - [[ -n "$task" ]] || continue - - # Word-bounded: `coverage` must not be satisfied by `COVERAGE_OUT_DIR`, and - # must still fire on `"coverage"` inside a `depends` list. - if grep -qE "(^|[^a-zA-Z0-9_:-])${task}([^a-zA-Z0-9_:-]|$)" <<<"$verify_block"; then - problem "\`$task\` is a report, and [tasks.verify] names it — running it on the landing path makes a number a gate." - fi -done - -# A `pull_request` workflow is the other way onto the landing path, and the one -# `verify` cannot see. Scoped to that trigger deliberately: a scheduled workflow -# running a report is the whole point of the report existing. -shopt -s nullglob -for wf in "$workflows"/*.yml "$workflows"/*.yaml; do - # Read once into a variable, then here-string: `grep -q` exits on its first - # match, and under `pipefail` the SIGPIPE'd producer makes a pipeline report - # failure on a MATCH — the inversion `pipefail-grep-check` exists to catch. - triggers=$(awk '/^on:/{p=1;next} /^[a-z]/{p=0} p' "$wf") - grep -q '^ pull_request:' <<<"$triggers" || continue - - body=$(cat "$wf") - for task in "${reports[@]}"; do - [[ -n "$task" ]] || continue - if grep -qE "mise run ${task}([^a-zA-Z0-9_:-]|$)" <<<"$body"; then - problem "\`$task\` is a report, and ${wf#"$workflows"/} runs it on pull_request — that spends a runner per push for a verdict nobody may block on." - fi - done -done - -if [[ "$violations" -ne 0 ]]; then - echo "::error:: report-only-check: $violations violation(s). A report emits a number for a human; binding it to landing gates on a tool's opinion (non-negotiable rule 3). Keep it scheduled, or decide deliberately that it is a gate and remove it from this task's list." >&2 - exit 1 -fi - -echo "report-only-check: ${#reports[@]} report task(s) off the landing path — absent from [tasks.verify] and from every pull_request workflow" diff --git a/mise.toml b/mise.toml index 4c33e7cff..7f754f5a2 100644 --- a/mise.toml +++ b/mise.toml @@ -617,7 +617,7 @@ CI_FANIN_WORKFLOW = ".github/workflows/ci.yml" BATS_TEST_TIMEOUT = "300" REGORUS_OPA_COMPLIANCE = "1.2.0" REGORUS_OPA_COMPLIANCE_FOR = "0.11" -MUTANT_GATES = "mise,attestation-check,engine-checks-green,engine-config,engine-doctor,engine-landed,engine-perf,engine-mcp,engine-pinned,engine-ready,engine-verdict,engine-wiring,engine-surface,agentic-experiment-record,awk-regex-check,bats-invocation,batten-glob-check,board-diff-overlap,board-payloads,board-sweep,branch-age-check,cap-drift,cfg-gated-test,ci-cache-declared,ci-hygiene,ci-parity,ci-slow-needed,ci-suite-lane,ci-tools-check,claim-before-code,claim-order-is-stated,claimed-keys,closing-key-check,coderabbit-config-check,commit-hygiene,connector-allow-guard,connector-allow-resolve,container-preflight,darwin-link,deferral-check,denials-outlive-the-turn,digest-major-agreement,doctor,done-check,done-pr-check,duplicate-close-check,egress-fencing,evaluator-closure-check,evaluator-io-check,filed-here,finding-sink-check,fixture-forks,forge-verdict-required,graph-check,harness-grant,harness-wiring,hk-fix-selection,hk-plan-required,hook-pin-check,hook-skip-local,in-progress-drain,install-check,land-divergence-assert,landed-check,landing-loop,landing-roster-guarded,leased-push,license-table-check,linear-check,lock-complete,macos-link-check,mcp-allow-check,mcp-attach-check,mcp-timeout-budget,merged-pr-keys,mise-action-floor,mise-pin-agreement,module-map-check,msrv-pin-agreement,mutation-declared-case,no-doctests,nonverdict-assert,ntia-check,obligations-bound,perf-assert,pinned-toolchain,pipefail-grep-check,plan-complete,pr-partition-restated,pr-unsubscribed,privileged-lane,prose-only,publish-credential-check,ready-cites-check,ready-lint,reclaim-census,release-assets-check,release-due,release-provision-parity,release-tag-shape,release-tracking-check,released,remedy-authorship,repetition-without-progress,report-only-check,review-answered,review-dispatched,run-shape,rust-paths-check,sbom,sbom-inventory,serena-mcp,shell-hygiene,shell-retirement,shell-write-advisory,signing-posture,sonar-gate,spec-ref-check,stop-posture,stop-posture-check,suite-bench-check,suite-subject-retirable,task-substitution,test-targets,timeout-check,token-bench-check,transcript-corpus-check,tree-clean,trunk-based,validator-verdict-clean,verdict-routes-resolve,weakens-declared,worktree-registration,spawn-widening,nextest-slow,engine-lease,engine-handler" +MUTANT_GATES = "agentic-experiment-record,attestation-check,awk-regex,bats-invocation,batten-glob-check,board-diff-overlap,board-payloads,board-sweep,branch-age-check,cap-drift,cfg-gated-test,ci-cache-declared,ci-hygiene,ci-parity,ci-slow-needed,ci-suite-lane,ci-tools-check,claim-before-code,claim-order-is-stated,claimed-keys,closing-key-check,coderabbit-config-check,commit-hygiene,connector-allow-guard,connector-allow-resolve,container-preflight,darwin-link,deferral-check,denials-outlive-the-turn,digest-major-agreement,doctor,done-check,done-pr-check,duplicate-close-check,egress-fencing,engine-checks-green,engine-config,engine-doctor,engine-exec,engine-handler,engine-landed,engine-lease,engine-mcp,engine-perf,engine-pinned,engine-ready,engine-surface,engine-verdict,engine-wiring,evaluator-closure-check,evaluator-io-check,filed-here,finding-sink-check,fixture-forks,forge-verdict-required,graph-check,harness-grant,harness-wiring,hk-fix-selection,hk-plan-required,hook-pin-check,hook-skip-local,in-progress-drain,install-check,land-divergence-assert,landed-check,landing-loop,landing-roster-guarded,leased-push,license-table,linear-check,lock-complete,macos-link-check,mcp-allow-check,mcp-attach-check,mcp-timeout-budget,merged-pr-keys,mise,mise-action-floor,mise-pin-agreement,module-map,msrv-pin-agreement,mutation-declared-case,nextest-slow,no-doctests,nonverdict-assert,ntia-check,obligations-bound,perf-assert,pinned-toolchain,pipefail-grep,plan-complete,pr-partition-restated,pr-unsubscribed,privileged-lane,prose-only,publish-credential-check,ready-cites-check,ready-lint,reclaim-census,release-assets-check,release-due,release-provision-parity,release-tag-shape,release-tracking-check,released,remedy-authorship,repetition-without-progress,report-only,review-answered,review-dispatched,run-shape,rust-paths-check,sbom,sbom-inventory,serena-mcp,shell-hygiene,shell-retirement,shell-write-advisory,signing-posture,sonar-gate,spawn-widening,spec-ref-check,stop-posture,stop-posture-check,suite-bench-check,suite-subject-retirable,task-substitution,test-targets,timeout-check,token-bench-check,transcript-corpus-check,tree-clean,trunk-based,validator-verdict-clean,verdict-routes-resolve,weakens-declared,worktree-registration" # --- GitHub reachability behind an egress proxy (Claude Code web sandbox etc.) --- # mise resolves every tool's release through GitHub's *API* host, api.github.com. diff --git a/policy/awk-regex.rego b/policy/awk-regex.rego new file mode 100644 index 000000000..b9c36d8c7 --- /dev/null +++ b/policy/awk-regex.rego @@ -0,0 +1,156 @@ +# METADATA +# description: | +# No shell program hands awk a regex through `-v`, where escape handling is +# implementation-defined — ported from `mise-tasks/awk-regex-check.sh` under +# CLOUD-843. +# +# A pattern passed through `awk -v` goes through the assignment's escape +# processing before awk ever sees it as a regex, and what that does to a +# backslash is not defined across implementations. gawk strips `\(` to `(` +# with a warning; mawk keeps it. So the same pattern is a literal paren on one +# machine and a capturing group on the other. +# +# Not theoretical: `ready-lint` matched its §8 label that way. It worked on +# mawk locally and matched NOTHING on the gawk runner, so the clause that +# exists to catch a blocker claimed without a relation went back to passing +# silently. A gate that cannot match its own label does not fail; it passes. +# +# THE PREDICATE IS THE USE, NOT THE VALUE. A literal with no backslash is safe +# today and unsafe the moment someone adds one, and a variable's runtime +# content is invisible to any static check. So this flags a `-v` name that the +# awk program then uses in REGEX POSITION — `~ name` or `match(…, name)` — +# whatever the value looks like at the call site. `-v` for a plain value stays +# fine, which is most of its use. +# +# IDENTIFIERS ARE COMPARED WHOLE, WHICH IS STRICTLY BETTER THAN THE SHELL HAD. +# The predecessor built a per-name regex and guarded the boundary with a +# trailing character class. A module may not do that at all — an inline regex +# is refused at load, and a name-parameterised one is not a concept with one +# spelling — so the port reads the identifier that FOLLOWS `~` or the comma +# and compares it whole. `name` and `namespace` are then distinct by +# construction rather than by a character class that has to be got right. +# +# Output is a pointer (non-negotiable rule 4): `path:line` and the name, never +# the command. +# +# THIS BLOCK IS YAML AND MUST STAY THE LAST COMMENT BLOCK BEFORE `package`. +# schemas: +# - input: schema["policy-input.schema"] +package batten.awk_regex + +import rego.v1 + +rules contains "awk-regex" + +# Only lines that reach awk at all. The shell narrowed the same way with a grep +# for `awk` before it looked for `-v`, and the narrowing is what keeps this from +# reading every `-v` in the tree as an awk assignment. +awk_lines contains [path, index, line] if { + some path, file_lines in input.tree.lines + some index, line in file_lines + contains(line, "awk") + contains(line, "-v") +} + +# Every name assigned with `-v` on that line. +assigned contains [path, index, line, name] if { + some [path, index, line] in awk_lines + some capture in regex.find_all_string_submatch_n( + data.batten.patterns["awk-v-assignment"], + line, + -1, + ) + name := capture[1] +} + +# The identifier a fragment starts with, or undefined where it starts with +# anything else. +leading(fragment) := found[0] if { + found := regex.find_n( + data.batten.patterns["leading-identifier"], + trim_space(fragment), + 1, + ) + count(found) == 1 +} + +# `x ~ name` — the identifier immediately after a `~`. +in_regex_position(line, name) if { + some part in array.slice(split(line, "~"), 1, count(split(line, "~"))) + leading(part) == name +} + +# `match(s, name)` — the identifier after a comma inside a `match(` call. +in_regex_position(line, name) if { + some call in array.slice(split(line, "match("), 1, count(split(line, "match("))) + some arg in array.slice(split(call, ","), 1, count(split(call, ","))) + leading(arg) == name +} + +violation contains { + "rule": "awk-regex", + "verdict": "call run loose", + "subjects": [{"path": path, "line": index + 1}], +} if { + some [path, index, line, name] in assigned + in_regex_position(line, name) +} + +# --- the load-time tier ------------------------------------------------------ +# +# These pin the PREDICATE. They cannot pin that the ENGINE hands this module the +# shell corpus, which is what a fabricated `input.tree.lines` asserts into +# existence. `crates/batten/tests/it/awk_regex.rs` is that tier. + +scan(line) := {"tree": {"lines": {"mise-tasks/demo.sh": [line]}}} + +test_a_name_used_with_tilde_is_refused if { + some v in violation with input as scan("awk -v re=\"$p\" '$0 ~ re'") + v.verdict == "call run loose" +} + +test_match_is_regex_position_too if { + count(violation) == 1 with input as scan("awk -v re=\"$p\" '{ if (match($0, re)) print }'") +} + +test_a_value_compared_with_equals_is_fine if { + count(violation) == 0 with input as scan("awk -v want=\"$p\" '$1 == want'") +} + +test_a_value_printed_is_fine if { + count(violation) == 0 with input as scan("awk -v n=\"$p\" '{ print n, $0 }'") +} + +test_an_inline_regex_is_the_recommended_form if { + count(violation) == 0 with input as scan("awk '$0 ~ /^ISSUE-[0-9]+$/'") +} + +# THE PREFIX CASE, and the one the whole-identifier comparison exists for. +test_a_name_that_prefixes_another_is_not_confused_for_it if { + count(violation) == 0 with input as scan("awk -v re=\"$p\" '$0 ~ rex'") +} + +# TWO NAMES ON ONE LINE COLLAPSE TO ONE FINDING, and that is the pointer +# contract rather than lost coverage. A `subjects` member is a tagged pointer — +# `{path, line}` here — so two hazards on the same line produce the same subject +# and `violation` is a set. The predecessor printed a line per NAME; a reader +# following this pointer opens the line and sees both. What must not collapse is +# a hazard on a DIFFERENT line, which the case below pins. +test_two_names_on_one_line_are_one_pointer if { + count(violation) == 1 with input as scan("awk -v a=\"$x\" -v b=\"$y\" '$0 ~ a || $1 ~ b'") +} + +test_a_hazard_on_each_of_two_lines_is_two_findings if { + count(violation) == 2 with input as {"tree": {"lines": {"mise-tasks/demo.sh": [ + "awk -v a=\"$x\" '$0 ~ a'", + "awk -v b=\"$y\" '$1 ~ b'", + ]}}} +} + +test_a_line_that_never_reaches_awk_is_not_judged if { + count(violation) == 0 with input as scan("grep -v re && echo '$0 ~ re'") +} + +#MUTANT-SUITE crates/batten/tests/it/awk_regex.rs +#MUTANT awk-regex-position-unread|s@in_regex_position(line, name)$@true@|a_value_compared_with_equals_is_fine_over_the_binary +#MUTANT awk-regex-prefix-unread|s@leading(part) == name@startswith(part, name)@|a_name_that_prefixes_another_is_not_confused_for_it diff --git a/policy/license-table.rego b/policy/license-table.rego new file mode 100644 index 000000000..4965a0442 --- /dev/null +++ b/policy/license-table.rego @@ -0,0 +1,141 @@ +# METADATA +# description: | +# Every adopted tool's license row is resolved — the release precondition +# `CONTRIBUTING.md` states in prose, as a predicate. Ported from +# `mise-tasks/license-table-check.sh` under CLOUD-843. +# +# That file's table ends with "Confirm each _to confirm_ entry before that tool +# is adopted in a shipped release." It is a release precondition and it had no +# runnable check: three of five rows carried `_to confirm_` in both columns and +# nothing failed. A rule without its mechanism is half a change (non-negotiable +# rule 2); this is the other half. +# +# THE TABLE IS THE DATA AND THIS IS ONLY THE ASSERTION OVER IT. The verdicts +# are not restated here — a second copy would be a second authority for one +# fact, and the two would drift. +# +# DELIBERATELY NARROW. It judges whether a row is RESOLVED, never whether the +# recorded license is CORRECT: correctness is a human reading an upstream +# LICENSE file, which no exit code can stand in for. What a gate can prove is +# that nobody shipped while the question was still open. +# +# THE COMPATIBILITY COLUMN IS A CLOSED SET, and an unrecognised glyph is a +# FAILURE rather than a pass — "some other marker" is exactly how an unresolved +# row would slip past a check that only looked for the literal placeholder. +# +# ZERO ROWS IS A FAILURE, and it is the reason the predecessor existed at all: +# a table that parses to no rows passes every per-row assertion vacuously, so a +# renamed heading or a reformatted table would read as "all rows resolved". +# +# THIS BLOCK IS YAML AND MUST STAY THE LAST COMMENT BLOCK BEFORE `package`. +# schemas: +# - input: schema["policy-input.schema"] +package batten.license_table + +import rego.v1 + +rules contains "license-table" + +doc := "CONTRIBUTING.md" + +# The cells of a table row, or undefined for any line that is not one. +# +# Rows are `| cell | cell | cell | cell |`; the header and the `---` separator +# are skipped BY SHAPE rather than by line number, so inserting a row cannot +# shift the parse. +cells(line) := parts if { + trimmed := trim_space(line) + startswith(trimmed, "|") + endswith(trimmed, "|") + not contains(trimmed, "---") + parts := split(trimmed, "|") +} + +# One adopted tool's row: its name, the license cell and the verdict cell. +row contains [tool, license, compat] if { + some line in input.tree.lines[doc] + parts := cells(line) + count(parts) > 5 + tool := trim_space(parts[1]) + tool != "" + + # The header row names the columns rather than a tool. + tool != "Tool" + license := trim_space(parts[3]) + compat := trim_space(parts[4]) +} + +resolved_verdict := {"✅", "❌"} + +violation contains { + "rule": "license-table", + "verdict": "tool declare missing", + "subjects": [{"path": doc}], +} if { + some [_, license, _] in row + unresolved_license(license) +} + +unresolved_license(license) if license == "" + +unresolved_license(license) if contains(license, "to confirm") + +violation contains { + "rule": "license-table", + "verdict": "tool declare missing", + "subjects": [{"path": doc}], +} if { + some [_, license, compat] in row + not unresolved_license(license) + not compat in resolved_verdict +} + +# THE ANTI-VACUITY ARM. A table that parses to zero rows satisfies every clause +# above, which is the false green the predecessor was written to kill. +violation contains { + "rule": "license-table", + "verdict": "tool declare missing", + "subjects": [{"path": doc}], +} if { + input.tree.lines[doc] + count(row) == 0 +} + +# --- the load-time tier ------------------------------------------------------ + +table(ls) := {"tree": {"lines": {"CONTRIBUTING.md": ls}}} + +header := "| Tool | Use | License | Apache-2.0 |" + +sep := "| --- | --- | --- | --- |" + +test_a_fully_resolved_table_passes if { + count(violation) == 0 with input as table([header, sep, "| hk | hooks | MIT | ✅ |"]) +} + +test_an_explicit_incompatible_verdict_is_resolved if { + count(violation) == 0 with input as table([header, sep, "| thing | x | GPL-3.0 | ❌ |"]) +} + +test_an_unresolved_license_fails if { + some v in violation with input as table([header, sep, "| hk | hooks | _to confirm_ | ✅ |"]) + v.verdict == "tool declare missing" +} + +test_a_resolved_license_with_an_unresolved_verdict_still_fails if { + count(violation) == 1 with input as table([header, sep, "| hk | hooks | MIT | _to confirm_ |"]) +} + +# THE CLOSED SET. "Some other marker" is how an unresolved row slips past a +# check that only looked for the literal placeholder. +test_a_verdict_outside_the_closed_set_fails if { + count(violation) == 1 with input as table([header, sep, "| hk | hooks | MIT | probably |"]) +} + +test_a_table_with_no_rows_is_a_failure_not_a_vacuous_pass if { + count(violation) == 1 with input as table(["# Contributing", "", "no table here"]) +} + +#MUTANT-SUITE crates/batten/tests/it/license_table.rs +#MUTANT license-vacuous-table-passes|s@count(row) == 0@false@|a_table_with_no_rows_is_a_failure_not_a_vacuous_pass +#MUTANT license-closed-set-unread|s@not compat in resolved_verdict@false@|a_verdict_outside_the_closed_set_fails_over_the_binary diff --git a/policy/module-map.rego b/policy/module-map.rego new file mode 100644 index 000000000..b14416235 --- /dev/null +++ b/policy/module-map.rego @@ -0,0 +1,159 @@ +# METADATA +# description: | +# Every crate source module appears in the `mem:core` module map — CLOUD-194, +# ported from `mise-tasks/module-map-check.sh` under CLOUD-843. +# +# `rules/rust.md` carries no module tree; it defers outright — "the full +# per-module map ... is `mem:core`, which is kept current instead of this +# tree." That makes `mem:core` the single authority on what each module owns, +# and an authority nothing checks is prose (non-negotiable rule 2). A module +# added without its row leaves the map silently incomplete and the rule +# pointing at it untrue. Measured: `severity.rs` (CLOUD-168) landed with no +# row, past a green gate. +# +# `memories-check` did not catch it and is not meant to — it gates the graph's +# EDGES (`mem:` references resolve, names are addressable), a different +# property that holds fine while the map is missing half its rows. +# +# THE PREDICATE IS THIS CONSUMER'S. Where a repository keeps its module map, +# and that it keeps one at all, is a fact about this consumer rather than about +# the engine — non-negotiable rule 1 — so this is a `policy/*.rego` module and +# not engine source. +# +# A MISSING MAP IS COULD-NOT-LOOK, REPORTED ONCE. The shell said so in as many +# words and the reason survives the port: with the map gone, every module is +# "absent from the map" and a per-module report would bury the one fact that +# matters under one line per file. `memories-check` owns the map's existence; +# this states the dependency and stops. +# +# THAT ARM CANNOT FIRE TODAY, and it stays anyway. A rule whose declared +# `line_sources` match nothing is not evaluated at all, and `input.tree.missing` +# is never populated on the tree surface — `policy/mise-pin-agreement.rego` +# carries the same measurement for its own could-not-look clause, over an absent +# `documents` path, an absent `sources` path and an unparseable document, all +# three exit 0 with no finding (CLOUD-1049). So this clause is correct and the +# engine is what has to catch up; it carries no `#MUTANT` row for the same +# reason that module states, because a mutation over an unreachable clause +# would be reported as a survivor and be right. +# +# THE SUCCESSOR JUDGES THE CHECKOUT WHERE THE SHELL JUDGED THE INDEX, and that +# is a stated change rather than an absorbed one (CLOUD-1559). `git ls-files` +# asked the index; `input.tree.tracked` is a working-tree walk honouring +# `.gitignore` and is explicitly NOT the index (`facts.rs`, and +# `policy/lock-complete.rego` records the same trap). Nothing available to a +# module expresses index membership for a glob: `input.tree.staged` parses each +# declared path by format and no format owns `.rs`, and `git-status.changed` +# conflates untracked with modified. +# +# The consequence, stated because it is a real difference and not a rounding: +# a NEW module that is written but not yet committed is judged here and was not +# judged by the shell — the retiring suite pinned that as "an untracked module +# is not yet the map's problem". The successor is stricter, in the fail-closed +# direction, and the cost is that a contributor drafting a module is asked for +# its row before they commit it. That is a decision for CLOUD-1716 to keep or +# reverse, not one to leave undocumented. +# +# THE BACKTICKS ARE THE PREDICATE, not decoration. The map names modules in +# backticks, so a bare prose mention — a sentence ABOUT `severity.rs` — must +# not read as a row, or the gate passes on the very drift it exists to catch. +# That is the weakest claim that still catches an absent module while leaving +# a row's wording free. +# +# THIS BLOCK IS YAML AND MUST STAY THE LAST COMMENT BLOCK BEFORE `package`. +# schemas: +# - input: schema["policy-input.schema"] +package batten.module_map + +import rego.v1 + +rules contains "module-map" + +# Where this consumer keeps its map. +map_path := ".serena/memories/core.md" + +# The map's text, bound only when it was actually read. +# +# BOUND THROUGH A RULE rather than indexed at each use: every predicate below is +# then undefined without it rather than vacuously clean, which is the difference +# between could-not-look and a pass. +map_lines := lines if { + lines := input.tree.lines[map_path] +} + +# THE COULD-NOT-LOOK ARM. Reported once, and nothing else is reported with it. +violation contains { + "rule": "module-map", + "verdict": "memory resolve missing", + "subjects": [{"path": map_path}], +} if { + not map_lines +} + +# Every tracked crate source module. +# +# `input.tree.tracked` is the successor to the shell's `git ls-files`, and the +# distinction it preserves is the one the retiring suite pinned: an UNTRACKED +# module is not yet the map's problem. The row is owed when the module lands, +# not while it is a draft. +modules contains path if { + some path in input.tree.tracked + startswith(path, "crates/") + contains(path, "/src/") + endswith(path, ".rs") +} + +# The module's own filename, which is what a row names. +basename(path) := parts[count(parts) - 1] if { + parts := split(path, "/") +} + +named(base) if { + some line in map_lines + contains(line, sprintf("`%s`", [base])) +} + +violation contains { + "rule": "module-map", + "verdict": "module place missing", + "subjects": [{"path": path}], +} if { + map_lines + some path in modules + not named(basename(path)) +} + +# --- the load-time tier ------------------------------------------------------ +# +# These pin the PREDICATE. They cannot pin that the ENGINE resolves +# `input.tree.tracked` to the tracked set at all — a `with input as` block +# fabricates the very shape the engine may be unable to produce (CLOUD-845), and +# here it would fabricate the tracked/untracked distinction the gate turns on. +# `crates/batten/tests/it/module_map.rs` is that tier. + +tree(tracked, lines) := {"tree": {"tracked": tracked, "lines": lines}} + +mapped := {".serena/memories/core.md": ["- `main.rs` — the binary boundary."]} + +test_a_module_with_a_map_row_is_clean if { + count(violation) == 0 with input as tree({"crates/demo/src/main.rs"}, mapped) +} + +test_a_module_with_no_map_row_is_refused if { + some v in violation with input as tree({"crates/demo/src/severity.rs"}, mapped) + v.verdict == "module place missing" +} + +test_a_bare_mention_does_not_satisfy_the_row if { + some v in violation with input as tree( + {"crates/demo/src/severity.rs"}, + {".serena/memories/core.md": ["Note: severity.rs is described in another memory."]}, + ) + v.verdict == "module place missing" +} + +test_a_path_outside_a_crate_source_is_not_a_module if { + count(violation) == 0 with input as tree({"docs/severity.rs", "crates/demo/tests/severity.rs"}, mapped) +} + +#MUTANT-SUITE crates/batten/tests/it/module_map.rs +#MUTANT module-map-row-unread|s@not named(basename(path))@true@|the_repositorys_own_map_is_complete diff --git a/policy/no-doctests.rego b/policy/no-doctests.rego new file mode 100644 index 000000000..07b24dfd9 --- /dev/null +++ b/policy/no-doctests.rego @@ -0,0 +1,169 @@ +# METADATA +# description: | +# No runnable doctest exists, because this workspace's test runner does not run +# them — CLOUD-813, ported from `mise-tasks/no-doctests.sh` under CLOUD-843. +# +# `test:cargo` runs `cargo nextest run`, and nextest does not execute +# doctests. That is the scheduler's scope rather than a defect, but it means +# the runner swap moved a class of test from "run on every PR" to "run +# nowhere". What made the swap safe was a measurement: the class was EMPTY. An +# empty class is not a stable property, so the emptiness is asserted rather +# than assumed — the moment someone writes a doc example it is dead code that +# reads like a tested one, which is the worst shape a test can take because a +# reader trusts it precisely for being executable. +# +# A doctest is not forbidden. This forces a decision: run doctests as their own +# step, or mark the fence `text`/`ignore`. Either is fine; neither is silence. +# +# TEXT, NOT A COMPILE. The obvious predicate is `cargo test --doc` reporting +# zero, and it costs a full workspace build to answer a question the source +# already answers. This reads the fences — which is also what lets it be a +# `read`-effect rule at all rather than a spawn. +# +# THE PARITY IS THE PORT'S ONE PIECE OF REAL WORK. The shell toggled an `open` +# flag as it walked each file, because a CLOSING fence carries no info string +# and would otherwise read as an unattributed — therefore runnable — opening +# one. Rego has no walk state, so the same decision is expressed as parity: a +# fence is an opener exactly when an even number of fences precede it in that +# file. Same rule, no mutable flag. +# +# `no_run` COUNTS AS NON-RUNNING and that is deliberate: it still compiles +# under `cargo test --doc`, and it is still not run by nextest, so the gate +# stays about EXECUTION rather than about compilation. +# +# Output is a pointer, never the payload (non-negotiable rule 4): `path:line` +# and the fence's info string, never the example. +# +# THIS BLOCK IS YAML AND MUST STAY THE LAST COMMENT BLOCK BEFORE `package`. +# schemas: +# - input: schema["policy-input.schema"] +package batten.no_doctests + +import rego.v1 + +rules contains "no-doctests" + +# A doc-comment line's text, with the marker stripped. Undefined for any line +# that is not a doc comment, which is what keeps the fence scan inside them. +doc_body(line) := body if { + trimmed := trim_left(line, " \t") + startswith(trimmed, "///") + body := trim_left(trim_left(trimmed, "/"), " \t") +} + +doc_body(line) := body if { + trimmed := trim_left(line, " \t") + startswith(trimmed, "//!") + body := trim_left(trim_space(substring(trimmed, 3, -1)), " \t") +} + +# Is this doc-comment body a fence, and what does it declare? +fence_info(body) := info if { + startswith(body, "```") + info := trim_space(substring(body, 3, -1)) +} + +fence_info(body) := info if { + startswith(body, "~~~") + info := trim_space(substring(body, 3, -1)) +} + +# Every fence line in every scanned file, as [path, index, info]. +fences contains [path, index, info] if { + some path, file_lines in input.tree.lines + some index, line in file_lines + info := fence_info(doc_body(line)) +} + +# How many fences precede this one in the same file. +preceding(path, index) := count([other | + some [p, other, _] in fences + p == path + other < index +]) + +# An OPENER is a fence with an even number of fences before it in its file. The +# shell's toggle, written as the parity it always was. +openers contains [path, index, info] if { + some [path, index, info] in fences + preceding(path, index) % 2 == 0 +} + +# The info strings rustdoc will not RUN. `no_run` is here for the reason the +# header gives: it compiles and is still not executed. +non_running := {"text", "ignore", "compile_fail", "no_run"} + +runnable contains [path, index] if { + some [path, index, info] in openers + not declares_non_running(info) +} + +# An info string is a comma- or space-separated attribute list, so membership is +# over its fields rather than over the whole string: ` rust,ignore ` names +# `ignore`, and a substring test would also match `ignored_thing`. +declares_non_running(info) if { + some field in split(replace(info, ",", " "), " ") + trim_space(field) in non_running +} + +violation contains { + "rule": "no-doctests", + "verdict": "test state early", + "subjects": [{"path": path, "line": index + 1}], +} if { + some [path, index] in runnable +} + +# --- the load-time tier ------------------------------------------------------ +# +# These pin the PREDICATE — the parity rule and the attribute list. They cannot +# pin that the ENGINE hands this module the crate sources at all, which is the +# anti-vacuity half the shell spent an explicit arm on and which a fabricated +# input would assert into existence. `crates/batten/tests/it/no_doctests.rs` is +# that tier. + +scan(ls) := {"tree": {"lines": {"crates/demo/src/lib.rs": ls}}} + +test_an_unattributed_fence_is_runnable if { + some v in violation with input as scan(["/// ```", "/// let x = 1;", "/// ```"]) + v.verdict == "test state early" +} + +test_a_text_fence_is_not_a_doctest if { + count(violation) == 0 with input as scan(["/// ```text", "/// not rust", "/// ```"]) +} + +test_every_non_running_attribute_is_honoured if { + count(violation) == 0 with input as scan([ + "/// ```ignore", + "/// ```", + "/// ```compile_fail", + "/// ```", + "/// ```no_run", + "/// ```", + ]) +} + +# THE PARITY CASE. Three fences: opener, closer, opener. The middle one carries +# no info string, and a reader without the parity rule counts it as a second +# unattributed opener — reporting two findings where there is one. +test_a_closing_fence_is_not_an_unattributed_opener if { + found := violation with input as scan([ + "/// ```text", + "/// safe", + "/// ```", + ]) + count(found) == 0 +} + +test_a_fence_outside_a_doc_comment_is_not_a_doctest if { + count(violation) == 0 with input as scan(["// ```", "let x = 1;", "// ```"]) +} + +test_an_attribute_list_names_its_fields if { + count(violation) == 0 with input as scan(["/// ```rust,ignore", "/// x", "/// ```"]) +} + +#MUTANT-SUITE crates/batten/tests/it/no_doctests.rs +#MUTANT doctest-parity-unread|s@preceding(path, index) % 2 == 0@true@|a_closing_fence_is_not_read_as_an_unattributed_opener +#MUTANT doctest-attribute-unread|s@not declares_non_running(info)@true@|a_text_fence_is_not_a_doctest_over_the_binary diff --git a/policy/pipefail-grep.rego b/policy/pipefail-grep.rego new file mode 100644 index 000000000..aee606b49 --- /dev/null +++ b/policy/pipefail-grep.rego @@ -0,0 +1,171 @@ +# METADATA +# description: | +# Under `set -o pipefail`, no shell program pipes a producer into an +# early-exiting `grep` — ported from `mise-tasks/pipefail-grep-check.sh` under +# CLOUD-843. +# +# `producer | grep -q PATTERN` under pipefail can return FAILURE on a match. +# grep exits the moment it finds the first hit; if the producer is still +# writing it dies of SIGPIPE, and pipefail promotes that signal (141) to the +# pipeline's status. So the SUCCESSFUL case is the one that reports failure. +# +# It is a RACE, which is what lets it survive review and a green suite: +# measured on a two-commit range, 2 failures in 300 runs. A large producer +# loses nearly always; a small one loses rarely, passes every test written for +# it, and denies someone months later. Two instances landed here before the +# class was named — `landed-check` reported a CLEAN BOARD over three issues +# whose refs were on main, and `issue-guard` DENIED `gh pr ready` on a branch +# where every commit carried its key, with a reason stating the opposite of +# what it had just found. Both fail toward the verdict nobody checks. +# +# THE FIX NEEDS NO NEW TOOL and is always the same shape: read the producer +# into a variable and match from a here-string, which has no upstream process +# and so no status to promote. +# +# SCOPE, DELIBERATELY. Only files that actually enable pipefail, and only the +# early-exiting forms — `-q`, `-l` (stops at the first matching file) and +# `-m N`. A plain `| grep` consumes its whole input, so the producer never +# takes SIGPIPE and the pipeline's status is honest. +# +# THE CLUSTER IS READ LETTER BY LETTER rather than enumerated, and that is the +# predecessor's decision carried rather than its spelling. `-qxF` and `-oq` are +# the same hazard as `-q`, so the test is "a short-flag cluster containing q or +# l" — an enumeration of exact spellings is what would rot. +# +# `--` ENDS THE FLAGS. `grep -- -q` is searching for the literal `-q`, not +# asking to be quiet, and the predecessor stopped its scan there for that +# reason. +# +# Output is a pointer (non-negotiable rule 4): `path:line` and the fix, never +# the matched content. +# +# THIS BLOCK IS YAML AND MUST STAY THE LAST COMMENT BLOCK BEFORE `package`. +# schemas: +# - input: schema["policy-input.schema"] +package batten.pipefail_grep + +import rego.v1 + +rules contains "pipefail-grep" + +# Files that actually turn pipefail on. Everything else is out of scope, because +# without it a SIGPIPE never reaches the pipeline's status. +enables_pipefail(path) if { + some line in input.tree.lines[path] + regex.match(data.batten.patterns["shell-enables-pipefail"], line) +} + +# A line that pipes into grep, in a file that enables pipefail, and is not a +# comment describing the hazard. +candidate contains [path, index, line] if { + some path, file_lines in input.tree.lines + enables_pipefail(path) + some index, line in file_lines + not startswith(trim_space(line), "#") + regex.match(data.batten.patterns["pipe-into-grep"], line) +} + +# What follows the LAST `| grep` on the line — the flags of the grep being piped +# into, rather than of some earlier one. +piped_flags(line) := tail if { + parts := split(line, "| grep") + count(parts) > 1 + tail := parts[count(parts) - 1] +} + +piped_flags(line) := tail if { + not contains(line, "| grep") + parts := split(line, "|grep") + count(parts) > 1 + tail := parts[count(parts) - 1] +} + +# The tokens before `--`, which ends the flags: `grep -- -q` searches for the +# literal `-q`. +flag_tokens(line) := tokens if { + tail := piped_flags(line) + before := split(tail, " -- ")[0] + tokens := [token | + some token in split(before, " ") + token != "" + ] +} + +# A long flag that exits early. +early(token) if token == "--quiet" + +early(token) if token == "--files-with-matches" + +early(token) if startswith(token, "--max-count") + +early(token) if startswith(token, "-m") + +# A SHORT-FLAG CLUSTER carrying `q` or `l` anywhere in it. Letter by letter, so +# `-qxF` and `-oq` are the same hazard as `-q` without enumerating spellings. +early(token) if { + startswith(token, "-") + not startswith(token, "--") + some letter in split(substring(token, 1, -1), "") + letter in {"q", "l"} +} + +violation contains { + "rule": "pipefail-grep", + "verdict": "call run loose", + "subjects": [{"path": path, "line": index + 1}], +} if { + some [path, index, line] in candidate + some token in flag_tokens(line) + early(token) +} + +# --- the load-time tier ------------------------------------------------------ + +scan(ls) := {"tree": {"lines": {"mise-tasks/demo.sh": array.concat(["set -euo pipefail"], ls)}}} + +test_the_shape_that_broke_issue_guard_is_flagged if { + some v in violation with input as scan(["git log --format=%B main | grep -q \"$id\""]) + v.verdict == "call run loose" +} + +test_the_here_string_fix_passes if { + count(violation) == 0 with input as scan(["x=$(git log); grep -q \"$id\" <<<\"$x\""]) +} + +test_a_flag_cluster_is_judged_by_its_letters if { + count(violation) == 1 with input as scan(["producer | grep -qxF thing"]) +} + +test_long_names_are_the_same_hazard if { + count(violation) == 2 with input as scan([ + "producer | grep --quiet thing", + "producer | grep --files-with-matches thing", + ]) +} + +test_a_grep_that_consumes_its_whole_input_is_not_the_hazard if { + count(violation) == 0 with input as scan(["producer | grep thing"]) +} + +# `||` IS NOT A PIPE, and this is the case the predecessor's own scan failed: +# it matched the second bar and reported the recommended remedy as the defect. +test_an_or_before_grep_is_not_a_pipe if { + count(violation) == 0 with input as scan(["[[ -n \"$x\" ]] || grep -qE 'p' <<<\"$var\""]) +} + +test_q_after_the_separator_is_a_pattern_not_a_flag if { + count(violation) == 0 with input as scan(["producer | grep -- -q"]) +} + +test_a_file_that_does_not_enable_pipefail_is_out_of_scope if { + count(violation) == 0 with input as {"tree": {"lines": {"mise-tasks/demo.sh": ["producer | grep -q thing"]}}} +} + +test_a_comment_describing_the_hazard_is_not_the_hazard if { + count(violation) == 0 with input as scan(["# never write producer | grep -q thing"]) +} + +#MUTANT-SUITE crates/batten/tests/it/pipefail_grep.rs +#MUTANT pipefail-cluster-unread|s@letter in {"q", "l"}@false@|a_flag_cluster_is_judged_by_its_letters_over_the_binary +#MUTANT pipefail-or-is-a-pipe|s@data.batten.patterns\["pipe-into-grep"\]@"grep"@|an_or_before_grep_is_not_a_pipe +#MUTANT pipefail-scope-unread|s@enables_pipefail(path)$@true@|a_file_that_does_not_enable_pipefail_is_out_of_scope diff --git a/policy/report-only.rego b/policy/report-only.rego new file mode 100644 index 000000000..0b909bd51 --- /dev/null +++ b/policy/report-only.rego @@ -0,0 +1,163 @@ +# METADATA +# description: | +# A report task stays off the landing path — CLOUD-582, ported from +# `mise-tasks/report-only-check.sh` under CLOUD-843. +# +# Two tasks here are REPORTS rather than gates, `coverage` (CLOUD-111) and +# `scorecard`, and both were non-gates by a decision no exit code held. Each +# carries a long comment saying so, which is feedforward with no sensor: the +# whole of what kept them off the landing path was that nobody added them. +# +# THE FAILURE IS QUIET AND ONE-DIRECTIONAL. Adding a report to `verify` or to a +# `pull_request` workflow makes it a gate, and it then fails a branch for a +# NUMBER — a coverage percentage, or a scorecard check counting a deliberate +# design decision as a defect. That is the model-verdict shape non-negotiable +# rule 3 refuses, arriving as a one-line diff nobody reads as a policy change. +# +# NOT PART OF `ci-local-parity`, which parses the same two inputs. That gate +# asserts the FORWARD direction — every task CI runs is one `verify` runs. This +# is the INVERSE: the named tasks appear in neither. Composing them would give +# one task two properties that fail for opposite reasons, and the deny would +# have to explain which. +# +# WHICH TASKS, AND WHY THE LIST LIVES HERE. A report is not a syntactic +# category — nothing in a task's declaration says "this emits a number nobody +# may block on" — so the set is a judgement, made once, written where the gate +# enforcing it can be read beside it. Adding a name is a decision to keep it off +# the landing path forever; removing one is a decision to allow it on, and both +# are visible in the same diff as the gate. This is a consumer module, so the +# consumer's own judgement belongs in it. +# +# THE SCHEDULE IS THE POINT, NOT A VIOLATION. Only the `pull_request` trigger is +# judged: a scheduled workflow running a report is why the report exists. +# +# THE SUCCESSOR READS PARSED DOCUMENTS WHERE THE SHELL READ TEXT, and that is +# strictly better rather than merely different. The predecessor carved +# `[tasks.verify]`'s span out of `mise.toml` with awk and then word-bounded a +# grep so `coverage` was not satisfied by `COVERAGE_OUT_DIR`. A parsed document +# has no span to carve and no boundary to get right: `depends` is a list of +# task names and the body is a string, so a name either IS an entry or appears +# in the text, and the two are asked separately. +# +# Pointer-only (non-negotiable rule 4): the task name and where it was found, +# never the matched line. +# +# THIS BLOCK IS YAML AND MUST STAY THE LAST COMMENT BLOCK BEFORE `package`. +# schemas: +# - input: schema["policy-input.schema"] +package batten.report_only + +import rego.v1 + +rules contains "report-only" + +manifest := "mise.toml" + +# The consumer's judgement, written beside the gate that enforces it. +reports := {"coverage", "scorecard"} + +verify := task if { + task := input.tree.documents[manifest].tasks.verify +} + +# THE COULD-NOT-LOOK ARM. No `[tasks.verify]` means there is nothing to judge a +# report against, and reporting that as clean would be the false green the +# predecessor spent an explicit exit 2 on. +violation contains { + "rule": "report-only", + "verdict": "task declare dropped", + "subjects": [{"path": manifest}], +} if { + input.tree.documents[manifest] + not verify +} + +# A report named in `verify`'s own `depends` list. +violation contains { + "rule": "report-only", + "verdict": "task run loose", + "subjects": [{"path": manifest}], +} if { + some task in reports + some named in verify.depends + named == task +} + +# Or reached from its body. A string rather than a list, so this is a text +# question — and it is the one the predecessor's `COVERAGE_OUT_DIR` case is +# about: the invocation is what makes it a gate, so the spelling that counts is +# `mise run ` rather than the bare name. +violation contains { + "rule": "report-only", + "verdict": "task run loose", + "subjects": [{"path": manifest}], +} if { + some task in reports + contains(verify.run, sprintf("mise run %s", [task])) +} + +# The other way onto the landing path, and the one `verify` cannot see. +violation contains { + "rule": "report-only", + "verdict": "task run loose", + "subjects": [{"path": path}], +} if { + some path, doc in input.tree.documents + startswith(path, ".github/workflows/") + doc.on.pull_request + some _, job in doc.jobs + some step in job.steps + some task in reports + contains(step.run, sprintf("mise run %s", [task])) +} + +# --- the load-time tier ------------------------------------------------------ + +verify_task(task) := {"tree": {"documents": {"mise.toml": {"tasks": {"verify": task}}}}} + +test_a_clean_manifest_passes if { + count(violation) == 0 with input as verify_task({"depends": ["ci"], "run": "mise run land-divergence"}) +} + +test_a_report_in_verifys_depends_is_refused if { + some v in violation with input as verify_task({"depends": ["ci", "coverage"], "run": ""}) + v.verdict == "task run loose" +} + +test_a_report_invoked_by_verifys_body_is_refused if { + count(violation) == 1 with input as verify_task({"depends": ["ci"], "run": "mise run scorecard"}) +} + +# THE BOUNDARY CASE the predecessor word-bounded a grep for. A parsed `depends` +# is a list of names, so a longer identifier is simply a different entry. +test_a_longer_identifier_merely_containing_the_name_does_not_fire if { + count(violation) == 0 with input as verify_task({ + "depends": ["coverage-report-check"], + "run": "echo $COVERAGE_OUT_DIR", + }) +} + +test_no_verify_task_is_could_not_look if { + some v in violation with input as {"tree": {"documents": {"mise.toml": {"tasks": {}}}}} + v.verdict == "task declare dropped" +} + +workflow(trigger, run) := {"tree": {"documents": { + "mise.toml": {"tasks": {"verify": {"depends": ["ci"], "run": ""}}}, + ".github/workflows/report.yml": {"on": trigger, "jobs": {"j": {"steps": [{"run": run}]}}}, +}}} + +test_a_report_run_on_pull_request_is_refused if { + some v in violation with input as workflow({"pull_request": {}}, "mise run coverage") + v.verdict == "task run loose" +} + +# A SCHEDULED workflow running a report is the whole point of the report. +test_a_report_run_on_a_schedule_is_the_point_not_a_violation if { + count(violation) == 0 with input as workflow({"schedule": [{"cron": "0 0 * * 0"}]}, "mise run coverage") +} + +#MUTANT-SUITE crates/batten/tests/it/report_only.rs +#MUTANT report-on-pull-request-passes|s@workflow.on.pull_request@true@|a_report_run_on_a_schedule_is_the_point_not_a_violation +#MUTANT report-in-depends-unread|s@named == task@false@|a_report_in_verifys_depends_is_refused_over_the_binary +#MUTANT report-no-verify-unread|s@not verify$@false@|no_verify_task_is_could_not_look diff --git a/tests/awk-regex-check.bats b/tests/awk-regex-check.bats deleted file mode 100644 index a0851cf1a..000000000 --- a/tests/awk-regex-check.bats +++ /dev/null @@ -1,102 +0,0 @@ -#!/usr/bin/env bats -# subject: mise-tasks/awk-regex-check.sh -# A pattern passed through `awk -v` goes through assignment escape processing -# before awk sees it as a regex, and what that does to a backslash is undefined -# across implementations. gawk strips `\(` to `(`; mawk keeps it. -# -# The failure this encodes: `ready-lint` matched its §8 label that way, worked on -# mawk locally, matched NOTHING on the gawk runner — so the clause that catches a -# blocker claimed without a relation went back to passing silently, taking three -# older tests with it. A gate that cannot match its own label does not fail. -# -# The predicate is the USE, not the value. A literal without a backslash is safe -# today and unsafe the moment someone adds one, and a variable's runtime content -# is invisible to any static check. - -setup() { - GATE="$BATS_TEST_DIRNAME/../mise-tasks/awk-regex-check.sh" - REPO="$BATS_TEST_TMPDIR/repo-$BATS_TEST_NUMBER" - mkdir -p "$REPO/mise-tasks" - cd "$REPO" || return 1 - git init -q . - git config user.email t@t - git config user.name t -} - -# Writes $1 as a task body and stages it, since the gate reads tracked files. -task() { - printf '#!/usr/bin/env bash\n%s\n' "$1" >mise-tasks/subject.sh - git add -A -} - -@test "this repo's own tasks pass today" { - cd "$BATS_TEST_DIRNAME/.." || return 1 - run "$GATE" - [ "$status" -eq 0 ] -} - -@test "the real bug: a -v name used with ~ is reported" { - task 'x=$(awk -v re="$LABEL" '"'"'!f && $0 ~ re { f=1; print }'"'"' <<<"$b")' - run "$GATE" - [ "$status" -eq 1 ] - [[ "$output" == *"assigned with -v and used as a regex"* ]] -} - -@test "the report names file and line, not the whole command" { - task 'x=$(awk -v re="$L" '"'"'$0 ~ re'"'"')' - run "$GATE" - [[ "$output" == *"mise-tasks/subject.sh:2"* ]] -} - -@test "match() is regex position too" { - task 'x=$(awk -v pat="$P" '"'"'{ if (match($0, pat)) print }'"'"')' - run "$GATE" - [ "$status" -eq 1 ] -} - -@test "a -v value compared with == is fine — that is most of its use" { - task 'x=$(awk -F"\t" -v t="$tool" '"'"'$1 == t { print $2 }'"'"')' - run "$GATE" - [ "$status" -eq 0 ] -} - -@test "a -v value printed or counted is fine" { - task 'x=$(awk -v n="$count" '"'"'END { print n + NR }'"'"')' - run "$GATE" - [ "$status" -eq 0 ] -} - -@test "an inline regex in the awk program is the recommended form, not a finding" { - # No assignment processing happens, so backslashes survive intact. - task 'x=$(awk '"'"'/^[[:space:]]*Blockers \(/ { print }'"'"')' - run "$GATE" - [ "$status" -eq 0 ] -} - -@test "a name that merely PREFIXES another is not confused for it" { - # `re` assigned, `remainder` used with ~ — different names. - task 'x=$(awk -v re="$L" '"'"'$0 ~ remainder { print re }'"'"')' - run "$GATE" - [ "$status" -eq 0 ] -} - -@test "several -v assignments on one line are each judged" { - task 'x=$(awk -v a="$A" -v b="$B" '"'"'$1 == a && $0 ~ b { print }'"'"')' - run "$GATE" - [ "$status" -eq 1 ] - [[ "$output" == *'`b`'* ]] - [[ "$output" != *'`a`'* ]] -} - -@test "an untracked file is not judged — the gate reads what is committed" { - printf '#!/usr/bin/env bash\nx=$(awk -v re="$L" '"'"'$0 ~ re'"'"')\n' >mise-tasks/untracked.sh - run "$GATE" - [ "$status" -eq 0 ] -} - -@test "a tree with no awk at all passes rather than erroring" { - task 'echo hello' - run "$GATE" - [ "$status" -eq 0 ] - [[ "$output" == *"no regex reaches awk"* ]] -} diff --git a/tests/license-table-check.bats b/tests/license-table-check.bats deleted file mode 100644 index c58b7d485..000000000 --- a/tests/license-table-check.bats +++ /dev/null @@ -1,104 +0,0 @@ -#!/usr/bin/env bats -# subject: mise-tasks/license-table-check.sh -# The gate that ships with the license-table release precondition (CLOUD-325). -# -# The precondition was prose for the table's whole life and nothing failed while -# three of five rows were unresolved. These cases pin the two directions that -# matter: an open row must fail, and a resolved table must pass — plus the -# vacuous case, where a table the parser cannot find would otherwise satisfy -# every per-row assertion by having no rows to check. - -setup() { - CHECK="$BATS_TEST_DIRNAME/../mise-tasks/license-table-check.sh" - cd "$BATS_TEST_DIRNAME/.." || return 1 -} - -# A table with the same shape as the real one, so a fixture exercises the parser -# rather than a simplified stand-in. -write_table() { - # $1 = destination, $2 = license cell, $3 = compatibility cell - cat >"$1" <"$BATS_TEST_TMPDIR/t.md" - run "$CHECK" "$BATS_TEST_TMPDIR/t.md" - [ "$status" -ne 0 ] - [[ "$output" == *"no license rows"* ]] -} - -@test "an unreadable file is exit 1 — could not look is not a verdict" { - run "$CHECK" "$BATS_TEST_TMPDIR/does-not-exist.md" - [ "$status" -eq 1 ] -} - -@test "output is a pointer — it names the tool and the cell, never the table body" { - write_table "$BATS_TEST_TMPDIR/t.md" "_to confirm_" "_to confirm_" - run "$CHECK" "$BATS_TEST_TMPDIR/t.md" - [[ "$output" != *"Preamble prose"* ]] - [[ "$output" != *"Trailing prose"* ]] -} diff --git a/tests/module-map-check.bats b/tests/module-map-check.bats deleted file mode 100644 index f3a212f40..000000000 --- a/tests/module-map-check.bats +++ /dev/null @@ -1,89 +0,0 @@ -#!/usr/bin/env bats -# subject: mise-tasks/module-map-check.sh -# module-map-check's decision table (CLOUD-194): mem:core's completeness as an -# exit code. Fixtures are real git trees, since the gate walks `git ls-files`. - -setup() { - CHECK="$BATS_TEST_DIRNAME/../mise-tasks/module-map-check.sh" - ROOT="$BATS_TEST_TMPDIR/repo" - MAP="$ROOT/.serena/memories/core.md" - mkdir -p "$ROOT/.serena/memories" "$ROOT/crates/demo/src" - git init -q "$ROOT" - git -C "$ROOT" config user.email t@example.com - git -C "$ROOT" config user.name t - export MODMAP_ROOT="$ROOT" -} - -commit_all() { - git -C "$ROOT" add -A && git -C "$ROOT" commit -qm x -} - -@test "a module with a map row exits 0" { - echo "fn main() {}" >"$ROOT/crates/demo/src/main.rs" - printf -- '- `main.rs` — the binary boundary.\n' >"$MAP" - commit_all - run "$CHECK" - [ "$status" -eq 0 ] -} - -@test "a module with no map row is reported with a pointer" { - echo "pub fn f() {}" >"$ROOT/crates/demo/src/severity.rs" - printf -- '- `main.rs` — the binary boundary.\n' >"$ROOT/.serena/memories/core.md" - echo "fn main() {}" >"$ROOT/crates/demo/src/main.rs" - commit_all - run "$CHECK" - [ "$status" -eq 1 ] - [[ "$output" == *"crates/demo/src/severity.rs:0 module-map-missing-row (severity.rs)"* ]] -} - -@test "output is pointer-only — no map or source prose echoed" { - echo "pub fn secret_helper() {}" >"$ROOT/crates/demo/src/hidden.rs" - printf -- '- `main.rs` — the binary boundary, a distinctive phrase.\n' >"$MAP" - commit_all - run "$CHECK" - [ "$status" -eq 1 ] - [[ "$output" != *"secret_helper"* ]] - [[ "$output" != *"distinctive phrase"* ]] -} - -@test "an untracked module is not yet the map's problem" { - printf -- '- `main.rs` — the binary boundary.\n' >"$MAP" - echo "fn main() {}" >"$ROOT/crates/demo/src/main.rs" - commit_all - # Written but never added: `git ls-files` does not see it, so neither does - # the gate. The row is owed when the module lands, not while it is a draft. - echo "pub fn f() {}" >"$ROOT/crates/demo/src/draft.rs" - run "$CHECK" - [ "$status" -eq 0 ] -} - -@test "a bare filename mention does not satisfy the row" { - # The map names modules in backticks. A prose mention elsewhere (a sentence - # about severity.rs, say) must not read as a row, or the gate passes on the - # very drift it exists to catch. - echo "pub fn f() {}" >"$ROOT/crates/demo/src/severity.rs" - printf -- 'Note: severity.rs is described in another memory.\n' >"$MAP" - commit_all - run "$CHECK" - [ "$status" -eq 1 ] - [[ "$output" == *"module-map-missing-row (severity.rs)"* ]] -} - -@test "a missing map is reported once, not once per module" { - echo "fn main() {}" >"$ROOT/crates/demo/src/main.rs" - echo "pub fn f() {}" >"$ROOT/crates/demo/src/other.rs" - rm -f "$MAP" - commit_all - run "$CHECK" - [ "$status" -eq 1 ] - [[ "$output" == *"module-map-missing"* ]] - [ "$(grep -c "module-map-missing-row" <<<"$output" || true)" -eq 0 ] -} - -@test "every module of this repo has a row — the gate on the real tree" { - # The self-consumption case: run against the actual repository, which is the - # assertion that this change also fixed the gap it was written for. - unset MODMAP_ROOT - run "$CHECK" - [ "$status" -eq 0 ] -} diff --git a/tests/no-doctests.bats b/tests/no-doctests.bats deleted file mode 100644 index 6a6b86742..000000000 --- a/tests/no-doctests.bats +++ /dev/null @@ -1,132 +0,0 @@ -#!/usr/bin/env bats -# subject: mise-tasks/no-doctests.sh -# The gate that ships with the nextest swap (AGENTS.md non-negotiable 2, -# CLOUD-813). `[tasks."test:cargo"]` runs `cargo nextest run`, which does not -# execute doctests. That was safe to land only because the class is empty here — -# `cargo test --doc --workspace` reported `0 passed; 0 failed` on 2026-08-21 — -# and an empty class is a measurement, not an invariant. -# -# The failure it exists to catch is silent and is the worst shape a test can -# take: a doc example that never runs, which a reader trusts precisely because it -# is executable. Nothing else in the tree would go red. -# -# Driven against fixture trees rather than the real crate, so the suite can hold -# a fence the committed tree must never contain. The committed tree is asserted -# too — that row is the regression test for the workspace itself. - -setup() { - load helpers - CHECK="$BATS_TEST_DIRNAME/../mise-tasks/no-doctests.sh" - REPO="$BATS_TEST_TMPDIR/repo" - mkdir -p "$REPO/crates/x/src" - cd "$REPO" || return 1 - git init -q . - git config user.email t@example.com - git config user.name t -} - -# Writes lib.rs, tracks it, and answers from the fixture root. -fixture() { - printf '%s\n' "$1" >"$REPO/crates/x/src/lib.rs" - git -C "$REPO" add -A -} - -@test "the committed workspace carries no runnable doctest" { - cd "$BATS_TEST_DIRNAME/.." || return 1 - run "$CHECK" - [ "$status" -eq 0 ] - [[ "$output" == *"no runnable doctest fence"* ]] -} - -@test "an unattributed fence in a doc comment is refused" { - # The discriminating row: this is the shape that compiles and runs under - # `cargo test` and is skipped entirely by nextest. - fixture '/// Example -/// ``` -/// let a = 1; -/// ``` -pub fn f() {}' - run "$CHECK" crates - [ "$status" -eq 1 ] - [[ "$output" == *"crates/x/src/lib.rs:2"* ]] - [[ "$output" == *"info=(none)"* ]] -} - -@test "the refusal is a pointer, never the example" { - # Non-negotiable 4: a doc example can carry anything its author wrote. - fixture '/// Example -/// ``` -/// let the_secret = "hunter2"; -/// ``` -pub fn f() {}' - run "$CHECK" crates - [ "$status" -eq 1 ] - [[ "$output" != *"hunter2"* ]] -} - -@test "a text fence is not a doctest" { - # Both fences in the committed crate are `text`, which is why the class is - # empty; a gate that flagged them would be unusable and would get bypassed. - fixture '//! ```text -//! deny-stop <=> at-risk work -//! ``` -pub fn f() {}' - run "$CHECK" crates - [ "$status" -eq 0 ] -} - -@test "ignore, compile_fail and no_run are all non-running" { - # `no_run` compiles but does not execute, so nextest skipping it costs - # nothing — the gate is about EXECUTION, not compilation. - fixture '/// ```ignore -/// one -/// ``` -/// ```compile_fail -/// two -/// ``` -/// ```no_run -/// three -/// ``` -pub fn f() {}' - run "$CHECK" crates - [ "$status" -eq 0 ] -} - -@test "a closing fence is not read as an unattributed opening one" { - # THE PARSE THAT MATTERS: a closing fence carries no info string, so a - # scanner that did not track open/closed would report every `text` block as - # runnable — and a gate with false positives gets bypassed, which enforces - # nothing. - fixture '/// ```text -/// one -/// ``` -pub fn f() {}' - run "$CHECK" crates - [ "$status" -eq 0 ] -} - -@test "a fence outside a doc comment is not a doctest" { - # A fence in an ordinary `//` comment is prose. rustdoc never sees it. - fixture '// ``` -// let a = 1; -// ``` -pub fn f() {}' - run "$CHECK" crates - [ "$status" -eq 0 ] -} - -@test "a root with no tracked .rs file is could-not-look, not clean" { - # The anti-vacuity term. A scan matching nothing would report "no runnable - # doctest" over nothing, which is the reads-as-coverage defect CLOUD-418 - # names — and it is exactly what a moved crate directory would produce. - git -C "$REPO" rm -q --cached -r . 2>/dev/null || true - run "$CHECK" crates - [ "$status" -eq 2 ] - [[ "$output" == *"could-not-look"* ]] -} - -@test "a missing root is could-not-look, not clean" { - run "$CHECK" nope - [ "$status" -eq 2 ] - [[ "$output" == *"could-not-look"* ]] -} diff --git a/tests/pipefail-grep-check.bats b/tests/pipefail-grep-check.bats deleted file mode 100644 index 7b3e20eaf..000000000 --- a/tests/pipefail-grep-check.bats +++ /dev/null @@ -1,142 +0,0 @@ -#!/usr/bin/env bats -# subject: mise-tasks/pipefail-grep-check.sh -# `producer | grep -q P` under `set -o pipefail` can return FAILURE on a match: -# grep exits at the first hit, a producer still writing dies of SIGPIPE, and -# pipefail promotes 141 to the pipeline's status. The successful case is the one -# that reports failure — and it is a RACE, measured at 2 failures in 300 runs on -# a two-commit range, so it passes review and passes tests. -# -# Two instances landed here before the class was named. `landed-check` reported -# a clean board over three landed issues. `issue-guard` denied `gh pr ready` on -# a branch whose every commit carried `Refs: CLOUD-186`, with a reason asserting -# the opposite of what it had just found — it blocked its own PR. -# -# Both fail toward the verdict nobody checks, which is why a green suite that -# only feeds non-matching input never sees it. - -setup() { - GATE="$BATS_TEST_DIRNAME/../mise-tasks/pipefail-grep-check.sh" - REPO="$BATS_TEST_TMPDIR/repo-$BATS_TEST_NUMBER" - mkdir -p "$REPO/mise-tasks" - cd "$REPO" || return 1 - git init -q . - git config user.email t@t - git config user.name t -} - -# Writes $1 as a task body and stages it, since the gate reads tracked files. -task() { - printf '%s\n' "$1" >mise-tasks/t.sh - git add -A -} - -@test "the exact shape that broke issue-guard is flagged" { - task 'set -euo pipefail -git log --format="%B" origin/main..HEAD | grep -qiE "$ISSUE_RE" && exit 0' - run "$GATE" - [ "$status" -eq 1 ] - [[ "$output" == *"mise-tasks/t.sh:2"* ]] -} - -@test "the here-string fix passes" { - task 'set -euo pipefail -log=$(git log --format="%B" origin/main..HEAD || true) -grep -qiE "$ISSUE_RE" <<<"$log" && exit 0' - run "$GATE" - [ "$status" -eq 0 ] -} - -@test "a flag cluster is judged by its letters, not its spelling" { - # -qxF and -oq are the same hazard as -q; an enumeration of spellings rots. - task 'set -euo pipefail -rustup target list --installed | grep -qxF "$target"' - run "$GATE" - [ "$status" -eq 1 ] -} - -@test "--quiet is the same hazard under its long name" { - task 'set -euo pipefail -producer | grep --quiet PATTERN' - run "$GATE" - [ "$status" -eq 1 ] -} - -@test "-l stops at the first matching file, so it is flagged too" { - task 'set -euo pipefail -producer | grep -l PATTERN' - run "$GATE" - [ "$status" -eq 1 ] -} - -@test "-m N stops after N matches" { - task 'set -euo pipefail -producer | grep -m 1 PATTERN' - run "$GATE" - [ "$status" -eq 1 ] -} - -@test "a grep that consumes its whole input is not the hazard" { - # Without an early exit the producer never takes SIGPIPE, so the pipeline - # status is honest and the pipe is fine. - task 'set -euo pipefail -producer | grep -E PATTERN' - run "$GATE" - [ "$status" -eq 0 ] -} - -@test "an || before grep is not a pipe" { - # CLOUD-852. The scan was `\|[[:space:]]*grep`, which matches the SECOND bar - # of `a || grep -q ...` — so a here-string form, the very remedy this gate - # recommends, was reported as the defect. Measured on `mise-tasks/ready-lint.sh`, - # whose line pipes nothing. - task 'set -euo pipefail -[[ "$tok" == *x* ]] || grep -qE PATTERN <<<"$line"' - run "$GATE" - [ "$status" -eq 0 ] -} - -@test "a real pipe into an early-exiting grep is still caught alongside an ||" { - # The discriminator: the fix must not have turned the scan off. Same line - # shape, but a genuine producer pipe. - task 'set -euo pipefail -[[ "$tok" == *x* ]] || producer | grep -q PATTERN' - run "$GATE" - [ "$status" -eq 1 ] -} - -@test "a file that does not enable pipefail is out of scope" { - # Without pipefail the pipeline reports grep's status, which is correct. - task 'set -eu -producer | grep -q PATTERN' - run "$GATE" - [ "$status" -eq 0 ] -} - -@test "a comment describing the hazard is not the hazard" { - task 'set -euo pipefail -# Never write `producer | grep -q P` here — pipefail inverts it. -echo ok' - run "$GATE" - [ "$status" -eq 0 ] -} - -@test "-q after -- is a pattern, not a flag" { - task 'set -euo pipefail -producer | grep -- -q' - run "$GATE" - [ "$status" -eq 0 ] -} - -@test "output is a pointer — file:line and the fix, never the matched content" { - task 'set -euo pipefail -producer | grep -q "s3://acct-1234567890/secret"' - run "$GATE" - [ "$status" -eq 1 ] - [[ "$output" != *"acct-1234567890"* ]] -} - -@test "an untracked file is not judged — the gate reads committed bytes" { - printf 'set -euo pipefail\nproducer | grep -q P\n' >mise-tasks/t.sh - run "$GATE" - [ "$status" -eq 0 ] -} diff --git a/tests/report-only-check.bats b/tests/report-only-check.bats deleted file mode 100644 index 395a0bb04..000000000 --- a/tests/report-only-check.bats +++ /dev/null @@ -1,102 +0,0 @@ -#!/usr/bin/env bats -# subject: mise-tasks/report-only-check.sh -# CLOUD-582. `coverage` and `scorecard` are reports, not gates, and until this -# gate existed the only thing keeping them off the landing path was that nobody -# had added them to it — a decision held by a comment, which is feedforward with -# no sensor. -# -# The two ways onto that path are independent and fail for the same reason, so -# both are exercised: `[tasks.verify]` (what an agent runs before readying) and -# a `pull_request` workflow (what CI spends a runner on per push). - -setup() { - GATE="$BATS_TEST_DIRNAME/../mise-tasks/report-only-check.sh" - MANIFEST="$BATS_TEST_TMPDIR/mise.toml" - WORKFLOWS="$BATS_TEST_TMPDIR/workflows" - mkdir -p "$WORKFLOWS" - export REPORT_ONLY_MANIFEST="$MANIFEST" - export REPORT_ONLY_WORKFLOWS="$WORKFLOWS" - export REPORT_ONLY_TASKS="coverage scorecard" -} - -# A `[tasks.verify]` block whose depends list is exactly the arguments. -verify_with() { - local deps="" - local d - for d in "$@"; do deps="$deps\"$d\", "; done - printf '[tasks.verify]\ndepends = [%s]\nrun = """\necho hi\n"""\n\n[tasks.other]\n' "${deps%, }" >"$MANIFEST" -} - -workflow() { - printf 'on:\n%s\njobs:\n x:\n steps:\n - run: %s\n' "$2" "$3" >"$WORKFLOWS/$1" -} - -@test "the repo's real manifest and workflows are clean today" { - run env -u REPORT_ONLY_MANIFEST -u REPORT_ONLY_WORKFLOWS -u REPORT_ONLY_TASKS "$GATE" - [ "$status" -eq 0 ] - [[ "$output" == *"off the landing path"* ]] -} - -@test "a report named in [tasks.verify] is refused" { - verify_with tree-clean ci scorecard - run "$GATE" - [ "$status" -eq 1 ] - [[ "$output" == *"scorecard"* ]] - [[ "$output" == *"[tasks.verify] names it"* ]] -} - -@test "a report run by a pull_request workflow is refused" { - verify_with tree-clean ci - workflow "pr.yml" " pull_request:" "mise run coverage" - run "$GATE" - [ "$status" -eq 1 ] - [[ "$output" == *"coverage"* ]] - [[ "$output" == *"pr.yml"* ]] -} - -@test "a report run by a SCHEDULED workflow is the point, not a violation" { - verify_with tree-clean ci - workflow "weekly.yml" " schedule:\n - cron: \"0 9 * * 1\"" "mise run scorecard" - run "$GATE" - [ "$status" -eq 0 ] -} - -# The substring hazard. A name glued into a longer identifier is not a run of -# the task, and treating it as one is how a gate becomes noise and gets -# switched off. -@test "a longer identifier merely containing the name does not fire" { - printf '[tasks.verify]\ndepends = ["ci"]\nrun = """\necho "$coverage_out_dir"\n"""\n\n[tasks.other]\n' >"$MANIFEST" - run "$GATE" - [ "$status" -eq 0 ] -} - -# The other side of that boundary, and it is a real fire rather than a tolerated -# false positive: `verify` naming the report's own output path means `verify` is -# producing the report, which is the thing being refused. -@test "the report's output path in verify's body does fire" { - printf '[tasks.verify]\ndepends = ["ci"]\nrun = """\nmkdir -p target/coverage\n"""\n\n[tasks.other]\n' >"$MANIFEST" - run "$GATE" - [ "$status" -eq 1 ] - [[ "$output" == *"coverage"* ]] -} - -@test "both routes are reported together, not one at a time" { - verify_with tree-clean scorecard - workflow "pr.yml" " pull_request:" "mise run coverage" - run "$GATE" - [ "$status" -eq 1 ] - [[ "$output" == *"2 violation(s)"* ]] -} - -@test "a manifest with no [tasks.verify] cannot be judged, and says so" { - printf '[tasks.other]\n' >"$MANIFEST" - run "$GATE" - [ "$status" -eq 2 ] - [[ "$output" == *"nothing to judge"* ]] -} - -@test "a missing manifest is exit 2, never a pass" { - export REPORT_ONLY_MANIFEST="$BATS_TEST_TMPDIR/absent.toml" - run "$GATE" - [ "$status" -eq 2 ] -} From f5549430000a4115a3e34b54410bb4f0a3109fc7 Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Wed, 9 Sep 2026 19:24:59 +0000 Subject: [PATCH 02/29] fix(test): drop the tracker key from the awk-regex inline fixture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `no-tracker-key-in-core` refuses a `CLOUD-` literal inside `crates/**`. The fixture only needs an anchored character class for awk to match against, so the prefix carries no meaning here — `ISSUE-` states the same shape without naming this repository's tracker inside the core. --- crates/batten/tests/it/awk_regex.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/batten/tests/it/awk_regex.rs b/crates/batten/tests/it/awk_regex.rs index 22814a7d4..a4d6041a5 100644 --- a/crates/batten/tests/it/awk_regex.rs +++ b/crates/batten/tests/it/awk_regex.rs @@ -140,7 +140,7 @@ fn a_name_that_prefixes_another_is_not_confused_for_it() { fn an_inline_regex_is_the_recommended_form_not_a_finding() { let dir = awk_repo( "awk-regex-inline", - "#!/usr/bin/env bash\nawk '$0 ~ /^CLOUD-[0-9]+$/' file\n", + "#!/usr/bin/env bash\nawk '$0 ~ /^ISSUE-[0-9]+$/' file\n", ); assert_eq!(check(&dir).status.code(), Some(0)); } From 4d4659ccf3ff9dfc16b4589b68688a1a9529804d Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Wed, 9 Sep 2026 19:44:24 +0000 Subject: [PATCH 03/29] feat(policy): retire batten-glob-check onto glob-containment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `batten-check` step's `glob` is a second authority over the set batten.toml already defines, and a second authority narrows silently: a `[[rule]]` naming a path the step does not select stops gating that path with nothing going red. `policy/glob-containment.rego` decides the containment over the two committed files; `crates/batten/tests/it/glob_containment.rs` drives it over whole fixture repositories and over this repository itself. The defect the retired gate actually shipped is pinned as a case: a comment inside the `List(...)` carried a parenthesised tracker key whose `)` ended the parse early, so every entry below it read as uncovered. Comments are skipped, and skipping is preferred to being clever about the paren — mis-parsing in the reporting direction is survivable, dropping entries from the required set is not. Subsumption stays a `P/**` prefix test rather than general glob subsumption, and `a_slashless_entry_does_not_subsume_by_prefix` pins that the `/**` is what makes an entry a prefix at all. CLOUD-224 CLOUD-1716 Admits: 094420f42069af16800b2692fdbb734380b3c643121dd72641ce06489d8ac50f Admits-rule: protected-mutation Admits-verdict: path write refused Admits-subject: batten.toml Admits-anchor: call:d703d9534990a4f96c65951986264e53e7200fcd Admits-epoch: 9a52094cca1f7523dc7bef5e37ca34c0de921b485f0ccbf95f458f6ba24afcdd Admits-author: alec@wenzowski.com Admits-prev: a59bcc6146897ead2888b84396abb3cae8d2c59248ad2f7e1944545f170efb33 Admits-answer-lost: `mise-tasks/batten-glob-check.sh` cannot retire. A `policy/*.rego` module is inert until a `[[rule]]` row registers it, so without this write the ported predicate is a dead file: the shell program stays, its bats suite stays, and the containment this repository depends on is decided by a program the campaign has already replaced. Admits-answer-precondition: No `batten` verb authors a `[[rule]]` row. `batten config` exposes only `show`, `epoch`, `deprecations` and `lint`, every one a read, and `init` refuses to overwrite an existing config. CLOUD-843's retirement shape REQUIRES a rule row per retired program — `shell-retirement` refuses a deletion whose predicate has no policy surface — so writing batten.toml directly is the only route left. The write is one declarative row plus its comment, and a reviewer sees it in the diff it lands in, beside `policy/glob-containment.rego` and the tier that registers it. Admits-answer-rejected-route: `config read first` is the route this class prefers and it cannot reach: every `batten config` subcommand is a read, and none authors or edits a `[[rule]]`, `[[pattern]]` or `[[verdict]]` row. `patch run first` (`git restore`) is the undo route for an unwanted write, not a way to author config — restoring batten.toml would delete the row the ported module needs, which is the change itself rather than a safer spelling of it. --- batten.toml | 17 + bench/suites/RESULTS.md | 1 - contracts/hk.json | 88 ++--- crates/batten/tests/it/glob_containment.rs | 365 ++++++++++++++++++++ crates/batten/tests/it/main.rs | 1 + hk.pkl | 33 +- mise-tasks/batten-glob-check.sh | 190 ----------- mise.toml | 2 +- policy/glob-containment.rego | 378 +++++++++++++++++++++ tests/batten-glob-check.bats | 180 ---------- 10 files changed, 810 insertions(+), 445 deletions(-) create mode 100644 crates/batten/tests/it/glob_containment.rs delete mode 100755 mise-tasks/batten-glob-check.sh create mode 100644 policy/glob-containment.rego delete mode 100644 tests/batten-glob-check.bats diff --git a/batten.toml b/batten.toml index dc36ebea7..8b09ec21d 100644 --- a/batten.toml +++ b/batten.toml @@ -7018,6 +7018,23 @@ line_sources = ["crates/**/*.rs"] module = "policy/no-doctests.rego" severity = "deny" +# CLOUD-224, ported from `mise-tasks/batten-glob-check.sh` under CLOUD-843. The +# `batten-check` step's `glob` is a SECOND authority over the set this config +# already defines, and a second authority narrows silently: add a `[[rule]]` whose +# glob names a path the step does not select, and the gate stops running for +# commits that touch only that path with nothing going red. The whole-tree run in +# CI still covers it, so the symptom is a rule that quietly does not gate a +# branch — feedback deleted, verdict preserved. `line_sources` names both halves +# because the containment is a pure function of the two committed files, and +# because editing either one is a reason to re-decide it. +[[rule]] +id = "glob-containment" +kind = "policy" +scope = "tree" +line_sources = ["batten.toml", "hk.pkl"] +module = "policy/glob-containment.rego" +severity = "deny" + [[rule]] id = "module-map" kind = "policy" diff --git a/bench/suites/RESULTS.md b/bench/suites/RESULTS.md index 9009faf08..a2fe4f25c 100644 --- a/bench/suites/RESULTS.md +++ b/bench/suites/RESULTS.md @@ -87,7 +87,6 @@ to it pays. | 0.7 | 0.2% | `tests/commit-convention.bats` | | 0.7 | 0.2% | `tests/cap-drift.bats` | | 0.7 | 0.2% | `tests/release-due.bats` | -| 0.6 | 0.2% | `tests/batten-glob-check.bats` | | 0.6 | 0.2% | `tests/connector-allow-resolve.bats` | | 0.5 | 0.2% | `tests/container-preflight.bats` | | 0.5 | 0.2% | `tests/coderabbit-config-check.bats` | diff --git a/contracts/hk.json b/contracts/hk.json index d41202aa5..04524cbea 100644 --- a/contracts/hk.json +++ b/contracts/hk.json @@ -42,7 +42,6 @@ "timeout-check", "mise-action-floor", "policy-budget", - "batten-glob-check", "skill-check", "macos-link-check", "shfmt", @@ -246,124 +245,118 @@ "orderIndex": 29, "parallelGroupId": "group_0" }, - { - "name": "batten-glob-check", - "status": "included", - "orderIndex": 30, - "parallelGroupId": "group_0" - }, { "name": "skill-check", "status": "included", - "orderIndex": 31, + "orderIndex": 30, "parallelGroupId": "group_0" }, { "name": "macos-link-check", "status": "included", - "orderIndex": 32, + "orderIndex": 31, "parallelGroupId": "group_0" }, { "name": "shfmt", "status": "included", - "orderIndex": 33, + "orderIndex": 32, "parallelGroupId": "group_0" }, { "name": "shellcheck", "status": "included", - "orderIndex": 34, + "orderIndex": 33, "parallelGroupId": "group_0" }, { "name": "test:bats", "status": "included", - "orderIndex": 35, + "orderIndex": 34, "parallelGroupId": "group_0" }, { "name": "taplo", "status": "included", - "orderIndex": 36, + "orderIndex": 35, "parallelGroupId": "group_0" }, { "name": "taplo-format", "status": "included", - "orderIndex": 37, + "orderIndex": 36, "parallelGroupId": "group_0" }, { "name": "pkl", "status": "included", - "orderIndex": 38, + "orderIndex": 37, "parallelGroupId": "group_0" }, { "name": "pkl-format", "status": "included", - "orderIndex": 39, + "orderIndex": 38, "parallelGroupId": "group_0" }, { "name": "prettier", "status": "included", - "orderIndex": 40, + "orderIndex": 39, "parallelGroupId": "group_0" }, { "name": "deno-fmt", "status": "included", - "orderIndex": 41, + "orderIndex": 40, "parallelGroupId": "group_0" }, { "name": "rego", "status": "included", - "orderIndex": 42, + "orderIndex": 41, "parallelGroupId": "group_0" }, { "name": "actionlint", "status": "included", - "orderIndex": 43, + "orderIndex": 42, "parallelGroupId": "group_0" }, { "name": "cargo-fmt", "status": "included", - "orderIndex": 44, + "orderIndex": 43, "parallelGroupId": "group_0" }, { "name": "cargo-clippy", "status": "included", - "orderIndex": 45, + "orderIndex": 44, "parallelGroupId": "group_0" }, { "name": "test", "status": "included", - "orderIndex": 46, + "orderIndex": 45, "parallelGroupId": "group_0" }, { "name": "batten-check", "status": "included", - "orderIndex": 47, + "orderIndex": 46, "parallelGroupId": "group_0" }, { "name": "policy-test", "status": "included", - "orderIndex": 48, + "orderIndex": 47, "parallelGroupId": "group_0" }, { "name": "memories-check", "status": "included", - "orderIndex": 49, + "orderIndex": 48, "parallelGroupId": "group_0" } ] @@ -473,7 +466,6 @@ "timeout-check", "mise-action-floor", "policy-budget", - "batten-glob-check", "skill-check", "macos-link-check", "shfmt", @@ -676,118 +668,112 @@ "orderIndex": 29, "parallelGroupId": "group_0" }, - { - "name": "batten-glob-check", - "status": "included", - "orderIndex": 30, - "parallelGroupId": "group_0" - }, { "name": "skill-check", "status": "included", - "orderIndex": 31, + "orderIndex": 30, "parallelGroupId": "group_0" }, { "name": "macos-link-check", "status": "included", - "orderIndex": 32, + "orderIndex": 31, "parallelGroupId": "group_0" }, { "name": "shfmt", "status": "included", - "orderIndex": 33, + "orderIndex": 32, "parallelGroupId": "group_0" }, { "name": "shellcheck", "status": "included", - "orderIndex": 34, + "orderIndex": 33, "parallelGroupId": "group_0" }, { "name": "test:bats", "status": "included", - "orderIndex": 35, + "orderIndex": 34, "parallelGroupId": "group_0" }, { "name": "taplo", "status": "included", - "orderIndex": 36, + "orderIndex": 35, "parallelGroupId": "group_0" }, { "name": "taplo-format", "status": "included", - "orderIndex": 37, + "orderIndex": 36, "parallelGroupId": "group_0" }, { "name": "pkl", "status": "included", - "orderIndex": 38, + "orderIndex": 37, "parallelGroupId": "group_0" }, { "name": "pkl-format", "status": "included", - "orderIndex": 39, + "orderIndex": 38, "parallelGroupId": "group_0" }, { "name": "prettier", "status": "included", - "orderIndex": 40, + "orderIndex": 39, "parallelGroupId": "group_0" }, { "name": "deno-fmt", "status": "included", - "orderIndex": 41, + "orderIndex": 40, "parallelGroupId": "group_0" }, { "name": "rego", "status": "included", - "orderIndex": 42, + "orderIndex": 41, "parallelGroupId": "group_0" }, { "name": "actionlint", "status": "included", - "orderIndex": 43, + "orderIndex": 42, "parallelGroupId": "group_0" }, { "name": "cargo-fmt", "status": "included", - "orderIndex": 44, + "orderIndex": 43, "parallelGroupId": "group_0" }, { "name": "cargo-clippy", "status": "included", - "orderIndex": 45, + "orderIndex": 44, "parallelGroupId": "group_0" }, { "name": "test", "status": "included", - "orderIndex": 46, + "orderIndex": 45, "parallelGroupId": "group_0" }, { "name": "batten-check", "status": "included", - "orderIndex": 47, + "orderIndex": 46, "parallelGroupId": "group_0" }, { "name": "policy-test", "status": "included", - "orderIndex": 48, + "orderIndex": 47, "parallelGroupId": "group_0" } ] diff --git a/crates/batten/tests/it/glob_containment.rs b/crates/batten/tests/it/glob_containment.rs new file mode 100644 index 000000000..0feb899a4 --- /dev/null +++ b/crates/batten/tests/it/glob_containment.rs @@ -0,0 +1,365 @@ +//! The hook step's trigger covers every path the config makes an input, over the +//! compiled binary (CLOUD-224, ported from `mise-tasks/batten-glob-check.sh` +//! under CLOUD-843). +//! +//! **What is decidable only here.** `policy/glob-containment.rego` carries +//! load-time cases pinning the predicate, and every one of them supplies +//! `input.tree.lines` with `with input as`. That fabricates the very shape the +//! engine may be unable to produce (CLOUD-845): whether a declared `line_sources` +//! path resolves to the COMMITTED bytes at all, and whether two separate paths +//! both resolve in one evaluation, is the engine's half and no `with input as` +//! block can test it. The retiring suite took its two files as positional +//! arguments so it could be pointed at fixtures; the successor's fixtures are +//! whole repositories, which is a stronger tier and not a weaker one. +//! +//! The self-consumption case is the one the retiring suite opened on: this +//! repository's own committed pair covers itself, so the containment claim is +//! checkable rather than asserted. +// +// carried: mise-tasks/batten-glob-check.sh policy/glob-containment.rego crates/batten/tests/it/glob_containment.rs +// carried: tests/batten-glob-check.bats policy/glob-containment.rego crates/batten/tests/it/glob_containment.rs +// +// carried: "the committed pair covers itself today" policy/glob-containment.rego +// carried: "a rule glob absent from the list is caught, and named" policy/glob-containment.rego +// carried: "a verbatim entry covers a rule glob" policy/glob-containment.rego +// carried: "a P/** entry subsumes anything under P — the reason the list stays short" policy/glob-containment.rego +// carried: "subsumption is a prefix test, so a sibling prefix does not count" policy/glob-containment.rego +// carried: "a budget file is an input, and an uncovered one is caught" policy/glob-containment.rego +// carried: "an embedded budget path is an input too" policy/glob-containment.rego +// carried: "a shape rule declares no glob and demands nothing" policy/glob-containment.rego +// carried: "a comment inside the list is not list syntax, parenthesis and all" policy/glob-containment.rego +// carried: "another step's glob list is not read as batten-check's" policy/glob-containment.rego +// carried: "output is a pointer — no file contents echoed" policy/glob-containment.rego +// changed: "a config the gate parses nothing out of is exit 2, not a pass" policy/glob-containment.rego the refusal is carried whole and its EXIT CODE is the engine's rather than the shell's: the shell reserved 2 for could-not-look and used 1 for a violation, and the engine's one contract makes every deny finding exit 2 with no per-verb exception (AGENTS.md rule 5). So the case still separates a failed parse from a pass, which is what it was for, and no longer separates it from a violation by exit code — it separates it by verdict, which is the pointer a reader acts on +// changed: "a batten-check step with no glob at all is a regression, not a default" policy/glob-containment.rego same exit-code change as the row above: the shell exited 1 here and the successor emits a `step declare missing` finding, which is exit 2 on the engine's contract +// changed: "a missing input file is exit 2, distinct from a violation" policy/glob-containment.rego the clause has no successor to carry because the ENGINE decides it earlier: a rule whose declared `line_sources` match nothing is not evaluated at all, and `input.tree.missing` is never populated on the tree surface (CLOUD-1049, measured identically for `policy/mise-pin-agreement.rego`'s own could-not-look clause). A case asserting it would assert the engine gap rather than the predicate, so it ships without one until that fact does + +// Panicking on setup failure is the idiomatic way for a test to fail loudly. +#![allow(clippy::unwrap_used, clippy::expect_used)] + +use crate::common; + +use std::path::{Path, PathBuf}; +use std::process::Output; + +use common::{Fixture, git_in, run, stderr, stdout}; + +/// A repository declaring only this rule, so any finding is the one under test. +/// +/// The three `[[verdict]]` rows are carried into the fixture rather than +/// assumed: a verdict is emittable only where a row declares it, and the +/// built-in registry is not this consumer's vocabulary. Without them the module +/// loads and the run is a USAGE error, which is exit 1 and not the exit 2 these +/// cases are about — so a fixture that omitted them would test the declaration +/// rather than the predicate. +fn glob_repo(name: &str, config_body: &str, hooks_body: &str) -> PathBuf { + let config = format!( + "version = 1\n\n\ + [[verdict]]\n\ + id = \"manifest cover missing\"\n\ + gloss = \"the manifest does not select a path it must judge\"\n\ + class = \"A trigger narrower than its rule set deletes feedback while preserving the verdict.\"\n\n\ + [[verdict.route]]\n\ + id = \"prose read first\"\n\ + kind = \"document\"\n\ + target = \"AGENTS.md\"\n\n\ + [[verdict]]\n\ + id = \"step declare missing\"\n\ + gloss = \"a step declares no trigger\"\n\ + class = \"A step with no glob runs on every commit, which is what the trigger removed.\"\n\n\ + [[verdict.route]]\n\ + id = \"prose read first\"\n\ + kind = \"document\"\n\ + target = \"AGENTS.md\"\n\n\ + [[verdict]]\n\ + id = \"gate parse unread\"\n\ + gloss = \"the gate parsed nothing out of its own input\"\n\ + class = \"A containment check that parses zero requirements passes vacuously.\"\n\n\ + [[verdict.route]]\n\ + id = \"prose read first\"\n\ + kind = \"document\"\n\ + target = \"AGENTS.md\"\n\n\ + [[pattern]]\n\ + id = \"md-quoted-span\"\n\ + regex = '\"[^\"]*\"'\n\n\ + [[rule]]\n\ + id = \"glob-containment\"\n\ + kind = \"policy\"\n\ + scope = \"tree\"\n\ + line_sources = [\"batten.toml\", \"hk.pkl\"]\n\ + module = \"policy/glob-containment.rego\"\n\ + severity = \"deny\"\n\n\ + {config_body}" + ); + let dir = Fixture::new(name) + .config(&config) + .file("AGENTS.md", "the consumer's own authority\n") + .file("hk.pkl", hooks_body) + .git() + .build(); + // The module is copied in rather than referenced: the fixture is its own + // repository, and a rule row naming a path outside it would not resolve. + common::write( + &dir, + "policy/glob-containment.rego", + &std::fs::read_to_string(common::at_root("policy/glob-containment.rego")).unwrap(), + ); + git_in(&dir, &["add", "-A"]); + git_in(&dir, &["commit", "-q", "-m", "base"]); + dir +} + +fn check(dir: &Path) -> Output { + run(dir, &["check", "--rule", "glob-containment"]) +} + +/// A `["batten-check"]` step selecting `entries`, followed by another step so +/// the reader's upper bound is a real one. +fn hooks(entries: &[&str]) -> String { + let listed = entries + .iter() + .map(|e| format!(" \"{e}\",\n")) + .collect::(); + format!( + "hooks {{\n [\"batten-check\"] {{\n glob =\n List(\n{listed} )\n check = \"mise run batten-check\"\n }}\n [\"other-step\"] {{\n glob = List(\"unrelated\")\n }}\n}}\n" + ) +} + +fn rule_glob(glob: &str) -> String { + format!( + "[[rule]]\nid = \"r\"\nkind = \"forbid\"\nscope = \"tree\"\nglob = \"{glob}\"\npattern = \"x\"\nseverity = \"deny\"\n" + ) +} + +#[test] +fn a_verbatim_entry_covers_a_rule_glob() { + let dir = glob_repo( + "glob-verbatim", + &rule_glob("mise.toml"), + &hooks(&["batten.toml", "hk.pkl", "policy/**", "mise.toml"]), + ); + let output = check(&dir); + assert_eq!( + output.status.code(), + Some(0), + "out={} err={}", + stdout(&output), + stderr(&output) + ); +} + +#[test] +fn a_prefix_entry_subsumes_anything_under_it() { + // The reason the list can stay short: one `crates/**` stands for every glob + // beneath it. + let dir = glob_repo( + "glob-prefix", + &rule_glob("crates/batten/tests/**/*.rs"), + &hooks(&["batten.toml", "hk.pkl", "policy/**", "crates/**"]), + ); + let output = check(&dir); + assert_eq!(output.status.code(), Some(0), "{}", stdout(&output)); +} + +#[test] +fn an_unlisted_glob_is_refused_and_named() { + let dir = glob_repo( + "glob-unlisted", + &rule_glob("mise.toml"), + &hooks(&["batten.toml", "hk.pkl", "policy/**"]), + ); + let output = check(&dir); + assert_eq!(output.status.code(), Some(2), "{}", stdout(&output)); + assert!( + stdout(&output).contains("batten.toml:"), + "the finding points at the config line that demands it: {:?}", + stdout(&output) + ); +} + +#[test] +fn a_sibling_prefix_does_not_count() { + // `crates-extra/` is not under `crates/`. A looser string match would call + // this covered, which is the direction a containment check must never fail + // in. + let dir = glob_repo( + "glob-sibling-prefix", + &rule_glob("crates-extra/**/*.rs"), + &hooks(&["batten.toml", "hk.pkl", "policy/**", "crates/**"]), + ); + let output = check(&dir); + assert_eq!(output.status.code(), Some(2), "{}", stdout(&output)); +} + +#[test] +fn a_slashless_entry_does_not_subsume_by_prefix() { + // The `/**` is what makes an entry a prefix at all. An entry without it + // subsumes nothing, however much of it a required glob happens to start + // with — otherwise a bare `crates` would silently stand for + // `crates-extra/**`, which is a different tree. + let dir = glob_repo( + "glob-slashless", + &rule_glob("crates-extra/**/*.rs"), + &hooks(&["batten.toml", "hk.pkl", "policy/**", "crates"]), + ); + let output = check(&dir); + assert_eq!(output.status.code(), Some(2), "{}", stdout(&output)); +} + +#[test] +fn a_budget_file_is_an_input() { + // The case the issue's own wording missed: a declared budget is a gate under + // `check`, not only under its own verb, so a budgeted document is as much an + // input as any rule glob. + let dir = glob_repo( + "glob-budget-files", + &format!( + "{}\n[budget.instructions]\nfiles = [\"AGENTS.md\", \"CONTRIBUTING.md\"]\nmax_tokens = 10\n", + rule_glob("mise.toml") + ), + &hooks(&[ + "batten.toml", + "hk.pkl", + "policy/**", + "mise.toml", + "AGENTS.md", + ]), + ); + let output = check(&dir); + assert_eq!(output.status.code(), Some(2), "{}", stdout(&output)); +} + +#[test] +fn an_embedded_budget_path_is_an_input_too() { + let dir = glob_repo( + "glob-budget-embedded", + &format!( + "{}\n[budget.instructions]\nfiles = [\"AGENTS.md\"]\nmax_tokens = 10\n\n[[budget.instructions.embedded]]\npath = \".serena/project.yml\"\nkey = \"initial_prompt\"\n", + rule_glob("mise.toml") + ), + &hooks(&[ + "batten.toml", + "hk.pkl", + "policy/**", + "mise.toml", + "AGENTS.md", + ]), + ); + let output = check(&dir); + assert_eq!(output.status.code(), Some(2), "{}", stdout(&output)); + assert!( + stdout(&output).contains("batten.toml:"), + "the finding points at the config line that demands it: {:?}", + stdout(&output) + ); +} + +#[test] +fn a_rule_with_no_glob_demands_nothing() { + let dir = glob_repo( + "glob-no-glob-rule", + &format!( + "[[rule]]\nid = \"s\"\nkind = \"shape\"\nscope = \"mediated_call\"\nseverity = \"deny\"\npattern = \"gh pr merge\"\nreason = \"no\"\n\n{}", + rule_glob("mise.toml") + ), + &hooks(&["batten.toml", "hk.pkl", "policy/**", "mise.toml"]), + ); + let output = check(&dir); + assert_eq!(output.status.code(), Some(0), "{}", stdout(&output)); +} + +#[test] +fn a_comment_inside_the_list_is_not_list_syntax() { + // The failure the retired gate actually shipped: an entry's comment + // contained a parenthesised tracker key whose `)` ended the list early, so + // every entry below it read as uncovered and the gate reported four paths + // that were listed all along. + let dir = glob_repo( + "glob-comment-in-list", + &rule_glob("mise.toml"), + &"hooks {\n [\"batten-check\"] {\n glob =\n List(\n \"batten.toml\",\n \"hk.pkl\",\n // the rule that made this an input (CLOUD-614)\n \"policy/**\",\n \"mise.toml\",\n )\n }\n [\"other-step\"] {\n glob = List(\"unrelated\")\n }\n}\n".to_owned(), + ); + let output = check(&dir); + assert_eq!( + output.status.code(), + Some(0), + "a comment closed the list: {}", + stdout(&output) + ); +} + +#[test] +fn another_steps_list_is_not_read_as_this_ones() { + let dir = glob_repo( + "glob-other-step", + &rule_glob("mise.toml"), + &"hooks {\n [\"batten-check\"] {\n glob = List(\"batten.toml\", \"hk.pkl\", \"policy/**\")\n }\n [\"other-step\"] {\n glob = List(\"mise.toml\")\n }\n}\n".to_owned(), + ); + let output = check(&dir); + assert_eq!(output.status.code(), Some(2), "{}", stdout(&output)); +} + +#[test] +fn a_step_with_no_glob_at_all_is_a_regression_not_a_default() { + let dir = glob_repo( + "glob-step-without-list", + &rule_glob("mise.toml"), + &"hooks {\n [\"batten-check\"] {\n check = \"mise run batten-check\"\n }\n [\"other-step\"] {\n glob = List(\"unrelated\")\n }\n}\n".to_owned(), + ); + let output = check(&dir); + assert_eq!(output.status.code(), Some(2), "{}", stdout(&output)); + assert!( + stdout(&output).contains("hk.pkl"), + "the finding points at the manifest: {:?}", + stdout(&output) + ); +} + +#[test] +fn a_config_yielding_no_inputs_is_refused_rather_than_passed() { + // The vacuous green a containment check produces most easily: parse zero + // requirements and every list covers them. + let dir = glob_repo( + "glob-unparseable-config", + "# this config declares no rule glob and no budget path\n", + &hooks(&["batten.toml", "hk.pkl", "policy/**"]), + ); + let output = check(&dir); + assert_eq!(output.status.code(), Some(2), "{}", stdout(&output)); +} + +#[test] +fn output_is_pointer_only() { + let dir = glob_repo( + "glob-pointer-only", + &format!( + "{}\n# a distinctive phrase nobody should see in a finding\n", + rule_glob("mise.toml") + ), + &hooks(&["batten.toml", "hk.pkl", "policy/**"]), + ); + let output = check(&dir); + assert_eq!(output.status.code(), Some(2)); + assert!( + !stdout(&output).contains("distinctive phrase"), + "the config's prose is payload: {:?}", + stdout(&output) + ); +} + +#[test] +fn the_committed_pair_covers_itself_today() { + // The self-consumption case the retiring suite opened on, and the reason + // this gate is worth having: the two committed files agree, checked rather + // than asserted. + let output = common::run_at_real_root( + &common::at_root(""), + &["check", "--rule", "glob-containment"], + ); + assert_eq!( + output.status.code(), + Some(0), + "this repository's own batten-check glob does not cover every input: {}", + stdout(&output) + ); +} diff --git a/crates/batten/tests/it/main.rs b/crates/batten/tests/it/main.rs index a417bba34..24b82c106 100644 --- a/crates/batten/tests/it/main.rs +++ b/crates/batten/tests/it/main.rs @@ -132,6 +132,7 @@ mod forge_facts; mod fuzz_corpus; mod gh_guard; mod git_facts; +mod glob_containment; mod glob_exclusion; mod guardrail_bypass; mod handler_dispatch; diff --git a/hk.pkl b/hk.pkl index ba7bf4f42..3c0da50af 100644 --- a/hk.pkl +++ b/hk.pkl @@ -506,7 +506,7 @@ local gate = new Mapping { // path that records a tag which shipped before the recording worked — and it // needs its own trigger here or it would be judged only when the release path // happened to change. The batten.toml row's glob names only the script — that - // one is the rule's subject, and `batten-glob-check` must be able to prove it + // one is the rule's subject, and `glob-containment` must be able to prove it // covered. ["release-tracking-check"] { glob = @@ -594,17 +594,6 @@ local gate = new Mapping { glob = List("AGENTS.md", ".serena/project.yml", "batten.toml") check = "mise run policy-budget" } - // The other half of `batten-check`'s glob (CLOUD-224). That list is derived - // from batten.toml — every `[[rule]]` glob, every declared budget file — so - // writing it here makes hk.pkl a second authority over a set the config - // already defines, and a second authority narrows SILENTLY: add a rule whose - // glob names a path outside the list and the step stops running for commits - // that touch only that path, with nothing going red. A glob without this is - // half a change (non-negotiable 2). - ["batten-glob-check"] { - glob = List("batten.toml", "hk.pkl") - check = "mise run batten-glob-check" - } // The shipped skill is the dispositional surface of the interaction model, and // two of its properties are specification rather than taste: a ~300-line // budget somebody measured (a 341-line skill beat a 2,187-line one head to @@ -872,7 +861,7 @@ local gate = new Mapping { // // Deriving that union from batten.toml by hand makes hk.pkl a second authority // over it, which narrows silently the next time a rule adds a glob — half a - // change by non-negotiable 2. `batten-glob-check` below is the other half. + // change by non-negotiable 2. `glob-containment` below is the other half. // // The task runs `batten enforce` since CLOUD-229 — the committed // no-conflict-markers rule delegates to `hk util check-merge-conflict`, and a @@ -907,7 +896,7 @@ local gate = new Mapping { // and not redundant with it: the narrow entry misses a `.yaml` workflow // and misses a nested path. A step whose glob does not cover its own // inputs stops running for exactly the commits it is meant to judge, so - // `batten-glob-check` refused the rows until this followed them. + // `glob-containment` refused the rows until this followed them. ".github/workflows/**", // Recursive since CLOUD-328: the ratchet row over bats suites was // narrowed to one segment only to dodge a submodule the walker used to @@ -917,12 +906,12 @@ local gate = new Mapping { // CLOUD-383's portability row scopes a `forbid` to `mise-tasks/**`, so // those files are an input to `batten check` now — and a step whose glob // does not cover its own inputs stops running for exactly the commits it - // is meant to judge. `batten-glob-check` refused the rule until this + // is meant to judge. `glob-containment` refused the rule until this // followed it, which is the coupling working rather than a chore. "mise-tasks/**", // CLOUD-1457's `install-does-one-thing` row scopes a `forbid` to // `install.sh`, so the installer is an input to `batten check` — the same - // coupling as `mise-tasks/**` above, and `batten-glob-check` refused the + // coupling as `mise-tasks/**` above, and `glob-containment` refused the // rule until this followed it. The consequence of omitting it is sharper // here than elsewhere: `install.sh` is the one file exempt from the bash // moratorium, so a commit touching only it is exactly the commit the rule @@ -931,13 +920,13 @@ local gate = new Mapping { // CLOUD-876's `no-rego-metadata` row scopes a `forbid` to // `policy/**/*.rego`, so the modules are an input to `batten check` too // — the same coupling as `mise-tasks/**` above, and refused by - // `batten-glob-check` until it followed. Without it the gate goes silent + // `glob-containment` until it followed. Without it the gate goes silent // for exactly the commits that add a policy module, which is when the // `rego.metadata.*` refusal has anything to catch. "policy/**/*.rego", // CLOUD-761's `no-tracker-key-in-modules` row scopes a `forbid` to // `policy/**` — the whole tree rather than the `.rego` files above it — - // so the broader glob has to follow it here, and `batten-glob-check` + // so the broader glob has to follow it here, and `glob-containment` // refused the commit until it did. The two entries are not redundant: // every tracked path under `policy/` is a module today, so they select // the same set, and the day one is not, the narrow entry would leave the @@ -953,21 +942,21 @@ local gate = new Mapping { // The two `command` rows over the Rego corpus are globbed on their own // configuration rather than on `policy/**`, so that they run here and in // no fixture (CLOUD-614) — which makes these files batten inputs in the - // literal sense `batten-glob-check` reads, and it refused the commit + // literal sense `glob-containment` reads, and it refused the commit // until they were listed. ".regal/config.yaml", "schema/policy-input.schema.json", "AGENTS.md", ".serena/project.yml", // CLOUD-681's `fix-selection-complete` globs this very file, so editing - // the gate is now a reason to run the gate. `batten-glob-check` is what + // the gate is now a reason to run the gate. `glob-containment` is what // named it — the row landed, the list did not, and that suite went red // on the same commit rather than the step quietly not selecting. "hk.pkl", // CLOUD-1137's `claude-shell-not-growing` ratchets shebangs under // `.claude/**`, so those files are a `batten check` input now — the same // coupling `mise-tasks/**` and `policy/**/*.rego` above carry, and - // `batten-glob-check` refused the commit until this followed the row. It + // `glob-containment` refused the commit until this followed the row. It // is the entry that matters most for that row: a new shell program under // `.claude/` is exactly the commit the ratchet exists to catch, and // without this it is the commit the step would not run for. @@ -975,7 +964,7 @@ local gate = new Mapping { // CLOUD-605's `no-denied-identity-prescribed` row scopes a `forbid` to // Markdown — where the user-level hook's remedy would get pasted — so // every tracked `.md` is an input now. The bare `**` below does not - // discharge this: `batten-glob-check`'s subsumption is a `P/**` prefix + // discharge this: `glob-containment`'s subsumption is a `P/**` prefix // test and a slashless `**` matches only verbatim, so an explicit entry // is what the gate accepts. It is also what this list wants — each line // records WHICH rule made its path an input. diff --git a/mise-tasks/batten-glob-check.sh b/mise-tasks/batten-glob-check.sh deleted file mode 100755 index c34df7946..000000000 --- a/mise-tasks/batten-glob-check.sh +++ /dev/null @@ -1,190 +0,0 @@ -#!/usr/bin/env bash -#MISE description="Gate: hk.pkl's batten-check glob covers every path batten.toml makes an input — a pure function of the two committed files" -# -# CLOUD-224. `batten-check` used to carry no glob at all, on the reasoning that -# any file can carry a violation, so `cargo run -p batten -- check` rebuilt the -# engine on every commit whatever it touched. Giving it a glob is what makes a -# docs-only commit cheap; the cost of a glob is that the step's trigger is now a -# SECOND authority over a set `batten.toml` already defines, and a second -# authority narrows silently. -# -# The failure that would produce is the one the whole gate model exists to -# prevent: add a `[[rule]]` whose glob names a path outside the list, and the -# gate simply stops running for commits that touch only that path. Nothing goes -# red. `hk check --all` still covers it in CI, so the symptom is a rule that -# quietly does not gate a branch — feedback deleted, verdict preserved. -# -# So this asserts the containment directly, of the committed bytes. What -# `batten check` reads is three things (crates/batten/src/lib.rs, run_check_with): -# -# 1. every `[[rule]]`, over that rule's own `glob` -# 2. `budget::measure_all` — `[budget.instructions] files` and every -# `[[budget.instructions.embedded]] path`. A declared budget is a gate under -# `check`, not only under `policy budget` (CLOUD-50), which is why AGENTS.md -# is an input and "Markdown cannot change this verdict" is false. -# 3. `defects::gate`, which reads a ledger path only when [defects] is declared -# -# 1 and 2 are what this gate reads out of batten.toml. 3 declares no path in this -# repository today; a [defects] table that names one is the case to extend here. -# -# NOT checked, and not checkable by a glob at all: a `ratchet` rule's verdict -# also moves when its `base` (origin/main) moves, with no file in this repository -# changing. That is a property of the world, and `hk check --all` in CI is what -# covers it — the same split `lock-complete`/`lock-currency` documents. -# -# Output is a pointer, never a payload (non-negotiable 4): the uncovered glob and -# the file that demands it. -# A gate listed in $MUTANT_GATES with no row here fails `mise run mutant`. -#MUTANT uncovered-glob-passes|s/^\tif \[\[ "\$ok" = 0 \]\]; then$/\tif false; then/|absent from the list is caught - -set -euo pipefail - -# Both inputs are ARGUMENTS defaulting to the real files, for the same reason -# `ci-tools-check` takes both of its: the decision is the part worth testing, and -# it only tests if the suite can point it at fixtures carrying drift the real -# tree must never have. -cd "$(git rev-parse --show-toplevel)" - -readonly CONFIG="${1:-batten.toml}" -readonly HOOKS="${2:-hk.pkl}" - -for f in "$CONFIG" "$HOOKS"; do - if [[ ! -f "$f" ]]; then - echo "::error:: batten-glob-check: $f not found" >&2 - exit 2 - fi -done - -# --- what batten.toml makes an input --------------------------------------- -# -# Three shapes, one per table that can name a path. Each prints `\t` -# so a finding can point at the line that demands it. -# -# `[[rule]]` blocks: the `glob` key. A rule with no `glob` selects nothing extra. -# `[budget.instructions]`: `files = [...]`, a one-line array in this config and -# gated as such — a multi-line array would need continuation tracking, and the -# `no-budget-entries` guard below is what notices if one ever appears. -# `[[budget.instructions.embedded]]`: the `path` key. -required=$(awk ' - /^\[\[rule\]\]/ { table = "rule"; next } - /^\[budget\.instructions\]/ { table = "budget"; next } - /^\[\[budget\.instructions\.embedded\]\]/ { table = "embedded"; next } - /^\[/ { table = ""; next } - table == "rule" && /^glob = / { - line = $0; sub(/^glob = /, "", line); gsub(/"/, "", line) - print line "\t" NR - next - } - table == "embedded" && /^path = / { - line = $0; sub(/^path = /, "", line); gsub(/"/, "", line) - print line "\t" NR - next - } - table == "budget" && /^files = \[/ { - line = $0 - sub(/^files = \[/, "", line); sub(/\].*$/, "", line) - n = split(line, parts, ",") - for (i = 1; i <= n; i++) { - gsub(/^[ \t]+|[ \t]+$/, "", parts[i]) - gsub(/"/, "", parts[i]) - if (parts[i] != "") print parts[i] "\t" NR - } - next - } -' "$CONFIG") - -# A config this gate can parse nothing out of is not a config with no inputs — -# it is a parse that failed, and passing on it would be the vacuous green a -# containment check can most easily produce. -if [[ -z "$required" ]]; then - echo "::error:: batten-glob-check: parsed no rule glob or budget path out of $CONFIG — a config batten check reads nothing from is not a thing this repo has" >&2 - exit 2 -fi - -# --- what hk.pkl's batten-check step selects -------------------------------- -# -# The `glob = List(...)` belonging to the `["batten-check"]` step, and only that -# one: the file carries a dozen others. Bounded by the step's own header and the -# next step's, so a later step's list cannot be read as this step's. -covered=$(awk ' - /^ \["batten-check"\]/ { in_step = 1; next } - in_step && /^ \["/ { in_step = 0 } - in_step && /glob =/ { in_glob = 1 } - # A COMMENT INSIDE THE LIST IS NOT LIST SYNTAX, and reading it as such made - # this gate lie. The entries carry a comment each recording which rule made - # the path an input; one of them contained `(CLOUD-614)`, whose `)` ended the - # list here — so every entry BELOW it went uncovered and the gate reported - # four paths that were listed all along. A containment check that mis-parses - # in the reporting direction is survivable; the same parse silently DROPPING - # entries from `required` would not be, which is why this skips rather than - # tries to be clever about the paren. - in_glob && /^[ \t]*\/\// { next } - in_glob { - # One entry per quoted string, however the list is wrapped: pkl format - # breaks a long List across lines, so neither a one-line nor a - # one-per-line shape can be assumed. - rest = $0 - while (match(rest, /"[^"]*"/)) { - print substr(rest, RSTART + 1, RLENGTH - 2) - rest = substr(rest, RSTART + RLENGTH) - } - if (index($0, ")") > 0) in_glob = 0 - } -' "$HOOKS") - -if [[ -z "$covered" ]]; then - echo "::error:: batten-glob-check: found no \`glob = List(...)\` on the [\"batten-check\"] step in $HOOKS. A glob-less step runs on every commit — which is what CLOUD-224 removed, so its absence is a regression, not a default." >&2 - exit 1 -fi - -# --- containment ------------------------------------------------------------ -# -# Covered means: present verbatim, or subsumed by a `P/**` entry whose prefix the -# required glob starts with. That second clause is the whole reason the list can -# stay short — one `crates/**` stands for `crates/**/*.rs`, -# `crates/batten/tests/**` and `crates/batten/tests/**/*.rs`. -# -# It is deliberately NOT general glob subsumption, which is undecidable in the -# directions that matter and would be a matcher this repo would then have to own. -# A prefix test is the narrow, honest case; anything it cannot prove must be -# listed verbatim, which fails CLOSED — the direction a containment check has to -# fail in. -fail=0 -reported=0 -while IFS=$'\t' read -r want line; do - [[ -n "$want" ]] || continue - - ok=0 - while IFS= read -r have; do - [[ -n "$have" ]] || continue - if [[ "$have" = "$want" ]]; then - ok=1 - break - fi - case $have in - */'**') - prefix=${have%'**'} - case $want in - "$prefix"*) - ok=1 - break - ;; - esac - ;; - esac - done <<<"$covered" - - if [[ "$ok" = 0 ]]; then - if [[ "$reported" = 0 ]]; then - echo "::error:: hk.pkl's batten-check glob does not cover every path batten.toml makes an input, so the gate silently stops running for commits that touch only these (CLOUD-224):" >&2 - reported=1 - fi - echo " $CONFIG:$line: \`$want\` — add it to the \`glob = List(...)\` on the [\"batten-check\"] step in $HOOKS" >&2 - fail=1 - fi -done <<<"$required" - -if [[ "$fail" = 0 ]]; then - echo "batten-glob-check: $HOOKS's batten-check glob covers every path $CONFIG makes an input" -fi -exit "$fail" diff --git a/mise.toml b/mise.toml index 7f754f5a2..f1416588c 100644 --- a/mise.toml +++ b/mise.toml @@ -617,7 +617,7 @@ CI_FANIN_WORKFLOW = ".github/workflows/ci.yml" BATS_TEST_TIMEOUT = "300" REGORUS_OPA_COMPLIANCE = "1.2.0" REGORUS_OPA_COMPLIANCE_FOR = "0.11" -MUTANT_GATES = "agentic-experiment-record,attestation-check,awk-regex,bats-invocation,batten-glob-check,board-diff-overlap,board-payloads,board-sweep,branch-age-check,cap-drift,cfg-gated-test,ci-cache-declared,ci-hygiene,ci-parity,ci-slow-needed,ci-suite-lane,ci-tools-check,claim-before-code,claim-order-is-stated,claimed-keys,closing-key-check,coderabbit-config-check,commit-hygiene,connector-allow-guard,connector-allow-resolve,container-preflight,darwin-link,deferral-check,denials-outlive-the-turn,digest-major-agreement,doctor,done-check,done-pr-check,duplicate-close-check,egress-fencing,engine-checks-green,engine-config,engine-doctor,engine-exec,engine-handler,engine-landed,engine-lease,engine-mcp,engine-perf,engine-pinned,engine-ready,engine-surface,engine-verdict,engine-wiring,evaluator-closure-check,evaluator-io-check,filed-here,finding-sink-check,fixture-forks,forge-verdict-required,graph-check,harness-grant,harness-wiring,hk-fix-selection,hk-plan-required,hook-pin-check,hook-skip-local,in-progress-drain,install-check,land-divergence-assert,landed-check,landing-loop,landing-roster-guarded,leased-push,license-table,linear-check,lock-complete,macos-link-check,mcp-allow-check,mcp-attach-check,mcp-timeout-budget,merged-pr-keys,mise,mise-action-floor,mise-pin-agreement,module-map,msrv-pin-agreement,mutation-declared-case,nextest-slow,no-doctests,nonverdict-assert,ntia-check,obligations-bound,perf-assert,pinned-toolchain,pipefail-grep,plan-complete,pr-partition-restated,pr-unsubscribed,privileged-lane,prose-only,publish-credential-check,ready-cites-check,ready-lint,reclaim-census,release-assets-check,release-due,release-provision-parity,release-tag-shape,release-tracking-check,released,remedy-authorship,repetition-without-progress,report-only,review-answered,review-dispatched,run-shape,rust-paths-check,sbom,sbom-inventory,serena-mcp,shell-hygiene,shell-retirement,shell-write-advisory,signing-posture,sonar-gate,spawn-widening,spec-ref-check,stop-posture,stop-posture-check,suite-bench-check,suite-subject-retirable,task-substitution,test-targets,timeout-check,token-bench-check,transcript-corpus-check,tree-clean,trunk-based,validator-verdict-clean,verdict-routes-resolve,weakens-declared,worktree-registration" +MUTANT_GATES = "agentic-experiment-record,attestation-check,awk-regex,bats-invocation,board-diff-overlap,board-payloads,board-sweep,branch-age-check,cap-drift,cfg-gated-test,ci-cache-declared,ci-hygiene,ci-parity,ci-slow-needed,ci-suite-lane,ci-tools-check,claim-before-code,claim-order-is-stated,claimed-keys,closing-key-check,coderabbit-config-check,commit-hygiene,connector-allow-guard,connector-allow-resolve,container-preflight,darwin-link,deferral-check,denials-outlive-the-turn,digest-major-agreement,doctor,done-check,done-pr-check,duplicate-close-check,egress-fencing,engine-checks-green,engine-config,engine-doctor,engine-exec,engine-handler,engine-landed,engine-lease,engine-mcp,engine-perf,engine-pinned,engine-ready,engine-surface,engine-verdict,engine-wiring,evaluator-closure-check,evaluator-io-check,filed-here,finding-sink-check,fixture-forks,forge-verdict-required,glob-containment,graph-check,harness-grant,harness-wiring,hk-fix-selection,hk-plan-required,hook-pin-check,hook-skip-local,in-progress-drain,install-check,land-divergence-assert,landed-check,landing-loop,landing-roster-guarded,leased-push,license-table,linear-check,lock-complete,macos-link-check,mcp-allow-check,mcp-attach-check,mcp-timeout-budget,merged-pr-keys,mise,mise-action-floor,mise-pin-agreement,module-map,msrv-pin-agreement,mutation-declared-case,nextest-slow,no-doctests,nonverdict-assert,ntia-check,obligations-bound,perf-assert,pinned-toolchain,pipefail-grep,plan-complete,pr-partition-restated,pr-unsubscribed,privileged-lane,prose-only,publish-credential-check,ready-cites-check,ready-lint,reclaim-census,release-assets-check,release-due,release-provision-parity,release-tag-shape,release-tracking-check,released,remedy-authorship,repetition-without-progress,report-only,review-answered,review-dispatched,run-shape,rust-paths-check,sbom,sbom-inventory,serena-mcp,shell-hygiene,shell-retirement,shell-write-advisory,signing-posture,sonar-gate,spawn-widening,spec-ref-check,stop-posture,stop-posture-check,suite-bench-check,suite-subject-retirable,task-substitution,test-targets,timeout-check,token-bench-check,transcript-corpus-check,tree-clean,trunk-based,validator-verdict-clean,verdict-routes-resolve,weakens-declared,worktree-registration" # --- GitHub reachability behind an egress proxy (Claude Code web sandbox etc.) --- # mise resolves every tool's release through GitHub's *API* host, api.github.com. diff --git a/policy/glob-containment.rego b/policy/glob-containment.rego new file mode 100644 index 000000000..e778f378a --- /dev/null +++ b/policy/glob-containment.rego @@ -0,0 +1,378 @@ +# METADATA +# description: | +# The hook step's trigger covers every path the config makes an input — CLOUD-224, +# ported from `mise-tasks/batten-glob-check.sh` under CLOUD-843. +# +# The `batten-check` step used to carry no glob at all, so `batten check` ran on +# every commit whatever it touched. Giving it a glob is what makes a docs-only +# commit cheap; the cost is that the step's trigger becomes a SECOND authority +# over a set the config already defines, and a second authority narrows silently. +# +# The failure that produces is the one the gate model exists to prevent: add a +# `[[rule]]` whose glob names a path outside the step's list, and the gate simply +# stops running for commits that touch only that path. Nothing goes red. The +# whole-tree run in CI still covers it, so the symptom is a rule that quietly does +# not gate a branch — feedback deleted, verdict preserved. +# +# WHAT `check` READS out of the config is three things: every `[[rule]]`'s own +# `glob`; the instruction budget's `files` array and every embedded `path`, +# because a declared budget is a gate under `check` and not only under its own +# verb; and a defects ledger path, which this consumer declares nowhere today and +# is the case to extend here when it does. +# +# NOT CHECKED, and not checkable by a glob at all: a `ratchet` rule's verdict also +# moves when its base moves, with no file in this repository changing. That is a +# property of the world, and the whole-tree run in CI is what covers it. +# +# THE PREDICATE IS THIS CONSUMER'S, NOT THE ENGINE'S. That this repository drives +# its gate from a hook manifest, and which step inside it carries the trigger, are +# facts about this consumer — non-negotiable rule 1 — so the containment lives in +# a `policy/*.rego` module and not in engine source. +# +# THE SUCCESSOR READS LINES WHERE THE SHELL READ FILES, and the reading is the +# same reading. `input.tree.lines` is the committed bytes of a declared +# `line_sources` path, which is what the shell's two positional arguments defaulted +# to. The shell took those as arguments so a suite could point it at fixtures; the +# successor's fixtures are whole repositories under +# `crates/batten/tests/it/glob_containment.rs`, which is a stronger tier and not a +# weaker one — it drives the compiled binary over a real tree rather than a +# hand-written pair of files. +# +# A CONFIG THIS READER PARSES NOTHING OUT OF IS NOT A CONFIG WITH NO INPUTS. It is +# a parse that failed, and passing on it is the vacuous green a containment check +# can most easily produce, so it is its own refusal. +# +# COVERED MEANS present verbatim, or subsumed by a `P/**` entry whose prefix the +# required glob starts with. That second clause is the whole reason the list can +# stay short. It is deliberately NOT general glob subsumption, which is +# undecidable in the directions that matter and would be a matcher this repository +# would then have to own. A prefix test is the narrow, honest case; anything it +# cannot prove must be listed verbatim, which fails CLOSED — the direction a +# containment check has to fail in. +# +# POINTER, NEVER PAYLOAD (rule 4): a finding names the uncovered glob and the line +# of the config that demands it, never the rule's body. +# +# THIS BLOCK IS YAML AND MUST STAY THE LAST COMMENT BLOCK BEFORE `package`. +# schemas: +# - input: schema["policy-input.schema"] +package batten.glob_containment + +import rego.v1 + +rules contains "glob-containment" + +config_path := "batten.toml" + +hooks_path := "hk.pkl" + +step_header := " [\"batten-check\"]" + +config_lines := lines if { + lines := input.tree.lines[config_path] +} + +hooks_lines := lines if { + lines := input.tree.lines[hooks_path] +} + +# --- which table a config line sits in --------------------------------------- +# +# A key's meaning is its TABLE's, not its spelling's: `path` appears under nine +# different tables here and only one of them makes a file an input. So each line +# resolves to the nearest header above it, which is the whole of TOML's scoping +# that this reader needs. +header_indices := {i | + some i, line in config_lines + startswith(line, "[") +} + +table_of(i) := config_lines[j] if { + above := {k | + some k in header_indices + k < i + } + j := max(above) +} + +unquoted(text) := replace(text, "\"", "") + +# --- what the config makes an input ------------------------------------------ + +required contains {"glob": glob, "line": i + 1} if { + some i, line in config_lines + table_of(i) == "[[rule]]" + startswith(line, "glob = ") + glob := trim_space(unquoted(substring(line, 7, -1))) + glob != "" +} + +required contains {"glob": glob, "line": i + 1} if { + some i, line in config_lines + table_of(i) == "[[budget.instructions.embedded]]" + startswith(line, "path = ") + glob := trim_space(unquoted(substring(line, 7, -1))) + glob != "" +} + +# The budget's own `files` array, gated as the one-line array it is in this +# config. A multi-line array would need continuation tracking, and the parse +# refusal below is what notices if one ever appears. +required contains {"glob": glob, "line": i + 1} if { + some i, line in config_lines + table_of(i) == "[budget.instructions]" + startswith(line, "files = [") + inner := substring(line, 9, indexof(line, "]") - 9) + some raw in split(inner, ",") + glob := trim_space(unquoted(raw)) + glob != "" +} + +# --- what the hook step selects ---------------------------------------------- +# +# The `glob = List(...)` belonging to the `["batten-check"]` step, and only that +# one: the manifest carries a dozen others. Bounded by the step's own header and +# the next step's, so a later step's list cannot be read as this step's. +step_start := i if { + some i, line in hooks_lines + startswith(line, step_header) +} + +# The next step's header, or the end of the file when this step is the last one. +step_end := e if { + after := {j | + some j, line in hooks_lines + j > step_start + startswith(line, " [\"") + } + e := min(after) +} + +step_end := count(hooks_lines) if { + step_start + not next_step_exists +} + +next_step_exists if { + some j, line in hooks_lines + j > step_start + startswith(line, " [\"") +} + +glob_start := g if { + within := {j | + some j, line in hooks_lines + j > step_start + j < step_end + contains(line, "glob =") + } + g := min(within) +} + +# The list's last line: the first one at or after its opening that closes a +# paren. A COMMENT INSIDE THE LIST IS NOT LIST SYNTAX, and reading it as such made +# the retired gate lie — an entry's comment contained a parenthesised tracker key +# whose `)` ended the list early, so every entry below it read as uncovered. The +# skip below is why a comment cannot close the list, and skipping is deliberately +# preferred to being clever about the paren: mis-parsing in the REPORTING +# direction is survivable, the same parse silently dropping entries from +# `required` would not be. +glob_end := e if { + closers := {j | + some j, line in hooks_lines + j >= glob_start + j < step_end + not comment_line(hooks_lines[j]) + contains(line, ")") + } + e := min(closers) +} + +comment_line(line) if { + startswith(trim_space(line), "//") +} + +covered contains entry if { + some j, line in hooks_lines + j >= glob_start + j <= glob_end + not comment_line(line) + some quoted in regex.find_n(data.batten.patterns["md-quoted-span"], line, -1) + entry := unquoted(quoted) + entry != "" +} + +# --- containment -------------------------------------------------------------- + +satisfied(want) if { + some have in covered + have == want +} + +satisfied(want) if { + some have in covered + endswith(have, "/**") + startswith(want, substring(have, 0, count(have) - 2)) +} + +# THE PARSE REFUSALS, both of which fail closed rather than passing vacuously. +violation contains { + "rule": "glob-containment", + "verdict": "gate parse unread", + "subjects": [{"path": config_path}], +} if { + config_lines + count(required) == 0 +} + +violation contains { + "rule": "glob-containment", + "verdict": "step declare missing", + "subjects": [{"path": hooks_path}], +} if { + hooks_lines + count(required) > 0 + count(covered) == 0 +} + +violation contains { + "rule": "glob-containment", + "verdict": "manifest cover missing", + "subjects": [{"path": sprintf("%s:%d", [config_path, entry.line])}], +} if { + count(covered) > 0 + some entry in required + not satisfied(entry.glob) +} + +# --- the load-time tier ------------------------------------------------------ +# +# These pin the PREDICATE. They cannot pin that the ENGINE resolves +# `input.tree.lines` to the committed bytes of a declared `line_sources` path at +# all — a `with input as` block fabricates the very shape the engine may be unable +# to produce (CLOUD-845). `crates/batten/tests/it/glob_containment.rs` is that +# tier, and it drives the compiled binary over whole fixture repositories. + +tree(config, hooks) := {"tree": {"lines": {"batten.toml": config, "hk.pkl": hooks}}} + +step(entries) := array.concat( + array.concat([" [\"batten-check\"]"], entries), + [" [\"other-step\"]"], +) + +test_a_listed_glob_is_clean if { + count(violation) == 0 with input as tree( + ["[[rule]]", "glob = \"crates/**/*.rs\""], + step([" glob = List(\"crates/**/*.rs\")"]), + ) +} + +test_a_prefix_entry_subsumes_a_longer_glob if { + count(violation) == 0 with input as tree( + ["[[rule]]", "glob = \"crates/batten/tests/**/*.rs\""], + step([" glob = List(\"crates/**\")"]), + ) +} + +# Subsumption is a PREFIX test over a `P/**` entry, so a sibling prefix does not +# count — the direction a containment check must never fail in. +test_a_sibling_prefix_does_not_count if { + some v in violation with input as tree( + ["[[rule]]", "glob = \"crates-extra/**/*.rs\""], + step([" glob = List(\"crates/**\")"]), + ) + v.verdict == "manifest cover missing" +} + +# And the `/**` is what makes an entry a prefix at all: an entry without it +# subsumes nothing, however much of it a required glob happens to start with. +test_a_slashless_entry_does_not_subsume if { + some v in violation with input as tree( + ["[[rule]]", "glob = \"crates-extra/**/*.rs\""], + step([" glob = List(\"crates\")"]), + ) + v.verdict == "manifest cover missing" +} + +test_an_unlisted_glob_is_refused if { + some v in violation with input as tree( + ["[[rule]]", "glob = \"policy/**/*.rego\""], + step([" glob = List(\"crates/**\")"]), + ) + v.verdict == "manifest cover missing" +} + +# The failure the retired gate actually shipped: a comment inside the list closed +# it early and every entry below read as uncovered. +test_a_comment_inside_the_list_does_not_close_it if { + count(violation) == 0 with input as tree( + ["[[rule]]", "glob = \"policy/**/*.rego\""], + step([ + " glob = List(", + " // the rule that makes this an input (see the tracker)", + " \"policy/**/*.rego\",", + " )", + ]), + ) +} + +test_a_budget_files_entry_is_an_input if { + some v in violation with input as tree( + ["[budget.instructions]", "files = [\"AGENTS.md\"]"], + step([" glob = List(\"crates/**\")"]), + ) + v.verdict == "manifest cover missing" +} + +test_an_embedded_budget_path_is_an_input if { + some v in violation with input as tree( + ["[[budget.instructions.embedded]]", "path = \"rules/rust.md\""], + step([" glob = List(\"crates/**\")"]), + ) + v.verdict == "manifest cover missing" +} + +# A `path` key belongs to its table, not to its spelling: eight other tables in +# this config carry one and none of them makes a file an input. +test_a_path_under_another_table_is_not_an_input if { + count(violation) == 0 with input as tree( + ["[[rule]]", "glob = \"crates/**\"", "[[waiver]]", "path = \"some/waived.yml\""], + step([" glob = List(\"crates/**\")"]), + ) +} + +# A later step's list cannot be read as this step's. +test_a_following_steps_list_is_not_this_steps if { + some v in violation with input as tree( + ["[[rule]]", "glob = \"policy/**\""], + array.concat( + [" [\"batten-check\"]", " glob = List(\"crates/**\")", " [\"other-step\"]"], + [" glob = List(\"policy/**\")"], + ), + ) + v.verdict == "manifest cover missing" +} + +# A glob-less step runs on every commit, which is what the trigger removed — so +# its absence is a regression, not a default. +test_a_step_with_no_glob_list_is_refused if { + some v in violation with input as tree( + ["[[rule]]", "glob = \"crates/**\""], + [" [\"batten-check\"]", " [\"other-step\"]"], + ) + v.verdict == "step declare missing" +} + +# A config this reader parses nothing out of is a failed parse, never a config +# with no inputs. +test_a_config_yielding_no_inputs_is_refused if { + some v in violation with input as tree( + ["# nothing but prose"], + step([" glob = List(\"crates/**\")"]), + ) + v.verdict == "gate parse unread" +} + +#MUTANT-SUITE crates/batten/tests/it/glob_containment.rs +#MUTANT uncovered-glob-unread|s@^\tnot satisfied(entry.glob)$@\tfalse@|an_unlisted_glob_is_refused_and_named +#MUTANT prefix-subsumption-unbounded|s@^\tendswith(have, "/\*\*")$@\ttrue@|a_slashless_entry_does_not_subsume_by_prefix diff --git a/tests/batten-glob-check.bats b/tests/batten-glob-check.bats deleted file mode 100644 index 7afde66ea..000000000 --- a/tests/batten-glob-check.bats +++ /dev/null @@ -1,180 +0,0 @@ -#!/usr/bin/env bats -# subject: mise-tasks/batten-glob-check.sh -# CLOUD-224. `batten-check`'s glob is a second authority over a set batten.toml -# already defines, and a second authority narrows silently: add a `[[rule]]` -# whose glob names a path outside the list and the step simply stops running for -# commits that touch only that path, with nothing going red. -# -# This suite drives the containment check that closes that. The cases that -# matter are the ones where a gate can pass for the wrong reason — a parse that -# found nothing, a subsumption that is too generous — not the happy path. - -setup() { - GATE="$BATS_TEST_DIRNAME/../mise-tasks/batten-glob-check.sh" - CONFIG="$BATS_TEST_TMPDIR/batten.toml" - HOOKS="$BATS_TEST_TMPDIR/hk.pkl" -} - -# A batten.toml carrying one rule with the given glob, and nothing else this -# gate reads. -config_with_rule_glob() { - printf '[[rule]]\nid = "r"\nkind = "forbid"\nglob = "%s"\npattern = "x"\nseverity = "deny"\nscope = "tree"\n' "$1" >"$CONFIG" -} - -# An hk.pkl whose ["batten-check"] step globs exactly the given entries, wrapped -# the way `pkl format` wraps a long list. -hooks_with() { - { - printf ' ["some-other-step"] {\n glob = List("never-read.txt")\n check = "x"\n }\n' - printf ' ["batten-check"] {\n depends = List("test")\n glob =\n List(\n' - local g - for g in "$@"; do - printf ' "%s",\n' "$g" - done - printf ' )\n check = "mise run batten-check"\n }\n' - } >"$HOOKS" -} - -@test "the committed pair covers itself today" { - run "$GATE" - [ "$status" -eq 0 ] -} - -@test "a rule glob absent from the list is caught, and named" { - config_with_rule_glob "scripts/**/*.py" - hooks_with "crates/**" batten.toml - - run "$GATE" "$CONFIG" "$HOOKS" - [ "$status" -eq 1 ] - [[ "$output" == *"scripts/**/*.py"* ]] - # Pointer: the file and the line that demands it, never file contents. - [[ "$output" == *"$CONFIG:4:"* ]] -} - -@test "a verbatim entry covers a rule glob" { - config_with_rule_glob "mise.toml" - hooks_with mise.toml - - run "$GATE" "$CONFIG" "$HOOKS" - [ "$status" -eq 0 ] -} - -@test "a P/** entry subsumes anything under P — the reason the list stays short" { - config_with_rule_glob "crates/batten/tests/**/*.rs" - hooks_with "crates/**" - - run "$GATE" "$CONFIG" "$HOOKS" - [ "$status" -eq 0 ] -} - -@test "subsumption is a prefix test, so a sibling prefix does not count" { - # `crates-extra/` is not under `crates/`. A looser string match would call - # this covered, which is the direction a containment check must never fail in. - config_with_rule_glob "crates-extra/**/*.rs" - hooks_with "crates/**" - - run "$GATE" "$CONFIG" "$HOOKS" - [ "$status" -eq 1 ] - [[ "$output" == *"crates-extra/**/*.rs"* ]] -} - -@test "a budget file is an input, and an uncovered one is caught" { - # The case the issue's own wording missed: a declared budget is a gate under - # `check`, not only under `policy budget` (CLOUD-50), so AGENTS.md is as much - # an input as any rule glob. - printf '[[rule]]\nid = "r"\nkind = "forbid"\nglob = "mise.toml"\npattern = "x"\nseverity = "deny"\nscope = "tree"\n\n[budget.instructions]\nfiles = ["AGENTS.md", "CONTRIBUTING.md"]\nmax_tokens = 10\n' >"$CONFIG" - hooks_with mise.toml AGENTS.md - - run "$GATE" "$CONFIG" "$HOOKS" - [ "$status" -eq 1 ] - [[ "$output" == *"CONTRIBUTING.md"* ]] - [[ "$output" != *"AGENTS.md"* ]] -} - -@test "an embedded budget path is an input too" { - printf '[[rule]]\nid = "r"\nkind = "forbid"\nglob = "mise.toml"\npattern = "x"\nseverity = "deny"\nscope = "tree"\n\n[[budget.instructions.embedded]]\npath = ".serena/project.yml"\nkey = "initial_prompt"\n' >"$CONFIG" - hooks_with mise.toml - - run "$GATE" "$CONFIG" "$HOOKS" - [ "$status" -eq 1 ] - [[ "$output" == *".serena/project.yml"* ]] -} - -@test "a shape rule declares no glob and demands nothing" { - printf '[[rule]]\nid = "s"\nkind = "shape"\nscope = "mediated_call"\nseverity = "deny"\npattern = "gh pr merge"\nreason = "no"\n\n[[rule]]\nid = "r"\nkind = "forbid"\nglob = "mise.toml"\npattern = "x"\nseverity = "deny"\nscope = "tree"\n' >"$CONFIG" - hooks_with mise.toml - - run "$GATE" "$CONFIG" "$HOOKS" - [ "$status" -eq 0 ] -} - -@test "a config the gate parses nothing out of is exit 2, not a pass" { - # The vacuous green a containment check produces most easily: parse zero - # requirements and every list covers them. Distinguished from a violation, - # the same way a missing lockfile is in lock-complete. - printf '[epoch]\ntracked = ["batten.toml"]\n' >"$CONFIG" - hooks_with "crates/**" - - run "$GATE" "$CONFIG" "$HOOKS" - [ "$status" -eq 2 ] - [[ "$output" == *"parsed no rule glob"* ]] -} - -@test "a batten-check step with no glob at all is a regression, not a default" { - # Glob-less means unconditional, which is the state CLOUD-224 removed. A - # containment check that read absence as "covers everything" would wave the - # revert straight back through. - config_with_rule_glob "mise.toml" - printf ' ["batten-check"] {\n depends = List("test")\n check = "mise run batten-check"\n }\n' >"$HOOKS" - - run "$GATE" "$CONFIG" "$HOOKS" - [ "$status" -eq 1 ] - [[ "$output" == *"glob-less step runs on every commit"* ]] -} - -@test "a comment inside the list is not list syntax, parenthesis and all" { - # THE PARSE THAT LIED. Each entry in the committed list carries a comment - # recording which rule made the path an input; one of them contained - # `(CLOUD-614)`, and the `)` was read as the end of `List(` — so every entry - # below it dropped out of `covered` and the gate reported four paths that had - # been listed all along. Two entries here, the second reachable only if the - # comment between them is skipped rather than terminating the list. - config_with_rule_glob "mise.toml" - { - printf ' ["batten-check"] {\n glob =\n List(\n' - printf ' "crates/**",\n' - printf ' // a note about a rule (CLOUD-614) and why it is here\n' - printf ' "mise.toml",\n' - printf ' )\n check = "mise run batten-check"\n }\n' - } >"$HOOKS" - - run "$GATE" "$CONFIG" "$HOOKS" - [ "$status" -eq 0 ] -} - -@test "another step's glob list is not read as batten-check's" { - # The file carries a dozen glob lists; only one belongs to this step. - config_with_rule_glob "never-read.txt" - hooks_with "crates/**" - - run "$GATE" "$CONFIG" "$HOOKS" - [ "$status" -eq 1 ] - [[ "$output" == *"never-read.txt"* ]] -} - -@test "a missing input file is exit 2, distinct from a violation" { - config_with_rule_glob "mise.toml" - - run "$GATE" "$CONFIG" "$BATS_TEST_TMPDIR/absent.pkl" - [ "$status" -eq 2 ] -} - -@test "output is a pointer — no file contents echoed" { - config_with_rule_glob "scripts/**/*.py" - hooks_with "crates/**" - - run "$GATE" "$CONFIG" "$HOOKS" - [ "$status" -eq 1 ] - [[ "$output" != *"severity = "* ]] - [[ "$output" != *"pattern = "* ]] -} From 616237f9445ace457b3733e64caef2a40ae1c481 Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Wed, 9 Sep 2026 19:49:12 +0000 Subject: [PATCH 04/29] feat(policy): retire mise-action-floor onto the engine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The toolchain-install action's pre-retry commit is a reachable backslide: the latest release and the floating major both resolve to it, the dependency bot tracks that ecosystem, and this repository lands bot bumps with no human in the loop. A bump to it would be a silent downgrade to the un-retried install, auto-landed, and the next transient would read as fresh. `policy/mise-action-floor.rego` carries the denylist and derives both the action name and the bad sha from one coordinate, so the two cannot drift. A floating ref is not a pin it can judge — `@v4` carries no sha, so the denylist cannot speak about it and reporting green would be a claim the gate cannot support; it falls to the could-not-look arm, as does a tree carrying no pin of this action at all. `[[pattern]] git-object-id` is what separates the two, and is declared rather than inlined because a module carrying its own regex is refused at load. CLOUD-404 CLOUD-1716 Admits: 6cd089ce9d13699b227eeb976594a1ba2fd905114c332449add4216976348ce2 Admits-rule: protected-mutation Admits-verdict: path write refused Admits-subject: batten.toml Admits-anchor: call:985c22eae848c0664314e87bbf62cd056ad502b1 Admits-epoch: c66e5f37aa1ed027338b11ca4fd259a78583c467ed2bd74b2d543237bacafe51 Admits-author: alec@wenzowski.com Admits-prev: 094420f42069af16800b2692fdbb734380b3c643121dd72641ce06489d8ac50f Admits-answer-lost: `mise-tasks/mise-action-floor.sh` cannot retire, and the floor it holds goes with it. A `policy/*.rego` module is inert until a `[[rule]]` row registers it, and this module is additionally refused at load without its `[[pattern]]` row — so without this write the ported predicate is a dead file while the shell program and its bats suite stay standing. Admits-answer-precondition: No `batten` verb authors a `[[rule]]` or `[[pattern]]` row. `batten config` exposes only `show`, `epoch`, `deprecations` and `lint`, every one a read, and `init` refuses to overwrite an existing config. CLOUD-843's retirement shape REQUIRES a rule row per retired program — `shell-retirement` refuses a deletion whose predicate has no policy surface — and a module referencing an undeclared pattern is refused at load, so writing batten.toml directly is the only route left. The write is one rule row, one pattern row and their comments, and a reviewer sees them in the diff they land in, beside `policy/mise-action-floor.rego` and the tier that registers it. Admits-answer-rejected-route: `config read first` is the route this class prefers and it cannot reach: every `batten config` subcommand is a read, and none authors or edits a `[[rule]]`, `[[pattern]]` or `[[verdict]]` row. `patch run first` (`git restore`) is the undo route for an unwanted write, not a way to author config — restoring batten.toml would delete the two rows the ported module needs, which is the change itself rather than a safer spelling of it. --- batten.toml | 24 ++ bench/suites/RESULTS.md | 1 - contracts/hk.json | 92 +++---- crates/batten/tests/it/main.rs | 1 + crates/batten/tests/it/mise_action_floor.rs | 253 ++++++++++++++++++++ hk.pkl | 14 -- mise-tasks/mise-action-floor.sh | 164 ------------- policy/mise-action-floor.rego | 192 +++++++++++++++ tests/mise-action-floor.bats | 123 ---------- 9 files changed, 509 insertions(+), 355 deletions(-) create mode 100644 crates/batten/tests/it/mise_action_floor.rs delete mode 100755 mise-tasks/mise-action-floor.sh create mode 100644 policy/mise-action-floor.rego delete mode 100644 tests/mise-action-floor.bats diff --git a/batten.toml b/batten.toml index 8b09ec21d..2a721586a 100644 --- a/batten.toml +++ b/batten.toml @@ -1694,6 +1694,14 @@ regex = '-v[[:space:]]*([A-Za-z_][A-Za-z0-9_]*)=' id = "leading-identifier" regex = '^[A-Za-z_][A-Za-z0-9_]*' +# A full git object id, lowercase hex. `mise-action-floor` uses it to tell a PINNED +# coordinate from a floating one: `@v4` carries no sha, so a denylist cannot speak +# about it at all and reporting green over it would be a claim the gate cannot +# support. +[[pattern]] +id = "git-object-id" +regex = '^[0-9a-f]{40}$' + [[pattern]] id = "release-tag" regex = '^v[0-9]+\.[0-9]+\.[0-9]+$' @@ -7027,6 +7035,22 @@ severity = "deny" # branch — feedback deleted, verdict preserved. `line_sources` names both halves # because the containment is a pure function of the two committed files, and # because editing either one is a reason to re-decide it. +# CLOUD-404, ported from `mise-tasks/mise-action-floor.sh` under CLOUD-843. The +# toolchain-install action's pre-retry commit is a reachable BACKSLIDE, not a +# hypothetical one: the latest release and the floating major both resolve to it, +# the dependency bot tracks that ecosystem, and this repository lands bot bumps +# with no human in the loop. So a bump to it would be a silent downgrade to the +# un-retried install, auto-landed — and the rule that forbids it ships as a gate +# rather than a note. A denylist rather than a required sha, so every legitimate +# forward bump stays silent. +[[rule]] +id = "mise-action-floor" +kind = "policy" +scope = "tree" +line_sources = [".github/workflows/*.yml"] +module = "policy/mise-action-floor.rego" +severity = "deny" + [[rule]] id = "glob-containment" kind = "policy" diff --git a/bench/suites/RESULTS.md b/bench/suites/RESULTS.md index a2fe4f25c..df7a0f936 100644 --- a/bench/suites/RESULTS.md +++ b/bench/suites/RESULTS.md @@ -94,7 +94,6 @@ to it pays. | 0.5 | 0.1% | `tests/timeout-check.bats` | | 0.5 | 0.1% | `tests/rust-paths-check.bats` | | 0.4 | 0.1% | `tests/git-hook.bats` | -| 0.4 | 0.1% | `tests/mise-action-floor.bats` | | 0.4 | 0.1% | `tests/remedy-payload-source.bats` | | 0.3 | 0.1% | `tests/token-bench-check.bats` | | 0.3 | 0.1% | `tests/dist.bats` | diff --git a/contracts/hk.json b/contracts/hk.json index 04524cbea..5bd4f7520 100644 --- a/contracts/hk.json +++ b/contracts/hk.json @@ -40,7 +40,6 @@ "mcp-allow-check", "lock-complete", "timeout-check", - "mise-action-floor", "policy-budget", "skill-check", "macos-link-check", @@ -233,130 +232,124 @@ "orderIndex": 27, "parallelGroupId": "group_0" }, - { - "name": "mise-action-floor", - "status": "included", - "orderIndex": 28, - "parallelGroupId": "group_0" - }, { "name": "policy-budget", "status": "included", - "orderIndex": 29, + "orderIndex": 28, "parallelGroupId": "group_0" }, { "name": "skill-check", "status": "included", - "orderIndex": 30, + "orderIndex": 29, "parallelGroupId": "group_0" }, { "name": "macos-link-check", "status": "included", - "orderIndex": 31, + "orderIndex": 30, "parallelGroupId": "group_0" }, { "name": "shfmt", "status": "included", - "orderIndex": 32, + "orderIndex": 31, "parallelGroupId": "group_0" }, { "name": "shellcheck", "status": "included", - "orderIndex": 33, + "orderIndex": 32, "parallelGroupId": "group_0" }, { "name": "test:bats", "status": "included", - "orderIndex": 34, + "orderIndex": 33, "parallelGroupId": "group_0" }, { "name": "taplo", "status": "included", - "orderIndex": 35, + "orderIndex": 34, "parallelGroupId": "group_0" }, { "name": "taplo-format", "status": "included", - "orderIndex": 36, + "orderIndex": 35, "parallelGroupId": "group_0" }, { "name": "pkl", "status": "included", - "orderIndex": 37, + "orderIndex": 36, "parallelGroupId": "group_0" }, { "name": "pkl-format", "status": "included", - "orderIndex": 38, + "orderIndex": 37, "parallelGroupId": "group_0" }, { "name": "prettier", "status": "included", - "orderIndex": 39, + "orderIndex": 38, "parallelGroupId": "group_0" }, { "name": "deno-fmt", "status": "included", - "orderIndex": 40, + "orderIndex": 39, "parallelGroupId": "group_0" }, { "name": "rego", "status": "included", - "orderIndex": 41, + "orderIndex": 40, "parallelGroupId": "group_0" }, { "name": "actionlint", "status": "included", - "orderIndex": 42, + "orderIndex": 41, "parallelGroupId": "group_0" }, { "name": "cargo-fmt", "status": "included", - "orderIndex": 43, + "orderIndex": 42, "parallelGroupId": "group_0" }, { "name": "cargo-clippy", "status": "included", - "orderIndex": 44, + "orderIndex": 43, "parallelGroupId": "group_0" }, { "name": "test", "status": "included", - "orderIndex": 45, + "orderIndex": 44, "parallelGroupId": "group_0" }, { "name": "batten-check", "status": "included", - "orderIndex": 46, + "orderIndex": 45, "parallelGroupId": "group_0" }, { "name": "policy-test", "status": "included", - "orderIndex": 47, + "orderIndex": 46, "parallelGroupId": "group_0" }, { "name": "memories-check", "status": "included", - "orderIndex": 48, + "orderIndex": 47, "parallelGroupId": "group_0" } ] @@ -464,7 +457,6 @@ "mcp-allow-check", "lock-complete", "timeout-check", - "mise-action-floor", "policy-budget", "skill-check", "macos-link-check", @@ -656,124 +648,118 @@ "orderIndex": 27, "parallelGroupId": "group_0" }, - { - "name": "mise-action-floor", - "status": "included", - "orderIndex": 28, - "parallelGroupId": "group_0" - }, { "name": "policy-budget", "status": "included", - "orderIndex": 29, + "orderIndex": 28, "parallelGroupId": "group_0" }, { "name": "skill-check", "status": "included", - "orderIndex": 30, + "orderIndex": 29, "parallelGroupId": "group_0" }, { "name": "macos-link-check", "status": "included", - "orderIndex": 31, + "orderIndex": 30, "parallelGroupId": "group_0" }, { "name": "shfmt", "status": "included", - "orderIndex": 32, + "orderIndex": 31, "parallelGroupId": "group_0" }, { "name": "shellcheck", "status": "included", - "orderIndex": 33, + "orderIndex": 32, "parallelGroupId": "group_0" }, { "name": "test:bats", "status": "included", - "orderIndex": 34, + "orderIndex": 33, "parallelGroupId": "group_0" }, { "name": "taplo", "status": "included", - "orderIndex": 35, + "orderIndex": 34, "parallelGroupId": "group_0" }, { "name": "taplo-format", "status": "included", - "orderIndex": 36, + "orderIndex": 35, "parallelGroupId": "group_0" }, { "name": "pkl", "status": "included", - "orderIndex": 37, + "orderIndex": 36, "parallelGroupId": "group_0" }, { "name": "pkl-format", "status": "included", - "orderIndex": 38, + "orderIndex": 37, "parallelGroupId": "group_0" }, { "name": "prettier", "status": "included", - "orderIndex": 39, + "orderIndex": 38, "parallelGroupId": "group_0" }, { "name": "deno-fmt", "status": "included", - "orderIndex": 40, + "orderIndex": 39, "parallelGroupId": "group_0" }, { "name": "rego", "status": "included", - "orderIndex": 41, + "orderIndex": 40, "parallelGroupId": "group_0" }, { "name": "actionlint", "status": "included", - "orderIndex": 42, + "orderIndex": 41, "parallelGroupId": "group_0" }, { "name": "cargo-fmt", "status": "included", - "orderIndex": 43, + "orderIndex": 42, "parallelGroupId": "group_0" }, { "name": "cargo-clippy", "status": "included", - "orderIndex": 44, + "orderIndex": 43, "parallelGroupId": "group_0" }, { "name": "test", "status": "included", - "orderIndex": 45, + "orderIndex": 44, "parallelGroupId": "group_0" }, { "name": "batten-check", "status": "included", - "orderIndex": 46, + "orderIndex": 45, "parallelGroupId": "group_0" }, { "name": "policy-test", "status": "included", - "orderIndex": 47, + "orderIndex": 46, "parallelGroupId": "group_0" } ] diff --git a/crates/batten/tests/it/main.rs b/crates/batten/tests/it/main.rs index 24b82c106..c65c8dfc1 100644 --- a/crates/batten/tests/it/main.rs +++ b/crates/batten/tests/it/main.rs @@ -178,6 +178,7 @@ mod mediated_verbs; mod memories; mod memory_injection; mod minted_facts; +mod mise_action_floor; mod mise_pin_agreement; mod mise_preset; mod module_map; diff --git a/crates/batten/tests/it/mise_action_floor.rs b/crates/batten/tests/it/mise_action_floor.rs new file mode 100644 index 000000000..878619d2a --- /dev/null +++ b/crates/batten/tests/it/mise_action_floor.rs @@ -0,0 +1,253 @@ +//! No workflow pins a toolchain-install commit that predates its download +//! retry, over the compiled binary (CLOUD-404, ported from +//! `mise-tasks/mise-action-floor.sh` under CLOUD-843). +//! +//! **What is decidable only here.** `policy/mise-action-floor.rego` carries +//! load-time cases pinning the predicate, and every one of them supplies +//! `input.tree.lines` with `with input as`. That fabricates the very shape the +//! engine may be unable to produce (CLOUD-845) — and here it fabricates the +//! multi-path resolution of a declared `line_sources` GLOB, which is exactly what +//! the anti-vacuity arm turns on: a module whose suite only fabricated the map +//! would report "no pin found" as could-not-look while an engine that resolved +//! nothing at all reported the same thing, and the two would be +//! indistinguishable. +//! +//! The self-consumption case is the one the retiring suite ended on: this +//! repository's own workflows are judged, so the floor is a live claim rather +//! than an asserted one. +// +// carried: mise-tasks/mise-action-floor.sh policy/mise-action-floor.rego crates/batten/tests/it/mise_action_floor.rs +// carried: tests/mise-action-floor.bats policy/mise-action-floor.rego crates/batten/tests/it/mise_action_floor.rs +// +// carried: "a pin carrying the retry passes, and says what it judged" policy/mise-action-floor.rego +// carried: "THE ACCEPTANCE CASE: a pre-retry pin fails and is named with path:line" policy/mise-action-floor.rego +// carried: "THE BACKSLIDE: one reverted pin among many still fails" policy/mise-action-floor.rego +// carried: "SHOWN ABLE TO FAIL IN BOTH DIRECTIONS: every pin reverted fails with the full count" policy/mise-action-floor.rego +// carried: "the predicate is scoped to this action, so a lookalike coordinate does not fire" policy/mise-action-floor.rego +// carried: "the sha in prose or a comment is not a pin" policy/mise-action-floor.rego +// carried: "ANTI-VACUITY: a workflow with no mise-action pin is exit 2, never a pass" policy/mise-action-floor.rego +// carried: "an unversioned float is not a pin this gate can judge, so it is exit 2" policy/mise-action-floor.rego +// carried: "POINTER, NEVER PAYLOAD: the report carries no workflow content" policy/mise-action-floor.rego +// changed: "COULD NOT LOOK: a missing path is exit 2 rather than an empty pass" policy/mise-action-floor.rego the shell took explicit paths and refused one it could not open; the successor takes a declared `line_sources` glob and the ENGINE decides this earlier — a rule whose glob matches nothing is not evaluated at all, and `input.tree.missing` is never populated on the tree surface (CLOUD-1049). What the case protected is not lost: a tree with workflows but no pin of this action still reports could-not-look, which is `a_tree_with_no_pin_of_this_action_is_not_clean` + +// Panicking on setup failure is the idiomatic way for a test to fail loudly. +#![allow(clippy::unwrap_used, clippy::expect_used)] + +use crate::common; + +use std::path::{Path, PathBuf}; +use std::process::Output; + +use common::{Fixture, git_in, run, stderr, stdout}; + +/// The pre-retry commit, split so the literal never reads as a live coordinate +/// in this file. +const PRE_RETRY: &str = "7e36c90d9ab29c415a2384db3006f3ec8a8cc654"; + +/// Any later commit. Its only property that matters is not being the one above. +const FORWARD: &str = "1111111111111111111111111111111111111111"; + +/// A repository declaring only this rule, so any finding is the one under test. +/// +/// The `[[pattern]]` row is carried in beside the two `[[verdict]]` rows for the +/// same reason: a module referencing a pattern no row declares is refused AT +/// LOAD, which is a usage error rather than the verdict these cases are about. +fn floor_repo(name: &str, workflows: &[(&str, &str)]) -> PathBuf { + let mut fixture = Fixture::new(name).config( + "version = 1\n\n\ + [[pattern]]\n\ + id = \"git-object-id\"\n\ + regex = '^[0-9a-f]{40}$'\n\n\ + [[verdict]]\n\ + id = \"pin read stale\"\n\ + gloss = \"a pin resolves behind a fix this repository depends on\"\n\ + class = \"A backslide auto-lands silently and the next occurrence reads as fresh.\"\n\n\ + [[verdict.route]]\n\ + id = \"prose read first\"\n\ + kind = \"document\"\n\ + target = \"AGENTS.md\"\n\n\ + [[verdict]]\n\ + id = \"pin read unread\"\n\ + gloss = \"the pin under judgement could not be read\"\n\ + class = \"A gate whose subject can vanish and read as clean is not a gate.\"\n\n\ + [[verdict.route]]\n\ + id = \"prose read first\"\n\ + kind = \"document\"\n\ + target = \"AGENTS.md\"\n\n\ + [[rule]]\n\ + id = \"mise-action-floor\"\n\ + kind = \"policy\"\n\ + scope = \"tree\"\n\ + line_sources = [\".github/workflows/*.yml\"]\n\ + module = \"policy/mise-action-floor.rego\"\n\ + severity = \"deny\"\n", + ); + fixture = fixture.file("AGENTS.md", "the consumer's own authority\n"); + for (path, body) in workflows { + fixture = fixture.file(path, body); + } + let dir = fixture.git().build(); + common::write( + &dir, + "policy/mise-action-floor.rego", + &std::fs::read_to_string(common::at_root("policy/mise-action-floor.rego")).unwrap(), + ); + git_in(&dir, &["add", "-A"]); + git_in(&dir, &["commit", "-q", "-m", "base"]); + dir +} + +fn check(dir: &Path) -> Output { + run(dir, &["check", "--rule", "mise-action-floor"]) +} + +/// A workflow whose install step pins the action at `reference`. +fn workflow(reference: &str) -> String { + format!( + "on: push\njobs:\n build:\n runs-on: ubuntu-latest\n steps:\n - uses: jdx/mise-action@{reference}\n" + ) +} + +#[test] +fn a_pin_carrying_the_retry_passes() { + let dir = floor_repo( + "floor-forward", + &[(".github/workflows/ci.yml", &workflow(FORWARD))], + ); + let output = check(&dir); + assert_eq!( + output.status.code(), + Some(0), + "out={} err={}", + stdout(&output), + stderr(&output) + ); +} + +#[test] +fn a_pre_retry_pin_is_refused_and_named() { + let dir = floor_repo( + "floor-backslide", + &[(".github/workflows/ci.yml", &workflow(PRE_RETRY))], + ); + let output = check(&dir); + assert_eq!(output.status.code(), Some(2), "{}", stdout(&output)); + assert!( + stdout(&output).contains(".github/workflows/ci.yml:"), + "the finding points at the workflow line: {:?}", + stdout(&output) + ); +} + +#[test] +fn one_reverted_pin_among_many_still_fails() { + // THE BACKSLIDE the gate exists for: the bot bumps one workflow and the + // others stay forward, so a whole-tree "all pins agree" reading would miss + // it. + let dir = floor_repo( + "floor-one-among-many", + &[ + (".github/workflows/ci.yml", &workflow(FORWARD)), + (".github/workflows/release.yml", &workflow(PRE_RETRY)), + ], + ); + let output = check(&dir); + assert_eq!(output.status.code(), Some(2), "{}", stdout(&output)); + assert!( + stdout(&output).contains("release.yml"), + "the finding names the reverted workflow: {:?}", + stdout(&output) + ); +} + +#[test] +fn a_lookalike_coordinate_does_not_fire() { + // The same sha on a different action is not this defect. A gate firing on a + // lookalike trains its readers to ignore it. + let dir = floor_repo( + "floor-lookalike", + &[( + ".github/workflows/ci.yml", + &format!( + "{} - uses: some/other-action@{PRE_RETRY}\n", + workflow(FORWARD) + ), + )], + ); + let output = check(&dir); + assert_eq!(output.status.code(), Some(0), "{}", stdout(&output)); +} + +#[test] +fn a_sha_in_prose_is_not_a_pin() { + let dir = floor_repo( + "floor-prose", + &[( + ".github/workflows/ci.yml", + &format!( + "{} # was {PRE_RETRY} before the retry landed\n", + workflow(FORWARD) + ), + )], + ); + let output = check(&dir); + assert_eq!(output.status.code(), Some(0), "{}", stdout(&output)); +} + +#[test] +fn a_tree_with_no_pin_of_this_action_is_not_clean() { + // ANTI-VACUITY: the thing under test must not be able to vanish and read as + // clean. + let dir = floor_repo( + "floor-vacuous", + &[( + ".github/workflows/ci.yml", + "on: push\njobs:\n build:\n steps:\n - uses: actions/checkout@v4\n", + )], + ); + let output = check(&dir); + assert_eq!(output.status.code(), Some(2), "{}", stdout(&output)); +} + +#[test] +fn a_floating_ref_is_not_a_pin_this_gate_can_judge() { + // `@v4` carries no sha, so the denylist cannot speak about it at all. + // Reporting green over it would be a claim the gate cannot support. + let dir = floor_repo( + "floor-floating", + &[(".github/workflows/ci.yml", &workflow("v4"))], + ); + let output = check(&dir); + assert_eq!(output.status.code(), Some(2), "{}", stdout(&output)); +} + +#[test] +fn output_is_pointer_only() { + let dir = floor_repo( + "floor-pointer-only", + &[(".github/workflows/ci.yml", &workflow(PRE_RETRY))], + ); + let output = check(&dir); + assert_eq!(output.status.code(), Some(2)); + let text = stdout(&output); + assert!( + !text.contains("ubuntu-latest") && !text.contains("runs-on"), + "the workflow's content is payload: {text:?}" + ); +} + +#[test] +fn this_repositorys_own_workflows_hold_the_floor() { + // The self-consumption case the retiring suite ended on: the floor is a live + // claim about this repository, checked rather than asserted. + let output = common::run_at_real_root( + &common::at_root(""), + &["check", "--rule", "mise-action-floor"], + ); + assert_eq!( + output.status.code(), + Some(0), + "a workflow here pins behind the download retry: {}", + stdout(&output) + ); +} diff --git a/hk.pkl b/hk.pkl index 3c0da50af..614ea6d67 100644 --- a/hk.pkl +++ b/hk.pkl @@ -553,20 +553,6 @@ local gate = new Mapping { glob = List(".github/workflows/*.yml") check = "mise run timeout-check" } - // We pin an UNRELEASED toolchain-install commit — - // uses: jdx/mise-action@9dda3952d607125725deac9ec10a5f0e245d266b — because it is - // the one that added the download retry, and that action ships its own built - // dist. The pin therefore no longer matches a release tag — while renovate.json5 - // tracks the github-actions ecosystem and auto-bot-land.yml lands bot bumps - // with no human in the loop. A bump resolving back to the pre-retry commit - // would be a silent downgrade, auto-landed, and the next transient would read - // as fresh. So the rule ships with its mechanism: this reds such a pin at check - // time, which is what stops that PR going green (CLOUD-404). A denylist rather - // than a required sha, so a real forward bump needs no edit here. - ["mise-action-floor"] { - glob = List(".github/workflows/*.yml") - check = "mise run mise-action-floor" - } // Every agent pays AGENTS.md on every turn, so its size is a per-turn tax // rather than a style question. Counted with any memory declared always-load, // since moving a section into one of those moves the cost without cutting it. diff --git a/mise-tasks/mise-action-floor.sh b/mise-tasks/mise-action-floor.sh deleted file mode 100755 index f6a0a7028..000000000 --- a/mise-tasks/mise-action-floor.sh +++ /dev/null @@ -1,164 +0,0 @@ -#!/usr/bin/env bash -#MISE description="Gate: no workflow pins a toolchain-install action commit known to predate its download retry (CLOUD-404)" -# -# CLOUD-404. The toolchain install action fetched its own mise binary with a bare -# `curl -fsSL` and no retry, so a transient release-asset error (curl 22/503, -# curl 60/TLS) killed a job in provisioning — spending its minutes, redding the -# branch, and answering nothing. Three occurrences in two days. Upstream fixed it -# in `9dda3952d` (`retryDownload`, 5 attempts at 2s, wrapping both download tools -# and retrying on any thrown error) after our report, and we adopted that commit -# directly because it ships its own built `dist/`. -# -# WHY THIS GATE EXISTS, AND IT IS NOT ABOUT HUMANS FORGETTING. We adopted an -# UNRELEASED commit, so the pin no longer corresponds to a release tag. The pin -# comment used to name the major version alone, and `renovate.json5` -# tracks the `github-actions` ecosystem while `auto-bot-land.yml` lands bot -# bumps **with no human in the loop** — every check green is the only condition. -# A bot resolving that major back to the pre-retry commit would therefore be a -# silent DOWNGRADE to the un-retried install, auto-landed. That is strictly worse -# than the transient it reverts, because nothing announces it and the next -# occurrence reads as fresh. -# -# So the rule "do not slide back behind the retry" ships with its mechanism -# (non-negotiable 2): this gate reds such a pin at check time, the bot's PR -# cannot go green, and `auto-bot-land` cannot land it. The accepted cost is -# that such a PR then sits open and red until somebody closes it — stated here -# rather than discovered later. -# -# A DENYLIST OF KNOWN-BAD PINS, NOT A REQUIRED SHA. An "equals the expected -# commit" gate would fail every legitimate forward bump and demand a hand edit in -# lockstep with the bot — which is how a gate earns a bypass and then gets -# switched off. A denylist is silent on the next release and every one after it, -# and speaks only for the backslide. It also cannot answer "is this pin new -# enough", which is the honest limit: ancestry needs the network, so it lives on -# the issue as a checkable acceptance line rather than being faked offline here. -# -# WHICH BYTES: the INDEX (`git show :`), the `timeout-check` and -# `lock-complete` idiom — exactly the bytes a commit would carry, identical in CI -# and in a sandbox, immune to whatever an editor left in the tree. Explicit path -# arguments win, which is how the bats suite drives fixtures without a git repo. -# -# Exit 0 pass, 1 a denylisted pin, 2 could-not-look. `2` is the `lock-complete` -# doctrine — "the gate could not read what it was asked to judge" — and a gate -# reporting green over bytes it failed to read is what gets a gate switched off. -# A gate listed in $MUTANT_GATES with no row here fails `mise run mutant`. -#MUTANT pre-retry-pin-passes|s/^\texit 1$/\texit 0/|a pre-retry pin fails and is named - -set -euo pipefail - -# ONE COORDINATE, and both the action name and the denylist are derived from it. -# Written as a real pinned coordinate rather than a bare vendor string on purpose: -# `attribution-check` exempts a line that NAMES a dependency (`uses:`, `@<40 hex>`) -# and flags the same name in prose as an appeal to authority — and the exemption is -# per LINE, so the spelling has to live where it reads as a coordinate. Deriving -# also means the sha appears once, so the name and the denylisted pin cannot drift. -# -# This is the pin that predates the retry: the latest release and the floating -# major tag both resolve to it, which is exactly what makes it the reachable -# backslide rather than a hypothetical one. -PRE_RETRY_COORDINATES=" -uses: jdx/mise-action@7e36c90d9ab29c415a2384db3006f3ec8a8cc654 -" - -# The action every message and pattern below expands, taken from the first -# coordinate: everything left of the `@`, with the `uses: ` prefix dropped. -# `<<<` rather than a pipe into `grep -m1`: an early-exiting grep SIGPIPEs its -# producer, and under `pipefail` that makes a MATCH report failure — the inversion -# `pipefail-grep-check` gates, which caught exactly this line. -first_coordinate=$(grep -m1 '@' <<<"$PRE_RETRY_COORDINATES" || true) -if [[ -z "$first_coordinate" ]]; then - echo "::error:: mise-action-floor: PRE_RETRY_COORDINATES declares no pinned coordinate, so this gate has nothing to judge against." >&2 - exit 2 -fi -ACTION=${first_coordinate#*: } -ACTION=${ACTION%@*} - -# The shas themselves. Add a coordinate above when a pin is found to predate a fix -# we depend on; never remove one, because a commit does not stop being pre-retry. -PRE_RETRY_PINS=$(sed -n 's/.*@//p' <<<"$PRE_RETRY_COORDINATES") - -# Pointer-only per non-negotiable rule 4: the workflow, the line number and a -# short sha. Never a line of workflow content. -violations=0 -report() { - echo "::error:: $1" >&2 - violations=$((violations + 1)) -} - -# The files to judge. With arguments, those paths; without, every workflow in the -# index. Fixture mode is the argument form. -declare -a labels=() -declare -a sources=() -scratch="" -cleanup() { [[ -z "$scratch" ]] || rm -rf "$scratch"; } -trap cleanup EXIT - -if [[ "$#" -gt 0 ]]; then - for path in "$@"; do - if [[ ! -f "$path" ]]; then - echo "::error:: mise-action-floor: $path not found" >&2 - exit 2 - fi - labels+=("$path") - sources+=("$path") - done -else - scratch="$(mktemp -d)" - tracked="$(git ls-files '.github/workflows/*.yml')" - if [[ -z "$tracked" ]]; then - echo "::error:: mise-action-floor: no tracked .github/workflows/*.yml — run from the repo, or pass paths" >&2 - exit 2 - fi - while IFS= read -r path; do - [[ -n "$path" ]] || continue - blob="$scratch/$(basename "$path")" - if ! git show ":$path" >"$blob" 2>/dev/null; then - echo "::error:: mise-action-floor: $path is not in the index — stage it, or pass a path" >&2 - exit 2 - fi - labels+=("$path") - sources+=("$blob") - done <<<"$tracked" -fi - -# Every 40-hex pin of the action, as `