diff --git a/AGENTS.md b/AGENTS.md index 4fb90f2d3..e4e465b8f 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`, `background-redirect`. -**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`**. +**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 504c8e5a4..c2989bb53 100644 --- a/batten.toml +++ b/batten.toml @@ -1966,9 +1966,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 @@ -1986,6 +2026,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)$' @@ -5629,6 +5678,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, @@ -6002,6 +6058,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-age" +kind = "policy" +scope = "tree" +module = "policy/branch-age.rego" +severity = "warn" + [[rule]] id = "plan-complete" kind = "policy" @@ -6597,6 +6673,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 = "validator-verdict-clean" kind = "policy" @@ -12475,6 +12556,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]] @@ -12486,14 +12618,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" @@ -12512,15 +12647,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 e28538149..44f807103 100644 --- a/completions/batten.bash +++ b/completions/batten.bash @@ -217,6 +217,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" ;; @@ -232,6 +238,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" ;; @@ -538,6 +550,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" ;; @@ -751,12 +769,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" ;; @@ -1216,30 +1249,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" ;; @@ -2078,7 +2141,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 @@ -2206,7 +2269,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 @@ -2275,6 +2338,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 @@ -2289,6 +2380,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 @@ -3760,7 +3927,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 @@ -3815,6 +3982,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 @@ -5146,7 +5341,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 @@ -5173,6 +5368,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 @@ -5187,6 +5396,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 @@ -5201,6 +5452,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 @@ -8894,7 +9159,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 @@ -8953,6 +9218,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 @@ -8984,7 +9279,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 @@ -9011,6 +9306,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 @@ -9039,6 +9348,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 @@ -9053,6 +9404,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 @@ -9067,6 +9432,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 @@ -9097,6 +9552,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 122b06e7c..a182e5c61 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' @@ -2915,32 +2967,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'" @@ -2983,6 +3040,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'" @@ -3027,6 +3189,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)' @@ -3685,6 +3852,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' @@ -3711,6 +3880,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 d4a9f9b04..23d0c9127 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 ;; @@ -5006,6 +5078,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" @@ -5084,6 +5308,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 @@ -6583,7 +6827,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 ;; @@ -6871,6 +7123,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 @@ -7248,6 +7520,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' \ @@ -7274,6 +7548,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' \ @@ -7302,11 +7578,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=() @@ -7779,6 +8075,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' \ @@ -7801,6 +8099,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=() @@ -8371,6 +8679,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' \ ) @@ -8381,16 +8694,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=() @@ -9475,6 +9813,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)' \ @@ -9486,6 +9829,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=() @@ -9496,6 +9844,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)' \ @@ -9507,6 +9860,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=() @@ -9517,21 +9875,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 2db82089a..8fa28bf81 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. @@ -1346,6 +1364,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 @@ -2100,6 +2156,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"), }), @@ -2356,6 +2422,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/config.rs b/crates/batten/src/config.rs index 73084dd12..16aa5bb8a 100644 --- a/crates/batten/src/config.rs +++ b/crates/batten/src/config.rs @@ -1386,7 +1386,8 @@ pub const RETIRED_KEYS: &[(&str, &str)] = &[( /// /// [`trust::load_base`]: crate::trust::load_base pub fn parse_base(text: &str, source: &str) -> Result { - let mut table: toml::Table = toml::from_str(text).map_err(|err| config_error(source, &err))?; + let mut table: toml::Table = + toml::from_str(text).map_err(|err| config_error(source, text, &err))?; // Nothing is reported when a key is dropped: the report this feeds is a // comparison of two policies, and "the base declared a key this build no // longer has" is a fact about the build rather than about either policy. @@ -1518,7 +1519,8 @@ pub fn parse_override(text: &str, source: &str) -> Result { prune_unresolvable::(text, binary_is_behind_the_config(source, text)); let config = match pruned.config { Some(config) => config, - None => toml::from_str(&pruned.text).map_err(|err| config_error(source, &err))?, + None => toml::from_str(&pruned.text) + .map_err(|err| config_error(source, &pruned.text, &err))?, }; (config, pruned.dropped) }; @@ -1967,8 +1969,35 @@ fn names_an_unknown_key(rendered: &str) -> bool { //MUTANT-SUITE crates/batten/tests/it/config_skew.rs //MUTANT skew-reads-as-malformed|s@ if !names_an_unknown_key(&rendered) {@ if true {@|an_unknown_key_names_the_rebuild //MUTANT every-parse-error-blames-skew|s@ if !names_an_unknown_key(&rendered) {@ if false {@|a_malformed_config_does_not_mention_a_rebuild -pub(crate) fn config_error(source: &str, err: &toml::de::Error) -> anyhow::Error { +pub(crate) fn config_error(source: &str, text: &str, err: &toml::de::Error) -> anyhow::Error { let rendered = err.to_string(); + // THE SYNTAX PROBE, AND IT RUNS ONLY HERE — ON THE ERROR PATH (CLOUD-1677). + // + // `toml::de::Error` is the one type for two very different faults, and the + // rendering hides it: a missing field and an invalid type both arrive as + // "TOML parse error at line N, column C", exactly like a stray brace. Reading + // the message cannot tell them apart, and `names_an_unknown_key` answers a + // third question again — measured on the `[[fact]]`-with-no-`returns` fixture, + // which is a SCHEMA fault that renders as a parse error and was classed as + // unreadable by the message alone. + // + // A `Table` parse answers it exactly: if the bytes are well-formed TOML then + // whatever failed was the SCHEMA over them, and the file still has rows a + // build can read. This is the probe the comment in `parse_ungated` records as + // removed for costing a parse on the hot path — it is free here, because + // nothing reaches this function until a parse has already failed. + if toml::from_str::(text).is_err() { + // CLASSED, AND THE CLASS IS A DISCRIMINATOR RATHER THAN A LABEL. This is + // the one config fault with no partial function left to preserve — the + // file is not TOML, so no row is readable and none can be enforced. Every + // other fault leaves the rest of the file deciding, which is what lets an + // agent be told to repair the broken part instead of losing the gate + // surface entirely. `Native::ConfigUnreadable` carries the full argument. + return UsageError::raise_as( + crate::verdict::Native::ConfigUnreadable, + format!("invalid config {source}: {err}"), + ); + } if !names_an_unknown_key(&rendered) { return UsageError::raise(format!("invalid config {source}: {err}")); } @@ -3042,7 +3071,8 @@ fn parse_ungated(text: &str, source: &str) -> Result { let pruned = prune_unresolvable::(text, binary_is_behind_the_config(source, text)); let config = match pruned.config { Some(config) => config, - None => toml::from_str(&pruned.text).map_err(|err| config_error(source, &err))?, + None => toml::from_str(&pruned.text) + .map_err(|err| config_error(source, &pruned.text, &err))?, }; (config, pruned.dropped) }; 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 adf64dd04..aec9c0951 100644 --- a/crates/batten/src/hook.rs +++ b/crates/batten/src/hook.rs @@ -8844,13 +8844,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 @@ -15447,11 +15451,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 a9b933c08..e80922254 100644 --- a/crates/batten/src/lib.rs +++ b/crates/batten/src/lib.rs @@ -397,7 +397,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), } } @@ -3692,6 +3692,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, @@ -3988,6 +4222,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), @@ -12088,16 +12344,10 @@ fn run_hook( out: &mut dyn Write, err: &mut dyn Write, ) -> Result { - let mut raw = String::new(); - if std::io::stdin().read_to_string(&mut raw).is_err() { - output::message(mode, Verbosity::Normal, err, UNREADABLE_STDIN)?; - return Ok(ExitCode::Success); - } - let bypass = std::env::var_os(hook::BYPASS_ENV).is_some_and(|value| !value.is_empty()); - let Some(mut envelope) = hook::decode(harness, &raw) else { - output::message(mode, Verbosity::Normal, err, UNDECODABLE_PAYLOAD)?; + let Some((raw, mut envelope)) = read_envelope(harness, mode, err)? else { return Ok(ExitCode::Success); }; + let bypass = std::env::var_os(hook::BYPASS_ENV).is_some_and(|value| !value.is_empty()); // THE WRITE TARGET IS READ AS THE REPOSITORY READS IT (CLOUD-1133), and this // is the one place that can do it: `decode` is pure and has no repository, // and the readers below — the protected gate, and any module over @@ -12115,8 +12365,7 @@ fn run_hook( // The note rides the ladder above `normal`, because on the hosts where this // is reachable it is the ordinary state rather than news. let capabilities = harness.capabilities(); - if !capabilities.emits(envelope.event) && envelope.event != hook::Event::Unrecognized { - let note = unsupported_event_note(harness, &capabilities, envelope.event); + if let Some(note) = undeclared_event_note(harness, &capabilities, envelope.event) { output::message(mode, Verbosity::Verbose, err, ¬e)?; return Ok(ExitCode::Success); } @@ -12226,18 +12475,7 @@ fn run_hook( // end-of-turn surface. The retired shell hook this replaces paid ~330-440ms // at the same boundary; `perf`'s `passthrough` and `noop` arms are pre-tool // shapes and are untouched by this clause. - let adjudicable = !envelope.command.is_empty() - || envelope.writes.is_some() - || envelope.event == hook::Event::Stop - // A FOURTH TIME, and for a mint rather than a verdict (CLOUD-856). Session - // start carries no command, no write and no tool name, so this predicate - // was false there and config was never loaded — which means the receipt - // this event exists to mint could not know which manifests were declared. - // The cost is one config load per SESSION, not per call, which is the - // same trade the `Stop` clause above makes, and it buys the whole reason - // `Fact::Document` can stay `None` on the mediated path. - || envelope.event == hook::Event::SessionStart - || (envelope.event == hook::Event::PreTool && !envelope.raw_tool.is_empty()); + let adjudicable = is_adjudicable(&envelope); // A BYPASSED CALL NOW PAYS THE CONFIG READ, and that invariant is retired // deliberately rather than eroded. // @@ -12256,8 +12494,39 @@ fn run_hook( // arms, ~0.7 ms against a 100 ms budget. `!adjudicable` keeps its old // behaviour, because an event with nothing to adjudicate has no protected // gate to run either, and that is the arm the hot path actually rides. + // A DECLARATION NOTHING COULD READ IS THE ONE FAULT THAT REFUSES, and the + // narrowness is the decision rather than caution (CLOUD-1677). + // + // Gates are registered fail-open, so a gate that fails open is INERT — it + // neither allows nor denies, it is absent. A config fault is therefore never + // a choice between refusing and allowing: it is a choice between keeping the + // enforcement surface we still have and losing it entirely. An unknown key, a + // version this build is too old for, a table whose validator refused — each + // leaves every other row readable and enforceable, and leaves an agent that + // can still be TOLD to repair the broken one. Refusing there would trade a + // working partial surface for nothing. + // + // `Native::ConfigUnreadable` is the one class with no partial function left: + // the file is not TOML, so no row is readable and none can be enforced. Then + // the refusal is the only signal available, and the hatch below is how the + // container gets back. + //MUTANT-SUITE crates/batten/tests/it/adjudicate_absent.rs + //MUTANT unloadable-config-allows|s@ Err(unreadable) if unreadable_declaration(\&unreadable) => {@ Err(unreadable) if false \&\& unreadable_declaration(\&unreadable) => {@|a_config_this_build_cannot_load_denies_rather_than_failing_open let (policy, waivers) = if adjudicable { - load_policy(overrides, harness)? + match load_policy(overrides, harness) { + Ok(loaded) => loaded, + // The declared hatch, read before the refusal so a stale binary + // meeting a newer config leaves a container recoverable, not bricked. + Err(_) if bypass => (hook::Policy::declaring_nothing(harness), Vec::new()), + Err(unreadable) if unreadable_declaration(&unreadable) => { + return deny_unadjudicable(harness, &envelope, &unreadable, mode, out, err); + } + // EVERY OTHER FAULT KEEPS ITS OLD BEHAVIOUR, deliberately. Today that + // is still a whole-file refusal at exit `1`, which is the outcome the + // argument above says is wrong — making these actually preserve the + // rows they can read is its own change, over `prune_unresolvable`. + Err(other) => return Err(other), + } } else { (hook::Policy::declaring_nothing(harness), Vec::new()) }; @@ -12499,6 +12768,237 @@ fn run_hook( render(harness, &envelope, decision, &rendering, mode, out, err) } +/// The note for an event this host does not declare, or `None` to carry on. +/// +/// **`Unrecognized` is not undeclared**, and collapsing the two is why this is a +/// named predicate rather than an inline `&&`: an event nobody could parse has +/// no capability row to be absent from, so it falls through to the ordinary +/// path instead of being reported as a host that offers less. +/// +/// Returning the note rather than a `bool` keeps [`unsupported_event_note`]'s +/// call beside the condition that earns it — a caller that tested one and +/// rendered the other could report an event the table actually declares. +fn undeclared_event_note( + harness: hook::Harness, + capabilities: &hook::Capabilities, + event: hook::Event, +) -> Option { + (!capabilities.emits(event) && event != hook::Event::Unrecognized) + .then(|| unsupported_event_note(harness, capabilities, event)) +} + +/// Whether this envelope has anything for the config to decide about. +/// +/// **The gate on whether a call pays a config read at all**, which is why the +/// hot path stays cheap: `perf`'s `passthrough` arm — a `Read` with a +/// `file_path`, no command, no write — takes the `false` branch, and its +/// below-`noop` reading comes from doing so. +/// +/// Every clause was added by a measurement rather than by symmetry, and the +/// history is the argument for keeping them enumerated here: +/// +/// * a command or a write is the original shape; +/// * `Stop` carries neither, so a `mediated_call` module registered for the end +/// of turn could not run at all — a dead gate whose own suite stayed green, +/// because a `with input as` case fabricates the shape the boundary never +/// built (CLOUD-1051); +/// * `SessionStart` likewise, and for a MINT rather than a verdict (CLOUD-856): +/// the receipt that event exists to write could not know which manifests were +/// declared. One config load per session, not per call; +/// * a `PreTool` call naming a tool is the shape a tool-keyed row exists to +/// judge, and without it such a row was loaded for no call that could match. +fn is_adjudicable(envelope: &hook::Envelope) -> bool { + !envelope.command.is_empty() + || envelope.writes.is_some() + || envelope.event == hook::Event::Stop + || envelope.event == hook::Event::SessionStart + || (envelope.event == hook::Event::PreTool && !envelope.raw_tool.is_empty()) +} + +/// The mediated call on stdin, or `None` where the call must simply proceed. +/// +/// **THE TWO FAIL-OPEN BOUNDARIES, TOGETHER BECAUSE THEY ARE ONE ANSWER.** Stdin +/// that will not read and a payload that will not decode are both "the engine +/// does not know what this call IS", and neither may block it: a guard must never +/// be the reason a session cannot proceed. That is the opposite side of +/// [`unreadable_declaration`], where the engine knows the call perfectly well and +/// has been told it cannot enforce the rules over it. +/// +/// **Loud, never silent** (CLOUD-43). A guard that cannot read its input is a gate +/// that did not run, and the silent version of that is byte-identical to a clean +/// allow — the false green this engine exists to catch, in the one place nobody +/// would think to look. +/// **The RAW bytes travel with the decoded value**, because `dispatch_handlers` +/// hands a declared handler the payload as it arrived. Re-reading stdin for it is +/// not an option — the stream is consumed — and re-serializing the envelope would +/// hand a handler a document the host never sent. +fn read_envelope( + harness: hook::Harness, + mode: Mode, + err: &mut dyn Write, +) -> Result> { + let mut raw = String::new(); + if std::io::stdin().read_to_string(&mut raw).is_err() { + output::message(mode, Verbosity::Normal, err, UNREADABLE_STDIN)?; + return Ok(None); + } + let Some(envelope) = hook::decode(harness, &raw) else { + output::message(mode, Verbosity::Normal, err, UNDECODABLE_PAYLOAD)?; + return Ok(None); + }; + Ok(Some((raw, envelope))) +} + +/// Whether this load failure is a declaration nothing could read at all. +/// +/// **Positively identified, never inferred from an absence.** The tempting +/// spelling is "carries no declared class", and it is wrong: the +/// unsupported-version and `min_batten_version` refusals carry none either, and +/// both leave every row in the file readable. Keying on absence would refuse +/// those too — and every future unclassed refusal after them, silently widening +/// what denies. +/// +/// So the loader says which one this is. `config_error` already separates a +/// syntax failure from an unknown key, and since CLOUD-1677 its syntax arm raises +/// under [`verdict::Native::ConfigUnreadable`]. +fn unreadable_declaration(err: &anyhow::Error) -> bool { + err.downcast_ref::() + .and_then(|usage| usage.verdict) + .is_some_and(|class| class == verdict::Native::ConfigUnreadable) +} + +/// Refuse a call whose rules this build could not load (CLOUD-1677). +/// +/// Lifted out of [`run_hook`] rather than left inline because that function is +/// already at its line budget, and a boundary this load-bearing should be +/// readable on its own rather than as a match arm nine levels in. +/// +/// # Certainty denies; guessing allows +/// +/// The load used to propagate with `?`, raising a [`UsageError`] — exit `1` — +/// and [`crate::exit`] makes only `2` a denial precisely so no FAILURE path can +/// block a call. So a harness read this whole class as a non-blocking hook error +/// and ran the mediated tool anyway. Measured over one 5-day session: 1,149 +/// calls proceeded unjudged through seven windows of a mid-edit `batten.toml`, +/// and ~456 more through a preset the installed build did not ship — the +/// unknown-preset arm in [`crate::policy`] raises exactly this error. +/// +/// This is the discrimination `UNREADABLE_STDIN` sits on the other side of, and +/// the one CLOUD-1572 drew one level down. Where the engine is GUESSING about +/// the call — stdin it could not read, a payload that would not decode, an event +/// the host does not declare — allowing is right, because nothing is known and +/// refusing would make Batten the reason a session cannot proceed. Here the +/// engine has READ its own authority and been told it cannot enforce it: the +/// rule set is named, and unavailable. Proceeding is not caution then, it is a +/// gate reporting a clean allow over rules it never ran. +/// +/// # The surfaces stay separate +/// +/// `doctor` still never answers `2` — a diagnosis is not a policy verdict — and +/// the CLI verbs still raise a usage error over a config they cannot read. The +/// mediated boundary is the one place where "cannot judge" must not resolve to +/// "proceed", because here the alternative is a tool call nobody looked at. +/// +/// # The hatch is read before this is reached +/// +/// [`run_hook`] takes the bypass arm first, and that is what keeps a container +/// recoverable rather than bricked: a stale binary meeting a newer config +/// refuses every call until one of them moves, so the operator's declared escape +/// has to survive exactly the state that needs it. +/// +/// **A DECISION, NOT AN ERROR, WHICH IS WHY IT RENDERS.** [`render`] owns the +/// per-harness deny channel: Claude Code answers in its JSON decision object at +/// exit `0`, where the document *is* the deny, and the neutral adapter answers +/// [`ExitCode::Violation`]. Raising a [`Denial`] here would send `2` to the one +/// host that reads the document instead of the number. +/// +/// **The rendering carries no ceiling and the general hatch**, because both live +/// on the policy that would not load. `None` reads downstream as "no declared +/// bound" rather than as a bound of zero, which is the direction that keeps a +/// refusal about an unreadable config from being truncated by a value nobody +/// could read. +fn deny_unadjudicable( + harness: hook::Harness, + envelope: &hook::Envelope, + unreadable: &dyn std::fmt::Display, + mode: Mode, + out: &mut dyn Write, + err: &mut dyn Write, +) -> Result { + // THE FIRST LINE ONLY, AND THAT IS NON-NEGOTIABLE RULE 4 RATHER THAN BREVITY. + // + // A `toml` parse error renders as a multi-line span — a header naming the + // position, then the OFFENDING SOURCE LINE with a caret under it. Interpolating + // the whole thing puts a byte of the unreadable config into a refusal that + // reaches the model, the host's log and the transcript, which is exactly the + // payload rule 4 keeps out of every report this engine writes. Measured by + // `the_declaration_that_would_not_parse_is_named_without_quoting_it`, which + // fails on the un-truncated form. + // + // The first line is the POINTER and loses nothing an operator needs: for a + // parse failure it is `TOML parse error at line L, column C`, and for the + // skew and unknown-key arms the whole message is one line already — the + // `max_age = 0` and `command_matcher` diagnostics ride through intact, which + // is what keeps the repair as findable as it was when this arm exited `1`. + let pointer = unreadable + .to_string() + .lines() + .next() + .unwrap_or_default() + .to_owned(); + let refusal = Refusal::new( + "engine-cannot-adjudicate", + format!( + "this build could not load the rules it is registered to enforce, so nothing judged \ + this call: {pointer}" + ), + // No remedy the ENGINE may declare: the repair is rebuilding or + // reinstalling the binary, or fixing the config, and each is the + // consumer's own command (non-negotiable rule 1). + Fix::None, + ); + let rendering = Rendering { + hatch: hook::BYPASS_ENV, + ceiling: None, + }; + // WHICH CHANNEL CARRIED THE REFUSAL IS `render`'S OWN ANSWER, and reading it + // here is what lets the number say could-not-look without ever spending the + // refusal to do it (CLOUD-1677's exit-code half). + // + // `render` denies through the host's protocol: a document harness gets its + // decision object and answers `Ok`, while the neutral adapter's ONLY deny + // channel is the number, so it answers `Err(Denial)` — `run_hook`'s own + // contract says as much. The two arms below are therefore not a preference + // between codes, they are the two protocols. + match render( + harness, + envelope, + hook::Decision::Deny(refusal), + &rendering, + mode, + out, + err, + ) { + // The DOCUMENT already refuses, so the number is free to be honest: §6–§7 + // reserve `3` for could-not-look, and nothing was judged about this call. + // `Internal` rather than `Violation` also keeps `exit.rs`'s guarantee + // whole — `Usage` and `Internal` are the only codes a failure of Batten's + // own may produce, *so that fail-open is structural* — and an unreadable + // declaration is such a failure. Answering `2` here would have bought the + // refusal twice and spent that guarantee for the second copy. + Ok(_) => Ok(ExitCode::Internal), + // THE NUMBER IS THIS HARNESS'S ONLY CHANNEL, so it stays the deny. Turning + // it into `3` would be honest about the cause and silent about the verdict, + // which is the fail-open this row exists to close. + // + // **The cost is stated rather than absorbed** (CLOUD-1677's Replay asks for + // the per-harness table): on this adapter the boundary cannot say BOTH + // "refused" and "nothing could be read", so the could-not-look half is + // unavailable there until that protocol grows a way to carry it. + Err(denial) => Err(denial), + } +} + /// Run the refusing row's declared repair, and say what the boundary decides now. /// /// **Every other decision passes straight through**, and most `Deny`s do too: diff --git a/crates/batten/src/lint.rs b/crates/batten/src/lint.rs index 011cf024f..9bddf29d2 100644 --- a/crates/batten/src/lint.rs +++ b/crates/batten/src/lint.rs @@ -401,7 +401,7 @@ pub fn smells( // same message it would anywhere else rather than this module's own. let config = config::parse(text, source)?; let located: Located = - toml::from_str(text).map_err(|err| config::config_error(source, &err))?; + toml::from_str(text).map_err(|err| config::config_error(source, text, &err))?; let mut found = Vec::new(); 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 bcd17c397..d74eeda78 100644 --- a/crates/batten/src/pr_watch.rs +++ b/crates/batten/src/pr_watch.rs @@ -817,6 +817,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 eff7efc05..744e5c62c 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 2eacc099f..9e37a285e 100644 --- a/crates/batten/src/rest.rs +++ b/crates/batten/src/rest.rs @@ -169,6 +169,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 { @@ -219,6 +234,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. @@ -341,8 +375,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()) @@ -376,6 +422,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", @@ -4710,6 +4857,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/src/verdict.rs b/crates/batten/src/verdict.rs index ff125021b..067b79ad8 100644 --- a/crates/batten/src/verdict.rs +++ b/crates/batten/src/verdict.rs @@ -1258,6 +1258,39 @@ pub enum Native { /// than in a consumer `[[verdict]]` row. No consumer can name the class, /// because the engine is what takes the plan and what compares it. PlanReadStale, + /// The declaration could not be READ AT ALL — not valid TOML. + /// + /// **APPENDED, NEVER INSERTED.** This enum carries no `repr`, so a variant + /// added in the middle shifts every later discriminant and + /// `enum_no_repr_variant_discriminant_changed` reads the whole tail as + /// broken — measured here, where placing it beside the other config classes + /// moved eighteen of them. Position is API; the reading order below is not. + /// + /// **Deliberately not in [`Native::CONFIG_FAULTS`]**, which is the per-TABLE + /// set and is censused in both directions against `config.rs`'s own list. This + /// one has no table: it is raised before any table exists, by the parse that + /// every table's validator runs after. + /// + /// # Why it needs a class when the other parse failures do not + /// + /// A class here is not for `explain` — it is the DISCRIMINATOR the mediated + /// boundary acts on (CLOUD-1677). Gates are registered fail-open, and a gate + /// that fails open is inert: it neither allows nor denies, it is absent. So a + /// config fault is never a choice between refusing and allowing, it is a + /// choice between keeping the enforcement surface we still have and losing it + /// entirely. + /// + /// An unknown key, a version this build is too old for, a row whose validator + /// refused — each leaves every OTHER row readable and enforceable, and leaves + /// an agent that can still be told to repair the one that is broken. Failing + /// the whole load there buys nothing and costs the surface that would have + /// carried the repair instruction. + /// + /// A file that is not TOML is the one case with no partial function to + /// preserve: zero rows are readable, so refusing the call is the only signal + /// left, and the declared hatch is the recovery path. That asymmetry is why + /// this class exists and why it is exactly one class wide. + ConfigUnreadable, } impl Native { @@ -1291,6 +1324,7 @@ impl Native { Native::CallFixSilent, Native::ContentRefused, Native::KeyMissing, + Native::ConfigUnreadable, Native::VerbTableRefused, Native::PatternTableRefused, Native::VerdictTableRefused, @@ -1373,6 +1407,7 @@ impl Native { Native::CallFixSilent => "call fix silent", Native::ContentRefused => "input write refused", Native::KeyMissing => "issue name missing", + Native::ConfigUnreadable => "config read refused", Native::VerbTableRefused => "verb declare refused", Native::PatternTableRefused => "pattern declare refused", Native::VerdictTableRefused => "verdict declare refused", @@ -1891,6 +1926,20 @@ it serves.", // Every route is the config itself, which is not a placeholder: a config // fault is edited in exactly one file, and a `command` route would have to // name a task that can run over a config that does not load. + VendoredVerdict { + id: "config read refused", + gloss: "the declaration is not TOML, so no rule in it could be read", + class: "Every other config fault leaves the rest of the file deciding -- an unknown key \ +costs its own row, a table whose validator refuses names that table, and a version this build \ +is too old for still says so. Each of those keeps a working gate surface and an agent that can \ +be told to repair the broken part. This one has no partial function to preserve: the bytes are \ +not TOML, so zero rows are readable and nothing is enforced. That is why it is the one class \ +the mediated boundary refuses a call under, rather than reporting and proceeding -- a gate that \ +fails open is inert, and an inert gate over an unreadable authority is the false green this \ +engine exists to catch.", + routes: &[read("config read first", "batten.toml")], + applicability: Applicability::Advice, + }, VendoredVerdict { id: "verb declare refused", gloss: "the verb table would not load", @@ -2367,6 +2416,7 @@ mod tests { | Native::VerdictTrailing | Native::RunOrphaned | Native::CeilingExceeded + | Native::ConfigUnreadable | Native::ShapeRefused | Native::CallRetryNow | Native::CallFixSilent diff --git a/crates/batten/tests/it/adjudicate_absent.rs b/crates/batten/tests/it/adjudicate_absent.rs new file mode 100644 index 000000000..300938ce4 --- /dev/null +++ b/crates/batten/tests/it/adjudicate_absent.rs @@ -0,0 +1,185 @@ +//! A call this build cannot adjudicate is DENIED, never allowed by a failure. +//! +//! The tier that proves the engine does not fail open when it has read its own +//! authority and been told it cannot enforce it. Unit cases over `adjudicate` +//! cannot host this: the defect is not in the decision, it is in what the +//! BOUNDARY does with a load that failed, and only the compiled binary answers +//! that — `mediated_admission.rs`'s header records the same lesson, where unit +//! cases passed while the binary allowed the write. +//! +//! # The mirror is not decoration +//! +//! `a_loadable_config_still_allows_an_ordinary_call` is what stops this being +//! satisfied by an adjudicator that denies everything. A fail-closed hook that +//! refuses each call is not a fix, it is an outage — CLOUD-1688's falsifier says +//! so in as many words, and that is why the pair lands together. +//! +//! # Scope +//! +//! The CONFIG half of CLOUD-1688: a `batten.toml` this build cannot load. The +//! VERB half — a registration spelling a subcommand the installed binary does +//! not have — needs `doctor` to interrogate the installed artifact rather than +//! itself, because a self-check runs in the build that mise resolves and never +//! in the one the hook does. It lands with that part and belongs in this file. + +// Panicking on setup failure is the idiomatic way for a test to fail loudly. +#![allow(clippy::unwrap_used, clippy::expect_used)] + +use crate::common; + +use std::path::{Path, PathBuf}; + +use common::{Fixture, run_with_stdin}; + +/// A `batten.toml` mid-edit, which is the largest measured bucket: seven windows +/// across one 5-day session, 1,149 calls, every one of them unjudged. +const WILL_NOT_PARSE: &str = "version = 1\nthis is not toml\n"; + +/// A config that loads and declares nothing this call matches. +const LOADS: &str = "version = 1\n"; + +/// A fixture carrying `body` as its committed authority. +fn fixture(name: &str, body: &str) -> PathBuf { + Fixture::new(name) + .config(body) + .file("notes.md", "ordinary\n") + .git() + .base_commit() + .build() +} + +/// A Claude Code `PreToolUse` envelope carrying a command, so the boundary +/// treats it as adjudicable and reaches the config load at all. +fn payload() -> String { + "{\"hook_event_name\":\"PreToolUse\",\"tool_name\":\"Bash\",\ + \"tool_input\":{\"command\":\"echo hello\"}}" + .to_owned() +} + +/// Adjudicate on the neutral adapter, where the VERDICT IS THE NUMBER. +/// +/// `exit-code` rather than `claude-code` for the two cases that ask what the +/// code is: on Claude Code the deny is the JSON document at exit `0`, so a case +/// asserting a number there would assert the wrong channel. The document is +/// checked separately below. +fn code(dir: &Path) -> Option { + run_with_stdin(dir, &["adjudicate", "--harness", "exit-code"], &payload()) + .status + .code() +} + +#[test] +fn a_config_this_build_cannot_load_denies_rather_than_failing_open() { + // WAS `1`, WHICH A HARNESS READS AS A NON-BLOCKING HOOK ERROR, so the + // mediated tool ran with nothing judging it (CLOUD-1677). + // + // `2` HERE AND `3` ON A DOCUMENT HARNESS, and the split is the protocol + // rather than a preference: this adapter's ONLY deny channel is the number, + // so the number has to carry the refusal. Where the decision object carries + // it instead, the number is free to say could-not-look — the case below + // asserts that side. + let dir = fixture("adjudicate-unloadable", WILL_NOT_PARSE); + assert_eq!( + code(&dir), + Some(2), + "a call nothing could judge must be refused, not allowed by the failure" + ); +} + +#[test] +fn a_loadable_config_still_allows_an_ordinary_call() { + // THE MIRROR. Without it the case above is satisfied by an adjudicator that + // denies every call in the fleet, which is an outage wearing a fix's clothes. + let dir = fixture("adjudicate-loadable", LOADS); + assert_eq!( + code(&dir), + Some(0), + "a config that loads and refuses nothing must still allow" + ); +} + +#[test] +fn the_declared_hatch_still_reaches_a_clone_whose_config_will_not_load() { + // What keeps a container recoverable rather than bricked. A stale binary + // meeting a newer config refuses every call until one of them moves, so the + // operator's declared escape has to survive exactly the state that needs it. + // + // `common::batten()` scrubs every bypass variable by construction, so setting + // one here is the only way it is present — a case that inherited it from the + // developer's shell would pass without testing anything. + use std::io::Write as _; + use std::process::Stdio; + + let dir = fixture("adjudicate-hatch", WILL_NOT_PARSE); + let mut child = common::batten() + .args(["adjudicate", "--harness", "exit-code"]) + .current_dir(&dir) + .env("BATTEN_HOOK_BYPASS", "1") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("the binary runs"); + child + .stdin + .as_mut() + .expect("stdin is piped") + .write_all(payload().as_bytes()) + .expect("the payload is writable"); + let output = child.wait_with_output().expect("the binary answers"); + assert_eq!( + output.status.code(), + Some(0), + "the declared hatch must still pass a call the engine cannot judge" + ); +} + +#[test] +fn on_claude_code_the_refusal_is_the_document_rather_than_the_number() { + // THE CHANNEL IS PER-HARNESS AND THE NUMBER IS NOT (`run_hook`'s own + // contract). This host reads the JSON decision object and ignores the code, + // so a deny raised as `2` here would be a refusal nobody receives — which is + // the reason this arm renders rather than raising a `Denial`. + let dir = fixture("adjudicate-document", WILL_NOT_PARSE); + let output = run_with_stdin( + dir.as_path(), + &["adjudicate", "--harness", "claude-code"], + &payload(), + ); + let rendered = String::from_utf8_lossy(&output.stdout); + // The row's falsifier names the field rather than the word: a `contains("deny")` + // would pass on a document that merely mentioned it, including one that said + // the opposite. + assert!( + rendered.contains(r#""permissionDecision":"deny""#), + "the decision object must carry the deny: {rendered}" + ); + // AND THE NUMBER IS FREE TO BE HONEST, which is the half that needs the + // document to exist. §6-§7 reserve `3` for could-not-look, and `exit.rs` + // keeps `Usage` and `Internal` the only codes a failure of Batten's own may + // produce *so that fail-open is structural*. Answering `2` here would buy the + // refusal a second time and spend that guarantee for the copy. + assert_eq!( + output.status.code(), + Some(3), + "where the document refuses, the number says nothing was judged" + ); +} + +#[test] +fn the_declaration_that_would_not_parse_is_named_without_quoting_it() { + // Non-negotiable rule 4, and the row asks for this clause by name: the reason + // carries the parse position, never the config's contents. The fixture's body + // is `this is not toml`, so its presence in the output would be the leak. + let dir = fixture("adjudicate-pointer-only", WILL_NOT_PARSE); + let output = run_with_stdin( + dir.as_path(), + &["adjudicate", "--harness", "claude-code"], + &payload(), + ); + let rendered = String::from_utf8_lossy(&output.stdout); + assert!( + !rendered.contains("this is not toml"), + "a refusal about an unreadable config must not quote it: {rendered}" + ); +} 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/cli.rs b/crates/batten/tests/it/cli.rs index 1a1df93e3..ce96741e7 100644 --- a/crates/batten/tests/it/cli.rs +++ b/crates/batten/tests/it/cli.rs @@ -2680,24 +2680,56 @@ fn hook_allows_when_no_authority_is_configured() { } #[test] -fn hook_fails_open_and_loud_on_an_unloadable_authority() { - // The opposite case, and the one CLOUD-40 could not reach because `hook` - // loaded no config: an authority that EXISTS and cannot be read means the - // rules the operator wrote are not being applied. Allowing silently there - // would be the false green the engine exists to catch, so it is a usage - // error — loud on stderr, exit 1, and structurally not a deny, because §7 - // spends 2 on the verdict alone. +fn hook_refuses_and_is_loud_on_an_unloadable_authority() { + // RENAMED FROM `hook_fails_open_and_loud_…`, because the first half stopped + // being true (CLOUD-1677) — and ONLY for this fixture's fault. + // + // The reasoning it was written on is what the row acted on: an authority that + // EXISTS and cannot be read means the operator's rules are not being applied, + // and allowing silently is the false green the engine exists to catch. What + // was wrong is that "loud" was the whole remedy — exit `1` is a NON-BLOCKING + // hook error, so the tool ran anyway and the loudness reached a log nobody + // gates on. Measured: 1,149 calls proceeded through seven windows of a + // mid-edit `batten.toml`, which is itself a protected path, so the gate + // guarding the config stopped guarding it during the one operation that + // changes it. + // + // **THIS FIXTURE IS `not toml at all`, WHICH IS THE WHOLE SCOPE.** A config + // that parses and merely fails a validator, or names a key this build + // predates, still answers `1` — every other row in it remains readable and + // enforceable, and an agent can still be told to repair the broken one. + // Refusing there would trade a working partial gate surface for nothing. + // Here there is no partial surface: nothing parsed, so nothing is enforced. let dir = repo_with_config("hook-broken-authority", "this is not toml at all\n"); for harness in harnesses() { let output = run_hook_in(&dir, harness, &claude_payload("gh pr view 42"), false); let code = output.status.code(); - assert_eq!(code, Some(1), "{harness}: an unreadable authority is usage"); - assert_ne!(code, Some(2), "{harness}: must never deny"); - assert!(output.stdout.is_empty(), "{harness}: no decision document"); + let stdout = String::from_utf8_lossy(&output.stdout); + // WHAT THIS CASE PROVES IS THAT NO HARNESS FAILS OPEN, and it deliberately + // does not assert the channel. Six protocols write six different decision + // documents, so a `permissionDecision` assertion inside this loop tests + // Claude Code's spelling five times and passes it off as coverage. The + // per-protocol detail — the document on a host that reads one, the `2` on + // the neutral adapter whose only channel is the number — belongs where it + // can be stated exactly, which is `adjudicate_absent.rs`. + // + // `1` was the whole defect: non-blocking, so the call ran. `0` would be a + // clean allow over an authority nothing could read. + assert!( + matches!(code, Some(2 | 3)), + "{harness}: a call under an unreadable authority must not proceed, got {code:?}" + ); + // NEVER SILENT, AND NEVER ANONYMOUS — the half of the original case that + // was always right, asked of whichever channel actually carried the + // refusal. The original asked stderr of every harness, which was true + // while the answer was always an exit-`1` diagnostic; now a document + // harness puts the reason in the document and leaves stderr empty, so + // asserting stderr alone would fail on the hosts that refuse best. let stderr = String::from_utf8_lossy(&output.stderr); + let spoken = format!("{stderr}{stdout}"); assert!( - stderr.contains("batten.toml"), - "{harness}: the failure names the file, got: {stderr}" + spoken.contains("batten.toml"), + "{harness}: the failure names the file, got: {spoken}" ); } } diff --git a/crates/batten/tests/it/config_forward_compatible.rs b/crates/batten/tests/it/config_forward_compatible.rs index 69a9018e3..c1a623148 100644 --- a/crates/batten/tests/it/config_forward_compatible.rs +++ b/crates/batten/tests/it/config_forward_compatible.rs @@ -658,14 +658,33 @@ fn the_report_never_tells_the_reader_to_install_an_older_release() { /// fault from a well-formed row naming a key from a newer schema, and collapsing /// the two is what produced the defect — a prune that swallowed a syntax error /// would load "no rules configured" over a broken file. +/// +/// **AND SINCE CLOUD-1677 THE REFUSAL REACHES THE CALL.** The claim above is +/// unchanged and is exactly why: a newer-schema row leaves every other row +/// readable, so the file still decides and an agent can be told to repair the one +/// key. A file that is not TOML leaves nothing — zero rows readable, nothing +/// enforced — and a gate that fails open there is inert, which is the false green +/// the engine exists to catch. So this is the one config fault that denies. +/// +/// It was `1`: the usage code, non-blocking, and the `rm /` below simply ran. #[test] fn a_file_that_is_not_toml_is_still_refused() { let dir = repo("config-forward-broken", "[[rule\nbroken\n"); - let (code, _, stderr) = adjudicate(&dir); - assert_eq!(code, Some(1), "malformed TOML is a usage error: {stderr}"); + let (code, stdout, stderr) = adjudicate(&dir); + // THE DENY IS THE JSON, as the case above states for this harness. What + // differs here is the number beside it: with the document carrying the + // refusal, the code is free to say could-not-look — §6-§7's `3` — rather than + // claiming a verdict was reached. `exit.rs` keeps `Usage` and `Internal` the + // only codes a Batten failure produces, so refusing costs that guarantee + // nothing. + assert_eq!(code, Some(3), "nothing could be judged: {stdout} {stderr}"); + assert!( + stdout.contains(r#""permissionDecision":"deny""#), + "a call under an unreadable authority is refused: {stdout}" + ); assert!( - stderr.contains("TOML parse error"), - "the refusal says the file is not TOML: {stderr}" + format!("{stdout}{stderr}").contains("TOML parse error"), + "the refusal says the file is not TOML: {stdout} {stderr}" ); } 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 70c2072bb..5b67c78ad 100644 --- a/crates/batten/tests/it/main.rs +++ b/crates/batten/tests/it/main.rs @@ -42,6 +42,7 @@ mod acquisition_metric; mod acquisition_sweep; mod address_resolve; mod address_transport; +mod adjudicate_absent; mod admission; mod admission_narrowing; mod advisory_drain; @@ -78,6 +79,7 @@ mod claim_carry; mod claim_order; mod claim_race; mod claim_receipt; +mod claimed_keys; mod cli; mod commit; mod commit_admission; @@ -127,6 +129,7 @@ mod fixture_forks; mod fixture_repos; mod forced_push; mod forge_facts; +mod forge_window; mod fuzz_corpus; mod gh_guard; mod git_facts; @@ -215,6 +218,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 43ba583b0..a8a551d67 100644 --- a/crates/batten/tests/it/pointer_only.rs +++ b/crates/batten/tests/it/pointer_only.rs @@ -647,6 +647,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 @@ -1031,6 +1038,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 @@ -1824,6 +1856,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 811c7ec1d..de1d5f527 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 6967fec4b..bc447900e 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 d085ee2b1..f01381a0c 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 @@ -594,6 +594,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", @@ -2394,6 +2462,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", @@ -2412,6 +2498,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", @@ -2421,6 +2569,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", @@ -3027,6 +3201,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" @@ -3219,6 +3401,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 c3aeb65b9..7f9867bc2 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 26ca74f48..5f0f65dcb 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 @@ -1843,8 +1907,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 @@ -1954,6 +2022,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 @@ -4307,7 +4441,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 @@ -4335,7 +4469,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..c4ce1d7e6 --- /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-stale" + +rules contains "branch-reused" + +rules contains "branch-listing-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-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-reused", + "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-listing-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 e7e77db0e..86e36a7fe 100644 --- a/policy/run-shape.rego +++ b/policy/run-shape.rego @@ -22,17 +22,16 @@ # 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 "background-redirect" # `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 +# `background-timer` 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; `background-timer` 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 `background-timer` rather than `polls-a-local-process`. 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 == "background-timer" } # 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 == "background-timer" } # 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 == "background-timer" } -# 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 == "background-timer" } # 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 b47890915..3a74acd61 100644 --- a/policy/shell-retirement.rego +++ b/policy/shell-retirement.rego @@ -282,8 +282,100 @@ violation contains { some path in delta.edited governed_at_head(path) not only_drops_a_retired_reference(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). # @@ -963,6 +1055,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" ] -}