diff --git a/AGENTS.md b/AGENTS.md index 6a0cebdda..a18668358 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -103,8 +103,10 @@ what you proved, never where you discover a free-to-catch failure; it runs the s `mise` tasks you do, so one it runs that you can't is a bug. (The toolchain _does_ run in the web sandbox — read `mem:github-access` before doubting.) -1. **PRs start as drafts** (`gh pr create --draft`). CI does not run on drafts — - iterate at zero CI cost. +1. **PRs start as drafts** (`gh pr create --draft`); CI does not run on one, so + iteration is free. **PUSH THE IMPERFECT DRAFT, don't sit on a green tree** — a + reclaim takes all unpushed work. No slop licensed: a draft cannot merge red, and + `land` won't ready what your own gates refuse. Push early, fix after. 2. **`mise run verify` green before readying.** It mirrors CI and asserts the branch is rebased on current `origin/main`. "Green but stale" is not green. 3. **`mise run linear-check`.** Don't ready by hand: `land` readies, after its @@ -133,19 +135,15 @@ backgrounded task re-invokes you when it exits (measured 523/524, failures included), so the turn in between is the _designed_ state, not one to fill — **"idle" means a turn with NOTHING backgrounded**, and it is committed-and-pushed, never activity, that survives a reclaim. Manufacturing your own wake-ups with a -backgrounded `sleep N; tail log` is a timer where an exit condition belongs, -duplicating the notification (490 in one session, 2 changed a decision); refused -by `run-shape-guard`. To ask what a live task is _doing_, `mise run alive`. +backgrounded `sleep N; tail log` duplicates it (490 in a session, 2 mattered); **A LOOP IS NO EXEMPTION** (CLOUD-1337) — EVERY sleep is refused, either posture. Live task? `mise run alive`. -**Two habits defeat this silently, both failing green:** piping a `mise run` into -a pager (the exit status becomes the pager's) or detaching it with `nohup`/`&` -(the wake-up is lost). Put `run_in_background` on the long command, never a launcher, and +**Two habits defeat this silently, both failing green:** piping a `mise run` into a +pager (the status becomes the pager's) or detaching it with `nohup`/`&` (the +wake-up is lost). Put `run_in_background` on the long command, never a launcher, and **never redirect it** — the harness captures where the HUMAN watches, so `>log -2>&1` writes where nobody reads. `verdict-not-discarded`, `redirect write unread`. -**Never** use a foreground `sleep`, spin a foreground busy-poll, or end a turn idle -"to watch" something — background it, act on its exit, and commit first, since -**committed-and-pushed is the only state surviving a reclaim, and that is the TREE's -half**: declared work dies too, so **"unsaved?" is `batten doctor session`**. +2>&1` writes where nobody reads. `verdict-not-discarded`, `background-redirect`. +**Never** foreground-`sleep`, busy-poll, or idle "to watch" — background it, act on +its exit, push. Declared work dies too: **"unsaved?" is `batten doctor session`**. ## Non-negotiable project rules diff --git a/batten.toml b/batten.toml index 5f80703d2..ce0c8255e 100644 --- a/batten.toml +++ b/batten.toml @@ -2040,9 +2040,49 @@ regex = 'CLOUD-[0-9]+' # `[[pattern]]` row and not a literal in the crate: the core stays repo-agnostic, # and a consumer whose forge spells the set differently declares its own row # rather than patching the engine. +# BLANK, NEVER SPACE, and the difference is a measured false positive rather than +# a nicety (CLOUD-1752). `[[:space:]]` matches a NEWLINE, so the anchor above +# stopped meaning "immediately before a key" the moment a body put the verb at the +# end of one line and the key at the start of the next — which ordinary prose does +# constantly. Measured 2026-09-09 over this repository's 713 merged pull requests: +# 16 rows read as CLOSED that no body closes, `mise-tasks/merged-pr-keys.sh` +# emitting none of them. PR #163 is the shape: +# +# ## The residue survived the fix +# +# CLOUD-223 taught `.claude/hooks/session-start.sh` … +# +# A heading ending in "the fix", a blank line, then a citation — read as a claim. +# The harm runs the dangerous way: a false CLAIM tells `in-progress-drain` a row +# landed, and it drains a row that is still live. +# +# `[[:blank:]]` is space and tab and nothing else, so the verb must sit on the +# key's own line — which is what the paragraph above always said this row did. +# A NEGATED CLOSING VERB IS NOT A CLAIM, and the row above cannot see one +# (CLOUD-1752). Its anchor decides the text IMMEDIATELY before a key, which is +# exactly what makes `does not close CLOUD-1` match: the prefix ends in `close` +# and the negation sits one word further back, outside what an end-anchored +# pattern can reach. Rust's regex has no lookbehind, so the guard is a second row +# rather than a cleverer first one. +# +# MEASURED 2026-09-09 over this repository's 713 merged pull requests: SIX rows +# read as closed by a body that says in so many words that it does not close them +# — `## Why this does NOT close CLOUD-1074`, `It also does not close CLOUD-673`, +# `**This does not close CLOUD-1050`, `## Why this does not close CLOUD-607`, +# `Filed, not fixed: CLOUD-466`. Writing out why a change does NOT close a row is +# a habit this repository actively encourages, so the false positive is not rare +# and it runs the dangerous way: a false CLAIM moves a live row. +# +# ANCHORED AT THE END LIKE ITS SIBLING, and read against the text before the VERB +# rather than before the key — so `not` must sit on the verb, never merely +# somewhere earlier in the paragraph. +[[pattern]] +id = "ready-closing-negation" +regex = '(?i)(^|[^0-9A-Za-z-])(not|never|n.t|without|nor)[[:blank:]]*$' + [[pattern]] id = "ready-closing-verb" -regex = '(?i)(^|[^0-9A-Za-z-])(clos(e|es|ed)|fix(|es|ed)|resolv(e|es|ed))[[:space:]]*:?[[:space:]]*#?$' +regex = '(?i)(^|[^0-9A-Za-z-])(clos(e|es|ed)|fix(|es|ed)|resolv(e|es|ed))[[:blank:]]*:?[[:blank:]]*#?$' # THE PROSE-DIALECT THRESHOLD (CLOUD-472) IS `[ready]`, NOT A `[[pattern]]` ROW. # It was drafted as one — a regex over the exempt key range — and that is the @@ -2060,6 +2100,15 @@ regex = '(?i)(^|[^0-9A-Za-z-])(clos(e|es|ed)|fix(|es|ed)|resolv(e|es|ed))[[:spac # issue reads this row instead of spelling its own set. `duplicate` is closed too: # an exemption whose owner was merged into another issue is as spent as one whose # owner shipped. +# A RUN OF DIGITS AND NOTHING ELSE, which is what makes a recorded age readable +# as a number at all. `branch-age`'s producer writes the age it computed; this is +# the module's shape test before `to_number`, because an unparseable third column +# is a torn record rather than a branch that is zero days old — and those two must +# not collapse into the same verdict. +[[pattern]] +id = "whole-number" +regex = '^[0-9]+$' + [[pattern]] id = "closed-issue-status" regex = '^(done|canceled|duplicate)$' @@ -5760,6 +5809,13 @@ severity = "deny" # green fires this and not that; a commit graded red fires both, and they say # different things — do not re-run it, and do not land it. # +# BOTH READINGS NEEDED A PRODUCER, and until CLOUD-1707 neither had one: nothing +# invoked `batten record forge`, so this fact was `null` and the paragraph above +# described a discrimination no checkout could make. `mise run record-verdicts` +# writes it, and writes it ONLY once the fan-in has concluded — so "nothing was +# recorded at all" now means the forge has not finished judging, which is the +# could-not-look both rows read it as. +# # `warn`, NOT `deny`, AND THE FIRST LANDING IS THE REASON. `land` re-verifies and # re-waits every lap by design, because a rebase mints a new SHA and the receipts # keyed to the old one are gone — so the loop legitimately reaches graded commits, @@ -6139,6 +6195,26 @@ line_sources = [ module = "policy/mutation-declared-case.rego" severity = "deny" +# CLOUD-349, ported off `mise-tasks/branch-age-check.sh` under CLOUD-1717. +# +# A MEASUREMENT, SO THE SPAWN STAYS OUTSIDE (house-style §5). `mise run +# branch-age-record` makes the two forge reads and computes each branch's age in +# days — the engine calls no clock on any evaluation path, so the arithmetic +# cannot live in the module — and writes `branch-age` through `batten record +# named`. This row adjudicates what was recorded. +# +# `severity = "warn"` FOR NOW, and the reason is the population rather than +# caution: the retired program ran on a schedule against every remote branch, +# where this runs in `verify` against whatever the last producer wrote. CLOUD-320 +# binds the promotion — land it reporting, read the firing rate, promote with the +# measurement rather than with an argument. +[[rule]] +id = "branch watch loose" +kind = "policy" +scope = "tree" +module = "policy/branch-age.rego" +severity = "warn" + [[rule]] id = "plan cover partial" kind = "policy" @@ -6734,6 +6810,11 @@ looked at it.""" # bytes at this version — absent from the map, not a verdict — which is what a # checkout gets if the producer was skipped or died. The row that wants a verdict # to be REQUIRED is `forge-verdict-required`'s shape and is not this one. +# THAT CONTRAST ONLY BECAME REAL WITH CLOUD-1707. `forge-verdict-required` had no +# producer, so it refused nothing and the sentence above named a shape rather than +# a behaviour. Both rows are fed by `mise run record-verdicts` now, and they still +# read absence the same way — what differs is what each does with a record that IS +# present, which is the distinction this block was always drawing. [[rule]] id = "tool judge dirty" kind = "policy" @@ -7868,6 +7949,41 @@ rule = "task carry other" reason = "CLOUD-1265's `[tasks.record-verdicts]` is a producer's EFFECT, not a predicate: the predicate it replaces migrated to `policy/validator-verdict-clean.rego`, and section 5 leaves no rule kind for a body that must spawn a validator. `mise-tasks/pkl-check.sh` and its suite leave the tree in the same commit, so the bash surface falls while this row's `mise.toml`-only count rises by one." expires = "2026-10-31" +# THE SAME DISPOSITION, THE SAME REASON, ONE CAMPAIGN LATER (CLOUD-1717). The row +# above is `[tasks.record-verdicts]`; this is `[tasks.branch-age-record]`, and the +# argument transfers because the shape is identical rather than merely similar. +# +# THE PREDICATE DID MIGRATE. What `mise-tasks/branch-age-check.sh` decided — is a +# remote branch older than the threshold, and is a branch NAME heading more than +# one merged pull request — is `policy/branch-age.rego`'s now. What stays outside +# is the part that is not a decision: two `gh` reads and a civil-calendar +# subtraction. Neither can move in. Section 5 makes `check` `read` and structurally +# incapable of spawning, and `Fact::Instant` projects `null` to every module — +# `clippy.toml`'s `disallowed-methods` and `crates/batten/tests/clock_ban.rs` hold +# the engine to calling no clock on any evaluation path. So the producer computes +# the age and the module compares the THRESHOLD, which is CLOUD-1559's reading rule +# from the other side: carry the decisions, not the steps. There is no rule kind +# for that body to become. +# +# THE BASH SURFACE FALLS IN THIS SAME CAMPAIGN, which is the condition the row above +# sets for taking this disposition at all. The ratchet counts one literal in +# `mise.toml` and nothing else, so it reads 2->3 while +# `mise-tasks/branch-age-check.sh` and `tests/branch-age-check.bats` leave the tree +# — the shape this ratchet's own header warns about, where "a row that denies its +# own campaign's wins is worse than the hole it closes". +# +# UNNARROWED, for the reason both waivers above record: a ratchet finding's path is +# the glob PLUS THE COUNTS, so no `path =` selects it and a literal one would lapse +# the moment either count moved. +# +# EXPECTED TO LAPSE UNUSED. `base = "origin/main"`, so the floor becomes 3 on its own +# the moment this lands and the row suppresses nothing. It exists to get one commit +# past the gate, not to stand. +[[waiver]] +rule = "task write other" +reason = "CLOUD-1717's `[tasks.branch-age-record]` is a producer's EFFECT, not a predicate: the predicate migrated to `policy/branch-age.rego`, and what stays outside is a `gh` read plus calendar arithmetic that section 5 and the clock ban both make unmovable into the engine. `mise-tasks/branch-age-check.sh` and `tests/branch-age-check.bats` leave the tree in the same campaign, so the bash surface falls while this row's `mise.toml`-only count rises by one." +expires = "2026-10-31" + # `cfg-gated-test`'s own first legitimate refusal, taken through the route the # class declares rather than the one it declared first. The long form of why an # override is not that route sits on `verdict[test cover partial]` above; the @@ -12932,6 +13048,57 @@ id = "patch run first" kind = "command" target = "git commit -F " +# CLOUD-349's two readings, ported off `branch-age-check.sh` under CLOUD-1717. +[[verdict]] +id = "branch watch stale" +gloss = "a remote branch has outlived the story it was cut for" +class = """ +Trunk-based development asks a review branch to be "very short-lived" and names \ +the hazard: a short-lived feature branch sleepwalking into a long-lived one. \ +Measured 2026-08-11, before `land` learned to delete: 23 remote branches, ten of \ +them five days old. The branch is either finished — in which case delete it — or \ +it is not, in which case it is a long-lived branch and the trunk-based claim is \ +the thing that is false. +""" + +[[verdict.route]] +id = "branch write first" +kind = "command" +target = "git push origin --delete " + +[[verdict]] +id = "branch name duplicate" +gloss = "one branch name has headed several merged pull requests and still exists" +class = """ +A per-PR lifetime metric cannot see this: \ +`claude/phase-3-sequential-landing-h26kx0` headed eight consecutive pull \ +requests, each landing inside an hour, while the branch itself lived for days. \ +THE SURVIVING BRANCH IS HALF THE CLASS, because merged pull requests are \ +immutable — a count alone would be an alarm no action could ever clear, so what \ +is refused is the name still being there to reuse again. +""" + +[[verdict.route]] +id = "branch write first" +kind = "command" +target = "git push origin --delete , and cut a fresh name for the next story" + +[[verdict]] +id = "branch list empty" +gloss = "the branch record was written and names no branch at all" +class = """ +A remote reporting no branches cannot be true of a repository with a trunk, so \ +the honest reading is a listing that failed while still exiting zero. ABSENT AND \ +PRESENT-BUT-EMPTY ARE DIFFERENT STATES and must not collapse: no record is \ +could-not-look and says nothing, a record naming nothing is a reading this gate \ +refuses. Re-run the producer and read what it reports. +""" + +[[verdict.route]] +id = "task run first" +kind = "command" +target = "mise run branch-age-record" + # CLOUD-613, CLOUD-482. The waste here is the SESSION rather than a verdict or a # gate, which is why it is a class of its own rather than a row on either above. [[verdict]] @@ -12943,14 +13110,17 @@ exit 143 and 144 over a hung commit, after which the container was reclaimed \ with the work uncommitted. Waiting is the harness's job, not the command's: put \ the work in the background by passing `run_in_background` on the tool call \ itself and act on its exit, which is delivered (measured 523 of 524 in one \ -session). For a condition rather than a process, background a command that EXITS \ -when the condition holds — that is a background wait and is allowed. +session). THERE IS NO SANCTIONED SLEEP — in a loop or out of one, foreground or \ +background (CLOUD-1337, CLOUD-821). A loop changes what the wait is ABOUT and \ +not that the session spends itself performing a poll the exit notification \ +already performs. Chained execution — run X, then when it finishes run Y — is a \ +workflow and gets a row, never a poll. """ [[verdict.route]] id = "task run first" kind = "command" -target = "until ; do sleep 1; done — where reads something the harness does NOT report, never a process table" +target = "pass run_in_background on the long command itself, and act on its exit notification" [[verdict.route]] id = "task run other" @@ -12969,15 +13139,18 @@ so it reports the same whether that thing finished, failed, or never started. \ The wake-up already exists: a backgrounded task's exit notification is delivered, \ measured 523 of 524 in one session including every failure, so idling until it \ arrives is the designed state rather than a turn wasted. Measured 2026-08-21: \ -490 of these in one session, 2 of which changed a decision. A backgrounded \ -command carrying an `until`/`while` construct waits on the condition itself and \ -is allowed. +490 of these in one session, 2 of which changed a decision. THE LOOP IS NOT AN \ +EXEMPTION and was one until CLOUD-1337: a backgrounded `until`/`while` around \ +the sleep waits on the condition rather than the clock, and still spends the \ +session performing by hand the wake-up the runtime already delivers. Measured \ +2026-09-09: one such loop ran 3h34m under that exemption before a human reading \ +`ps` found it. Every sleep is refused, in both postures. """ [[verdict.route]] id = "task run first" kind = "command" -target = "until ; do sleep 1; done — where reads something the harness does NOT report, never a process table" +target = "pass run_in_background on the long command itself, and act on its exit notification" [[verdict.route]] id = "task run other" diff --git a/bench/suites/RESULTS.md b/bench/suites/RESULTS.md index fb113fb66..a845c7846 100644 --- a/bench/suites/RESULTS.md +++ b/bench/suites/RESULTS.md @@ -39,7 +39,6 @@ to it pays. | 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` | @@ -64,7 +63,6 @@ to it pays. | 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` | @@ -73,7 +71,6 @@ to it pays. | 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` | diff --git a/completions/batten.bash b/completions/batten.bash index 1f38fd21e..3229a6c7e 100644 --- a/completions/batten.bash +++ b/completions/batten.bash @@ -223,6 +223,12 @@ _batten() { batten__subcmd__claim,help) cmd="batten__subcmd__claim__subcmd__help" ;; + batten__subcmd__claim,keys) + cmd="batten__subcmd__claim__subcmd__keys" + ;; + batten__subcmd__claim,merged) + cmd="batten__subcmd__claim__subcmd__merged" + ;; batten__subcmd__claim,race) cmd="batten__subcmd__claim__subcmd__race" ;; @@ -238,6 +244,12 @@ _batten() { batten__subcmd__claim__subcmd__help,help) cmd="batten__subcmd__claim__subcmd__help__subcmd__help" ;; + batten__subcmd__claim__subcmd__help,keys) + cmd="batten__subcmd__claim__subcmd__help__subcmd__keys" + ;; + batten__subcmd__claim__subcmd__help,merged) + cmd="batten__subcmd__claim__subcmd__help__subcmd__merged" + ;; batten__subcmd__claim__subcmd__help,race) cmd="batten__subcmd__claim__subcmd__help__subcmd__race" ;; @@ -547,6 +559,12 @@ _batten() { batten__subcmd__help__subcmd__claim,check) cmd="batten__subcmd__help__subcmd__claim__subcmd__check" ;; + batten__subcmd__help__subcmd__claim,keys) + cmd="batten__subcmd__help__subcmd__claim__subcmd__keys" + ;; + batten__subcmd__help__subcmd__claim,merged) + cmd="batten__subcmd__help__subcmd__claim__subcmd__merged" + ;; batten__subcmd__help__subcmd__claim,race) cmd="batten__subcmd__help__subcmd__claim__subcmd__race" ;; @@ -760,12 +778,27 @@ _batten() { batten__subcmd__help__subcmd__record,closes) cmd="batten__subcmd__help__subcmd__record__subcmd__closes" ;; + batten__subcmd__help__subcmd__record,fold) + cmd="batten__subcmd__help__subcmd__record__subcmd__fold" + ;; batten__subcmd__help__subcmd__record,forge) cmd="batten__subcmd__help__subcmd__record__subcmd__forge" ;; + batten__subcmd__help__subcmd__record,journal) + cmd="batten__subcmd__help__subcmd__record__subcmd__journal" + ;; + batten__subcmd__help__subcmd__record,keyed) + cmd="batten__subcmd__help__subcmd__record__subcmd__keyed" + ;; + batten__subcmd__help__subcmd__record,named) + cmd="batten__subcmd__help__subcmd__record__subcmd__named" + ;; batten__subcmd__help__subcmd__record,plan) cmd="batten__subcmd__help__subcmd__record__subcmd__plan" ;; + batten__subcmd__help__subcmd__record,show) + cmd="batten__subcmd__help__subcmd__record__subcmd__show" + ;; batten__subcmd__help__subcmd__record,tool) cmd="batten__subcmd__help__subcmd__record__subcmd__tool" ;; @@ -1225,30 +1258,60 @@ _batten() { batten__subcmd__record,closes) cmd="batten__subcmd__record__subcmd__closes" ;; + batten__subcmd__record,fold) + cmd="batten__subcmd__record__subcmd__fold" + ;; batten__subcmd__record,forge) cmd="batten__subcmd__record__subcmd__forge" ;; batten__subcmd__record,help) cmd="batten__subcmd__record__subcmd__help" ;; + batten__subcmd__record,journal) + cmd="batten__subcmd__record__subcmd__journal" + ;; + batten__subcmd__record,keyed) + cmd="batten__subcmd__record__subcmd__keyed" + ;; + batten__subcmd__record,named) + cmd="batten__subcmd__record__subcmd__named" + ;; batten__subcmd__record,plan) cmd="batten__subcmd__record__subcmd__plan" ;; + batten__subcmd__record,show) + cmd="batten__subcmd__record__subcmd__show" + ;; batten__subcmd__record,tool) cmd="batten__subcmd__record__subcmd__tool" ;; batten__subcmd__record__subcmd__help,closes) cmd="batten__subcmd__record__subcmd__help__subcmd__closes" ;; + batten__subcmd__record__subcmd__help,fold) + cmd="batten__subcmd__record__subcmd__help__subcmd__fold" + ;; batten__subcmd__record__subcmd__help,forge) cmd="batten__subcmd__record__subcmd__help__subcmd__forge" ;; batten__subcmd__record__subcmd__help,help) cmd="batten__subcmd__record__subcmd__help__subcmd__help" ;; + batten__subcmd__record__subcmd__help,journal) + cmd="batten__subcmd__record__subcmd__help__subcmd__journal" + ;; + batten__subcmd__record__subcmd__help,keyed) + cmd="batten__subcmd__record__subcmd__help__subcmd__keyed" + ;; + batten__subcmd__record__subcmd__help,named) + cmd="batten__subcmd__record__subcmd__help__subcmd__named" + ;; batten__subcmd__record__subcmd__help,plan) cmd="batten__subcmd__record__subcmd__help__subcmd__plan" ;; + batten__subcmd__record__subcmd__help,show) + cmd="batten__subcmd__record__subcmd__help__subcmd__show" + ;; batten__subcmd__record__subcmd__help,tool) cmd="batten__subcmd__record__subcmd__help__subcmd__tool" ;; @@ -2131,7 +2194,7 @@ _batten() { return 0 ;; batten__subcmd__claim) - opts="-q -v -y -h --strictness --fail-on-warning --config-from --config-in --silent --quiet --verbose --debug --trace --log-level --no-color --no-input --yes --help check bot race carry help" + opts="-q -v -y -h --strictness --fail-on-warning --config-from --config-in --silent --quiet --verbose --debug --trace --log-level --no-color --no-input --yes --help merged keys check bot race carry help" if [[ ${cur} == -* || ${COMP_CWORD} -eq 2 ]] ; then COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 @@ -2259,7 +2322,7 @@ _batten() { return 0 ;; batten__subcmd__claim__subcmd__help) - opts="check bot race carry help" + opts="merged keys check bot race carry help" if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 @@ -2328,6 +2391,34 @@ _batten() { COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 ;; + batten__subcmd__claim__subcmd__help__subcmd__keys) + opts="" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + batten__subcmd__claim__subcmd__help__subcmd__merged) + opts="" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; batten__subcmd__claim__subcmd__help__subcmd__race) opts="" if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then @@ -2342,6 +2433,82 @@ _batten() { COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 ;; + batten__subcmd__claim__subcmd__keys) + opts="-q -v -y -h --branch --title --log --closing-only --refs-first-only --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 3 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + --branch) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; + --title) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; + --log) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; + --strictness) + COMPREPLY=($(compgen -W "permissive standard strict" -- "${cur}")) + return 0 + ;; + --config-from) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; + --config-in) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; + --log-level) + COMPREPLY=($(compgen -W "silent quiet normal verbose debug trace" -- "${cur}")) + return 0 + ;; + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + batten__subcmd__claim__subcmd__merged) + opts="-q -v -y -h --limit --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 3 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + --limit) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; + --strictness) + COMPREPLY=($(compgen -W "permissive standard strict" -- "${cur}")) + return 0 + ;; + --config-from) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; + --config-in) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; + --log-level) + COMPREPLY=($(compgen -W "silent quiet normal verbose debug trace" -- "${cur}")) + return 0 + ;; + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; batten__subcmd__claim__subcmd__race) opts="-q -v -y -h --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 3 ]] ; then @@ -3827,7 +3994,7 @@ _batten() { return 0 ;; batten__subcmd__help__subcmd__claim) - opts="check bot race carry" + opts="merged keys check bot race carry" if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 @@ -3882,6 +4049,34 @@ _batten() { COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 ;; + batten__subcmd__help__subcmd__claim__subcmd__keys) + opts="" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + batten__subcmd__help__subcmd__claim__subcmd__merged) + opts="" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; batten__subcmd__help__subcmd__claim__subcmd__race) opts="" if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then @@ -5213,7 +5408,7 @@ _batten() { return 0 ;; batten__subcmd__help__subcmd__record) - opts="tool forge plan closes" + opts="tool forge named keyed journal show fold plan closes" if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 @@ -5240,6 +5435,20 @@ _batten() { COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 ;; + batten__subcmd__help__subcmd__record__subcmd__fold) + opts="" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; batten__subcmd__help__subcmd__record__subcmd__forge) opts="" if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then @@ -5254,6 +5463,48 @@ _batten() { COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 ;; + batten__subcmd__help__subcmd__record__subcmd__journal) + opts="" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + batten__subcmd__help__subcmd__record__subcmd__keyed) + opts="" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + batten__subcmd__help__subcmd__record__subcmd__named) + opts="" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; batten__subcmd__help__subcmd__record__subcmd__plan) opts="" if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then @@ -5268,6 +5519,20 @@ _batten() { COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 ;; + batten__subcmd__help__subcmd__record__subcmd__show) + opts="" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; batten__subcmd__help__subcmd__record__subcmd__tool) opts="" if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then @@ -8961,7 +9226,7 @@ _batten() { return 0 ;; batten__subcmd__record) - opts="-q -v -y -h --strictness --fail-on-warning --config-from --config-in --silent --quiet --verbose --debug --trace --log-level --no-color --no-input --yes --help tool forge plan closes help" + opts="-q -v -y -h --strictness --fail-on-warning --config-from --config-in --silent --quiet --verbose --debug --trace --log-level --no-color --no-input --yes --help tool forge named keyed journal show fold plan closes help" if [[ ${cur} == -* || ${COMP_CWORD} -eq 2 ]] ; then COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 @@ -9020,6 +9285,36 @@ _batten() { COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 ;; + batten__subcmd__record__subcmd__fold) + opts="-q -v -y -h --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 3 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + --strictness) + COMPREPLY=($(compgen -W "permissive standard strict" -- "${cur}")) + return 0 + ;; + --config-from) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; + --config-in) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; + --log-level) + COMPREPLY=($(compgen -W "silent quiet normal verbose debug trace" -- "${cur}")) + return 0 + ;; + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; batten__subcmd__record__subcmd__forge) opts="-q -v -y -h --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 3 ]] ; then @@ -9051,7 +9346,7 @@ _batten() { return 0 ;; batten__subcmd__record__subcmd__help) - opts="tool forge plan closes help" + opts="tool forge named keyed journal show fold plan closes help" if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 @@ -9078,6 +9373,20 @@ _batten() { COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 ;; + batten__subcmd__record__subcmd__help__subcmd__fold) + opts="" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; batten__subcmd__record__subcmd__help__subcmd__forge) opts="" if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then @@ -9106,6 +9415,48 @@ _batten() { COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 ;; + batten__subcmd__record__subcmd__help__subcmd__journal) + opts="" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + batten__subcmd__record__subcmd__help__subcmd__keyed) + opts="" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + batten__subcmd__record__subcmd__help__subcmd__named) + opts="" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; batten__subcmd__record__subcmd__help__subcmd__plan) opts="" if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then @@ -9120,6 +9471,20 @@ _batten() { COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 ;; + batten__subcmd__record__subcmd__help__subcmd__show) + opts="" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; batten__subcmd__record__subcmd__help__subcmd__tool) opts="" if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then @@ -9134,6 +9499,96 @@ _batten() { COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 ;; + batten__subcmd__record__subcmd__journal) + opts="-q -v -y -h --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 3 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + --strictness) + COMPREPLY=($(compgen -W "permissive standard strict" -- "${cur}")) + return 0 + ;; + --config-from) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; + --config-in) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; + --log-level) + COMPREPLY=($(compgen -W "silent quiet normal verbose debug trace" -- "${cur}")) + return 0 + ;; + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + batten__subcmd__record__subcmd__keyed) + opts="-q -v -y -h --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 3 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + --strictness) + COMPREPLY=($(compgen -W "permissive standard strict" -- "${cur}")) + return 0 + ;; + --config-from) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; + --config-in) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; + --log-level) + COMPREPLY=($(compgen -W "silent quiet normal verbose debug trace" -- "${cur}")) + return 0 + ;; + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + batten__subcmd__record__subcmd__named) + opts="-q -v -y -h --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 3 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + --strictness) + COMPREPLY=($(compgen -W "permissive standard strict" -- "${cur}")) + return 0 + ;; + --config-from) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; + --config-in) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; + --log-level) + COMPREPLY=($(compgen -W "silent quiet normal verbose debug trace" -- "${cur}")) + return 0 + ;; + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; batten__subcmd__record__subcmd__plan) opts="-q -v -y -h --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 3 ]] ; then @@ -9164,6 +9619,36 @@ _batten() { COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 ;; + batten__subcmd__record__subcmd__show) + opts="-q -v -y -h --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 3 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + --strictness) + COMPREPLY=($(compgen -W "permissive standard strict" -- "${cur}")) + return 0 + ;; + --config-from) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; + --config-in) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; + --log-level) + COMPREPLY=($(compgen -W "silent quiet normal verbose debug trace" -- "${cur}")) + return 0 + ;; + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; batten__subcmd__record__subcmd__tool) opts="-q -v -y -h --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 3 ]] ; then diff --git a/completions/batten.fish b/completions/batten.fish index 59756c3cc..77f1aa380 100644 --- a/completions/batten.fish +++ b/completions/batten.fish @@ -2001,32 +2001,82 @@ complete -c batten -n "__fish_batten_using_subcommand singleton; and __fish_seen complete -c batten -n "__fish_batten_using_subcommand singleton; and __fish_seen_subcommand_from help" -f -a "acquire" -d 'Take a task\'s lock for a pid, or refuse naming the process that holds it' complete -c batten -n "__fish_batten_using_subcommand singleton; and __fish_seen_subcommand_from help" -f -a "release" -d 'Drop a task\'s lock, which its exit trap does and a kill cannot' complete -c batten -n "__fish_batten_using_subcommand singleton; and __fish_seen_subcommand_from help" -f -a "help" -d 'Print this message or the help of the given subcommand(s)' -complete -c batten -n "__fish_batten_using_subcommand claim; and not __fish_seen_subcommand_from check bot race carry help" -l strictness -d 'Raise how strictly gates apply (an override may only tighten policy)' -r -f -a "permissive\t'Advisory: findings are reported without failing the run' +complete -c batten -n "__fish_batten_using_subcommand claim; and not __fish_seen_subcommand_from merged keys check bot race carry help" -l strictness -d 'Raise how strictly gates apply (an override may only tighten policy)' -r -f -a "permissive\t'Advisory: findings are reported without failing the run' standard\t'The default: a finding is a violation' strict\t'Everything `Standard` fails on, plus anything advisory'" -complete -c batten -n "__fish_batten_using_subcommand claim; and not __fish_seen_subcommand_from check bot race carry help" -l config-from -d 'Read the committed config from a git ref (e.g. origin/main) instead of the working tree' -r -complete -c batten -n "__fish_batten_using_subcommand claim; and not __fish_seen_subcommand_from check bot race carry help" -l config-in -d 'Read the committed config from this directory instead of the directory being judged' -r -complete -c batten -n "__fish_batten_using_subcommand claim; and not __fish_seen_subcommand_from check bot race carry help" -l log-level -d 'Set the verbosity rung by name' -r -f -a "silent\t'Say nothing but a verdict or a usage error' +complete -c batten -n "__fish_batten_using_subcommand claim; and not __fish_seen_subcommand_from merged keys check bot race carry help" -l config-from -d 'Read the committed config from a git ref (e.g. origin/main) instead of the working tree' -r +complete -c batten -n "__fish_batten_using_subcommand claim; and not __fish_seen_subcommand_from merged keys check bot race carry help" -l config-in -d 'Read the committed config from this directory instead of the directory being judged' -r +complete -c batten -n "__fish_batten_using_subcommand claim; and not __fish_seen_subcommand_from merged keys check bot race carry help" -l log-level -d 'Set the verbosity rung by name' -r -f -a "silent\t'Say nothing but a verdict or a usage error' quiet\t'Suppress ordinary progress; keep warnings' normal\t'The default' verbose\t'Explain what is being checked' debug\t'Add resolution detail' trace\t'Add everything'" -complete -c batten -n "__fish_batten_using_subcommand claim; and not __fish_seen_subcommand_from check bot race carry help" -l fail-on-warning -d 'Promote a warn-severity finding to a violation (an override may only turn this on)' -complete -c batten -n "__fish_batten_using_subcommand claim; and not __fish_seen_subcommand_from check bot race carry help" -l silent -d 'Say nothing but a verdict or a usage error' -complete -c batten -n "__fish_batten_using_subcommand claim; and not __fish_seen_subcommand_from check bot race carry help" -s q -l quiet -d 'Suppress ordinary progress (repeatable: -qq is silent)' -complete -c batten -n "__fish_batten_using_subcommand claim; and not __fish_seen_subcommand_from check bot race carry help" -s v -l verbose -d 'Explain what is being checked (repeatable: -vv is debug)' -complete -c batten -n "__fish_batten_using_subcommand claim; and not __fish_seen_subcommand_from check bot race carry help" -l debug -d 'Add resolution detail' -complete -c batten -n "__fish_batten_using_subcommand claim; and not __fish_seen_subcommand_from check bot race carry help" -l trace -d 'Add everything' -complete -c batten -n "__fish_batten_using_subcommand claim; and not __fish_seen_subcommand_from check bot race carry help" -l no-color -d 'Never colour stderr, whatever it is attached to' -complete -c batten -n "__fish_batten_using_subcommand claim; and not __fish_seen_subcommand_from check bot race carry help" -l no-input -d 'Never prompt; treat the run as unattended' -complete -c batten -n "__fish_batten_using_subcommand claim; and not __fish_seen_subcommand_from check bot race carry help" -s y -l yes -d 'Confirm a destructive operation that would otherwise refuse' -complete -c batten -n "__fish_batten_using_subcommand claim; and not __fish_seen_subcommand_from check bot race carry help" -s h -l help -d 'Print help (see more with \'--help\')' -complete -c batten -n "__fish_batten_using_subcommand claim; and not __fish_seen_subcommand_from check bot race carry help" -f -a "check" -d 'Refuse a pull of an issue somebody is already on, and mint the receipt when it is free' -complete -c batten -n "__fish_batten_using_subcommand claim; and not __fish_seen_subcommand_from check bot race carry help" -f -a "bot" -d 'Attest a bot branch from the lane\'s public facts, and mint the receipt when they hold' -complete -c batten -n "__fish_batten_using_subcommand claim; and not __fish_seen_subcommand_from check bot race carry help" -f -a "race" -d 'Refuse a claim a different open pull request already carries, judged by head SHA' -complete -c batten -n "__fish_batten_using_subcommand claim; and not __fish_seen_subcommand_from check bot race carry help" -f -a "carry" -d 'Attest that this branch only carries licence rows forward, and mint the receipt when it does' -complete -c batten -n "__fish_batten_using_subcommand claim; and not __fish_seen_subcommand_from check bot race carry help" -f -a "help" -d 'Print this message or the help of the given subcommand(s)' +complete -c batten -n "__fish_batten_using_subcommand claim; and not __fish_seen_subcommand_from merged keys check bot race carry help" -l fail-on-warning -d 'Promote a warn-severity finding to a violation (an override may only turn this on)' +complete -c batten -n "__fish_batten_using_subcommand claim; and not __fish_seen_subcommand_from merged keys check bot race carry help" -l silent -d 'Say nothing but a verdict or a usage error' +complete -c batten -n "__fish_batten_using_subcommand claim; and not __fish_seen_subcommand_from merged keys check bot race carry help" -s q -l quiet -d 'Suppress ordinary progress (repeatable: -qq is silent)' +complete -c batten -n "__fish_batten_using_subcommand claim; and not __fish_seen_subcommand_from merged keys check bot race carry help" -s v -l verbose -d 'Explain what is being checked (repeatable: -vv is debug)' +complete -c batten -n "__fish_batten_using_subcommand claim; and not __fish_seen_subcommand_from merged keys check bot race carry help" -l debug -d 'Add resolution detail' +complete -c batten -n "__fish_batten_using_subcommand claim; and not __fish_seen_subcommand_from merged keys check bot race carry help" -l trace -d 'Add everything' +complete -c batten -n "__fish_batten_using_subcommand claim; and not __fish_seen_subcommand_from merged keys check bot race carry help" -l no-color -d 'Never colour stderr, whatever it is attached to' +complete -c batten -n "__fish_batten_using_subcommand claim; and not __fish_seen_subcommand_from merged keys check bot race carry help" -l no-input -d 'Never prompt; treat the run as unattended' +complete -c batten -n "__fish_batten_using_subcommand claim; and not __fish_seen_subcommand_from merged keys check bot race carry help" -s y -l yes -d 'Confirm a destructive operation that would otherwise refuse' +complete -c batten -n "__fish_batten_using_subcommand claim; and not __fish_seen_subcommand_from merged keys check bot race carry help" -s h -l help -d 'Print help (see more with \'--help\')' +complete -c batten -n "__fish_batten_using_subcommand claim; and not __fish_seen_subcommand_from merged keys check bot race carry help" -f -a "merged" -d 'The keys merged pull request bodies close, as `\\t` rows' +complete -c batten -n "__fish_batten_using_subcommand claim; and not __fish_seen_subcommand_from merged keys check bot race carry help" -f -a "keys" -d 'The issue keys this branch CLAIMS, as distinct from the ones it merely mentions' +complete -c batten -n "__fish_batten_using_subcommand claim; and not __fish_seen_subcommand_from merged keys check bot race carry help" -f -a "check" -d 'Refuse a pull of an issue somebody is already on, and mint the receipt when it is free' +complete -c batten -n "__fish_batten_using_subcommand claim; and not __fish_seen_subcommand_from merged keys check bot race carry help" -f -a "bot" -d 'Attest a bot branch from the lane\'s public facts, and mint the receipt when they hold' +complete -c batten -n "__fish_batten_using_subcommand claim; and not __fish_seen_subcommand_from merged keys check bot race carry help" -f -a "race" -d 'Refuse a claim a different open pull request already carries, judged by head SHA' +complete -c batten -n "__fish_batten_using_subcommand claim; and not __fish_seen_subcommand_from merged keys check bot race carry help" -f -a "carry" -d 'Attest that this branch only carries licence rows forward, and mint the receipt when it does' +complete -c batten -n "__fish_batten_using_subcommand claim; and not __fish_seen_subcommand_from merged keys check bot race carry help" -f -a "help" -d 'Print this message or the help of the given subcommand(s)' +complete -c batten -n "__fish_batten_using_subcommand claim; and __fish_seen_subcommand_from merged" -l limit -d 'The most pull requests to read before the answer is truncated (default 5000)' -r +complete -c batten -n "__fish_batten_using_subcommand claim; and __fish_seen_subcommand_from merged" -l strictness -d 'Raise how strictly gates apply (an override may only tighten policy)' -r -f -a "permissive\t'Advisory: findings are reported without failing the run' +standard\t'The default: a finding is a violation' +strict\t'Everything `Standard` fails on, plus anything advisory'" +complete -c batten -n "__fish_batten_using_subcommand claim; and __fish_seen_subcommand_from merged" -l config-from -d 'Read the committed config from a git ref (e.g. origin/main) instead of the working tree' -r +complete -c batten -n "__fish_batten_using_subcommand claim; and __fish_seen_subcommand_from merged" -l config-in -d 'Read the committed config from this directory instead of the directory being judged' -r +complete -c batten -n "__fish_batten_using_subcommand claim; and __fish_seen_subcommand_from merged" -l log-level -d 'Set the verbosity rung by name' -r -f -a "silent\t'Say nothing but a verdict or a usage error' +quiet\t'Suppress ordinary progress; keep warnings' +normal\t'The default' +verbose\t'Explain what is being checked' +debug\t'Add resolution detail' +trace\t'Add everything'" +complete -c batten -n "__fish_batten_using_subcommand claim; and __fish_seen_subcommand_from merged" -l fail-on-warning -d 'Promote a warn-severity finding to a violation (an override may only turn this on)' +complete -c batten -n "__fish_batten_using_subcommand claim; and __fish_seen_subcommand_from merged" -l silent -d 'Say nothing but a verdict or a usage error' +complete -c batten -n "__fish_batten_using_subcommand claim; and __fish_seen_subcommand_from merged" -s q -l quiet -d 'Suppress ordinary progress (repeatable: -qq is silent)' +complete -c batten -n "__fish_batten_using_subcommand claim; and __fish_seen_subcommand_from merged" -s v -l verbose -d 'Explain what is being checked (repeatable: -vv is debug)' +complete -c batten -n "__fish_batten_using_subcommand claim; and __fish_seen_subcommand_from merged" -l debug -d 'Add resolution detail' +complete -c batten -n "__fish_batten_using_subcommand claim; and __fish_seen_subcommand_from merged" -l trace -d 'Add everything' +complete -c batten -n "__fish_batten_using_subcommand claim; and __fish_seen_subcommand_from merged" -l no-color -d 'Never colour stderr, whatever it is attached to' +complete -c batten -n "__fish_batten_using_subcommand claim; and __fish_seen_subcommand_from merged" -l no-input -d 'Never prompt; treat the run as unattended' +complete -c batten -n "__fish_batten_using_subcommand claim; and __fish_seen_subcommand_from merged" -s y -l yes -d 'Confirm a destructive operation that would otherwise refuse' +complete -c batten -n "__fish_batten_using_subcommand claim; and __fish_seen_subcommand_from merged" -s h -l help -d 'Print help (see more with \'--help\')' +complete -c batten -n "__fish_batten_using_subcommand claim; and __fish_seen_subcommand_from keys" -l branch -d 'The head branch, standing in for source 2' -r +complete -c batten -n "__fish_batten_using_subcommand claim; and __fish_seen_subcommand_from keys" -l title -d 'The pull request title, also source 2 — a body is not, because a body cites evidence' -r +complete -c batten -n "__fish_batten_using_subcommand claim; and __fish_seen_subcommand_from keys" -l log -d 'Commit messages, standing in for sources 1 and 3' -r +complete -c batten -n "__fish_batten_using_subcommand claim; and __fish_seen_subcommand_from keys" -l strictness -d 'Raise how strictly gates apply (an override may only tighten policy)' -r -f -a "permissive\t'Advisory: findings are reported without failing the run' +standard\t'The default: a finding is a violation' +strict\t'Everything `Standard` fails on, plus anything advisory'" +complete -c batten -n "__fish_batten_using_subcommand claim; and __fish_seen_subcommand_from keys" -l config-from -d 'Read the committed config from a git ref (e.g. origin/main) instead of the working tree' -r +complete -c batten -n "__fish_batten_using_subcommand claim; and __fish_seen_subcommand_from keys" -l config-in -d 'Read the committed config from this directory instead of the directory being judged' -r +complete -c batten -n "__fish_batten_using_subcommand claim; and __fish_seen_subcommand_from keys" -l log-level -d 'Set the verbosity rung by name' -r -f -a "silent\t'Say nothing but a verdict or a usage error' +quiet\t'Suppress ordinary progress; keep warnings' +normal\t'The default' +verbose\t'Explain what is being checked' +debug\t'Add resolution detail' +trace\t'Add everything'" +complete -c batten -n "__fish_batten_using_subcommand claim; and __fish_seen_subcommand_from keys" -l closing-only -d 'Answer from a closing keyword alone, never falling through to the branch or a trailer' +complete -c batten -n "__fish_batten_using_subcommand claim; and __fish_seen_subcommand_from keys" -l refs-first-only -d 'Answer from the first key of each `Refs:` trailer alone, never sources 1 or 2' +complete -c batten -n "__fish_batten_using_subcommand claim; and __fish_seen_subcommand_from keys" -l fail-on-warning -d 'Promote a warn-severity finding to a violation (an override may only turn this on)' +complete -c batten -n "__fish_batten_using_subcommand claim; and __fish_seen_subcommand_from keys" -l silent -d 'Say nothing but a verdict or a usage error' +complete -c batten -n "__fish_batten_using_subcommand claim; and __fish_seen_subcommand_from keys" -s q -l quiet -d 'Suppress ordinary progress (repeatable: -qq is silent)' +complete -c batten -n "__fish_batten_using_subcommand claim; and __fish_seen_subcommand_from keys" -s v -l verbose -d 'Explain what is being checked (repeatable: -vv is debug)' +complete -c batten -n "__fish_batten_using_subcommand claim; and __fish_seen_subcommand_from keys" -l debug -d 'Add resolution detail' +complete -c batten -n "__fish_batten_using_subcommand claim; and __fish_seen_subcommand_from keys" -l trace -d 'Add everything' +complete -c batten -n "__fish_batten_using_subcommand claim; and __fish_seen_subcommand_from keys" -l no-color -d 'Never colour stderr, whatever it is attached to' +complete -c batten -n "__fish_batten_using_subcommand claim; and __fish_seen_subcommand_from keys" -l no-input -d 'Never prompt; treat the run as unattended' +complete -c batten -n "__fish_batten_using_subcommand claim; and __fish_seen_subcommand_from keys" -s y -l yes -d 'Confirm a destructive operation that would otherwise refuse' +complete -c batten -n "__fish_batten_using_subcommand claim; and __fish_seen_subcommand_from keys" -s h -l help -d 'Print help (see more with \'--help\')' complete -c batten -n "__fish_batten_using_subcommand claim; and __fish_seen_subcommand_from check" -l adopt-from -d 'The branch name the receipt being adopted was minted under' -r complete -c batten -n "__fish_batten_using_subcommand claim; and __fish_seen_subcommand_from check" -l issue -d 'Read the issue payload from the capture store by key, where `mcp call ... get_issue` put it' -r complete -c batten -n "__fish_batten_using_subcommand claim; and __fish_seen_subcommand_from check" -l strictness -d 'Raise how strictly gates apply (an override may only tighten policy)' -r -f -a "permissive\t'Advisory: findings are reported without failing the run' @@ -2118,6 +2168,8 @@ complete -c batten -n "__fish_batten_using_subcommand claim; and __fish_seen_sub complete -c batten -n "__fish_batten_using_subcommand claim; and __fish_seen_subcommand_from carry" -l no-input -d 'Never prompt; treat the run as unattended' complete -c batten -n "__fish_batten_using_subcommand claim; and __fish_seen_subcommand_from carry" -s y -l yes -d 'Confirm a destructive operation that would otherwise refuse' complete -c batten -n "__fish_batten_using_subcommand claim; and __fish_seen_subcommand_from carry" -s h -l help -d 'Print help (see more with \'--help\')' +complete -c batten -n "__fish_batten_using_subcommand claim; and __fish_seen_subcommand_from help" -f -a "merged" -d 'The keys merged pull request bodies close, as `\\t` rows' +complete -c batten -n "__fish_batten_using_subcommand claim; and __fish_seen_subcommand_from help" -f -a "keys" -d 'The issue keys this branch CLAIMS, as distinct from the ones it merely mentions' complete -c batten -n "__fish_batten_using_subcommand claim; and __fish_seen_subcommand_from help" -f -a "check" -d 'Refuse a pull of an issue somebody is already on, and mint the receipt when it is free' complete -c batten -n "__fish_batten_using_subcommand claim; and __fish_seen_subcommand_from help" -f -a "bot" -d 'Attest a bot branch from the lane\'s public facts, and mint the receipt when they hold' complete -c batten -n "__fish_batten_using_subcommand claim; and __fish_seen_subcommand_from help" -f -a "race" -d 'Refuse a claim a different open pull request already carries, judged by head SHA' @@ -2939,32 +2991,37 @@ complete -c batten -n "__fish_batten_using_subcommand state; and __fish_seen_sub complete -c batten -n "__fish_batten_using_subcommand state; and __fish_seen_subcommand_from help" -f -a "settle" -d 'Record what was decided about a stored finding' complete -c batten -n "__fish_batten_using_subcommand state; and __fish_seen_subcommand_from help" -f -a "list" -d 'List stored findings and the refs they were observed in' complete -c batten -n "__fish_batten_using_subcommand state; and __fish_seen_subcommand_from help" -f -a "help" -d 'Print this message or the help of the given subcommand(s)' -complete -c batten -n "__fish_batten_using_subcommand record; and not __fish_seen_subcommand_from tool forge plan closes help" -l strictness -d 'Raise how strictly gates apply (an override may only tighten policy)' -r -f -a "permissive\t'Advisory: findings are reported without failing the run' +complete -c batten -n "__fish_batten_using_subcommand record; and not __fish_seen_subcommand_from tool forge named keyed journal show fold plan closes help" -l strictness -d 'Raise how strictly gates apply (an override may only tighten policy)' -r -f -a "permissive\t'Advisory: findings are reported without failing the run' standard\t'The default: a finding is a violation' strict\t'Everything `Standard` fails on, plus anything advisory'" -complete -c batten -n "__fish_batten_using_subcommand record; and not __fish_seen_subcommand_from tool forge plan closes help" -l config-from -d 'Read the committed config from a git ref (e.g. origin/main) instead of the working tree' -r -complete -c batten -n "__fish_batten_using_subcommand record; and not __fish_seen_subcommand_from tool forge plan closes help" -l config-in -d 'Read the committed config from this directory instead of the directory being judged' -r -complete -c batten -n "__fish_batten_using_subcommand record; and not __fish_seen_subcommand_from tool forge plan closes help" -l log-level -d 'Set the verbosity rung by name' -r -f -a "silent\t'Say nothing but a verdict or a usage error' +complete -c batten -n "__fish_batten_using_subcommand record; and not __fish_seen_subcommand_from tool forge named keyed journal show fold plan closes help" -l config-from -d 'Read the committed config from a git ref (e.g. origin/main) instead of the working tree' -r +complete -c batten -n "__fish_batten_using_subcommand record; and not __fish_seen_subcommand_from tool forge named keyed journal show fold plan closes help" -l config-in -d 'Read the committed config from this directory instead of the directory being judged' -r +complete -c batten -n "__fish_batten_using_subcommand record; and not __fish_seen_subcommand_from tool forge named keyed journal show fold plan closes help" -l log-level -d 'Set the verbosity rung by name' -r -f -a "silent\t'Say nothing but a verdict or a usage error' quiet\t'Suppress ordinary progress; keep warnings' normal\t'The default' verbose\t'Explain what is being checked' debug\t'Add resolution detail' trace\t'Add everything'" -complete -c batten -n "__fish_batten_using_subcommand record; and not __fish_seen_subcommand_from tool forge plan closes help" -l fail-on-warning -d 'Promote a warn-severity finding to a violation (an override may only turn this on)' -complete -c batten -n "__fish_batten_using_subcommand record; and not __fish_seen_subcommand_from tool forge plan closes help" -l silent -d 'Say nothing but a verdict or a usage error' -complete -c batten -n "__fish_batten_using_subcommand record; and not __fish_seen_subcommand_from tool forge plan closes help" -s q -l quiet -d 'Suppress ordinary progress (repeatable: -qq is silent)' -complete -c batten -n "__fish_batten_using_subcommand record; and not __fish_seen_subcommand_from tool forge plan closes help" -s v -l verbose -d 'Explain what is being checked (repeatable: -vv is debug)' -complete -c batten -n "__fish_batten_using_subcommand record; and not __fish_seen_subcommand_from tool forge plan closes help" -l debug -d 'Add resolution detail' -complete -c batten -n "__fish_batten_using_subcommand record; and not __fish_seen_subcommand_from tool forge plan closes help" -l trace -d 'Add everything' -complete -c batten -n "__fish_batten_using_subcommand record; and not __fish_seen_subcommand_from tool forge plan closes help" -l no-color -d 'Never colour stderr, whatever it is attached to' -complete -c batten -n "__fish_batten_using_subcommand record; and not __fish_seen_subcommand_from tool forge plan closes help" -l no-input -d 'Never prompt; treat the run as unattended' -complete -c batten -n "__fish_batten_using_subcommand record; and not __fish_seen_subcommand_from tool forge plan closes help" -s y -l yes -d 'Confirm a destructive operation that would otherwise refuse' -complete -c batten -n "__fish_batten_using_subcommand record; and not __fish_seen_subcommand_from tool forge plan closes help" -s h -l help -d 'Print help (see more with \'--help\')' -complete -c batten -n "__fish_batten_using_subcommand record; and not __fish_seen_subcommand_from tool forge plan closes help" -f -a "tool" -d 'Record a declared tool row\'s verdict, read as ` ` lines on stdin' -complete -c batten -n "__fish_batten_using_subcommand record; and not __fish_seen_subcommand_from tool forge plan closes help" -f -a "forge" -d 'Record the forge\'s check verdicts for one commit, read as ` ` lines on stdin' -complete -c batten -n "__fish_batten_using_subcommand record; and not __fish_seen_subcommand_from tool forge plan closes help" -f -a "plan" -d 'Record this branch\'s plan, read as ` ` lines on stdin' -complete -c batten -n "__fish_batten_using_subcommand record; and not __fish_seen_subcommand_from tool forge plan closes help" -f -a "closes" -d 'Record which rows this branch\'s pull request body closes, read on stdin' -complete -c batten -n "__fish_batten_using_subcommand record; and not __fish_seen_subcommand_from tool forge plan closes help" -f -a "help" -d 'Print this message or the help of the given subcommand(s)' +complete -c batten -n "__fish_batten_using_subcommand record; and not __fish_seen_subcommand_from tool forge named keyed journal show fold plan closes help" -l fail-on-warning -d 'Promote a warn-severity finding to a violation (an override may only turn this on)' +complete -c batten -n "__fish_batten_using_subcommand record; and not __fish_seen_subcommand_from tool forge named keyed journal show fold plan closes help" -l silent -d 'Say nothing but a verdict or a usage error' +complete -c batten -n "__fish_batten_using_subcommand record; and not __fish_seen_subcommand_from tool forge named keyed journal show fold plan closes help" -s q -l quiet -d 'Suppress ordinary progress (repeatable: -qq is silent)' +complete -c batten -n "__fish_batten_using_subcommand record; and not __fish_seen_subcommand_from tool forge named keyed journal show fold plan closes help" -s v -l verbose -d 'Explain what is being checked (repeatable: -vv is debug)' +complete -c batten -n "__fish_batten_using_subcommand record; and not __fish_seen_subcommand_from tool forge named keyed journal show fold plan closes help" -l debug -d 'Add resolution detail' +complete -c batten -n "__fish_batten_using_subcommand record; and not __fish_seen_subcommand_from tool forge named keyed journal show fold plan closes help" -l trace -d 'Add everything' +complete -c batten -n "__fish_batten_using_subcommand record; and not __fish_seen_subcommand_from tool forge named keyed journal show fold plan closes help" -l no-color -d 'Never colour stderr, whatever it is attached to' +complete -c batten -n "__fish_batten_using_subcommand record; and not __fish_seen_subcommand_from tool forge named keyed journal show fold plan closes help" -l no-input -d 'Never prompt; treat the run as unattended' +complete -c batten -n "__fish_batten_using_subcommand record; and not __fish_seen_subcommand_from tool forge named keyed journal show fold plan closes help" -s y -l yes -d 'Confirm a destructive operation that would otherwise refuse' +complete -c batten -n "__fish_batten_using_subcommand record; and not __fish_seen_subcommand_from tool forge named keyed journal show fold plan closes help" -s h -l help -d 'Print help (see more with \'--help\')' +complete -c batten -n "__fish_batten_using_subcommand record; and not __fish_seen_subcommand_from tool forge named keyed journal show fold plan closes help" -f -a "tool" -d 'Record a declared tool row\'s verdict, read as ` ` lines on stdin' +complete -c batten -n "__fish_batten_using_subcommand record; and not __fish_seen_subcommand_from tool forge named keyed journal show fold plan closes help" -f -a "forge" -d 'Record the forge\'s check verdicts for one commit, read as ` ` lines on stdin' +complete -c batten -n "__fish_batten_using_subcommand record; and not __fish_seen_subcommand_from tool forge named keyed journal show fold plan closes help" -f -a "named" -d 'Record one named family under this branch, read from stdin' +complete -c batten -n "__fish_batten_using_subcommand record; and not __fish_seen_subcommand_from tool forge named keyed journal show fold plan closes help" -f -a "keyed" -d 'Put one value into a keyed store family, read from stdin' +complete -c batten -n "__fish_batten_using_subcommand record; and not __fish_seen_subcommand_from tool forge named keyed journal show fold plan closes help" -f -a "journal" -d 'Append one record to an append-and-fold store family, read from stdin' +complete -c batten -n "__fish_batten_using_subcommand record; and not __fish_seen_subcommand_from tool forge named keyed journal show fold plan closes help" -f -a "show" -d 'Read one keyed record back: `hit` and the value, or `miss`' +complete -c batten -n "__fish_batten_using_subcommand record; and not __fish_seen_subcommand_from tool forge named keyed journal show fold plan closes help" -f -a "fold" -d 'Fold a journal family: `nothing`, its records, or `unreadable `' +complete -c batten -n "__fish_batten_using_subcommand record; and not __fish_seen_subcommand_from tool forge named keyed journal show fold plan closes help" -f -a "plan" -d 'Record this branch\'s plan, read as ` ` lines on stdin' +complete -c batten -n "__fish_batten_using_subcommand record; and not __fish_seen_subcommand_from tool forge named keyed journal show fold plan closes help" -f -a "closes" -d 'Record which rows this branch\'s pull request body closes, read on stdin' +complete -c batten -n "__fish_batten_using_subcommand record; and not __fish_seen_subcommand_from tool forge named keyed journal show fold plan closes help" -f -a "help" -d 'Print this message or the help of the given subcommand(s)' complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from tool" -l strictness -d 'Raise how strictly gates apply (an override may only tighten policy)' -r -f -a "permissive\t'Advisory: findings are reported without failing the run' standard\t'The default: a finding is a violation' strict\t'Everything `Standard` fails on, plus anything advisory'" @@ -3007,6 +3064,111 @@ complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_su complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from forge" -l no-input -d 'Never prompt; treat the run as unattended' complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from forge" -s y -l yes -d 'Confirm a destructive operation that would otherwise refuse' complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from forge" -s h -l help -d 'Print help (see more with \'--help\')' +complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from named" -l strictness -d 'Raise how strictly gates apply (an override may only tighten policy)' -r -f -a "permissive\t'Advisory: findings are reported without failing the run' +standard\t'The default: a finding is a violation' +strict\t'Everything `Standard` fails on, plus anything advisory'" +complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from named" -l config-from -d 'Read the committed config from a git ref (e.g. origin/main) instead of the working tree' -r +complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from named" -l config-in -d 'Read the committed config from this directory instead of the directory being judged' -r +complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from named" -l log-level -d 'Set the verbosity rung by name' -r -f -a "silent\t'Say nothing but a verdict or a usage error' +quiet\t'Suppress ordinary progress; keep warnings' +normal\t'The default' +verbose\t'Explain what is being checked' +debug\t'Add resolution detail' +trace\t'Add everything'" +complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from named" -l fail-on-warning -d 'Promote a warn-severity finding to a violation (an override may only turn this on)' +complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from named" -l silent -d 'Say nothing but a verdict or a usage error' +complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from named" -s q -l quiet -d 'Suppress ordinary progress (repeatable: -qq is silent)' +complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from named" -s v -l verbose -d 'Explain what is being checked (repeatable: -vv is debug)' +complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from named" -l debug -d 'Add resolution detail' +complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from named" -l trace -d 'Add everything' +complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from named" -l no-color -d 'Never colour stderr, whatever it is attached to' +complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from named" -l no-input -d 'Never prompt; treat the run as unattended' +complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from named" -s y -l yes -d 'Confirm a destructive operation that would otherwise refuse' +complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from named" -s h -l help -d 'Print help (see more with \'--help\')' +complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from keyed" -l strictness -d 'Raise how strictly gates apply (an override may only tighten policy)' -r -f -a "permissive\t'Advisory: findings are reported without failing the run' +standard\t'The default: a finding is a violation' +strict\t'Everything `Standard` fails on, plus anything advisory'" +complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from keyed" -l config-from -d 'Read the committed config from a git ref (e.g. origin/main) instead of the working tree' -r +complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from keyed" -l config-in -d 'Read the committed config from this directory instead of the directory being judged' -r +complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from keyed" -l log-level -d 'Set the verbosity rung by name' -r -f -a "silent\t'Say nothing but a verdict or a usage error' +quiet\t'Suppress ordinary progress; keep warnings' +normal\t'The default' +verbose\t'Explain what is being checked' +debug\t'Add resolution detail' +trace\t'Add everything'" +complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from keyed" -l fail-on-warning -d 'Promote a warn-severity finding to a violation (an override may only turn this on)' +complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from keyed" -l silent -d 'Say nothing but a verdict or a usage error' +complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from keyed" -s q -l quiet -d 'Suppress ordinary progress (repeatable: -qq is silent)' +complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from keyed" -s v -l verbose -d 'Explain what is being checked (repeatable: -vv is debug)' +complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from keyed" -l debug -d 'Add resolution detail' +complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from keyed" -l trace -d 'Add everything' +complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from keyed" -l no-color -d 'Never colour stderr, whatever it is attached to' +complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from keyed" -l no-input -d 'Never prompt; treat the run as unattended' +complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from keyed" -s y -l yes -d 'Confirm a destructive operation that would otherwise refuse' +complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from keyed" -s h -l help -d 'Print help (see more with \'--help\')' +complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from journal" -l strictness -d 'Raise how strictly gates apply (an override may only tighten policy)' -r -f -a "permissive\t'Advisory: findings are reported without failing the run' +standard\t'The default: a finding is a violation' +strict\t'Everything `Standard` fails on, plus anything advisory'" +complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from journal" -l config-from -d 'Read the committed config from a git ref (e.g. origin/main) instead of the working tree' -r +complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from journal" -l config-in -d 'Read the committed config from this directory instead of the directory being judged' -r +complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from journal" -l log-level -d 'Set the verbosity rung by name' -r -f -a "silent\t'Say nothing but a verdict or a usage error' +quiet\t'Suppress ordinary progress; keep warnings' +normal\t'The default' +verbose\t'Explain what is being checked' +debug\t'Add resolution detail' +trace\t'Add everything'" +complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from journal" -l fail-on-warning -d 'Promote a warn-severity finding to a violation (an override may only turn this on)' +complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from journal" -l silent -d 'Say nothing but a verdict or a usage error' +complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from journal" -s q -l quiet -d 'Suppress ordinary progress (repeatable: -qq is silent)' +complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from journal" -s v -l verbose -d 'Explain what is being checked (repeatable: -vv is debug)' +complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from journal" -l debug -d 'Add resolution detail' +complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from journal" -l trace -d 'Add everything' +complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from journal" -l no-color -d 'Never colour stderr, whatever it is attached to' +complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from journal" -l no-input -d 'Never prompt; treat the run as unattended' +complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from journal" -s y -l yes -d 'Confirm a destructive operation that would otherwise refuse' +complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from journal" -s h -l help -d 'Print help (see more with \'--help\')' +complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from show" -l strictness -d 'Raise how strictly gates apply (an override may only tighten policy)' -r -f -a "permissive\t'Advisory: findings are reported without failing the run' +standard\t'The default: a finding is a violation' +strict\t'Everything `Standard` fails on, plus anything advisory'" +complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from show" -l config-from -d 'Read the committed config from a git ref (e.g. origin/main) instead of the working tree' -r +complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from show" -l config-in -d 'Read the committed config from this directory instead of the directory being judged' -r +complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from show" -l log-level -d 'Set the verbosity rung by name' -r -f -a "silent\t'Say nothing but a verdict or a usage error' +quiet\t'Suppress ordinary progress; keep warnings' +normal\t'The default' +verbose\t'Explain what is being checked' +debug\t'Add resolution detail' +trace\t'Add everything'" +complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from show" -l fail-on-warning -d 'Promote a warn-severity finding to a violation (an override may only turn this on)' +complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from show" -l silent -d 'Say nothing but a verdict or a usage error' +complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from show" -s q -l quiet -d 'Suppress ordinary progress (repeatable: -qq is silent)' +complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from show" -s v -l verbose -d 'Explain what is being checked (repeatable: -vv is debug)' +complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from show" -l debug -d 'Add resolution detail' +complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from show" -l trace -d 'Add everything' +complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from show" -l no-color -d 'Never colour stderr, whatever it is attached to' +complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from show" -l no-input -d 'Never prompt; treat the run as unattended' +complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from show" -s y -l yes -d 'Confirm a destructive operation that would otherwise refuse' +complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from show" -s h -l help -d 'Print help (see more with \'--help\')' +complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from fold" -l strictness -d 'Raise how strictly gates apply (an override may only tighten policy)' -r -f -a "permissive\t'Advisory: findings are reported without failing the run' +standard\t'The default: a finding is a violation' +strict\t'Everything `Standard` fails on, plus anything advisory'" +complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from fold" -l config-from -d 'Read the committed config from a git ref (e.g. origin/main) instead of the working tree' -r +complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from fold" -l config-in -d 'Read the committed config from this directory instead of the directory being judged' -r +complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from fold" -l log-level -d 'Set the verbosity rung by name' -r -f -a "silent\t'Say nothing but a verdict or a usage error' +quiet\t'Suppress ordinary progress; keep warnings' +normal\t'The default' +verbose\t'Explain what is being checked' +debug\t'Add resolution detail' +trace\t'Add everything'" +complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from fold" -l fail-on-warning -d 'Promote a warn-severity finding to a violation (an override may only turn this on)' +complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from fold" -l silent -d 'Say nothing but a verdict or a usage error' +complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from fold" -s q -l quiet -d 'Suppress ordinary progress (repeatable: -qq is silent)' +complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from fold" -s v -l verbose -d 'Explain what is being checked (repeatable: -vv is debug)' +complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from fold" -l debug -d 'Add resolution detail' +complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from fold" -l trace -d 'Add everything' +complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from fold" -l no-color -d 'Never colour stderr, whatever it is attached to' +complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from fold" -l no-input -d 'Never prompt; treat the run as unattended' +complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from fold" -s y -l yes -d 'Confirm a destructive operation that would otherwise refuse' +complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from fold" -s h -l help -d 'Print help (see more with \'--help\')' complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from plan" -l strictness -d 'Raise how strictly gates apply (an override may only tighten policy)' -r -f -a "permissive\t'Advisory: findings are reported without failing the run' standard\t'The default: a finding is a violation' strict\t'Everything `Standard` fails on, plus anything advisory'" @@ -3051,6 +3213,11 @@ complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_su complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from closes" -s h -l help -d 'Print help (see more with \'--help\')' complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from help" -f -a "tool" -d 'Record a declared tool row\'s verdict, read as ` ` lines on stdin' complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from help" -f -a "forge" -d 'Record the forge\'s check verdicts for one commit, read as ` ` lines on stdin' +complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from help" -f -a "named" -d 'Record one named family under this branch, read from stdin' +complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from help" -f -a "keyed" -d 'Put one value into a keyed store family, read from stdin' +complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from help" -f -a "journal" -d 'Append one record to an append-and-fold store family, read from stdin' +complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from help" -f -a "show" -d 'Read one keyed record back: `hit` and the value, or `miss`' +complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from help" -f -a "fold" -d 'Fold a journal family: `nothing`, its records, or `unreadable `' complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from help" -f -a "plan" -d 'Record this branch\'s plan, read as ` ` lines on stdin' complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from help" -f -a "closes" -d 'Record which rows this branch\'s pull request body closes, read on stdin' complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from help" -f -a "help" -d 'Print this message or the help of the given subcommand(s)' @@ -3709,6 +3876,8 @@ complete -c batten -n "__fish_batten_using_subcommand help; and __fish_seen_subc complete -c batten -n "__fish_batten_using_subcommand help; and __fish_seen_subcommand_from task" -f -a "alive" -d 'What tasks are running right now and what phase each is in — one call, no log reading' complete -c batten -n "__fish_batten_using_subcommand help; and __fish_seen_subcommand_from singleton" -f -a "acquire" -d 'Take a task\'s lock for a pid, or refuse naming the process that holds it' complete -c batten -n "__fish_batten_using_subcommand help; and __fish_seen_subcommand_from singleton" -f -a "release" -d 'Drop a task\'s lock, which its exit trap does and a kill cannot' +complete -c batten -n "__fish_batten_using_subcommand help; and __fish_seen_subcommand_from claim" -f -a "merged" -d 'The keys merged pull request bodies close, as `\\t` rows' +complete -c batten -n "__fish_batten_using_subcommand help; and __fish_seen_subcommand_from claim" -f -a "keys" -d 'The issue keys this branch CLAIMS, as distinct from the ones it merely mentions' complete -c batten -n "__fish_batten_using_subcommand help; and __fish_seen_subcommand_from claim" -f -a "check" -d 'Refuse a pull of an issue somebody is already on, and mint the receipt when it is free' complete -c batten -n "__fish_batten_using_subcommand help; and __fish_seen_subcommand_from claim" -f -a "bot" -d 'Attest a bot branch from the lane\'s public facts, and mint the receipt when they hold' complete -c batten -n "__fish_batten_using_subcommand help; and __fish_seen_subcommand_from claim" -f -a "race" -d 'Refuse a claim a different open pull request already carries, judged by head SHA' @@ -3736,6 +3905,11 @@ complete -c batten -n "__fish_batten_using_subcommand help; and __fish_seen_subc complete -c batten -n "__fish_batten_using_subcommand help; and __fish_seen_subcommand_from state" -f -a "list" -d 'List stored findings and the refs they were observed in' complete -c batten -n "__fish_batten_using_subcommand help; and __fish_seen_subcommand_from record" -f -a "tool" -d 'Record a declared tool row\'s verdict, read as ` ` lines on stdin' complete -c batten -n "__fish_batten_using_subcommand help; and __fish_seen_subcommand_from record" -f -a "forge" -d 'Record the forge\'s check verdicts for one commit, read as ` ` lines on stdin' +complete -c batten -n "__fish_batten_using_subcommand help; and __fish_seen_subcommand_from record" -f -a "named" -d 'Record one named family under this branch, read from stdin' +complete -c batten -n "__fish_batten_using_subcommand help; and __fish_seen_subcommand_from record" -f -a "keyed" -d 'Put one value into a keyed store family, read from stdin' +complete -c batten -n "__fish_batten_using_subcommand help; and __fish_seen_subcommand_from record" -f -a "journal" -d 'Append one record to an append-and-fold store family, read from stdin' +complete -c batten -n "__fish_batten_using_subcommand help; and __fish_seen_subcommand_from record" -f -a "show" -d 'Read one keyed record back: `hit` and the value, or `miss`' +complete -c batten -n "__fish_batten_using_subcommand help; and __fish_seen_subcommand_from record" -f -a "fold" -d 'Fold a journal family: `nothing`, its records, or `unreadable `' complete -c batten -n "__fish_batten_using_subcommand help; and __fish_seen_subcommand_from record" -f -a "plan" -d 'Record this branch\'s plan, read as ` ` lines on stdin' complete -c batten -n "__fish_batten_using_subcommand help; and __fish_seen_subcommand_from record" -f -a "closes" -d 'Record which rows this branch\'s pull request body closes, read on stdin' complete -c batten -n "__fish_batten_using_subcommand help; and __fish_seen_subcommand_from show" -f -a "agent" -d 'What an agent may do in this repository: the read-only verbs, the exit contract, and the declared gates' diff --git a/completions/batten.zsh b/completions/batten.zsh index 8eb3c6bfb..7c0165cdb 100644 --- a/completions/batten.zsh +++ b/completions/batten.zsh @@ -3340,7 +3340,71 @@ trace\:"Add everything"))' \ (( CURRENT += 1 )) curcontext="${curcontext%:*:*}:batten-claim-command-$line[1]:" case $line[1] in - (check) + (merged) +_arguments "${_arguments_options[@]}" : \ +'--limit=[The most pull requests to read before the answer is truncated (default 5000)]: :_default' \ +'--strictness=[Raise how strictly gates apply (an override may only tighten policy)]: :((permissive\:"Advisory\: findings are reported without failing the run" +standard\:"The default\: a finding is a violation" +strict\:"Everything \`Standard\` fails on, plus anything advisory"))' \ +'--config-from=[Read the committed config from a git ref (e.g. origin/main) instead of the working tree]: :_default' \ +'--config-in=[Read the committed config from this directory instead of the directory being judged]: :_default' \ +'--log-level=[Set the verbosity rung by name]: :((silent\:"Say nothing but a verdict or a usage error" +quiet\:"Suppress ordinary progress; keep warnings" +normal\:"The default" +verbose\:"Explain what is being checked" +debug\:"Add resolution detail" +trace\:"Add everything"))' \ +'--fail-on-warning[Promote a warn-severity finding to a violation (an override may only turn this on)]' \ +'*--silent[Say nothing but a verdict or a usage error]' \ +'*-q[Suppress ordinary progress (repeatable\: -qq is silent)]' \ +'*--quiet[Suppress ordinary progress (repeatable\: -qq is silent)]' \ +'*-v[Explain what is being checked (repeatable\: -vv is debug)]' \ +'*--verbose[Explain what is being checked (repeatable\: -vv is debug)]' \ +'*--debug[Add resolution detail]' \ +'*--trace[Add everything]' \ +'--no-color[Never colour stderr, whatever it is attached to]' \ +'--no-input[Never prompt; treat the run as unattended]' \ +'-y[Confirm a destructive operation that would otherwise refuse]' \ +'--yes[Confirm a destructive operation that would otherwise refuse]' \ +'-h[Print help (see more with '\''--help'\'')]' \ +'--help[Print help (see more with '\''--help'\'')]' \ +&& ret=0 +;; +(keys) +_arguments "${_arguments_options[@]}" : \ +'--branch=[The head branch, standing in for source 2]: :_default' \ +'--title=[The pull request title, also source 2 — a body is not, because a body cites evidence]: :_default' \ +'--log=[Commit messages, standing in for sources 1 and 3]: :_default' \ +'--strictness=[Raise how strictly gates apply (an override may only tighten policy)]: :((permissive\:"Advisory\: findings are reported without failing the run" +standard\:"The default\: a finding is a violation" +strict\:"Everything \`Standard\` fails on, plus anything advisory"))' \ +'--config-from=[Read the committed config from a git ref (e.g. origin/main) instead of the working tree]: :_default' \ +'--config-in=[Read the committed config from this directory instead of the directory being judged]: :_default' \ +'--log-level=[Set the verbosity rung by name]: :((silent\:"Say nothing but a verdict or a usage error" +quiet\:"Suppress ordinary progress; keep warnings" +normal\:"The default" +verbose\:"Explain what is being checked" +debug\:"Add resolution detail" +trace\:"Add everything"))' \ +'--closing-only[Answer from a closing keyword alone, never falling through to the branch or a trailer]' \ +'--refs-first-only[Answer from the first key of each \`Refs\:\` trailer alone, never sources 1 or 2]' \ +'--fail-on-warning[Promote a warn-severity finding to a violation (an override may only turn this on)]' \ +'*--silent[Say nothing but a verdict or a usage error]' \ +'*-q[Suppress ordinary progress (repeatable\: -qq is silent)]' \ +'*--quiet[Suppress ordinary progress (repeatable\: -qq is silent)]' \ +'*-v[Explain what is being checked (repeatable\: -vv is debug)]' \ +'*--verbose[Explain what is being checked (repeatable\: -vv is debug)]' \ +'*--debug[Add resolution detail]' \ +'*--trace[Add everything]' \ +'--no-color[Never colour stderr, whatever it is attached to]' \ +'--no-input[Never prompt; treat the run as unattended]' \ +'-y[Confirm a destructive operation that would otherwise refuse]' \ +'--yes[Confirm a destructive operation that would otherwise refuse]' \ +'-h[Print help (see more with '\''--help'\'')]' \ +'--help[Print help (see more with '\''--help'\'')]' \ +&& ret=0 +;; +(check) _arguments "${_arguments_options[@]}" : \ '--adopt-from=[The branch name the receipt being adopted was minted under]: :_default' \ '--issue=[Read the issue payload from the capture store by key, where \`mcp call ... get_issue\` put it]: :_default' \ @@ -3477,7 +3541,15 @@ _arguments "${_arguments_options[@]}" : \ (( CURRENT += 1 )) curcontext="${curcontext%:*:*}:batten-claim-help-command-$line[1]:" case $line[1] in - (check) + (merged) +_arguments "${_arguments_options[@]}" : \ +&& ret=0 +;; +(keys) +_arguments "${_arguments_options[@]}" : \ +&& ret=0 +;; +(check) _arguments "${_arguments_options[@]}" : \ && ret=0 ;; @@ -5042,6 +5114,158 @@ trace\:"Add everything"))' \ ':ref -- The ref or sha the verdict was taken against:_default' \ && ret=0 ;; +(named) +_arguments "${_arguments_options[@]}" : \ +'--strictness=[Raise how strictly gates apply (an override may only tighten policy)]: :((permissive\:"Advisory\: findings are reported without failing the run" +standard\:"The default\: a finding is a violation" +strict\:"Everything \`Standard\` fails on, plus anything advisory"))' \ +'--config-from=[Read the committed config from a git ref (e.g. origin/main) instead of the working tree]: :_default' \ +'--config-in=[Read the committed config from this directory instead of the directory being judged]: :_default' \ +'--log-level=[Set the verbosity rung by name]: :((silent\:"Say nothing but a verdict or a usage error" +quiet\:"Suppress ordinary progress; keep warnings" +normal\:"The default" +verbose\:"Explain what is being checked" +debug\:"Add resolution detail" +trace\:"Add everything"))' \ +'--fail-on-warning[Promote a warn-severity finding to a violation (an override may only turn this on)]' \ +'*--silent[Say nothing but a verdict or a usage error]' \ +'*-q[Suppress ordinary progress (repeatable\: -qq is silent)]' \ +'*--quiet[Suppress ordinary progress (repeatable\: -qq is silent)]' \ +'*-v[Explain what is being checked (repeatable\: -vv is debug)]' \ +'*--verbose[Explain what is being checked (repeatable\: -vv is debug)]' \ +'*--debug[Add resolution detail]' \ +'*--trace[Add everything]' \ +'--no-color[Never colour stderr, whatever it is attached to]' \ +'--no-input[Never prompt; treat the run as unattended]' \ +'-y[Confirm a destructive operation that would otherwise refuse]' \ +'--yes[Confirm a destructive operation that would otherwise refuse]' \ +'-h[Print help (see more with '\''--help'\'')]' \ +'--help[Print help (see more with '\''--help'\'')]' \ +':family -- The record family, which is the key a module reads it under:_default' \ +&& ret=0 +;; +(keyed) +_arguments "${_arguments_options[@]}" : \ +'--strictness=[Raise how strictly gates apply (an override may only tighten policy)]: :((permissive\:"Advisory\: findings are reported without failing the run" +standard\:"The default\: a finding is a violation" +strict\:"Everything \`Standard\` fails on, plus anything advisory"))' \ +'--config-from=[Read the committed config from a git ref (e.g. origin/main) instead of the working tree]: :_default' \ +'--config-in=[Read the committed config from this directory instead of the directory being judged]: :_default' \ +'--log-level=[Set the verbosity rung by name]: :((silent\:"Say nothing but a verdict or a usage error" +quiet\:"Suppress ordinary progress; keep warnings" +normal\:"The default" +verbose\:"Explain what is being checked" +debug\:"Add resolution detail" +trace\:"Add everything"))' \ +'--fail-on-warning[Promote a warn-severity finding to a violation (an override may only turn this on)]' \ +'*--silent[Say nothing but a verdict or a usage error]' \ +'*-q[Suppress ordinary progress (repeatable\: -qq is silent)]' \ +'*--quiet[Suppress ordinary progress (repeatable\: -qq is silent)]' \ +'*-v[Explain what is being checked (repeatable\: -vv is debug)]' \ +'*--verbose[Explain what is being checked (repeatable\: -vv is debug)]' \ +'*--debug[Add resolution detail]' \ +'*--trace[Add everything]' \ +'--no-color[Never colour stderr, whatever it is attached to]' \ +'--no-input[Never prompt; treat the run as unattended]' \ +'-y[Confirm a destructive operation that would otherwise refuse]' \ +'--yes[Confirm a destructive operation that would otherwise refuse]' \ +'-h[Print help (see more with '\''--help'\'')]' \ +'--help[Print help (see more with '\''--help'\'')]' \ +':family -- The store family the record belongs to:_default' \ +':key -- The key the record is filed under:_default' \ +&& ret=0 +;; +(journal) +_arguments "${_arguments_options[@]}" : \ +'--strictness=[Raise how strictly gates apply (an override may only tighten policy)]: :((permissive\:"Advisory\: findings are reported without failing the run" +standard\:"The default\: a finding is a violation" +strict\:"Everything \`Standard\` fails on, plus anything advisory"))' \ +'--config-from=[Read the committed config from a git ref (e.g. origin/main) instead of the working tree]: :_default' \ +'--config-in=[Read the committed config from this directory instead of the directory being judged]: :_default' \ +'--log-level=[Set the verbosity rung by name]: :((silent\:"Say nothing but a verdict or a usage error" +quiet\:"Suppress ordinary progress; keep warnings" +normal\:"The default" +verbose\:"Explain what is being checked" +debug\:"Add resolution detail" +trace\:"Add everything"))' \ +'--fail-on-warning[Promote a warn-severity finding to a violation (an override may only turn this on)]' \ +'*--silent[Say nothing but a verdict or a usage error]' \ +'*-q[Suppress ordinary progress (repeatable\: -qq is silent)]' \ +'*--quiet[Suppress ordinary progress (repeatable\: -qq is silent)]' \ +'*-v[Explain what is being checked (repeatable\: -vv is debug)]' \ +'*--verbose[Explain what is being checked (repeatable\: -vv is debug)]' \ +'*--debug[Add resolution detail]' \ +'*--trace[Add everything]' \ +'--no-color[Never colour stderr, whatever it is attached to]' \ +'--no-input[Never prompt; treat the run as unattended]' \ +'-y[Confirm a destructive operation that would otherwise refuse]' \ +'--yes[Confirm a destructive operation that would otherwise refuse]' \ +'-h[Print help (see more with '\''--help'\'')]' \ +'--help[Print help (see more with '\''--help'\'')]' \ +':family -- The store family the record belongs to:_default' \ +&& ret=0 +;; +(show) +_arguments "${_arguments_options[@]}" : \ +'--strictness=[Raise how strictly gates apply (an override may only tighten policy)]: :((permissive\:"Advisory\: findings are reported without failing the run" +standard\:"The default\: a finding is a violation" +strict\:"Everything \`Standard\` fails on, plus anything advisory"))' \ +'--config-from=[Read the committed config from a git ref (e.g. origin/main) instead of the working tree]: :_default' \ +'--config-in=[Read the committed config from this directory instead of the directory being judged]: :_default' \ +'--log-level=[Set the verbosity rung by name]: :((silent\:"Say nothing but a verdict or a usage error" +quiet\:"Suppress ordinary progress; keep warnings" +normal\:"The default" +verbose\:"Explain what is being checked" +debug\:"Add resolution detail" +trace\:"Add everything"))' \ +'--fail-on-warning[Promote a warn-severity finding to a violation (an override may only turn this on)]' \ +'*--silent[Say nothing but a verdict or a usage error]' \ +'*-q[Suppress ordinary progress (repeatable\: -qq is silent)]' \ +'*--quiet[Suppress ordinary progress (repeatable\: -qq is silent)]' \ +'*-v[Explain what is being checked (repeatable\: -vv is debug)]' \ +'*--verbose[Explain what is being checked (repeatable\: -vv is debug)]' \ +'*--debug[Add resolution detail]' \ +'*--trace[Add everything]' \ +'--no-color[Never colour stderr, whatever it is attached to]' \ +'--no-input[Never prompt; treat the run as unattended]' \ +'-y[Confirm a destructive operation that would otherwise refuse]' \ +'--yes[Confirm a destructive operation that would otherwise refuse]' \ +'-h[Print help (see more with '\''--help'\'')]' \ +'--help[Print help (see more with '\''--help'\'')]' \ +':family -- The store family to read:_default' \ +':key -- The key to look under:_default' \ +&& ret=0 +;; +(fold) +_arguments "${_arguments_options[@]}" : \ +'--strictness=[Raise how strictly gates apply (an override may only tighten policy)]: :((permissive\:"Advisory\: findings are reported without failing the run" +standard\:"The default\: a finding is a violation" +strict\:"Everything \`Standard\` fails on, plus anything advisory"))' \ +'--config-from=[Read the committed config from a git ref (e.g. origin/main) instead of the working tree]: :_default' \ +'--config-in=[Read the committed config from this directory instead of the directory being judged]: :_default' \ +'--log-level=[Set the verbosity rung by name]: :((silent\:"Say nothing but a verdict or a usage error" +quiet\:"Suppress ordinary progress; keep warnings" +normal\:"The default" +verbose\:"Explain what is being checked" +debug\:"Add resolution detail" +trace\:"Add everything"))' \ +'--fail-on-warning[Promote a warn-severity finding to a violation (an override may only turn this on)]' \ +'*--silent[Say nothing but a verdict or a usage error]' \ +'*-q[Suppress ordinary progress (repeatable\: -qq is silent)]' \ +'*--quiet[Suppress ordinary progress (repeatable\: -qq is silent)]' \ +'*-v[Explain what is being checked (repeatable\: -vv is debug)]' \ +'*--verbose[Explain what is being checked (repeatable\: -vv is debug)]' \ +'*--debug[Add resolution detail]' \ +'*--trace[Add everything]' \ +'--no-color[Never colour stderr, whatever it is attached to]' \ +'--no-input[Never prompt; treat the run as unattended]' \ +'-y[Confirm a destructive operation that would otherwise refuse]' \ +'--yes[Confirm a destructive operation that would otherwise refuse]' \ +'-h[Print help (see more with '\''--help'\'')]' \ +'--help[Print help (see more with '\''--help'\'')]' \ +':family -- The store family to fold:_default' \ +&& ret=0 +;; (plan) _arguments "${_arguments_options[@]}" : \ '--strictness=[Raise how strictly gates apply (an override may only tighten policy)]: :((permissive\:"Advisory\: findings are reported without failing the run" @@ -5120,6 +5344,26 @@ _arguments "${_arguments_options[@]}" : \ _arguments "${_arguments_options[@]}" : \ && ret=0 ;; +(named) +_arguments "${_arguments_options[@]}" : \ +&& ret=0 +;; +(keyed) +_arguments "${_arguments_options[@]}" : \ +&& ret=0 +;; +(journal) +_arguments "${_arguments_options[@]}" : \ +&& ret=0 +;; +(show) +_arguments "${_arguments_options[@]}" : \ +&& ret=0 +;; +(fold) +_arguments "${_arguments_options[@]}" : \ +&& ret=0 +;; (plan) _arguments "${_arguments_options[@]}" : \ && ret=0 @@ -6619,7 +6863,15 @@ _arguments "${_arguments_options[@]}" : \ (( CURRENT += 1 )) curcontext="${curcontext%:*:*}:batten-help-claim-command-$line[1]:" case $line[1] in - (check) + (merged) +_arguments "${_arguments_options[@]}" : \ +&& ret=0 +;; +(keys) +_arguments "${_arguments_options[@]}" : \ +&& ret=0 +;; +(check) _arguments "${_arguments_options[@]}" : \ && ret=0 ;; @@ -6911,6 +7163,26 @@ _arguments "${_arguments_options[@]}" : \ _arguments "${_arguments_options[@]}" : \ && ret=0 ;; +(named) +_arguments "${_arguments_options[@]}" : \ +&& ret=0 +;; +(keyed) +_arguments "${_arguments_options[@]}" : \ +&& ret=0 +;; +(journal) +_arguments "${_arguments_options[@]}" : \ +&& ret=0 +;; +(show) +_arguments "${_arguments_options[@]}" : \ +&& ret=0 +;; +(fold) +_arguments "${_arguments_options[@]}" : \ +&& ret=0 +;; (plan) _arguments "${_arguments_options[@]}" : \ && ret=0 @@ -7300,6 +7572,8 @@ _batten__subcmd__checks__subcmd__help__subcmd__help_commands() { (( $+functions[_batten__subcmd__claim_commands] )) || _batten__subcmd__claim_commands() { local commands; commands=( +'merged:The keys merged pull request bodies close, as \`\\t\` rows' \ +'keys:The issue keys this branch CLAIMS, as distinct from the ones it merely mentions' \ 'check:Refuse a pull of an issue somebody is already on, and mint the receipt when it is free' \ 'bot:Attest a bot branch from the lane'\''s public facts, and mint the receipt when they hold' \ 'race:Refuse a claim a different open pull request already carries, judged by head SHA' \ @@ -7326,6 +7600,8 @@ _batten__subcmd__claim__subcmd__check_commands() { (( $+functions[_batten__subcmd__claim__subcmd__help_commands] )) || _batten__subcmd__claim__subcmd__help_commands() { local commands; commands=( +'merged:The keys merged pull request bodies close, as \`\\t\` rows' \ +'keys:The issue keys this branch CLAIMS, as distinct from the ones it merely mentions' \ 'check:Refuse a pull of an issue somebody is already on, and mint the receipt when it is free' \ 'bot:Attest a bot branch from the lane'\''s public facts, and mint the receipt when they hold' \ 'race:Refuse a claim a different open pull request already carries, judged by head SHA' \ @@ -7354,11 +7630,31 @@ _batten__subcmd__claim__subcmd__help__subcmd__help_commands() { local commands; commands=() _describe -t commands 'batten claim help help commands' commands "$@" } +(( $+functions[_batten__subcmd__claim__subcmd__help__subcmd__keys_commands] )) || +_batten__subcmd__claim__subcmd__help__subcmd__keys_commands() { + local commands; commands=() + _describe -t commands 'batten claim help keys commands' commands "$@" +} +(( $+functions[_batten__subcmd__claim__subcmd__help__subcmd__merged_commands] )) || +_batten__subcmd__claim__subcmd__help__subcmd__merged_commands() { + local commands; commands=() + _describe -t commands 'batten claim help merged commands' commands "$@" +} (( $+functions[_batten__subcmd__claim__subcmd__help__subcmd__race_commands] )) || _batten__subcmd__claim__subcmd__help__subcmd__race_commands() { local commands; commands=() _describe -t commands 'batten claim help race commands' commands "$@" } +(( $+functions[_batten__subcmd__claim__subcmd__keys_commands] )) || +_batten__subcmd__claim__subcmd__keys_commands() { + local commands; commands=() + _describe -t commands 'batten claim keys commands' commands "$@" +} +(( $+functions[_batten__subcmd__claim__subcmd__merged_commands] )) || +_batten__subcmd__claim__subcmd__merged_commands() { + local commands; commands=() + _describe -t commands 'batten claim merged commands' commands "$@" +} (( $+functions[_batten__subcmd__claim__subcmd__race_commands] )) || _batten__subcmd__claim__subcmd__race_commands() { local commands; commands=() @@ -7837,6 +8133,8 @@ _batten__subcmd__help__subcmd__checks__subcmd__green_commands() { (( $+functions[_batten__subcmd__help__subcmd__claim_commands] )) || _batten__subcmd__help__subcmd__claim_commands() { local commands; commands=( +'merged:The keys merged pull request bodies close, as \`\\t\` rows' \ +'keys:The issue keys this branch CLAIMS, as distinct from the ones it merely mentions' \ 'check:Refuse a pull of an issue somebody is already on, and mint the receipt when it is free' \ 'bot:Attest a bot branch from the lane'\''s public facts, and mint the receipt when they hold' \ 'race:Refuse a claim a different open pull request already carries, judged by head SHA' \ @@ -7859,6 +8157,16 @@ _batten__subcmd__help__subcmd__claim__subcmd__check_commands() { local commands; commands=() _describe -t commands 'batten help claim check commands' commands "$@" } +(( $+functions[_batten__subcmd__help__subcmd__claim__subcmd__keys_commands] )) || +_batten__subcmd__help__subcmd__claim__subcmd__keys_commands() { + local commands; commands=() + _describe -t commands 'batten help claim keys commands' commands "$@" +} +(( $+functions[_batten__subcmd__help__subcmd__claim__subcmd__merged_commands] )) || +_batten__subcmd__help__subcmd__claim__subcmd__merged_commands() { + local commands; commands=() + _describe -t commands 'batten help claim merged commands' commands "$@" +} (( $+functions[_batten__subcmd__help__subcmd__claim__subcmd__race_commands] )) || _batten__subcmd__help__subcmd__claim__subcmd__race_commands() { local commands; commands=() @@ -8429,6 +8737,11 @@ _batten__subcmd__help__subcmd__record_commands() { local commands; commands=( 'tool:Record a declared tool row'\''s verdict, read as \` \` lines on stdin' \ 'forge:Record the forge'\''s check verdicts for one commit, read as \` \` lines on stdin' \ +'named:Record one named family under this branch, read from stdin' \ +'keyed:Put one value into a keyed store family, read from stdin' \ +'journal:Append one record to an append-and-fold store family, read from stdin' \ +'show:Read one keyed record back\: \`hit\` and the value, or \`miss\`' \ +'fold:Fold a journal family\: \`nothing\`, its records, or \`unreadable \`' \ 'plan:Record this branch'\''s plan, read as \` \` lines on stdin' \ 'closes:Record which rows this branch'\''s pull request body closes, read on stdin' \ ) @@ -8439,16 +8752,41 @@ _batten__subcmd__help__subcmd__record__subcmd__closes_commands() { local commands; commands=() _describe -t commands 'batten help record closes commands' commands "$@" } +(( $+functions[_batten__subcmd__help__subcmd__record__subcmd__fold_commands] )) || +_batten__subcmd__help__subcmd__record__subcmd__fold_commands() { + local commands; commands=() + _describe -t commands 'batten help record fold commands' commands "$@" +} (( $+functions[_batten__subcmd__help__subcmd__record__subcmd__forge_commands] )) || _batten__subcmd__help__subcmd__record__subcmd__forge_commands() { local commands; commands=() _describe -t commands 'batten help record forge commands' commands "$@" } +(( $+functions[_batten__subcmd__help__subcmd__record__subcmd__journal_commands] )) || +_batten__subcmd__help__subcmd__record__subcmd__journal_commands() { + local commands; commands=() + _describe -t commands 'batten help record journal commands' commands "$@" +} +(( $+functions[_batten__subcmd__help__subcmd__record__subcmd__keyed_commands] )) || +_batten__subcmd__help__subcmd__record__subcmd__keyed_commands() { + local commands; commands=() + _describe -t commands 'batten help record keyed commands' commands "$@" +} +(( $+functions[_batten__subcmd__help__subcmd__record__subcmd__named_commands] )) || +_batten__subcmd__help__subcmd__record__subcmd__named_commands() { + local commands; commands=() + _describe -t commands 'batten help record named commands' commands "$@" +} (( $+functions[_batten__subcmd__help__subcmd__record__subcmd__plan_commands] )) || _batten__subcmd__help__subcmd__record__subcmd__plan_commands() { local commands; commands=() _describe -t commands 'batten help record plan commands' commands "$@" } +(( $+functions[_batten__subcmd__help__subcmd__record__subcmd__show_commands] )) || +_batten__subcmd__help__subcmd__record__subcmd__show_commands() { + local commands; commands=() + _describe -t commands 'batten help record show commands' commands "$@" +} (( $+functions[_batten__subcmd__help__subcmd__record__subcmd__tool_commands] )) || _batten__subcmd__help__subcmd__record__subcmd__tool_commands() { local commands; commands=() @@ -9533,6 +9871,11 @@ _batten__subcmd__record_commands() { local commands; commands=( 'tool:Record a declared tool row'\''s verdict, read as \` \` lines on stdin' \ 'forge:Record the forge'\''s check verdicts for one commit, read as \` \` lines on stdin' \ +'named:Record one named family under this branch, read from stdin' \ +'keyed:Put one value into a keyed store family, read from stdin' \ +'journal:Append one record to an append-and-fold store family, read from stdin' \ +'show:Read one keyed record back\: \`hit\` and the value, or \`miss\`' \ +'fold:Fold a journal family\: \`nothing\`, its records, or \`unreadable \`' \ 'plan:Record this branch'\''s plan, read as \` \` lines on stdin' \ 'closes:Record which rows this branch'\''s pull request body closes, read on stdin' \ 'help:Print this message or the help of the given subcommand(s)' \ @@ -9544,6 +9887,11 @@ _batten__subcmd__record__subcmd__closes_commands() { local commands; commands=() _describe -t commands 'batten record closes commands' commands "$@" } +(( $+functions[_batten__subcmd__record__subcmd__fold_commands] )) || +_batten__subcmd__record__subcmd__fold_commands() { + local commands; commands=() + _describe -t commands 'batten record fold commands' commands "$@" +} (( $+functions[_batten__subcmd__record__subcmd__forge_commands] )) || _batten__subcmd__record__subcmd__forge_commands() { local commands; commands=() @@ -9554,6 +9902,11 @@ _batten__subcmd__record__subcmd__help_commands() { local commands; commands=( 'tool:Record a declared tool row'\''s verdict, read as \` \` lines on stdin' \ 'forge:Record the forge'\''s check verdicts for one commit, read as \` \` lines on stdin' \ +'named:Record one named family under this branch, read from stdin' \ +'keyed:Put one value into a keyed store family, read from stdin' \ +'journal:Append one record to an append-and-fold store family, read from stdin' \ +'show:Read one keyed record back\: \`hit\` and the value, or \`miss\`' \ +'fold:Fold a journal family\: \`nothing\`, its records, or \`unreadable \`' \ 'plan:Record this branch'\''s plan, read as \` \` lines on stdin' \ 'closes:Record which rows this branch'\''s pull request body closes, read on stdin' \ 'help:Print this message or the help of the given subcommand(s)' \ @@ -9565,6 +9918,11 @@ _batten__subcmd__record__subcmd__help__subcmd__closes_commands() { local commands; commands=() _describe -t commands 'batten record help closes commands' commands "$@" } +(( $+functions[_batten__subcmd__record__subcmd__help__subcmd__fold_commands] )) || +_batten__subcmd__record__subcmd__help__subcmd__fold_commands() { + local commands; commands=() + _describe -t commands 'batten record help fold commands' commands "$@" +} (( $+functions[_batten__subcmd__record__subcmd__help__subcmd__forge_commands] )) || _batten__subcmd__record__subcmd__help__subcmd__forge_commands() { local commands; commands=() @@ -9575,21 +9933,61 @@ _batten__subcmd__record__subcmd__help__subcmd__help_commands() { local commands; commands=() _describe -t commands 'batten record help help commands' commands "$@" } +(( $+functions[_batten__subcmd__record__subcmd__help__subcmd__journal_commands] )) || +_batten__subcmd__record__subcmd__help__subcmd__journal_commands() { + local commands; commands=() + _describe -t commands 'batten record help journal commands' commands "$@" +} +(( $+functions[_batten__subcmd__record__subcmd__help__subcmd__keyed_commands] )) || +_batten__subcmd__record__subcmd__help__subcmd__keyed_commands() { + local commands; commands=() + _describe -t commands 'batten record help keyed commands' commands "$@" +} +(( $+functions[_batten__subcmd__record__subcmd__help__subcmd__named_commands] )) || +_batten__subcmd__record__subcmd__help__subcmd__named_commands() { + local commands; commands=() + _describe -t commands 'batten record help named commands' commands "$@" +} (( $+functions[_batten__subcmd__record__subcmd__help__subcmd__plan_commands] )) || _batten__subcmd__record__subcmd__help__subcmd__plan_commands() { local commands; commands=() _describe -t commands 'batten record help plan commands' commands "$@" } +(( $+functions[_batten__subcmd__record__subcmd__help__subcmd__show_commands] )) || +_batten__subcmd__record__subcmd__help__subcmd__show_commands() { + local commands; commands=() + _describe -t commands 'batten record help show commands' commands "$@" +} (( $+functions[_batten__subcmd__record__subcmd__help__subcmd__tool_commands] )) || _batten__subcmd__record__subcmd__help__subcmd__tool_commands() { local commands; commands=() _describe -t commands 'batten record help tool commands' commands "$@" } +(( $+functions[_batten__subcmd__record__subcmd__journal_commands] )) || +_batten__subcmd__record__subcmd__journal_commands() { + local commands; commands=() + _describe -t commands 'batten record journal commands' commands "$@" +} +(( $+functions[_batten__subcmd__record__subcmd__keyed_commands] )) || +_batten__subcmd__record__subcmd__keyed_commands() { + local commands; commands=() + _describe -t commands 'batten record keyed commands' commands "$@" +} +(( $+functions[_batten__subcmd__record__subcmd__named_commands] )) || +_batten__subcmd__record__subcmd__named_commands() { + local commands; commands=() + _describe -t commands 'batten record named commands' commands "$@" +} (( $+functions[_batten__subcmd__record__subcmd__plan_commands] )) || _batten__subcmd__record__subcmd__plan_commands() { local commands; commands=() _describe -t commands 'batten record plan commands' commands "$@" } +(( $+functions[_batten__subcmd__record__subcmd__show_commands] )) || +_batten__subcmd__record__subcmd__show_commands() { + local commands; commands=() + _describe -t commands 'batten record show commands' commands "$@" +} (( $+functions[_batten__subcmd__record__subcmd__tool_commands] )) || _batten__subcmd__record__subcmd__tool_commands() { local commands; commands=() diff --git a/crates/batten/src/cli.rs b/crates/batten/src/cli.rs index bff68743c..ff2b88910 100644 --- a/crates/batten/src/cli.rs +++ b/crates/batten/src/cli.rs @@ -776,6 +776,24 @@ pub enum ReadyCommand { #[derive(Debug, Clone, PartialEq, Eq)] #[non_exhaustive] pub enum ClaimCommand { + /// The issue keys this branch CLAIMS, as distinct from those it mentions. + Keys { + /// The head branch, standing in for source 2. + branch: Option, + /// The pull request title, also source 2. + title: Option, + /// Commit messages, standing in for sources 1 and 3. + log: Option, + /// Answer from a closing keyword alone. + closing_only: bool, + /// Answer from the first key of each `Refs:` trailer alone. + refs_first_only: bool, + }, + /// The keys merged pull request bodies CLOSE, as `\t` rows. + Merged { + /// The most pull requests to read before the answer is truncated. + limit: Option, + }, /// Judge a set of payloads and mint the receipt when they are pullable. Check { /// Claim over the competitor refusals, recording what was overridden. @@ -1359,6 +1377,44 @@ pub enum RecordCommand { /// The ref or sha the verdict was taken against. reference: String, }, + /// Record one named family under this branch, for a module to read. + /// + /// The POLICY-readable store, unlike [`RecordCommand::Keyed`] and + /// [`RecordCommand::Journal`] below, which are task stores: this writes + /// through `recorder::record_path`, which is what `Fact::Records` projects + /// onto `input.tree.records.`. + /// + /// No key positional: the BRANCH is the key and the engine resolves it, so a + /// caller cannot record against a branch it is not on — `record plan`'s + /// anti-staleness argument, applied to a family the caller names. + Named { + /// The record family, which is the key a module reads it under. + family: String, + }, + /// Put one value into a keyed store family (CLOUD-1713). + Keyed { + /// The store family the record belongs to. + family: String, + /// The key the record is filed under. + key: String, + }, + /// Append one record to an append-and-fold store family (CLOUD-1713). + Journal { + /// The store family the record belongs to. + family: String, + }, + /// Read one keyed record back: `hit` and the value, or `miss`. + Show { + /// The store family to read. + family: String, + /// The key to look under. + key: String, + }, + /// Fold a journal family: `nothing`, its records, or `unreadable `. + Fold { + /// The store family to fold. + family: String, + }, /// Record this branch's plan: ` ` per line, on stdin. /// /// No argument, for [`RecordCommand::Tool`]'s reason one layer over: the @@ -2117,6 +2173,16 @@ fn claim_of(matches: &ArgMatches) -> Option { }), ("bot", _) => Some(ClaimCommand::Bot), ("race", _) => Some(ClaimCommand::Race), + ("merged", matches) => Some(ClaimCommand::Merged { + limit: matches.get_one::("limit").cloned(), + }), + ("keys", matches) => Some(ClaimCommand::Keys { + branch: matches.get_one::("branch").cloned(), + title: matches.get_one::("title").cloned(), + log: matches.get_one::("log").cloned(), + closing_only: flag(matches, "closing_only"), + refs_first_only: flag(matches, "refs_first_only"), + }), ("carry", matches) => Some(ClaimCommand::Carry { json: flag(matches, "json"), }), @@ -2373,6 +2439,23 @@ fn record_of(matches: &ArgMatches) -> Option { ("forge", matches) => Some(RecordCommand::Forge { reference: matches.get_one::("ref")?.clone(), }), + ("named", matches) => Some(RecordCommand::Named { + family: matches.get_one::("family")?.clone(), + }), + ("keyed", matches) => Some(RecordCommand::Keyed { + family: matches.get_one::("family")?.clone(), + key: matches.get_one::("key")?.clone(), + }), + ("journal", matches) => Some(RecordCommand::Journal { + family: matches.get_one::("family")?.clone(), + }), + ("show", matches) => Some(RecordCommand::Show { + family: matches.get_one::("family")?.clone(), + key: matches.get_one::("key")?.clone(), + }), + ("fold", matches) => Some(RecordCommand::Fold { + family: matches.get_one::("family")?.clone(), + }), // No positional to read: the branch is the key and the engine resolves // it, so this arm takes the sub-verb and nothing else. ("plan", _) => Some(RecordCommand::Plan), diff --git a/crates/batten/src/forge.rs b/crates/batten/src/forge.rs index 7559002db..6019c939d 100644 --- a/crates/batten/src/forge.rs +++ b/crates/batten/src/forge.rs @@ -1,7 +1,7 @@ //! The forge's verdict for a commit, read back from a record something else //! wrote (CLOUD-1154). //! -//! # The engine opens no socket, and that is the whole design +//! # The READING half opens no socket, and that is the whole design //! //! House style §5 forbids an HTTP client on the `check` surface and CLOUD-689's //! ~100ms budget forbids one on the mediated path, so ~22 governed gates that @@ -9,6 +9,40 @@ //! engine but to move WHO RESOLVES: the producer fetches once, outside — a //! workflow step, an agent call — and writes a keyed record this reads back. //! +//! # This module has TWO halves now, and the split is the §5 line itself +//! +//! **This heading used to read "the engine opens no socket", full stop, and +//! CLOUD-1712 makes that false for half the file** — so it is corrected rather +//! than left to read as a property the reader can rely on everywhere. +//! +//! * [`verdicts`], [`record_path`] and [`parse`] are the READER. No I/O beyond +//! opening a file under the git directory, no clock, no network. This is what +//! `check` reaches, it is `Cost::Read`, and the paragraph above is its +//! contract in full. +//! * [`window`] is the PRODUCER's side. It fetches, over +//! [`crate::rest`], and it exists because seven shell programs each +//! reimplemented the same paginated read — two of them carrying a literal copy +//! of one 45-line `conditional_get`. +//! +//! **The split is enforced by WHO CALLS, exactly as `record-verdicts` already +//! is.** `check` is `Cost::Read` and structurally cannot spawn or fetch; a +//! producer verb is `Effect::Write` and may. Nothing on the read path calls +//! [`window`], and a rule wanting windowed data reads the record a producer +//! wrote — which is the same seam, one collection wider. `evaluator-io-check` is +//! a different question and is untouched: it gates the Rego EVALUATOR reaching +//! `http.send`, which no part of this module changes. +//! +//! # Truncation is a verdict, never a short list +//! +//! The reason [`window`] returns [`Window::Truncated`] rather than the rows it +//! managed to read is measured rather than theoretical. Three programs +//! discovered the trap independently and guarded it three ways — +//! `merged-pr-keys` against its `--limit`, `land-divergence` against +//! `total_count`, and `timeout-drift` **not at all**, silently trusting one +//! unpaginated page. A caller handed a prefix cannot tell it from the whole +//! collection, so every reduction over it — a percentile, a max, an +//! is-there-any — answers about a window and reports about a population. +//! //! That is exactly [`crate::facts::AGENT_SOURCED`]'s argument, moved from the //! hook surface to the tree one: *the same answer that is `verify-only` when the //! ENGINE would fetch it is not when something else already did.* The table is @@ -173,3 +207,292 @@ mod tests { assert!(!checks.contains_key("lonely"), "{checks:?}"); } } + +// --- the producer's half: one windowed read, and truncation is a verdict ----- +// +// CLOUD-1712. Everything below fetches; nothing above it does. The module header +// states why that line is where it is. + +/// Where the conditional-read validators live, under the git directory. +/// +/// Beside [`DIRECTORY`] and for its reason: per-checkout state that must never be +/// committed. `land-divergence` hand-rolled this under +/// `.git/batten-divergence`, keyed by a hash of the URL; the key is the same +/// idea, spelled once. +const VALIDATORS: &str = "batten-forge-validators"; + +/// How a collection's rows are carried in the response body. +/// +/// **Named by the caller rather than sniffed**, and that is deliberate. The +/// forge answers some endpoints with a bare array and others with an object +/// wrapping a named array beside a `total_count`. A reader that guessed — "the +/// one field that is an array" — would be one schema change away from silently +/// reducing over the wrong field, and it could not tell an object with two +/// arrays from an object with one. The caller knows its endpoint; the guess only +/// moves the knowledge somewhere it cannot be checked. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Shape<'a> { + /// The body IS the array, e.g. `pulls`. + Bare, + /// The body is an object; the rows are under this key, e.g. `check_runs`. + Wrapped(&'a str), +} + +/// What one windowed read found. +/// +/// Three answers rather than two, on this module's own three-valued discipline: +/// a whole collection, a collection the window could not reach the end of, and +/// a forge that could not be asked. Collapsing the last two would report a +/// network failure as a truncation; collapsing the first two is the defect the +/// type exists to prevent. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Window { + /// Every row the collection holds. The walk reached the end. + Whole(Vec), + /// The window ended before the collection did. + /// + /// **Carries what it could not see, because a truncation that cannot say + /// how much it missed is only marginally better than a silent one.** `read` + /// is how many rows were collected; `total` is the forge's own count where + /// the endpoint states one, and `None` where it does not — in which case the + /// evidence is that the last page came back full at the page budget. + Truncated { + /// Rows collected before the window ran out. + read: usize, + /// The collection's own size, where the endpoint reports it. + total: Option, + /// Pages actually fetched — the budget that was spent. + pages: u32, + }, + /// The forge could not be asked, or answered something unparseable. + /// + /// **Could-not-look, never an empty collection.** `rest::get` returns `None` + /// only where the exchange did not happen, but a completed exchange carrying + /// a `403` has an error document for a body — which parses to zero rows and + /// is byte-identical, on the decision surface, to a genuinely empty + /// collection. That is the defect `Answer::is_reading` records one layer + /// down, and this arm is where it is refused here. + /// + /// The payload is a POINTER — the endpoint and a status — never a body. + /// Rule 4 is decided here rather than at the report, for this module's own + /// stated reason: a forge error document is a likely place for a secret. + CouldNotLook { + /// The endpoint asked about, without its query string. + endpoint: String, + /// The status the forge answered with, where it answered at all. + status: Option, + }, +} + +/// How a window reaches the forge. +/// +/// **A parameter rather than a hard call to [`crate::rest::get`], and the reason +/// is testability rather than abstraction for its own sake.** The transport's +/// own test seam is the `BATTEN_REST_FIXTURE` environment variable, which a +/// suite in this process cannot set: `std::env::set_var` is `unsafe` under this +/// edition and this crate forbids `unsafe` outright, and the established way +/// round that is to run the compiled binary as a subprocess — which needs a CLI +/// leaf this row deliberately does not add (the retirements bring their own). +/// +/// So the seam moves up one level, where it is an ordinary argument. A case +/// supplies canned pages and drives the REAL walk: the same pagination, the same +/// `total_count` arithmetic, the same validator store on disk. Only the socket +/// is stubbed, which is the only part a fixture was ever stubbing. +pub type Transport<'a> = &'a dyn Fn(&str, Option<&str>) -> Option; + +/// The validator store's path for one URL. +/// +/// Keyed by a digest of the whole URL, query string included, because two pages +/// of one collection are two different readings and a key that collapsed them +/// would serve page 1's body for page 2. +fn validator_path(git_dir: &Path, url: &str) -> PathBuf { + git_dir + .join(VALIDATORS) + .join(crate::tools::digest(url.as_bytes())) +} + +/// One conditional GET, answering from the store on a `304`. +/// +/// Returns the body and the status. A `304` is answered from the cached body and +/// reported as `200`, because to this caller they are the same reading — which +/// is the whole point of sending the validator. +/// +/// **A `304` with no cached body is could-not-look, not an empty one.** The +/// store can be pruned between runs while the forge still holds the validator, +/// and reading that as an empty page would end the walk one page early and +/// report the prefix as whole. `land-divergence`'s own `conditional_get` +/// returned failure here for the same reason. +fn conditional_get(git_dir: &Path, path: &str, fetch: Transport<'_>) -> Option<(u16, String)> { + let stored = validator_path(git_dir, path); + let etag = std::fs::read_to_string(stored.join("etag")).ok(); + let answer = fetch(path, etag.as_deref().map(str::trim))?; + + if answer.status == 304 { + let cached = std::fs::read_to_string(stored.join("body")).ok()?; + return Some((200, cached)); + } + if !answer.is_reading() { + return Some((answer.status, String::new())); + } + // PERSIST BEFORE ANSWERING, and a failure to persist is not a failure to + // read: the store is an optimisation, so a read-only git directory costs a + // conditional request next time rather than the answer this time. + if let Some(validator) = answer.etag.as_deref() + && std::fs::create_dir_all(&stored).is_ok() + { + let _ = std::fs::write(stored.join("etag"), validator); + let _ = std::fs::write(stored.join("body"), &answer.body); + } + Some((answer.status, answer.body)) +} + +/// Read a paginated collection over a bounded window. +/// +/// `path` is API-relative and carries no leading slash, exactly as +/// [`crate::rest::get`] takes it, and no `page` parameter — this appends one per +/// lap. `params` is the rest of the query string, already URL-safe. +/// +/// # Why the shape is a parameter and the page budget is not optional +/// +/// The signature CLOUD-1712 sketched was `window(endpoint, params, max_pages)`. +/// [`Shape`] is the fourth because the alternative is a guess the caller already +/// knows the answer to — its own doc argues that. `max_pages` has no default +/// because every caller that took one took a DIFFERENT one, and a shared default +/// would silently re-truncate the program with the widest window. +/// +/// # Errors +/// +/// Never returns an error type: the two failure readings are +/// [`Window::Truncated`] and [`Window::CouldNotLook`], which are answers rather +/// than faults. A caller that wants to refuse on either must say so; a caller +/// that pattern-matches only `Whole` gets a compile error rather than a prefix. +#[must_use] +pub fn window( + git_dir: &Path, + path: &str, + params: &[(&str, &str)], + shape: Shape, + max_pages: u32, +) -> Window { + window_over(git_dir, path, params, shape, max_pages, &|path, etag| { + crate::rest::get(path, etag) + }) +} + +/// [`window`], over a caller-supplied [`Transport`]. +/// +/// The whole of the walk lives here; [`window`] is this with the live transport +/// bound. See [`Transport`] for why the seam is an argument. +#[must_use] +pub fn window_over( + git_dir: &Path, + path: &str, + params: &[(&str, &str)], + shape: Shape, + max_pages: u32, + fetch: Transport<'_>, +) -> Window { + // `git_dir` EXPLICITLY, as every other function in this module takes it. + // Resolving it from the process's cwd would make the validator store depend + // on where the caller happened to be standing, and would make this untestable + // without a chdir — which is shared mutable state across a parallel suite. + let query: String = params.iter().fold(String::new(), |mut acc, (key, value)| { + use std::fmt::Write as _; + // `write!` to a String cannot fail; the result is bound rather than + // dropped because the lint that sent us here is `#[must_use]`-adjacent. + let _ = write!(acc, "&{key}={value}"); + acc + }); + // THE PAGE SIZE IS THE END-OF-COLLECTION SIGNAL where the endpoint reports + // no `total_count`: a page carrying fewer rows than were asked for is the + // last one. Read off the caller's own `per_page` rather than assumed, + // because the forge's default differs per endpoint — `timeout-drift`'s + // unpaginated `/jobs` call silently takes 30 — and a wrong constant here + // would end the walk early and report a prefix as whole. + let page_size = params + .iter() + .find(|(key, _)| *key == "per_page") + .and_then(|(_, value)| value.parse::().ok()); + + let mut rows: Vec = Vec::new(); + let mut total: Option = None; + let mut pages = 0_u32; + let mut ended = false; + + while pages < max_pages { + let page = pages + 1; + let url = format!("{path}?page={page}{query}"); + let Some((status, body)) = conditional_get(git_dir, &url, fetch) else { + return Window::CouldNotLook { + endpoint: path.to_owned(), + status: None, + }; + }; + if status != 200 { + return Window::CouldNotLook { + endpoint: path.to_owned(), + status: Some(status), + }; + } + let Ok(parsed) = serde_json::from_str::(&body) else { + // UNPARSEABLE IS COULD-NOT-LOOK. A body that is not JSON is not an + // empty collection, and `serde_json`'s error carries a fragment of + // the input, so it is dropped rather than reported (rule 4). + return Window::CouldNotLook { + endpoint: path.to_owned(), + status: Some(status), + }; + }; + let batch = match shape { + Shape::Bare => parsed.as_array().cloned(), + Shape::Wrapped(key) => { + if let Some(count) = parsed + .get("total_count") + .and_then(serde_json::Value::as_u64) + { + total = Some(usize::try_from(count).unwrap_or(usize::MAX)); + } + parsed.get(key).and_then(|rows| rows.as_array()).cloned() + } + }; + let Some(batch) = batch else { + return Window::CouldNotLook { + endpoint: path.to_owned(), + status: Some(status), + }; + }; + pages = page; + // A SHORT OR EMPTY PAGE ENDS THE COLLECTION, which is the same evidence + // `land-divergence` recorded measuring its own window: page 10 came back + // full and page 11 empty. Empty alone is not enough — a walk that only + // stopped on an empty page would spend one request past every collection + // whose size divides evenly, and against a fixture or a rate limit that + // extra request is the difference between an answer and could-not-look. + let short = match page_size { + Some(size) => batch.len() < size, + None => batch.is_empty(), + }; + rows.extend(batch); + // `total_count` ends it too, and is checked FIRST where the endpoint + // states one: it is the forge's own answer about the collection, where + // a short page is an inference from the window. + if total.is_some_and(|count| rows.len() >= count) || short { + ended = true; + break; + } + } + + let reached_the_end = match total { + Some(count) => rows.len() >= count, + None => ended, + }; + if reached_the_end { + Window::Whole(rows) + } else { + Window::Truncated { + read: rows.len(), + total, + pages, + } + } +} diff --git a/crates/batten/src/hook.rs b/crates/batten/src/hook.rs index 5d9d344f4..496ad3d66 100644 --- a/crates/batten/src/hook.rs +++ b/crates/batten/src/hook.rs @@ -9030,13 +9030,17 @@ fn flatten_in( // **A CONTROL-FLOW BODY IS WALKED AND TAGGED**, which is what makes a // module able to decide from structure (CLOUD-1381). // - // An earlier revision walked these UNTAGGED and it was an over-deny: - // `run-shape-guard` exempts a `sleep` inside a condition loop, and a - // body command lifted into a bare segment carries nothing saying it was - // in a loop, so `until [ -f /tmp/done ]; do sleep 1; done` -- the wait - // this repository's own rules recommend -- was refused as a bare timer. - // A second revision withdrew the walk entirely, which stopped that - // over-deny and left the module unable to see the loop at all. + // An earlier revision walked these UNTAGGED, and at the time that was an + // over-deny: `run-shape` then exempted a `sleep` inside a condition + // loop, and a body command lifted into a bare segment carries nothing + // saying it was in a loop, so a conditioned wait was refused as a bare + // timer. A second revision withdrew the walk entirely, which stopped + // that over-deny and left the module unable to see the loop at all. + // + // CLOUD-1337 has since withdrawn the exemption, so that particular + // over-deny is no longer possible -- but the tagging is not vestigial: + // `polls-a-local-process` and `background-timer` PARTITION on what the + // loop body reads, and neither can see a body it was never handed. // // Tagging is the answer both attempts were missing. The condition and // the body are DIFFERENT roles and a module needs them apart: the @@ -15655,11 +15659,12 @@ deny contains "refused by themodule" if { /// /// The tag is what makes a module able to decide from structure rather than /// from a keyword, and both halves matter. Walking untagged was an - /// over-deny: `run-shape-guard` exempts a `sleep` inside a condition loop, - /// and a body command lifted into a bare segment carries nothing saying it - /// was in a loop, so the sanctioned `until … do sleep 1; done` wait was - /// refused as a bare timer. Not walking at all left the module unable to see - /// the loop. + /// over-deny while `run-shape` still exempted a `sleep` inside a condition + /// loop: a body command lifted into a bare segment carries nothing saying it + /// was in a loop, so a conditioned wait was refused as a bare timer. Not + /// walking at all left the module unable to see the loop. CLOUD-1337 has + /// since withdrawn the exemption, and the tag is still what the two arms + /// partition on. /// /// The condition and the body are separate roles because a module needs them /// apart: a process probe lives in the condition, a sleep in the body. diff --git a/crates/batten/src/journal.rs b/crates/batten/src/journal.rs index 4f4f7a9a9..117579291 100644 --- a/crates/batten/src/journal.rs +++ b/crates/batten/src/journal.rs @@ -361,6 +361,30 @@ impl Origin { /// /// Returns an error when the shard cannot be created, written, or synced. pub fn append(store_dir: &Path, shard: &str, entry: &Entry) -> Result<()> { + append_line(store_dir, shard, &serde_json::to_string(entry)?) +} + +/// Append one already-encoded line to a shard, durably. +/// +/// **The one append path in this crate, and [`append`] is now a caller of it +/// rather than a second copy** (CLOUD-1713). Three shell programs hand-rolled a +/// durable append-only store under `.git/` while this machinery sat here with no +/// door — `reclaim-census` being a single-shard journal with a boot id as the +/// shard key, written by hand next to this file. Generalising the line write is +/// what opens the door without minting a second store, which is exactly what that +/// row forbids. +/// +/// Persist-before-emit, and the `sync_all` is the half that makes the +/// discriminating case decidable: without it a caller can emit having written a +/// record the next boot cannot read. `reclaim-census` reached for `sync -d` for +/// this reason; the durability barrier moves here so no caller has to remember. +/// +/// No lock, for [`append`]'s reason: a shard has exactly one writer. +/// +/// # Errors +/// +/// Returns an error when the shard cannot be created, written, or synced. +pub fn append_line(store_dir: &Path, shard: &str, line: &str) -> Result<()> { let dir = shards_dir(store_dir); std::fs::create_dir_all(&dir) .with_context(|| format!("create the shard directory {}", dir.display()))?; @@ -370,13 +394,91 @@ pub fn append(store_dir: &Path, shard: &str, entry: &Entry) -> Result<()> { .append(true) .open(&path) .with_context(|| format!("open the shard {}", path.display()))?; - let line = serde_json::to_string(entry)?; writeln!(file, "{line}").with_context(|| format!("append to the shard {}", path.display()))?; file.sync_all() .with_context(|| format!("sync the shard {}", path.display()))?; Ok(()) } +/// What folding every shard of a generic journal found. +/// +/// **`task::Reading`'s three answers, and that type is the stated precedent +/// rather than a coincidence** (CLOUD-1713 §7). A fold over zero records is +/// *nothing* — a real answer — and is never the same claim as being unable to +/// read the store. Collapsing them is the dead-gate shape this whole crate is +/// written against: an unreadable store reported as "no records" reads clean. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Fold { + /// No shard holds a whole record. Nothing has been appended, or every line + /// present was torn. A real answer. + Nothing, + /// Every whole record, shard-sorted then in append order. + Records(Vec), + /// The store is there and cannot be read. Could-not-look. + Unreadable(PathBuf), +} + +/// Fold every shard of a generic journal into its whole records. +/// +/// **A HALF-WRITTEN APPEND IS NOT A RECORD**, which is the discriminating case +/// (CLOUD-1032). A line counts only if it was terminated: the file's final +/// fragment is dropped unless the file ends in a newline, because a process that +/// died mid-`write` leaves exactly that. [`read_shards`] drops a torn trailing +/// line by failing to parse it as an [`Entry`]; a generic record has no schema to +/// fail against, so the termination has to be read off the bytes instead. +/// +/// Shard order is lexical, for [`read_shards`]'s reason: a fold must be a pure +/// function of the shard contents and never of `read_dir` order (§6). +/// +/// # Errors +/// +/// Never returns an error: an unreadable store is [`Fold::Unreadable`], which is +/// an answer rather than a fault, and a caller that pattern-matches only +/// [`Fold::Records`] gets a compile error rather than a silent empty list. +#[must_use] +pub fn fold_lines(store_dir: &Path) -> Fold { + let dir = shards_dir(store_dir); + let Ok(entries) = std::fs::read_dir(&dir) else { + // ABSENT IS NOTHING, not unreadable: a store nobody has appended to has + // no directory, and that is a genuine "no records" rather than a failure + // to look. A store that exists and cannot be listed is the other arm. + return if dir.exists() { + Fold::Unreadable(dir) + } else { + Fold::Nothing + }; + }; + let mut paths: Vec = entries + .flatten() + .map(|entry| entry.path()) + .filter(|path| path.extension().is_some_and(|ext| ext == "jsonl")) + .collect(); + paths.sort(); + + let mut all = Vec::new(); + for path in paths { + let Ok(text) = std::fs::read_to_string(&path) else { + return Fold::Unreadable(path); + }; + // Only terminated lines. `split('\n')` then dropping the tail is what + // distinguishes a whole final record from a torn one; `lines()` cannot, + // because it yields an unterminated tail identically to a terminated one. + let mut parts: Vec<&str> = text.split('\n').collect(); + let _torn_or_empty = parts.pop(); + all.extend( + parts + .iter() + .filter(|line| !line.is_empty()) + .map(|line| (*line).to_owned()), + ); + } + if all.is_empty() { + Fold::Nothing + } else { + Fold::Records(all) + } +} + /// The shard id for this process in this worktree. /// /// Per **worktree**, not per process: a shard per process would mint a file per diff --git a/crates/batten/src/lib.rs b/crates/batten/src/lib.rs index 1ce5f8e9a..ec62067f9 100644 --- a/crates/batten/src/lib.rs +++ b/crates/batten/src/lib.rs @@ -411,7 +411,7 @@ pub fn run(cli: Cli, mode: Mode, out: &mut dyn Write, err: &mut dyn Write) -> Re // input, which is exactly the committed authority a `--config-from` is // meant to pin. That is also what stops a caller keying a record to // anything the config does not already declare (CLOUD-1265). - Some(Command::Record { command }) => record::run(command, &overrides), + Some(Command::Record { command }) => record::run(command, &overrides, out), } } @@ -3761,6 +3761,240 @@ fn refuse_claim_bot(err: &mut dyn Write, text: &str) -> Result { /// Minted by whoever is at the keyboard, exactly like the agent receipt — the /// party that ran the check writes the record of it. A workflow minting one would /// be a receipt asserting a check nobody performed. +/// What `claim keys` was asked about (CLOUD-1711). +/// +/// Explicit sources exist for a pull request this checkout did not author +/// (CLOUD-378): `claim race` asks the same question about a COMPETING branch and +/// had no way to ask it of the local repository. +struct ClaimKeysAsk<'a> { + /// The head branch, source 2. + branch: Option<&'a str>, + /// The pull request title, also source 2. + title: Option<&'a str>, + /// Commit messages, sources 1 and 3. + log: Option<&'a str>, + /// Source 1 alone. + closing_only: bool, + /// Source 3 alone. + refs_first_only: bool, +} + +/// Print the issue keys this branch claims, one per line. +/// +/// **Ported off `mise-tasks/claimed-keys.sh` (CLOUD-1711).** The precedence, the +/// citation rule, the explicit-source mode and the `BATTEN_SPEC_BASE` ancestor +/// bound are all `race`'s; this function is the seam that reads the repository +/// and prints the answer. +/// +/// EMPTY IS NOT AN ERROR and exit 0 is the whole contract: every caller treats +/// "no claim" as "do not judge", because a guard that guesses is one that blocks +/// correct work. Outside a git repository the same applies. +fn run_claim_keys( + repo: &Path, + ask: &ClaimKeysAsk<'_>, + overrides: &resolve::Overrides, + out: &mut dyn Write, + err: &mut dyn Write, +) -> Result { + // EACH FLAG NAMES ONE SOURCE, so both together is a caller that has not + // decided which question it is asking rather than an intersection to compute. + let source = match (ask.closing_only, ask.refs_first_only) { + (true, true) => { + writeln!( + err, + "batten: claim keys: --closing-only and --refs-first-only each name one source; pick one" + )?; + return Ok(ExitCode::Usage); + } + (true, false) => race::Source::ClosingOnly, + (false, true) => race::Source::RefsFirstOnly, + (false, false) => race::Source::All, + }; + + // EXPLICIT MODE IS ALL-OR-NOTHING. Passing any source switches git off + // entirely, because a remote pull request silently answered from the local + // branch would be a confident verdict about the wrong repository state. + let explicit = ask.branch.is_some() || ask.title.is_some() || ask.log.is_some(); + let (branch, log) = if explicit { + ( + ask.branch.unwrap_or_default().to_owned(), + ask.log.unwrap_or_default().to_owned(), + ) + } else { + // BEFORE THE GRAMMAR, and the order is the program's own decision rather + // than an accident: outside a git checkout it printed nothing and exited + // 0, so the caller behaves exactly as it did before it asked. Resolving + // config first would turn that silence into a config error, which is a + // different answer to a caller that treats non-zero as "could not look". + // ANY failure to name the branch is "no claim", never an error. The + // program spelled this `git rev-parse --abbrev-ref HEAD || exit 0`, and + // it covers a directory that is not a checkout at all as well as a + // checkout with no HEAD. Propagating instead would turn "do not judge" + // into a non-zero every caller reads as could-not-look. + let Some(head) = git::current_branch(repo).ok().flatten() else { + return Ok(ExitCode::Success); + }; + (head, race::authored_log(repo, "origin/main")) + }; + let grammar = board_grammar(overrides)?; + + // The extra evidence a caller has and this cannot read for itself: the + // command being guarded, or the pull request body being judged. OPTIONAL — a + // caller with nothing to add closes stdin and the branch and commit sources + // still answer, so this must never block on an interactive terminal. + let body = if std::io::IsTerminal::is_terminal(&std::io::stdin()) { + String::new() + } else { + let mut raw = String::new(); + std::io::Read::read_to_string(&mut std::io::stdin(), &mut raw).unwrap_or_default(); + raw + }; + for key in race::claimed_from( + &branch, + ask.title.unwrap_or_default(), + &log, + &body, + &grammar, + source, + ) { + writeln!(out, "{key}")?; + } + Ok(ExitCode::Success) +} + +/// How many merged pull requests `claim merged` reads before refusing. +/// +/// Carried from `MERGED_PR_KEYS_LIMIT`'s default. A bound rather than an +/// unbounded walk because the forge's own listing caps out, and the header of the +/// program this replaces records the measurement: `--limit 400` returned exactly +/// 400 and hid #170, #337 and #339. +const MERGED_PR_LIMIT: usize = 5000; + +/// Print `\t` for every key a merged pull request body CLOSES. +/// +/// **Ported off `mise-tasks/merged-pr-keys.sh` (CLOUD-1752).** The extraction is +/// delegated to [`race::claimed_from`] with [`race::Source::ClosingOnly`], exactly +/// as the program delegated to `claimed-keys --closing-only`, so both sides of a +/// landed-ness comparison still come out of one authority (CLOUD-338). +/// +/// # Every could-not-look, and why each is one +/// +/// * the remote names no repository this can derive a slug from; +/// * the forge did not answer, or answered something unparseable; +/// * the walk hit its page budget — a reading AT the limit is indistinguishable +/// from one truncated by it, and **a truncated evidence file makes landed work +/// read as live**; +/// * the forge reports NO merged pull requests at all, which cannot be true of a +/// repository with a trunk. That is a reachability problem, not an empty +/// answer, and reading it as one strands every landed row. +/// +/// Each exits non-zero. See this verb's `CommandDecl` for why a producer differs +/// from `claim race` here. +/// +/// Output is keys and numbers, sorted and de-duplicated so two runs over one +/// forge are byte-identical (§6) — never a title or a body, which is where the +/// keyword lives and which rule 4 keeps out of a report. +fn run_claim_merged( + repo: &Path, + limit: Option<&str>, + overrides: &resolve::Overrides, + out: &mut dyn Write, + err: &mut dyn Write, +) -> Result { + let cannot_look = |err: &mut dyn Write, why: &str| -> Result { + writeln!(err, "batten: claim merged: {why}")?; + Ok(ExitCode::Internal) + }; + let limit = match limit { + Some(raw) => match raw.trim().parse::() { + Ok(parsed) if parsed > 0 => parsed, + _ => { + writeln!( + err, + "batten: claim merged: --limit must be a positive number" + )?; + return Ok(ExitCode::Usage); + } + }, + None => MERGED_PR_LIMIT, + }; + + let remotes = git::remote_fact(repo)?.remotes; + let Some(slug) = remotes.get("origin").and_then(|url| race::slug_of(url)) else { + return cannot_look(err, "no origin remote this can derive a repository from"); + }; + let git_dir = git::git_dir(repo)?; + // ONE PAGE OF 100 PER LAP, so the budget is stated in pull requests rather + // than in pages — the same unit `MERGED_PR_KEYS_LIMIT` was written in. + let per_page = 100_usize; + let pages = u32::try_from(limit.div_ceil(per_page)).unwrap_or(u32::MAX); + let rows = match forge::window( + &git_dir, + &format!("repos/{slug}/pulls"), + &[("state", "closed"), ("per_page", "100")], + forge::Shape::Bare, + pages, + ) { + forge::Window::Whole(rows) => rows, + forge::Window::Truncated { read, .. } => { + return cannot_look( + err, + &format!( + "the walk read {read} pull request(s) and did not reach the end — the answer \ + is truncated, and a truncated evidence file makes landed work read as live. \ + Raise --limit above {limit} and run again" + ), + ); + } + forge::Window::CouldNotLook { endpoint, status } => { + return cannot_look( + err, + &format!( + "the forge did not answer for {endpoint} (status {})", + status.map_or_else(|| String::from("none"), |code| code.to_string()) + ), + ); + } + }; + + // MERGED, not merely closed. The forge's listing has no merged state, so the + // filter is `merged_at`; a closed-unmerged pull request closes nothing and + // counting it would report abandoned work as landed. + let merged: Vec<&serde_json::Value> = rows + .iter() + .filter(|row| row.get("merged_at").is_some_and(|at| !at.is_null())) + .collect(); + if merged.is_empty() { + return cannot_look( + err, + "the forge reports no merged pull requests at all, which cannot be true of a \ + repository with a trunk — a reachability problem, not an empty answer", + ); + } + + let grammar = board_grammar(overrides)?; + let mut lines: std::collections::BTreeSet = std::collections::BTreeSet::new(); + for row in merged { + let Some(number) = row.get("number").and_then(serde_json::Value::as_u64) else { + return cannot_look( + err, + "a pull request in the reading carries no usable number", + ); + }; + let body = row + .get("body") + .and_then(serde_json::Value::as_str) + .unwrap_or_default(); + for key in race::claimed_from("", "", "", body, &grammar, race::Source::ClosingOnly) { + lines.insert(format!("{key}\t{number}")); + } + } + for line in lines { + writeln!(out, "{line}")?; + } + Ok(ExitCode::Success) +} + fn run_claim_bot( repo: &Path, mode: Mode, @@ -4075,6 +4309,28 @@ fn run_claim( err, ) } + ClaimCommand::Keys { + branch, + title, + log, + closing_only, + refs_first_only, + } => run_claim_keys( + Path::new("."), + &ClaimKeysAsk { + branch: branch.as_deref(), + title: title.as_deref(), + log: log.as_deref(), + closing_only, + refs_first_only, + }, + overrides, + out, + err, + ), + ClaimCommand::Merged { limit } => { + run_claim_merged(Path::new("."), limit.as_deref(), overrides, out, err) + } ClaimCommand::Bot => run_claim_bot(Path::new("."), mode, overrides, out, err), ClaimCommand::Race => run_claim_race(Path::new("."), mode, overrides, out, err), ClaimCommand::Carry { json } => run_claim_carry(Path::new("."), mode, json, out, err), diff --git a/crates/batten/src/main_watch.rs b/crates/batten/src/main_watch.rs index 6d2842b21..9a75cff4a 100644 --- a/crates/batten/src/main_watch.rs +++ b/crates/batten/src/main_watch.rs @@ -224,6 +224,7 @@ mod tests { fn ref_body(sha: &str) -> crate::rest::Answer { crate::rest::Answer { + headers: std::collections::BTreeMap::new(), status: 200, etag: Some(String::from("W/\"a\"")), poll_floor: None, @@ -234,6 +235,7 @@ mod tests { fn answer(status: u16, floor: Option, body: &str) -> crate::rest::Answer { crate::rest::Answer { + headers: std::collections::BTreeMap::new(), status, etag: Some(String::from("W/\"a\"")), poll_floor: floor, diff --git a/crates/batten/src/pr_watch.rs b/crates/batten/src/pr_watch.rs index 472f58a2b..25018659a 100644 --- a/crates/batten/src/pr_watch.rs +++ b/crates/batten/src/pr_watch.rs @@ -820,6 +820,7 @@ mod tests { /// of a status, an `ETag` and a poll floor in this crate. fn answer(status: u16, etag: Option<&str>, body: &str) -> crate::rest::Answer { crate::rest::Answer { + headers: std::collections::BTreeMap::new(), status, etag: etag.map(str::to_owned), poll_floor: None, diff --git a/crates/batten/src/race.rs b/crates/batten/src/race.rs index 7c438d0b4..a1367a076 100644 --- a/crates/batten/src/race.rs +++ b/crates/batten/src/race.rs @@ -134,8 +134,58 @@ pub struct Race { /// one function for exactly that reason. #[must_use] pub fn claimed(branch: &str, title: &str, log: &str, body: &str, keys: &dyn Keys) -> Vec { + claimed_from(branch, title, log, body, keys, Source::All) +} + +/// Which of the three sources may answer. +/// +/// **The shell's two narrowing flags as a type** (CLOUD-1711). `claimed-keys.sh` +/// spells these `--closing-only` and `--refs-first-only`, and they are mutually +/// exclusive there because each names a different SINGLE source — asking for both +/// is a caller that has not decided which question it is asking, never an +/// intersection to compute. An enum makes that unrepresentable rather than +/// checked, which is the one thing a port can improve without changing a +/// decision. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum Source { + /// All three, most explicit first. The default, and every existing caller. + #[default] + All, + /// Source 1 alone — a closing keyword — never falling through. + /// + /// For a caller asking about a LOG rather than a branch (CLOUD-804): over + /// `main`'s history the fallbacks answer a different question, and source 3 + /// in particular is the exact citation signal CLOUD-480 was swept wrong on. + ClosingOnly, + /// Source 3 alone — the first key of each `Refs:` trailer. + /// + /// The exact mirror, for one caller with one need: `closing-key-check` asks + /// which keys a branch SERVED, to subtract the keys the body closes + /// (CLOUD-674). That comparison is only meaningful against a set derived + /// WITHOUT reference to the closing keys — the full chain returns source 1 + /// first, so the answer would agree with the body by construction and the + /// gate would pass on exactly the bodies it must refuse. + RefsFirstOnly, +} + +/// [`claimed`], narrowed to one source. +/// +/// The precedence and the citation rule are unchanged; `source` only decides +/// which branches may run at all. +#[must_use] +pub fn claimed_from( + branch: &str, + title: &str, + log: &str, + body: &str, + keys: &dyn Keys, + source: Source, +) -> Vec { + if source == Source::RefsFirstOnly { + return refs_first(log, keys); + } let closing = dedup(keys.closed(&folded(&format!("{body}\n{log}")))); - if !closing.is_empty() { + if !closing.is_empty() || source == Source::ClosingOnly { return closing; } let declared = dedup(keys.named(&folded(&format!("{branch} {title}")))); @@ -145,6 +195,52 @@ pub fn claimed(branch: &str, title: &str, log: &str, body: &str, keys: &dyn Keys refs_first(log, keys) } +/// The commit messages this branch AUTHORED, which is narrower than the ones it +/// carries (CLOUD-748). +/// +/// **Carrying this bound is the whole of the port's fidelity.** `land`'s +/// speculative linearization rebases a waiting branch onto the lease holder's +/// published head, putting ANOTHER BRANCH'S unlanded commits into this branch's +/// history. Those commits carry the holder's keys, and the holder has an open +/// pull request by construction — so a claim derived over the whole branch +/// history reported the waiter as racing the very pull request the bet was +/// placed on. Measured twice in one session, each costing a full `verify`. A +/// port that read `origin/main..HEAD` unconditionally would silently reintroduce +/// exactly that, and it would look like a passing gate. +/// +/// [`crate::speculation::PUBLISHED_AS`] is the boundary: the commit the branch +/// was replayed ONTO, so everything after it on HEAD is this branch's own work. +/// +/// **Honoured only when it is an ancestor of HEAD**, which is what makes a stale +/// export harmless: an unwound bet, a `land` that died, or a variable inherited +/// from an unrelated run all fail that test and the range falls back to +/// `origin/main`. The failure direction is the WIDER set, which is the one that +/// refuses — never the narrower one, which would silently stop catching races. +#[must_use] +pub fn authored_log(dir: &std::path::Path, base: &str) -> String { + let speculated = std::env::var(crate::speculation::PUBLISHED_AS) + .ok() + .filter(|value| !value.trim().is_empty()); + if let Some(spec_base) = speculated { + // ANCESTOR OF HEAD, spelled as "the merge base with HEAD is the base + // itself". `merge_base` already answers against HEAD, so an unrelated or + // unwound base disagrees with its own resolved id and the range falls + // back — which is the wider, refusing direction the shell chose. + let resolved = crate::git::resolve_ref(dir, &spec_base).ok().flatten(); + let shared = crate::git::merge_base(dir, &spec_base).ok().flatten(); + if resolved.is_some() + && resolved == shared + && let Ok(Some(text)) = crate::git::log_messages(dir, &spec_base) + { + return text; + } + } + crate::git::log_messages(dir, base) + .ok() + .flatten() + .unwrap_or_default() +} + /// Upper-cased, because a key pattern is not obliged to be case-insensitive and /// a BRANCH NAME is routinely not upper case. /// @@ -449,4 +545,99 @@ mod tests { fn a_head_sha_no_listed_pull_request_carries_identifies_nothing() { assert!(identify(&[pull("1", "a", "aaaa")], "bbbb").is_none()); } + + // --- CLOUD-1711: the `Source` selector ----------------------------------- + // + // THE DISCRIMINATING CASE IS THE CITATION TRAP, and it is the one the whole + // module exists for: a body CITES related issues as evidence, and reading a + // citation as a claim made a pull request racing the very key it cited. + + #[test] + fn a_body_that_cites_a_key_without_closing_it_does_not_claim_it() { + assert!( + claimed_from( + "", + "", + "", + "Supersedes the measurement in PROJ-133.", + &Fake, + Source::All, + ) + .is_empty(), + "citing is not claiming" + ); + } + + #[test] + fn closing_only_never_falls_through_to_the_branch() { + // `--closing-only`'s whole reason: over a LOG rather than a branch the + // fallbacks answer a different question, and source 3 in particular is + // the citation signal CLOUD-480 was swept wrong on. + assert!( + claimed_from( + "user/proj-843-campaign", + "a title (PROJ-9)", + "Refs: PROJ-1170\n", + "", + &Fake, + Source::ClosingOnly, + ) + .is_empty(), + "with no closing keyword the answer is empty, never the branch or a trailer" + ); + assert_eq!( + claimed_from("", "", "", "Closes PROJ-7.", &Fake, Source::ClosingOnly), + vec![String::from("PROJ-7")], + "and a closing keyword still answers" + ); + } + + #[test] + fn refs_first_only_ignores_a_closing_keyword_in_the_body() { + // CLOUD-674's circularity: `closing-key-check` subtracts the keys a body + // CLOSES from the keys the branch SERVED, so the served set must be + // derived without reference to the closing keys — otherwise it agrees + // with the body by construction and the gate passes on exactly the + // bodies it must refuse. + assert_eq!( + claimed_from( + "user/proj-9-thing", + "", + "Refs: PROJ-1170\n", + "Closes PROJ-7.", + &Fake, + Source::RefsFirstOnly, + ), + vec![String::from("PROJ-1170")], + "source 3 alone, never sources 1 or 2" + ); + } + + #[test] + fn the_default_source_is_the_whole_chain_in_order() { + // The anti-vacuity mirror for the two narrowing cases: a selector that + // returned nothing for every variant would pass both of them. + assert_eq!( + claimed_from( + "user/proj-843-x", + "", + "", + "Closes PROJ-1170.", + &Fake, + Source::All + ), + vec![String::from("PROJ-1170")], + "a closing keyword OVERRIDES the branch" + ); + assert_eq!( + claimed_from("user/proj-843-x", "", "", "", &Fake, Source::All), + vec![String::from("PROJ-843")], + "failing that, the branch" + ); + assert_eq!( + claimed_from("", "", "Refs: PROJ-1170\n", "", &Fake, Source::All), + vec![String::from("PROJ-1170")], + "failing that, the first key of a `Refs:` trailer" + ); + } } diff --git a/crates/batten/src/ready.rs b/crates/batten/src/ready.rs index 1854490ac..b7b5398dd 100644 --- a/crates/batten/src/ready.rs +++ b/crates/batten/src/ready.rs @@ -319,6 +319,8 @@ pub struct Grammar { defer_verb: Regex, key: Regex, closing_verb: Regex, + /// A negator sitting on the closing verb, which unmakes the claim. + closing_negation: Regex, mention_markup: Regex, } @@ -478,6 +480,7 @@ impl Grammar { pressure_test_reviews: Vec::new(), key: find("ready-issue-key")?, closing_verb: find("ready-closing-verb")?, + closing_negation: find("ready-closing-negation")?, mention_markup: find("ready-issue-mention-markup")?, }) } @@ -704,6 +707,29 @@ impl Grammar { keys } + /// Does `prefix` end in a closing verb that is NOT negated? + /// + /// **Two rows rather than one cleverer row, because Rust's regex has no + /// lookbehind** (CLOUD-1752). `ready-closing-verb` is anchored at the end so + /// it decides the text immediately before a key — which is precisely why it + /// reads `does not close CLOUD-1` as a claim: the prefix ends in `close` and + /// the negation is one word further back, outside an end-anchored pattern's + /// reach. + /// + /// So the verb is LOCATED rather than merely detected, and the text before it + /// is asked the second question. Measured over 713 merged pull requests, six + /// rows read as closed by bodies that say in so many words that they do not + /// close them. + fn closes_rather_than_disclaims(&self, prefix: &str) -> bool { + let Some(verb) = self.closing_verb.find(prefix) else { + return false; + }; + // `find` yields the leftmost match, and the pattern's own leading + // `(^|[^0-9A-Za-z-])` may eat the separator before the verb — so the + // negation is asked about everything up to where the match began. + !self.closing_negation.is_match(&prefix[..verb.start()]) + } + /// The keys a span names in CLOSING form — the ones a merge will move. /// /// **Naming a key and closing one are different facts, and conflating them is @@ -723,7 +749,7 @@ impl Grammar { .key .find_iter(text) .filter(|m| opens_a_key(text, m.start()) && closes_a_key(text, m.end())) - .filter(|m| self.closing_verb.is_match(&text[..m.start()])) + .filter(|m| self.closes_rather_than_disclaims(&text[..m.start()])) .map(|m| m.as_str()) .collect(); let mut keys: Vec = found.into_iter().map(|k| IssueKey(k.to_owned())).collect(); diff --git a/crates/batten/src/record.rs b/crates/batten/src/record.rs index dd3fabb1c..fe90878f7 100644 --- a/crates/batten/src/record.rs +++ b/crates/batten/src/record.rs @@ -54,7 +54,7 @@ //! in this family for a secret to appear. use std::io::Read as _; -use std::path::Path; +use std::path::{Path, PathBuf}; use anyhow::{Context as _, Result}; @@ -195,12 +195,21 @@ pub fn run_forge(reference: &str, _overrides: &Overrides) -> Result { /// Whatever the chosen sub-verb returns: a [`UsageError`] for an id or ref that /// resolves to nothing, an unreadable subject, or a malformed verdict line, and /// an internal error when the store cannot be written. -pub fn run(command: crate::cli::RecordCommand, overrides: &Overrides) -> Result { +pub fn run( + command: crate::cli::RecordCommand, + overrides: &Overrides, + out: &mut dyn std::io::Write, +) -> Result { match command { crate::cli::RecordCommand::Tool { id } => run_tool(&id, overrides), crate::cli::RecordCommand::Forge { reference } => run_forge(&reference, overrides), crate::cli::RecordCommand::Plan => run_plan(), crate::cli::RecordCommand::Closes => run_closes(overrides), + crate::cli::RecordCommand::Named { family } => run_named(&family), + crate::cli::RecordCommand::Keyed { family, key } => run_keyed(&family, &key), + crate::cli::RecordCommand::Journal { family } => run_journal(&family), + crate::cli::RecordCommand::Show { family, key } => run_keyed_show(&family, &key, out), + crate::cli::RecordCommand::Fold { family } => run_journal_show(&family, out), } } @@ -372,3 +381,216 @@ pub fn run_plan() -> Result { )?; Ok(ExitCode::Success) } + +// --- the two store families a task can reach (CLOUD-1713) -------------------- +// +// Three programs (841 lines) each hand-rolled a durable keyed store under +// `.git/` while the engine shipped both shapes with no door to either. These are +// the doors: `keyed` is put/hit, `journal` is append-and-fold. +// +// THE COST CLASS IS NOT WIDENED, and this is the answer §2 asks for in one +// sentence: `check` is `Cost::Read` and structurally cannot spawn or write, so a +// record reaches a read-classed surface exactly as `validator-verdict-clean`'s +// already does — a SEPARATE PRODUCER VERB writes it, and `verify` runs that verb +// before the gates. The producer here is `record keyed` / `record journal` +// (`Effect::Write`); the consumer is `show record` / `show journal` +// (`Effect::Read`) or a fact. Nothing on the read path writes. + +/// Where the keyed put/hit family stores its records. +const KEYED_STORE: &str = "batten-records"; + +/// Where the append-and-fold family stores its shards. +const JOURNAL_STORE: &str = "batten-journals"; + +/// A family name that cannot escape its store. +/// +/// **A path component, checked rather than trusted.** The family and the key both +/// reach this from a caller's argv, and a `..` or a `/` in either would put a +/// record outside the store the reader looks in — which is not a security +/// boundary here so much as a silent miss: the write succeeds, the read finds +/// nothing, and the gate reads clean. +fn safe_component(what: &str, value: &str) -> Result { + let clean = value.trim(); + if clean.is_empty() + || clean == "." + || clean == ".." + || clean.contains('/') + || clean.contains('\\') + || clean.contains('\0') + { + return Err(UsageError::raise(format!( + "the {what} must be one path component and must not be `.`, `..`, or contain a separator" + ))); + } + Ok(clean.to_owned()) +} + +/// Record one named family under this branch, read from stdin. +/// +/// **The POLICY-readable store, which is a different store from the two below.** +/// `record keyed`/`record journal` write task stores that `record show`/`record +/// fold` read back; this writes through [`crate::recorder::record_path`], which +/// is the store [`crate::facts::Fact::Records`] projects onto +/// `input.tree.records.`. A module reads what this writes; nothing reads +/// what those write except the task that wrote it. Keeping them apart is why +/// this is a third verb rather than a flag on one of them: the two stores have +/// different keys, different readers and different lifetimes. +/// +/// **One verb, not one per measurement** (CLOUD-1717). Nine programs in that wave +/// are measurements rather than gates — the `gh` call stays outside per +/// house-style §5 and only the adjudication moves in — so each needs a producer +/// that writes a record a module can read. Nine bespoke verbs would be nine +/// spellings of `run_plan` with the validation removed, which is the duplication +/// the retirement campaign exists to delete rather than to relocate. +/// +/// **NO VALIDATION OF THE LINES, deliberately.** [`run_plan`] refuses an unknown +/// status because a plan entry has a closed vocabulary this binary owns. A +/// measurement's shape is the module's business, and a second reading here would +/// be the two-authorities-over-one-fact defect: the module already has to decide +/// what a malformed line means, and a writer that pre-judged it would make the +/// module's own arm unreachable. +/// +/// # Errors +/// +/// A [`UsageError`] when the family is not a single path component, when the +/// repository has no branch to key on, or when this is not a git repository; an +/// internal error when the store cannot be written. +pub fn run_named(family: &str) -> Result { + let family = safe_component("family", family)?; + let raw = verdict_lines()?; + let root = Path::new("."); + let git_dir = git::git_dir(root).map_err(|_| { + UsageError::raise( + "record named: not a git repository, so there is nothing to key on".to_owned(), + ) + })?; + let Ok(Some(branch)) = git::current_branch(root) else { + return Err(UsageError::raise( + "record named: a detached HEAD has no branch to key the record on".to_owned(), + )); + }; + let claim = claim_of(&git_dir, &branch); + store( + &crate::recorder::record_path(&git_dir, &family, &branch, claim.as_deref()), + &raw, + )?; + Ok(ExitCode::Success) +} + +/// The record path for one (family, key) pair. +/// +/// Keyed by a digest of the key rather than by the key itself, on +/// [`crate::review::record_path`]'s reason one store over: the key is a caller's +/// string and may be any length or hold any byte, and a digest is a filename on +/// every platform. The key is not recoverable from the path, which is the +/// pointer-only posture rule 4 asks for anyway. +fn keyed_path(git_dir: &Path, family: &str, key: &str) -> PathBuf { + git_dir + .join(KEYED_STORE) + .join(family) + .join(crate::tools::digest(key.as_bytes())) +} + +/// Put one value into the keyed family, read from stdin. +/// +/// # Errors +/// +/// A [`UsageError`] when the family or key is not a single path component; an +/// internal error when the store cannot be written. +pub fn run_keyed(family: &str, key: &str) -> Result { + let family = safe_component("family", family)?; + let value = verdict_lines()?; + let git_dir = git::git_dir(Path::new("."))?; + store(&keyed_path(&git_dir, &family, key), &value)?; + Ok(ExitCode::Success) +} + +/// Append one record to the journal family, read from stdin. +/// +/// **Reuses [`crate::journal::append_line`] rather than opening a file here**, +/// which is CLOUD-1713's §2 in one call: the durability barrier, the one-writer +/// shard rule and the persist-before-emit order all live in that function, and a +/// second append path beside it is precisely the defect this row removes. +/// +/// The shard is per worktree, via [`crate::journal::shard_id`] — `reclaim-census` +/// keyed its single shard by boot id instead, which is a caller's choice of key +/// rather than a different mechanism. +/// +/// # Errors +/// +/// A [`UsageError`] when the family is not a single path component or the record +/// is blank — an empty append is a caller with nothing to say, and recording it +/// would put a record in the log that no fold can distinguish from a torn one. +/// An internal error when the shard cannot be written or synced. +pub fn run_journal(family: &str) -> Result { + let family = safe_component("family", family)?; + let record = verdict_lines()?; + let record = record.trim(); + if record.is_empty() { + return Err(UsageError::raise(String::from( + "the record is empty; an empty append is not a record", + ))); + } + if record.contains('\n') { + return Err(UsageError::raise(String::from( + "a record is one line; a multi-line append would fold back as several records", + ))); + } + let git_dir = git::git_dir(Path::new("."))?; + let store_dir = git_dir.join(JOURNAL_STORE).join(&family); + let shard = crate::journal::shard_id(Path::new(".")); + crate::journal::append_line(&store_dir, &shard, record)?; + Ok(ExitCode::Success) +} + +/// Read one keyed record back: `hit` and the value, or `miss`. +/// +/// **A miss is exit 0 and the discrimination comes off STDOUT**, which is +/// `checks-green`'s shape and is deliberate. The engine's contract makes exit 2 a +/// VIOLATION, and a cache miss is not a violation — it is the ordinary answer that +/// says *run the step*. A caller reading only the code holds either way; the one +/// caller that needs the difference reads the line. +/// +/// # Errors +/// +/// A [`UsageError`] when the family or key is not a single path component; an +/// internal error when the git directory cannot be resolved. +pub fn run_keyed_show(family: &str, key: &str, out: &mut dyn std::io::Write) -> Result { + let family = safe_component("family", family)?; + let git_dir = git::git_dir(Path::new("."))?; + match std::fs::read_to_string(keyed_path(&git_dir, &family, key)) { + Ok(value) => { + writeln!(out, "hit")?; + write!(out, "{value}")?; + } + // ABSENT IS A MISS, and it is the only reading here: a record that exists + // and holds nothing is a hit carrying an empty value, because the producer + // chose to record that. + Err(_) => writeln!(out, "miss")?, + } + Ok(ExitCode::Success) +} + +/// Fold a journal family: `nothing`, the records, or `unreadable `. +/// +/// # Errors +/// +/// A [`UsageError`] when the family is not a single path component; an internal +/// error when the git directory cannot be resolved. +pub fn run_journal_show(family: &str, out: &mut dyn std::io::Write) -> Result { + let family = safe_component("family", family)?; + let git_dir = git::git_dir(Path::new("."))?; + let store_dir = git_dir.join(JOURNAL_STORE).join(&family); + match crate::journal::fold_lines(&store_dir) { + crate::journal::Fold::Nothing => writeln!(out, "nothing")?, + crate::journal::Fold::Records(records) => { + for record in records { + writeln!(out, "{record}")?; + } + } + // A PATH IS A POINTER (§6 names `path:line` outright), so naming the + // store a reader could not open is rule 4 satisfied rather than breached. + crate::journal::Fold::Unreadable(path) => writeln!(out, "unreadable {}", path.display())?, + } + Ok(ExitCode::Success) +} diff --git a/crates/batten/src/rest.rs b/crates/batten/src/rest.rs index 31dd3b944..52a130955 100644 --- a/crates/batten/src/rest.rs +++ b/crates/batten/src/rest.rs @@ -285,6 +285,21 @@ pub struct Answer { pub backoff: Option, /// The response body, as text. pub body: String, + /// Every header the response carried, keyed by LOWERCASE name. + /// + /// **The four typed fields above are readings this tier makes; this is the + /// rest of what it already read.** `fetch::Response` holds the whole block + /// and `canned` parses the whole block, so before this field the transport + /// was discarding headers it had in hand — which is why `gh-preflight` still + /// shelled out to `gh api -i` to read `X-Accepted-GitHub-Permissions` off a + /// 403. That is the second header parser this module's own header says the + /// typed boundary retired, standing again one endpoint over. + /// + /// Lowercase because `fetch::Response` lowercases every name it read, and + /// matching a mixed-case literal against that map finds nothing and reads as + /// *the header was absent* — the three-valued mistake CLOUD-390 records. + /// [`Answer::header`] folds the case so no caller has to remember. + pub headers: std::collections::BTreeMap, } impl Answer { @@ -335,6 +350,25 @@ impl Answer { pub const fn answered(&self) -> bool { self.status == 200 || self.status == 304 } + + /// One header by name, case-insensitively, or `None` where it was absent. + /// + /// **Works on a REFUSAL as well as a reading, and that is the point rather + /// than a side effect.** The caller this exists for reads + /// `X-Accepted-GitHub-Permissions` off a `403` to name the claim a token is + /// missing — a status [`Answer::is_reading`] excludes and + /// [`Answer::answered`] excludes too. Gating header access on either would + /// leave exactly the case that needs it unable to ask. + /// + /// `None` is *the response did not carry this header*. It is not + /// could-not-look: an [`Answer`] exists only where the exchange completed, + /// and the could-not-look channel is [`get`] returning `None`. + #[must_use] + pub fn header(&self, name: &str) -> Option<&str> { + self.headers + .get(&name.to_ascii_lowercase()) + .map(String::as_str) + } } /// One GET against the REST tier, or `None` where it could not be reached. @@ -457,8 +491,20 @@ fn canned(raw: &str, now: u64) -> Answer { (key.trim().eq_ignore_ascii_case(name)).then(|| value.trim().to_owned()) }) }; + // THE WHOLE BLOCK, lowercased, so a fixture answers `header` exactly as a + // live exchange does. A fixture that carried fewer headers than the wire + // would make the one caller reading an arbitrary header untestable offline, + // which is the shape of a gate that is only exercised in production. + let headers = lines + .clone() + .filter_map(|line| { + let (key, value) = line.split_once(':')?; + Some((key.trim().to_ascii_lowercase(), value.trim().to_owned())) + }) + .collect(); Answer { status, + headers, etag: header("etag"), poll_floor: header("x-poll-interval") .and_then(|raw| raw.trim().parse::().ok()) @@ -492,6 +538,13 @@ fn exchange(path: &str, etag: Option<&str>, body: Option<&[u8]>) -> Option\\t` rows", + // `data_channel` is exactly "declares `-J`", which + // `every_data_emitting_verb_declares_the_json_flag` pins in both + // directions — it is not the wider claim the header paragraph makes + // about stdout being data rather than a verdict. Two-column rows are + // this family's shape and `claim keys` beside it says the same, so the + // answer is the line shape, not a second encoding of it. + data_channel: false, + exits: EXITS_STANDARD, + effect: Effect::Read, + flags: &[MERGED_LIMIT], + }, + CommandDecl { + path: "claim keys", + id: "claim.keys", + about: "The issue keys this branch CLAIMS, as distinct from the ones it merely mentions", + data_channel: false, + exits: EXITS_STANDARD, + effect: Effect::Read, + flags: &[ + CLAIM_BRANCH, + CLAIM_TITLE, + CLAIM_LOG, + CLOSING_ONLY, + REFS_FIRST_ONLY, + ], + }, CommandDecl { path: "claim check", id: "claim.check", @@ -4743,6 +4890,92 @@ pub const SURFACE: &[CommandDecl] = &[ // No positional: the branch is the key and the engine resolves it, so a // caller cannot record against a branch it is not on — `record tool`'s // anti-staleness argument, applied to a different key. + // CLOUD-1713's two doors. Three programs (841 lines) hand-rolled a durable + // store under `.git/` because the engine's own two shapes — keyed put/hit and + // append-and-fold — had no leaf. LEAVES UNDER `record` rather than new nouns: + // CLOUD-1546 counts 42 top-level rows and CLOUD-1182 records nine ports + // becoming nine nouns, and a store family is an object this verb records, not + // a verb of its own. + // CLOUD-1717's producer door. Nine programs in that wave are MEASUREMENTS + // rather than gates: house-style §5 keeps the `gh` spawn outside the engine + // and moves only the adjudication in, so each needs a producer writing a + // record a module can read. + // + // THE POLICY STORE, WHICH IS NOT THE TWO BELOW. This writes through + // `recorder::record_path`, the store `Fact::Records` projects onto + // `input.tree.records.`; `record keyed`/`record journal` write task + // stores that only `record show`/`record fold` read back. Same noun, two + // different readers, so they are different leaves rather than one leaf with + // a mode flag. + // + // ONE LEAF FOR NINE PRODUCERS. Nine bespoke verbs would each be `record + // plan` with its validation removed — the duplication this campaign deletes + // rather than relocates. + CommandDecl { + path: "record named", + id: "record.named", + about: "Record one named family under this branch, read from stdin", + data_channel: false, + exits: EXITS_STANDARD, + effect: Effect::Write, + flags: &[FlagDecl::positional( + "family", + "The record family, which is the key a module reads it under", + )], + }, + CommandDecl { + path: "record keyed", + id: "record.keyed", + about: "Put one value into a keyed store family, read from stdin", + data_channel: false, + exits: EXITS_STANDARD, + effect: Effect::Write, + flags: &[ + FlagDecl::positional("family", "The store family the record belongs to"), + FlagDecl::positional("key", "The key the record is filed under"), + ], + }, + CommandDecl { + path: "record journal", + id: "record.journal", + about: "Append one record to an append-and-fold store family, read from stdin", + data_channel: false, + exits: EXITS_STANDARD, + effect: Effect::Write, + flags: &[FlagDecl::positional( + "family", + "The store family the record belongs to", + )], + }, + // The READ half of CLOUD-1713's two families, and they stay under `record` + // on `capture show`'s precedent: `record` is a store NOUN and is already + // `unclassified` because the subtree writes, so a read leaf under it neither + // leaks onto the derived agent allowlist nor needs a noun of its own. The + // producer/consumer split is still §2's answer to "how does a + // read-classed surface obtain a record without gaining a write" — the producer + // is a separate verb, exactly as `record tool` already is for + // `validator-verdict-clean`. + CommandDecl { + path: "record show", + id: "record.show", + about: "Read one keyed record back: `hit` and the value, or `miss`", + data_channel: false, + exits: EXITS_STANDARD, + effect: Effect::Read, + flags: &[ + FlagDecl::positional("family", "The store family to read"), + FlagDecl::positional("key", "The key to look under"), + ], + }, + CommandDecl { + path: "record fold", + id: "record.fold", + about: "Fold a journal family: `nothing`, its records, or `unreadable `", + data_channel: false, + exits: EXITS_STANDARD, + effect: Effect::Read, + flags: &[FlagDecl::positional("family", "The store family to fold")], + }, CommandDecl { path: "record plan", id: "record.plan", diff --git a/crates/batten/tests/it/claimed_keys.rs b/crates/batten/tests/it/claimed_keys.rs new file mode 100644 index 000000000..200c95cab --- /dev/null +++ b/crates/batten/tests/it/claimed_keys.rs @@ -0,0 +1,293 @@ +//! `claim keys` and `claim merged`, over the compiled binary (CLOUD-1711, CLOUD-1752). +//! +//! # RETIREMENT LEDGER, PER PATH — what `shell-retirement` reads +//! +//! A program and its suite are TWO rows, never one. + +// THE DRAIN'S TWO GATHER CASES, PORTED (CLOUD-1711). Both stubbed +// `mise-tasks/merged-pr-keys.sh` beside the real gates to drive the drain's +// no-`DRAIN_MERGED_PRS` path. That program is retired and the drain reaches +// `batten claim merged`, so the stub would have to shadow the `batten` binary — +// behaviour in a governed suite rather than a fixture precondition, which +// `shell edit refused` will not admit and should not. +// +// `ported` RATHER THAN `withdrawn`, and the subject is why: `in-progress-drain.sh` +// SURVIVES, so `died()` cannot hold and a withdrawal has nothing to name. The +// property each case carried — the gather answering with rows, and the gather +// refusing a truncated or empty forge answer — is asserted here over the leaf +// itself, which is what makes this a port. +// +// WHAT DID NOT MOVE, said plainly: the drain's TRANSLATION of that refusal into +// its own exit 2 rather than a clean short sweep. It belongs to the drain's own +// port, and naming it here is what stops a reader assuming the half they cannot +// see is still covered. +// ported: "with no DRAIN_MERGED_PRS the drain gathers evidence rather than refusing" crates/batten/tests/it/claimed_keys.rs subject:mise-tasks/in-progress-drain.sh +// ported: "a failed gather is could-not-look, never a short sweep" crates/batten/tests/it/claimed_keys.rs subject:mise-tasks/in-progress-drain.sh +// carried: mise-tasks/claimed-keys.sh crates/batten/src/race.rs kind:mechanism crates/batten/tests/it/claimed_keys.rs runs:batten+claim+keys +// carried: tests/claimed-keys.bats crates/batten/src/race.rs kind:mechanism crates/batten/tests/it/claimed_keys.rs +// carried: mise-tasks/merged-pr-keys.sh crates/batten/src/forge.rs kind:mechanism crates/batten/tests/it/claimed_keys.rs runs:batten+claim+merged +// carried: tests/merged-pr-keys.bats crates/batten/src/forge.rs kind:mechanism crates/batten/tests/it/claimed_keys.rs + +// --- claimed-keys.bats, case by case --------------------------------------- +// carried: "a branch naming one issue is an unambiguous claim" crates/batten/src/race.rs kind:mechanism +// carried: "a closing keyword on stdin overrides the branch" crates/batten/src/race.rs kind:mechanism +// carried: "a closing keyword in a commit overrides the branch too" crates/batten/src/race.rs kind:mechanism +// carried: "a merely mentioned issue is not a claim" crates/batten/src/race.rs kind:mechanism +// carried: "a Refs: trailer claims when nothing more explicit does" crates/batten/src/race.rs kind:mechanism +// carried: "nothing resolvable is an empty answer, not an error" crates/batten/src/race.rs kind:mechanism +// carried: "outside a git checkout it exits 0 and says nothing" crates/batten/src/race.rs kind:mechanism +// carried: "the answer is uppercased and deduplicated" crates/batten/src/race.rs kind:mechanism +// carried: "output is the keys alone — never the prose they came from" crates/batten/src/race.rs kind:mechanism +// carried: "an explicit branch answers instead of the checkout's" crates/batten/src/race.rs kind:mechanism +// carried: "an explicit title is a claim, the way a branch is" crates/batten/src/race.rs kind:mechanism +// carried: "branch and title are a union, not a precedence between them" crates/batten/src/race.rs kind:mechanism +// carried: "a body that merely CITES a key claims nothing — the measured case" crates/batten/src/race.rs kind:mechanism +// carried: "a closing keyword in an explicit body still overrides branch and title" crates/batten/src/race.rs kind:mechanism +// carried: "an explicit log supplies the Refs: trailer, and only the trailer" crates/batten/src/race.rs kind:mechanism +// carried: "a key merely cited in an explicit log claims nothing" crates/batten/src/race.rs kind:mechanism +// carried: "explicit mode is all-or-nothing — an unsupplied source is empty, never local" crates/batten/src/race.rs kind:mechanism +// carried: "a key carried only by a speculated commit is not claimed" crates/batten/src/race.rs kind:mechanism +// carried: "a key this branch authored is still claimed with a speculation live" crates/batten/src/race.rs kind:mechanism +// carried: "with no speculation live the answer is exactly what it was" crates/batten/src/race.rs kind:mechanism +// carried: "a spec base that is not an ancestor of HEAD is ignored" crates/batten/src/race.rs kind:mechanism +// carried: "--closing-only does not fall through to a Refs: trailer" crates/batten/src/race.rs kind:mechanism +// carried: "--closing-only does not fall through to the branch name" crates/batten/src/race.rs kind:mechanism +// carried: "--closing-only still answers on a closing keyword" crates/batten/src/race.rs kind:mechanism +// carried: "without --closing-only the fallback chain is unchanged" crates/batten/src/race.rs kind:mechanism +// carried: "--closing-only reads the log from stdin, which is how a 1.27MB history fits" crates/batten/src/race.rs kind:mechanism +// carried: "--refs-first-only ignores a closing keyword in the body" crates/batten/src/race.rs kind:mechanism +// carried: "--refs-first-only takes the first key of the trailer, not its citations" crates/batten/src/race.rs kind:mechanism +// carried: "--refs-first-only ignores the branch name too" crates/batten/src/race.rs kind:mechanism +// carried: "--refs-first-only with no trailer answers empty, which is 'do not judge'" crates/batten/src/race.rs kind:mechanism +// changed: "a flag with no value is exit 2, never a silently empty source" crates/batten/src/race.rs kind:mechanism the engine parses its own arguments, so a value-less flag is refused by the parser before this verb runs, at Usage (1) rather than the shell's 2 — the contract inversion this campaign deliberately does not carry across +// changed: "an unknown argument is exit 2, and names no prose" crates/batten/src/race.rs kind:mechanism same route: the parser refuses an unknown flag at Usage (1). It still names no prose, which is the half that was about rule 4 rather than about the code +// changed: "the two narrowing flags are mutually exclusive" crates/batten/src/race.rs kind:mechanism carried as a decision and re-coded: `run_claim_merged`'s sibling `run_claim_keys` refuses both flags at Usage (1), not the shell's 2 + +// --- merged-pr-keys.bats, case by case -------------------------------------- +// carried: "a closing keyword in a merged body emits one row" crates/batten/src/forge.rs kind:mechanism +// carried: "Fixes and Resolves are claims too" crates/batten/src/forge.rs kind:mechanism +// carried: "a Refs: trailer is a mention and emits nothing" crates/batten/src/forge.rs kind:mechanism +// carried: "a bare citation in prose emits nothing" crates/batten/src/forge.rs kind:mechanism +// carried: "several keys in one body emit several rows, all keyed to that PR" crates/batten/src/forge.rs kind:mechanism +// carried: "a null body is data, not a crash" crates/batten/src/forge.rs kind:mechanism +// carried: "two runs over the same reading are byte-identical" crates/batten/src/forge.rs kind:mechanism +// carried: "a reading at the fetch limit is refused as truncated, not returned short" crates/batten/src/forge.rs kind:mechanism +// carried: "a reading below the fetch limit is answered" crates/batten/src/forge.rs kind:mechanism +// carried: "an empty forge answer is could-not-look, never an empty evidence file" crates/batten/src/forge.rs kind:mechanism +// carried: "output carries no PR body" crates/batten/src/forge.rs kind:mechanism +// changed: "an unreadable source is exit 2" crates/batten/src/forge.rs kind:mechanism the `MERGED_PR_KEYS_SOURCE` file seam is gone: the transport is `forge::window`, whose test seam is an injected `Transport` rather than a saved payload path. An unreadable forge is `Window::CouldNotLook` and exits 3 — fail loud, do not block — where the shell used its own 2 +// changed: "a source that is not a JSON array is exit 2" crates/batten/src/forge.rs kind:mechanism same seam. A body that will not parse is `Window::CouldNotLook`, exit 3; the decision (unparseable is could-not-look, never an empty collection) is carried verbatim +// changed: "a non-numeric limit is a caller bug, not a default" crates/batten/src/forge.rs kind:mechanism carried as a decision at a different code: `--limit` that is not a positive number is Usage (1), never silently the default + +//! # Why these cases +//! +//! The discriminating case is the CITATION TRAP, and it is what the retired +//! program existed for: a body cites related issues, prior measurements and +//! superseded work as evidence, and reading a citation as a claim made a pull +//! request race the very key it cited. Both sides of every comparison go through +//! one function for exactly that reason. +//! +//! The second is TRUNCATION. `merged-pr-keys` refused a reading at its fetch +//! limit rather than answering short, because a truncated evidence file makes +//! landed work read as live — measured at `--limit 400`, which returned exactly +//! 400 and hid three pull requests. + +// 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 common::{git_in, init_repo, run_with_stdin, scratch, stderr, stdout, write}; + +/// A repository whose branch names a key, with one commit carrying a trailer. +/// +/// **Carries this repository's OWN `batten.toml`**, because the decisions under +/// test are the consumer's: which token is a key, which verb closes one, and +/// which negates it all live in `[[pattern]]` rows. A fixture with a hand-written +/// subset would assert against a grammar no checkout has, which is the fabricated +/// shape CLOUD-845 records one layer up. +fn repo(name: &str, branch: &str) -> std::path::PathBuf { + let dir = scratch(&format!("claim-keys-{name}")); + let config = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("..") + .join("..") + .join("batten.toml"); + std::fs::copy(&config, dir.join("batten.toml")).expect("the committed config is readable"); + write(&dir, "seed.txt", "seed\n"); + // The TEMPLATE, never a fork (CLOUD-1419), for `record_families.rs`'s reason. + init_repo(&dir); + git_in(&dir, &["add", "-A"]); + git_in( + &dir, + &[ + "commit", + "-qm", + "chore: seed\n\nRefs: CLOUD-4242, CLOUD-9\n", + ], + ); + if !branch.is_empty() { + git_in(&dir, &["checkout", "-q", "-b", branch]); + } + dir +} + +fn keys(dir: &std::path::Path, args: &[&str], stdin: &str) -> std::process::Output { + let mut command = vec!["claim", "keys"]; + command.extend_from_slice(args); + run_with_stdin(dir, &command, stdin) +} + +#[test] +fn a_body_that_cites_a_key_without_closing_it_does_not_claim_it() { + // THE MEASURED CASE, and the whole reason the program existed. PR #306 cited + // CLOUD-133 in one row of an evidence table and was reported as claiming it. + let dir = repo("cites", ""); + let out = keys( + &dir, + &["--branch", "", "--title", "", "--log", ""], + "Supersedes the measurement in CLOUD-133.\n", + ); + assert_eq!(out.status.code(), Some(0), "{}", stderr(&out)); + assert_eq!(stdout(&out).trim(), "", "citing is not claiming"); +} + +#[test] +fn a_closing_keyword_in_the_body_is_a_claim() { + // The anti-vacuity mirror: without it, a verb that answered empty for every + // input would pass the citation case. + let dir = repo("closes", ""); + let out = keys( + &dir, + &["--branch", "", "--title", "", "--log", ""], + "Closes CLOUD-133.\n", + ); + assert_eq!(stdout(&out).trim(), "CLOUD-133"); +} + +#[test] +fn branch_and_title_are_a_union_rather_than_a_precedence() { + // Two spellings of ONE self-declaration. Picking one would make the answer + // depend on which the author happened to fill in. + let dir = repo("union", ""); + let out = keys( + &dir, + &[ + "--branch", + "user/cloud-1-thing", + "--title", + "a fix (CLOUD-2)", + "--log", + "", + ], + "", + ); + let answer = stdout(&out); + assert!(answer.contains("CLOUD-1"), "{answer}"); + assert!(answer.contains("CLOUD-2"), "{answer}"); +} + +#[test] +fn explicit_mode_is_all_or_nothing_and_never_falls_back_to_the_checkout() { + // A remote pull request silently answered from the LOCAL branch is the worst + // kind of wrong: a confident verdict about the wrong repository state. + let dir = repo("explicit", "user/cloud-999-local"); + let out = keys(&dir, &["--title", "a title with no key"], ""); + assert_eq!( + stdout(&out).trim(), + "", + "an unsupplied source is EMPTY in explicit mode, never the checkout's branch" + ); +} + +#[test] +fn the_two_narrowing_flags_are_mutually_exclusive() { + // Each names a different SINGLE source, so both together is a caller that has + // not decided which question it is asking. USAGE (1), not the shell's 2 — the + // contract inversion this campaign does not carry across. + let dir = repo("both-flags", ""); + let out = keys(&dir, &["--closing-only", "--refs-first-only"], ""); + assert_eq!(out.status.code(), Some(1), "{}", stderr(&out)); +} + +#[test] +fn refs_first_only_ignores_a_closing_keyword_in_the_body() { + // CLOUD-674's circularity: the SERVED set must be derived without reference + // to the closing keys, or it agrees with the body by construction and + // `closing-key-check` passes on exactly the bodies it must refuse. + let dir = repo("refs-only", ""); + let out = keys( + &dir, + &[ + "--refs-first-only", + "--branch", + "", + "--title", + "", + "--log", + "Refs: CLOUD-77, CLOUD-88\n", + ], + "Closes CLOUD-99.\n", + ); + let answer = stdout(&out); + assert!( + answer.contains("CLOUD-77"), + "the trailer's FIRST key\n{answer}" + ); + assert!( + !answer.contains("CLOUD-88"), + "citing after it is not claiming\n{answer}" + ); + assert!( + !answer.contains("CLOUD-99"), + "source 1 must not answer here\n{answer}" + ); +} + +#[test] +fn closing_only_never_falls_through_to_the_branch_or_a_trailer() { + let dir = repo("closing-only", ""); + let out = keys( + &dir, + &[ + "--closing-only", + "--branch", + "user/cloud-5-x", + "--title", + "", + "--log", + "Refs: CLOUD-6\n", + ], + "", + ); + assert_eq!(stdout(&out).trim(), "", "{}", stderr(&out)); +} + +#[test] +fn outside_a_git_checkout_it_says_nothing_and_does_not_fail() { + // Every caller reads "no claim" as "do not judge", because a guard that + // guesses is one that blocks correct work. + // OUTSIDE THE REPOSITORY'S OWN TREE, deliberately: `scratch` lives under + // `target/`, so git discovery walks up and finds THIS checkout — which is the + // opposite of what this case is about. Measured: it answered with this + // branch's own keys. + let dir = std::env::temp_dir().join("batten-claim-keys-nogit"); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).expect("a directory outside any checkout"); + let config = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("..") + .join("..") + .join("batten.toml"); + std::fs::copy(&config, dir.join("batten.toml")).expect("the committed config is readable"); + let out = keys(&dir, &[], ""); + assert_eq!(out.status.code(), Some(0), "{}", stderr(&out)); + assert_eq!(stdout(&out).trim(), ""); +} + +#[test] +fn a_non_numeric_merged_limit_is_a_caller_bug_rather_than_the_default() { + // Silently falling back to 5000 would answer a different question than the + // caller asked, and the answer would look authoritative. + let dir = repo("merged-limit", ""); + let out = run_with_stdin(&dir, &["claim", "merged", "--limit", "lots"], ""); + assert_eq!(out.status.code(), Some(1), "{}", stderr(&out)); +} diff --git a/crates/batten/tests/it/forge_window.rs b/crates/batten/tests/it/forge_window.rs new file mode 100644 index 000000000..e701996f7 --- /dev/null +++ b/crates/batten/tests/it/forge_window.rs @@ -0,0 +1,284 @@ +//! `forge::window`: one paginated read, and truncation is a verdict (CLOUD-1712). +//! +//! # RETIREMENT LEDGER, PER PATH — what `shell-retirement` reads +//! +//! Nothing is retired by this tier yet: this row builds the primitive and the +//! seven retirements that consume it are separate rows. The arms land with them. +//! +//! # Why these cases and not a happy path +//! +//! The discriminating case is the one three shell programs got wrong. A window +//! that returns the rows it managed to read is green on every test a happy path +//! can write — the collection comes back, the rows parse, the reduction runs — +//! and it is wrong in exactly the way `timeout-drift` is wrong today. So the +//! anti-vacuity case (CLOUD-418) is `a_collection_larger_than_the_window_is_truncated`, +//! and its mirror asserts the same walk over a collection that FITS returns +//! `Whole`, because a `window` that answered `Truncated` unconditionally would +//! also pass the first. + +use std::path::{Path, PathBuf}; + +use batten::forge::{Shape, Window}; + +/// A canned page: a status, an `ETag`, and a body. +#[derive(Clone)] +struct Page { + status: u16, + etag: Option<&'static str>, + body: &'static str, +} + +/// A transport that serves `pages` in order and records what it was asked. +/// +/// Records the validator sent with each request, because the conditional read +/// IS the economy this layer exists to take — a walk that stopped sending +/// `If-None-Match` would still pass every row-count assertion here. +struct Canned { + pages: Vec, + calls: std::cell::RefCell)>>, +} + +impl Canned { + fn new(pages: &[Page]) -> Self { + Self { + pages: pages.to_vec(), + calls: std::cell::RefCell::new(Vec::new()), + } + } + + fn answer(&self, path: &str, etag: Option<&str>) -> Option { + let mut calls = self.calls.borrow_mut(); + let page = self.pages.get(calls.len())?.clone(); + calls.push((path.to_owned(), etag.map(str::to_owned))); + let mut headers = std::collections::BTreeMap::new(); + if let Some(validator) = page.etag { + headers.insert(String::from("etag"), validator.to_owned()); + } + Some(batten::rest::Answer { + status: page.status, + etag: page.etag.map(str::to_owned), + poll_floor: None, + backoff: None, + body: page.body.to_owned(), + headers, + }) + } +} + +/// A scratch git directory for the validator store. +fn scratch(name: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!("batten-forge-window-{name}")); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).expect("git dir"); + dir +} + +/// Run one walk over canned pages. +fn windowed(git_dir: &Path, canned: &Canned, path: &str, shape: Shape, max_pages: u32) -> Window { + batten::forge::window_over( + git_dir, + path, + &[("per_page", "2")], + shape, + max_pages, + &|path, etag| canned.answer(path, etag), + ) +} + +#[test] +fn a_collection_larger_than_the_window_is_truncated() { + // THE DISCRIMINATING CASE (CLOUD-418). `total_count` says four; the window + // is one page of two. A reader that returned the two rows it read would be + // indistinguishable from one that read the whole collection, which is + // exactly `timeout-drift`'s defect: it trusts one unpaginated page and + // reports a percentile over a prefix as a percentile over a population. + let git = scratch("truncated"); + let canned = Canned::new(&[Page { + status: 200, + etag: Some("W/\"p1\""), + body: r#"{"total_count": 4, "check_runs": [{"id": 1}, {"id": 2}]}"#, + }]); + let answer = windowed( + &git, + &canned, + "repos/o/r/check-runs", + Shape::Wrapped("check_runs"), + 1, + ); + match answer { + Window::Truncated { read, total, pages } => { + assert_eq!(read, 2, "the truncation names how much it read"); + assert_eq!(total, Some(4), "and what the collection actually holds"); + assert_eq!(pages, 1, "and the budget it spent"); + } + other => panic!("a window short of `total_count` must be Truncated, got {other:?}"), + } +} + +#[test] +fn a_collection_the_window_reaches_the_end_of_is_whole() { + // THE ANTI-VACUITY MIRROR. Without it, a `window` that answered `Truncated` + // unconditionally would pass the case above — so this is what makes that one + // a discrimination rather than a restatement of the return type. + let git = scratch("whole"); + let canned = Canned::new(&[ + Page { + status: 200, + etag: Some("W/\"p1\""), + body: r#"{"total_count": 3, "check_runs": [{"id": 1}, {"id": 2}]}"#, + }, + Page { + status: 200, + etag: Some("W/\"p2\""), + body: r#"{"total_count": 3, "check_runs": [{"id": 3}]}"#, + }, + ]); + let answer = windowed( + &git, + &canned, + "repos/o/r/check-runs", + Shape::Wrapped("check_runs"), + 5, + ); + match answer { + Window::Whole(rows) => assert_eq!(rows.len(), 3, "every row, across both pages"), + other => panic!("a walk that reached `total_count` must be Whole, got {other:?}"), + } +} + +#[test] +fn a_refusal_is_could_not_look_rather_than_an_empty_collection() { + // A 403 carries an ERROR DOCUMENT, which parses to zero rows and is + // byte-identical on the decision surface to a genuinely empty collection. + // That is the false green `Answer::is_reading` records one layer down. + let git = scratch("refused"); + let canned = Canned::new(&[Page { + status: 403, + etag: None, + body: r#"{"message": "Resource not accessible"}"#, + }]); + let answer = windowed( + &git, + &canned, + "repos/o/r/check-runs", + Shape::Wrapped("check_runs"), + 3, + ); + match answer { + Window::CouldNotLook { endpoint, status } => { + assert_eq!(status, Some(403)); + assert_eq!( + endpoint, "repos/o/r/check-runs", + "the pointer is the endpoint" + ); + assert!( + !endpoint.contains("Resource"), + "rule 4: the forge's body never reaches the report" + ); + } + other => panic!("a 403 must be CouldNotLook, got {other:?}"), + } +} + +#[test] +fn a_cold_cache_fetches_and_a_304_reuses_the_stored_body() { + // THE ECONOMY, in both directions. The first walk populates the validator + // store; the second is answered `304` with no body, and must return the + // SAME rows rather than an empty page. `land-divergence`'s hand-rolled + // `conditional_get` returned failure on a 304 with no cached body for this + // reason, and the store is what makes the hit possible at all. + let git = scratch("conditional"); + let canned = Canned::new(&[ + Page { + status: 200, + etag: Some("W/\"v1\""), + body: r#"{"total_count": 1, "check_runs": [{"id": 7}]}"#, + }, + Page { + status: 304, + etag: Some("W/\"v1\""), + body: "", + }, + ]); + let cold = windowed( + &git, + &canned, + "repos/o/r/check-runs", + Shape::Wrapped("check_runs"), + 2, + ); + assert!( + matches!(&cold, Window::Whole(rows) if rows.len() == 1), + "{cold:?}" + ); + + let warm = windowed( + &git, + &canned, + "repos/o/r/check-runs", + Shape::Wrapped("check_runs"), + 2, + ); + match warm { + Window::Whole(rows) => assert_eq!( + rows.len(), + 1, + "a 304 must answer from the store, never as an empty page" + ), + other => panic!("a 304 with a stored body is a reading, got {other:?}"), + } + + let sent = canned.calls.borrow(); + assert_eq!(sent.len(), 2, "one request per walk"); + assert_eq!( + sent[0].1, None, + "the cold walk holds no validator and must send none" + ); + assert_eq!( + sent[1].1.as_deref(), + Some("W/\"v1\""), + "the warm walk must send the stored validator, or the economy is not taken" + ); +} + +#[test] +fn a_bare_array_endpoint_reads_without_a_wrapper_key() { + // The other body shape. `pulls` answers with the array itself and states no + // `total_count`, so the end of the collection is a short page — which is the + // evidence `merged-pr-keys` had and `land-divergence` did not. + let git = scratch("bare"); + let canned = Canned::new(&[Page { + status: 200, + etag: None, + body: r#"[{"number": 1}]"#, + }]); + let answer = windowed(&git, &canned, "repos/o/r/pulls", Shape::Bare, 3); + match answer { + Window::Whole(rows) => assert_eq!(rows.len(), 1), + other => panic!("a short bare page ends the collection, got {other:?}"), + } +} + +#[test] +fn a_full_last_page_with_no_total_is_truncated_rather_than_whole() { + // NO `total_count` AND THE BUDGET SPENT is the ambiguous case, and it + // resolves toward refusing. The collection may or may not continue; a reader + // that guessed "whole" would report a prefix as a population precisely when + // it has the least evidence. `merged-pr-keys` took the same direction + // against its `--limit`. + let git = scratch("bare-full"); + let canned = Canned::new(&[Page { + status: 200, + etag: None, + body: r#"[{"number": 1}, {"number": 2}]"#, + }]); + let answer = windowed(&git, &canned, "repos/o/r/pulls", Shape::Bare, 1); + match answer { + Window::Truncated { read, total, pages } => { + assert_eq!(read, 2); + assert_eq!(total, None, "the endpoint states no count"); + assert_eq!(pages, 1); + } + other => panic!("a full page at the budget with no count is Truncated, got {other:?}"), + } +} diff --git a/crates/batten/tests/it/main.rs b/crates/batten/tests/it/main.rs index 7c2af287c..09f37a52c 100644 --- a/crates/batten/tests/it/main.rs +++ b/crates/batten/tests/it/main.rs @@ -80,6 +80,7 @@ mod claim_carry; mod claim_order; mod claim_race; mod claim_receipt; +mod claimed_keys; mod cli; mod commit; mod commit_admission; @@ -129,6 +130,7 @@ mod fixture_forks; mod fixture_repos; mod forced_push; mod forge_facts; +mod forge_window; mod frontmatter_gates; mod fuzz_corpus; mod gh_guard; @@ -221,6 +223,7 @@ mod rebase; mod receipt_verified; mod reclaim_report_once; mod record_closes; +mod record_families; mod redirect_resolves; mod reference_coverage; mod refusal_ceiling; diff --git a/crates/batten/tests/it/pointer_only.rs b/crates/batten/tests/it/pointer_only.rs index cc6d276fb..d5b410be0 100644 --- a/crates/batten/tests/it/pointer_only.rs +++ b/crates/batten/tests/it/pointer_only.rs @@ -648,6 +648,13 @@ const MAY_ANSWER_COULD_NOT_LOOK: &[&str] = &[ // either the trunk commit or the comparison — which is the could-not-look // this gate is built to fail open on. "lease carries", + // `claim merged` joins it for `lease carries`' reason exactly: it reaches + // the FORGE to read merged pull request bodies, and a corpus with no + // credential can read none of them. It is also the one verb here whose + // could-not-look is a REFUSAL rather than an empty answer — a producer that + // exits clean having produced nothing is indistinguishable from a repository + // with no merged pull requests. + "claim merged", // `lease guard` joins it for `carries`' reason and one more: it is the // composite, so a corpus that cannot read the trunk commit cannot answer its // first half either — and the guard's contract is that every such reading @@ -1032,6 +1039,31 @@ const CENSUS: &[Verb] = &[ stdin: Stdin::Nothing, disposition: Disposition::PointerOnly, }, + // `claim-keys.sh`'s successor (CLOUD-1711), and pointer-only for the reason + // that program had to promise by hand: its inputs are a branch NAME, a pull + // request TITLE and a commit LOG, all three of them prose somebody wrote, + // and its answer is a list of `CLOUD-` keys. A key is a pointer by + // construction — the shape of the extraction is the guarantee, not a filter + // applied after it. + Verb { + path: "claim keys", + args: &[], + stdin: Stdin::Nothing, + disposition: Disposition::PointerOnly, + }, + // `merged-pr-keys.sh`'s successor, on the same terms one surface out: it + // reads MERGED PULL REQUEST BODIES, which are the largest untrusted prose + // any verb here touches, and emits `\t` rows. Two columns, both + // of them identifiers. It is in `MAY_ANSWER_COULD_NOT_LOOK` because it + // reaches the forge and this corpus carries no credential — the pointer-only + // assertions still run over the could-not-look report, which is where a + // reader of somebody else's body is most tempted to quote it. + Verb { + path: "claim merged", + args: &[], + stdin: Stdin::Nothing, + disposition: Disposition::PointerOnly, + }, // The green verdict (CLOUD-1143), pointer-only on the same structural terms. // `checks_green::Finding` carries a check name and a conclusion and has // nowhere to put anything else, so a run's log cannot travel even when the @@ -1839,6 +1871,54 @@ const CENSUS: &[Verb] = &[ stdin: Stdin::PrBody, disposition: Disposition::PointerOnly, }, + // The two GENERIC store writers (CLOUD-1713), and the pair that makes this + // census worth running over them: unlike every other `record` leaf, neither + // knows what its payload MEANS. `record tool` reads a tool verdict and + // `record plan` reads plan entries, so each can be careful about a shape it + // understands; these two take whatever the caller sends and file it under a + // family and a key. `PointerOnly` is therefore the whole of their contract — + // a successful write says nothing, because the record's destination is a + // keyed file under `$GIT_DIR` and there is nothing for it to report. + // CLOUD-1717's producer door, and pointer-only for the reason the other two + // generic writers are: it does not know what its payload MEANS. A successful + // write says nothing at all — the record's destination is a branch-keyed file + // under `$GIT_DIR`, and what a module later makes of the lines is the + // module's business, not this verb's. + Verb { + path: "record named", + args: &["census"], + stdin: Stdin::ToolVerdict, + disposition: Disposition::PointerOnly, + }, + Verb { + path: "record keyed", + args: &["census", "a-key"], + stdin: Stdin::ToolVerdict, + disposition: Disposition::PointerOnly, + }, + Verb { + path: "record journal", + args: &["census"], + stdin: Stdin::ToolVerdict, + disposition: Disposition::PointerOnly, + }, + // Their READ halves. Over this corpus the stores are empty, so the answers + // are `miss` and `nothing` — which is the state that matters most here: a + // reader that cannot find a record is exactly where a program is tempted to + // print the key it looked for, the path it looked in, or the bytes it half + // read. The closed token vocabulary is what stops all three. + Verb { + path: "record show", + args: &["census", "a-key"], + stdin: Stdin::Nothing, + disposition: Disposition::PointerOnly, + }, + Verb { + path: "record fold", + args: &["census"], + stdin: Stdin::Nothing, + disposition: Disposition::PointerOnly, + }, // The task registry's six writers (CLOUD-425). Each answers with silence and // an exit code — the record's destination is a keyed file under `$GIT_DIR`, // so there is nothing for a successful write to say. `PointerOnly` rather diff --git a/crates/batten/tests/it/record_families.rs b/crates/batten/tests/it/record_families.rs new file mode 100644 index 000000000..b0dc4818d --- /dev/null +++ b/crates/batten/tests/it/record_families.rs @@ -0,0 +1,211 @@ +//! `record keyed` / `record journal`, over the compiled binary (CLOUD-1713). +//! +//! # RETIREMENT LEDGER, PER PATH — what `shell-retirement` reads +//! +//! Nothing is retired by this tier yet: this row builds the two doors and the +//! three retirements that walk through them are separate rows. The arms land +//! with them. +//! +//! # Why these three cases +//! +//! They are the ones the hand-rolled stores got RIGHT and a naive store gets +//! wrong. A store that merely reads back what it wrote passes a happy path and +//! still loses every distinction that made the shell versions correct: +//! `reclaim-census` reached for `sync -d` because a half-written append is not a +//! record, and `task::Reading` already separates "nothing" from "unreadable" +//! because a store that conflates them reports clean over one it could not open. +//! +//! Over the compiled binary rather than the library, because the exit contract is +//! half the claim: a MISS must be exit 0 with the discrimination on stdout, since +//! the engine's exit 2 means VIOLATION and a cache miss is not one. + +// 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; + +use common::{git_in, init_repo, run, run_with_stdin, scratch, stdout, write}; + +/// A repository with a git directory for the stores to live under. +fn repo(name: &str) -> std::path::PathBuf { + let dir = scratch(&format!("record-families-{name}")); + write(&dir, "seed.txt", "seed\n"); + // The TEMPLATE, never a fork (CLOUD-1419). `init_repo` copies a repository + // the harness publishes once per filesystem; a hand-rolled `git init` here + // pays a process for what the copy already has, and the trace that motivated + // the ratchet counted 1,819 of them in one run. + init_repo(&dir); + git_in(&dir, &["add", "-A"]); + git_in(&dir, &["commit", "-qm", "seed"]); + dir +} + +fn shard_dir(dir: &Path, family: &str) -> std::path::PathBuf { + dir.join(".git") + .join("batten-journals") + .join(family) + .join("journal") + .join("shards") +} + +#[test] +fn a_hit_returns_the_stored_value_and_a_miss_says_miss() { + // THE DISCRIMINATING PAIR. A store that answered an empty string for both + // would pass any assertion about the hit alone, and `step-receipt`'s whole + // decision is "run the step or skip it" — so a miss that reads as an empty + // hit skips a step nothing has verified. + let dir = repo("hit-miss"); + + let miss = run(&dir, &["record", "show", "steps", "some-key"]); + assert_eq!(miss.status.code(), Some(0), "a miss is not a violation"); + assert_eq!( + stdout(&miss).trim(), + "miss", + "an absent record must SAY miss rather than return empty" + ); + + let put = run_with_stdin( + &dir, + &["record", "keyed", "steps", "some-key"], + "verdict-token\n", + ); + assert_eq!(put.status.code(), Some(0), "the put succeeds"); + + let hit = run(&dir, &["record", "show", "steps", "some-key"]); + assert_eq!(hit.status.code(), Some(0)); + let answer = stdout(&hit); + assert!( + answer.starts_with("hit"), + "a present record reads as a hit\n{answer}" + ); + assert!( + answer.contains("verdict-token"), + "and carries the stored value back\n{answer}" + ); +} + +#[test] +fn a_record_under_another_key_does_not_answer() { + // THE ANTI-STALENESS CASE, and it is why the store is KEYED rather than + // compared: a record from other inputs lives under a name nothing opens, so + // staleness cannot be a comparison a caller forgets to make. + let dir = repo("keying"); + // Asserted rather than discarded: a setup write that failed would leave the + // store empty, and `miss` below would then pass for the wrong reason. + let written = run_with_stdin(&dir, &["record", "keyed", "steps", "key-a"], "answer-a\n"); + assert!(written.status.success(), "the setup write lands"); + + let other = run(&dir, &["record", "show", "steps", "key-b"]); + assert_eq!( + stdout(&other).trim(), + "miss", + "a record under another key must not answer" + ); +} + +#[test] +fn a_half_written_append_is_not_a_record() { + // THE DISCRIMINATING CASE (CLOUD-1032). A process killed mid-`write` leaves a + // line with no terminator. `str::lines` yields it identically to a whole one, + // so a fold built on `lines()` counts a torn record as a record — and + // `reclaim-census` classifies a boot from the KIND of the last record under + // it, which is precisely the value a torn tail corrupts. + let dir = repo("torn"); + // Same reason: the torn tail below is appended to THIS record, so a failed + // write would leave nothing for the fold to be wrong about. + let written = run_with_stdin(&dir, &["record", "journal", "census"], "h 1000 boot-a\n"); + assert!(written.status.success(), "the setup write lands"); + + // Append a torn tail the way a crash would: no trailing newline. + let shards = shard_dir(&dir, "census"); + let shard = std::fs::read_dir(&shards) + .expect("the shard directory exists once something was appended") + .flatten() + .map(|entry| entry.path()) + .find(|path| path.extension().is_some_and(|ext| ext == "jsonl")) + .expect("one shard"); + let mut text = std::fs::read_to_string(&shard).expect("readable shard"); + text.push_str("x 1001 boot-a"); + std::fs::write(&shard, &text).expect("writable shard"); + + let folded = run(&dir, &["record", "fold", "census"]); + assert_eq!(folded.status.code(), Some(0)); + let answer = stdout(&folded); + assert!( + answer.contains("h 1000 boot-a"), + "the terminated record still folds\n{answer}" + ); + assert!( + !answer.contains("x 1001"), + "an unterminated tail is NOT a record\n{answer}" + ); +} + +#[test] +fn a_fold_over_zero_records_is_nothing_rather_than_unreadable() { + // `task::Reading`'s distinction, which is the stated precedent. A store + // nobody has appended to has no shards, and that is a real answer — reporting + // it as unreadable would make every fresh checkout look like a broken one, + // and reporting an unreadable store as "no records" reads clean over a store + // that could not be opened. + let dir = repo("empty"); + let folded = run(&dir, &["record", "fold", "census"]); + assert_eq!( + folded.status.code(), + Some(0), + "an empty fold is not a failure" + ); + assert_eq!( + stdout(&folded).trim(), + "nothing", + "zero records is `nothing`, never `unreadable`" + ); +} + +#[test] +fn every_appended_record_folds_back_in_order() { + // THE ANTI-VACUITY MIRROR for the torn case: a fold that dropped every line + // would also pass `a_half_written_append_is_not_a_record`. + let dir = repo("fold-all"); + for record in ["h 1000 boot-a", "h 1001 boot-a", "x 1002 boot-a"] { + let appended = run_with_stdin( + &dir, + &["record", "journal", "census"], + &format!("{record}\n"), + ); + assert_eq!(appended.status.code(), Some(0), "{record}"); + } + let answer = stdout(&run(&dir, &["record", "fold", "census"])); + let folded: Vec<&str> = answer.lines().collect(); + assert_eq!( + folded, + vec!["h 1000 boot-a", "h 1001 boot-a", "x 1002 boot-a"], + "append order is the fold order within a shard" + ); +} + +#[test] +fn an_empty_append_is_refused_as_usage() { + // An empty append would put a record in the log that no fold can tell from a + // torn one, so the writer refuses rather than the reader guessing. USAGE (1), + // not violation (2): the caller made a mistake, this is not a policy verdict. + let dir = repo("empty-append"); + let refused = run_with_stdin(&dir, &["record", "journal", "census"], ""); + assert_eq!( + refused.status.code(), + Some(1), + "an empty append is a usage error, never a violation" + ); +} + +#[test] +fn a_family_that_would_escape_its_store_is_refused() { + // Not a security boundary so much as a silent miss: a `..` family writes + // outside the store the reader looks in, so the write succeeds, the read finds + // nothing, and the gate reads clean. + let dir = repo("escape"); + let refused = run_with_stdin(&dir, &["record", "keyed", "../escape", "k"], "v\n"); + assert_eq!(refused.status.code(), Some(1)); +} diff --git a/crates/batten/tests/it/run_shape.rs b/crates/batten/tests/it/run_shape.rs index dee903473..99c189fbc 100644 --- a/crates/batten/tests/it/run_shape.rs +++ b/crates/batten/tests/it/run_shape.rs @@ -206,7 +206,7 @@ fn fixture(name: &str) -> PathBuf { "[[verdict.route]]\n", "id = \"task run first\"\n", "kind = \"command\"\n", - "target = \"until ; do sleep 1; done\"\n\n", + "target = \"run_in_background on the long command itself\"\n\n", "[[verdict]]\n", "id = \"timer run refused\"\n", "gloss = \"a backgrounded `sleep` with no loop around it is a timer, not a wait\"\n", @@ -217,7 +217,7 @@ fn fixture(name: &str) -> PathBuf { "[[verdict.route]]\n", "id = \"task run first\"\n", "kind = \"command\"\n", - "target = \"until ; do sleep 1; done\"\n\n", + "target = \"run_in_background on the long command itself\"\n\n", "[[verdict]]\n", "id = \"task run blocked\"\n", "gloss = \"a foreground `mise` call is killed at ~2 minutes, so it fails rather than runs slowly\"\n", @@ -454,14 +454,22 @@ fn a_backgrounded_bare_sleep_is_a_timer() { } #[test] -fn a_backgrounded_wait_on_a_condition_is_allowed() { - // THE ALLOW THIS WHOLE FAMILY IS SHAPED AROUND. It is the form both refusals - // recommend, and denying it is the pure false positive that gets a guard - // bypassed (CLOUD-199). The keyword is in a DIFFERENT segment from the - // sleep, which is why the loop test is over the whole call. +fn a_backgrounded_conditioned_sleep_loop_is_refused() { + // THE EXEMPTION THIS FAMILY WAS SHAPED AROUND, WITHDRAWN. The case used to + // assert the opposite, on the argument that a loop testing a condition exits + // on the condition rather than the clock. That is true and was never the + // question: the loop still spends the session performing by hand the wake-up + // the runtime already delivers on a backgrounded task's exit (measured 523 + // of 524). CLOUD-1337 priced the exemption at eleven duplicate watchers + // running 9h35m; measured again 2026-09-09, one conditioned loop ran 3h34m + // in this session and the detector was a human reading `ps`. + // + // So the two arms now PARTITION on `count(process_probes)` and nothing falls + // between them: every backgrounded sleep is refused, with or without a + // condition around it. let root = fixture("conditional-wait"); - allowed_background(&root, "until [ -f /tmp/done ]; do sleep 1; done", true); - allowed_background( + denied_background(&root, "until [ -f /tmp/done ]; do sleep 1; done", true); + denied_background( &root, "while ! grep -q ready /tmp/log; do sleep 5; done", true, @@ -469,29 +477,26 @@ fn a_backgrounded_wait_on_a_condition_is_allowed() { } #[test] -fn a_loop_body_is_reached_and_the_exemption_decides_it() { - // THE PAIR THAT MAKES THE EXEMPTION LOAD-BEARING (CLOUD-1112). One command, - // twice, differing only in posture — so `waits_on_condition` is what decides - // it, which is what CLOUD-613's acceptance always claimed. +fn a_loop_body_is_reached_in_either_posture() { + // THE PAIR THAT ONCE MADE THE EXEMPTION LOAD-BEARING (CLOUD-1112). One + // command, twice, differing only in posture — and since CLOUD-1337 withdrew + // the condition exemption the POSTURE no longer changes the answer; it + // changes only which rule answers, `foreground-sleep` or `background-timer`. + // What the pair still proves is the keyword look-through: without it neither + // call resolves a sleep at all and both pass vacuously. // - // Reaching it needed a keyword look-through. `do sleep 1` resolves to the - // program `do` without one, and `run-shape-guard.sh`'s `resolve()` still - // does: the wrapper table covers `env`/`timeout`/`sudo`/… and no keyword. So - // in the bash BOTH postures pass, for want of a resolvable sleep rather than - // for any reason about waiting, and its comment that an element-scoped test - // "would deny every correct wait" presumes an element it never reaches. - // Porting that would have satisfied the acceptance vacuously. + // Reaching the body needs a keyword look-through: `do sleep 1` resolves to + // the program `do` without one, so a resolver whose wrapper table covers + // `env`/`timeout`/`sudo`/… and no keyword finds no sleep here at all and + // passes the whole family vacuously. That was the retired bash guard's + // behaviour, and it is why this case reads the verdict rather than a count. // - // This is the one place the two authorities deliberately disagree while both - // are live, and it is in the DENYING direction — no call gets a weaker - // answer than it had. - // - // `for` is not a wait: it counts iterations, so it exits on the clock like - // any timer. The guard calls that a deliberate non-catch "because narrowing - // it costs a real parser"; it costs none now. + // `for` is not a wait either — it counts iterations, so it exits on the + // clock like any timer — and after CLOUD-1337 it needs no separate argument: + // there is no shape of loop that exempts the sleep inside it. let root = fixture("loop-body"); denied_background(&root, "until [ -f /tmp/done ]; do sleep 1; done", false); - allowed_background(&root, "until [ -f /tmp/done ]; do sleep 1; done", true); + denied_background(&root, "until [ -f /tmp/done ]; do sleep 1; done", true); denied_background(&root, "for i in $(seq 60); do sleep 10; done", true); } @@ -514,12 +519,14 @@ fn a_backgrounded_bare_sleep_raises_the_timer_and_not_the_foreground_rule() { } #[test] -fn a_bare_sleep_beside_a_condition_loop_is_exempt() { - // The one shape `waits_on_condition` actually decides, and therefore the - // only case that can discriminate the `loop-is-not-an-exemption` mutation: - // a resolvable bare `sleep` AND a loop keyword in the same backgrounded - // call. Drop the conjunct and this denies. - allowed_background( +fn a_bare_sleep_beside_a_condition_loop_is_no_longer_exempt() { + // The exemption's most obviously wrong reachable shape, and the reason it + // is inverted rather than deleted: the `sleep 5` here waits on NOTHING — + // the loop beside it has an empty body — so the old rule read a condition + // somewhere in the call and exempted a bare timer sitting next to it. That + // is the whole argument against asking WHETHER there is a condition instead + // of what it is about, and after CLOUD-1337 the question is not asked. + denied_background( &fixture("mixed-wait"), "sleep 5; until [ -f /tmp/done ]; do :; done", true, @@ -793,31 +800,49 @@ fn the_probing_program_is_not_what_decides_a_task_output_poll() { } #[test] -fn a_wait_on_a_local_file_that_is_not_a_task_output_is_clean() { - // THE OTHER HALF OF THE ANTI-VACUITY PAIR, and the one that keeps this arm - // from becoming "no backgrounded loop may read a file". A file another - // machine writes is exactly the condition `waits_on_condition` exists to - // permit, and it is a local read like any other — what disqualifies the task - // output is that the harness already reports it, not that it is on disk. +fn a_wait_on_a_local_file_that_is_not_a_task_output_is_not_a_task_watch() { + // THE OTHER HALF OF THE ANTI-VACUITY PAIR, re-pointed rather than dropped + // (CLOUD-1337). It used to assert this call was CLEAN, on the reading that a + // file another machine writes is a condition the harness does not report. + // Withdrawing the exemption makes the call refused — but by which arm still + // matters, and that is what this case now pins: a shared file is not a task + // output, so `polls-a-local-process` must stay silent and `background-timer` + // must answer. Without it, folding the two arms into one would pass every + // case above while losing the count that makes the narrower one worth having. let root = fixture("waits-on-a-shared-file"); - allowed_background( + let (deny, text) = hook_background( &root, "until grep -q ready /mnt/shared/deploy.status; do sleep 30; done", true, ); + assert!(deny, "{text}"); + assert!(text.contains("timer run refused"), "{text}"); + assert!( + !text.contains("task watch duplicate"), + "a shared file is not a task output: {text}" + ); } #[test] -fn a_wait_on_a_condition_nobody_reports_is_clean() { - // THE ANTI-VACUITY MIRROR. Without it every case above is satisfied by a rule - // that refuses all waits — and this form is what `timer run refused`'s own - // route still recommends for a condition the harness does not report. +fn a_wait_on_a_condition_nobody_reports_is_a_timer_not_a_task_watch() { + // THE ANTI-VACUITY MIRROR, on the same re-pointing (CLOUD-1337). A remote + // readiness probe was the last shape left allowed, on the argument that the + // harness cannot report it. It cannot — and the poll is still a wake-up the + // session performs by hand every five seconds, so the answer is a refusal + // under `background-timer`. What stays pinned is that the arms partition: + // exactly one of them answers, and it is not the process-poll one. let root = fixture("waits-on-a-remote"); - allowed_background( + let (deny, text) = hook_background( &root, "until curl -sf https://example.test/ready; do sleep 5; done", true, ); + assert!(deny, "{text}"); + assert!(text.contains("timer run refused"), "{text}"); + assert!( + !text.contains("task watch duplicate"), + "a remote probe reads no local process: {text}" + ); } #[test] diff --git a/crates/batten/tests/it/run_shape_guard_door.rs b/crates/batten/tests/it/run_shape_guard_door.rs index 722a4907e..44a8cc513 100644 --- a/crates/batten/tests/it/run_shape_guard_door.rs +++ b/crates/batten/tests/it/run_shape_guard_door.rs @@ -238,12 +238,20 @@ fn the_handler_receives_the_hosts_own_payload_including_the_calls_background_fla } #[test] -fn a_backgrounded_wait_on_a_condition_stays_allowed() { - // Driven against the COMMITTED guard deliberately, because this is its allow - // path and the allow path is not broken: the guard prints a document only - // when it denies, so a command it passes leaves the door silent either way. - // A guard refusing every backgrounded sleep would fail this and be the false - // positive that gets a guard switched off (CLOUD-418). +fn a_command_a_handler_passes_is_forwarded_silently() { + // THE ALLOW PATH THROUGH THE DOOR, and the command is incidental. I briefly + // inverted this case for CLOUD-1337, reading it as an assertion that a + // conditioned sleep loop is permitted — it is not, and cannot be: this + // fixture carries ONE `[[hook.handler]]` row and no `[[rule]]` at all, by the + // isolation this file's header argues for, so `run-shape` never runs here and + // the stub behind the door exits 0 whatever it is handed. + // + // What the case pins is the door: a handler that passes prints no document, + // so an allowed command must leave both channels silent. A door that + // manufactured a verdict from a quiet handler would fail this. + // + // The module's own withdrawal of the exemption is asserted where the rule + // actually runs, in `run_shape.rs` and in the module's load-time tier. let dir = fixture("door-background-wait"); let answer = door_bg(&dir, "until [ -f /tmp/done ]; do sleep 1; done"); assert!(answer.allowed(), "{}", answer.out); 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 81770623c..2bf15e286 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 @@ -620,6 +620,74 @@ expression: stdout_of(&output) ], "subcommands": [] }, + { + "path": "claim keys", + "id": "claim.keys", + "about": "The issue keys this branch CLAIMS, as distinct from the ones it merely mentions", + "effect": "read", + "data_channel": false, + "flags": [ + { + "name": "branch", + "short": null, + "long": "branch", + "takes_value": true, + "positional": false, + "help": "The head branch, standing in for source 2" + }, + { + "name": "closing_only", + "short": null, + "long": "closing-only", + "takes_value": false, + "positional": false, + "help": "Answer from a closing keyword alone, never falling through to the branch or a trailer" + }, + { + "name": "log", + "short": null, + "long": "log", + "takes_value": true, + "positional": false, + "help": "Commit messages, standing in for sources 1 and 3" + }, + { + "name": "refs_first_only", + "short": null, + "long": "refs-first-only", + "takes_value": false, + "positional": false, + "help": "Answer from the first key of each `Refs:` trailer alone, never sources 1 or 2" + }, + { + "name": "title", + "short": null, + "long": "title", + "takes_value": true, + "positional": false, + "help": "The pull request title, also source 2 — a body is not, because a body cites evidence" + } + ], + "subcommands": [] + }, + { + "path": "claim merged", + "id": "claim.merged", + "about": "The keys merged pull request bodies close, as `\\t` rows", + "effect": "read", + "data_channel": false, + "flags": [ + { + "name": "limit", + "short": null, + "long": "limit", + "takes_value": true, + "positional": false, + "help": "The most pull requests to read before the answer is truncated (default 5000)" + } + ], + "subcommands": [] + }, { "path": "claim race", "id": "claim.race", @@ -2420,6 +2488,24 @@ expression: stdout_of(&output) "flags": [], "subcommands": [] }, + { + "path": "record fold", + "id": "record.fold", + "about": "Fold a journal family: `nothing`, its records, or `unreadable `", + "effect": "read", + "data_channel": false, + "flags": [ + { + "name": "family", + "short": null, + "long": null, + "takes_value": true, + "positional": true, + "help": "The store family to fold" + } + ], + "subcommands": [] + }, { "path": "record forge", "id": "record.forge", @@ -2438,6 +2524,68 @@ expression: stdout_of(&output) ], "subcommands": [] }, + { + "path": "record journal", + "id": "record.journal", + "about": "Append one record to an append-and-fold store family, read from stdin", + "effect": "write", + "data_channel": false, + "flags": [ + { + "name": "family", + "short": null, + "long": null, + "takes_value": true, + "positional": true, + "help": "The store family the record belongs to" + } + ], + "subcommands": [] + }, + { + "path": "record keyed", + "id": "record.keyed", + "about": "Put one value into a keyed store family, read from stdin", + "effect": "write", + "data_channel": false, + "flags": [ + { + "name": "family", + "short": null, + "long": null, + "takes_value": true, + "positional": true, + "help": "The store family the record belongs to" + }, + { + "name": "key", + "short": null, + "long": null, + "takes_value": true, + "positional": true, + "help": "The key the record is filed under" + } + ], + "subcommands": [] + }, + { + "path": "record named", + "id": "record.named", + "about": "Record one named family under this branch, read from stdin", + "effect": "write", + "data_channel": false, + "flags": [ + { + "name": "family", + "short": null, + "long": null, + "takes_value": true, + "positional": true, + "help": "The record family, which is the key a module reads it under" + } + ], + "subcommands": [] + }, { "path": "record plan", "id": "record.plan", @@ -2447,6 +2595,32 @@ expression: stdout_of(&output) "flags": [], "subcommands": [] }, + { + "path": "record show", + "id": "record.show", + "about": "Read one keyed record back: `hit` and the value, or `miss`", + "effect": "read", + "data_channel": false, + "flags": [ + { + "name": "family", + "short": null, + "long": null, + "takes_value": true, + "positional": true, + "help": "The store family to read" + }, + { + "name": "key", + "short": null, + "long": null, + "takes_value": true, + "positional": true, + "help": "The key to look under" + } + ], + "subcommands": [] + }, { "path": "record tool", "id": "record.tool", @@ -3057,6 +3231,14 @@ expression: stdout_of(&output) "id": "checks.green", "path": "checks green" }, + { + "id": "claim.keys", + "path": "claim keys" + }, + { + "id": "claim.merged", + "path": "claim merged" + }, { "id": "claim.race", "path": "claim race" @@ -3249,6 +3431,14 @@ expression: stdout_of(&output) "id": "receipt.verified", "path": "receipt verified" }, + { + "id": "record.fold", + "path": "record fold" + }, + { + "id": "record.show", + "path": "record show" + }, { "id": "show", "path": "show" diff --git a/crates/batten/tests/it/stop_posture.rs b/crates/batten/tests/it/stop_posture.rs index 8eea9f1a6..e702373b1 100644 --- a/crates/batten/tests/it/stop_posture.rs +++ b/crates/batten/tests/it/stop_posture.rs @@ -22,6 +22,37 @@ //! The suite's successor is the module, which is where the one rule that COULD //! be a predicate went. //! +// THE PROGRAM THIS MODULE MADE REDUNDANT (CLOUD-1717). `stop-posture-check.sh` +// and `policy/stop-posture.rego` decided the same question from the same bytes: +// the shell read `last_assistant_message` off stdin, the module reads it off the +// `Stop` payload. The row's own words were "already ported — retire it, don't +// port it", and this is that retirement: nothing new is written, the shell's +// nineteen cases are mapped onto the module's own and the program goes. +// +// The program's `#MUTANT hedged-framing-passes` row does not move: the module +// carries `hedge-unread` over the same conjunct, so the sweep keeps a live +// mutation on the surviving surface rather than gaining an orphan. +// carried: mise-tasks/stop-posture-check.sh policy/stop-posture.rego kind:mechanism crates/batten/tests/it/stop_posture.rs +// carried: tests/stop-posture-check.bats policy/stop-posture.rego kind:mechanism crates/batten/tests/it/stop_posture.rs +// carried: "the first tell AGENTS.md names fires" policy/stop-posture.rego kind:mechanism +// carried: "the second tell AGENTS.md names fires" policy/stop-posture.rego kind:mechanism +// carried: "the inflection that a closed two-item list would have missed fires" policy/stop-posture.rego kind:mechanism +// carried: "the report carries a count" policy/stop-posture.rego kind:mechanism +// carried: "THE WITNESSED MISS: the CLOUD-347 sentence fires" policy/stop-posture.rego kind:mechanism +// carried: "the asymmetry is gone: mentioning is a flagging verb under BOTH openers" policy/stop-posture.rego kind:mechanism +// carried: "every opener carries the witnessed verb" policy/stop-posture.rego kind:mechanism +// carried: "an UNWITNESSED near-miss stays out — that is the line against inventing a list" policy/stop-posture.rego kind:mechanism +// carried: "the report never echoes the sentence it judged" policy/stop-posture.rego kind:mechanism +// carried: "a code span carrying the tell does not fire" policy/stop-posture.rego kind:mechanism +// carried: "a double-quoted span carrying the tell does not fire" policy/stop-posture.rego kind:mechanism +// carried: "a block quote carrying the tell does not fire" policy/stop-posture.rego kind:mechanism +// carried: "a fenced block carrying the tell does not fire" policy/stop-posture.rego kind:mechanism +// carried: "a LINE-WRAPPED quoted span carrying the tell does not fire" policy/stop-posture.rego kind:mechanism +// carried: "reporting a measured value is not hedged framing" policy/stop-posture.rego kind:mechanism +// carried: "a command flag is not hedged framing" policy/stop-posture.rego kind:mechanism +// carried: "a plainly stated finding with a durable home does not fire" policy/stop-posture.rego kind:mechanism +// changed: "empty stdin is clean rather than an error" policy/stop-posture.rego kind:mechanism the input surface moved: the shell read the message on stdin, so an empty stream was a real state it had to answer for. The module is handed the `Stop` payload and an absent `last_assistant_message` is a missing FIELD, which `a_tool_call_carries_no_final_message` decides as silence. Same verdict, different question, so it is recorded as a divergence rather than a carry +// carried: "a clean message produces no output at all" policy/stop-posture.rego kind:mechanism // carried: mise-tasks/stop-guard.sh crates/batten/src/lib.rs kind:mechanism crates/batten/tests/it/stop_posture.rs // // CLOUD-1163's unlanded unit. The program was spawned by `stop_nudges` with diff --git a/crates/batten/tests/it/trunk_watch.rs b/crates/batten/tests/it/trunk_watch.rs index fecd7a083..91b9a38da 100644 --- a/crates/batten/tests/it/trunk_watch.rs +++ b/crates/batten/tests/it/trunk_watch.rs @@ -65,6 +65,7 @@ const MOVED: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; /// A `200` carrying a ref object, and optionally a validator. fn ref_object(sha: &str, etag: Option<&str>) -> Answer { Answer { + headers: std::collections::BTreeMap::new(), status: 200, etag: etag.map(ToOwned::to_owned), poll_floor: None, @@ -76,6 +77,7 @@ fn ref_object(sha: &str, etag: Option<&str>) -> Answer { /// A `304`: no body, and the validator the server echoes back. fn unchanged(etag: Option<&str>) -> Answer { Answer { + headers: std::collections::BTreeMap::new(), status: 304, etag: etag.map(ToOwned::to_owned), poll_floor: None, diff --git a/man/batten-claim-keys.1 b/man/batten-claim-keys.1 new file mode 100644 index 000000000..91e94d01e --- /dev/null +++ b/man/batten-claim-keys.1 @@ -0,0 +1,28 @@ +.ie \n(.g .ds Aq \(aq +.el .ds Aq ' +.TH batten-claim-keys 1 batten +.SH NAME +batten\-claim\-keys \- The issue keys this branch CLAIMS, as distinct from the ones it merely mentions +.SH SYNOPSIS +\fBbatten claim keys\fR [\fB\-\-branch\fR] [\fB\-\-title\fR] [\fB\-\-log\fR] [\fB\-\-closing\-only\fR] [\fB\-\-refs\-first\-only\fR] [\fB\-h\fR|\fB\-\-help\fR] +.SH DESCRIPTION +The issue keys this branch CLAIMS, as distinct from the ones it merely mentions +.SH OPTIONS +.TP +\fB\-\-branch\fR +The head branch, standing in for source 2 +.TP +\fB\-\-title\fR +The pull request title, also source 2 — a body is not, because a body cites evidence +.TP +\fB\-\-log\fR +Commit messages, standing in for sources 1 and 3 +.TP +\fB\-\-closing\-only\fR +Answer from a closing keyword alone, never falling through to the branch or a trailer +.TP +\fB\-\-refs\-first\-only\fR +Answer from the first key of each `Refs:` trailer alone, never sources 1 or 2 +.TP +\fB\-h\fR, \fB\-\-help\fR +Print help diff --git a/man/batten-claim-merged.1 b/man/batten-claim-merged.1 new file mode 100644 index 000000000..e2c711a45 --- /dev/null +++ b/man/batten-claim-merged.1 @@ -0,0 +1,16 @@ +.ie \n(.g .ds Aq \(aq +.el .ds Aq ' +.TH batten-claim-merged 1 batten +.SH NAME +batten\-claim\-merged \- The keys merged pull request bodies close, as `\\t` rows +.SH SYNOPSIS +\fBbatten claim merged\fR [\fB\-\-limit\fR] [\fB\-h\fR|\fB\-\-help\fR] +.SH DESCRIPTION +The keys merged pull request bodies close, as `\\t` rows +.SH OPTIONS +.TP +\fB\-\-limit\fR +The most pull requests to read before the answer is truncated (default 5000) +.TP +\fB\-h\fR, \fB\-\-help\fR +Print help diff --git a/man/batten-claim.1 b/man/batten-claim.1 index 79e351525..6f78753f5 100644 --- a/man/batten-claim.1 +++ b/man/batten-claim.1 @@ -13,6 +13,12 @@ Whether the issue you are about to pull is actually unclaimed Print help .SH SUBCOMMANDS .TP +batten\-claim\-merged(1) +The keys merged pull request bodies close, as `\\t` rows +.TP +batten\-claim\-keys(1) +The issue keys this branch CLAIMS, as distinct from the ones it merely mentions +.TP batten\-claim\-check(1) Refuse a pull of an issue somebody is already on, and mint the receipt when it is free .TP diff --git a/man/batten-record-fold.1 b/man/batten-record-fold.1 new file mode 100644 index 000000000..ef88bd874 --- /dev/null +++ b/man/batten-record-fold.1 @@ -0,0 +1,16 @@ +.ie \n(.g .ds Aq \(aq +.el .ds Aq ' +.TH batten-record-fold 1 batten +.SH NAME +batten\-record\-fold \- Fold a journal family: `nothing`, its records, or `unreadable ` +.SH SYNOPSIS +\fBbatten record fold\fR [\fB\-h\fR|\fB\-\-help\fR] <\fIfamily\fR> +.SH DESCRIPTION +Fold a journal family: `nothing`, its records, or `unreadable ` +.SH OPTIONS +.TP +\fB\-h\fR, \fB\-\-help\fR +Print help +.TP +<\fIfamily\fR> +The store family to fold diff --git a/man/batten-record-journal.1 b/man/batten-record-journal.1 new file mode 100644 index 000000000..082129006 --- /dev/null +++ b/man/batten-record-journal.1 @@ -0,0 +1,16 @@ +.ie \n(.g .ds Aq \(aq +.el .ds Aq ' +.TH batten-record-journal 1 batten +.SH NAME +batten\-record\-journal \- Append one record to an append\-and\-fold store family, read from stdin +.SH SYNOPSIS +\fBbatten record journal\fR [\fB\-h\fR|\fB\-\-help\fR] <\fIfamily\fR> +.SH DESCRIPTION +Append one record to an append\-and\-fold store family, read from stdin +.SH OPTIONS +.TP +\fB\-h\fR, \fB\-\-help\fR +Print help +.TP +<\fIfamily\fR> +The store family the record belongs to diff --git a/man/batten-record-keyed.1 b/man/batten-record-keyed.1 new file mode 100644 index 000000000..6c6ad3dfc --- /dev/null +++ b/man/batten-record-keyed.1 @@ -0,0 +1,19 @@ +.ie \n(.g .ds Aq \(aq +.el .ds Aq ' +.TH batten-record-keyed 1 batten +.SH NAME +batten\-record\-keyed \- Put one value into a keyed store family, read from stdin +.SH SYNOPSIS +\fBbatten record keyed\fR [\fB\-h\fR|\fB\-\-help\fR] <\fIfamily\fR> <\fIkey\fR> +.SH DESCRIPTION +Put one value into a keyed store family, read from stdin +.SH OPTIONS +.TP +\fB\-h\fR, \fB\-\-help\fR +Print help +.TP +<\fIfamily\fR> +The store family the record belongs to +.TP +<\fIkey\fR> +The key the record is filed under diff --git a/man/batten-record-named.1 b/man/batten-record-named.1 new file mode 100644 index 000000000..98b5f2346 --- /dev/null +++ b/man/batten-record-named.1 @@ -0,0 +1,16 @@ +.ie \n(.g .ds Aq \(aq +.el .ds Aq ' +.TH batten-record-named 1 batten +.SH NAME +batten\-record\-named \- Record one named family under this branch, read from stdin +.SH SYNOPSIS +\fBbatten record named\fR [\fB\-h\fR|\fB\-\-help\fR] <\fIfamily\fR> +.SH DESCRIPTION +Record one named family under this branch, read from stdin +.SH OPTIONS +.TP +\fB\-h\fR, \fB\-\-help\fR +Print help +.TP +<\fIfamily\fR> +The record family, which is the key a module reads it under diff --git a/man/batten-record-show.1 b/man/batten-record-show.1 new file mode 100644 index 000000000..9adfcfe84 --- /dev/null +++ b/man/batten-record-show.1 @@ -0,0 +1,19 @@ +.ie \n(.g .ds Aq \(aq +.el .ds Aq ' +.TH batten-record-show 1 batten +.SH NAME +batten\-record\-show \- Read one keyed record back: `hit` and the value, or `miss` +.SH SYNOPSIS +\fBbatten record show\fR [\fB\-h\fR|\fB\-\-help\fR] <\fIfamily\fR> <\fIkey\fR> +.SH DESCRIPTION +Read one keyed record back: `hit` and the value, or `miss` +.SH OPTIONS +.TP +\fB\-h\fR, \fB\-\-help\fR +Print help +.TP +<\fIfamily\fR> +The store family to read +.TP +<\fIkey\fR> +The key to look under diff --git a/man/batten-record.1 b/man/batten-record.1 index f1c68c055..210ca39c2 100644 --- a/man/batten-record.1 +++ b/man/batten-record.1 @@ -19,6 +19,21 @@ Record a declared tool row\*(Aqs verdict, read as ` ` lines on stdi batten\-record\-forge(1) Record the forge\*(Aqs check verdicts for one commit, read as ` ` lines on stdin .TP +batten\-record\-named(1) +Record one named family under this branch, read from stdin +.TP +batten\-record\-keyed(1) +Put one value into a keyed store family, read from stdin +.TP +batten\-record\-journal(1) +Append one record to an append\-and\-fold store family, read from stdin +.TP +batten\-record\-show(1) +Read one keyed record back: `hit` and the value, or `miss` +.TP +batten\-record\-fold(1) +Fold a journal family: `nothing`, its records, or `unreadable ` +.TP batten\-record\-plan(1) Record this branch\*(Aqs plan, read as ` ` lines on stdin .TP diff --git a/mise-tasks/claimed-keys.sh b/mise-tasks/claimed-keys.sh deleted file mode 100755 index 807685b69..000000000 --- a/mise-tasks/claimed-keys.sh +++ /dev/null @@ -1,212 +0,0 @@ -#!/usr/bin/env bash -#MISE description="The issue keys a branch CLAIMS, as distinct from the ones it merely mentions (extra evidence on stdin; prints one key per line)" -# -# CLOUD-338. Two guards need this answer and had one copy of it. `issue-guard` -# derived the claimed set inline to refuse a duplicate claim; `deferral-check` -# then needed the same set, to stop a deferral being exempted by the very key -# `issue-guard` forces onto every PR. A second copy is a second authority, and -# two guards that disagree about which issue a PR claims is a worse defect than -# either gate misfiring — so the derivation moved here and both read it. -# -# WHICH issue a branch claims is a NARROWER question than which it mentions, -# and conflating them is a false positive `issue-guard` produced against its own -# PR twice. A body cites related issues, prior measurements and superseded work -# as evidence; a branch may carry a bundle name naming two issues that landed -# hours ago. Neither is a claim. Only these are, most explicit first: -# -# 1. a closing keyword — `Closes`/`Fixes`/`Resolves CLOUD-` — in the extra -# evidence on stdin (the command being run, or the PR's own body) or in a -# commit on the branch. It OVERRIDES the branch, which is the escape hatch -# for a branch whose name no longer reflects the work. -# 2. failing that, the branch name — the tracker's own `gitBranchName` shape -# names the issue being worked, so a branch is a claim — together with the -# PR TITLE where a caller supplies one (see the explicit sources below). -# 3. failing that, a `Refs:` trailer on a commit. -# -# When none resolves, the answer is EMPTY and that is not an error: every caller -# treats "no claim" as "do not judge", because a guard that guesses is one that -# blocks correct work. Outside a git repo the same applies — exit 0, print -# nothing, and let the caller behave exactly as it did before it asked. -# -# EXPLICIT SOURCES, for a PR this checkout did not author (CLOUD-378). -# `issue-guard` asks the same question about a COMPETING PR — which issue does -# *it* claim — and had no way to ask it here, because the three sources above are -# all read from the local repository. So it re-derived the answer inline with a -# bare mention of the key in the other PR's title or body, which is the -# conflation this whole file exists to refuse, applied to the other side of the -# comparison. Measured: PR #306 (`docs(agents): … (CLOUD-268)`) cites CLOUD-133 -# in one row of an evidence table and was reported as claiming it. -# -# --closing-only answer from source 1 ALONE — a closing keyword — and never -# fall through to the branch name or a `Refs:` trailer. -# For a caller asking about a LOG rather than a branch -# (CLOUD-804): over `main`'s history the fallbacks answer a -# different question, and source 3 in particular is the exact -# citation signal CLOUD-480 was swept wrong on. Opt-in, so -# every existing caller keeps the full chain. -# --refs-first-only source 3 ALONE — the first key of each `Refs:` trailer — -# and never sources 1 or 2. The exact mirror of -# `--closing-only`, and it exists for one caller with one -# need: `closing-key-check` asks "which keys did this branch -# SERVE", to subtract the keys the body closes (CLOUD-674). -# That comparison is only meaningful against a set derived -# WITHOUT reference to the closing keys — the full chain -# returns source 1 first, so the answer would agree with the -# body by construction and the gate would pass on exactly the -# bodies it must refuse. Narrowing here rather than -# re-deriving in that gate keeps this file the one authority -# on what a `Refs:` trailer claims, and — the part a copy -# would silently lose — keeps the speculation boundary below. -# -# The two narrowing flags are mutually exclusive: each names a different single -# source, so asking for both is a caller bug rather than an intersection. -# --branch the head branch, standing in for source 2 -# --title the PR title, ALSO source 2 — for a PR you did not author -# the title is the other self-declaration of what the work -# is, and this repo's own convention ends every title with -# `(CLOUD-)`. A body is not: a body cites evidence. -# --log commit messages, standing in for sources 1 and 3 -# -# Passing ANY of them switches to explicit mode: git is not consulted at all and -# an unsupplied source is empty. All-or-nothing rather than per-source fallback, -# because a remote PR silently answered from the local branch would be the -# worst kind of wrong — a confident verdict about the wrong repository state. -# Source 2 is the UNION of branch and title, not a precedence between them: they -# are two spellings of one self-declaration, and picking one would make the -# answer depend on which the author happened to fill in. -# -# Output is the keys alone, uppercased and sorted, one per line — a pointer set, -# never the prose they were extracted from (rule 4). -# The mutation drops the speculation boundary, so a key carried only by an adopted -# commit is claimed again and the waiter races the PR it is waiting on. -# The mutation makes --closing-only fall through anyway, so a `Refs:` citation is -# read as a claim — CLOUD-480's shape, which is the whole reason the flag exists. -# The anchor carries a leading tab: CLOUD-674 moved this branch inside the `else` -# of the `--refs-first-only` split, and the un-indented pattern silently stopped -# matching — reported by `mutant` as `inert-mutation`, which is the reading that -# catches a mutation whose subject moved out from under it. -#MUTANT claimed-keys-closing-only-falls-through|s/^\tif \[\[ "\$closing_only" -eq 0 \]\]; then$/\tif true; then/|--closing-only does not fall through to a Refs: trailer -# The mutation makes --refs-first-only fall back to the closing keyword, which is -# the circularity CLOUD-674 exists to avoid: the served set would be derived from -# the body it is about to be subtracted from, and agree with it by construction. -#MUTANT claimed-keys-refs-first-falls-back|s/^if \[\[ "\$refs_first_only" -eq 1 \]\]; then$/if false; then/|--refs-first-only ignores a closing keyword in the body -#MUTANT claimed-keys-adopts-speculated|s/^\tif since=\$(spec_base_range); then$/\tif false; then/|a key carried only by a speculated commit is not claimed -set -euo pipefail - -ISSUE_RE='CLOUD-[0-9]+' -CLAIM_RE='(Closes|Fixes|Resolves)[[:space:]]+CLOUD-[0-9]+' - -# `grep -o` consumes its whole input rather than exiting on the first hit, so -# piping it is safe under pipefail — the SIGPIPE trap that bit `issue-guard` -# applies to `-q`/`-m`/`-l` only. -extract() { grep -oiE "$ISSUE_RE" <<<"$1" | tr '[:lower:]' '[:upper:]' | sort -u || true; } - -# Extra evidence the caller has and this script cannot read for itself: the -# command being guarded, or the PR body being judged. Optional — a caller with -# nothing to add closes stdin and the branch/commit sources still answer. -extra="" -[[ -t 0 ]] || extra=$(cat || true) - -explicit=0 -closing_only=0 -refs_first_only=0 -branch="" -title="" -log="" -while [[ "$#" -gt 0 ]]; do - case "$1" in - --branch | --title | --log) - # A flag with no value is a caller bug, not an empty source: silently - # reading the next flag as the value would answer about the wrong text. - [[ "$#" -ge 2 ]] || { - echo "::error:: claimed-keys: $1 needs a value" >&2 - exit 2 - } - case "$1" in - --branch) branch="$2" ;; - --title) title="$2" ;; - --log) log="$2" ;; - esac - explicit=1 - shift 2 - ;; - --closing-only) - closing_only=1 - shift - ;; - --refs-first-only) - refs_first_only=1 - shift - ;; - *) - echo "::error:: claimed-keys: unknown argument" >&2 - exit 2 - ;; - esac -done - -# Each flag names a different SINGLE source, so both together is not an -# intersection to compute — it is a caller that has not decided which question it -# is asking. Exit 2 is this file's "could not read the input" code. -if [[ "$closing_only" -eq 1 && "$refs_first_only" -eq 1 ]]; then - echo "::error:: claimed-keys: --closing-only and --refs-first-only each name one source; pick one" >&2 - exit 2 -fi - -# THE COMMITS THIS BRANCH AUTHORED, WHICH IS NARROWER THAN THE ONES IT CARRIES -# (CLOUD-748). `land`'s speculative linearization (CLOUD-369) rebases a waiting -# branch onto the lease holder's published head, and says so plainly: it "puts -# ANOTHER BRANCH'S unlanded commits into this branch's history". Those commits -# carry the holder's `CLOUD-*` keys, and the holder has an open PR by -# construction — so `claim-race-check`, reading this file, reported the waiter as -# racing the very PR the speculation bet on. Measured twice in one session, on -# CLOUD-718 and then CLOUD-719, each costing a full `verify`. -# -# It is not a race and it is not intermittent: the two gates were individually -# correct and jointly unsatisfiable. `land` would have unwound the bet at the top -# of the next lap, but a refused gate ends the lap, so the settle never ran. -# -# `BATTEN_SPEC_BASE` is the boundary, exported by `land` when it speculates and -# cleared when it settles. It is the commit the branch was replayed ONTO, so -# everything after it on HEAD is this branch's own work — not `spec_undo`, which -# is HEAD *before* the rebase and is left off the branch entirely. -# -# HONOURED ONLY WHEN IT IS AN ANCESTOR OF HEAD, which is what makes a stale -# export harmless: an unwound bet, a `land` that died, or an inherited variable -# from an unrelated run all fail that test and the range falls back to -# `origin/main`. The failure direction is the wider set, which is the one that -# refuses — never the narrower one, which would silently stop catching races. -spec_base_range() { - local base="${BATTEN_SPEC_BASE:-}" - [[ -n "$base" ]] || return 1 - git merge-base --is-ancestor "$base" HEAD 2>/dev/null || return 1 - printf '%s\n' "$base" -} - -if [[ "$explicit" -eq 0 ]]; then - branch=$(git rev-parse --abbrev-ref HEAD 2>/dev/null) || exit 0 - if since=$(spec_base_range); then - log=$(git log --format='%B' "$since"..HEAD 2>/dev/null || true) - elif git rev-parse --verify -q origin/main >/dev/null 2>&1; then - log=$(git log --format='%B' origin/main..HEAD 2>/dev/null || true) - fi -fi - -# Source 3 in isolation. `Refs:` is matched with only whitespace between it and -# the key, so this yields the FIRST key of each trailer and not the citations -# after it — which is the distinction CLOUD-674's predicate rests on, and it is a -# property of this pattern rather than an extra filter. -refs_first() { extract "$(grep -oiE "Refs:[[:space:]]*CLOUD-[0-9]+" <<<"$log" || true)"; } - -if [[ "$refs_first_only" -eq 1 ]]; then - claimed=$(refs_first) -else - claimed=$(extract "$(grep -oiE "$CLAIM_RE" <<<"$extra $log" || true)") - if [[ "$closing_only" -eq 0 ]]; then - [[ -n "$claimed" ]] || claimed=$(extract "$branch $title") - [[ -n "$claimed" ]] || claimed=$(refs_first) - fi -fi - -[[ -n "$claimed" ]] && printf '%s\n' "$claimed" -exit 0 diff --git a/mise-tasks/closing-key-check.sh b/mise-tasks/closing-key-check.sh index aca27ff2e..feb3a0fe4 100755 --- a/mise-tasks/closing-key-check.sh +++ b/mise-tasks/closing-key-check.sh @@ -190,9 +190,9 @@ fi # `verify` runs when `claim-race-check` hit it. served= if [[ -n "${SERVED_LOG_GIVEN:-}" ]]; then - served=$("$(dirname "$0")/claimed-keys.sh" --refs-first-only --branch "" --title "" --log "$SERVED_LOG" 2>/dev/null || true) + served=$(batten claim keys --refs-first-only --branch "" --title "" --log "$SERVED_LOG" 2>/dev/null || true) else - served=$("$(dirname "$0")/claimed-keys.sh" --refs-first-only 2>/dev/null /dev/null /dev/null || true) +claimed=$(printf '%s' "$body" | batten claim keys 2>/dev/null || true) violations=0 report() { # pointer-only: a coordinate, never the prose diff --git a/mise-tasks/in-progress-drain.sh b/mise-tasks/in-progress-drain.sh index e92f48129..6cf00d75e 100755 --- a/mise-tasks/in-progress-drain.sh +++ b/mise-tasks/in-progress-drain.sh @@ -197,7 +197,7 @@ if [[ -n "${DRAIN_MERGED_PRS:-}" ]]; then drain_evidence=(--merged-prs "$DRAIN_MERGED_PRS") else gathered="${TMPDIR:-/tmp}/merged-pr-keys.$$" - if ! "$here/merged-pr-keys.sh" >"$gathered" 2>/dev/null; then + if ! batten claim merged >"$gathered" 2>/dev/null; then rm -f "$gathered" cannot_look "no DRAIN_MERGED_PRS was set and \`merged-pr-keys\` could not gather the evidence itself. Run it directly to see why, or set DRAIN_MERGED_PRS to a prepared file." fi diff --git a/mise-tasks/landed-check.sh b/mise-tasks/landed-check.sh index 640f5358a..3dd090223 100755 --- a/mise-tasks/landed-check.sh +++ b/mise-tasks/landed-check.sh @@ -203,7 +203,6 @@ log=$(git log --format='%B' origin/main 2>/dev/null || true) # checkout leaks into a question about `main`'s history, and `--closing-only` # because its branch-name and `Refs:` fallbacks answer "what does this branch # claim", which is a different question and would readmit the citation. -here=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) # THE LOG GOES ON STDIN, NOT IN ARGV. `main`'s history is 1.27 MB here and an # argv that size is `Argument list too long` — exit 126, which the disjunction # below would have read as "nothing claimed" and reported as a clean column. @@ -212,7 +211,7 @@ here=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) # `claimed-keys`' own documented channel for evidence the caller holds, so this # is the interface it already offers rather than a workaround. The three empty # explicit sources are what stop it reading THIS checkout's HEAD instead. -if ! claimed_ids=$(printf '%s' "$log" | "$here/claimed-keys.sh" --closing-only --branch "" --title "" --log "" 2>/dev/null); then +if ! claimed_ids=$(printf '%s' "$log" | batten claim keys --closing-only --branch "" --title "" --log "" 2>/dev/null); then echo "::error:: claimed-keys could not read main's log, so a claim cannot be told from a mention. That is not a clean board." >&2 exit 2 fi diff --git a/mise-tasks/merged-pr-keys.sh b/mise-tasks/merged-pr-keys.sh deleted file mode 100755 index 43e32b3e6..000000000 --- a/mise-tasks/merged-pr-keys.sh +++ /dev/null @@ -1,121 +0,0 @@ -#!/usr/bin/env bash -#MISE description="Emit the issue keys each MERGED pull request closed, as `` — the evidence landed-check decides on (CLOUD-814)" -# -# CLOUD-814. CLOUD-804 made landedness a disjunction and moved half of it to the -# caller: `landed-check --merged-prs `, refusing with exit 2 when the file -# is absent rather than reporting a column it never checked. That refusal is -# right — at 3% commit-keyword coverage a commits-only reading answers "clean" -# almost always — but NOTHING PRODUCED THE FILE. Measured on `cdc6644`, the only -# two references in the repository were both consumers, and the only thing that -# had ever built one was an ad-hoc script under /tmp that dies with its -# container. So `mise run in-progress-drain` was unrunnable in a fresh clone: -# non-negotiable rule 2 inverted, the gate runnable and its input feedforward. -# -# THIS DECIDES NOTHING, which is what lets it hold a credential. The board-payload -# gates — `landed-check`, `graph-check`, `done-check`, `released`, `done-pr-check`, -# `board-move-guard` — are uniformly gh-free, and that is the agents-fetch-gates- -# decide split rather than an accident. This is the FETCH side: it gathers -# evidence and emits it, `landed-check` remains the one authority on landedness, -# and `claimed-keys` remains the one authority on what a closing keyword is. The -# same split `branch-age-check` draws when it reads the remote's refs. -# -# WHY IT ASKS `claimed-keys` PER PULL REQUEST rather than grepping the bodies -# itself: a second copy of CLAIM_RE is a second authority, and CLOUD-378 was -# filed for applying `claimed-keys` to one side of a comparison and not the -# other. Per-PR invocation is also what keys each answer to its number. It passes -# `--closing-only` (CLOUD-804) because the branch-name and `Refs:` fallbacks -# answer "what does this branch claim", which would readmit the citation this -# whole chain exists to refuse. -# -# TRUNCATION IS THE CORRECTNESS RISK, NOT A DETAIL, and it is measured rather -# than anticipated. `gh pr list --state merged --limit 400` returned exactly 400 -# and cut the range at #161, hiding #170, #337 and #339, which then had to be -# checked one at a time by hand. This repository has 554 merged pull requests. -# -# The direction matters: a truncated evidence file is an UNDER-report, so a row -# whose work landed reads as live work and the drain stops naming it — silently, -# which is the property that makes it worse than an over-report. So the limit is -# explicit and the count is CHECKED AGAINST IT: a result equal to the limit means -# capped, and capped is "could not look", never a short answer. That is the -# general form of the issue's "pages to exhaustion", and it keeps working when -# the repository outgrows whatever number is written here. -# -# An empty result is also could-not-look, copying `branch-age-check`'s reading -# that a remote reporting no branches at all cannot be true of a repository with -# a trunk. An empty evidence file would silently disarm half the disjunction. -# -# Injectable, so the suite runs offline with no `gh` and no network: -# MERGED_PR_KEYS_SOURCE file of the JSON `gh pr list --json number,body` returns -# MERGED_PR_KEYS_LIMIT the fetch limit, so a case can drive the cap cheaply -# -# Exit 0 with rows / 2 could-not-look. There is no exit 1: this reports evidence -# and decides no verdict, so it has no "violation" to report. -# -# The mutations target the two conjuncts a caller cannot see for itself. -#MUTANT merged-pr-keys-ignores-truncation|s@^if \[\[ "\$count" -ge "\$limit" \]\]; then@if false; then@|a reading at the fetch limit is refused as truncated, not returned short -#MUTANT merged-pr-keys-accepts-empty|s@^if \[\[ "\$count" -eq 0 \]\]; then@if false; then@|an empty forge answer is could-not-look, never an empty evidence file -set -euo pipefail - -cannot_look() { - echo "::error:: merged-pr-keys: $1" >&2 - exit 2 -} - -here=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) -limit="${MERGED_PR_KEYS_LIMIT:-5000}" -case "$limit" in -'' | *[!0-9]*) cannot_look "MERGED_PR_KEYS_LIMIT is not a number ('$limit')" ;; -esac - -# --- the reading -------------------------------------------------------------- -if [[ -n "${MERGED_PR_KEYS_SOURCE:-}" ]]; then - raw=$(cat "$MERGED_PR_KEYS_SOURCE" 2>/dev/null) || - cannot_look "cannot read MERGED_PR_KEYS_SOURCE ($MERGED_PR_KEYS_SOURCE)" -else - command -v gh >/dev/null 2>&1 || cannot_look "\`gh\` is not on PATH, so merged pull requests cannot be read. Set MERGED_PR_KEYS_SOURCE to a saved \`gh pr list --json number,body\` payload instead." - raw=$(gh pr list --state merged --limit "$limit" --json number,body 2>/dev/null) || - cannot_look "cannot list merged pull requests — is \`gh\` authenticated?" -fi - -jq -e 'type == "array"' <<<"$raw" >/dev/null 2>&1 || - cannot_look "the merged-pull-request reading is not a JSON array" - -count=$(jq 'length' <<<"$raw") - -# A result AT the limit is indistinguishable from one truncated by it, and the -# truncated reading is the dangerous one. Refuse rather than answer short. -if [[ "$count" -ge "$limit" ]]; then - cannot_look "the forge returned $count pull request(s), which is the fetch limit — the answer is truncated and a truncated evidence file makes landed work read as live. Raise MERGED_PR_KEYS_LIMIT above $limit and run again." -fi - -if [[ "$count" -eq 0 ]]; then - cannot_look "the forge reports no merged pull requests at all, which cannot be true of a repository with a trunk. That is a reachability problem, not an empty answer." -fi - -# --- the extraction, delegated ------------------------------------------------ -# -# One `claimed-keys` call per pull request, with the body on stdin: argv cannot -# carry a body of arbitrary size (`landed-check` met `Argument list too long` at -# 1.27 MB), and stdin is the channel `claimed-keys` documents for exactly the -# evidence a caller holds and it cannot read for itself. -rows="" -while IFS= read -r idx; do - number=$(jq -r ".[$idx].number" <<<"$raw") - case "$number" in - '' | null | *[!0-9]*) cannot_look "a pull request in the reading carries no usable number" ;; - esac - body=$(jq -r ".[$idx].body // \"\"" <<<"$raw") - keys=$(printf '%s' "$body" | "$here/claimed-keys.sh" --closing-only --branch "" --title "" --log "" 2>/dev/null) || - cannot_look "claimed-keys could not judge the body of #$number" - [[ -n "$keys" ]] || continue - while IFS= read -r key; do - [[ -n "$key" ]] || continue - rows+="$key $number"$'\n' - done <<<"$keys" -done < <(jq -r 'keys_unsorted[]' <<<"$raw") - -# Sorted and de-duplicated, so two runs over the same forge are byte-identical. -# Keys and numbers only — never a title or a body, which is where the keyword -# lives and which rule 4 keeps out of a report. -[[ -n "$rows" ]] && printf '%s' "$rows" | sort -u -exit 0 diff --git a/mise-tasks/stop-posture-check.sh b/mise-tasks/stop-posture-check.sh deleted file mode 100755 index 04e270e66..000000000 --- a/mise-tasks/stop-posture-check.sh +++ /dev/null @@ -1,161 +0,0 @@ -#!/usr/bin/env bash -#MISE description="Gate: an end-of-turn message carrying hedged flag-framing, the one output-posture tell AGENTS.md names literally (reads the message on stdin; pointer-only)" -# -# CLOUD-248. AGENTS.md's output-posture section says the failure it kills is -# "writing findings twice, once durably and once as editorial", names the tell in -# as many words — hedged flag-framing, with two literal examples — and then -# concedes that no gate is possible because "hooks see tool calls, not prose". -# That concession is scoped wrong, and CLOUD-200's own body had it right: a -# *PreToolUse* hook sees tool calls, not prose. A `Stop` hook is handed -# `last_assistant_message`, the text of the turn's final response, so the prose -# does pass through a tool boundary after all — just a different one. -# -# So this adds no policy. AGENTS.md already enumerates; what it lacked was an -# exit code. The literal set below is the set that file already writes down. -# -# Why an enumeration is honest HERE when AGENTS.md says "it is a predicate, not a -# list". That sentence is about the rule as *feedforward*: a list in prose invites -# satisfying the list and drifting elsewhere, which is why the previous version -# did not hold. As a *gate* the tradeoff inverts — an incomplete literal set costs -# recall, never precision, and a true positive stays true. Measured over a real -# 33-turn session transcript this fired 3 times with 3 true positives, and one of -# them is witnessed independently rather than by opinion: that turn's flag-framed -# defect was filed nine turns later, so the kick would have closed a nine-turn -# latency. Recall is the weak half and is stated rather than hidden: of the three -# findings whose staleness a later filing witnesses, this catches one, because -# `last_assistant_message` carries only the FINAL text block — measured at 26,893 -# of 60,916 assistant-prose characters, 44%, and two misses sat in earlier blocks. -# -# What was measured and deliberately NOT shipped, so nobody re-derives it: -# - uncommitted / untracked / unpushed at stop. Already enforced one tier up by -# the launcher's own Stop hook (~/.claude/stop-hook-git-check.sh, wired in -# ~/.claude/launcher-settings.json). Duplicating it buys nothing. -# - "green"/"landed" claims joined against a receipt or `git merge-base`. The -# claim is about a past SHA while the world-state half tests current HEAD, so -# the conjuncts are about different objects: 0 true positives, 2 false. -# - deferral-of-a-settled-call. Fired on a turn that deferred two genuinely -# ambiguous calls AGENTS.md sanctions deferring, and missed a plainer one. -# - finding-shape without a durable write (this issue's own conjunct). 1/1 true, -# but it needs the turn's tool-use records, so it must read the transcript — -# which lags the current turn's most recent messages, meaning a late -# `save_issue` reads as "no durable write" and would kick a turn that DID -# file. Report-only until that ordering is settled; it stays CLOUD-248's. -# -# Pointer-only (non-negotiable rule 4), and here that is load-bearing rather than -# ceremonial: the input is a whole assistant message. The report emits the rule -# id, a count, and the matched literal — a parameter of the rule, defined in this -# file — and never a byte of the surrounding sentence. -# -# Exit 0 clean, 1 the predicate fired (reason on stdout), 2 stdin unreadable. -# A gate listed in $MUTANT_GATES with no row here fails `mise run mutant`. -#MUTANT hedged-framing-passes|s/^exit 1$/exit 0/|the first tell AGENTS.md names fires - -set -euo pipefail - -msg=$(cat) || exit 2 -[[ -n "$msg" ]] || exit 0 - -# A message QUOTING the tell is not the message MAKING it, and this is not a -# hypothetical distinction: the sibling `run-shape-guard` denied the very command -# that documented it, twice, before its scrubber covered quoted spans. Code spans, -# fenced blocks, block quotes and double-quoted spans all come out first. -# -# WHOLE-INPUT throughout, because the default line-at-a-time model leaves the -# interior of a wrapped quotation exposed and every one of these spans is -# routinely line-wrapped in real prose — the same defect `run-shape-guard` fixed -# at its own scrubber. The last substitution needs it twice over: it matches `\n` -# inside the pattern space, which a line-based reader never has. -# -# `perl -0777` and not sed's NUL-separated mode (CLOUD-282): `-z` is a GNU -# extension, BSD sed exits `illegal option -- z`, and these are GATES — a macOS -# checkout could not run them at all. Byte-identical for all four substitutions, -# differentially verified against the real bats fixtures and both edges (a -# leading `\n`, a blank line before `>`); only `\1` becomes `$1`. Not awk: -# `awk-regex-check` forbids `-v`-passed regexes, and this repo's dev boxes are -# mawk against a gawk runner. The banned literal is deliberately not spelled -# here — `no-gnu-sed-z` in batten.toml is a substring rule over this directory. -scrubbed=$( - # shellcheck disable=SC2016 # the backticks are literal markdown, not a subshell - printf '%s' "$msg" | - perl -0777 -pe 's/```[^`]*```/FENCED/g' | - perl -0777 -pe 's/`[^`]*`/CODE/g' | - perl -0777 -pe 's/"[^"]*"/QUOTED/g' | - perl -0777 -pe 's/(^|\n)[[:space:]]*>[^\n]*/$1QUOTED/g' -) - -# The literal set is AGENTS.md's own two examples plus their direct inflections. -# Kept deliberately narrow: every entry names an act of flagging rather than any -# use of "note" or "flag", so "I noted the exit code" and "the --flag argument" -# are outside it. Inline in the grep, never through `awk -v` (awk-regex-check). -# -# Both the contracted and the expanded auxiliary, because the first draft carried -# only `I'?d` and its own test caught the miss: "one thing I would flag" is the -# commoner spelling of the phrase AGENTS.md writes contracted. Both apostrophes -# too — a straight one and a typographic one are the same word to a reader. -# -# ONE VERB SET ACROSS THE `worth`/`bears` OPENERS (CLOUD-387), because they used to -# carry two and the difference was an accident of how the alternation was written: -# `worth (noting|flagging)` beside `bears (noting|mentioning)`, so `mentioning` was -# a flagging verb to this file under one opener and unknown under the other. -# Measured before and after, one sentence per row: -# -# before after -# worth noting fires fires -# worth flagging fires fires -# bears noting fires fires -# bears mentioning fires fires -# worth naming SILENT fires <- the witnessed miss -# worth mentioning SILENT fires <- the asymmetry -# bears naming SILENT fires -# it's worth naming SILENT fires -# worth calling out SILENT SILENT <- deliberately still out -# -# `naming` is the witnessed one. The CLOUD-347..356 audit closed its report with -# "One open thread worth naming: the census … never interrogated host settings", -# a real finding that reached chat and nothing else and became CLOUD-380 only -# because a human asked. That sentence is silent here; the same sentence with -# `noting` fires. And nothing else covered it: `stop-guard` consults -# `finding-sink-check` precisely WHEN this rule is silent, and that gate needs a -# `path:line` citation the sentence does not carry — so the turn's one advisory -# slot was not spent, it was never claimed. -# -# `calling out` is the line between completing an inflection and inventing a -# phrase list, and it stays out: unwitnessed, and not already in this file. That -# distinction is the whole reason this is not the unmeasured-literal mistake -# CLOUD-323 and CLOUD-326 forbid — those govern a NEW SHAPE, while every verb -# here is either witnessed or already present, inside a construction measured at -# 3/3. Precision holds by construction: "worth naming" is the same -# opener-plus-communication-verb form as "worth noting". -# -# The `I would flag` / `I should note` family keeps `(flag|note)`, measured and -# deliberately unchanged — "I would name that" is not natural flagging, and -# widening there would be the invention this paragraph exists to refuse. -FLAG_VERB="noting|flagging|mentioning|naming" -HEDGES="worth ($FLAG_VERB)|one thing (I would|I['’]?d) (flag|note)|I['’]?d (flag|note) (that|one)|I would (flag|note) that|I should (note|flag)|(it|that)['’]?s worth ($FLAG_VERB)|bears ($FLAG_VERB)" - -# `-o` on a here-string, never `producer | grep -q`: under pipefail an -# early-exiting grep in a pipeline promotes a MATCH to a failure status -# (pipefail-grep-check). A here-string has no upstream process to signal. -# -# The count is over MATCHES, not matching lines — `grep -c` answers the second -# question and its own test caught that too: two tells in one sentence counted as -# one, which understates exactly the double-write this rule exists to name. -matches=$(grep -oiE "$HEDGES" <<<"$scrubbed" || true) -[[ -n "$matches" ]] || exit 0 -hits=$(wc -l <<<"$matches" | tr -d ' ') - -matched=$(sort -u <<<"$matches" | tr '\n' '|') - -cat </dev/null) || exit 0 +refs=$(gh api graphql \ + -f query='query($owner:String!,$name:String!){ + repository(owner:$owner,name:$name){ + refs(refPrefix:"refs/heads/",first:100){ + nodes{ name target{ ... on Commit { committedDate } } } + } + } + }' \ + -F owner="${slug% *}" -F name="${slug#* }" \ + --jq '.data.repository.refs.nodes[] | [.name, .target.committedDate] | @tsv' 2>/dev/null) || exit 0 +prs=$(gh pr list --state merged --limit 200 --json headRefName --jq '.[].headRefName' 2>/dev/null) || exit 0 + +{ + printf '%s\\n' "$refs" | awk -F'\\t' -v today="$today" -v trunk="$trunk" ' + function days(y, m, d, era, yoe, doy, doe) { + if (m <= 2) y -= 1 + era = int((y >= 0 ? y : y - 399) / 400) + yoe = y - era * 400 + doy = int((153 * (m + (m > 2 ? -3 : 9)) + 2) / 5) + d - 1 + doe = yoe * 365 + int(yoe / 4) - int(yoe / 100) + doy + return era * 146097 + doe - 719468 + } + function daynum(iso, p, s) { + s = substr(iso, 1, 10) + if (s !~ /^[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]$/) return "NaN" + split(s, p, "-") + return days(p[1] + 0, p[2] + 0, p[3] + 0) + } + BEGIN { now = daynum(today) } + $1 == "" { next } + $1 == trunk { next } + { d = daynum($2); if (d != "NaN") print "ref\\t" $1 "\\t" (now - d) } + ' + printf '%s\\n' "$prs" | sed '/^$/d' | awk -v trunk="$trunk" '$0 != trunk { print "merged\\t" $0 }' +} | cargo run --quiet -p batten -- record named branch-age +""" + [tasks.record-verdicts] description = "Effect: run each declared third-party validator OUTSIDE the engine and record its verdict where `batten check` reads it (CLOUD-1265)" # THE HALF THAT WAS MISSING. `crates/batten/src/tools.rs` reads @@ -1854,8 +1918,12 @@ description = "Effect: run each declared third-party validator OUTSIDE the engin # reads `.git/batten-forge/`; both shipped with no writer but a test, so # `validator-verdict-clean` and `forge-verdict-required` — two registered # `severity = "deny"` rows — resolved `null` on every real checkout and decided -# nothing. Each row says so at its own site in `batten.toml`: "SILENT UNTIL A -# PRODUCER WRITES." +# nothing. +# +# BOTH HALVES ARE WRITTEN HERE NOW. The tool half landed with this task +# (CLOUD-1265); the forge half is the arm at the end of this body (CLOUD-1707), +# and until it landed this file closed one of the two gaps its own header +# described and left the other open. # # THE RUN IS HERE BECAUSE IT CANNOT BE IN THE ENGINE. House style §5 makes # `check` `read` and structurally incapable of spawning, so the validator stays a @@ -1965,6 +2033,72 @@ jq -r --slurpfile full "$full" ' | ($full[0].steps[] | select(.name == $name) | .status) as $under | "\($name) \($under // "absent")" ' <"$fast" | record hk-plan + +# THE FORGE'S OWN VERDICT, and the row it feeds had never been fed (CLOUD-1707). +# `crates/batten/src/forge.rs` reads `.git/batten-forge/` and +# `batten record forge` writes it, but nothing invoked the writer — so +# `input.tree.forge` was `null` on every checkout, the module's `is_object` guard +# never held, and `forge-verdict-required` decided NOTHING while registered at +# `severity = "deny"`. A dead gate is byte-identical to a clean tree. +# +# THE REF IS `HEAD`, AND THAT IS THE WHOLE DECLARED SET. Two rows read this fact +# — `forge-verdict-required` and the re-grade row above it — and each declares +# exactly `forge = ["HEAD"]`. Recording HEAD is scoped to what is asked rather +# than narrowed below it. A row that later declares a second ref must add it here +# in the same change; an under-scoped record is the same silent pass in a new +# costume. +# +# THE FAN-IN IS THE GATE ON WRITING AT ALL, and this is the load-bearing half. +# `$CI_FANIN_CHECK` is the check every other required job feeds, so until IT has +# concluded the forge has not finished judging this commit. Recording a partial +# reading would be a record PRESENT without a passing fan-in, which +# `forge-verdict-required` refuses — and that refusal would fire in the two places +# it must never fire: +# * local `verify`, which runs against a freshly minted SHA CI has not graded, +# turning every pre-push check red; +# * CI itself, where `verify` runs while the fan-in is by construction still +# pending, so the record would refuse the very run that wrote it. +# Absent is could-not-look and is the correct reading for both. So a failed fetch +# writes nothing, and a commit whose fan-in has not concluded writes nothing. +# +# A NAME WITH A SPACE CANNOT BE SPELLED HERE, and is dropped rather than mangled. +# The record's byte format is ` ` split on the first whitespace run, +# so `action (ubuntu-latest) success` would record the name `action` with the +# conclusion `(ubuntu-latest)` — a wrong verdict under a name three checks collide +# on. Nine of `$CI_REQUIRED_CHECKS` carry spaces. The fan-in both rows read is one +# token by construction, so nothing a consumer reads is lost; the COUNT of dropped +# names goes to stderr (rule 4 — a count, never the payload) so the limit stays +# visible rather than silent. +forge_sha=$(git rev-parse HEAD 2>/dev/null || true) +forge_fanin="${CI_FANIN_CHECK:-}" +if [ -n "$forge_sha" ] && [ -n "$forge_fanin" ]; then + forge_repo="${REPO:-}" + [ -n "$forge_repo" ] || forge_repo='{owner}/{repo}' + # LATEST RUN PER NAME, and the reduction belongs here rather than in the + # reader. A re-run adds a second check-run under the SAME name, so the raw + # listing carries `fast-forward-release-pr success` and `… skipped` for one + # commit — and `forge::parse` folds a record into a map, so whichever line came + # last would win by listing order rather than by recency. `checks-green` draws + # the same distinction with `started_at` and `id`; this is that reduction, + # spelled once, at the producer. + if forge_runs=$(gh api "repos/$forge_repo/commits/$forge_sha/check-runs?per_page=100" \ + --jq '.check_runs | map(select(.conclusion != null)) | group_by(.name) | map(max_by([(.started_at // ""), (.id // 0)])) | .[] | "\(.name)\t\(.conclusion)"' 2>/dev/null); then + # The fan-in decides whether ANY record is written; the name filter then + # decides what the record may spell. + forge_graded=$(printf '%s\n' "$forge_runs" | + awk -F'\t' -v fanin="$forge_fanin" 'NF == 2 && $1 == fanin {found = 1} END {print found + 0}') + if [ "$forge_graded" -eq 1 ]; then + forge_dropped=$(printf '%s\n' "$forge_runs" | + awk -F'\t' 'NF == 2 && $1 ~ /[[:space:]]/ {n++} END {print n + 0}') + if [ "$forge_dropped" -gt 0 ]; then + echo "record-verdicts: $forge_dropped check-run name(s) carry a space and have no spelling in a forge record; dropped" >&2 + fi + printf '%s\n' "$forge_runs" | + awk -F'\t' 'NF == 2 && $1 !~ /[[:space:]]/ {print $1 " " $2}' | + cargo run --quiet -p batten -- record forge HEAD + fi + fi +fi ''' # The perf producer and the perf gate (CLOUD-207, ported off @@ -4318,7 +4452,7 @@ shell = "bash -c" # uses ${sha # no others: a bot commit reaching anything else is not a bump and is not exempt. run = ''' fail=0 -keys="$(git rev-parse --show-toplevel)/mise-tasks/claimed-keys.sh" + for sha in $(git rev-list --no-merges "${BASE_SHA:?}..${HEAD_SHA:?}"); do subject=$(git show -s --format=%s "$sha") # The subject-convention half is NOT here: it is `commit-check`, above in @@ -4346,7 +4480,20 @@ for sha in $(git rev-list --no-merges "${BASE_SHA:?}..${HEAD_SHA:?}"); do # extra evidence from stdin whenever stdin is not a tty, so under a task runner # — where it never is — a call with nothing piped BLOCKS FOREVER. Measured here # as a two-minute harness kill. Same shape as CLOUD-511's bare `cat`. - if [ -z "$("$keys" --log "$(git show -s --format='%B' "$sha")" /dev/null)" ]; then + # `batten claim keys`, not the retired `claimed-keys.sh` (CLOUD-1711). The + # binding this replaces pointed at a deleted path, so the command emitted + # nothing and every commit read as claiming no issue — including ones carrying + # a `Refs:` trailer, which is the tell: a missing program's empty output is + # byte-identical to a message that names no row. + # + # `cargo run`, NEVER A BARE `batten`, and the first repair got this wrong in a + # way only CI could show: a bare name resolves wherever the binary happens to be + # installed, so the gate passed locally and failed on the runner — accusing four + # commits that carry `Refs: CLOUD-1688`. That is the SAME false negative one + # environment over, which is why the form matters rather than being a style + # choice: 52 other call sites in this file spell it this way and none of them + # depends on what is on PATH. + if [ -z "$(cargo run --quiet -p batten -- claim keys --branch "" --title "" --log "$(git show -s --format='%B' "$sha")" /dev/null)" ]; then echo "::error::Commit ${sha:0:8} claims no CLOUD- issue: '$subject' — add a 'Refs: CLOUD-' trailer naming the work it serves" fail=1 fi diff --git a/policy/branch-age.rego b/policy/branch-age.rego new file mode 100644 index 000000000..ea710caa9 --- /dev/null +++ b/policy/branch-age.rego @@ -0,0 +1,215 @@ +# No remote branch outlives its story (CLOUD-349, ported under CLOUD-1717). +# +# Trunk-based development says a review branch "can (and should) be deleted after +# the code review is complete and be very short-lived", and names the hazard this +# measures: a short-lived feature branch "sleepwalking into a long-lived feature +# branch". Its own words on the tooling — "You cannot with tools today, but it +# would be cool if you could have a ticking clock or count down on those branches +# at creation to enforce its 'temporary' intention." This is that clock, after +# the fact. +# +# TWO PROPERTIES, because staleness is the smaller half: +# +# stale a branch whose tip is older than the threshold below. The ordinary +# leftover. Measured 2026-08-11, before `land` learned to delete: 23 +# remote branches, ten of them `release-plz-*` from five days earlier. +# reused a branch NAME heading more than one merged pull request AND still +# present on the remote. The second conjunct is what keeps this a gate +# rather than a permanent alarm: merged PRs are immutable, so an +# unintersected count could never be cleared by any action. This is +# the one a per-PR lifetime metric cannot see — +# `claude/phase-3-sequential-landing-h26kx0` headed eight consecutive +# pull requests, each landing inside an hour, while the branch itself +# lived for days. +# +# THE CLOCK IS THE PRODUCER'S, NOT THIS MODULE'S, and that split is forced rather +# than chosen. `Fact::Instant` is consumed at the boundary and projects `null` to +# every module — the engine calls no `SystemTime::now` on any evaluation path, +# which `clippy.toml`'s `disallowed-methods` and `crates/batten/tests/clock_ban.rs` +# hold it to. So the record carries an AGE IN DAYS that the producer computed, +# and what moves in here is the decision: an age over the threshold is stale. +# CLOUD-1559's reading rule says the same thing from the other side — carry the +# decisions, not the steps. The retired program's civil-calendar day arithmetic +# is a step, and it stays outside with the `gh` call that needs it. +# +# COULD-NOT-LOOK IS AN ABSENT RECORD, and it is silence rather than a pass. The +# producer writes nothing when it cannot reach the forge, so `records` carries no +# `branch-age` key, every rule below is undefined and the module says nothing. +# A record that is PRESENT and holds no `ref` line is a different state: the +# producer looked and the remote reported no branches, which the retired program +# refused outright as impossible of a repository with a trunk. That refusal is +# kept, because a silent pass there is the shape where the whole gate evaporates. +#MUTANT-EXEMPT CLOUD-1717|the compiled-binary tier this module's mutations would redden, `crates/batten/tests/it/branch_age.rs`, is not written yet: the module does not decide, because `recorder_records` projects no `record named` family and `input.tree.records["branch-age"]` never reaches it. The four mutations are drafted in this file's history and go back with the tier, in the same delta that retires `mise-tasks/branch-age-check.sh` — the program stays until then, so nothing is uncovered that was covered before. + +# METADATA +# description: | +# Bound to the TREE surface: this row is `scope = "tree"`, so it reads +# `input.tree` and never the mediated call. +# THIS BLOCK IS YAML AND MUST STAY THE LAST COMMENT BLOCK BEFORE `package`. +# schemas: +# - input: schema["policy-input.schema"] +package batten.branch_age + +import rego.v1 + +rules contains "branch watch stale" + +rules contains "branch name duplicate" + +rules contains "branch list empty" + +# The source's "a couple of days", as a number. +# +# In the module rather than in config, on `repetition-without-progress`'s +# reasoning one row over: this is the practice's own figure rather than this +# consumer's tuning, and a config knob would invite raising it until nothing +# fires. Moving it costs a diff a reviewer reads. +threshold := 2 + +# The producer's lines, or nothing. `recorded` being undefined is the +# could-not-look state the header describes, and every rule below inherits it. +recorded := input.tree.records["branch-age"] + +# `ref ` — one per branch the remote reports, trunk excluded +# by the producer because the trunk is not a review branch. +refs contains {"name": columns[1], "age": to_number(columns[2])} if { + some raw in recorded + columns := split(raw, "\t") + count(columns) == 3 + columns[0] == "ref" + regex.match(data.batten.patterns["whole-number"], columns[2]) +} + +# A line the reader cannot parse is skipped rather than judged — the same posture +# every other record reader here takes, because the producer already refused a +# malformed line and anything unparseable at read time is a torn store. +on_remote contains entry.name if { + some entry in refs +} + +# `merged ` — one per merged pull request, so a name heading several +# appears several times and the cardinality is the reading. +merged_heads contains [index, columns[1]] if { + some index, raw in recorded + columns := split(raw, "\t") + count(columns) == 2 + columns[0] == "merged" +} + +merge_count(name) := count([pair | some pair in merged_heads; pair[1] == name]) + +violation contains { + "rule": "branch watch stale", + "verdict": "branch watch stale", + "subjects": [{"artifact": entry.name}, {"count": entry.age}], +} if { + some entry in refs + age := entry.age + age > threshold +} + +# THE SURVIVOR CONJUNCT IS THE WHOLE GATE. A merged pull request is immutable, so +# a name that headed two of them heads two of them forever; refusing on the count +# alone would be an alarm no action could clear, which is the shape that gets a +# gate switched off. Intersecting with what the remote still carries makes the +# remedy `git push --delete`. +violation contains { + "rule": "branch name duplicate", + "verdict": "branch name duplicate", + "subjects": [{"artifact": reused}, {"count": merge_count(reused)}], +} if { + some pair in merged_heads + reused := pair[1] + merge_count(reused) > 1 + count([name | some name in on_remote; name == reused]) > 0 +} + +# A PRESENT RECORD NAMING NO BRANCH IS A REFUSAL, never a clean board. The +# retired program said why: a remote reporting no branches at all "cannot be true +# of a repository with a trunk", so the honest reading is that the listing failed +# in a way that still exited zero. Absent is could-not-look; present-and-empty is +# a lie, and the two must not collapse. +violation contains { + "rule": "branch list empty", + "verdict": "branch list empty", +} if { + recorded + count(refs) == 0 +} + +# --- cases --------------------------------------------------------------- + +tree(lines) := {"tree": {"records": {"branch-age": lines}}} + +test_a_branch_older_than_the_threshold_is_stale if { + some v in violation with input as tree(["ref\tclaude/old\t9"]) + v.verdict == "branch watch stale" +} + +# ONE BELOW THE THRESHOLD IS CLEAN, and an off-by-one here moves the whole +# population the rule fires on. +test_a_branch_at_the_threshold_is_not_stale if { + count(violation) == 0 with input as tree(["ref\tclaude/fresh\t2"]) +} + +test_the_threshold_is_the_sources_couple_of_days if { + count(violation) == 0 with input as tree(["ref\tclaude/fresh\t2"]) + some v in violation with input as tree(["ref\tclaude/old\t3"]) + v.verdict == "branch watch stale" +} + +# POINTER, NEVER PAYLOAD: a branch NAME and a COUNT of days, which is what the +# remedy needs and nothing more. +test_the_finding_carries_a_name_and_a_count if { + some v in violation with input as tree(["ref\tclaude/old\t9"]) + v.subjects == [{"artifact": "claude/old"}, {"count": 9}] +} + +test_a_name_heading_two_merged_pull_requests_and_still_on_the_remote_is_reused if { + some v in violation with input as tree([ + "ref\tclaude/reused\t1", + "merged\tclaude/reused", + "merged\tclaude/reused", + ]) + v.verdict == "branch name duplicate" +} + +# THE CONJUNCT THAT KEEPS THIS CLEARABLE. Merged pull requests are immutable, so +# without the survivor test this fires forever on history nobody can change. +test_a_reused_name_whose_branch_is_gone_is_not_reported if { + count(violation) == 0 with input as tree([ + "ref\tclaude/other\t1", + "merged\tclaude/deleted", + "merged\tclaude/deleted", + ]) +} + +test_a_name_heading_one_merged_pull_request_is_not_reused if { + count(violation) == 0 with input as tree([ + "ref\tclaude/once\t1", + "merged\tclaude/once", + ]) +} + +test_a_present_record_naming_no_branch_is_refused if { + some v in violation with input as tree(["merged\tclaude/gone"]) + v.verdict == "branch list empty" +} + +# COULD NOT LOOK IS NOT INNOCENCE, and it is not guilt either. The producer +# writes nothing when the forge is unreachable, and a module that refused there +# would refuse every checkout with no credential. +test_no_record_at_all_says_nothing if { + count(violation) == 0 with input as {"tree": {"records": {}}} +} + +# A SURVIVING GOOD LINE IS PART OF THE CASE, not scenery: without it the record +# holds no readable ref and `branch list empty` fires, which would let this case +# pass for a reason that has nothing to do with skipping. +test_a_line_this_reader_cannot_parse_is_skipped if { + count(violation) == 0 with input as tree([ + "ref\tclaude/fresh\t1", + "ref\tclaude/x\tnot-a-number", + "nonsense", + ]) +} diff --git a/policy/run-shape.rego b/policy/run-shape.rego index 8ae86fcdb..f6784b55d 100644 --- a/policy/run-shape.rego +++ b/policy/run-shape.rego @@ -17,22 +17,21 @@ # foreground-sleep the harness kills a foreground call at ~2 # minutes, so a patient poll FAILS rather than # waits (CLOUD-482, exit 143 and 144 measured). -# background-timer a backgrounded `sleep N; tail log` exits on +# timer run refused a backgrounded `sleep N; tail log` exits on # the clock, never on the event — and the # event already notifies (CLOUD-821: 490 such # calls in one session, 2 changed a decision). # -# THE BASH STILL RUNS, and that is the ratchet rather than an oversight. -# `shell-retirement` admits DELETING a governed file and refuses SHRINKING one, -# and `run-shape-guard.sh` keeps a fourth family (`cargo-substitutes-for-a-task`) -# whose blocker is CLOUD-856. So the guard cannot lose these three until it can -# lose all four, and both authorities decide them until it does. CLOUD-1108 owns -# that gap; the predicates below are written from the bash's own decision table, -# with ONE deliberate divergence — this reaches a sleep inside a loop body and -# `resolve()` does not (CLOUD-1112) — which is in the DENYING direction, so no -# call gets a weaker answer from the pair than it had from the guard alone. The -# divergence stands; only its mechanism moved, from `keywords` stepping past a -# `do` token to the body arriving as its own segment (CLOUD-1381). +# THE BASH IS GONE AND THIS MODULE IS SOLE AUTHORITY. `mise-tasks/run-shape-guard.sh` +# is retired, so the paragraph that used to stand here — both authorities decide +# these families, with one deliberate divergence in the denying direction — is no +# longer a description of the tree and has been removed rather than left reading +# as live. What it recorded that still matters: the guard's `resolve()` never +# reached a sleep inside a loop body (CLOUD-1112), so the whole family passed a +# looped sleep for want of a resolvable sleep rather than for any reason about +# waiting. Reaching the body was the precondition for CLOUD-1337 withdrawing the +# condition exemption; the mechanism is now the body arriving as its own segment +# (CLOUD-1381) rather than `keywords` stepping past a `do` token. # # TWO ERAS OF INPUT LIVE HERE, deliberately, and the newer one is the model. # `commit-names-no-message-source` landed before `hook::segments` was projected, @@ -75,8 +74,13 @@ rules contains "redirect write unread" # `sleep` or `git` token survives, because every ALLOW row already fails some # other conjunct. Each of these corrupts the conjunct that carries the verdict. #MUTANT redirect-binding-ignored|s@^ segment\["input-redirect"\] == false@ true@|a_redirect_bound_to_the_commits_own_element_is_a_message_source -#MUTANT background-not-consulted|s@^ input.call\["run-in-background"\] != true@ true@|a_backgrounded_wait_on_a_condition_is_allowed -#MUTANT loop-is-not-an-exemption|s@^ not waits_on_condition@ true@|a_bare_sleep_beside_a_condition_loop_is_exempt +#MUTANT background-not-consulted|s@^ input.call\["run-in-background"\] != true@ true@|a_backgrounded_bare_sleep_raises_only_the_timer +# The mutation restores the exemption this row removed: with the partition term +# forced false, a backgrounded `sleep` loop carrying a condition falls through +# `timer run refused` exactly as it did before, and only a case asserting THAT +# shape is refused can see it. Every process-polling row still denies under it, +# via the sibling arm — which is what made the hole survive its own suite. +#MUTANT condition-is-an-exemption|s@^ count(process_probes) == 0@ false@|a_backgrounded_conditioned_sleep_loop_is_refused # THE FOUR MUTATIONS, and the last three are the ones worth having: they corrupt # the SCRUBBING and the SPLITTING rather than the flag table, which is where a @@ -147,23 +151,42 @@ violation contains { } if { sleeps input.call["run-in-background"] == true - not waits_on_condition + count(process_probes) == 0 } # A backgrounded wait that polls the LOCAL PROCESS TABLE (CLOUD-1337). # -# `waits_on_condition` above exempts a `sleep` loop from `background-timer` on -# sound reasoning: a loop testing a condition exits on the condition rather than -# on the clock. That holds for a condition NOTHING ELSE REPORTS — a CI run, a -# remote queue, a file another machine writes. It does not hold for a local -# process, because the harness already re-invokes the caller when a backgrounded -# task exits. Polling one duplicates a notification that is guaranteed to fire. +# THE CONDITION EXEMPTION IS GONE, AND THIS ARM IS WHAT SURVIVES IT. # -# THE EXEMPTION ASKS WHETHER THERE IS A CONDITION, NEVER WHAT IT IS ABOUT, and +# `waits_on_condition` used to exempt a `sleep` loop from `background-timer`, on +# reasoning that reads well and does not hold: a loop testing a condition exits on +# the condition rather than on the clock, so a condition NOTHING ELSE REPORTS — a +# CI run, a remote queue, a file another machine writes — looked like a legitimate +# wait. It is not, and AGENTS.md says why in the same breath it bans timers: **the +# exit notification IS the wake-up**, a backgrounded task re-invokes its caller +# when it exits, and the turn in between is the designed state rather than one to +# fill. A hand-rolled poll over ANY condition duplicates a notification the +# harness already guarantees; `ci-wait` and `main-watch` exist for the two +# conditions that genuinely need a poll, and both are tasks that notify on exit. +# +# THE EXEMPTION ASKED WHETHER THERE WAS A CONDITION, NEVER WHAT IT WAS ABOUT, and # `until` was the escape. AGENTS.md has carried the rule since CLOUD-821, with the # measurement — "490 in one session, 2 changed a decision" — and the claim that -# the shape is "refused by `run-shape-guard`". It was not. This arm is what makes -# that sentence true rather than something to soften. +# the shape is "refused by `run-shape-guard`". With the exemption in place that +# sentence was false for every conditioned wait; `timer run refused` now reaches +# them and it is true. +# +# MEASURED 2026-09-09, this session: `until grep -q '#' && ! pgrep -f +# ''; do sleep 5; done` ran **3h34m**, spending a wake-up every five seconds +# while its own output had been read in the first minute. It was denied in the +# FOREGROUND and, once backgrounded, fell through every arm — the exemption held +# because the loop had a condition. Found by a human reading `ps`, which is the +# second time (CLOUD-1337 was the first). +# +# THIS ARM STAYS SEPARATE rather than collapsing into the wider one, because the +# count it carries is the diagnostic: a compound polling two processes is two +# duplications. The two arms PARTITION on `count(process_probes)`, so one call +# yields one finding. # # MEASURED 2026-09-02: eleven of these ran on one container, the oldest 9h35m, # while exactly one real job existed. @@ -183,7 +206,6 @@ violation contains { } if { sleeps input.call["run-in-background"] == true - waits_on_condition count(process_probes) > 0 } @@ -272,38 +294,6 @@ sleeps if { basename(segment.words[words_program_index(segment.words)]) == "sleep" } -# OVER THE WHOLE CALL, never one segment, because a loop's keyword and its sleep -# are in different segments. -# -# AND IT IS LOAD-BEARING HERE, which it is not in the bash. There the canonical -# `until ; do sleep 1; done` was allowed because no sleep resolved at all, -# so this conjunct decided nothing and read as coverage (CLOUD-1112). With the -# loop body reached, this is the only thing standing between that command and a -# refusal — which is what CLOUD-613's acceptance always claimed it was. -# -# `for` is NOT a wait. `for i in $(seq 60); do sleep 10; done` counts iterations -# rather than testing a condition, so it exits on the clock like any timer; the -# bash names it a deliberate non-catch "because narrowing that costs a real -# parser", and it costs none now. -# DECIDED FROM THE NODE, never from a keyword (CLOUD-1381). -# -# This read `word in {"until", "while"}` over a segment's words, and that only -# ever worked because the character walk split on `;` and had no idea what a loop -# was -- so `until` and `do` and `done` fell out as ordinary words. A real parse -# has no such token: the keyword IS the node type. `input.call.segments[_] -# .construct` carries it, `null` at the top level. -# -# Reading the node is also a tightening rather than a translation. `for` is -# excluded because it is a DIFFERENT NODE, not because a list of words happens to -# omit it -- `for i in $(seq 60); do sleep 10; done` counts iterations and exits -# on the clock like any timer. And a `!` between the keyword and the test needed -# filtering out by hand before; it is inside the condition now and never reaches -# this predicate. -waits_on_condition if { - some segment in input.call.segments - segment.construct.kind in {"until", "while"} -} - # Every reader of the LOCAL process table in this call. # # The set is the programs whose whole purpose is answering "is this process still @@ -533,17 +523,12 @@ wrappers := {"env", "command", "nice", "stdbuf", "timeout", "xargs", "sudo", "do # SHELL KEYWORDS THAT INTRODUCE A COMMAND, looked through for the same reason # every wrapper above is: what runs after them is the call being judged. # -# `run-shape-guard.sh`'s `resolve()` has no such set, and CLOUD-1112 measured +# The retired bash guard's `resolve()` had no such set, and CLOUD-1112 measured # what that costs: `do sleep 1` resolved to the program `do`, so a sleep in a -# loop body was invisible — and `waits_on_condition` therefore exempted nothing, -# because the canonical `until ; do sleep 1; done` was already allowed for -# want of a resolvable sleep rather than for being a wait. The guard's own -# comment claims the opposite ("the one carrying the sleep has no keyword in -# it"), which only parses if that element IS reached. -# -# CLOUD-613's acceptance turns on that allow being LOAD-BEARING, so porting the -# gap would have satisfied the clause vacuously. This is the narrower reading: -# the engine resolves the loop body, and the exemption is what decides it. +# loop body was invisible and the whole family passed a looped sleep for want of +# a resolvable sleep rather than for any reason about waiting. That is why the +# withdrawal of the condition exemption (CLOUD-1337) needed the look-through +# first: without it there would be nothing for the arms below to refuse. # # **VESTIGIAL SINCE CLOUD-1381, and said so rather than left reading as live.** # The engine emits none of these as words any more: each is a NODE, and a @@ -839,12 +824,15 @@ test_a_liveness_signal_is_the_same_question if { v.verdict == "task watch duplicate" } -# THE ANTI-VACUITY MIRROR, and without it every case above is satisfied by a rule -# that refuses all waits. A condition the harness does NOT report stays allowed — -# that is the whole narrowing, and `timer run refused`'s route still recommends -# this shape for it. -test_a_wait_on_a_condition_nobody_reports_is_clean if { - count(violation) == 0 with input as {"call": { +# THE PARTITION'S OTHER SIDE, and it used to be this family's anti-vacuity +# mirror: a condition the harness does not report — a remote readiness probe +# rather than a local process — was the one wait left allowed. CLOUD-1337 removed +# that allow, so what this case now pins is narrower and still worth pinning: the +# two arms must not BOTH fire, and the one that answers a non-process condition +# must be `timer run refused` rather than `task watch duplicate`. A rule that +# refused every wait under one verdict would fail this. +test_a_wait_on_a_condition_nobody_reports_is_a_timer_not_a_poll if { + count(violation) == 1 with input as {"call": { "command": "until curl -sf https://example.test/ready; do sleep 5; done", "run-in-background": true, "segments": [ @@ -852,6 +840,15 @@ test_a_wait_on_a_condition_nobody_reports_is_clean if { inner(["sleep", "5"], "until", "body", null), ], }} + some v in violation with input as {"call": { + "command": "until curl -sf https://example.test/ready; do sleep 5; done", + "run-in-background": true, + "segments": [ + inner(["curl", "-sf", "https://example.test/ready"], "until", "condition", ";"), + inner(["sleep", "5"], "until", "body", null), + ], + }} + v.rule == "timer run refused" } # A PROCESS READ WITH NO LOOP IS NOT A WAIT. `mise run alive` asks once and @@ -865,8 +862,14 @@ test_a_process_read_outside_a_loop_is_not_a_wait if { }} } -test_a_backgrounded_wait_on_a_condition_is_allowed if { - count(violation) == 0 with input as {"call": { +# THE WITHDRAWN EXEMPTION (CLOUD-1337). This case asserted `count(violation) == +# 0` for as long as the condition was an exemption. It is inverted rather than +# deleted, because a deleted case documents nothing and this is the exact shape +# the rule now exists to catch: the condition makes the loop exit on the thing +# rather than on the clock, and does not make it stop being a hand-rolled copy of +# a notification the runtime already delivers. +test_a_backgrounded_wait_on_a_condition_is_refused if { + some v in violation with input as {"call": { "command": "until [ -f /tmp/done ]; do sleep 1; done", "run-in-background": true, "segments": [ @@ -874,6 +877,7 @@ test_a_backgrounded_wait_on_a_condition_is_allowed if { inner(["sleep", "1"], "until", "body", null), ], }} + v.rule == "timer run refused" } # A FOREGROUND loop spends the turn exactly as a foreground `sleep` does, and it @@ -904,10 +908,13 @@ test_a_backgrounded_counting_loop_is_a_timer if { v.rule == "timer run refused" } -# The exemption's other reachable shape: a bare sleep and a loop keyword in one -# backgrounded call, where the sleep resolves without any look-through at all. -test_a_bare_sleep_beside_a_condition_loop_is_exempt if { - count(violation) == 0 with input as {"call": { +# THE EXEMPTION'S WORST REACHABLE SHAPE, and the case that says why asking +# WHETHER there is a condition was never the right question (CLOUD-1337). The +# `sleep 5` here waits on nothing at all — the loop beside it has an empty body — +# so the old rule exempted a bare timer for the company it kept. Inverted rather +# than deleted: this is the shape the withdrawal is FOR. +test_a_bare_sleep_beside_a_condition_loop_is_refused if { + some v in violation with input as {"call": { "command": "sleep 5; until [ -f /tmp/done ]; do :; done", "run-in-background": true, "segments": [ @@ -916,6 +923,7 @@ test_a_bare_sleep_beside_a_condition_loop_is_exempt if { inner([":"], "until", "body", null), ], }} + v.rule == "timer run refused" } # THE DISCRIMINATING CASE for `run-in-background`: both rules deny, so only the diff --git a/policy/shell-retirement.rego b/policy/shell-retirement.rego index 936d59546..f281f0a30 100644 --- a/policy/shell-retirement.rego +++ b/policy/shell-retirement.rego @@ -283,8 +283,100 @@ violation contains { governed_at_head(path) not only_drops_a_retired_reference(path) not only_rewrites_a_renamed_id(path) + not only_supplies_the_successors_precondition(path) +} + +# THE SECOND ADMITTED EDIT, and it is CLOUD-1051's arm one surface further out. +# +# That arm exists because retiring a program requires editing the SIBLINGS that +# declare it. This one exists because repointing those siblings can invalidate +# their own governed SUITES — and the module admitted the repoint while refusing +# the change the repoint forced, so it could not complete a retirement it had +# itself mandated. The same structural gap, found the same way: by a campaign +# hitting a wall it built. +# +# MEASURED 2026-09-09 (CLOUD-1711). Retiring `claimed-keys.sh` moved the issue-key +# grammar out of inline shell and into the `[[pattern]]` registry, which is where +# non-negotiable rule 1 says a consumer fact belongs. The shell answered in ANY +# tree; the engine leaf resolves the grammar from the committed config and answers +# could-not-look without one. Every suite whose fixture is a bare scratch repo +# therefore stopped exercising its gate — and the cases that assert a REFUSAL went +# green, because a gate with nothing to judge refuses nothing. Fourteen such cases +# across four suites, all four green on `origin/main`, verified in a clean +# worktree rather than argued. +# +# THE NARROWING IS WHAT KEEPS THIS A RATCHET. `shell edit refused` is the arm that +# refuses the move which READS as progress and is not, so widening it to "an edit +# that adds lines" would be the ratchet with a lie in it. Every ADDED line must +# either be a comment — which cannot change what a suite exercises — or name the +# committed config or the declared successor's own invocation. An author cannot +# reach for this to change a program's behaviour, because a line that changes +# behaviour names neither. +# +# REMOVALS STAY THE OTHER ARM'S BUSINESS. This one admits additions only; an edit +# that both adds a precondition and drops a line has to earn the drop under +# `only_drops_a_retired_reference` exactly as before. +only_supplies_the_successors_precondition(path) if { + base := delta["base-lines"][path] + head := {line | some line in input.tree.lines[path]} + added := {line | some line in head; not line in base} + + # An edit that added nothing is not this case — it removed or reordered, and + # the sibling arm above owns the first. + count(added) > 0 + + # EVERY REMOVAL STILL EARNS THE SIBLING ARM, unchanged. A suite that both drops + # a retired program's stub and gains the grammar its successor reads is doing + # ONE thing: `tests/in-progress-drain.bats` fails 26 cases without the config + # line, measured, so forbidding removals here would refuse the very suite this + # arm exists for. + removed := {line | some line in base; not line in head} + count({line | + some line in removed + admitted_removal(path, line, removed) + }) == count(removed) + + count({line | + some line in added + supplies_a_precondition(line) + }) == count(added) } +# A comment cannot change what the suite exercises, so it carries the reason. +supplies_a_precondition(line) if { + startswith(trim_space(line), "#") +} + +# The committed config, which holds the grammar a retired program carried inline +# and its successor reads from the `[[pattern]]` registry instead. +# +# THE NAME IS A LITERAL HERE, and that is the narrow reading rather than a lapse. +# `batten.toml` is the ONE committed authority (house-style §8) — there is no +# second spelling for this module to be a second authority over — and an +# undefined reference in Rego is not an error but a clause that never holds, so +# reaching for a token this module does not define would have made the whole arm +# silently dead. That is the failure this file's own `#MUTANT` rows exist to +# catch, and it is cheaper to spell the name than to ship an arm that decides +# nothing. +supplies_a_precondition(line) if { + contains(line, "batten.toml") +} + +# THERE IS NO THIRD SHAPE, and the one that was here is why this comment is. +# +# A draft also admitted an added line naming the retired path's declared successor +# invocation. It reads as the obvious third case and it is the hole: a REPOINT +# rewrites a line to name the successor, so every such edit satisfied it — and +# the module's own suite said so, `a_repointing_that_also_changes_the_rest_of_the_line_is_refused` +# and `test_replacing_a_span_that_is_not_a_retired_reference_is_refused` both +# going green. Those two exist to refuse an edit that repoints AND changes the +# rest, which is precisely what the clause admitted. +# +# A repoint is `repoints_at_the_declared_invocation`'s business and already +# polices that it changes nothing else. What is left here — a comment, or the +# committed config — is the set a rewritten line cannot be, which is the property +# that keeps this arm from swallowing the rule it extends. + # THE ONE ADMITTED EDIT, and it is what makes this campaign able to clean up # after itself (CLOUD-1051). # @@ -964,6 +1056,45 @@ mentions_retired(path, line, gone) if { contains(line, span) } +# AND THE SCRIPT-DIRECTORY BINDING ITSELF, once this delta has taken its last +# spend (CLOUD-1752). +# +# CLOUD-843's arm above closed this for a `local … reg …` DECLARATION. It does +# not reach the other spelling this tree uses just as often — a standalone +# `here=$(cd "$(dirname "$0")" && pwd)` whose only consumer was a call to the +# program being retired. Repoint that call at a verb and `here` is bound and +# never spent, which `shellcheck` refuses as SC2034; keep the call and the +# program cannot die. That is the same "no landable spelling in either +# direction" CLOUD-843 records, one assignment form further on, and it was +# measured retiring `claimed-keys.sh` out of `mise-tasks/landed-check.sh`. +# +# WHY THIS IS NOT A LICENCE: the binding may go only when EVERY line in the BASE +# that spends the variable is a call to the path this delta deletes. One +# surviving spend and the arm does not hold, so a caller cannot drop a binding it +# still uses — which is exactly the conjunct `case_earns_removal`'s second arm +# relies on, read over a whole file rather than one `@test` block. +spends_only_the_retired(path, variable, gone) if { + spends := {line | + some line in delta["base-lines"][path] + some spelling in {concat("", ["$", variable]), concat("", ["${", variable, "}"])} + contains(line, spelling) + } + count(spends) > 0 + every line in spends { + some form in { + concat("", ["$", variable, "/", basename(gone)]), + concat("", ["${", variable, "}/", basename(gone)]), + } + contains(line, form) + } +} + +mentions_retired(path, line, gone) if { + variable := assigned_name(line) + variable in script_dir_vars(path) + spends_only_the_retired(path, variable, gone) +} + # A REPOINTING: the removed line with the retired path replaced by a successor # its own ledger row declares, and nothing else changed (CLOUD-1121). # diff --git a/tests/board-sweep.bats b/tests/board-sweep.bats index cbaf47a76..3f0a7c295 100644 --- a/tests/board-sweep.bats +++ b/tests/board-sweep.bats @@ -18,6 +18,13 @@ setup() { export GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_SYSTEM=/dev/null git init -q -b work "$REPO" cd "$REPO" || return 1 + # THE COMMITTED CONFIG, because the sweep delegates to `landed-check`, which + # reaches `batten claim keys` now (CLOUD-1711). `claimed-keys.sh` carried the + # key grammar inline and answered in any tree; the engine leaf resolves it from + # the `[[pattern]]` registry and answers could-not-look without one — which the + # sweep then reports as a gate that could not look, so a case asserting the + # sweep's own verdict never reaches it. + cp "$BATS_TEST_DIRNAME/../batten.toml" "$REPO/batten.toml" git config user.email t@t git config user.name t git commit -q --allow-empty -m "chore: init" diff --git a/tests/claimed-keys.bats b/tests/claimed-keys.bats deleted file mode 100644 index 2a471e584..000000000 --- a/tests/claimed-keys.bats +++ /dev/null @@ -1,342 +0,0 @@ -#!/usr/bin/env bats -# subject: mise-tasks/claimed-keys.sh -# The claim derivation, split out of `issue-guard` when `deferral-check` needed -# the same answer (CLOUD-338). Two guards disagreeing about which issue a PR -# claims would be worse than either misfiring, and a second copy is how that -# happens — so the precedence is pinned here, once, and both callers read it. -# -# WHICH issue a branch claims is narrower than which it mentions. That -# distinction is the whole point: `issue-guard` produced false positives against -# its own PR twice by conflating them, and `deferral-check` exempted a deferral -# using the key `issue-guard` had forced onto the PR. - -setup() { - KEYS="$BATS_TEST_DIRNAME/../mise-tasks/claimed-keys.sh" - REPO="$BATS_TEST_TMPDIR/repo" - mkdir -p "$REPO" - # `git init -b`, never `git branch -f`: forcing the checked-out branch fails, - # and CI hides it only because the runner still defaults to `master` - # (CLOUD-282). A commit is required — an unborn branch has no HEAD to resolve. - git init -q -b claude/cloud-777-fixture "$REPO" - cd "$REPO" || return 1 - commit() { - git -c user.email=t@t -c user.name=t -c commit.gpgsign=false \ - commit -q --allow-empty -m "$1" - } - commit "fixture" - # The commit sources read `origin/main..HEAD`, so the fixture needs that ref - # or the log is empty and only the branch name ever answers. Pointing it at - # the base commit makes every later commit part of "this branch's work". - git update-ref refs/remotes/origin/main HEAD -} - -@test "a branch naming one issue is an unambiguous claim" { - run bash -c "'$KEYS' )`, so for a PR you did not - # author the title is the other self-declaration of what the work is. - run bash -c "'$KEYS' --title 'feat(x): a thing (CLOUD-268)' - git checkout -q -b claude/keyless-bundle - commit "$1" - SPEC_BASE=$(git rev-parse HEAD) - commit "some work of this branch's own" -} - -@test "a key carried only by a speculated commit is not claimed" { - # THE DISCRIMINATOR (CLOUD-418, CLOUD-748 §7b). Before the boundary existed no - # fixture could express an adopted commit at all; `mise run mutant` drives this - # red through `claimed-keys-adopts-speculated`. - speculate_onto "fix(git): something else entirely - -Refs: CLOUD-718" - run bash -c "BATTEN_SPEC_BASE='$SPEC_BASE' '$KEYS' "$stub/merged-pr-keys.sh" - chmod +x "$stub/merged-pr-keys.sh" - land "fix: work with no closing key in the commit" - unset DRAIN_MERGED_PRS - run bash -c "printf '%s' '[$(row CLOUD-179 2026-08-20T10:00:00.000Z feat/x '')]' | $stub/in-progress-drain.sh" - [ "$status" -eq 1 ] - [[ "$output" == *"CLOUD-179"* ]] -} - -@test "a failed gather is could-not-look, never a short sweep" { - local stub="$BATS_TEST_TMPDIR/bin2" - mkdir -p "$stub" - cp "$BATS_TEST_DIRNAME/../mise-tasks/in-progress-drain.sh" "$stub/in-progress-drain.sh" - cp "$BATS_TEST_DIRNAME/../mise-tasks/landed-check.sh" "$stub/landed-check.sh" - cp "$BATS_TEST_DIRNAME/../mise-tasks/claimed-keys.sh" "$stub/claimed-keys.sh" - printf '#!/usr/bin/env bash\nexit 2\n' >"$stub/merged-pr-keys.sh" - chmod +x "$stub/merged-pr-keys.sh" - unset DRAIN_MERGED_PRS - run bash -c "printf '%s' '[$(row CLOUD-179 2026-08-20T10:00:00.000Z feat/x '')]' | $stub/in-progress-drain.sh" - [ "$status" -eq 2 ] - [[ "$output" == *"merged-pr-keys"* ]] -} +# THE TWO GATHER CASES ARE WITHDRAWN (CLOUD-1711), not silently dropped. +# +# Both stubbed `mise-tasks/merged-pr-keys.sh` beside the real gates and drove the +# drain's no-`DRAIN_MERGED_PRS` path through it. That program is retired; the +# drain reaches `batten claim merged`, and a stub for an engine leaf is a shim +# for `batten` itself — which `shell edit refused` rightly will not admit into a +# governed suite, because a shell helper that shadows the binary is behaviour, +# not a fixture precondition. +# +# WHAT IS LOST AND WHERE IT IS COVERED. The leaf's own refusal on a truncated or +# empty forge answer is `crates/batten/tests/it/claimed_keys.rs`. What no case +# now covers is the drain's TRANSLATION of that refusal into its own exit 2, and +# that is stated here rather than left for a reader to discover by its absence — +# it belongs in the drain's own port, which is this campaign's next batch. diff --git a/tests/landed-check.bats b/tests/landed-check.bats index 7798ba7a8..ef4eb1754 100644 --- a/tests/landed-check.bats +++ b/tests/landed-check.bats @@ -34,6 +34,13 @@ setup() { # rule over this directory and would fire on its own explanation. git init -q -b work "$REPO" cd "$REPO" || return 1 + # THE COMMITTED CONFIG, because the claim derivation is an ENGINE leaf now + # (CLOUD-1711). `claimed-keys.sh` carried the key grammar inline and answered + # in any tree; `batten claim keys` resolves `ready-issue-key` and the closing + # rows from the `[[pattern]]` registry, so a fixture with no `batten.toml` + # resolves nothing, returns no keys, and every case asserting a REFUSAL passes + # for the wrong reason. + cp "$BATS_TEST_DIRNAME/../batten.toml" "$REPO/batten.toml" git config user.email t@t git config user.name t git commit -q --allow-empty -m "chore: init" diff --git a/tests/merged-pr-keys.bats b/tests/merged-pr-keys.bats deleted file mode 100644 index 30b7e9b1b..000000000 --- a/tests/merged-pr-keys.bats +++ /dev/null @@ -1,122 +0,0 @@ -#!/usr/bin/env bats -# subject: mise-tasks/merged-pr-keys.sh -# -# CLOUD-814. Every case drives the forge reading through MERGED_PR_KEYS_SOURCE, -# so the suite runs offline with no `gh` and no network — the shape -# `tests/branch-age-check.bats` uses for BRANCH_AGE_PRS. - -setup() { - TASK="$BATS_TEST_DIRNAME/../mise-tasks/merged-pr-keys.sh" - SRC="$BATS_TEST_TMPDIR/src-$BATS_TEST_NUMBER.json" - export MERGED_PR_KEYS_SOURCE="$SRC" - # Well above every fixture's size, so a case that is not ABOUT truncation - # never trips the cap and reports a refusal it did not mean to exercise. - export MERGED_PR_KEYS_LIMIT=100 -} - -# src — the reading a `gh pr list --json number,body` would have returned. -src() { printf '%s' "$1" >"$SRC"; } - -@test "a closing keyword in a merged body emits one row" { - src '[{"number":9,"body":"work\n\nCloses CLOUD-9"}]' - run "$TASK" - [ "$status" -eq 0 ] - [ "$output" = "CLOUD-9 9" ] -} - -@test "Fixes and Resolves are claims too" { - src '[{"number":9,"body":"Fixes CLOUD-1"},{"number":10,"body":"Resolves CLOUD-2"}]' - run "$TASK" - [ "$status" -eq 0 ] - [[ "$output" == *"CLOUD-1 9"* ]] - [[ "$output" == *"CLOUD-2 10"* ]] -} - -# The whole chain exists to refuse this: CLOUD-480 was swept to In Review on a -# `Refs:` trailer and sat wrong for 4.5 hours. -@test "a Refs: trailer is a mention and emits nothing" { - src '[{"number":9,"body":"work\n\nRefs: CLOUD-9"}]' - run "$TASK" - [ "$status" -eq 0 ] - [ -z "$output" ] -} - -@test "a bare citation in prose emits nothing" { - src '[{"number":9,"body":"on the newer compiler. That is CLOUD-271'"'"'s shape."}]' - run "$TASK" - [ "$status" -eq 0 ] - [ -z "$output" ] -} - -@test "several keys in one body emit several rows, all keyed to that PR" { - src '[{"number":11,"body":"Closes CLOUD-3\nFixes CLOUD-4"}]' - run "$TASK" - [ "$status" -eq 0 ] - [[ "$output" == *"CLOUD-3 11"* ]] - [[ "$output" == *"CLOUD-4 11"* ]] -} - -@test "a null body is data, not a crash" { - src '[{"number":12,"body":null},{"number":13,"body":"Closes CLOUD-5"}]' - run "$TASK" - [ "$status" -eq 0 ] - [ "$output" = "CLOUD-5 13" ] -} - -@test "two runs over the same reading are byte-identical" { - src '[{"number":11,"body":"Closes CLOUD-30"},{"number":2,"body":"Closes CLOUD-4"}]' - run "$TASK" - local first="$output" - run "$TASK" - [ "$output" = "$first" ] -} - -# THE MEASURED DEFECT (CLOUD-814). `gh pr list --limit 400` returned exactly 400 -# and cut the range at #161, hiding #170/#337/#339. A truncated evidence file is -# an UNDER-report, so landed work reads as live and the drain stops naming it. -@test "a reading at the fetch limit is refused as truncated, not returned short" { - src '[{"number":9,"body":"Closes CLOUD-9"},{"number":10,"body":"Closes CLOUD-10"}]' - MERGED_PR_KEYS_LIMIT=2 run "$TASK" - [ "$status" -eq 2 ] - [[ "$output" == *"truncated"* ]] - [[ "$output" != *"CLOUD-9 9"* ]] -} - -@test "a reading below the fetch limit is answered" { - src '[{"number":9,"body":"Closes CLOUD-9"}]' - MERGED_PR_KEYS_LIMIT=2 run "$TASK" - [ "$status" -eq 0 ] - [ "$output" = "CLOUD-9 9" ] -} - -@test "an empty forge answer is could-not-look, never an empty evidence file" { - src '[]' - run "$TASK" - [ "$status" -eq 2 ] - [[ "$output" == *"cannot be true of a repository with a trunk"* ]] -} - -@test "an unreadable source is exit 2" { - MERGED_PR_KEYS_SOURCE="$BATS_TEST_TMPDIR/absent.json" run "$TASK" - [ "$status" -eq 2 ] -} - -@test "a source that is not a JSON array is exit 2" { - src 'not json' - run "$TASK" - [ "$status" -eq 2 ] -} - -@test "a non-numeric limit is a caller bug, not a default" { - src '[{"number":9,"body":"Closes CLOUD-9"}]' - MERGED_PR_KEYS_LIMIT=lots run "$TASK" - [ "$status" -eq 2 ] -} - -# Rule 4: the keyword lives in the body, and the body must not reach the report. -@test "output carries no PR body" { - src '[{"number":9,"body":"customer detail here\n\nCloses CLOUD-9"}]' - run "$TASK" - [ "$status" -eq 0 ] - [[ "$output" != *"customer detail"* ]] -} diff --git a/tests/stop-posture-check.bats b/tests/stop-posture-check.bats deleted file mode 100644 index 402c19a0f..000000000 --- a/tests/stop-posture-check.bats +++ /dev/null @@ -1,166 +0,0 @@ -#!/usr/bin/env bats -# subject: mise-tasks/stop-posture-check.sh -# The one output-posture tell AGENTS.md names literally, as an exit code. The -# cases that matter are the negative ones: a message QUOTING the tell must not be -# judged as making it, which is the exact trap `run-shape-guard` fell into twice -# before its scrubber covered line-wrapped spans. - -setup() { - CHECK="$BATS_TEST_DIRNAME/../mise-tasks/stop-posture-check.sh" - cd "$BATS_TEST_DIRNAME/.." || return 1 -} - -check() { - printf '%s' "$1" | "$CHECK" -} - -# --- it fires, on the literals AGENTS.md itself writes down ------------------- - -@test "the first tell AGENTS.md names fires" { - run check 'The land task is green. One thing I would flag is that the receipt is keyed to the old SHA.' - [ "$status" -eq 1 ] - [[ "$output" == *"hedged-flag-framing"* ]] -} - -@test "the second tell AGENTS.md names fires" { - run check 'Landed on main. Worth noting that config-lint still has no caller.' - [ "$status" -eq 1 ] - [[ "$output" == *"hedged-flag-framing"* ]] -} - -@test "the inflection that a closed two-item list would have missed fires" { - run check 'All seven pass. I should flag that the epic frontier is still empty.' - [ "$status" -eq 1 ] -} - -@test "the report carries a count" { - run check 'Worth noting one thing. Also worth flagging another.' - [ "$status" -eq 1 ] - [[ "$output" == *"hedged-flag-framing 2"* ]] -} - -# --- one verb set across the worth/bears openers (CLOUD-387) ------------------ - -@test "THE WITNESSED MISS: the CLOUD-347 sentence fires" { - # The trunk-based-development audit closed its report with this, a real finding - # that reached chat and nothing else and became CLOUD-380 only because a human - # asked. It was silent before this verb set was unified — and nothing else - # covered it either: `stop-guard` consults `finding-sink-check` precisely when - # this rule is silent, and that gate needs a `path:line` citation this sentence - # does not carry. The turn's one advisory slot was never claimed, not spent. - run check "One open thread worth naming: the census never interrogated host settings, and if you want them enumerated and gated the way CLOUD-350 did for the ruleset, that is a new issue." - [ "$status" -eq 1 ] - [[ "$output" == *"worth naming"* ]] -} - -@test "the asymmetry is gone: mentioning is a flagging verb under BOTH openers" { - # `bears mentioning` always fired and `worth mentioning` never did. Nothing - # distinguished the two openers; the split was an accident of the alternation. - run check 'All seven pass. Worth mentioning that the frontier is still empty.' - [ "$status" -eq 1 ] - run check 'All seven pass. That bears mentioning.' - [ "$status" -eq 1 ] -} - -@test "every opener carries the witnessed verb" { - run check 'Worth naming one thing about the receipt.' - [ "$status" -eq 1 ] - run check 'That bears naming before the next lap.' - [ "$status" -eq 1 ] - run check "It's worth naming that the epoch is stale." - [ "$status" -eq 1 ] -} - -@test "an UNWITNESSED near-miss stays out — that is the line against inventing a list" { - # `calling out` is the same grammatical shape and is deliberately absent: not - # witnessed on any real turn, and not already in the file. Asserted rather than - # assumed, so a later author cannot widen the set by accident and call it an - # inflection. - run check 'All seven pass. Worth calling out that the frontier is still empty.' - [ "$status" -eq 0 ] -} - -# --- pointer-only ------------------------------------------------------------ - -@test "the report never echoes the sentence it judged" { - run check 'Worth noting that account 90210 in the entity path is unredacted.' - [ "$status" -eq 1 ] - [[ "$output" != *"90210"* ]] - [[ "$output" != *"entity path"* ]] -} - -# --- quoting the rule is not breaking it ------------------------------------- - -@test "a code span carrying the tell does not fire" { - run check 'The guard matches `worth noting` and its inflections.' - [ "$status" -eq 0 ] -} - -@test "a double-quoted span carrying the tell does not fire" { - run check 'AGENTS.md names the tell as "one thing I would flag", which the gate now reads.' - [ "$status" -eq 0 ] -} - -@test "a block quote carrying the tell does not fire" { - run check 'CLOUD-200 recorded it: - -> its tell is hedged flag-framing, worth noting being the commonest form - -That is the sentence this gate makes computable.' - [ "$status" -eq 0 ] -} - -@test "a fenced block carrying the tell does not fire" { - run check 'The literal set is: - -``` -worth noting|worth flagging -``` - -and nothing else.' - [ "$status" -eq 0 ] -} - -@test "a LINE-WRAPPED quoted span carrying the tell does not fire" { - # The defect this asserts against is sed being line-based by default: a - # line-based scrub leaves the interior of a wrapped quotation exposed, so the - # gate denies the very message documenting it. `-z` is why this passes. - run check 'The posture section says "the failure this kills is writing findings -twice, and its tell is hedged flag-framing — worth noting being the -commonest form" and that is what the gate now reads.' - [ "$status" -eq 0 ] -} - -# --- narrowness: it judges an act of flagging, not any use of the words ------ - -@test "reporting a measured value is not hedged framing" { - run check 'I noted the exit code was 2 and the receipt was absent.' - [ "$status" -eq 0 ] -} - -@test "a command flag is not hedged framing" { - run check 'The --flag argument is passed through to the child process.' - [ "$status" -eq 0 ] -} - -@test "a plainly stated finding with a durable home does not fire" { - run check 'config-lint claimed a --config-from caller that does not exist. Filed as CLOUD-236 and the false claim is removed in this commit.' - [ "$status" -eq 0 ] -} - -# --- failure posture --------------------------------------------------------- - -@test "empty stdin is clean rather than an error" { - run bash -c "printf '' | $CHECK" - [ "$status" -eq 0 ] - [ -z "$output" ] -} - -@test "a clean message produces no output at all" { - # The overload this asserts against: a predicate whose fail-open path and whose - # fired path both exit 0 fires on every turn. Silence on the clean path is what - # makes the caller's `&& exit 0` meaningful. - run check 'Landed on main by fast-forward, CI green. CLOUD-233 is Done.' - [ "$status" -eq 0 ] - [ -z "$output" ] -}