diff --git a/.claude/rules/commits.md b/.claude/rules/commits.md index 570ecac1b..7d0761d77 100644 --- a/.claude/rules/commits.md +++ b/.claude/rules/commits.md @@ -41,9 +41,36 @@ ci-drift` polices `batten.toml`'s `[ci]` projection of it against the live writing the honest type — the changelog marker and the history depend on it, and the arrows start firing at `0.1.0` — but do not promise a bump in an issue's Ready block that the tool will not produce. -- Keep PRs small and focused; rebase on `main` before opening. Reference the - relevant `CLOUD-*` issue — scope lookups to the **Batten** project, since the - board spans others. +- Rebase on `main` before opening. Reference the relevant `CLOUD-*` issue — + scope lookups to the **Batten** project, since the board spans others. +- **A PR is bounded by what the work coherently needs, and a fix you can make is + part of that work rather than a follow-up row.** This bullet used to open + _"Keep PRs small and focused"_, and that clause is deleted rather than softened + because it was read exactly as it looks: as licence to stop at a diff size and + file the rest. AGENTS.md already settles this — _"a punt is any deferral you + could have closed... Can do it, do it; can't, file it"_ — and a style note + sitting one directory away must not read as an exception to it. Where the two + seem to disagree, the anti-punt directive wins, and the disagreement is a bug + in this file. + + Measured on CLOUD-1295 (2026-09-01), which is why the clause is gone rather + than qualified. Retiring `bot-issue` surfaced three rows — CLOUD-1297, + CLOUD-1299, CLOUD-1301. Two were closeable with the change in hand and the + third became closeable mid-session when `main` deleted the governed suite that + had blocked it. All three were filed instead, and this bullet was cited as the + reason. The real reason was that the PR was nearly landed after four rebases; + the citation was a route to the same outcome with less of the rule applied, + which is the laundering AGENTS.md's override section names. + + **Feedforward only, and deliberately so — no gate is implied.** Non-negotiable + rule 2 asks a new rule to ship a mechanism; this is the REMOVAL of a licence, + and the directive it was overriding already exists and already binds. A reader + who wants the mechanism should look at what actually catches this — `land`'s + own refusal to ready an unfinished branch, and `deferral-check`, which holds a + deferral to naming the row that owns it. Neither can decide whether a deferral + was closeable, because that is a judgement and non-negotiable rule 3 forbids a + gate resolving to one. + - **The FIRST key of a `Refs:` trailer is the row the commit SERVED; the rest are citations.** So `Refs: CLOUD-658, CLOUD-593, CLOUD-105` says this commit did CLOUD-658's work and cites the other two as evidence, prior measurement or diff --git a/.claude/rules/policy-modules.md b/.claude/rules/policy-modules.md index f5b83d2d0..589f66c47 100644 --- a/.claude/rules/policy-modules.md +++ b/.claude/rules/policy-modules.md @@ -84,6 +84,29 @@ The measured reason: one concept was spelled 19 different ways across 17 shell programs before the registry existed. A convention would not have stopped that; a load-time refusal does. +**NEVER BUILD A PREDICATE ON TEXT A ROUND TRIP REWRITES, and never spell a +threshold as a pattern.** Two failures, one root: reaching for the registry +because it is the nearest declaration surface rather than because the thing being +declared is a concept with one spelling. + +A tracker sanitises what it stores. This consumer already declares +`ready-issue-mention-markup` **because** a bare issue key comes back wrapped in +`` markup — so a rule matching key text is matching the one thing the +round trip is known to mangle, and it will pass in a fixture and fail in +production. Measured 2026-09-01: a prose-dialect ratchet was drafted as +`^CLOUD-([0-9]{1,3}|1[0-3][0-9]{2})$`, a key range in alternation. Wrong twice — +arithmetic is not a concept, so a range is unreadable and unmovable in a regex, +and the decision turned on rewritten text. It is a **value** now, in `[ready]`. + +Its replacement carried a subtler form of the same error and is worth the +sentence: a key ORDINAL — trailing digits, no separator assumed — reaches no +consumer literal and passes `no-tracker-key-in-core`, yet still requires keys +that are numeric AND monotonic with creation order. Three popular trackers give +that and a slug- or UUID-keyed one does not, where it would resolve to nothing +and **fail silently**. Prefer a fact every tracker actually stamps: the row's +creation instant, compared as fixed-width ISO-8601, which is what +`filed-here.rego`'s `predates_the_branch` already does. + **A PRESET IS EXEMPT, AND IN A PRESET YOU WRITE THE LITERAL INLINE.** This paragraph told authors the opposite — that the exemption was "a hole rather than a design" and to "write the row" anyway — and following it produces a **dead diff --git a/.claude/rules/toolchain.md b/.claude/rules/toolchain.md index 42d39ce4c..fceec42da 100644 --- a/.claude/rules/toolchain.md +++ b/.claude/rules/toolchain.md @@ -29,13 +29,58 @@ retired, and `crates/batten/tests/session_provisioning.rs` carries both its ledger and the tier that proves the door does what the rows say. Add a provisioning step by adding a task and a row — never by putting a second step inside an existing task's body, which is the shape that made the script -unreadable from the committed authority. Not `hk +unreadable from the committed authority. + +**AND NO NEW MECHANISM GOES BACK INTO A HARNESS'S OWN DIRECTORY.** That retirement +was a direction, not a one-off: `batten.toml` is the authority and `batten hook` +is the one entry, so a capability declared under `.claude/` — a hook, an agent +definition, a command — exists for **one** of the five wired harnesses and is +invisible to the other four, which is the reach the engine was built to have. +Measured 2026-09-01: designing a way to record that a pressure-test subagent had +actually run, an agent proposed `.claude/agents/*.md` as the prompt's home, which +would have bound the whole mechanism to Claude Code while `hook.rs` already +normalises a spawn to `Operation::Subagent` across three harnesses and reports +could-not-look on the two that declare no spelling. The declaration belongs in +`batten.toml` over a tracked file; the harness's directory is where a capability +goes to be unavailable. A harness that offers no spelling for something must read +as **unanswered**, never as absent-and-therefore-fine, which is a property only +the engine can hold. Not `hk install`: its generated hook calls `hk` bare, which does not resolve where mise's shims are off PATH, so the installed body is `.claude/hooks/git-hook.sh` — which also refuses to re-enter a gate that is already running, the recursion that hung a commit when `doctor` first tried to execute a hook from inside the gate. +**AND THE SUBJECT DECIDES BETWEEN A HOOK AND A VERB, WHICH IS THE WIDER HALF OF +THE SAME RULE.** The directory rule above catches one of the three instances +measured that day; the other two were proposed for `batten.toml` and would still +have been dead. A hook MEDIATES A CALL TO SOMEBODY ELSE'S TOOL, so its subject is +an envelope the host chose to send — and where the host spells the tool +differently, switches it off, or was never surveyed, the envelope never arrives +and the gate passes silently. A verb's subject is a RECORD THE AGENT MINTED, so a +missing one refuses identically on all five. **The rule: whenever the subject is +the agent's own conduct rather than a call it is making, land a batten verb and +read its store — never a hook over the harness's own tool.** + +The three instances, because each reaches the wrong answer by a different route: +the pressure-test subagent (its prompt proposed as `.claude/agents/*.md`, the +directory half); the todo gate (proposed as a hook over `TaskUpdate`, whose +spelling is `write_todos` on one host, `todowrite` on another, `update_plan` on a +third, and unknown on two); and `ExitPlanMode`, which only one harness sends at +all. `batten record plan` is what landed instead, and `policy/plan-complete.rego` +refuses a branch that recorded no plan while its diff is non-empty — the arm that +makes an empty store a finding rather than a pass. + +**DISABLEABILITY IS UNIVERSAL AND IS NOT THE ARGUMENT.** That reasoning was +offered first and is wrong: this harness's own todo tools can be switched off, +and Gemini CLI documents `useWriteTodos: false` for exactly that, so "the others +let you turn it off" discriminates nothing. The argument is that **absence must +be a reading, not silence** — `hook::PlanTools` therefore distinguishes +`Surveyed(&[])`, a measured none, from `Unsurveyed(owner)`, where nobody has +looked and the row names who owes the survey (CLOUD-209), and `doctor` fails on +an unsurveyed surface that names no owner. A host offering no spelling reads as +unanswered, which is a property only the engine can hold. + ## Touching a governed gate: two landable shapes, and there is no third **Read this before you open a `mise-tasks/*.sh` or a `tests/**/\*.bats`.** The @@ -511,9 +556,18 @@ call` with no `CLOUD-*` key **in that same paragraph** stops the lap. Two open the current branch carries no claim receipt. `claim-check` still mints that receipt on its pullable path, under `.git/batten-receipts/`, and the engine reads the same file: keyed by **branch**, not by SHA like `ready-guard`'s, - because a claim attests to a decision about an _issue_ that every commit on the - branch continues to serve, and a SHA-keyed one would demand a re-claim per - commit. The naive form ("refuse unless a `CLOUD-` is In Progress") is not + because a claim attests to a decision that every commit on the branch continues + to serve, and a SHA-keyed one would demand a re-claim per commit. + **THE KEY IS STORAGE, NOT CARDINALITY, and this clause used to imply + otherwise** — it read "a decision about an _issue_ that every commit on the + branch continues to serve", singular, which is the only sentence in the whole + instruction surface that touches issue-per-branch and it pointed the wrong way. + A branch carries **as many claims as it has rows** — AGENTS.md states the model + in its autonomous-workflow paragraph rather than its board one, because + `policy-budget` refused the fuller wording at its own ceiling, and + `mem:workflow/board-states` carries the rationale. Measured 2026-09-01: reading this sentence, an agent declined to + pull a second row onto an open branch and reported the receipt as forbidding + it, when the receipt is a file name. The naive form ("refuse unless a `CLOUD-` is In Progress") is not computable in a hook at all: no tracker credential exists there, which is why `claim-check` is a pure function of piped stdin. Scratch work is excluded structurally rather than by tuning — git-ignored, out-of-repo and `.git` paths diff --git a/.github/bot-lane-row.md b/.github/bot-lane-row.md new file mode 100644 index 000000000..4ef1afd0a --- /dev/null +++ b/.github/bot-lane-row.md @@ -0,0 +1,38 @@ +**Why** + +A bot proposed this change and no human refined it, which is exactly the case +CLOUD-693 exists for: the row is derived from the pull request's own manifest +diff so the merge moves the board like any other landing. Nothing here was +authored by an agent, and nothing here is a judgement. + +Pull request: #{{pr}} (`{{branch}}`, opened by `{{login}}`). + +Manifests touched: + +{{manifests}} + +**Refinement — Ready** + +_Refinement gate: Definition of Ready & Done. This body carries only specializations._ + +- **Source of truth (§1).** The manifest diff on #{{pr}}. It is the one + description of this change that cannot disagree with the change, which is why + nothing here re-types the versions it carries. +- **Computable predicate (§2).** Every required check green on the head SHA, + decided by `mise run checks-green` — the same predicate that gates every other + landing, asked of the SHA that fast-forwards. +- **Effect (§3).** No command-surface change: a dependency or toolchain bump + moves no verb, no flag and no effect row. +- **Output & exit (§5).** Unchanged — this row proposes no new output. +- **Commit / bump (§6).** `{{type}}` → no bump. +- **Test obligation (§7).** The existing suite, unchanged and unskipped: a bump + whose breakage this repo covers reds CI, and one it does not is a coverage gap + to file rather than a reason to hold the bump. +- **Blockers (§8).** None. + +**Acceptance** + +- #{{pr}} lands on `main` by fast-forward with every required check green, + through `auto-bot-land.yml` and with no human in the loop. +- This row moves to In Review by the merge, from the `Closes` key in the pull + request body. diff --git a/.github/workflows/auto-bot-land.yml b/.github/workflows/auto-bot-land.yml index 013729fdc..6050d193c 100644 --- a/.github/workflows/auto-bot-land.yml +++ b/.github/workflows/auto-bot-land.yml @@ -53,7 +53,7 @@ name: auto-bot-land # # AND THE BOARD MOVES WITH THE WORK (CLOUD-693). A bot proposes with no issue and # no session, so every lifecycle gate refuses it by construction and a merge moves -# nothing. `mise run bot-issue ensure` is the missing step: it derives an issue +# nothing. `batten pr ensure` is the missing step: it derives an issue # from the manifest diff, files it, and writes `Closes CLOUD-` into the PR body # so the merge moves the row like any other landing. It runs on every tick, before # anything is readied, so the row exists while the PR is still a draft. @@ -99,7 +99,12 @@ on: # was 46% of all workflow runs, and at ~3100 inserted runs a day the global run # list shifts page boundaries mid-walk, so paginating it is not stable. The # `if:` below stays as defence in depth; the two answer different failure modes. - branches: ["renovate/**"] + # + # TWO LANES SINCE CLOUD-1213, and the second is the licence-carry one + # `sbom-actions-currency` opens. The scope is per-lane rather than a wildcard + # for the same reason it exists at all: a broad filter puts every completion + # in the repository back on this workflow's run list. + branches: ["renovate/**", "sbom-actions/**"] # THE ONLY TRIGGER THAT CAN START THE LANE, and the reason this workflow has a # clock at all. A draft PR completes no workflow, so `workflow_run` never fires # for one — the `workflow_run` path above is the free ride that lands a head @@ -187,14 +192,30 @@ jobs: (github.event.workflow_run.event == 'pull_request' && github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.head_repository.full_name == github.repository && - startsWith(github.event.workflow_run.head_branch, 'renovate/')) + (startsWith(github.event.workflow_run.head_branch, 'renovate/') || + startsWith(github.event.workflow_run.head_branch, 'sbom-actions/'))) runs-on: ubuntu-latest timeout-minutes: 3 # budget: p95=52s x3 measured=2026-08-19 env: REPO: ${{ github.repository }} # The author half of the same test, applied in the resolve step below where - # the PR object is in hand. Named here so both arms read one constant. - BOT_LOGIN: renovate[bot] + # the PR object is in hand. Named here so both arms read one table. + # + # A TABLE OF PAIRS, NOT TWO INDEPENDENT LISTS (CLOUD-1213). The two lanes + # have different authors — Renovate opens its own PRs, and the licence-carry + # lane is opened by `sbom-actions-currency` under the default token — so a + # single login no longer answers. Pairing them is what keeps the widening + # honest: matching any-prefix against any-login would admit + # `renovate[bot]` on a `sbom-actions/` branch and the workflow's own token + # on a `renovate/` one, neither of which either lane can produce. A PR must + # satisfy ONE row whole. + # + # This does not touch CLOUD-867's origin test. `head_repository.full_name` + # is still what a fork cannot forge and is still checked separately; the + # prefix remains a filter, never the trust boundary. + LANES: >- + [{"prefix":"renovate/","login":"renovate[bot]"}, + {"prefix":"sbom-actions/","login":"github-actions[bot]"}] # The freeze `renovate.json5` declares as `stopUpdatingLabel` (CLOUD-1207). # The two must agree by string or the freeze is a label nothing reads, so # this is the one place either is spelled in a workflow. @@ -235,17 +256,24 @@ jobs: # above, so a failing `gh` still fails the step. if [ "$EVENT" = "schedule" ] || [ "$EVENT" = "workflow_dispatch" ]; then pr=$(gh api "repos/$REPO/pulls?state=open&per_page=100" | - jq -c --arg repo "$REPO" --arg bot "$BOT_LOGIN" \ - '[.[] | select(.head.repo.full_name == $repo) - | select(.user.login == $bot) - | select(.head.ref | startswith("renovate/"))] | .[0] // {}') - [ -n "$(jq -r '.number // empty' <<<"$pr")" ] || echo "no open renovate PR; nothing to land" + jq -c --arg repo "$REPO" --argjson lanes "$LANES" \ + '[.[] | select(.head.repo.full_name == $repo)] + | map(select(. as $p + | any($lanes[]; . as $l + | $p.user.login == $l.login + and ($p.head.ref | startswith($l.prefix))))) + | .[0] // {}') + [ -n "$(jq -r '.number // empty' <<<"$pr")" ] || echo "no open bot PR; nothing to land" else pr=$(gh api "repos/$REPO/commits/$RUN_SHA/pulls" | - jq -c --arg repo "$REPO" --arg bot "$BOT_LOGIN" \ + jq -c --arg repo "$REPO" --argjson lanes "$LANES" \ 'map(select(.state == "open") - | select(.head.repo.full_name == $repo) - | select(.user.login == $bot)) | .[0] // {}') + | select(.head.repo.full_name == $repo)) + | map(select(. as $p + | any($lanes[]; . as $l + | $p.user.login == $l.login + and ($p.head.ref | startswith($l.prefix))))) + | .[0] // {}') [ -n "$(jq -r '.number // empty' <<<"$pr")" ] || echo "no open PR for $RUN_SHA; skipping" fi num=$(jq -r '.number // empty' <<<"$pr") @@ -368,10 +396,16 @@ jobs: PR_NUM: ${{ steps.target.outputs.num }} run: | set +e - mise run bot-issue ensure "$PR_NUM" + batten pr ensure "$PR_NUM" verdict=$? + # THE ENGINE'S EXIT TABLE, which is not the retired program's + # (CLOUD-1295). `bot-issue` used 1 for "refused, not this lane's" and 2 + # for "could not look"; under house style section 7 a refusal is 2 and a + # could-not-look is 3, with 1 reserved for a usage error. A pull request + # this lane declines to file for is still an ordinary outcome and passes; + # everything else fails the run. case "$verdict" in - 0 | 1) exit 0 ;; + 0 | 2) exit 0 ;; *) exit "$verdict" ;; esac # IS THIS HEAD LANDABLE AT ALL? `compare/main...SHA` answers in one read: @@ -583,9 +617,9 @@ jobs: # small; it does not close it, and a landing inside it is silent — `main` # advances, the bump ships, and the row never leaves Backlog. # - # Exit 1 "closes nothing" is an ORDINARY outcome, like the `main`-moved + # Exit 2 "closes nothing" is an ORDINARY outcome, like the `main`-moved # refusal below: the next tick re-runs `ensure`, the key comes back, and it - # lands then. Only exit 2, "could not look", fails the run. + # lands then. Only exit 3, "could not look", fails the run. - name: Does the body still close its row? if: steps.checks.outputs.verdict == 'green' id: closes @@ -594,11 +628,14 @@ jobs: PR_NUM: ${{ steps.target.outputs.num }} run: | set +e - mise run bot-issue closes "$PR_NUM" + batten pr closes "$PR_NUM" verdict=$? + # The engine's table, as one step up: 2 is the refusal and 3 is the + # could-not-look that must fail the run rather than read as "closes + # nothing" and hold a landable head forever. case "$verdict" in 0) echo "linked=true" >> "$GITHUB_OUTPUT" ;; - 1) echo "linked=false" >> "$GITHUB_OUTPUT" ;; + 2) echo "linked=false" >> "$GITHUB_OUTPUT" ;; *) exit "$verdict" ;; esac - name: Fast-forward main to the tested SHA diff --git a/.github/workflows/sbom-actions-currency.yml b/.github/workflows/sbom-actions-currency.yml index 13a6b8228..42b3ffba2 100644 --- a/.github/workflows/sbom-actions-currency.yml +++ b/.github/workflows/sbom-actions-currency.yml @@ -201,6 +201,16 @@ jobs: # backticks in them are code spans for a human reader, not command # substitution. Double-quoting them would make the shell try to run # `sbom-check` and friends. + # + # THE `DO-NOT-CLOSE` LINE IS LOAD-BEARING, not decoration. A body that + # names a tracker key without closing it is exactly what + # `closing-key-check` stops a landing on, and this body names one in + # its `Refs:` trailer. Measured on #806, whose body had to be patched + # by hand before `land` would go past it. The marker is the declared + # way to say "this pull request deliberately completes no issue", and + # it does not read as a close even though it ends in a closing verb. + # The row stays open because a carry is not the completion of + # CLOUD-1213 -- it is the lane CLOUD-1213 built, doing its job. body=$(printf '%s\n' \ 'Opened by `sbom-actions-currency`.' \ '' \ @@ -210,7 +220,9 @@ jobs: '' \ 'A pin whose licence files differ, or whose repository has no row at all, is **not** here — it is reported as a warning in the run log for a person to read.' \ '' \ - 'Refs: CLOUD-1213') + 'Refs: CLOUD-1213' \ + '' \ + 'DO-NOT-CLOSE CLOUD-1213') gh pr create --repo "$REPO" --draft --base main --head "$branch" \ --title 'ci(deps): carry licence rows forward for bumped action pins' \ --body "$body" diff --git a/.serena/memories/core.md b/.serena/memories/core.md index ac01f5b19..c7c32ec87 100644 --- a/.serena/memories/core.md +++ b/.serena/memories/core.md @@ -71,6 +71,40 @@ err)` takes **both** channels and the resolved `Mode`, so a verb can write a `-J`?), and a flag carries `hidden` plus `Rung` — which §3 ladder rung it selects — so "is this a ladder flag" is a column rather than a naming convention, and the ladder's totality is a census test. +- `bot.rs` — the bot lane, retired off `mise-tasks/bot-issue.sh` (CLOUD-1295). + Two halves in one module: the PREDICATES — is this PR one of the lane's, which + manifests it touched, what Conventional type its subject declares, whether a + body still CLOSES a key rather than merely naming one — are pure functions with + no network at all, and the `forge` submodule is the only thing that talks to + anybody. It reads through `gh` rather than `fetch.rs` for `pr_watch`'s reason + (CLOUD-1143): the client resolves the credential OUTSIDE this crate, where the + transport would put token resolution inside it next to no config row declaring + one. Every consumer fact — repository, bot logins, owned manifests, the marker + strings, the tracker's key prefix, the branch prefix, the body template's path + — is `[bot_lane]` in `batten.toml`, so a grep of `crates/batten` for a bot's + name or a manifest path returns nothing (rule 1); `document_facts` caught the + first draft's doc comment naming one as an example. The verbs are `batten pr +derive|file|link|ensure|closes` plus `claim bot`, and neither forge-reading one + declares `-J`: the `-J` census's byte-stability term would be a claim about the + forge's answer rather than about the verb, which is the same call `pr watch` + makes. `claim bot` is the SECOND receipt kind and `carry.rs` below is the + third; they are separate because they attest different things (CLOUD-431). +- `carry.rs` — whether a licence-carry branch's diff is DERIVABLE, and the + receipt that records it (CLOUD-1295). `sbom-actions-currency` (CLOUD-1213) + opens its PRs on `sbom-actions/carry-`, which neither receipt + `verify` accepts would fit — so the first one landed on a `--takeover` claim + asserting a refinement nobody performed. **Nothing here reads the branch + name**: a prefix exemption would be a password wearing one, and anything able + to name itself so would pass. What is attested is checkable against the merge + base — one path differs and it is the licence table, every added row names a + repo the BASE already carries with an identical licence and holder so only the + sha moved, and nothing is removed or rewritten. Two choices are the whole + predicate: append-only is a PREFIX comparison, because a line-set difference + reads a rewritten row as a removal plus an addition and could admit the + addition; and the known verdicts come from the base side ONLY, or two unmapped + repos vouch for each other and the branch carries a licence nobody judged. + Byte-identity of the upstream files is the workflow's half, stated as such — + this bounds what the diff may say, not what upstream holds. - `claim.rs` — whether an issue is pullable, and the receipt that records the pull (CLOUD-272, CLOUD-431; ported off `mise-tasks/claim-check.sh` by CLOUD-1121). The tracker's automation fires on the PR event — the END of the diff --git a/.serena/memories/workflow/agent-fanout.md b/.serena/memories/workflow/agent-fanout.md index 5863f36ac..97a19a5f1 100644 --- a/.serena/memories/workflow/agent-fanout.md +++ b/.serena/memories/workflow/agent-fanout.md @@ -123,10 +123,31 @@ it were one, which is how a number nobody approved becomes a standing constraint The measurement below is unaffected and is NOT the cap: N ≈ 2.9 prices _land contention_, and the lever that measurement argues for is still "serialise the landing, shorten the lap, quiet `main`". The cap is a separate, owner-set bound on -how many implementers may hold a claim at once, and the two must not be conflated +how many BUILDS may be in flight at once, and the two must not be conflated again — if the arithmetic below argues for a different number, that is an argument to bring to the owner, not a licence to edit this one. +**THE UNIT IS A BUILD, NOT A TICKET, AND THIS FILE SAID THE WRONG ONE.** It read +"how many implementers may hold a claim at once", which is the PR/issue +conflation one layer down: a branch carries as many rows as the work needs, so a +PR closing ten tickets is **WIP 1**. It contends for the lease once, rebases +once, runs `verify` once. Counting claims makes the cap punish exactly the +bundling the section below tells you to maximise — an eight-row bundle in one +domain would read as WIP 8 while costing the trunk what WIP 1 costs. + +Measured 2026-09-01: reading "enforced at claim time", an agent reported the WIP +cap as a bound on how many tickets it could take, twice. + +**AND THE MECHANISM COUNTS THE WRONG THING TOO**, so this is not merely a wording +fix: `mise-tasks/graph-check.sh` emits +`wip $(jq -r '[.[] | select(.status == "In Progress")] | length')` — one per +ISSUE. The board-computable count of builds is the distinct PR attachments among +In Progress rows plus the In Progress rows carrying none; that over-counts a +pre-PR bundle and never under-counts, which is the safe direction. `graph-check` +is governed shell, so the fix is a retirement row rather than an edit +(`.claude/rules/toolchain.md`), and until it lands the emitted `wip` number reads +high for anybody who bundles. + Past the cap the binding constraint is **land contention**, not compute: every land forces siblings to rebase and re-run `verify`, so N ≈ time-between-lands ÷ verify-duration. **A rising re-verify rate is NOT the stop signal** — an @@ -261,10 +282,49 @@ procedure; this section owns why it is shaped that way. **Dispatch bundles, not single tickets.** A session handed one ticket stops when it lands, and its container plus its warm context are thrown away. A session -handed an ordered chain in one file domain keeps going, and — the part that -matters for the cap above — amortises several commits over one rebase cost -instead of paying that cost per ticket. Bundling is what raises the ceiling; -adding sessions is not. +handed an ordered chain in one file domain keeps going instead. Bundling is what +raises the ceiling; adding sessions is not. + +**AND THE REASON IS NOT REBASE AMORTISATION — that argument is refuted by this +file's own next section.** It used to read "amortises several commits over one +rebase cost instead of paying that cost per ticket", which contradicts the +caps section directly: _"a fast-forward refusal rebases and re-verifies with no +model turn, so a moved base costs CPU and wall-clock, both of which are free +here, and zero tokens. Re-verifying is the loop working."_ You cannot amortise a +free thing. `land` laps unattended and an agent absorbs rebases without a turn, +so a rebase is not a cost that bounds anything. + +**What bundling actually saves is the METERED half of AGENTS.md's three costs.** +Local execution — a build, a rebase, the whole suite — is free. A CI run costs +real minutes and a model call is metered in the same category. Ten rows in one +PR buy **one** CI matrix, one review, one lease acquisition and one landing +sequence, where ten PRs buy ten of each. That is a real multiple on the only +costs that are real, and it does not weaken as the fleet gets faster — where the +rebase argument got weaker the better the automation got, which is the tell that +it was never the reason. + +**SO MAXIMISE THE BUNDLE, subject to file-domain coherence and nothing else.** +The direction is not "a few is better than one" — it is _as many related rows as +the domain holds_. Every extra row in a bundle is one more thing landed per +rebase, per `verify`, per CI run and per lease acquisition, so it makes the +measured constraint smaller rather than larger. A bundle of eight in one domain +lands faster than four bundles of two, and the four bundles also contend with +each other. + +**THERE IS NO PR-SIZE CAP HERE, AND NONE SHOULD BE INFERRED.** The cap of 2 and +the WIP cap of 6 are bounds on concurrent LANDING; neither says anything about +how large a diff may be. Nothing in this repository caps lines changed, and a +reviewer reading a coherent domain-scoped diff is reading one story either way. +Measured 2026-09-01: an agent invented a "2500 lines is big" threshold, cited it +as a reason to split work across PRs, and it appears nowhere in this repository — +inventing a size limit is how the amortisation above gets thrown away by an agent +being careful about the wrong thing. The context window is not the binding +constraint on a frontier model, and treating it as one costs laps. + +The real bound on a bundle is the one already stated: **it must be one file +domain**, read off open PRs' file lists rather than their titles. Two rows that +sound unrelated and both edit `mise-tasks/land.sh` belong in the same bundle; +two that sound related and touch disjoint trees do not. Order within a bundle by real dependency: the ticket whose gate the next one needs goes first, and the ticket that _replaces_ what an earlier one fixed goes diff --git a/.serena/memories/workflow/board-states.md b/.serena/memories/workflow/board-states.md index 719ca6936..afa7d0c51 100644 --- a/.serena/memories/workflow/board-states.md +++ b/.serena/memories/workflow/board-states.md @@ -245,6 +245,30 @@ which is what this wanted: the predicate needs a network call, and no rule kind can make one on a mediated call (CLOUD-446). `verify` is the earliest surface that still sits on every path to a published PR. +**ONE COMMIT ONE ISSUE; ONE BRANCH MANY ISSUES; ONE PR EVERYTHING, NO PUNTS.** +AGENTS.md carries the rule; this is why it needs saying at all. **The commit is +the unit of work and the branch is not a row.** A second row you find mid-branch +is claimed and worked THERE — cutting a fresh branch for it is precisely the +batching `land`'s lap loop exists to prevent (each lap rebases onto a little more +landed work, so conflicts arrive one resolvable increment at a time), and +stopping at the first row is the punt `filed-and-left-open` prices. Drafts run no +CI, so the PR is finished before a runner is spent; readying to "get a first +signal" spends a matrix on work you already know is incomplete. + +**Nothing keys work to a branch except the claim receipt's FILENAME, and that is +storage rather than the model.** The distinction is worth spelling out because +both the prose and the mechanism used to point the other way. `.claude/rules/toolchain.md` +explained the branch keying as attesting "a decision about an _issue_ that every +commit on the branch continues to serve" — singular, and the only sentence in the +whole instruction surface touching issue-per-branch cardinality. Meanwhile +`claim::mint` took a slice and wrote line 1 as an id LIST, but wrote the file +with `fs::write`, so a second `claim check` INVOCATION replaced the first row's +claim silently. Measured 2026-09-01: an agent read the receipt as forbidding a +second row on an open branch and reported the filename as the rule. Both halves +are fixed — the ids union now, guarded by the recorded `base` so a restarted +branch (`git checkout -B origin/main`, which keeps the name and discards +the commits) starts a fresh list rather than carrying CLOUD-516's stale claim. + **`claim-check` runs BEFORE the board move, not after — and the order is not interchangeable.** It refuses `not-todo`, so once the issue is In Progress it refuses the very claim you just made, and it cannot tell your own move from a diff --git a/AGENTS.md b/AGENTS.md index f2c16a70f..47c7ac67f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -37,8 +37,8 @@ stopping short to ask is the deviation. **The gates ARE your authorization** — you run them yourself, and they halt you by _failing_, not by needing a blessing. So **`git commit` needs no asking** — local, reversible, and commit early and often, since a sprawling uncommitted tree is what this kills. Establish base -state first (`git fetch origin main`), work on a short-lived branch, never author -on `main`, and carry the lifecycle without stopping between steps to report. +state first (`git fetch origin main`), never author on `main`, and work ONE +short-lived branch: one commit one issue, one branch many rows, one PR all of it. **When you SHOULD still stop** (real exceptions, not an escape hatch): a gate fails and the fix is genuinely ambiguous; a rebase conflict needs a human diff --git a/batten.toml b/batten.toml index 55464fd21..026f2e9d4 100644 --- a/batten.toml +++ b/batten.toml @@ -386,6 +386,49 @@ exits non-zero unless all are green — with no timeout to reintroduce the \ VM-reap gap. Background it. (`gh pr view`/`list`/`create`, `gh pr ready`, `gh \ api`, `gh run view` are NOT blocked.)""" +# TWO CLONES CAN WRITE ONE BRANCH AND NOTHING REFUSED IT, measured 2026-09-02 on +# `claude/cloud-1295-retire-bot-issue`: a sibling session pushed onto this +# session's branch mid-work, and git's own non-fast-forward rejection was the only +# thing in the stack that noticed — after the work was written, verified and +# committed. +# +# THE PREDICATE IS BARE-VERSUS-EXPLICIT, NOT THE FLAG, and `land-lock.sh` already +# states why in its own words: "The expected value is passed EXPLICITLY +# (`:`) and must stay that way. Bare `--force-with-lease` compares +# against this clone's remote-tracking ref — what the last fetch happened to see — +# which for a ref other sessions are actively rewriting is precisely the stale +# value this must not trust. The two forms look interchangeable and are not." +# +# So a bare lease is what this refuses. `git fetch` moves the remote-tracking ref +# onto the sibling's commit, the bare lease then compares EQUAL, and the push +# destroys what the fetch just brought in — the flag chosen for being careful. +# The explicit `=:` form is allowed because naming the sha IS the +# assertion: you cannot name a value you never saw, and a stale one is refused by +# git rather than by policy. +# +# A MODULE RATHER THAN A `shape` ROW, for two reasons that arrive together. A +# `shape` row's `contains` is a substring, so it cannot tell `--force-with-lease` +# from `--force-with-lease=…` — the whole distinction. And `refusal.rs` is +# explicit that a refusal composed from a consumer `[[rule]]` row carries no +# declared class, "deliberately not a Batten class… no token an admission could +# bind" — so such a row can never be admitted, and the only way through would be +# a `bypass_env`, which is the password shape CLOUD-1051 retired on the stated +# ground that *the point of the admission mechanism is that the bare variable +# stops working*. A module raises a declared class, and a class can declare a +# route. +# +# `trunk-based/no-force-push` keeps `--force` and `-f`; this adds only the +# spelling that preset deliberately leaves out. `land-lock`'s own CAS is +# untouched and structurally so: it pushes from inside a task subprocess, which +# is not a mediated call, so no `mediated_call` row can reach it — and it passes +# the explicit form anyway, which this allows. +[[rule]] +id = "leased-push" +kind = "policy" +scope = "mediated_call" +module = "policy/leased-push.rego" +severity = "deny" + [[rule]] id = "gh-run-watch" kind = "shape" @@ -883,7 +926,24 @@ kind = "receipt" scope = "mediated_call" severity = "deny" trigger = "write" -checks = ["claim"] +# `checks_any` RATHER THAN `checks`, and it is a widening this row owes an +# account of (CLOUD-1297). `verify`'s own body has always accepted any one of +# these three and treated it as enough; this row demanded `claim` alone, so a +# branch legitimately carrying a `bot.` receipt was denied on every +# write while holding a valid attestation — a live false positive since +# CLOUD-693, which CLOUD-1295's `carry` kind inherited. +# +# A SECOND `checks` ROW COULD NOT HAVE SAID THIS. A receipt row's `checks` is a +# conjunction and two rows are a second AND, so the obvious spelling would have +# denied every ordinary write on every branch rather than admitting an +# alternative. That is why the column had to exist before the row could. +# +# The alternation is still a gate: a branch carrying NONE of the three is denied +# exactly as before, and no fourth kind is admitted by being nearby. What is +# given up is the claim that a `claim` receipt specifically was minted, and that +# was never what the gate meant to assert — `verify`, the authority this row was +# always meant to agree with, has accepted the alternatives all along. +checks_any = ["claim", "bot", "carry"] key = "branch" reason = """ Pull the issue first: search the board (`mise run graph-check` prints the ready \ @@ -1328,6 +1388,29 @@ regex = '(?i)(deferred?|deferring|defers) (it |that |this )?to|owned by|belongs id = "ready-issue-key" regex = 'CLOUD-[0-9]+' +# THE FORGE'S CLOSING VERBS, ANCHORED AT THE END, so the row decides the text +# IMMEDIATELY before a key rather than anywhere earlier in the body. Without the +# anchor a single `Closes` in a paragraph would make every key after it read as +# closed, which is the naming-versus-claiming conflation CLOUD-674 measured on +# `claimed-keys` — a body citing a row as evidence is not moving it. +# +# The vocabulary is GitHub's rather than this repository's, which is why it is a +# `[[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. +[[pattern]] +id = "ready-closing-verb" +regex = '(?i)(^|[^0-9A-Za-z-])(clos(e|es|ed)|fix(|es|ed)|resolv(e|es|ed))[[:space:]]*:?[[:space:]]*#?$' + +# 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 +# wrong surface twice over: this registry gives one CONCEPT one spelling, and +# arithmetic is not a concept, so a range spelled in alternation is unreadable +# and unmovable. Worse, it decides on key TEXT, which the tracker rewrites — the +# `ready-issue-mention-markup` row exists precisely because a bare key comes back +# wrapped in `` markup, so matching key text matches the one thing the +# round trip is known to mangle. The threshold is a number, in `[ready]` below. + # THE CLOSED STATUSES, as the tracker's `{slug:status}` renders them: lowercased # with non-alphanumeric runs folded to `-`. A row here rather than a literal in # the module for `ready-issue-key`'s reason one layer over -- a tracker's closed @@ -1454,6 +1537,31 @@ zero-is-a-count = true name = "sec1" value = { program = { run = "named-paths", read = "stdout", stdin = { section = { from = { result = "description" }, label = "clause-label", select = "clause-one" } } } } +# THE §7 OBLIGATIONS THE ROW DECLARES (CLOUD-472), as `:` per entry. +# +# `cites` above is the precedent and the shape is identical: an `authority` +# column reading a prefixed stdout line, over the description the TRACKER +# returned rather than a payload the caller assembled. That is what makes the set +# unforgeable by the author, and it was earned — `ready-lint` over a +# self-assembled payload was measured green three times against text in a local +# file, once under an id no row carried. +# +# THE THREE-VALUED READ IS THE WHOLE POINT HERE. An ABSENT line is +# could-not-look: a prose-dialect block emits none, and reading that as "declares +# no obligations" would exempt exactly the rows this gate exists for. A PRESENT +# and empty line is the honest zero — the object was read and declares none. +# `Read::StdoutLine` keeps those apart by construction, which is why the +# obligation set rides a line rather than an exit status. +# +# EVERY ROW WRITING `board-writes` CARRIES THIS COLUMN, because the readers index +# positionally: a recorder that skipped it would put its neighbour's value where +# this one belongs for exactly the rows it did write. +[[recorder.columns]] +name = "obligations" +value = { authority = { ask = "ready", read = { stdout-line = "obligations " }, stdin = { object = { id = { result = "id" }, description = { result = "description" } } } } } +counted-with = ":" +zero-is-a-count = true + # A GROOM OF A ROW THIS BRANCH FILED, and without it `filed-here-check`'s third # remedy is unreachable. That gate tells a branch which filed an unrefined row to # groom it and re-run `land` — but a groom is a `save_issue` WITH an id, so the @@ -1513,6 +1621,31 @@ zero-is-a-count = true name = "sec1" value = { program = { run = "named-paths", read = "stdout", stdin = { section = { from = { result = "description" }, label = "clause-label", select = "clause-one" } } } } +# THE §7 OBLIGATIONS THE ROW DECLARES (CLOUD-472), as `:` per entry. +# +# `cites` above is the precedent and the shape is identical: an `authority` +# column reading a prefixed stdout line, over the description the TRACKER +# returned rather than a payload the caller assembled. That is what makes the set +# unforgeable by the author, and it was earned — `ready-lint` over a +# self-assembled payload was measured green three times against text in a local +# file, once under an id no row carried. +# +# THE THREE-VALUED READ IS THE WHOLE POINT HERE. An ABSENT line is +# could-not-look: a prose-dialect block emits none, and reading that as "declares +# no obligations" would exempt exactly the rows this gate exists for. A PRESENT +# and empty line is the honest zero — the object was read and declares none. +# `Read::StdoutLine` keeps those apart by construction, which is why the +# obligation set rides a line rather than an exit status. +# +# EVERY ROW WRITING `board-writes` CARRIES THIS COLUMN, because the readers index +# positionally: a recorder that skipped it would put its neighbour's value where +# this one belongs for exactly the rows it did write. +[[recorder.columns]] +name = "obligations" +value = { authority = { ask = "ready", read = { stdout-line = "obligations " }, stdin = { object = { id = { result = "id" }, description = { result = "description" } } } } } +counted-with = ":" +zero-is-a-count = true + # A COMMENT IS RECORDED AND NEVER GATED. It is the honest common case — a comment # on the row that already owns a finding — and pricing it would push the pressure # toward silence, which is the failure `finding-sink-check` exists to catch. So the @@ -1559,6 +1692,10 @@ value = { literal = "-" } name = "sec1" value = { literal = "-" } +[[recorder.columns]] +name = "obligations" +value = { literal = "-" } + # ─── THE PR BODY REACHES THE PREDICATE BY HOOK (CLOUD-1051, phase B0) ─── # # `filed-here-check` exempts a row the PR CLOSES, because recomputing the overlap @@ -1620,6 +1757,50 @@ regex = 'gh[[:space:]]+pr[[:space:]]+view\b[^|;&]*--jq[[:space:]]+\.body\b' id = "shell-script-directory" regex = '^\$\(dirname([[:space:]]+--)?[[:space:]]+"[^"]*"\)$|^\$\(cd([[:space:]]+--)?[[:space:]]+"\$\(dirname([[:space:]]+--)?[[:space:]]+"[^"]*"\)"[[:space:]]*&&[[:space:]]*pwd\)$|^\$\{(BASH_SOURCE\[0\]|0)%/\*\}$' +# THE SUBCOMMAND TAIL of a task-name invocation (CLOUD-1299). +# +# `shell-retirement`'s repointing arm could only recognise a span that named its +# callee by PATH, and a caller that names it as a TASK had no landable shape. +# Measured on `tests/verify.bats:263`, which pins the `verify` remedy's prose: +# the span is `bot-issue receipt` — a task name plus a subcommand, which is none +# of a repo-relative path, a `$(dirname "$0")/x.sh` sibling, or a variable bound +# to one. Retiring the program that name refers to therefore had no admitted +# edit at all, and CLOUD-1295 shipped a dead task name kept alive in a remedy +# because a frozen suite pinned it. +# +# ANCHORED AT BOTH ENDS, which is the whole of why this is a shape rather than a +# licence. The module matches this against the text FOLLOWING a naming form, so +# an unanchored row would let any span merely CONTAINING a subcommand-ish run of +# characters satisfy the arm — and `contains` on the addition side is exactly +# what `shell-retirement`'s own comment refuses in as many words. +# +# The vocabulary is deliberately narrower than a shell word: lowercase, digits +# and hyphens, in space-separated runs. That is what a task name and its +# subcommands are spelled with in this tree, and it admits no quote, no `$`, no +# redirect and no separator — so a span carrying anything that could change what +# a line DOES is not a subcommand tail and is refused. +[[pattern]] +id = "shell-task-subcommand" +regex = '^([[:space:]]+[a-z0-9][a-z0-9-]*)*$' + +# THE OTHER HALF of the same span, and it is two rows for the reason the two +# script-directory rows above are: they decide different questions. That one +# decides what may FOLLOW the name; this decides what may PRECEDE it. +# +# A caller naming a task usually names the runner too — `mise run ` +# is the spelling in this tree — so a span anchored strictly at the name would +# admit the assertion in `tests/verify.bats` and refuse the comment three lines +# above it, which is half a repointing and no use to anyone. +# +# ANCHORED AT BOTH ENDS AND OPTIONAL. Empty matches, which is what admits a bare +# ` ` span through the same clause; anything else must be exactly the +# runner invocation. So the prefix cannot be arbitrary text that merely ends +# before the name — `mise-tasks/` does not match, which is what keeps a path +# span from being read as a task span. +[[pattern]] +id = "shell-task-runner-prefix" +regex = '^(mise run )?$' + # The MARKER, which is a different question from the expression above and is why # there are two rows rather than one. `sibling-resolves` states the reason on its # own `script_dir_line`: "the spelling varies more than a single regex should try @@ -2624,6 +2805,44 @@ no_fix_reason = "delete the literal; a consumer fact belongs in that consumer's # WHERE THE VOCABULARY GOES INSTEAD is `[[pattern]]`, which exists for exactly # this and whose own module doc says so: a tracker key "is a *consumer* # identifier … written here it is where consumer facts belong". +# THE TWENTY-FIRST COPY (CLOUD-1142). `no-tracker-key-in-core` below refuses a +# derivation outright, and can, because `crates/**` carries none — the crate owns +# the three AXES (`ready::Grammar::key_of` and `keys_in`) and never the token. +# The task tree is the other half and cannot be refused outright: CLOUD-761 +# measured twenty derivations there in nine spellings, and 34 occurrences across +# 12 programs survive today. A `forbid` would fire on every one of them. +# +# SO IT RATCHETS RATHER THAN FORBIDS, which is the whole of what is landable here. +# Converting those sites means editing governed `mise-tasks/` programs, which +# `V-SHELL-RULE-EDITED` refuses with one route — retire them — and that is +# CLOUD-761's remaining half, blocked on the retirement campaign's sequencing. +# What this row buys meanwhile is the property whose absence is how twenty +# arrived: the count cannot GROW. A twenty-first copy fails; the existing ones +# wait for their program's retirement, each taking its derivation with it. +# +# The direction is also why the row needs no exemption for the authority. A +# ratchet counts rather than judges, so `batten.toml`'s own rows are simply not in +# this glob, and `policy/**` carries none at all because a module reads +# `data.batten.patterns["ready-issue-key"]` by id — the registry doing its job. +[[rule]] +id = "issue-key-derivations-not-growing" +kind = "ratchet" +glob = "mise-tasks/**" +# A LITERAL SUBSTRING, NOT A REGEX, and the distinction is a dead gate away. +# `ratchet_rule` counts with `text.matches(pattern)`, which is `str::matches` — +# substring, not expression. The first draft of this row wrote the escaped regex +# form and counted ZERO at both ends, so the ratchet loaded, ran, and could never +# fire. It was caught by driving a twenty-first derivation through it rather than +# by reading, which is the only thing that tells a passing gate from an absent +# one. `forbid` above takes `regex` and this takes `pattern`: two columns, two +# languages, one file. +pattern = 'CLOUD-[0-9]' +direction = "non_increasing" +base = "origin/main" +severity = "deny" +scope = "tree" +no_fix_reason = "read the key from `[[pattern]] ready-issue-key`, or let the program's retirement carry its derivation away; a twenty-first spelling is how the first twenty arrived" + [[rule]] id = "no-tracker-key-in-core" kind = "forbid" @@ -4354,6 +4573,44 @@ severity = "deny" # paths it was going to accept anyway — a gate reading a pre-filtered input cannot # tell "nothing was added" from "the filter removed it". The depth test lives in # the module, where a reader can check it against Cargo's autodiscovery rule. +# CLOUD-472. The plan a branch declared, held to its own end. +# +# `delta_sources` is the whole tree because the vacuity arm asks whether this +# branch is holding ANYTHING open, and a narrower delta would hand it a +# pre-filtered answer — a gate that cannot tell "nothing changed" from "the +# filter removed it" is the class `test-targets` below states for its own reason. +# +# NO `line_sources`: the subject is the record `batten record plan` wrote and the +# delta the engine already resolved. This row opens no file. +# CLOUD-472. A §7 obligation is bound to a case, or it is not landing. +# +# `line_sources` is where a reader should look hardest, because the failure it +# guards is silent: the module resolves a declared slug by reading the named +# file's lines, so an obligation naming a file OUTSIDE these globs can never be +# bound and the rule would refuse it for the wrong reason. The globs are the +# places a `#MUTANT` row can legitimately live. +# +# `delta_sources` is the whole tree because the subject is the recorded +# obligation set rather than any path this branch happens to touch. +[[rule]] +id = "obligations-bound" +kind = "policy" +scope = "tree" +base = "origin/main" +delta_sources = ["**"] +line_sources = ["crates/batten/tests/**/*.rs", "policy/*.rego", "tests/**/*.bats", "mise-tasks/**"] +module = "policy/obligations-bound.rego" +severity = "deny" + +[[rule]] +id = "plan-complete" +kind = "policy" +scope = "tree" +base = "origin/main" +delta_sources = ["**"] +module = "policy/plan-complete.rego" +severity = "deny" + [[rule]] id = "test-targets" kind = "policy" @@ -5619,6 +5876,77 @@ cold = false [commit] subject_pattern = '^(build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test)([(][a-z0-9._-]+[)])?!?: .+' +# --------------------------------------------------------------------------- +# The bot lane: which proposals this repository files a tracker row for +# (CLOUD-693, ported off `mise-tasks/bot-issue.sh` by CLOUD-1295). +# +# WHY THESE FACTS ARE HERE. This is the answer, not the mechanism. `crates/batten` +# holds the matcher and carries no bot login, no manifest path and no tracker key +# shape — non-negotiable rule 1 — so every consumer-specific value the retired +# program spelled internally is one of the rows below. +# +# WHY A BOT ROW CAN BE MECHANICAL AT ALL, which is the honest part. A bump has no +# design question to refine: the source of truth is the manifest, the predicate is +# "CI green on the bump", the effect is none, and the type follows the one +# `renovate.json5` already decided. That is exactly why it must not reuse the +# agent refinement path, where a human judgement is the thing being attested — the +# two attest different things, and CLOUD-431 exists to keep them apart. +# +# THE TABLE IS AUTHORITY-ONLY, and structurally so: `resolve.rs` carries it +# straight from the committed file rather than through the local layer. Adding a +# login to `bots` or a path to `owned_manifests` turns a refusal into a filed row, +# which is a weakening dressed as an addition and not something house style §8's +# raise-only rule admits. +[bot_lane] +# Where the lane's pull requests live. +repo = "button-inc/batten" + +# The bots this lane files for, in every spelling the app authenticates as. +# `dependabot` is deliberately absent — CLOUD-660 retired it, and a row filed for +# a bot that cannot open a pull request would be a claim about a lane this +# repository does not have. +bots = ["renovate", "renovate[bot]", "mend-for-github-com[bot]"] + +# The manifests this lane owns, and the only paths a bot pull request may touch to +# earn a row. Declared here rather than derived from `renovate.json5`'s +# `enabledManagers`: a manager name is not a path, the mapping between them is +# Renovate's and not ours to re-derive, and `ci-tools-check` already decides that +# the manager list itself is complete. +owned_manifests = [ + "mise.toml", + "Cargo.toml", + "Cargo.lock", + ".github/workflows/**", +] + +# The marker that ties a mirror issue to the pull request it was filed for. A +# hidden HTML comment rather than a label or a title convention: it survives an +# edit, it is invisible in the rendered issue, and it is what makes `pr ensure` +# idempotent across the window where the row exists and the pull request body does +# not yet name it. +marker_prefix = "bot-lane pr=" + +# What the tracker's GitHub Issues sync leaves on the issue once it has mirrored +# it. Measured on #558 -> CLOUD-764, 2026-08-20: `linear-code[bot]` posts a +# comment carrying this marker and the row's URL, about two seconds after +# creation. +linkback_marker = "" + +# The tracker's key shape. The consumer's vocabulary, exactly as the Ready +# grammar's `[[pattern]]` rows are. +key_prefix = "CLOUD-" + +# The branch prefix `claim bot` will attest. A branch outside it is sent to the +# agent claim receipt, which attests something else entirely. +branch_prefix = "renovate/" + +# The derived row's body, as a tracked file with `{{...}}` placeholders rather +# than a string here: it is a page of markdown carrying a Ready block, and a page +# of markdown inside a TOML value is unreviewable. The engine refuses at +# substitution any placeholder it does not fill, so a template edit cannot leak a +# literal `{{...}}` into a tracker row. +body_template = ".github/bot-lane-row.md" + # --------------------------------------------------------------------------- # What produced commits may carry about the tooling that made them (CLOUD-274), # enforcing the agent-neutral attribution decision record (CLOUD-268). @@ -5699,6 +6027,36 @@ email = "alec@wenzowski.com" # The cost is stated rather than discovered: a pin bump now moves the epoch and # invalidates receipts, exactly as an AGENTS.md edit already does. That is the # intended direction — a toolchain change IS a change in what decided the check. +# The refinement gate's thresholds (CLOUD-472). +# +# WHAT IT CLOSES. A Ready block may be written in two dialects: prose, validated +# for the clauses that ARE present, or the fenced claims object, whose +# `REQUIRED_CLAIMS` forces `tests` and whose every entry must name a `file` AND +# the `mutation` that would kill it — CLOUD-418's obligation as a field, where an +# entry that cannot name its discriminating mutation cannot be written. The +# object landed and was UNREACHABLE: an absent fence drops the author onto the +# prose path, so the whole mechanism was opt-in. Measured 2026-09-01 over the +# 50-row Todo queue, nothing used it, and CLOUD-1306 — filed that day — carried a +# §7 naming three obligations in prose, none joinable to anything. +# +# A RATCHET, NOT A FLIP. `graph-check` enforces `Todo ⇒ ready-lint exits 0`, so +# refusing every prose block at once takes the whole ready frontier dark in one +# step — CLOUD-858 measured exactly that, three rows bringing the board down. +# +# THE CUTOVER IS AFTER EVERY ROW THAT EXISTS TODAY, so nothing currently on the +# board is refused. The headroom is the migration window rather than slack: +# moving this stamp later is the only direction that tightens, and each step +# costs somebody a body to groom. +# +# AN INSTANT RATHER THAN A KEY ORDINAL, and the reason is portability rather than +# taste. This value is the consumer's, but the ENGINE reading it must assume +# nothing: an ordinal threshold needs keys that are numeric and monotonic with +# creation order, which this tracker gives and a slug- or UUID-keyed one does +# not — and there it would fail silently rather than loudly. Every tracker stamps +# a creation time. +[ready] +prose_dialect_required_from = "2026-09-02T00:00:00.000Z" + [epoch] # `.mcp.json` is here for the agent-context record (CLOUD-579), which covers the # agent config a repository governs itself with through the epoch rather than by @@ -6403,6 +6761,10 @@ gloss = "fixed at a version" word = "program" gloss = "an executable" +[[vocabulary.subject]] +word = "plan" +gloss = "the work a branch declared it would do" + [[vocabulary.subject]] word = "prose" gloss = "authored text" @@ -7462,6 +7824,207 @@ id = "path admit first" kind = "override" precondition = "the row DOCUMENTS the change being landed, so naming its files is the point rather than a deferral" +# CLOUD-1274's destruction half, as a class rather than a `reason` string. +# +# A CLASS BECAUSE A CONSUMER `[[rule]]` ROW CANNOT BE ADMITTED. `refusal.rs` says +# it outright: a refusal composed from a consumer row carries no declared class, +# "deliberately not a Batten class", and "a consumer-composed refusal carries no +# declared class, so there is no token an admission could bind". So a `shape` row +# here would leave `bypass_env` as the only way through — the password shape +# CLOUD-1051 retired, on the ground `hook.rs` restates: *the point of the +# admission mechanism is that the bare variable stops working*. A module raises a +# declared class, `admit_mediated` binds (rule, class, subject), and the route +# below is one an author can actually take. +[[verdict]] +id = "branch write unsafe" +gloss = "a push that overwrites the remote without having said what it expects to find" +class = """ +A bare `--force-with-lease` compares against this clone's remote-tracking ref, so \ +`git fetch` followed by a leased push compares EQUAL and overwrites the sibling \ +commit the fetch just brought in. Every ownership fact this engine records lives \ +under `$GIT_DIR` and dies with the container, so a session writing the same \ +branch is invisible from here — which is why the assertion has to be in the \ +command rather than in a lookup. The explicit `=:` form is not this \ +class: naming the sha IS the assertion, and a stale one is refused by git. +""" + +[[verdict.route]] +id = "patch run first" +kind = "command" +target = "git fetch, then rebase onto what is there" + +[[verdict.route]] +id = "task run other" +kind = "command" +target = "git push --force-with-lease=: — name the commit you expect to replace" + +[[verdict.route]] +id = "path admit first" +kind = "override" +precondition = "the history being replaced is this clone's own and no other clone has fetched it, so there is no expected value to name that a reader could check" + +# CLOUD-1311. The punt sweep had a question and no exit code. +# +# `stop_nudges` rule 5 has asked the right thing at the end of every turn since +# CLOUD-1051 — "is it genuinely independent work, or a punt you could close here?" +# — and an agent reasons past it, because a nudge costs nothing to answer wrongly +# and the answer dies with the turn. Measured 2026-09-01: four deferrals in one +# session, each with a principled-sounding blocker, every one of them false. Three +# were invisible to `issue file same` because their §1 named paths outside +# the diff, which `cites_only` exempts by design. The detector was a human asking +# twice. +# +# WHY CLOUD-514'S EXCLUSION NO LONGER BINDS. That issue built the record and ruled +# this half out in terms that were right on the day: "deciding whether a given +# spin-off was legitimate — the judgement the gate must never make." The premise +# is now obsolete rather than the reasoning. The gate still makes no such +# judgement; it reports a SET, and the author pays for the classification through +# an admission whose articulation CLOUD-1278 made durable by binding it into the +# commit message. Deny over an object, with the only exit an explanation written +# into history — the shape CLOUD-514 lacked. +# +# THE OVERRIDE IS THE POINT, NOT THE LEAK. An honest split-out is never refused; +# it costs one articulation naming the row and saying why it is independent work, +# which is free for a row you genuinely could not close and expensive for one you +# could. That is CLOUD-514's own "the friction must sit only on the impulsive +# path", spent where it belongs. +# CLOUD-472. The agent's own declared work, held to its end. +# +# THE SENSOR WAS ALWAYS THERE. A task list is displayed every turn and is the +# most legible statement of what an agent believes is outstanding — and nothing +# in the tree could see it, so a branch landed with entries `pending` and every +# gate stayed green. That is this repository's recurring defect rather than a new +# one: `stop_nudges` rule 5 asked the right question with no exit code, +# `ready.rs` emitted `dialect prose` with no ratchet, and `graph-check` counts a +# `wip` in the wrong unit. A reporting surface with nothing downstream of it. +# CLOUD-472's core. The Ready block is the only artifact that still remembers +# what was promised, and until now nothing read it at implementation time. +[[verdict]] +id = "test name undefined" +gloss = "an obligation this row declared names no case, or a case with no mutation that could kill it" +class = """ +`ready-lint` gates the SHAPE of a Ready block and `verify` gates the CODE, and \ +nothing compared them -- so an obligation could be written, pass refinement, and \ +land with no test behind it while every gate stayed green. Measured on \ +CLOUD-369, which merged by fast-forward with an acceptance bullet that did not \ +describe the code. The reason it is structural rather than inattention: tests \ +written FROM an implementation can only confirm it, and are incapable of \ +catching an obligation that was dropped, because a missing behaviour has no \ +code to write a test against. This rule and `mise run mutant` are one \ +obligation in two halves -- this asks whether something is bound to the \ +promise, the sweep asks whether it discriminates, and a case that cannot fail \ +is not coverage. +""" + +[[verdict.route]] +id = "task run first" +kind = "command" +target = "write the case the obligation names, in the file it names" + +[[verdict.route]] +id = "task run other" +kind = "command" +target = "mise run mutant" + +[[verdict.route]] +id = "task run last" +kind = "command" +target = "groom the row: an obligation you are not keeping should not be declared" + +[[verdict.route]] +id = "path admit first" +kind = "override" +precondition = "the obligation is discharged by a successor this change declares in the `[rule.conserves]` ledger, so the case moved rather than never existing" + +[[verdict]] +id = "plan declare held" +gloss = "an entry this branch declared is neither completed nor withdrawn" +class = """ +The agent said it would do this and is landing without having done it. The gate \ +reads a status TOKEN and nothing else: it does not judge whether the entry was \ +worth doing, whether its text is honest, or whether the work behind `completed` \ +happened — those are model verdicts and no gate here makes one. Three exits, and \ +two of them are free: finish it, withdraw it deliberately so the store records \ +that the decision was made, or spend an admission saying why it is not this \ +branch's to finish. The store is written by `batten record plan` rather than \ +scraped from a harness's todo tool, because a hook needs a spelling per host and \ +records nothing where one is unsurveyed or switched off — a missing verb call \ +refuses everywhere instead. +""" + +[[verdict.route]] +id = "task run first" +kind = "command" +target = "do the entry, then re-record the plan with it completed" + +[[verdict.route]] +id = "task run other" +kind = "command" +target = "batten record plan" + +[[verdict.route]] +id = "path admit first" +kind = "override" +precondition = "the entry is work this branch could not have done — it needs a decision, a mechanism, or an artifact that does not exist yet — rather than work you declared and declined to finish while holding the file open" + +# The anti-vacuity half, and it is not optional: a refusal over unfinished +# entries is satisfied completely by never recording one, so silence is the +# cheapest route past it and must be priced. Same shape as `mutate` REPORTING a +# declared mutation whose named case does not exist rather than counting it. +[[verdict]] +id = "plan declare absent" +gloss = "this branch is holding work open and declared no plan at all" +class = """ +Not an exhortation to plan: it closes the hole the other class would otherwise \ +leave wide open, because a gate over entries left in flight costs nothing to \ +satisfy if you simply never record an entry. Gated on a NON-EMPTY diff, so it \ +prices work rather than existence and a fresh checkout is never refused — and \ +satisfied by an EMPTY record, so a genuinely trivial change costs one call \ +saying so rather than a fabricated entry, which is what a gate demanding a \ +non-empty list would have bought. +""" + +[[verdict.route]] +id = "task run first" +kind = "command" +target = "batten record plan" + +[[verdict]] +id = "issue file held" +gloss = "a row this branch put on the board is neither closed here nor closed by the body" +class = """ +The punt the other two refusals cannot see. `issue file unclear` prices \ +refinement and is payable in typing; `issue file same` prices proximity \ +and is silent by design on a row whose declared source of truth lies outside \ +this diff — which is exactly where a deferral hides, because the cheapest punt \ +names somebody else's file. This reads the set of rows the branch filed, \ +subtracts the ones the PR body closes and the ones recorded before the branch's \ +base, and reports what is left. It classifies nothing and compares no \ +semantics: whether a spin-off was legitimate is still the judgement no gate \ +makes, and it is still not made here — the author makes it, in an articulation \ +a reviewer reads in the commit message. +""" + +[[verdict.route]] +id = "task run first" +kind = "command" +target = "close the row you filed and fix it in this diff" + +[[verdict.route]] +id = "task run other" +kind = "command" +target = "name it in closing form in the PR body, so the merge lands it" + +[[verdict.route]] +id = "task run last" +kind = "command" +target = "file it from a clean tree, when it is no longer your branch's deferral" + +[[verdict.route]] +id = "path admit first" +kind = "override" +precondition = "the row is work this branch could not have done — it needs a decision, a mechanism, or an artifact that does not exist yet — rather than work you declined to do while holding the file open" + [[verdict]] id = "shell edit refused" gloss = "an authored shell rule or bats suite was edited in place rather than migrated" diff --git a/bench/suites/RESULTS.md b/bench/suites/RESULTS.md index c197a7855..bd663a96a 100644 --- a/bench/suites/RESULTS.md +++ b/bench/suites/RESULTS.md @@ -6,127 +6,126 @@ runner measured it; the suite runs `--no-parallelize-within-files`, so a file's number is its own serial cost and is what an author adding a case to it pays. -- suites: 119 -- serial total: 493.2s +- suites: 118 +- serial total: 458.7s | seconds | share | suite | | ---: | ---: | --- | -| 135.8 | 26.9% | `tests/land-lock.bats` | -| 76.7 | 15.2% | `tests/land.bats` | -| 34.7 | 6.9% | `tests/main-watch.bats` | -| 24.3 | 4.8% | `tests/hook-latency-drift.bats` | -| 18.7 | 3.7% | `tests/sbom-check.bats` | -| 18.2 | 3.6% | `tests/graph-check.bats` | -| 12.3 | 2.4% | `tests/board-diff-overlap.bats` | -| 7.9 | 1.6% | `tests/token-bench.bats` | -| 7.7 | 1.5% | `tests/ready-lint.bats` | -| 7.4 | 1.5% | `tests/target-race.bats` | -| 6.7 | 1.3% | `tests/ready-guard.bats` | -| 6.3 | 1.3% | `tests/released.bats` | -| 6.0 | 1.2% | `tests/board-sweep.bats` | -| 5.1 | 1.0% | `tests/release-tracking-check.bats` | -| 5.1 | 1.0% | `tests/replay.bats` | -| 5.1 | 1.0% | `tests/release-assets-check.bats` | -| 4.8 | 0.9% | `tests/sbom.bats` | -| 4.8 | 0.9% | `tests/mcp-allow-check.bats` | -| 4.5 | 0.9% | `tests/singleton.bats` | -| 4.5 | 0.9% | `tests/step-receipt.bats` | -| 4.5 | 0.9% | `tests/in-progress-drain.bats` | -| 3.9 | 0.8% | `tests/task-registry.bats` | -| 3.5 | 0.7% | `tests/doctor-race.bats` | -| 3.2 | 0.6% | `tests/land-divergence.bats` | -| 3.2 | 0.6% | `tests/ready-cites-check.bats` | -| 3.1 | 0.6% | `tests/ntia-check.bats` | +| 125.3 | 27.3% | `tests/land-lock.bats` | +| 70.3 | 15.3% | `tests/land.bats` | +| 34.5 | 7.5% | `tests/main-watch.bats` | +| 25.4 | 5.5% | `tests/sbom-check.bats` | +| 24.2 | 5.3% | `tests/hook-latency-drift.bats` | +| 15.3 | 3.3% | `tests/graph-check.bats` | +| 10.6 | 2.3% | `tests/board-diff-overlap.bats` | +| 7.2 | 1.6% | `tests/target-race.bats` | +| 6.9 | 1.5% | `tests/token-bench.bats` | +| 6.0 | 1.3% | `tests/ready-lint.bats` | +| 5.4 | 1.2% | `tests/released.bats` | +| 5.4 | 1.2% | `tests/singleton.bats` | +| 5.1 | 1.1% | `tests/ready-guard.bats` | +| 5.1 | 1.1% | `tests/board-sweep.bats` | +| 4.8 | 1.0% | `tests/sbom.bats` | +| 4.6 | 1.0% | `tests/signing-posture.bats` | +| 4.5 | 1.0% | `tests/replay.bats` | +| 4.4 | 0.9% | `tests/release-tracking-check.bats` | +| 4.0 | 0.9% | `tests/release-assets-check.bats` | +| 3.8 | 0.8% | `tests/step-receipt.bats` | +| 3.8 | 0.8% | `tests/task-registry.bats` | +| 3.8 | 0.8% | `tests/in-progress-drain.bats` | +| 3.4 | 0.7% | `tests/doctor-race.bats` | +| 3.3 | 0.7% | `tests/mcp-allow-check.bats` | | 3.0 | 0.6% | `tests/with-lock.bats` | -| 3.0 | 0.6% | `tests/target-ensure.bats` | -| 2.8 | 0.5% | `tests/hk-selection.bats` | -| 2.2 | 0.4% | `tests/landed-check.bats` | -| 2.1 | 0.4% | `tests/closing-key-check.bats` | -| 2.0 | 0.4% | `tests/install.bats` | -| 2.0 | 0.4% | `tests/claim-race-check.bats` | -| 2.0 | 0.4% | `tests/suite-select.bats` | -| 1.8 | 0.4% | `tests/spec-ref-check.bats` | -| 1.7 | 0.3% | `tests/finding-sink-check.bats` | -| 1.6 | 0.3% | `tests/signing-posture.bats` | -| 1.6 | 0.3% | `tests/reclaim-census.bats` | -| 1.6 | 0.3% | `tests/claimed-keys.bats` | -| 1.6 | 0.3% | `tests/tree-clean.bats` | -| 1.5 | 0.3% | `tests/bot-issue.bats` | -| 1.5 | 0.3% | `tests/ci-slow-needed.bats` | -| 1.4 | 0.3% | `tests/ci-tools-check.bats` | -| 1.4 | 0.3% | `tests/ready-lint-deferral.bats` | -| 1.3 | 0.3% | `tests/alive.bats` | -| 1.3 | 0.3% | `tests/perf-record.bats` | -| 1.2 | 0.2% | `tests/verify.bats` | -| 1.2 | 0.2% | `tests/spawn-census.bats` | -| 1.1 | 0.2% | `tests/install-check.bats` | -| 1.1 | 0.2% | `tests/ci-lease-precondition.bats` | -| 1.1 | 0.2% | `tests/land-divergence-assert.bats` | -| 1.0 | 0.2% | `tests/done-check.bats` | -| 1.0 | 0.2% | `tests/deferral-check.bats` | -| 1.0 | 0.2% | `tests/linear-check.bats` | -| 0.9 | 0.2% | `tests/nonverdict-scan.bats` | -| 0.9 | 0.2% | `tests/module-map-check.bats` | -| 0.9 | 0.2% | `tests/awk-regex-check.bats` | -| 0.9 | 0.2% | `tests/perf-assert.bats` | -| 0.8 | 0.2% | `tests/lint-rego.bats` | -| 0.8 | 0.2% | `tests/release-backfill.bats` | -| 0.8 | 0.2% | `tests/render-cli.bats` | -| 0.8 | 0.2% | `tests/commit-attribution.bats` | -| 0.8 | 0.2% | `tests/lint-deno.bats` | -| 0.8 | 0.1% | `tests/done-pr-check.bats` | +| 2.8 | 0.6% | `tests/ready-cites-check.bats` | +| 2.7 | 0.6% | `tests/land-divergence.bats` | +| 2.7 | 0.6% | `tests/target-ensure.bats` | +| 2.6 | 0.6% | `tests/ntia-check.bats` | +| 2.5 | 0.5% | `tests/hk-selection.bats` | +| 1.9 | 0.4% | `tests/closing-key-check.bats` | +| 1.9 | 0.4% | `tests/install.bats` | +| 1.8 | 0.4% | `tests/landed-check.bats` | +| 1.8 | 0.4% | `tests/claim-race-check.bats` | +| 1.6 | 0.3% | `tests/suite-select.bats` | +| 1.5 | 0.3% | `tests/serena-mcp.bats` | +| 1.5 | 0.3% | `tests/spec-ref-check.bats` | +| 1.5 | 0.3% | `tests/finding-sink-check.bats` | +| 1.5 | 0.3% | `tests/reclaim-census.bats` | +| 1.4 | 0.3% | `tests/lint-deno.bats` | +| 1.3 | 0.3% | `tests/claimed-keys.bats` | +| 1.2 | 0.3% | `tests/tree-clean.bats` | +| 1.2 | 0.3% | `tests/ci-slow-needed.bats` | +| 1.2 | 0.3% | `tests/alive.bats` | +| 1.2 | 0.3% | `tests/ci-tools-check.bats` | +| 1.0 | 0.2% | `tests/verify.bats` | +| 1.0 | 0.2% | `tests/ready-lint-deferral.bats` | +| 0.9 | 0.2% | `tests/install-check.bats` | +| 0.9 | 0.2% | `tests/ci-lease-precondition.bats` | +| 0.9 | 0.2% | `tests/perf-record.bats` | +| 0.9 | 0.2% | `tests/land-divergence-assert.bats` | +| 0.9 | 0.2% | `tests/spawn-census.bats` | +| 0.9 | 0.2% | `tests/deferral-check.bats` | +| 0.9 | 0.2% | `tests/done-check.bats` | +| 0.9 | 0.2% | `tests/linear-check.bats` | +| 0.8 | 0.2% | `tests/nonverdict-scan.bats` | +| 0.8 | 0.2% | `tests/awk-regex-check.bats` | +| 0.8 | 0.2% | `tests/module-map-check.bats` | +| 0.7 | 0.2% | `tests/release-backfill.bats` | +| 0.7 | 0.2% | `tests/perf-assert.bats` | +| 0.7 | 0.2% | `tests/lint-rego.bats` | +| 0.7 | 0.1% | `tests/evaluator-closure-check.bats` | | 0.7 | 0.1% | `tests/attestation-check.bats` | -| 0.7 | 0.1% | `tests/pr-unsubscribed.bats` | -| 0.7 | 0.1% | `tests/doctor.bats` | -| 0.7 | 0.1% | `tests/timeout-drift.bats` | -| 0.7 | 0.1% | `tests/sbom-binary.bats` | +| 0.6 | 0.1% | `tests/render-cli.bats` | +| 0.6 | 0.1% | `tests/doctor.bats` | +| 0.6 | 0.1% | `tests/pr-unsubscribed.bats` | +| 0.6 | 0.1% | `tests/done-pr-check.bats` | +| 0.6 | 0.1% | `tests/commit-attribution.bats` | +| 0.6 | 0.1% | `tests/timeout-drift.bats` | | 0.6 | 0.1% | `tests/hook-matcher-check.bats` | -| 0.6 | 0.1% | `tests/evaluator-closure-check.bats` | -| 0.6 | 0.1% | `tests/suite-bench-check.bats` | -| 0.6 | 0.1% | `tests/stop-posture-check.bats` | -| 0.6 | 0.1% | `tests/duplicate-close-check.bats` | -| 0.6 | 0.1% | `tests/perf-compare.bats` | -| 0.6 | 0.1% | `tests/merged-pr-keys.bats` | -| 0.6 | 0.1% | `tests/mcp-timeout-budget.bats` | -| 0.6 | 0.1% | `tests/verified.bats` | -| 0.6 | 0.1% | `tests/mcp-attach-check.bats` | -| 0.5 | 0.1% | `tests/macos-link-check.bats` | -| 0.5 | 0.1% | `tests/checksums.bats` | -| 0.5 | 0.1% | `tests/sonar-gate.bats` | +| 0.5 | 0.1% | `tests/duplicate-close-check.bats` | +| 0.5 | 0.1% | `tests/sbom-binary.bats` | +| 0.5 | 0.1% | `tests/perf-compare.bats` | +| 0.5 | 0.1% | `tests/verified.bats` | +| 0.5 | 0.1% | `tests/suite-bench-check.bats` | +| 0.5 | 0.1% | `tests/mcp-timeout-budget.bats` | +| 0.5 | 0.1% | `tests/merged-pr-keys.bats` | +| 0.5 | 0.1% | `tests/mcp-attach-check.bats` | +| 0.4 | 0.1% | `tests/macos-link-check.bats` | +| 0.4 | 0.1% | `tests/stop-posture-check.bats` | +| 0.4 | 0.1% | `tests/checksums.bats` | | 0.4 | 0.1% | `tests/publish-credential-check.bats` | | 0.4 | 0.1% | `tests/pipefail-grep-check.bats` | -| 0.4 | 0.1% | `tests/hook-pin-check.bats` | | 0.4 | 0.1% | `tests/digest-major-agreement.bats` | -| 0.4 | 0.1% | `tests/connector-allow-guard.bats` | -| 0.4 | 0.1% | `tests/msrv-pin-agreement.bats` | -| 0.4 | 0.1% | `tests/board-payloads.bats` | -| 0.4 | 0.1% | `tests/land-lock-check.bats` | -| 0.4 | 0.1% | `tests/abandon-matrix.bats` | -| 0.4 | 0.1% | `tests/branch-age-check.bats` | -| 0.3 | 0.1% | `tests/timeout-check.bats` | +| 0.3 | 0.1% | `tests/connector-allow-guard.bats` | +| 0.3 | 0.1% | `tests/board-payloads.bats` | +| 0.3 | 0.1% | `tests/msrv-pin-agreement.bats` | +| 0.3 | 0.1% | `tests/abandon-matrix.bats` | +| 0.3 | 0.1% | `tests/branch-age-check.bats` | +| 0.3 | 0.1% | `tests/hook-pin-check.bats` | +| 0.3 | 0.1% | `tests/land-lock-check.bats` | +| 0.3 | 0.1% | `tests/sonar-gate.bats` | | 0.3 | 0.1% | `tests/commit-convention.bats` | -| 0.3 | 0.1% | `tests/serena-mcp.bats` | | 0.3 | 0.1% | `tests/transcript-corpus-check.bats` | -| 0.3 | 0.1% | `tests/license-table-check.bats` | +| 0.3 | 0.1% | `tests/timeout-check.bats` | | 0.3 | 0.1% | `tests/no-doctests.bats` | -| 0.3 | 0.1% | `tests/release-due.bats` | | 0.3 | 0.1% | `tests/nonverdict-assert.bats` | -| 0.3 | 0.1% | `tests/connector-allow-resolve.bats` | | 0.3 | 0.1% | `tests/report-only-check.bats` | -| 0.3 | 0.1% | `tests/cap-drift.bats` | -| 0.3 | 0.1% | `tests/coderabbit-config-check.bats` | -| 0.3 | 0.1% | `tests/batten-glob-check.bats` | -| 0.3 | 0.1% | `tests/container-preflight.bats` | -| 0.2 | 0.0% | `tests/git-hook.bats` | +| 0.3 | 0.1% | `tests/release-due.bats` | +| 0.2 | 0.1% | `tests/license-table-check.bats` | +| 0.2 | 0.1% | `tests/connector-allow-resolve.bats` | +| 0.2 | 0.1% | `tests/batten-glob-check.bats` | +| 0.2 | 0.0% | `tests/container-preflight.bats` | +| 0.2 | 0.0% | `tests/coderabbit-config-check.bats` | +| 0.2 | 0.0% | `tests/cap-drift.bats` | | 0.2 | 0.0% | `tests/mise-action-floor.bats` | +| 0.2 | 0.0% | `tests/git-hook.bats` | | 0.2 | 0.0% | `tests/rust-paths-check.bats` | -| 0.2 | 0.0% | `tests/token-bench-check.bats` | -| 0.2 | 0.0% | `tests/perf-gate.bats` | +| 0.1 | 0.0% | `tests/perf-gate.bats` | | 0.1 | 0.0% | `tests/remedy-payload-source.bats` | +| 0.1 | 0.0% | `tests/token-bench-check.bats` | | 0.1 | 0.0% | `tests/task-fail-closed.bats` | | 0.1 | 0.0% | `tests/dist.bats` | -| 0.1 | 0.0% | `tests/evaluator-io-check.bats` | | 0.1 | 0.0% | `tests/egress-check.bats` | -| 0.1 | 0.0% | `tests/darwin-link.bats` | +| 0.1 | 0.0% | `tests/evaluator-io-check.bats` | | 0.1 | 0.0% | `tests/cross-check.bats` | +| 0.0 | 0.0% | `tests/darwin-link.bats` | | 0.0 | 0.0% | `tests/zizmor-split.bats` | diff --git a/completions/batten.bash b/completions/batten.bash index 3d8b1f6cc..61c71dbae 100644 --- a/completions/batten.bash +++ b/completions/batten.bash @@ -181,12 +181,24 @@ _batten() { batten__subcmd__checks__subcmd__help,help) cmd="batten__subcmd__checks__subcmd__help__subcmd__help" ;; + batten__subcmd__claim,bot) + cmd="batten__subcmd__claim__subcmd__bot" + ;; + batten__subcmd__claim,carry) + cmd="batten__subcmd__claim__subcmd__carry" + ;; batten__subcmd__claim,check) cmd="batten__subcmd__claim__subcmd__check" ;; batten__subcmd__claim,help) cmd="batten__subcmd__claim__subcmd__help" ;; + batten__subcmd__claim__subcmd__help,bot) + cmd="batten__subcmd__claim__subcmd__help__subcmd__bot" + ;; + batten__subcmd__claim__subcmd__help,carry) + cmd="batten__subcmd__claim__subcmd__help__subcmd__carry" + ;; batten__subcmd__claim__subcmd__help,check) cmd="batten__subcmd__claim__subcmd__help__subcmd__check" ;; @@ -439,6 +451,12 @@ _batten() { batten__subcmd__help__subcmd__checks,green) cmd="batten__subcmd__help__subcmd__checks__subcmd__green" ;; + batten__subcmd__help__subcmd__claim,bot) + cmd="batten__subcmd__help__subcmd__claim__subcmd__bot" + ;; + batten__subcmd__help__subcmd__claim,carry) + cmd="batten__subcmd__help__subcmd__claim__subcmd__carry" + ;; batten__subcmd__help__subcmd__claim,check) cmd="batten__subcmd__help__subcmd__claim__subcmd__check" ;; @@ -523,6 +541,21 @@ _batten() { batten__subcmd__help__subcmd__policy,tools) cmd="batten__subcmd__help__subcmd__policy__subcmd__tools" ;; + batten__subcmd__help__subcmd__pr,closes) + cmd="batten__subcmd__help__subcmd__pr__subcmd__closes" + ;; + batten__subcmd__help__subcmd__pr,derive) + cmd="batten__subcmd__help__subcmd__pr__subcmd__derive" + ;; + batten__subcmd__help__subcmd__pr,ensure) + cmd="batten__subcmd__help__subcmd__pr__subcmd__ensure" + ;; + batten__subcmd__help__subcmd__pr,file) + cmd="batten__subcmd__help__subcmd__pr__subcmd__file" + ;; + batten__subcmd__help__subcmd__pr,link) + cmd="batten__subcmd__help__subcmd__pr__subcmd__link" + ;; batten__subcmd__help__subcmd__pr,watch) cmd="batten__subcmd__help__subcmd__pr__subcmd__watch" ;; @@ -541,9 +574,15 @@ _batten() { batten__subcmd__help__subcmd__receipt,status) cmd="batten__subcmd__help__subcmd__receipt__subcmd__status" ;; + batten__subcmd__help__subcmd__record,closes) + cmd="batten__subcmd__help__subcmd__record__subcmd__closes" + ;; batten__subcmd__help__subcmd__record,forge) cmd="batten__subcmd__help__subcmd__record__subcmd__forge" ;; + batten__subcmd__help__subcmd__record,plan) + cmd="batten__subcmd__help__subcmd__record__subcmd__plan" + ;; batten__subcmd__help__subcmd__record,tool) cmd="batten__subcmd__help__subcmd__record__subcmd__tool" ;; @@ -694,15 +733,45 @@ _batten() { batten__subcmd__policy__subcmd__help,tools) cmd="batten__subcmd__policy__subcmd__help__subcmd__tools" ;; + batten__subcmd__pr,closes) + cmd="batten__subcmd__pr__subcmd__closes" + ;; + batten__subcmd__pr,derive) + cmd="batten__subcmd__pr__subcmd__derive" + ;; + batten__subcmd__pr,ensure) + cmd="batten__subcmd__pr__subcmd__ensure" + ;; + batten__subcmd__pr,file) + cmd="batten__subcmd__pr__subcmd__file" + ;; batten__subcmd__pr,help) cmd="batten__subcmd__pr__subcmd__help" ;; + batten__subcmd__pr,link) + cmd="batten__subcmd__pr__subcmd__link" + ;; batten__subcmd__pr,watch) cmd="batten__subcmd__pr__subcmd__watch" ;; + batten__subcmd__pr__subcmd__help,closes) + cmd="batten__subcmd__pr__subcmd__help__subcmd__closes" + ;; + batten__subcmd__pr__subcmd__help,derive) + cmd="batten__subcmd__pr__subcmd__help__subcmd__derive" + ;; + batten__subcmd__pr__subcmd__help,ensure) + cmd="batten__subcmd__pr__subcmd__help__subcmd__ensure" + ;; + batten__subcmd__pr__subcmd__help,file) + cmd="batten__subcmd__pr__subcmd__help__subcmd__file" + ;; batten__subcmd__pr__subcmd__help,help) cmd="batten__subcmd__pr__subcmd__help__subcmd__help" ;; + batten__subcmd__pr__subcmd__help,link) + cmd="batten__subcmd__pr__subcmd__help__subcmd__link" + ;; batten__subcmd__pr__subcmd__help,watch) cmd="batten__subcmd__pr__subcmd__help__subcmd__watch" ;; @@ -754,21 +823,33 @@ _batten() { batten__subcmd__receipt__subcmd__help,status) cmd="batten__subcmd__receipt__subcmd__help__subcmd__status" ;; + batten__subcmd__record,closes) + cmd="batten__subcmd__record__subcmd__closes" + ;; batten__subcmd__record,forge) cmd="batten__subcmd__record__subcmd__forge" ;; batten__subcmd__record,help) cmd="batten__subcmd__record__subcmd__help" ;; + batten__subcmd__record,plan) + cmd="batten__subcmd__record__subcmd__plan" + ;; 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,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,plan) + cmd="batten__subcmd__record__subcmd__help__subcmd__plan" + ;; batten__subcmd__record__subcmd__help,tool) cmd="batten__subcmd__record__subcmd__help__subcmd__tool" ;; @@ -1491,7 +1572,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 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 check bot carry help" if [[ ${cur} == -* || ${COMP_CWORD} -eq 2 ]] ; then COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 @@ -1520,6 +1601,66 @@ _batten() { COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 ;; + batten__subcmd__claim__subcmd__bot) + 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__claim__subcmd__carry) + opts="-J -q -v -y -h --json --strictness --fail-on-warning --config-from --config-in --silent --quiet --verbose --debug --trace --log-level --no-color --no-input --yes --help" + 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__claim__subcmd__check) opts="-J -q -v -y -h --takeover --bypass-sequence --adopt --adopt-from --issue --json --strictness --fail-on-warning --config-from --config-in --silent --quiet --verbose --debug --trace --log-level --no-color --no-input --yes --help" if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then @@ -1559,7 +1700,7 @@ _batten() { return 0 ;; batten__subcmd__claim__subcmd__help) - opts="check help" + opts="check bot carry help" if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 @@ -1572,6 +1713,34 @@ _batten() { COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 ;; + batten__subcmd__claim__subcmd__help__subcmd__bot) + 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__carry) + 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__check) opts="" if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then @@ -2847,7 +3016,7 @@ _batten() { return 0 ;; batten__subcmd__help__subcmd__claim) - opts="check" + opts="check bot carry" if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 @@ -2860,6 +3029,34 @@ _batten() { COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 ;; + batten__subcmd__help__subcmd__claim__subcmd__bot) + 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__carry) + 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__check) opts="" if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then @@ -3505,7 +3702,7 @@ _batten() { return 0 ;; batten__subcmd__help__subcmd__pr) - opts="watch" + opts="watch derive file link ensure closes" if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 @@ -3518,6 +3715,76 @@ _batten() { COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 ;; + batten__subcmd__help__subcmd__pr__subcmd__closes) + 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__pr__subcmd__derive) + 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__pr__subcmd__ensure) + 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__pr__subcmd__file) + 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__pr__subcmd__link) + 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__pr__subcmd__watch) opts="" if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then @@ -3645,7 +3912,7 @@ _batten() { return 0 ;; batten__subcmd__help__subcmd__record) - opts="tool forge" + opts="tool forge plan closes" if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 @@ -3658,6 +3925,20 @@ _batten() { COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 ;; + batten__subcmd__help__subcmd__record__subcmd__closes) + 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 @@ -3672,6 +3953,20 @@ _batten() { COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 ;; + batten__subcmd__help__subcmd__record__subcmd__plan) + 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 @@ -4975,7 +5270,7 @@ _batten() { return 0 ;; batten__subcmd__pr) - 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 watch 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 watch derive file link ensure closes help" if [[ ${cur} == -* || ${COMP_CWORD} -eq 2 ]] ; then COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 @@ -5004,13 +5299,29 @@ _batten() { COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 ;; - batten__subcmd__pr__subcmd__help) - opts="watch help" + batten__subcmd__pr__subcmd__closes) + 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=() ;; @@ -5018,15 +5329,189 @@ _batten() { COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 ;; - batten__subcmd__pr__subcmd__help__subcmd__help) - opts="" - if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then + batten__subcmd__pr__subcmd__derive) + 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 - *) - COMPREPLY=() + --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__pr__subcmd__ensure) + 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__pr__subcmd__file) + 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__pr__subcmd__help) + opts="watch derive file link ensure closes help" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + batten__subcmd__pr__subcmd__help__subcmd__closes) + 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__pr__subcmd__help__subcmd__derive) + 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__pr__subcmd__help__subcmd__ensure) + 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__pr__subcmd__help__subcmd__file) + 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__pr__subcmd__help__subcmd__help) + 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__pr__subcmd__help__subcmd__link) + 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}") ) @@ -5046,6 +5531,36 @@ _batten() { COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 ;; + batten__subcmd__pr__subcmd__link) + 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__pr__subcmd__watch) opts="-q -v -y -h --sha --repo --interval --progress --progress-id --required --absent-ok --answered --fanin --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 @@ -5515,7 +6030,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 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 plan closes help" if [[ ${cur} == -* || ${COMP_CWORD} -eq 2 ]] ; then COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 @@ -5544,6 +6059,36 @@ _batten() { COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 ;; + batten__subcmd__record__subcmd__closes) + 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 @@ -5575,7 +6120,7 @@ _batten() { return 0 ;; batten__subcmd__record__subcmd__help) - opts="tool forge help" + opts="tool forge plan closes help" if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 @@ -5588,6 +6133,20 @@ _batten() { COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 ;; + batten__subcmd__record__subcmd__help__subcmd__closes) + 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 @@ -5616,6 +6175,20 @@ _batten() { COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 ;; + batten__subcmd__record__subcmd__help__subcmd__plan) + 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 @@ -5630,6 +6203,36 @@ _batten() { 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 + 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 07711e2a5..3a3c5e505 100644 --- a/completions/batten.fish +++ b/completions/batten.fish @@ -1227,29 +1227,34 @@ complete -c batten -n "__fish_batten_using_subcommand checks; and __fish_seen_su complete -c batten -n "__fish_batten_using_subcommand checks; and __fish_seen_subcommand_from green" -s h -l help -d 'Print help (see more with \'--help\')' complete -c batten -n "__fish_batten_using_subcommand checks; and __fish_seen_subcommand_from help" -f -a "green" -d 'Refuse a head whose required checks are red, still running, or not yet registered' complete -c batten -n "__fish_batten_using_subcommand checks; 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 pr; and not __fish_seen_subcommand_from watch 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 pr; and not __fish_seen_subcommand_from watch derive file link ensure 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 pr; and not __fish_seen_subcommand_from watch 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 pr; and not __fish_seen_subcommand_from watch 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 pr; and not __fish_seen_subcommand_from watch 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 pr; and not __fish_seen_subcommand_from watch derive file link ensure 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 pr; and not __fish_seen_subcommand_from watch derive file link ensure 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 pr; and not __fish_seen_subcommand_from watch derive file link ensure 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 pr; and not __fish_seen_subcommand_from watch 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 pr; and not __fish_seen_subcommand_from watch help" -l silent -d 'Say nothing but a verdict or a usage error' -complete -c batten -n "__fish_batten_using_subcommand pr; and not __fish_seen_subcommand_from watch help" -s q -l quiet -d 'Suppress ordinary progress (repeatable: -qq is silent)' -complete -c batten -n "__fish_batten_using_subcommand pr; and not __fish_seen_subcommand_from watch help" -s v -l verbose -d 'Explain what is being checked (repeatable: -vv is debug)' -complete -c batten -n "__fish_batten_using_subcommand pr; and not __fish_seen_subcommand_from watch help" -l debug -d 'Add resolution detail' -complete -c batten -n "__fish_batten_using_subcommand pr; and not __fish_seen_subcommand_from watch help" -l trace -d 'Add everything' -complete -c batten -n "__fish_batten_using_subcommand pr; and not __fish_seen_subcommand_from watch help" -l no-color -d 'Never colour stderr, whatever it is attached to' -complete -c batten -n "__fish_batten_using_subcommand pr; and not __fish_seen_subcommand_from watch help" -l no-input -d 'Never prompt; treat the run as unattended' -complete -c batten -n "__fish_batten_using_subcommand pr; and not __fish_seen_subcommand_from watch help" -s y -l yes -d 'Confirm a destructive operation that would otherwise refuse' -complete -c batten -n "__fish_batten_using_subcommand pr; and not __fish_seen_subcommand_from watch help" -s h -l help -d 'Print help (see more with \'--help\')' -complete -c batten -n "__fish_batten_using_subcommand pr; and not __fish_seen_subcommand_from watch help" -f -a "watch" -d 'Poll a head\'s check runs until the required set answers, then report the verdict' -complete -c batten -n "__fish_batten_using_subcommand pr; and not __fish_seen_subcommand_from watch help" -f -a "help" -d 'Print this message or the help of the given subcommand(s)' +complete -c batten -n "__fish_batten_using_subcommand pr; and not __fish_seen_subcommand_from watch derive file link ensure 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 pr; and not __fish_seen_subcommand_from watch derive file link ensure closes help" -l silent -d 'Say nothing but a verdict or a usage error' +complete -c batten -n "__fish_batten_using_subcommand pr; and not __fish_seen_subcommand_from watch derive file link ensure closes help" -s q -l quiet -d 'Suppress ordinary progress (repeatable: -qq is silent)' +complete -c batten -n "__fish_batten_using_subcommand pr; and not __fish_seen_subcommand_from watch derive file link ensure closes help" -s v -l verbose -d 'Explain what is being checked (repeatable: -vv is debug)' +complete -c batten -n "__fish_batten_using_subcommand pr; and not __fish_seen_subcommand_from watch derive file link ensure closes help" -l debug -d 'Add resolution detail' +complete -c batten -n "__fish_batten_using_subcommand pr; and not __fish_seen_subcommand_from watch derive file link ensure closes help" -l trace -d 'Add everything' +complete -c batten -n "__fish_batten_using_subcommand pr; and not __fish_seen_subcommand_from watch derive file link ensure closes help" -l no-color -d 'Never colour stderr, whatever it is attached to' +complete -c batten -n "__fish_batten_using_subcommand pr; and not __fish_seen_subcommand_from watch derive file link ensure closes help" -l no-input -d 'Never prompt; treat the run as unattended' +complete -c batten -n "__fish_batten_using_subcommand pr; and not __fish_seen_subcommand_from watch derive file link ensure closes help" -s y -l yes -d 'Confirm a destructive operation that would otherwise refuse' +complete -c batten -n "__fish_batten_using_subcommand pr; and not __fish_seen_subcommand_from watch derive file link ensure closes help" -s h -l help -d 'Print help (see more with \'--help\')' +complete -c batten -n "__fish_batten_using_subcommand pr; and not __fish_seen_subcommand_from watch derive file link ensure closes help" -f -a "watch" -d 'Poll a head\'s check runs until the required set answers, then report the verdict' +complete -c batten -n "__fish_batten_using_subcommand pr; and not __fish_seen_subcommand_from watch derive file link ensure closes help" -f -a "derive" -d 'The tracker row a bot\'s pull request implies, as a payload the refinement gate reads' +complete -c batten -n "__fish_batten_using_subcommand pr; and not __fish_seen_subcommand_from watch derive file link ensure closes help" -f -a "file" -d 'Open the mirror issue a bot\'s pull request implies, and report its number' +complete -c batten -n "__fish_batten_using_subcommand pr; and not __fish_seen_subcommand_from watch derive file link ensure closes help" -f -a "link" -d 'Write the closing key into a bot pull request\'s body, so its merge moves the row' +complete -c batten -n "__fish_batten_using_subcommand pr; and not __fish_seen_subcommand_from watch derive file link ensure closes help" -f -a "ensure" -d 'File the row and link it, doing whatever this tick can and saying what it did' +complete -c batten -n "__fish_batten_using_subcommand pr; and not __fish_seen_subcommand_from watch derive file link ensure closes help" -f -a "closes" -d 'Whether a pull request\'s body still closes a tracker key, asked at the last moment' +complete -c batten -n "__fish_batten_using_subcommand pr; and not __fish_seen_subcommand_from watch derive file link ensure 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 pr; and __fish_seen_subcommand_from watch" -l sha -d 'The commit whose check runs to read' -r complete -c batten -n "__fish_batten_using_subcommand pr; and __fish_seen_subcommand_from watch" -l repo -d 'The repository to read, in the forge client\'s own spelling' -r complete -c batten -n "__fish_batten_using_subcommand pr; and __fish_seen_subcommand_from watch" -l interval -d 'Seconds between requests; a server-requested floor raises it and nothing lowers it' -r @@ -1280,31 +1285,143 @@ complete -c batten -n "__fish_batten_using_subcommand pr; and __fish_seen_subcom complete -c batten -n "__fish_batten_using_subcommand pr; and __fish_seen_subcommand_from watch" -l no-input -d 'Never prompt; treat the run as unattended' complete -c batten -n "__fish_batten_using_subcommand pr; and __fish_seen_subcommand_from watch" -s y -l yes -d 'Confirm a destructive operation that would otherwise refuse' complete -c batten -n "__fish_batten_using_subcommand pr; and __fish_seen_subcommand_from watch" -s h -l help -d 'Print help (see more with \'--help\')' +complete -c batten -n "__fish_batten_using_subcommand pr; and __fish_seen_subcommand_from derive" -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 pr; and __fish_seen_subcommand_from derive" -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 pr; and __fish_seen_subcommand_from derive" -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 pr; and __fish_seen_subcommand_from derive" -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 pr; and __fish_seen_subcommand_from derive" -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 pr; and __fish_seen_subcommand_from derive" -l silent -d 'Say nothing but a verdict or a usage error' +complete -c batten -n "__fish_batten_using_subcommand pr; and __fish_seen_subcommand_from derive" -s q -l quiet -d 'Suppress ordinary progress (repeatable: -qq is silent)' +complete -c batten -n "__fish_batten_using_subcommand pr; and __fish_seen_subcommand_from derive" -s v -l verbose -d 'Explain what is being checked (repeatable: -vv is debug)' +complete -c batten -n "__fish_batten_using_subcommand pr; and __fish_seen_subcommand_from derive" -l debug -d 'Add resolution detail' +complete -c batten -n "__fish_batten_using_subcommand pr; and __fish_seen_subcommand_from derive" -l trace -d 'Add everything' +complete -c batten -n "__fish_batten_using_subcommand pr; and __fish_seen_subcommand_from derive" -l no-color -d 'Never colour stderr, whatever it is attached to' +complete -c batten -n "__fish_batten_using_subcommand pr; and __fish_seen_subcommand_from derive" -l no-input -d 'Never prompt; treat the run as unattended' +complete -c batten -n "__fish_batten_using_subcommand pr; and __fish_seen_subcommand_from derive" -s y -l yes -d 'Confirm a destructive operation that would otherwise refuse' +complete -c batten -n "__fish_batten_using_subcommand pr; and __fish_seen_subcommand_from derive" -s h -l help -d 'Print help (see more with \'--help\')' +complete -c batten -n "__fish_batten_using_subcommand pr; and __fish_seen_subcommand_from file" -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 pr; and __fish_seen_subcommand_from file" -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 pr; and __fish_seen_subcommand_from file" -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 pr; and __fish_seen_subcommand_from file" -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 pr; and __fish_seen_subcommand_from file" -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 pr; and __fish_seen_subcommand_from file" -l silent -d 'Say nothing but a verdict or a usage error' +complete -c batten -n "__fish_batten_using_subcommand pr; and __fish_seen_subcommand_from file" -s q -l quiet -d 'Suppress ordinary progress (repeatable: -qq is silent)' +complete -c batten -n "__fish_batten_using_subcommand pr; and __fish_seen_subcommand_from file" -s v -l verbose -d 'Explain what is being checked (repeatable: -vv is debug)' +complete -c batten -n "__fish_batten_using_subcommand pr; and __fish_seen_subcommand_from file" -l debug -d 'Add resolution detail' +complete -c batten -n "__fish_batten_using_subcommand pr; and __fish_seen_subcommand_from file" -l trace -d 'Add everything' +complete -c batten -n "__fish_batten_using_subcommand pr; and __fish_seen_subcommand_from file" -l no-color -d 'Never colour stderr, whatever it is attached to' +complete -c batten -n "__fish_batten_using_subcommand pr; and __fish_seen_subcommand_from file" -l no-input -d 'Never prompt; treat the run as unattended' +complete -c batten -n "__fish_batten_using_subcommand pr; and __fish_seen_subcommand_from file" -s y -l yes -d 'Confirm a destructive operation that would otherwise refuse' +complete -c batten -n "__fish_batten_using_subcommand pr; and __fish_seen_subcommand_from file" -s h -l help -d 'Print help (see more with \'--help\')' +complete -c batten -n "__fish_batten_using_subcommand pr; and __fish_seen_subcommand_from link" -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 pr; and __fish_seen_subcommand_from link" -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 pr; and __fish_seen_subcommand_from link" -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 pr; and __fish_seen_subcommand_from link" -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 pr; and __fish_seen_subcommand_from link" -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 pr; and __fish_seen_subcommand_from link" -l silent -d 'Say nothing but a verdict or a usage error' +complete -c batten -n "__fish_batten_using_subcommand pr; and __fish_seen_subcommand_from link" -s q -l quiet -d 'Suppress ordinary progress (repeatable: -qq is silent)' +complete -c batten -n "__fish_batten_using_subcommand pr; and __fish_seen_subcommand_from link" -s v -l verbose -d 'Explain what is being checked (repeatable: -vv is debug)' +complete -c batten -n "__fish_batten_using_subcommand pr; and __fish_seen_subcommand_from link" -l debug -d 'Add resolution detail' +complete -c batten -n "__fish_batten_using_subcommand pr; and __fish_seen_subcommand_from link" -l trace -d 'Add everything' +complete -c batten -n "__fish_batten_using_subcommand pr; and __fish_seen_subcommand_from link" -l no-color -d 'Never colour stderr, whatever it is attached to' +complete -c batten -n "__fish_batten_using_subcommand pr; and __fish_seen_subcommand_from link" -l no-input -d 'Never prompt; treat the run as unattended' +complete -c batten -n "__fish_batten_using_subcommand pr; and __fish_seen_subcommand_from link" -s y -l yes -d 'Confirm a destructive operation that would otherwise refuse' +complete -c batten -n "__fish_batten_using_subcommand pr; and __fish_seen_subcommand_from link" -s h -l help -d 'Print help (see more with \'--help\')' +complete -c batten -n "__fish_batten_using_subcommand pr; and __fish_seen_subcommand_from ensure" -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 pr; and __fish_seen_subcommand_from ensure" -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 pr; and __fish_seen_subcommand_from ensure" -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 pr; and __fish_seen_subcommand_from ensure" -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 pr; and __fish_seen_subcommand_from ensure" -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 pr; and __fish_seen_subcommand_from ensure" -l silent -d 'Say nothing but a verdict or a usage error' +complete -c batten -n "__fish_batten_using_subcommand pr; and __fish_seen_subcommand_from ensure" -s q -l quiet -d 'Suppress ordinary progress (repeatable: -qq is silent)' +complete -c batten -n "__fish_batten_using_subcommand pr; and __fish_seen_subcommand_from ensure" -s v -l verbose -d 'Explain what is being checked (repeatable: -vv is debug)' +complete -c batten -n "__fish_batten_using_subcommand pr; and __fish_seen_subcommand_from ensure" -l debug -d 'Add resolution detail' +complete -c batten -n "__fish_batten_using_subcommand pr; and __fish_seen_subcommand_from ensure" -l trace -d 'Add everything' +complete -c batten -n "__fish_batten_using_subcommand pr; and __fish_seen_subcommand_from ensure" -l no-color -d 'Never colour stderr, whatever it is attached to' +complete -c batten -n "__fish_batten_using_subcommand pr; and __fish_seen_subcommand_from ensure" -l no-input -d 'Never prompt; treat the run as unattended' +complete -c batten -n "__fish_batten_using_subcommand pr; and __fish_seen_subcommand_from ensure" -s y -l yes -d 'Confirm a destructive operation that would otherwise refuse' +complete -c batten -n "__fish_batten_using_subcommand pr; and __fish_seen_subcommand_from ensure" -s h -l help -d 'Print help (see more with \'--help\')' +complete -c batten -n "__fish_batten_using_subcommand pr; and __fish_seen_subcommand_from closes" -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 pr; and __fish_seen_subcommand_from closes" -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 pr; and __fish_seen_subcommand_from closes" -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 pr; and __fish_seen_subcommand_from closes" -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 pr; and __fish_seen_subcommand_from closes" -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 pr; and __fish_seen_subcommand_from closes" -l silent -d 'Say nothing but a verdict or a usage error' +complete -c batten -n "__fish_batten_using_subcommand pr; and __fish_seen_subcommand_from closes" -s q -l quiet -d 'Suppress ordinary progress (repeatable: -qq is silent)' +complete -c batten -n "__fish_batten_using_subcommand pr; and __fish_seen_subcommand_from closes" -s v -l verbose -d 'Explain what is being checked (repeatable: -vv is debug)' +complete -c batten -n "__fish_batten_using_subcommand pr; and __fish_seen_subcommand_from closes" -l debug -d 'Add resolution detail' +complete -c batten -n "__fish_batten_using_subcommand pr; and __fish_seen_subcommand_from closes" -l trace -d 'Add everything' +complete -c batten -n "__fish_batten_using_subcommand pr; and __fish_seen_subcommand_from closes" -l no-color -d 'Never colour stderr, whatever it is attached to' +complete -c batten -n "__fish_batten_using_subcommand pr; and __fish_seen_subcommand_from closes" -l no-input -d 'Never prompt; treat the run as unattended' +complete -c batten -n "__fish_batten_using_subcommand pr; and __fish_seen_subcommand_from closes" -s y -l yes -d 'Confirm a destructive operation that would otherwise refuse' +complete -c batten -n "__fish_batten_using_subcommand pr; 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 pr; and __fish_seen_subcommand_from help" -f -a "watch" -d 'Poll a head\'s check runs until the required set answers, then report the verdict' +complete -c batten -n "__fish_batten_using_subcommand pr; and __fish_seen_subcommand_from help" -f -a "derive" -d 'The tracker row a bot\'s pull request implies, as a payload the refinement gate reads' +complete -c batten -n "__fish_batten_using_subcommand pr; and __fish_seen_subcommand_from help" -f -a "file" -d 'Open the mirror issue a bot\'s pull request implies, and report its number' +complete -c batten -n "__fish_batten_using_subcommand pr; and __fish_seen_subcommand_from help" -f -a "link" -d 'Write the closing key into a bot pull request\'s body, so its merge moves the row' +complete -c batten -n "__fish_batten_using_subcommand pr; and __fish_seen_subcommand_from help" -f -a "ensure" -d 'File the row and link it, doing whatever this tick can and saying what it did' +complete -c batten -n "__fish_batten_using_subcommand pr; and __fish_seen_subcommand_from help" -f -a "closes" -d 'Whether a pull request\'s body still closes a tracker key, asked at the last moment' complete -c batten -n "__fish_batten_using_subcommand pr; 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 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 check bot 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 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 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 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 check bot 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 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 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 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 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 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 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 help" -l debug -d 'Add resolution detail' -complete -c batten -n "__fish_batten_using_subcommand claim; and not __fish_seen_subcommand_from check help" -l trace -d 'Add everything' -complete -c batten -n "__fish_batten_using_subcommand claim; and not __fish_seen_subcommand_from check 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 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 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 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 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 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 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 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 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 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 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 carry help" -l trace -d 'Add everything' +complete -c batten -n "__fish_batten_using_subcommand claim; and not __fish_seen_subcommand_from check bot 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 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 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 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 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 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 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 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 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 'Resolve the payload from the capture store by this issue key instead of reading stdin' -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' @@ -1332,7 +1449,52 @@ 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 check" -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 check" -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 check" -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 bot" -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 bot" -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 bot" -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 bot" -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 bot" -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 bot" -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 bot" -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 bot" -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 bot" -l debug -d 'Add resolution detail' +complete -c batten -n "__fish_batten_using_subcommand claim; and __fish_seen_subcommand_from bot" -l trace -d 'Add everything' +complete -c batten -n "__fish_batten_using_subcommand claim; and __fish_seen_subcommand_from bot" -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 bot" -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 bot" -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 bot" -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 carry" -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 carry" -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 carry" -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 carry" -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 carry" -s J -l json -d 'Emit byte-stable JSON instead of pointer lines' +complete -c batten -n "__fish_batten_using_subcommand claim; and __fish_seen_subcommand_from carry" -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 carry" -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 carry" -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 carry" -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 carry" -l debug -d 'Add resolution detail' +complete -c batten -n "__fish_batten_using_subcommand claim; and __fish_seen_subcommand_from carry" -l trace -d 'Add everything' +complete -c batten -n "__fish_batten_using_subcommand claim; and __fish_seen_subcommand_from carry" -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 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 "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 "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 __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 semver; and not __fish_seen_subcommand_from check 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' @@ -2078,30 +2240,32 @@ 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 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 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 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 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 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 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' 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 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 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 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 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 help" -l debug -d 'Add resolution detail' -complete -c batten -n "__fish_batten_using_subcommand record; and not __fish_seen_subcommand_from tool forge help" -l trace -d 'Add everything' -complete -c batten -n "__fish_batten_using_subcommand record; and not __fish_seen_subcommand_from tool forge 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 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 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 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 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 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 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 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 __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'" @@ -2144,8 +2308,52 @@ 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 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'" +complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from plan" -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 plan" -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 plan" -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 plan" -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 plan" -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 plan" -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 plan" -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 plan" -l debug -d 'Add resolution detail' +complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from plan" -l trace -d 'Add everything' +complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from plan" -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 plan" -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 plan" -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 plan" -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 closes" -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 closes" -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 closes" -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 closes" -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 closes" -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 closes" -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 closes" -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 closes" -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 closes" -l debug -d 'Add resolution detail' +complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from closes" -l trace -d 'Add everything' +complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from closes" -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 closes" -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 closes" -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 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 "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)' complete -c batten -n "__fish_batten_using_subcommand wiring; and not __fish_seen_subcommand_from reclaim 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' @@ -2258,7 +2466,14 @@ 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 ready" -f -a "lint" -d 'Refuse an issue whose Ready block fails a checkable clause of the Definition of Ready' complete -c batten -n "__fish_batten_using_subcommand help; and __fish_seen_subcommand_from checks" -f -a "green" -d 'Refuse a head whose required checks are red, still running, or not yet registered' complete -c batten -n "__fish_batten_using_subcommand help; and __fish_seen_subcommand_from pr" -f -a "watch" -d 'Poll a head\'s check runs until the required set answers, then report the verdict' +complete -c batten -n "__fish_batten_using_subcommand help; and __fish_seen_subcommand_from pr" -f -a "derive" -d 'The tracker row a bot\'s pull request implies, as a payload the refinement gate reads' +complete -c batten -n "__fish_batten_using_subcommand help; and __fish_seen_subcommand_from pr" -f -a "file" -d 'Open the mirror issue a bot\'s pull request implies, and report its number' +complete -c batten -n "__fish_batten_using_subcommand help; and __fish_seen_subcommand_from pr" -f -a "link" -d 'Write the closing key into a bot pull request\'s body, so its merge moves the row' +complete -c batten -n "__fish_batten_using_subcommand help; and __fish_seen_subcommand_from pr" -f -a "ensure" -d 'File the row and link it, doing whatever this tick can and saying what it did' +complete -c batten -n "__fish_batten_using_subcommand help; and __fish_seen_subcommand_from pr" -f -a "closes" -d 'Whether a pull request\'s body still closes a tracker key, asked at the last moment' 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 "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 help; and __fish_seen_subcommand_from semver" -f -a "check" -d 'Refuse an API break this branch\'s commits do not declare' complete -c batten -n "__fish_batten_using_subcommand help; and __fish_seen_subcommand_from attribution" -f -a "check" -d 'Refuse vendor authorship, branding or session links in commit metadata' complete -c batten -n "__fish_batten_using_subcommand help; and __fish_seen_subcommand_from attribution" -f -a "identity" -d 'Set this clone\'s repo-local git identity when it is unset or denied' @@ -2280,4 +2495,6 @@ 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 "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 wiring" -f -a "reclaim" -d 'Remove non-batten hook registrations from this host\'s merged surfaces' diff --git a/completions/batten.zsh b/completions/batten.zsh index 83e71a0f6..f79d7e4a3 100644 --- a/completions/batten.zsh +++ b/completions/batten.zsh @@ -2125,6 +2125,157 @@ trace\:"Add everything"))' \ '--help[Print help (see more with '\''--help'\'')]' \ && ret=0 ;; +(derive) +_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'\'')]' \ +':pr -- The pull request number this verb is about:_default' \ +&& ret=0 +;; +(file) +_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'\'')]' \ +':pr -- The pull request number this verb is about:_default' \ +&& ret=0 +;; +(link) +_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'\'')]' \ +':pr -- The pull request number this verb is about:_default' \ +':key -- The tracker key the pull request should close:_default' \ +&& ret=0 +;; +(ensure) +_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'\'')]' \ +':pr -- The pull request number this verb is about:_default' \ +&& ret=0 +;; +(closes) +_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'\'')]' \ +':pr -- The pull request number this verb is about:_default' \ +&& ret=0 +;; (help) _arguments "${_arguments_options[@]}" : \ ":: :_batten__subcmd__pr__subcmd__help_commands" \ @@ -2141,6 +2292,26 @@ _arguments "${_arguments_options[@]}" : \ _arguments "${_arguments_options[@]}" : \ && ret=0 ;; +(derive) +_arguments "${_arguments_options[@]}" : \ +&& ret=0 +;; +(file) +_arguments "${_arguments_options[@]}" : \ +&& ret=0 +;; +(link) +_arguments "${_arguments_options[@]}" : \ +&& ret=0 +;; +(ensure) +_arguments "${_arguments_options[@]}" : \ +&& ret=0 +;; +(closes) +_arguments "${_arguments_options[@]}" : \ +&& ret=0 +;; (help) _arguments "${_arguments_options[@]}" : \ && ret=0 @@ -2226,6 +2397,66 @@ trace\:"Add everything"))' \ '--help[Print help (see more with '\''--help'\'')]' \ && ret=0 ;; +(bot) +_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'\'')]' \ +&& ret=0 +;; +(carry) +_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"))' \ +'-J[Emit byte-stable JSON instead of pointer lines]' \ +'--json[Emit byte-stable JSON instead of pointer lines]' \ +'--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 +;; (help) _arguments "${_arguments_options[@]}" : \ ":: :_batten__subcmd__claim__subcmd__help_commands" \ @@ -2242,6 +2473,14 @@ _arguments "${_arguments_options[@]}" : \ _arguments "${_arguments_options[@]}" : \ && ret=0 ;; +(bot) +_arguments "${_arguments_options[@]}" : \ +&& ret=0 +;; +(carry) +_arguments "${_arguments_options[@]}" : \ +&& ret=0 +;; (help) _arguments "${_arguments_options[@]}" : \ && ret=0 @@ -3688,6 +3927,64 @@ trace\:"Add everything"))' \ ':ref -- The ref or sha the verdict was taken against:_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" +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 +;; +(closes) +_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'\'')]' \ +&& ret=0 +;; (help) _arguments "${_arguments_options[@]}" : \ ":: :_batten__subcmd__record__subcmd__help_commands" \ @@ -3708,6 +4005,14 @@ _arguments "${_arguments_options[@]}" : \ _arguments "${_arguments_options[@]}" : \ && ret=0 ;; +(plan) +_arguments "${_arguments_options[@]}" : \ +&& ret=0 +;; +(closes) +_arguments "${_arguments_options[@]}" : \ +&& ret=0 +;; (help) _arguments "${_arguments_options[@]}" : \ && ret=0 @@ -4187,6 +4492,26 @@ _arguments "${_arguments_options[@]}" : \ (watch) _arguments "${_arguments_options[@]}" : \ && ret=0 +;; +(derive) +_arguments "${_arguments_options[@]}" : \ +&& ret=0 +;; +(file) +_arguments "${_arguments_options[@]}" : \ +&& ret=0 +;; +(link) +_arguments "${_arguments_options[@]}" : \ +&& ret=0 +;; +(ensure) +_arguments "${_arguments_options[@]}" : \ +&& ret=0 +;; +(closes) +_arguments "${_arguments_options[@]}" : \ +&& ret=0 ;; esac ;; @@ -4207,6 +4532,14 @@ _arguments "${_arguments_options[@]}" : \ (check) _arguments "${_arguments_options[@]}" : \ && ret=0 +;; +(bot) +_arguments "${_arguments_options[@]}" : \ +&& ret=0 +;; +(carry) +_arguments "${_arguments_options[@]}" : \ +&& ret=0 ;; esac ;; @@ -4471,6 +4804,14 @@ _arguments "${_arguments_options[@]}" : \ (forge) _arguments "${_arguments_options[@]}" : \ && ret=0 +;; +(plan) +_arguments "${_arguments_options[@]}" : \ +&& ret=0 +;; +(closes) +_arguments "${_arguments_options[@]}" : \ +&& ret=0 ;; esac ;; @@ -4705,10 +5046,22 @@ _batten__subcmd__checks__subcmd__help__subcmd__help_commands() { _batten__subcmd__claim_commands() { local commands; commands=( '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' \ +'carry:Attest that this branch only carries licence rows forward, and mint the receipt when it does' \ 'help:Print this message or the help of the given subcommand(s)' \ ) _describe -t commands 'batten claim commands' commands "$@" } +(( $+functions[_batten__subcmd__claim__subcmd__bot_commands] )) || +_batten__subcmd__claim__subcmd__bot_commands() { + local commands; commands=() + _describe -t commands 'batten claim bot commands' commands "$@" +} +(( $+functions[_batten__subcmd__claim__subcmd__carry_commands] )) || +_batten__subcmd__claim__subcmd__carry_commands() { + local commands; commands=() + _describe -t commands 'batten claim carry commands' commands "$@" +} (( $+functions[_batten__subcmd__claim__subcmd__check_commands] )) || _batten__subcmd__claim__subcmd__check_commands() { local commands; commands=() @@ -4718,10 +5071,22 @@ _batten__subcmd__claim__subcmd__check_commands() { _batten__subcmd__claim__subcmd__help_commands() { local commands; commands=( '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' \ +'carry:Attest that this branch only carries licence rows forward, and mint the receipt when it does' \ 'help:Print this message or the help of the given subcommand(s)' \ ) _describe -t commands 'batten claim help commands' commands "$@" } +(( $+functions[_batten__subcmd__claim__subcmd__help__subcmd__bot_commands] )) || +_batten__subcmd__claim__subcmd__help__subcmd__bot_commands() { + local commands; commands=() + _describe -t commands 'batten claim help bot commands' commands "$@" +} +(( $+functions[_batten__subcmd__claim__subcmd__help__subcmd__carry_commands] )) || +_batten__subcmd__claim__subcmd__help__subcmd__carry_commands() { + local commands; commands=() + _describe -t commands 'batten claim help carry commands' commands "$@" +} (( $+functions[_batten__subcmd__claim__subcmd__help__subcmd__check_commands] )) || _batten__subcmd__claim__subcmd__help__subcmd__check_commands() { local commands; commands=() @@ -5139,9 +5504,21 @@ _batten__subcmd__help__subcmd__checks__subcmd__green_commands() { _batten__subcmd__help__subcmd__claim_commands() { local commands; commands=( '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' \ +'carry:Attest that this branch only carries licence rows forward, and mint the receipt when it does' \ ) _describe -t commands 'batten help claim commands' commands "$@" } +(( $+functions[_batten__subcmd__help__subcmd__claim__subcmd__bot_commands] )) || +_batten__subcmd__help__subcmd__claim__subcmd__bot_commands() { + local commands; commands=() + _describe -t commands 'batten help claim bot commands' commands "$@" +} +(( $+functions[_batten__subcmd__help__subcmd__claim__subcmd__carry_commands] )) || +_batten__subcmd__help__subcmd__claim__subcmd__carry_commands() { + local commands; commands=() + _describe -t commands 'batten help claim carry commands' commands "$@" +} (( $+functions[_batten__subcmd__help__subcmd__claim__subcmd__check_commands] )) || _batten__subcmd__help__subcmd__claim__subcmd__check_commands() { local commands; commands=() @@ -5416,9 +5793,39 @@ _batten__subcmd__help__subcmd__policy__subcmd__tools_commands() { _batten__subcmd__help__subcmd__pr_commands() { local commands; commands=( 'watch:Poll a head'\''s check runs until the required set answers, then report the verdict' \ +'derive:The tracker row a bot'\''s pull request implies, as a payload the refinement gate reads' \ +'file:Open the mirror issue a bot'\''s pull request implies, and report its number' \ +'link:Write the closing key into a bot pull request'\''s body, so its merge moves the row' \ +'ensure:File the row and link it, doing whatever this tick can and saying what it did' \ +'closes:Whether a pull request'\''s body still closes a tracker key, asked at the last moment' \ ) _describe -t commands 'batten help pr commands' commands "$@" } +(( $+functions[_batten__subcmd__help__subcmd__pr__subcmd__closes_commands] )) || +_batten__subcmd__help__subcmd__pr__subcmd__closes_commands() { + local commands; commands=() + _describe -t commands 'batten help pr closes commands' commands "$@" +} +(( $+functions[_batten__subcmd__help__subcmd__pr__subcmd__derive_commands] )) || +_batten__subcmd__help__subcmd__pr__subcmd__derive_commands() { + local commands; commands=() + _describe -t commands 'batten help pr derive commands' commands "$@" +} +(( $+functions[_batten__subcmd__help__subcmd__pr__subcmd__ensure_commands] )) || +_batten__subcmd__help__subcmd__pr__subcmd__ensure_commands() { + local commands; commands=() + _describe -t commands 'batten help pr ensure commands' commands "$@" +} +(( $+functions[_batten__subcmd__help__subcmd__pr__subcmd__file_commands] )) || +_batten__subcmd__help__subcmd__pr__subcmd__file_commands() { + local commands; commands=() + _describe -t commands 'batten help pr file commands' commands "$@" +} +(( $+functions[_batten__subcmd__help__subcmd__pr__subcmd__link_commands] )) || +_batten__subcmd__help__subcmd__pr__subcmd__link_commands() { + local commands; commands=() + _describe -t commands 'batten help pr link commands' commands "$@" +} (( $+functions[_batten__subcmd__help__subcmd__pr__subcmd__watch_commands] )) || _batten__subcmd__help__subcmd__pr__subcmd__watch_commands() { local commands; commands=() @@ -5477,14 +5884,26 @@ _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' \ +'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' \ ) _describe -t commands 'batten help record commands' commands "$@" } +(( $+functions[_batten__subcmd__help__subcmd__record__subcmd__closes_commands] )) || +_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__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__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__tool_commands] )) || _batten__subcmd__help__subcmd__record__subcmd__tool_commands() { local commands; commands=() @@ -5882,28 +6301,88 @@ _batten__subcmd__policy__subcmd__tools_commands() { _batten__subcmd__pr_commands() { local commands; commands=( 'watch:Poll a head'\''s check runs until the required set answers, then report the verdict' \ +'derive:The tracker row a bot'\''s pull request implies, as a payload the refinement gate reads' \ +'file:Open the mirror issue a bot'\''s pull request implies, and report its number' \ +'link:Write the closing key into a bot pull request'\''s body, so its merge moves the row' \ +'ensure:File the row and link it, doing whatever this tick can and saying what it did' \ +'closes:Whether a pull request'\''s body still closes a tracker key, asked at the last moment' \ 'help:Print this message or the help of the given subcommand(s)' \ ) _describe -t commands 'batten pr commands' commands "$@" } +(( $+functions[_batten__subcmd__pr__subcmd__closes_commands] )) || +_batten__subcmd__pr__subcmd__closes_commands() { + local commands; commands=() + _describe -t commands 'batten pr closes commands' commands "$@" +} +(( $+functions[_batten__subcmd__pr__subcmd__derive_commands] )) || +_batten__subcmd__pr__subcmd__derive_commands() { + local commands; commands=() + _describe -t commands 'batten pr derive commands' commands "$@" +} +(( $+functions[_batten__subcmd__pr__subcmd__ensure_commands] )) || +_batten__subcmd__pr__subcmd__ensure_commands() { + local commands; commands=() + _describe -t commands 'batten pr ensure commands' commands "$@" +} +(( $+functions[_batten__subcmd__pr__subcmd__file_commands] )) || +_batten__subcmd__pr__subcmd__file_commands() { + local commands; commands=() + _describe -t commands 'batten pr file commands' commands "$@" +} (( $+functions[_batten__subcmd__pr__subcmd__help_commands] )) || _batten__subcmd__pr__subcmd__help_commands() { local commands; commands=( 'watch:Poll a head'\''s check runs until the required set answers, then report the verdict' \ +'derive:The tracker row a bot'\''s pull request implies, as a payload the refinement gate reads' \ +'file:Open the mirror issue a bot'\''s pull request implies, and report its number' \ +'link:Write the closing key into a bot pull request'\''s body, so its merge moves the row' \ +'ensure:File the row and link it, doing whatever this tick can and saying what it did' \ +'closes:Whether a pull request'\''s body still closes a tracker key, asked at the last moment' \ 'help:Print this message or the help of the given subcommand(s)' \ ) _describe -t commands 'batten pr help commands' commands "$@" } +(( $+functions[_batten__subcmd__pr__subcmd__help__subcmd__closes_commands] )) || +_batten__subcmd__pr__subcmd__help__subcmd__closes_commands() { + local commands; commands=() + _describe -t commands 'batten pr help closes commands' commands "$@" +} +(( $+functions[_batten__subcmd__pr__subcmd__help__subcmd__derive_commands] )) || +_batten__subcmd__pr__subcmd__help__subcmd__derive_commands() { + local commands; commands=() + _describe -t commands 'batten pr help derive commands' commands "$@" +} +(( $+functions[_batten__subcmd__pr__subcmd__help__subcmd__ensure_commands] )) || +_batten__subcmd__pr__subcmd__help__subcmd__ensure_commands() { + local commands; commands=() + _describe -t commands 'batten pr help ensure commands' commands "$@" +} +(( $+functions[_batten__subcmd__pr__subcmd__help__subcmd__file_commands] )) || +_batten__subcmd__pr__subcmd__help__subcmd__file_commands() { + local commands; commands=() + _describe -t commands 'batten pr help file commands' commands "$@" +} (( $+functions[_batten__subcmd__pr__subcmd__help__subcmd__help_commands] )) || _batten__subcmd__pr__subcmd__help__subcmd__help_commands() { local commands; commands=() _describe -t commands 'batten pr help help commands' commands "$@" } +(( $+functions[_batten__subcmd__pr__subcmd__help__subcmd__link_commands] )) || +_batten__subcmd__pr__subcmd__help__subcmd__link_commands() { + local commands; commands=() + _describe -t commands 'batten pr help link commands' commands "$@" +} (( $+functions[_batten__subcmd__pr__subcmd__help__subcmd__watch_commands] )) || _batten__subcmd__pr__subcmd__help__subcmd__watch_commands() { local commands; commands=() _describe -t commands 'batten pr help watch commands' commands "$@" } +(( $+functions[_batten__subcmd__pr__subcmd__link_commands] )) || +_batten__subcmd__pr__subcmd__link_commands() { + local commands; commands=() + _describe -t commands 'batten pr link commands' commands "$@" +} (( $+functions[_batten__subcmd__pr__subcmd__watch_commands] )) || _batten__subcmd__pr__subcmd__watch_commands() { local commands; commands=() @@ -6031,10 +6510,17 @@ _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' \ +'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)' \ ) _describe -t commands 'batten record commands' commands "$@" } +(( $+functions[_batten__subcmd__record__subcmd__closes_commands] )) || +_batten__subcmd__record__subcmd__closes_commands() { + local commands; commands=() + _describe -t commands 'batten record closes commands' commands "$@" +} (( $+functions[_batten__subcmd__record__subcmd__forge_commands] )) || _batten__subcmd__record__subcmd__forge_commands() { local commands; commands=() @@ -6045,10 +6531,17 @@ _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' \ +'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)' \ ) _describe -t commands 'batten record help commands' commands "$@" } +(( $+functions[_batten__subcmd__record__subcmd__help__subcmd__closes_commands] )) || +_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__forge_commands] )) || _batten__subcmd__record__subcmd__help__subcmd__forge_commands() { local commands; commands=() @@ -6059,11 +6552,21 @@ _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__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__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__plan_commands] )) || +_batten__subcmd__record__subcmd__plan_commands() { + local commands; commands=() + _describe -t commands 'batten record plan commands' commands "$@" +} (( $+functions[_batten__subcmd__record__subcmd__tool_commands] )) || _batten__subcmd__record__subcmd__tool_commands() { local commands; commands=() diff --git a/crates/batten/src/bot.rs b/crates/batten/src/bot.rs new file mode 100644 index 000000000..f52d35772 --- /dev/null +++ b/crates/batten/src/bot.rs @@ -0,0 +1,678 @@ +//! Turn a bot's pull request into a refined tracker row (CLOUD-1295). +//! +//! `mise-tasks/bot-issue.sh`, ported. Two halves, kept apart inside one module +//! for CLOUD-346's reason: the PREDICATES below decide — is this PR one of the +//! lane's, which manifests it touched, what Conventional type its subject +//! declares, whether a body still closes a key — and every one of them is a pure +//! function testable with no network at all. The [`forge`] half underneath is the +//! only thing that talks to anybody, through the same client [`crate::pr_watch`] +//! reads check runs with. +//! +//! # Why the forge's own client rather than this crate's HTTP transport +//! +//! [`crate::fetch`] could send these requests, and sending them would put +//! credential resolution into `crates/batten` — where no config row declares a +//! forge credential and where the token would then have to be read from an +//! environment this crate does not otherwise consult. `gh` resolves it outside, +//! which is the standing CLOUD-1143 already gave the check-run read, and it is +//! also byte-for-byte the call the retired program made. +//! +//! # Why a bot row can be mechanical, which is the honest part +//! +//! A bump has no design question to refine: the source of truth is the manifest, +//! the predicate is "CI green on the bump", the effect is none, and the type +//! follows the one the bot's own config already decided. That is exactly why this +//! must NOT reuse the agent refinement path, where a human judgement is the thing +//! being attested — the two attest different things, and CLOUD-431 exists to keep +//! them apart. +//! +//! # Every consumer fact is config, and that is non-negotiable rule 1 +//! +//! Which repository, which bot logins, which manifests a lane owns, which markers +//! tie a row to its PR — all of it is the consumer's, and none of it is spelled +//! here. `[bot_lane]` in `batten.toml` carries the answers; this module carries +//! the matcher. A grep of `crates/batten` for a bot's name or a manifest path +//! returns nothing, which is the same standing `[attribution]` has. +//! +//! # Pointer-only per non-negotiable rule 4 +//! +//! Every refusal names a PR number, an issue key, a login or a path. Never a diff +//! body, never a version, never the PR body — a bot PR carries a release-notes +//! dump, and echoing it would put that in the log of every landing. + +use anyhow::Result; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use crate::error::UsageError; + +/// The `[bot_lane]` table: which proposals this repository will file a row for. +/// +/// Absent means the repository runs no bot lane, and the verbs say so rather than +/// filing against defaults — a lane assembled from engine literals would be a row +/// asserting a bump nobody configured, which is the CLOUD-198 class with a new +/// author. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct BotLane { + /// The forge repository, `owner/name`, that the lane's pull requests live in. + pub repo: String, + /// The logins whose pull requests earn a row. + /// + /// A list rather than a pattern: a login is a literal the forge assigns, and + /// a regex over it would admit a neighbour nobody meant to trust. + pub bots: Vec, + /// The manifests this lane owns, as globs. A PR touching none of them is + /// refused rather than given an invented row. + pub owned_manifests: Vec, + /// The hidden marker that ties a mirror issue to the pull request it was + /// filed for. + /// + /// A comment rather than a label or a title convention: it survives an edit, + /// it is invisible rendered, and it is what makes `ensure` idempotent across + /// the window where the row exists and the PR body does not yet name it. + pub marker_prefix: String, + /// What the tracker's own sync leaves on the issue once it has mirrored it. + /// The key is read from a comment carrying this, never from the issue body — + /// that body is this lane's own text, so a key named there would be one we + /// wrote rather than one the tracker assigned. + pub linkback_marker: String, + /// The tracker's key prefix, which a key is this followed by digits. + /// + /// The consumer's vocabulary, exactly as the Ready grammar's `[[pattern]]` + /// rows are: a tracker's key shape in `crates/batten` is non-negotiable rule + /// 1's violation. + pub key_prefix: String, + /// The branch prefix a bot receipt may be keyed to. A branch outside it is + /// refused onto the agent claim receipt, which attests something else. + pub branch_prefix: String, + /// The path of the file whose text is the derived row's body, with + /// `{{...}}` placeholders substituted. + /// + /// A tracked file rather than a string in the config: the body is a page of + /// consumer prose carrying a Ready block, and a page of markdown inside a + /// TOML value is unreviewable. It is also what keeps `ready-lint`'s grammar + /// and the text it judges in one place a human edits. + pub body_template: String, +} + +/// The placeholders [`BotLane::body_template`] may carry. +/// +/// A closed set, checked at substitution: a template naming one this engine does +/// not fill would render the literal `{{...}}` into a tracker row, and a row +/// carrying a template artifact reads as a lane that half-ran. +pub const PLACEHOLDERS: &[&str] = &["pr", "branch", "login", "manifests", "type"]; + +/// Whether `login` is one of the lane's bots. +/// +/// Exact, case-sensitively: a forge login is a literal, and `renovate` is not +/// `Renovate` to the API that assigned it. +#[must_use] +pub fn is_lane_bot(login: &str, bots: &[String]) -> bool { + bots.iter().any(|bot| bot == login) +} + +/// The subset of `files` this lane owns, in the order given. +/// +/// The glob is the same matcher every other path row in this engine is decided +/// by, so a lane cannot grow a second opinion about what a recursive pattern +/// means. Which globs a lane declares is the consumer's, in `[bot_lane]`, and +/// non-negotiable rule 1 is why not one of them is named here — not even as an +/// example, which is what `document_facts` caught in the first draft of this +/// comment. +/// +/// # Errors +/// +/// [`UsageError`] when a declared glob will not compile — a lane whose pattern +/// cannot be read must refuse rather than silently own nothing. +pub fn owned<'a>(files: &'a [String], globs: &[String]) -> Result> { + let mut builder = globset::GlobSetBuilder::new(); + for glob in globs { + builder.add(globset::Glob::new(glob).map_err(|err| { + UsageError::raise(format!( + "bot lane: owned_manifests glob {glob} will not compile: {err}" + )) + })?); + } + let set = builder + .build() + .map_err(|err| UsageError::raise(format!("bot lane: owned_manifests: {err}")))?; + Ok(files.iter().filter(|path| set.is_match(path)).collect()) +} + +/// The Conventional type a subject declares, or `None` where it declares none. +/// +/// READ rather than chosen: the bot's own config already decided it, and +/// re-deciding here would be a second authority for one fact. A subject with no +/// prefix is a lane defect, not something to paper over — the commit gate would +/// refuse it anyway, so the caller says so instead of inventing a type. +#[must_use] +pub fn conventional_type(subject: &str) -> Option<&str> { + let head = subject.split(':').next()?; + if head == subject { + // No colon at all, so nothing was split and there is no prefix. + return None; + } + let word = head + .split_once('(') + .map_or_else(|| head.trim_end_matches('!'), |(before, _)| before); + let word = word.trim_end_matches('!'); + (!word.is_empty() && word.chars().all(|ch| ch.is_ascii_lowercase())).then_some(word) +} + +/// The closing verbs a body may use to move a row, as the board reads them. +const CLOSING_VERBS: &[&str] = &[ + "close", "closes", "closed", "fix", "fixes", "fixed", "resolve", "resolves", "resolved", +]; + +/// The first tracker key `body` **closes**, or `None`. +/// +/// The predicate is the board gate's, verbatim, and deliberately not a narrower +/// one matching only what this lane writes: a body a human edited to say +/// "Fixes CLOUD-767" closes the row just as well, and a gate that refused it +/// would be wrong about the one thing it exists to decide. +/// +/// A key merely NAMED does not count, which is the whole failure being caught: a +/// bot regenerates its body on every rebase and the closing line goes with it, +/// leaving a body that still mentions the key and moves nothing. +#[must_use] +pub fn closing_key(body: &str, prefix: &str) -> Option { + let lowered = body.to_lowercase(); + let mut best: Option<(usize, String)> = None; + for (at, _) in lowered.match_indices(&prefix.to_lowercase()) { + let Some(key) = key_at(body, at, prefix) else { + continue; + }; + // The word before the key, skipping the separators a body may put between + // them: whitespace, a colon, and the `#` some forges want. + let before = lowered[..at].trim_end_matches(['#', ':', ' ', '\t', '\n', '\r']); + let verb = before + .rsplit(|ch: char| !(ch.is_ascii_alphabetic())) + .next() + .unwrap_or_default(); + // `DO-NOT-CLOSE CLOUD-388` ends in a closing verb and is the one marker + // that must not read as a close, so the character before the verb decides: + // a hyphen means the verb is part of a longer token. + let joined = before + .strip_suffix(verb) + .is_some_and(|rest| rest.ends_with('-')); + if !joined && CLOSING_VERBS.contains(&verb) && best.is_none() { + best = Some((at, key)); + } + } + best.map(|(_, key)| key) +} + +/// The first tracker key `body` NAMES, closing or not. +/// +/// What idempotence keys on: a body already carrying any key has had its row +/// filed, and filing a second one is the failure a per-tick call must not have. +#[must_use] +pub fn named_key(body: &str, prefix: &str) -> Option { + body.match_indices(prefix) + .find_map(|(at, _)| key_at(body, at, prefix)) +} + +/// The whole `` token starting at `at`, or `None` where the +/// prefix is not followed by at least one digit. +fn key_at(body: &str, at: usize, prefix: &str) -> Option { + let rest = body.get(at + prefix.len()..)?; + let digits: String = rest.chars().take_while(char::is_ascii_digit).collect(); + (!digits.is_empty()).then(|| format!("{}{digits}", &body[at..at + prefix.len()])) +} + +/// The derived row's body: the template with every placeholder substituted. +/// +/// # Errors +/// +/// [`UsageError`] when the template names a placeholder this engine does not +/// fill. Refusing is the safe direction — the alternative is a tracker row +/// carrying a literal `{{...}}`, which reads as a lane that half-ran and which +/// nobody would notice until a human opened the row. +pub fn render(template: &str, values: &[(&str, String)]) -> Result { + let mut body = template.to_owned(); + for (name, value) in values { + body = body.replace(&format!("{{{{{name}}}}}"), value); + } + if let Some(at) = body.find("{{") { + let rest = &body[at..]; + let name: String = rest + .trim_start_matches('{') + .chars() + .take_while(|ch| *ch != '}') + .collect(); + return Err(UsageError::raise(format!( + "bot lane: the body template names placeholder {{{{{name}}}}}, which nothing fills — \ + the declared set is {}", + PLACEHOLDERS.join(", ") + ))); + } + Ok(body) +} + +/// The file name the bot receipt is keyed to. +/// +/// Keyed by BRANCH, like the agent claim and for the same reason: it attests a +/// decision about the pull request that every commit on the branch continues to +/// serve, where a SHA-keyed one would demand a re-claim per commit. A slash is +/// the one character a file name cannot carry, so it is spelled out. +#[must_use] +pub fn receipt_name(branch: &str) -> String { + format!("bot.{}", branch.replace('/', "-")) +} + +/// Write the bot receipt. +/// +/// **Pointer-only**: the key, the login, the pull request number, a timestamp and +/// the base commit. Never the body, never a version. +/// +/// The `base` line gives this receipt CLOUD-516's staleness rule: a branch +/// restarted out from under its receipt is void rather than silently trusted. +/// +/// # Errors +/// +/// [`UsageError`] when the receipt cannot be written. +pub fn mint( + receipts: &std::path::Path, + branch: &str, + attested: &Attested, + base: Option<&str>, + at: &str, +) -> Result { + use std::fmt::Write as _; + + let mut body = String::new(); + writeln!(body, "{}", attested.key)?; + writeln!(body, "bot {}", attested.login)?; + writeln!(body, "pr {}", attested.pr)?; + writeln!(body, "derived-at {at}")?; + writeln!(body, "base {}", base.unwrap_or("-"))?; + + std::fs::create_dir_all(receipts).map_err(|err| { + UsageError::raise(format!( + "claim bot: cannot create {}: {err}", + receipts.display() + )) + })?; + let path = receipts.join(receipt_name(branch)); + std::fs::write(&path, body).map_err(|err| { + UsageError::raise(format!("claim bot: cannot write {}: {err}", path.display())) + })?; + Ok(path) +} + +/// The facts a bot receipt records, once every one of them holds. +/// +/// A struct rather than three loose arguments so a caller cannot mint one with a +/// key it never checked: the only way to build it is to have read all three. +#[derive(Debug, Clone)] +pub struct Attested { + /// The tracker key the pull request's body names. + pub key: String, + /// The login that opened it. + pub login: String, + /// The pull request number. + pub pr: String, +} + +/// What a pull request is, as far as this lane is concerned. +/// +/// The fields the predicates above read and nothing else. Notably NOT the body's +/// prose: a bot PR carries a release-notes dump, and a struct with a place to put +/// it is a struct a report can leak it from. +#[derive(Debug, Clone)] +pub struct Pull { + /// The number, as given. + pub number: String, + /// The subject, which is also the derived row's title. + pub title: String, + /// The body, read for the keys it names and never emitted. + pub body: String, + /// The login that opened it. + pub login: String, + /// The head branch. + pub head: String, +} + +/// The forge half: every call this lane makes, and nothing that decides anything. +pub mod forge { + use anyhow::Result; + + use super::Pull; + use crate::error::UsageError; + + /// The client every call goes through, for [`crate::pr_watch`]'s reason. + const CLIENT: &str = "gh"; + + /// Run `gh` with `args` and hand back stdout, or a could-not-look. + /// + /// Pointer-only on the failure path: the endpoint and the status, never the + /// response body — a forge error can echo a header dump back, and a token + /// with it. + /// + /// # Errors + /// + /// Anything but a clean exit is an internal error (→ exit `3`): a lane that + /// cannot read the pull request must not report that it filed nothing. + fn run(args: &[&str]) -> Result { + run_with(args, None) + } + + /// As [`run`], with `stdin` handed to the child where there is one. + /// + /// The two writes send their body **on stdin** (`-F body=@-`) rather than + /// through a temporary file. A body is a page of markdown carrying newlines + /// and backticks; a file would put that page on disk under a path this + /// process then has to remember to remove, and a write that fails between + /// those two steps leaves a tracker row's text lying in the world. Stdin has + /// no such window. + fn run_with(args: &[&str], stdin: Option<&str>) -> Result { + #[expect( + clippy::disallowed_types, + reason = "stays: the forge's own client IS the call, and it resolves the credential outside this crate — the standing CLOUD-1143 gave the check-run read (CLOUD-1295)" + )] + let output = crate::rules::spawn_resolving( + Some(std::path::Path::new(".")), + CLIENT, + |program, extra| { + let mut command = std::process::Command::new(program); + command + .args(extra) + .args(args) + .stderr(std::process::Stdio::null()); + let Some(body) = stdin else { + return command.output(); + }; + command + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()); + let mut child = command.spawn()?; + if let Some(pipe) = child.stdin.as_mut() { + std::io::Write::write_all(pipe, body.as_bytes())?; + } + drop(child.stdin.take()); + child.wait_with_output() + }, + ); + let output = output.map_err(|err| { + anyhow::anyhow!("bot lane: cannot run {CLIENT}: {err} — nothing is written") + })?; + if !output.status.success() { + // The endpoint, which is the first argument, and nothing else. + let endpoint = args.get(1).copied().unwrap_or(""); + return Err(anyhow::anyhow!( + "bot lane: {CLIENT} refused {endpoint} — cannot read the pull request, so nothing \ + is written" + )); + } + Ok(String::from_utf8_lossy(&output.stdout).into_owned()) + } + + /// One `--jq` read against an endpoint. + fn read(endpoint: &str, jq: &str) -> Result { + Ok(run(&["api", endpoint, "--jq", jq])?.trim().to_owned()) + } + + /// The pull request, as [`Pull`]. + /// + /// # Errors + /// + /// As [`run`], plus a [`UsageError`] where the answer carries no title: the + /// derived row's title IS the PR's, so there is nothing to file. + pub fn pull(repo: &str, number: &str) -> Result { + let raw = read( + &format!("repos/{repo}/pulls/{number}"), + "[.title, .body // \"\", .user.login, .head.ref] | @tsv", + )?; + let mut fields = raw.split('\t'); + let title = fields.next().unwrap_or_default().to_owned(); + if title.is_empty() { + return Err(UsageError::raise(format!( + "bot lane: #{number} has no title, and the row's title is the pull request's — so \ + there is nothing to file" + ))); + } + Ok(Pull { + number: number.to_owned(), + title, + body: fields.next().unwrap_or_default().to_owned(), + login: fields.next().unwrap_or_default().to_owned(), + head: fields.next().unwrap_or_default().to_owned(), + }) + } + + /// The paths the pull request changed, capped at one page. + /// + /// A bump touches two files; a PR touching more than a page is not a bump, + /// and the cap refusing it is the safe direction rather than a truncation + /// nobody sees. + /// + /// # Errors + /// + /// As [`run`]. + pub fn files(repo: &str, number: &str) -> Result> { + Ok(read( + &format!("repos/{repo}/pulls/{number}/files?per_page=100"), + ".[].filename", + )? + .lines() + .map(str::to_owned) + .filter(|line| !line.is_empty()) + .collect()) + } + + /// The mirror issue this pull request already has, if any. + /// + /// LISTED rather than searched: the search API's indexing lag is measured in + /// tens of seconds, and a tick running inside that window would file a second + /// row for the same pull request. + /// + /// # Errors + /// + /// As [`run`]. + pub fn mirror(repo: &str, number: &str, marker_prefix: &str) -> Result> { + let marker = format!("{marker_prefix}{number} -->"); + let found = read( + &format!("repos/{repo}/issues?state=all&per_page=100"), + &format!( + "[.[] | select((.pull_request // null) == null) | select((.body // \"\") | \ + contains(\"{marker}\"))] | .[0].number // empty" + ), + )?; + Ok((!found.is_empty()).then_some(found)) + } + + /// The tracker's linkback comment on `issue`, or empty while the sync has not + /// run yet. + /// + /// Read from the comment alone: the issue BODY is this lane's own text, so a + /// key named there would be one we wrote rather than one the tracker + /// assigned. + /// + /// # Errors + /// + /// As [`run`]. + pub fn linkback(repo: &str, issue: &str, marker: &str) -> Result { + read( + &format!("repos/{repo}/issues/{issue}/comments?per_page=100"), + &format!( + "[.[] | select((.body // \"\") | contains(\"{marker}\"))] | .[0].body // empty" + ), + ) + } + + /// Open the mirror issue and answer with its number. + /// + /// The body travels on STDIN rather than on the command line: it is a page of + /// markdown carrying newlines and backticks, and an argument of that shape is + /// a quoting bug waiting for the first template edit. + /// + /// # Errors + /// + /// As [`run`], plus an internal error where the forge accepted the issue and + /// named no number — no row exists then, and none is invented. + pub fn open_issue(repo: &str, title: &str, body: &str) -> Result { + let created = run_with( + &[ + "api", + "-X", + "POST", + &format!("repos/{repo}/issues"), + "-f", + &format!("title={title}"), + "-F", + "body=@-", + "--jq", + ".number", + ], + Some(body), + )? + .trim() + .to_owned(); + if created.is_empty() { + return Err(anyhow::anyhow!( + "bot lane: the mirror issue was accepted and named no number, so no row exists \ + and none is invented" + )); + } + Ok(created) + } + + /// Replace the pull request's body. + /// + /// # Errors + /// + /// As [`run`]: a body that could not be written means the row exists and the + /// merge would not move it, which is a failure rather than a quiet skip. + pub fn set_body(repo: &str, number: &str, body: &str) -> Result<()> { + run_with( + &[ + "api", + "-X", + "PATCH", + &format!("repos/{repo}/pulls/{number}"), + "-F", + "body=@-", + ], + Some(body), + ) + .map(|_| ()) + } + + /// The number of the open pull request whose head is `branch`, if any. + /// + /// # Errors + /// + /// As [`run`]. + pub fn open_for(repo: &str, branch: &str) -> Result> { + let found = read( + &format!("repos/{repo}/pulls?state=open&per_page=100"), + &format!("[.[] | select(.head.ref == \"{branch}\")] | .[0].number // empty"), + )?; + Ok((!found.is_empty()).then_some(found)) + } +} + +#[cfg(test)] +// Panicking on a refusal the case does not expect is the idiomatic way for a +// test to fail loudly. +#[allow(clippy::unwrap_used, clippy::expect_used)] +mod tests { + use super::*; + + /// The key prefix the cases below use. A literal here rather than the + /// consumer's, because these are assertions about the MATCHER. + const KEY: &str = "CLOUD-"; + + #[test] + fn a_listed_login_is_the_lane_and_a_neighbour_is_not() { + let bots = vec!["renovate[bot]".to_owned()]; + assert!(is_lane_bot("renovate[bot]", &bots)); + // The retired sibling: a row filed for a bot that cannot open a PR would + // be a claim about a lane this repository does not have. + assert!(!is_lane_bot("dependabot[bot]", &bots)); + // Case is the forge's, not ours to normalise. + assert!(!is_lane_bot("Renovate[bot]", &bots)); + } + + #[test] + fn only_the_declared_manifests_are_owned() { + // Neutral names for rule 1's reason: a consumer's artifact path may not + // reach `crates/batten`, and a unit test is still `crates/batten`. + let files = vec![ + "manifest.toml".to_owned(), + "src/main.rs".to_owned(), + "lane/nested/one.yml".to_owned(), + ]; + let globs = vec!["manifest.toml".to_owned(), "lane/**".to_owned()]; + let owned = owned(&files, &globs).unwrap(); + assert_eq!(owned.len(), 2); + assert!(owned.iter().all(|path| *path != "src/main.rs")); + } + + #[test] + fn a_glob_that_will_not_compile_refuses_rather_than_owning_nothing() { + let files = vec!["manifest.toml".to_owned()]; + assert!(owned(&files, &["[".to_owned()]).is_err()); + } + + #[test] + fn the_type_is_read_from_the_subject_and_a_bare_one_has_none() { + assert_eq!( + conventional_type("build(deps): update cargo"), + Some("build") + ); + assert_eq!(conventional_type("ci: bump the action"), Some("ci")); + assert_eq!(conventional_type("feat!: a breaking change"), Some("feat")); + assert_eq!(conventional_type("update cargo"), None); + // Not a Conventional prefix: a capitalised word is not a type word, and + // reading it as one would let a subject the commit gate refuses through. + assert_eq!(conventional_type("Update: cargo"), None); + } + + #[test] + fn a_closing_verb_closes_and_a_bare_key_does_not() { + assert_eq!( + closing_key("Closes CLOUD-700", KEY).as_deref(), + Some("CLOUD-700") + ); + assert_eq!( + closing_key("Fixes #CLOUD-701", KEY).as_deref(), + Some("CLOUD-701") + ); + assert_eq!( + closing_key("resolved: CLOUD-702", KEY).as_deref(), + Some("CLOUD-702") + ); + // THE WHOLE FAILURE BEING CAUGHT: a rebase leaves the key named and the + // closing line gone, and a merge on that body moves nothing. + assert_eq!(closing_key("See CLOUD-703 for context", KEY), None); + assert_eq!(closing_key("nothing here", KEY), None); + } + + #[test] + fn the_do_not_close_marker_does_not_read_as_a_close() { + // It ends in a closing verb, which is exactly why the character before + // the verb has to decide rather than the verb alone. + assert_eq!(closing_key("DO-NOT-CLOSE CLOUD-388", KEY), None); + } + + #[test] + fn a_named_key_is_found_whether_or_not_it_closes() { + assert_eq!( + named_key("See CLOUD-703 for context", KEY).as_deref(), + Some("CLOUD-703") + ); + // A prefix with no digits is not a key, so a body discussing "CLOUD-" + // as a string does not read as one already filed. + assert_eq!(named_key("the CLOUD- prefix", KEY), None); + } + + #[test] + fn a_template_naming_an_unfilled_placeholder_refuses() { + let filled = render("pr {{pr}}", &[("pr", "7".to_owned())]).unwrap(); + assert_eq!(filled, "pr 7"); + let refused = render("pr {{pr}} by {{whoever}}", &[("pr", "7".to_owned())]); + assert!(refused.is_err(), "an unfilled placeholder must refuse"); + } +} diff --git a/crates/batten/src/carry.rs b/crates/batten/src/carry.rs new file mode 100644 index 000000000..aaf2567a3 --- /dev/null +++ b/crates/batten/src/carry.rs @@ -0,0 +1,380 @@ +//! Whether a carry branch's diff is DERIVABLE, and the receipt that records it +//! (CLOUD-1295). +//! +//! # What this exists for +//! +//! `sbom-actions-currency` (CLOUD-1213) opens its licence-carry pull requests on +//! `sbom-actions/carry-`. `verify` refuses a branch carrying no claim +//! receipt, and neither receipt it accepts fits: the agent claim attests that a +//! human or agent read a refined issue and checked it for a competitor, which no +//! workflow performed; and `bot.` refused the branch outright for not +//! being a bot head. So the first such PR was landed with a `--takeover` claim +//! against CLOUD-1213 — a receipt asserting a refinement nobody did. +//! +//! # The receipt attests DERIVABILITY, and that is the whole design +//! +//! A branch-name exemption would be a password wearing a branch name: anything +//! that could name itself `sbom-actions/…` would pass, and the receipt would +//! attest nothing about the change. So nothing here reads the branch name. +//! +//! What is attested is checkable offline, against the merge base: +//! +//! * exactly one tracked path differs, and it is the licence table; +//! * every added line parses as `@\t\t`; +//! * for each, another row for the SAME repo carries an identical licence and +//! holder — so only the sha differs; +//! * no line is removed or rewritten. +//! +//! Together those bound a carry branch to exactly what the workflow may produce. +//! It cannot introduce a new licence claim, edit an existing row, delete one, or +//! touch a second file — and each of those is a case the tier below drives. +//! +//! **Byte-identity of the upstream licence files is NOT attested here**, and +//! saying so matters. The workflow confirms it at carry time by fetching both +//! shas; this is the offline half, which bounds what the diff may *say* rather +//! than re-verifying what upstream *holds*. A reader wanting the second reads the +//! workflow run. Claiming otherwise would be a receipt asserting a check nobody +//! performed, which is the defect this module exists to end. + +use std::collections::BTreeMap; +use std::fmt::Write as _; +use std::path::{Path, PathBuf}; + +use crate::Result; +use crate::error::UsageError; + +/// The one path a carry branch may touch. +/// +/// A constant rather than config: the receipt's whole meaning is "this is a +/// licence carry", and a configurable subject would let a consumer point the +/// admission at a file whose diffs are not derivable at all. +pub const TABLE: &str = "mise-tasks/sbom-actions.tsv"; + +/// Why a branch is not a carry. +/// +/// **Pointer-only** (rule 4): a path, a repo name, a count. Never a licence +/// string and never a holder — those are the bytes the table exists to record, +/// and a refusal that echoed them would republish the thing being guarded. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Refusal { + /// A tracked path other than [`TABLE`] differs from the base. + TouchedAnotherPath(String), + /// The diff removes or rewrites a line rather than only appending. + NotAppendOnly, + /// An added line is not three tab-separated fields with a `repo@sha` key. + Unparseable(usize), + /// An added row names a repo the base table has no row for, so there is no + /// recorded judgement to carry forward. + NoPriorRow(String), + /// An added row names a repo the base HAS, with a different licence or + /// holder — a new claim rather than a carry. + VerdictChanged(String), + /// Nothing was added, so there is nothing to attest. + NothingCarried, +} + +impl Refusal { + /// The pointer line, house style §6. + #[must_use] + pub fn line(&self) -> String { + match self { + Refusal::TouchedAnotherPath(path) => format!("{path} not-the-licence-table"), + Refusal::NotAppendOnly => format!("{TABLE} not-append-only"), + Refusal::Unparseable(line) => format!("{TABLE}:{line} unparseable-row"), + Refusal::NoPriorRow(repo) => format!("{TABLE} no-prior-row {repo}"), + Refusal::VerdictChanged(repo) => format!("{TABLE} verdict-changed {repo}"), + Refusal::NothingCarried => format!("{TABLE} nothing-carried"), + } + } +} + +/// One row of the licence table: the repo, and the verdict recorded for it. +/// +/// The sha is deliberately NOT part of the value — carrying a row forward is +/// exactly the act of changing the sha and nothing else, so the comparison has to +/// be over what must stay equal. +#[derive(Debug, Clone, PartialEq, Eq)] +struct Row { + repo: String, + licence: String, + holder: String, +} + +/// Parse one table line, or `None` for a comment, a blank, or a malformed row. +fn row(line: &str) -> Option { + if line.trim_start().starts_with('#') || line.trim().is_empty() { + return None; + } + let mut fields = line.split('\t'); + let key = fields.next()?; + let licence = fields.next()?; + let holder = fields.next()?; + // A key with no `@` is not a pin, and a fourth field means the shape moved. + let (repo, sha) = key.split_once('@')?; + if repo.is_empty() || sha.is_empty() || fields.next().is_some() { + return None; + } + Some(Row { + repo: repo.to_owned(), + licence: licence.to_owned(), + holder: holder.to_owned(), + }) +} + +/// Judge a carry: `base` and `head` are the table's lines on each side, and +/// `other` names every OTHER tracked path that differs. +/// +/// Pure, so the tier below can drive every refusal without a git tree — and so +/// the predicate the receipt attests is the same one the tests assert. +/// +/// # Errors +/// +/// Never: a refusal is a value, not an error. The signature returns the count of +/// rows carried so a caller can record it. +pub fn judge(base: &str, head: &str, other: &[String]) -> std::result::Result { + if let Some(path) = other.first() { + return Err(Refusal::TouchedAnotherPath(path.clone())); + } + + // APPEND-ONLY, checked as a prefix rather than as a line-set difference. A + // set comparison would read a rewritten line as one removal plus one + // addition, which is precisely the edit this must refuse. + let base_lines: Vec<&str> = base.lines().collect(); + let head_lines: Vec<&str> = head.lines().collect(); + if head_lines.len() < base_lines.len() || !head_lines.starts_with(&base_lines) { + return Err(Refusal::NotAppendOnly); + } + + // The verdict already recorded for each repo, from the BASE side only. A row + // added by this same diff cannot vouch for another: two unmapped repos would + // otherwise vouch for each other and the branch would carry nothing real. + let mut known: BTreeMap = BTreeMap::new(); + for line in &base_lines { + if let Some(parsed) = row(line) { + known.insert(parsed.repo.clone(), parsed); + } + } + + let mut carried = 0; + for (offset, line) in head_lines[base_lines.len()..].iter().enumerate() { + if line.trim().is_empty() { + continue; + } + let number = base_lines.len() + offset + 1; + let Some(added) = row(line) else { + return Err(Refusal::Unparseable(number)); + }; + let Some(prior) = known.get(&added.repo) else { + return Err(Refusal::NoPriorRow(added.repo)); + }; + if prior.licence != added.licence || prior.holder != added.holder { + return Err(Refusal::VerdictChanged(added.repo)); + } + carried += 1; + } + + if carried == 0 { + return Err(Refusal::NothingCarried); + } + Ok(carried) +} + +/// The filename a carry receipt takes for `branch`. +/// +/// `.` with slashes replaced, matching `receipt`'s own spelling — +/// the same reason `claim::receipt_name` gives, and the same failure if the two +/// drift: `verify` reports a missing receipt for one that exists. +#[must_use] +pub fn receipt_name(branch: &str) -> String { + format!("carry.{}", branch.replace('/', "-")) +} + +/// Write the carry receipt. +/// +/// **Pointer-only**: a count, a path, a timestamp and the base commit. The rows +/// themselves stay in the table. +/// +/// The `base` line is not decoration — it is what gives this receipt CLOUD-516's +/// staleness rule for free, exactly as `bot.` gets it: a branch restarted +/// out from under its receipt is void rather than silently trusted. +/// +/// # Errors +/// +/// [`UsageError`] when the receipt cannot be written. +pub fn mint( + receipts: &Path, + branch: &str, + carried: usize, + base: Option<&str>, + at: &str, +) -> Result { + let mut body = String::new(); + writeln!(body, "carry {carried} row(s)")?; + writeln!(body, "table {TABLE}")?; + writeln!(body, "derived-at {at}")?; + writeln!(body, "base {}", base.unwrap_or("-"))?; + + std::fs::create_dir_all(receipts).map_err(|err| { + UsageError::raise(format!( + "claim carry: cannot create {}: {err}", + receipts.display() + )) + })?; + let path = receipts.join(receipt_name(branch)); + std::fs::write(&path, body).map_err(|err| { + UsageError::raise(format!( + "claim carry: cannot write {}: {err}", + path.display() + )) + })?; + Ok(path) +} + +#[cfg(test)] +// Panicking on setup failure is the idiomatic way for a test to fail loudly. +#[allow(clippy::unwrap_used, clippy::expect_used)] +mod tests { + use super::*; + + /// The committed table's shape, minus the header prose. + const BASE: &str = "# a comment the parser skips\n\ +tool/runner-action@aaa\tMIT\tCopyright (c) 2018 A Holder and contributors\n\ +tool/install-action@bbb\tApache-2.0 OR MIT\tNONE\n"; + + fn carried(head: &str) -> std::result::Result { + judge(BASE, head, &[]) + } + + #[test] + fn a_row_carried_forward_to_a_new_sha_is_admitted() { + let head = format!( + "{BASE}tool/runner-action@ccc\tMIT\tCopyright (c) 2018 A Holder and contributors\n" + ); + assert_eq!(carried(&head), Ok(1)); + } + + /// THE PREMISE CASE. Without it every refusal below could pass over a judge + /// that refuses everything, which is the vacuity CLOUD-418 records. + #[test] + fn two_rows_for_two_mapped_repos_both_carry() { + let head = format!( + "{BASE}tool/runner-action@ccc\tMIT\tCopyright (c) 2018 A Holder and contributors\n\ + tool/install-action@ddd\tApache-2.0 OR MIT\tNONE\n" + ); + assert_eq!(carried(&head), Ok(2)); + } + + #[test] + fn a_second_changed_path_is_refused_and_named() { + let head = format!( + "{BASE}tool/runner-action@ccc\tMIT\tCopyright (c) 2018 A Holder and contributors\n" + ); + assert_eq!( + judge(BASE, &head, &["Cargo.toml".to_owned()]), + Err(Refusal::TouchedAnotherPath("Cargo.toml".to_owned())) + ); + } + + /// The row that makes this more than a diff-size check: a repo with no + /// recorded verdict has nothing to carry, so a licence would be ASSERTED + /// rather than carried — CLOUD-629's class, which the table's own header + /// records four instances of. + #[test] + fn a_repo_with_no_prior_row_is_refused() { + let head = format!("{BASE}brand/new-action@eee\tMIT\tCopyright (c) 2026 Somebody\n"); + assert_eq!( + carried(&head), + Err(Refusal::NoPriorRow("brand/new-action".to_owned())) + ); + } + + /// A carry changes the sha and NOTHING else. A row whose licence or holder + /// moved is a new claim, and admitting it would let the receipt launder one. + #[test] + fn a_changed_licence_is_refused_even_for_a_mapped_repo() { + let head = format!( + "{BASE}tool/runner-action@ccc\tGPL-3.0\tCopyright (c) 2018 A Holder and contributors\n" + ); + assert_eq!( + carried(&head), + Err(Refusal::VerdictChanged("tool/runner-action".to_owned())) + ); + } + + #[test] + fn a_changed_holder_is_refused_too() { + let head = format!("{BASE}tool/runner-action@ccc\tMIT\tCopyright (c) 2026 Somebody Else\n"); + assert_eq!( + carried(&head), + Err(Refusal::VerdictChanged("tool/runner-action".to_owned())) + ); + } + + /// APPEND-ONLY, and the prefix comparison is why. A line-set difference would + /// read this as one removal plus one addition and could admit the addition. + #[test] + fn rewriting_an_existing_row_is_refused_rather_than_read_as_an_addition() { + let head = "# a comment the parser skips\n\ +tool/runner-action@aaa\tGPL-3.0\tCopyright (c) 2018 A Holder and contributors\n\ +tool/install-action@bbb\tApache-2.0 OR MIT\tNONE\n"; + assert_eq!(carried(head), Err(Refusal::NotAppendOnly)); + } + + #[test] + fn deleting_a_row_is_refused() { + let head = "# a comment the parser skips\n\ +tool/runner-action@aaa\tMIT\tCopyright (c) 2018 A Holder and contributors\n"; + assert_eq!(carried(head), Err(Refusal::NotAppendOnly)); + } + + #[test] + fn a_malformed_added_row_is_refused_with_its_line() { + let head = format!("{BASE}not a table row at all\n"); + assert_eq!(carried(&head), Err(Refusal::Unparseable(4))); + } + + /// A branch that changed nothing has nothing to attest, so it must not mint a + /// receipt — otherwise any branch touching no tracked file would earn one. + #[test] + fn an_unchanged_table_carries_nothing() { + assert_eq!(carried(BASE), Err(Refusal::NothingCarried)); + } + + /// A row added by THIS diff may not vouch for another one: two unmapped repos + /// would otherwise vouch for each other and the branch would carry nothing + /// that was ever judged. + #[test] + fn an_added_row_cannot_vouch_for_another_added_row() { + let head = format!( + "{BASE}brand/new-action@eee\tMIT\tCopyright (c) 2026 Somebody\n\ + brand/new-action@fff\tMIT\tCopyright (c) 2026 Somebody\n" + ); + assert_eq!( + carried(&head), + Err(Refusal::NoPriorRow("brand/new-action".to_owned())) + ); + } + + /// Pointer-only (rule 4): a refusal names the repo and the path, never the + /// licence text or the holder it was comparing. + #[test] + fn no_refusal_line_echoes_a_licence_or_a_holder() { + let head = + format!("{BASE}tool/runner-action@ccc\tGPL-3.0\tCopyright (c) 2026 Somebody Else\n"); + let line = carried(&head).unwrap_err().line(); + assert!(!line.contains("GPL-3.0"), "no licence: {line}"); + assert!(!line.contains("Somebody Else"), "no holder: {line}"); + assert!( + line.contains("tool/runner-action"), + "names the repo: {line}" + ); + } + + #[test] + fn the_receipt_filename_replaces_every_slash() { + assert_eq!( + receipt_name("sbom-actions/carry-20260901T110320Z"), + "carry.sbom-actions-carry-20260901T110320Z" + ); + } +} diff --git a/crates/batten/src/claim.rs b/crates/batten/src/claim.rs index 1cab08690..bd9faff94 100644 --- a/crates/batten/src/claim.rs +++ b/crates/batten/src/claim.rs @@ -101,6 +101,27 @@ pub struct Issue { pub live_pr: Option, /// The body, when the caller supplied one. pub description: Option, + /// When the tracker says the row was created, for the prose-dialect cutover. + /// + /// **Read here rather than dropped, because the premise for dropping it was + /// false** (CLOUD-472). This field carried `None` at the call site with a + /// comment saying "this gate reads a `claim check` payload rather than a full + /// `get_issue` one, so it has no creation instant" — but a `claim check` + /// payload IS a `get_issue` payload: the refusal's own remedy says to pipe + /// one, and every route it names hands over what the tracker returned. + /// Measured 2026-09-02 over the ten payloads this branch claimed with: all + /// ten carry `createdAt`. + /// + /// The cost of the gap was a hole at the worst moment. `ready lint` refuses a + /// post-cutover prose block and `claim check` accepted one, so the ratchet was + /// open exactly where work STARTS — and `graph-check` enforces `Todo => + /// ready-lint exits 0`, so the two gates disagreed about the same row. + /// + /// Still could-not-look when absent, which is the posture the wrong comment + /// was reaching for: a caller who hands over a payload without the field gets + /// the row judged exactly as before, and a claim is never refused for a field + /// nobody fetched. + pub created_at: Option, } impl Issue { @@ -142,6 +163,10 @@ impl Issue { .get("description") .and_then(serde_json::Value::as_str) .map(str::to_owned), + created_at: value + .get("createdAt") + .and_then(serde_json::Value::as_str) + .map(str::to_owned), }) } } @@ -484,6 +509,19 @@ fn is_ready( relations_present: false, blocked_by: Vec::new(), all_relations: Vec::new(), + // THE CUTOVER APPLIES HERE TOO (CLOUD-472). This read `None` with a + // comment claiming a `claim check` payload is not a full `get_issue` one + // and so carries no creation instant. It is one: the refusal's own remedy + // says to pipe a `get_issue` payload, and all ten this branch claimed with + // carry `createdAt`. The gap left the ratchet open exactly where work + // STARTS, with `ready lint` refusing a post-cutover prose block and this + // gate accepting it — while `graph-check` enforces `Todo => ready-lint + // exits 0`, so two gates disagreed about one row. + // + // Absent stays could-not-look, which is what the wrong comment was + // reaching for: a caller whose payload omits the field gets the row judged + // exactly as before. + created_at: issue.created_at.clone(), }; let report = crate::ready::lint(grammar, &payload, root)?; Ok(report.findings.is_empty()) @@ -575,6 +613,85 @@ pub fn receipt_name(branch: &str) -> String { format!("claim.{}", branch.replace('/', "-")) } +/// The ids an existing receipt for this branch still speaks for. +/// +/// Empty for every reason that is not "the same branch, still on the same base": +/// no receipt, an unreadable one, one with no `base` line, or one whose base is +/// not the base being claimed against now. **Could-not-look drops the list rather +/// than carrying it**, which is the safe direction here — a lost claim costs one +/// re-run of `claim check`, while a carried-over stale one is the defect +/// CLOUD-516 measured, where a receipt sat on a restarted branch through four +/// unrelated stories reporting nothing. +/// +/// `-` never matches, because [`mint`] writes it for a base that did not resolve +/// and two unresolvable bases are not evidence of the same branch. +fn carried_ids(receipt: &Path, base: Option<&str>) -> Vec { + let Some(existing) = receipt_on_the_same_base(receipt, base) else { + return Vec::new(); + }; + existing + .lines() + .next() + .unwrap_or_default() + .split_whitespace() + .map(str::to_owned) + .collect() +} + +/// The `weakens` lines a same-base receipt holds for rows this invocation does +/// not re-groom. +/// +/// **Carrying the ids alone is not enough, and this is the half that makes the +/// union useful rather than merely wider.** `config lint`'s groomed reader looks +/// for `weakens ` in this file (CLOUD-841), and the loop that +/// writes those lines walks the issues of THIS invocation — so a second `claim +/// check` carried row one's id forward and dropped row one's groomed clause, and +/// the refusal it then produces names a smell whose admission is in the tracker +/// where nothing reads it. +/// +/// A row named in `issues` is deliberately NOT carried: the payload in hand is +/// the authority for it, so a clause groomed off the row since the first claim +/// disappears rather than surviving in a file nobody re-reads. Only the rows this +/// invocation says nothing about keep what the last one recorded. +fn carried_weakenings(receipt: &Path, base: Option<&str>, regroomed: &[&str]) -> Vec { + let Some(existing) = receipt_on_the_same_base(receipt, base) else { + return Vec::new(); + }; + existing + .lines() + .filter(|line| line.starts_with("weakens ")) + .filter(|line| { + line.split_whitespace() + .nth(1) + .is_some_and(|id| !regroomed.contains(&id)) + }) + .map(str::to_owned) + .collect() +} + +/// The receipt's bytes, but only when it describes this same branch on this same +/// base — the one condition both carries are guarded by. +/// +/// `None` for every reason that is not "the same branch, still on the same base": +/// no receipt, an unreadable one, one with no `base` line, or one whose base is +/// not the base being claimed against now. **Could-not-look drops the record +/// rather than carrying it**, which is the safe direction here — a lost claim +/// costs one re-run of `claim check`, while a carried-over stale one is the +/// defect CLOUD-516 measured, where a receipt sat on a restarted branch through +/// four unrelated stories reporting nothing. +/// +/// `-` never matches, because [`mint`] writes it for a base that did not resolve +/// and two unresolvable bases are not evidence of the same branch. +fn receipt_on_the_same_base(receipt: &Path, base: Option<&str>) -> Option { + let base = base?; + let existing = std::fs::read_to_string(receipt).ok()?; + existing + .lines() + .filter_map(|line| line.strip_prefix("base ")) + .any(|recorded| recorded == base && recorded != "-") + .then_some(existing) +} + /// Write the claim receipt. /// /// **Only on the pullable path**, which is what makes it a claim rather than a @@ -601,11 +718,39 @@ pub fn mint( base: Option<&str>, claimed_at: &str, ) -> Result { - let mut body = String::new(); + let dest = receipts.join(receipt_name(branch)); + // LINE 1 IS THE ID LIST, exactly where it has always been, so any reader that // did parse it still finds it. Everything below is read BY KEY for the same // reason: a line added here must not move one somebody else counts on. - let ids: Vec<&str> = issues.iter().map(|issue| issue.id.as_str()).collect(); + // + // A SECOND CLAIM ON AN OPEN BRANCH ADDS TO THE LIST RATHER THAN REPLACING IT + // (CLOUD-472). `mint` has always taken a SLICE, so the many-row shape was + // expressible in one invocation — but the write is `fs::write`, so a second + // INVOCATION dropped the first row's claim on the floor. That is the model + // backwards: one commit is one issue, one branch is as many issues as the + // work needs, and a second row found mid-branch is claimed and worked there. + // + // Measured 2026-09-01: an agent read the branch-keyed receipt as forbidding a + // second row, declined to pull one onto an open branch, and reported the + // storage key as the rule. The prose that pointed it there is corrected in + // `.claude/rules/toolchain.md`; this is the half that makes the correction + // true rather than merely stated. + // + // THE BASE IS WHAT MAKES THE UNION SAFE, and it is CLOUD-516's arm reused + // rather than a new judgement. A branch NAME outlives the branch it described + // — `git checkout -B origin/main` discards the commits while this file, + // keyed by the name, survives — so ids carry over only when the recorded base + // still matches. A restarted branch starts a fresh list, which is exactly the + // stale-claim defect CLOUD-516 records rather than a case this widens. + let mut ids: Vec = carried_ids(&dest, base); + for issue in issues { + if !ids.iter().any(|held| held == &issue.id) { + ids.push(issue.id.clone()); + } + } + + let mut body = String::new(); body.push_str(&ids.join(" ")); body.push('\n'); if request.bypass_sequence { @@ -636,6 +781,10 @@ pub fn mint( // "could not look", which falls back to the trailer. That is decided by the // file's existence rather than by this loop writing zero lines, so nothing // here needs a placeholder. + let regroomed: Vec<&str> = issues.iter().map(|issue| issue.id.as_str()).collect(); + for line in carried_weakenings(&dest, base, ®roomed) { + writeln!(body, "{line}")?; + } for issue in issues { for pair in issue .description @@ -661,7 +810,6 @@ pub fn mint( // only record and it names something that no longer exists. writeln!(body, "branch {branch}")?; - let dest = receipts.join(receipt_name(branch)); std::fs::create_dir_all(receipts) .and_then(|()| std::fs::write(&dest, body)) .map_err(|_| { @@ -807,6 +955,7 @@ pub fn adopt( } #[cfg(test)] +#[allow(clippy::unwrap_used, clippy::expect_used)] mod tests { use super::*; @@ -817,7 +966,192 @@ mod tests { assigned: false, live_pr: None, description: None, + created_at: None, + } + } + + /// A payload as `Issue::parse` reads one, so the field under test comes + /// through the real parser rather than being set by hand. + fn parsed(id: &str, created_at: Option<&str>) -> Issue { + let mut value = serde_json::json!({"id": id, "status": "Todo"}); + if let Some(stamp) = created_at { + value["createdAt"] = serde_json::Value::String(stamp.to_owned()); } + Issue::parse(&value).expect("the entry contract is id and status") + } + + /// THE CUTOVER REACHES THE CLAIM PATH (CLOUD-472). `is_ready` passed `None` + /// here with a comment claiming a `claim check` payload is not a full + /// `get_issue` one — it is, and all ten payloads this branch claimed with + /// carry the field. The gap left `ready lint` refusing a post-cutover prose + /// block while this gate accepted it, at the moment work starts. + #[test] + fn the_creation_instant_survives_the_parse() { + assert_eq!( + parsed("CLOUD-1", Some("2026-09-02T10:00:00.000Z")).created_at, + Some("2026-09-02T10:00:00.000Z".to_owned()) + ); + } + + /// COULD-NOT-LOOK, and it is the arm that keeps the fix from over-refusing: + /// a payload without the field leaves the row judged exactly as before, so a + /// claim is never refused for something nobody fetched. Without this case the + /// parse could default to a stamp and every prose row would refuse. + #[test] + fn a_payload_with_no_creation_instant_carries_none() { + assert_eq!(parsed("CLOUD-1", None).created_at, None); + } + + fn scratch(name: &str) -> PathBuf { + let dir = std::env::temp_dir().join("batten-claim-tests").join(name); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir + } + + /// Mint through the real function, so these cases exercise the write the + /// engine actually performs rather than a hand-rolled file. + fn mint_one(receipts: &Path, id: &str, base: Option<&str>) -> String { + let dest = mint( + receipts, + "user/branch", + &[issue(id, "Todo")], + &Verdict::default(), + &Request::default(), + base, + "2026-09-01T00:00:00Z", + ) + .unwrap(); + std::fs::read_to_string(dest).unwrap() + } + + /// ONE BRANCH, MANY ISSUES (CLOUD-472). The receipt's first line has always + /// been an id LIST and `mint` has always taken a slice, but the write is a + /// whole-file replace — so a second `claim check` INVOCATION dropped the first + /// row's claim silently. That is the branching model backwards: a second row + /// found mid-branch is claimed and worked there. + #[test] + fn a_second_claim_on_an_open_branch_joins_the_first() { + let receipts = scratch("second-claim"); + mint_one(&receipts, "CLOUD-1", Some("abc123")); + let body = mint_one(&receipts, "CLOUD-2", Some("abc123")); + assert_eq!( + body.lines().next().unwrap(), + "CLOUD-1 CLOUD-2", + "the branch speaks for both rows:\n{body}" + ); + } + + /// Mint one row that groomed a weakening, so the `weakens` line the union + /// has to preserve is actually written. + fn mint_groomed(receipts: &Path, id: &str, base: Option<&str>, clause: &str) -> String { + let mut row = issue(id, "Todo"); + row.description = Some(clause.to_owned()); + let dest = mint( + receipts, + "user/branch", + &[row], + &Verdict::default(), + &Request::default(), + base, + "2026-09-01T00:00:00Z", + ) + .unwrap(); + std::fs::read_to_string(dest).unwrap() + } + + /// CARRYING THE ID WITHOUT ITS CLAUSE IS THE HALF THAT LOOKS DONE AND IS NOT + /// (CLOUD-1231's third acceptance clause). `config lint`'s groomed reader + /// resolves `weakens ` out of this file, and the loop that + /// writes those lines walks THIS invocation's issues — so a union over ids + /// alone leaves row one claimed and its admission gone, and the refusal that + /// follows names a smell whose groom is in the tracker where no gate reads it. + #[test] + fn a_carried_row_keeps_the_weakening_it_groomed() { + let receipts = scratch("carried-weakens"); + mint_groomed( + &receipts, + "CLOUD-1", + Some("abc123"), + "**Weakens:** `rule-predicate-changed` at `rule[x].checks`", + ); + let body = mint_one(&receipts, "CLOUD-2", Some("abc123")); + assert!( + body.contains("weakens CLOUD-1 rule-predicate-changed rule[x].checks"), + "row one's admission survives row two's claim:\n{body}" + ); + } + + /// THE PAYLOAD IN HAND IS THE AUTHORITY FOR THE ROW IT DESCRIBES, which is + /// what keeps the carry from becoming a ratchet nobody can lower: re-claiming + /// a row whose clause has since been groomed OFF must drop it, not resurrect + /// the copy this file happens to hold. + #[test] + fn a_regroomed_row_takes_the_payloads_answer_rather_than_the_files() { + let receipts = scratch("regroomed"); + mint_groomed( + &receipts, + "CLOUD-1", + Some("abc123"), + "**Weakens:** `rule-predicate-changed` at `rule[x].checks`", + ); + let body = mint_one(&receipts, "CLOUD-1", Some("abc123")); + assert!( + !body.contains("weakens "), + "the clause is gone from the row, so it is gone from the receipt:\n{body}" + ); + } + + /// The carry is guarded by the SAME base as the ids, so CLOUD-516's restart + /// forgets an admission exactly as it forgets a claim. Without this the two + /// halves could disagree, and the direction that over-claims is the one that + /// matters: a stale admission silently passes `config lint`. + #[test] + fn a_restarted_branch_carries_no_earlier_weakening_either() { + let receipts = scratch("restarted-weakens"); + mint_groomed( + &receipts, + "CLOUD-1", + Some("abc123"), + "**Weakens:** `rule-predicate-changed` at `rule[x].checks`", + ); + let body = mint_one(&receipts, "CLOUD-2", Some("def456")); + assert!(!body.contains("weakens "), "{body}"); + } + + /// ANTI-VACUITY: the union must not turn a re-claim into a duplicate, or the + /// list grows without bound across the laps a long branch makes. + #[test] + fn re_claiming_the_same_row_does_not_duplicate_it() { + let receipts = scratch("re-claim"); + mint_one(&receipts, "CLOUD-1", Some("abc123")); + let body = mint_one(&receipts, "CLOUD-1", Some("abc123")); + assert_eq!(body.lines().next().unwrap(), "CLOUD-1", "{body}"); + } + + /// A RESTARTED BRANCH STARTS A FRESH LIST, which is CLOUD-516's arm reused + /// rather than widened. `git checkout -B origin/main` discards the + /// commits while the receipt, keyed by the NAME, survives — so carrying ids + /// across a changed base is exactly the stale claim that sat through four + /// unrelated stories reporting nothing. + #[test] + fn a_branch_restarted_on_a_new_base_carries_no_earlier_ids() { + let receipts = scratch("restarted"); + mint_one(&receipts, "CLOUD-1", Some("abc123")); + let body = mint_one(&receipts, "CLOUD-2", Some("def456")); + assert_eq!(body.lines().next().unwrap(), "CLOUD-2", "{body}"); + } + + /// COULD-NOT-LOOK DROPS THE LIST rather than carrying it. `mint` writes `-` + /// for a base that did not resolve, and two unresolvable bases are not + /// evidence of the same branch — a lost claim costs one re-run, a carried + /// stale one is the defect. + #[test] + fn an_unresolvable_base_carries_nothing_in_either_direction() { + let receipts = scratch("no-base"); + mint_one(&receipts, "CLOUD-1", None); + let body = mint_one(&receipts, "CLOUD-2", None); + assert_eq!(body.lines().next().unwrap(), "CLOUD-2", "{body}"); } #[test] diff --git a/crates/batten/src/cli.rs b/crates/batten/src/cli.rs index 082b31600..cd2167431 100644 --- a/crates/batten/src/cli.rs +++ b/crates/batten/src/cli.rs @@ -349,6 +349,33 @@ pub enum PrCommand { /// The fan-in whose failure a cancelled sibling can manufacture. fanin: Option, }, + /// The tracker row a bot's pull request implies, written nowhere. + Derive { + /// The pull request to describe. + pr: String, + }, + /// Open the mirror issue that row is filed as. + File { + /// The pull request to file for. + pr: String, + }, + /// Write the closing key into the pull request's body. + Link { + /// The pull request whose body is rewritten. + pr: String, + /// The tracker key it should close. + key: String, + }, + /// File and link, doing whatever this tick can. + Ensure { + /// The pull request this tick is about. + pr: String, + }, + /// Whether the body still closes a key, asked at the last moment. + Closes { + /// The pull request to re-read. + pr: String, + }, } /// Subcommands of `checks`. @@ -407,6 +434,22 @@ pub enum ClaimCommand { /// Emit the refusals on the structured channel. json: bool, }, + /// Attest a bot branch from the lane's public facts. + /// + /// No payload and no flags at all, for `Carry`'s reason below: the subject is + /// the branch this checkout is on and the pull request the forge says is open + /// for it, so there is nothing for a caller to supply. + Bot, + /// Attest that this branch only carries licence rows forward. + /// + /// No payload and no flags but `--json`: the subject is the branch's own diff + /// against its merge base, so there is nothing for a caller to supply and + /// nothing it could choose. That is the point — a caller that could name its + /// own subject could name one that is derivable while changing another. + Carry { + /// Emit the refusal on the structured channel. + json: bool, + }, } /// Subcommands of `semver`. @@ -837,6 +880,20 @@ pub enum RecordCommand { /// The ref or sha the verdict was taken against. reference: String, }, + /// Record this branch's plan: ` ` per line, on stdin. + /// + /// No argument, for [`RecordCommand::Tool`]'s reason one layer over: the + /// branch is the key and the engine reads it, so a caller cannot record + /// against a branch it is not on. + Plan, + /// Record which rows this branch's pull request body closes, read on stdin. + /// + /// The same record a `[[recorder]]` row mints from an observed `gh pr view` + /// envelope, written by a verb for [`RecordCommand::Plan`]'s reason: the + /// envelope route depends on the agent happening to make that call through a + /// mediated tool on a harness whose spelling is surveyed, and its failure + /// produces NOTHING — so the exemption it feeds silently cannot fire. + Closes, } /// Subcommands of `receipt`. @@ -1363,6 +1420,10 @@ fn claim_of(matches: &ArgMatches) -> Option { issue: matches.get_one::("issue").cloned(), json: flag(matches, "json"), }), + ("bot", _) => Some(ClaimCommand::Bot), + ("carry", matches) => Some(ClaimCommand::Carry { + json: flag(matches, "json"), + }), _ => None, } } @@ -1401,6 +1462,26 @@ fn pr_of(matches: &ArgMatches) -> Option { answered: matches.get_one::("answered").cloned()?, fanin: matches.get_one::("fanin").cloned(), }), + // Every one of these is required by the surface, so clap has already + // refused an argv without it; `None` is unreachable and maps to a refusal + // rather than to a default, which here would be a verb acting on a pull + // request nobody named. + ("derive", matches) => Some(PrCommand::Derive { + pr: matches.get_one::("pr").cloned()?, + }), + ("file", matches) => Some(PrCommand::File { + pr: matches.get_one::("pr").cloned()?, + }), + ("link", matches) => Some(PrCommand::Link { + pr: matches.get_one::("pr").cloned()?, + key: matches.get_one::("key").cloned()?, + }), + ("ensure", matches) => Some(PrCommand::Ensure { + pr: matches.get_one::("pr").cloned()?, + }), + ("closes", matches) => Some(PrCommand::Closes { + pr: matches.get_one::("pr").cloned()?, + }), _ => None, } } @@ -1500,6 +1581,10 @@ fn record_of(matches: &ArgMatches) -> Option { ("forge", matches) => Some(RecordCommand::Forge { reference: matches.get_one::("ref")?.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), + ("closes", _) => Some(RecordCommand::Closes), _ => None, } } diff --git a/crates/batten/src/config.rs b/crates/batten/src/config.rs index e204d838c..8eb2c77b2 100644 --- a/crates/batten/src/config.rs +++ b/crates/batten/src/config.rs @@ -171,6 +171,11 @@ pub struct Config { /// [`Config::unlanded`]. CLOUD-31's config-trust diff defends this set. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub protected: Vec, + /// The refinement gate's consumer-set thresholds (CLOUD-472). Absent means + /// this file does not speak to them, which every reader takes as + /// could-not-look rather than as a default. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub ready: Option, /// Programs that only ever READ the operands they are given, so naming a /// [`Config::protected`] path is not a mutation (CLOUD-1141). /// @@ -510,12 +515,58 @@ pub struct Config { /// [`crate::commit`]. #[serde(default, skip_serializing_if = "Option::is_none")] pub commit: Option, + /// The bot lane this repository files rows for (CLOUD-1295). Absent means it + /// runs none, and the `pr` verbs say so rather than filing against defaults — + /// a lane assembled from engine literals would be a row asserting a bump + /// nobody configured. + /// + /// Consumer-specific by nature, and the reason it lives here rather than in + /// the crate: which repository, which bot logins and which manifests a lane + /// owns are that repository's business (non-negotiable rule 1), so the core + /// carries the matcher and this table carries the answers. The type and the + /// predicates are [`crate::bot`]. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub bot_lane: Option, /// How the base-ref authority behaves when the ref cannot be reached /// (CLOUD-720). Absent means the strict default: an unreachable ref refuses. #[serde(default, skip_serializing_if = "Option::is_none")] pub trust: Option, } +/// The `[ready]` table: the refinement gate's consumer-set thresholds. +/// +/// # Why a value and not a `[[pattern]]` row (CLOUD-472) +/// +/// The first draft of the prose-dialect ratchet spelled its threshold as a +/// regex over the exempt key range. That is wrong twice. The pattern registry +/// exists so that one CONCEPT has one spelling — arithmetic is not a concept, +/// and a range encoded in alternation is unreadable and unmovable. And it makes +/// the decision turn on key TEXT, which the tracker is known to rewrite: this +/// consumer already declares `ready-issue-mention-markup` because a bare key +/// comes back wrapped in `` markup, so matching key text is matching +/// the one thing the round trip mangles. +#[derive(Debug, Clone, Default, Deserialize, Serialize, JsonSchema, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct Ready { + /// From which creation instant a Ready block must carry the fenced claims + /// object rather than prose. An ISO-8601 UTC stamp, compared verbatim + /// against the tracker's own `createdAt`. + /// + /// **A RATCHET: moving it later is the only direction that tightens.** + /// Absent is could-not-look and exempts everything, so a consumer that has + /// not opted in is never refused for a question it did not ask. + /// + /// **An instant rather than a key ordinal, and that is a portability + /// decision.** An ordinal reaches no consumer literal — the trailing digits, + /// no separator assumed — so it passes `no-tracker-key-in-core`. It still + /// requires keys that are numeric AND monotonic with creation order, which + /// three popular trackers give and a slug- or UUID-keyed one does not, and it + /// would fail SILENTLY there rather than loudly. Every tracker stamps a + /// creation time, so this assumes nothing about how a consumer spells a key. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub prose_dialect_required_from: Option, +} + /// The `[trust]` table: what `--config-from` may do when the ref is unreachable. /// /// House style §4 requires the authority to degrade safely rather than fail @@ -1423,6 +1474,10 @@ impl Config { vocabulary: crate::verdict::Vocabulary::default(), scope: Vec::new(), protected: Vec::new(), + // Declaring nothing means declaring no threshold either, which the + // reader takes as could-not-look and exempts everything — the same + // direction every other field here grants. + ready: None, // No protected paths means the unknown-program clause has nothing to // guard, so an empty reader set costs nothing here and is the honest // value: a config declaring nothing declares no readers either. @@ -1489,6 +1544,7 @@ impl Config { // And no commit convention: absent is "no rule was declared", which // the gate answers 1 to rather than waving commits through. commit: None, + bot_lane: None, // An authority that cannot be read grants no permission to answer // from a pin either. The default is the strict one, and an absent // authority must not be the way to reach the lenient one. @@ -1658,6 +1714,7 @@ fn default_rules() -> Vec { predicate_severity: None, no_fix_reason: None, checks: None, + checks_any: None, key: None, trigger: None, verdict: None, diff --git a/crates/batten/src/doctor.rs b/crates/batten/src/doctor.rs index 5a713034b..814b9718c 100644 --- a/crates/batten/src/doctor.rs +++ b/crates/batten/src/doctor.rs @@ -129,6 +129,9 @@ const CONFIG: &str = "config"; /// against it, so a checkout where this fails is one where those silently have /// nothing to stand on. const GIT_REPO: &str = "git-repo"; +/// This harness's plan/todo surface has been SURVEYED — which is a different +/// question from whether it has one (CLOUD-472). +const PLAN_SURFACE: &str = "plan-surface"; /// Every `command`-kind rule names a program that resolves on `PATH`. /// /// A missing binary is otherwise discovered at `enforce` time, mid-run, as a @@ -277,6 +280,44 @@ pub fn diagnose(dir: &Path) -> Report { ), ); + // THE HOST'S PLAN SURFACE, REPORTED AND NEVER GATED ON (CLOUD-472). + // + // `plan-complete` reads a store `batten record plan` writes, so it fails + // closed on every host and this check decides nothing about it. What it + // answers is whether the human's NATIVE todo view can be kept in step — + // and, more importantly, it makes an unsurveyed host say so out loud. + // + // `Unsurveyed` is a FAILED check rather than a passed one, which is the + // whole reason the column has two variants. An absence of data reading as + // an absence of capability is the trap `hook::Harness::operation_of` + // records for Gemini and Copilot, and a diagnostic that reported "no plan + // tool" for a host nobody has looked at would be repeating it in the one + // place an operator goes to find out what is true. + // OVER THE TABLE, NOT OVER THE RUNNING HOST, because `diagnose` takes a + // directory: it answers for the checkout in front of it and has no harness + // to ask. Inferring one from the environment would be manufacturing the + // fact this check exists to report honestly. + // AN UNSURVEYED HOST MUST NAME WHO OWES THE SURVEY, and naming one changes + // no exit code. That is `#MUTANT-OWNER`'s bargain: the declaration buys that + // the gap is STATED, never that it is forgiven, and a check that reddened on + // every unsurveyed host would be permanently red on this repository — which + // is how a diagnostic stops being run at all. + // + // What it does catch is a harness added with neither a fetch nor an owner, + // which is the moment the gap becomes invisible. + checks.push( + if crate::hook::Harness::ALL.iter().any(|harness| { + matches!( + harness.capabilities().plan_tools, + crate::hook::PlanTools::Unsurveyed(owner) if owner.is_empty() + ) + }) { + Check::failed(PLAN_SURFACE, "harness-unsurveyed-and-unowned") + } else { + Check::passed(PLAN_SURFACE) + }, + ); + // The working-tree authority: `doctor` diagnoses the checkout in front of // it, so it does not take a base ref. let config_epoch = crate::epoch::compute(dir, None).ok(); @@ -1703,7 +1744,13 @@ mod tests { .collect(); assert_eq!( names, - vec![CONFIG, GIT_REPO, COMMAND_PROGRAMS, HOOK_HANDLERS] + vec![ + CONFIG, + GIT_REPO, + COMMAND_PROGRAMS, + HOOK_HANDLERS, + PLAN_SURFACE + ] ); } diff --git a/crates/batten/src/facts.rs b/crates/batten/src/facts.rs index 0b4729edb..a12be3856 100644 --- a/crates/batten/src/facts.rs +++ b/crates/batten/src/facts.rs @@ -4251,7 +4251,7 @@ pub fn validate_keying(facts: &[Declared], rules: &[crate::rules::Rule]) -> anyh { continue; } - for check in rule.checks.iter().flatten() { + for check in rule.receipt_names() { if facts.iter().any(|fact| &fact.name == check) { return Err(crate::error::UsageError::raise(format!( "rule {}: `key = \"named\"` over the agent-sourced fact `{check}` — a named \ diff --git a/crates/batten/src/hook.rs b/crates/batten/src/hook.rs index 05e7f9a15..2b4bad089 100644 --- a/crates/batten/src/hook.rs +++ b/crates/batten/src/hook.rs @@ -309,6 +309,34 @@ impl Harness { } } +/// How a host spells the agent's plan/todo tool, or that nobody has looked +/// (CLOUD-472). +/// +/// **Two variants for a THREE-valued fact, and the third value is +/// `Surveyed(&[])`.** Collapsing "surveyed and this host has none" into the same +/// answer as "nobody checked" is the exact trap [`Harness::operation_of`]'s own +/// comment warns about, where Gemini and Copilot carry no spellings because the +/// CLOUD-209 survey did not record them — an absence of DATA that reads as an +/// absence of CAPABILITY. A reader who cannot tell those apart will report a +/// host as having no todo tool when the truth is that nobody asked. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum PlanTools { + /// Fetched from this host's own documentation. An empty slice is a measured + /// "this host offers none", which is an answer. + Surveyed(&'static [&'static str]), + /// Nobody has looked, carrying the row that OWES the survey. NOT the same as + /// none, and never reported as none. + /// + /// **The key changes no exit code, and that is deliberate** — it is + /// `#MUTANT-OWNER`'s bargain, one layer over: a declaration that suppressed + /// the finding would be the laundering the runner exists to refuse, so what + /// the key buys is that the gap is STATED rather than that it is forgiven. A + /// new harness added without a survey has to name who owes one, which is the + /// moment an author either does the fetch or admits they did not. + Unsurveyed(&'static str), +} + /// What one host can and cannot do (CLOUD-45). /// /// A **host × capability** table, not a list of Claude-only events — the survey @@ -334,6 +362,22 @@ impl Harness { pub struct Capabilities { /// The events this host emits, so Batten can be invoked on them. pub events: &'static [Event], + /// How this host spells the agent's own plan/todo tool (CLOUD-472). + /// + /// **A column about what the AGENT can call, where the rest of this table is + /// about what the ENGINE can reach** — and it is here anyway, because this is + /// the one authority on host facts and a second table would be a second place + /// for the same answer to drift. + /// + /// Batten does not gate on it. The plan record is written by + /// [`crate::record::run_plan`], a verb, so the gate fails closed on every + /// host regardless of what this says. What the column buys is the MIRROR — + /// keeping the human's native todo view in step with the store — and an + /// honest report of hosts where that view does not exist. + /// + /// Every spelling here was FETCHED, per CLOUD-209's rule that anything + /// re-derived without one should be assumed wrong. + pub plan_tools: PlanTools, /// Where an escalate-to-human verdict is actually reachable on this host /// (CLOUD-601). /// @@ -1288,6 +1332,8 @@ impl Harness { pub const fn capabilities(self) -> Capabilities { match self { Harness::ClaudeCode => Capabilities { + // Fetched: this session's own tool surface. + plan_tools: PlanTools::Surveyed(&["TaskCreate", "TaskUpdate"]), events: CLAUDE_EVENTS, // Documented, and merged most-restrictive-first by the host // itself (`deny > defer > ask > allow`), so an ask cannot @@ -1402,6 +1448,11 @@ impl Harness { }, }, Harness::Cursor => Capabilities { + // Searched 2026-09-01 and NOT fetched: the host has a Todos + // feature from 1.2, but no vendor-documented tool spelling was + // found, and a name taken from a forum post is exactly what + // CLOUD-209's "assume it wrong without a fetch" refuses. + plan_tools: PlanTools::Unsurveyed("CLOUD-209"), events: CONVERGED_EVENTS, // The row that forced this column to become event-scoped // (CLOUD-601). M1 records the verdict vocabulary as @@ -1438,6 +1489,9 @@ impl Harness { capture: UNSURVEYED_CAPTURE, }, Harness::CopilotCli => Capabilities { + // Not fetched, like the rest of this host's tool surface — the + // same survey gap `operation_of` records for it. + plan_tools: PlanTools::Unsurveyed("CLOUD-209"), events: CONVERGED_EVENTS, // `Unknown`, not `No`, and not `Yes` either: M1 confirms the // verdict exists and names the `preToolUse` output *fields* @@ -1468,6 +1522,9 @@ impl Harness { capture: UNSURVEYED_CAPTURE, }, Harness::GeminiCli => Capabilities { + // Fetched 2026-09-01 from the vendor docs: `write_todos`, on by + // default and disableable with `"useWriteTodos": false`. + plan_tools: PlanTools::Surveyed(&["write_todos"]), events: CONVERGED_EVENTS, // Allow/deny only. A policy wanting confirmation must hard-deny // here — degrading to *allow* would turn "ask a human" into "go @@ -1502,6 +1559,8 @@ impl Harness { capture: UNSURVEYED_CAPTURE, }, Harness::CodexCli => Capabilities { + // Fetched 2026-09-01: `update_plan`, the built-in plan tool. + plan_tools: PlanTools::Surveyed(&["update_plan"]), events: CONVERGED_EVENTS, // Advertised in the output schema, marked "parsed but not // supported yet" in the docs. Advertised is not available, and @@ -1524,6 +1583,8 @@ impl Harness { capture: UNSURVEYED_CAPTURE, }, Harness::ExitCode => Capabilities { + // The neutral contract carries no host tool surface of its own. + plan_tools: PlanTools::Surveyed(&[]), events: CONVERGED_EVENTS, // Not a host: the channel is the exit status alone, which has no // third value to carry an escalation. Measured, not unsurveyed. @@ -3179,12 +3240,7 @@ impl Policy { self.shapes .iter() .filter(|rule| rule.kind == RuleKind::Receipt) - .find(|rule| { - rule.checks - .iter() - .flatten() - .any(|required| required == check) - }) + .find(|rule| rule.receipt_names().any(|required| required == check)) .map(Rule::receipt_key) } @@ -3219,10 +3275,7 @@ impl Policy { .into_iter() .flat_map(|rule| { let key = rule.receipt_key(); - rule.checks - .iter() - .flatten() - .map(move |check| (check.clone(), key)) + rule.receipt_names().map(move |check| (check.clone(), key)) }) .collect() } @@ -3249,7 +3302,7 @@ impl Policy { let Some(max_age) = rule.max_age else { continue; }; - for check in rule.checks.iter().flatten() { + for check in rule.receipt_names() { bounds .entry(check.clone()) .and_modify(|current| *current = (*current).min(max_age)) @@ -3284,7 +3337,7 @@ impl Policy { let Some(bound) = rule.requires_field.as_ref() else { continue; }; - for check in rule.checks.iter().flatten() { + for check in rule.receipt_names() { bounds.entry(check.clone()).or_insert_with(|| bound.clone()); } } @@ -4574,16 +4627,8 @@ fn tool_receipt_rules(policy: &Policy, envelope: &Envelope, facts: &ReceiptFacts if !modifier_admits(rule, envelope) { continue; } - for check in rule.checks.iter().flatten() { - let verdict = facts.get(check).copied().unwrap_or(Validity::Missing); - if verdict != Validity::Valid { - return Decision::Deny(receipt_refusal( - rule, - check, - verdict, - policy.agent_fact(check), - )); - } + if let Some(refusal) = receipt_verdict(policy, rule, facts) { + return Decision::Deny(refusal); } } Decision::Allow @@ -4603,21 +4648,96 @@ fn receipt_rules(policy: &Policy, envelope: &Envelope, facts: &ReceiptFacts) -> // Every named receipt must be valid. An unresolved name is Missing, // never absent-and-therefore-fine: a boundary that answered for // fewer checks than the row requires has not proved the precondition. - for check in rule.checks.iter().flatten() { - let verdict = facts.get(check).copied().unwrap_or(Validity::Missing); - if verdict != Validity::Valid { - return Decision::Deny(receipt_refusal( - rule, - check, - verdict, - policy.agent_fact(check), - )); - } + if let Some(refusal) = receipt_verdict(policy, rule, facts) { + return Decision::Deny(refusal); } } Decision::Allow } +/// Adjudicate one receipt row against the resolved facts (CLOUD-1297). +/// +/// **One adjudicator for both call sites**, which is the point of extracting it: +/// the two loops it replaces were byte-identical, and adding the alternation to +/// one and not the other would have left a row that denies on the mediated path +/// and allows on the other — a disagreement no test over either path alone can +/// see. +/// +/// The two columns are read APART because they mean different things. +/// [`Rule::checks`] is a conjunction: every name must be valid, and the first +/// that is not carries the refusal, so the reader is pointed at one receipt to +/// go and mint. [`Rule::checks_any`] is an alternation: it is satisfied as soon +/// as ONE name is valid, and only a row where none is refuses. +/// +/// A row carrying both is their conjunction, and the order here is deliberate — +/// the conjunction is adjudicated first so a row missing a mandatory receipt +/// names that receipt rather than the alternation it also happens to fail. +fn receipt_verdict( + policy: &Policy, + rule: &Rule, + facts: &std::collections::BTreeMap, +) -> Option { + let verdict_of = |check: &str| facts.get(check).copied().unwrap_or(Validity::Missing); + for check in rule.checks.iter().flatten() { + let verdict = verdict_of(check); + if verdict != Validity::Valid { + return Some(receipt_refusal( + rule, + check, + verdict, + policy.agent_fact(check), + )); + } + } + // An ABSENT alternation is not an unsatisfied one. `checks_any` is optional, + // and a row that declares none has nothing to satisfy here — reading absence + // as "no alternative was valid" would deny every row that uses only the + // conjunction, which is every row that existed before this column. + let alternatives: Vec<&String> = rule.checks_any.iter().flatten().collect(); + if alternatives.is_empty() + || alternatives + .iter() + .any(|check| verdict_of(check) == Validity::Valid) + { + return None; + } + Some(receipt_alternation_refusal( + rule, + &alternatives, + &verdict_of, + )) +} + +/// Compose the refusal for an alternation no receipt satisfied. +/// +/// It names EVERY alternative and each one's verdict, where the conjunction's +/// refusal names one. That asymmetry follows the remedy rather than a house +/// style: a failed conjunction has exactly one thing to go and do, and a failed +/// alternation has several, any of which would clear it — a message naming only +/// the first would send a reader to mint a `claim` receipt on a branch where +/// minting a `carry` was the right move and half the work. +/// +/// Pointer-only (rule 4): receipt names and verdict tokens, never a receipt's +/// contents. The names are sorted by the row's own declaration order rather than +/// alphabetically, so the row reads as written and the output stays byte-stable +/// under `-J` (house-style §6). +fn receipt_alternation_refusal( + rule: &Rule, + alternatives: &[&String], + verdict_of: &impl Fn(&str) -> Validity, +) -> Refusal { + let named = alternatives + .iter() + .map(|check| format!("`{check}` {}", verdict_of(check).as_str())) + .collect::>() + .join(", "); + Refusal::new( + &rule.id, + format!("this call needs any one of these receipts valid, and none is: {named}"), + Fix::declared(rule.reason.as_deref()), + ) +} + /// Compose a receipt row's refusal, naming the check and what is wrong with it. /// /// The verdict is in the cause rather than the fix because it is a *finding* @@ -8115,6 +8235,7 @@ mod tests { // the remediation column (CLOUD-81). no_fix_reason: None, checks: None, + checks_any: None, key: None, trigger: None, verdict: None, @@ -12576,6 +12697,45 @@ deny contains "refused by themodule" if { (Harness::ExitCode, "Write"), ]; + /// A new adapter must either name a fetched plan spelling or say who owes + /// the survey. CLOUD-472's column exists to keep those apart, so a row that + /// declares neither is the one thing it cannot express. + #[test] + fn every_harness_declares_a_plan_surface_or_names_who_owes_the_survey() { + for harness in Harness::ALL { + if let PlanTools::Unsurveyed(owner) = harness.capabilities().plan_tools { + assert!( + !owner.is_empty(), + "{}: unsurveyed with no owner — the gap has to be stated, \ + which is `#MUTANT-OWNER`'s bargain one layer over", + harness.as_str() + ); + } + } + } + + /// SURVEYED-AND-NONE IS AN ANSWER; UNSURVEYED IS NOT. The whole reason the + /// column has two variants is that collapsing them reproduces the trap + /// `operation_of`'s own comment records — an absence of DATA reading as an + /// absence of CAPABILITY. Asserted over the committed table so a later edit + /// cannot quietly turn one into the other. + #[test] + fn an_unsurveyed_plan_surface_is_never_reported_as_having_none() { + assert_eq!( + Harness::ExitCode.capabilities().plan_tools, + PlanTools::Surveyed(&[]), + "the neutral contract carries no host tool surface, which is a measured none" + ); + assert!( + matches!( + Harness::Cursor.capabilities().plan_tools, + PlanTools::Unsurveyed(_) + ), + "Cursor has a Todos feature and no vendor-documented spelling was fetched, \ + so it is unsurveyed rather than none" + ); + } + #[test] fn every_harness_classifies_its_own_write_spelling_as_write() { for harness in Harness::ALL { diff --git a/crates/batten/src/lib.rs b/crates/batten/src/lib.rs index de56bc33d..ec3a80fab 100644 --- a/crates/batten/src/lib.rs +++ b/crates/batten/src/lib.rs @@ -13,12 +13,14 @@ pub mod admission; pub mod advisory; pub mod attribution; pub mod baseline; +pub mod bot; pub mod brief; pub mod budget; pub mod bypass; pub mod capture; /// Declared reductions over responses the agent already captured. pub mod captured; +pub mod carry; pub mod checks_green; pub mod ci; pub mod claim; @@ -259,7 +261,7 @@ pub fn run(cli: Cli, mode: Mode, out: &mut dyn Write, err: &mut dyn Write) -> Re // The green verdict (CLOUD-1143). Reads a reading, never the network: // the fetch stays with the poller that already holds the body. Some(Command::Checks { command }) => run_checks(command, out, err), - Some(Command::Pr { command }) => run_pr(command, out, err), + Some(Command::Pr { command }) => run_pr(command, &overrides, mode, out, err), // The ledger is a committed file the consumer declares; the §8 config // chain supplies its path and taxonomy and nothing else layers. Some(Command::Defects { command }) => match command { @@ -2164,18 +2166,42 @@ fn run_receipt( } } -fn run_pr(command: PrCommand, out: &mut dyn Write, err: &mut dyn Write) -> Result { - let PrCommand::Watch { - sha, - repo, - interval, - progress, - progress_id, - required, - absent_ok, - answered, - fanin, - } = command; +fn run_pr( + command: PrCommand, + overrides: &Overrides, + mode: Mode, + out: &mut dyn Write, + err: &mut dyn Write, +) -> Result { + let (sha, repo, interval, progress, progress_id, required, absent_ok, answered, fanin) = + match command { + PrCommand::Derive { pr } => return run_pr_derive(&pr, overrides, out), + PrCommand::File { pr } => return run_pr_file(&pr, overrides, mode, out), + PrCommand::Link { pr, key } => return run_pr_link(&pr, &key, overrides, mode, err), + PrCommand::Ensure { pr } => return run_pr_ensure(&pr, overrides, mode, err), + PrCommand::Closes { pr } => return run_pr_closes(&pr, overrides, mode, err), + PrCommand::Watch { + sha, + repo, + interval, + progress, + progress_id, + required, + absent_ok, + answered, + fanin, + } => ( + sha, + repo, + interval, + progress, + progress_id, + required, + absent_ok, + answered, + fanin, + ), + }; // A NUMBER OR A REFUSAL, never a silent fallback. An interval that did not // parse is a typo in an invocation, and swallowing it would put the poll on @@ -2226,6 +2252,413 @@ fn run_pr(command: PrCommand, out: &mut dyn Write, err: &mut dyn Write) -> Resul pr_watch::watch(&config, &roster, out, err) } +/// The bot lane this repository declares, or a refusal naming what is missing. +/// +/// Absent is a USAGE ERROR rather than a silent skip: a lane assembled from +/// engine defaults would file a row asserting a bump nobody configured, which is +/// the class this whole surface exists to refuse. +fn bot_lane(overrides: &Overrides) -> Result { + let config = resolve::resolve(Path::new("."), overrides)?; + config.bot_lane.ok_or_else(|| { + UsageError::raise( + "bot lane: this repository declares no [bot_lane] table, so there is no lane to file \ + for — which is a different claim from a lane that owns nothing" + .to_owned(), + ) + }) +} + +/// The candidate row a bot's pull request implies, or a refusal. +/// +/// Refuses rather than inventing, which is the whole posture: a PR opened by +/// somebody the lane does not know, or whose diff touches no manifest it owns, +/// gets no row. The alternative is a tracker row asserting a bump nobody +/// proposed. +fn derive_row(lane: &bot::BotLane, number: &str) -> Result<(bot::Pull, String, String)> { + let pull = bot::forge::pull(&lane.repo, number)?; + if !bot::is_lane_bot(&pull.login, &lane.bots) { + return Err(Denial::raise(format!( + "pr derive: #{number} was opened by '{}', which is not a bot this lane files for — an \ + agent's pull request carries its own claim receipt and its own issue", + pull.login + ))); + } + let files = bot::forge::files(&lane.repo, number)?; + let owned = bot::owned(&files, &lane.owned_manifests)?; + if owned.is_empty() { + // Pointer-only: the paths, never their contents. + return Err(Denial::raise(format!( + "pr derive: #{number} touches no manifest this lane owns, so there is no bump to \ + describe: {} — filing a row here would assert a change nobody proposed", + files.join(" ") + ))); + } + // READ from the subject rather than chosen: the bot's own config already + // decided it. A subject with no prefix is a lane defect, and the commit gate + // would refuse it anyway, so this says so instead of inventing a type. + let Some(kind) = bot::conventional_type(&pull.title) else { + return Err(Denial::raise(format!( + "pr derive: #{number}'s subject carries no Conventional type, so the commit gate would \ + refuse it and it could never land — fix the bot's configured type rather than filing \ + a row for a commit that cannot merge" + ))); + }; + let repo_root = git::repo_root(Path::new("."))?; + let template = std::fs::read_to_string(repo_root.join(&lane.body_template)).map_err(|err| { + UsageError::raise(format!( + "bot lane: cannot read body_template {}: {err}", + lane.body_template + )) + })?; + let manifests = owned + .iter() + .map(|path| format!("- `{path}`")) + .collect::>() + .join("\n"); + let body = bot::render( + &template, + &[ + ("pr", number.to_owned()), + ("branch", pull.head.clone()), + ("login", pull.login.clone()), + ("manifests", manifests), + ("type", kind.to_owned()), + ], + )?; + let title = pull.title.clone(); + Ok((pull, title, body)) +} + +/// `batten pr derive`: the candidate payload, written nowhere. +/// +/// The shape is the tracker's own `get_issue` answer, and that is the point: the +/// refinement gate reads it unchanged, so the derived Ready block is checkable by +/// the same gate that checks a human's — which is what keeps "derived" from +/// meaning "exempt". +fn run_pr_derive(number: &str, overrides: &Overrides, out: &mut dyn Write) -> Result { + let lane = bot_lane(overrides)?; + // A lane refusal travels as a `Denial`, which the boundary renders and maps to + // the verdict code — so the refusal path is not caught here. Catching it would + // put a forge that could not be reached and a pull request the lane declines + // to file on the same exit code, and those are different claims. + let (_, title, body) = derive_row(&lane, number)?; + let payload = serde_json::json!({ + "id": "CLOUD-NEW", + "status": "Todo", + "title": title, + "description": body, + "pr": number, + "relations": { "blocks": [], "blockedBy": [], "relatedTo": [] }, + }); + // One encoding, unconditionally: the surface row above declares no `-J` for + // this verb, so there is no second form for a rung to select. + writeln!(out, "{}", serde_json::to_string_pretty(&payload)?)?; + Ok(ExitCode::Success) +} + +/// `batten pr file`: open the mirror issue, and report its number. +/// +/// THE ROW IS FILED AS A FORGE ISSUE AND THE TRACKER MIRRORS IT (CLOUD-750). The +/// alternative — calling the tracker's API — costs a credential this repository +/// does not hold, and would be the only place in the tree holding one. +/// +/// It never CLOSES the mirror, which would move the row to Done: Done means +/// released, so closing would assert a release that has not happened. The pull +/// request closes the tracker key instead, and the merge moves the row exactly as +/// it does for an agent's. +fn run_pr_file( + number: &str, + overrides: &Overrides, + mode: Mode, + out: &mut dyn Write, +) -> Result { + let lane = bot_lane(overrides)?; + let (_, title, body) = derive_row(&lane, number)?; + let issue = file_mirror(&lane, number, &title, &body)?; + output::message( + mode, + Verbosity::Normal, + out, + &format!("pr file: #{number} -> issue #{issue}"), + )?; + Ok(ExitCode::Success) +} + +/// Open the mirror and answer its number, with the marker appended. +/// +/// The marker goes LAST, after the derived block, so it is the one line a reader +/// never has to look at and the one line `ensure` always finds. +fn file_mirror(lane: &bot::BotLane, number: &str, title: &str, body: &str) -> Result { + let marked = format!("{body}\n\n\n", lane.marker_prefix); + bot::forge::open_issue(&lane.repo, title, &marked) +} + +/// `batten pr link`: write the closing key into the pull request's body. +/// +/// APPENDED rather than templated in, because the bot rewrites its own body on +/// every rebase and an append survives being reconstructed around. +fn run_pr_link( + number: &str, + key: &str, + overrides: &Overrides, + mode: Mode, + err: &mut dyn Write, +) -> Result { + let lane = bot_lane(overrides)?; + link(&lane, number, key, mode, err) +} + +/// The body rewrite, shared by `pr link` and `pr ensure`. +fn link( + lane: &bot::BotLane, + number: &str, + key: &str, + mode: Mode, + err: &mut dyn Write, +) -> Result { + let pull = bot::forge::pull(&lane.repo, number)?; + let closing = format!("Closes {key}"); + if pull.body.contains(&closing) { + output::message( + mode, + Verbosity::Normal, + err, + &format!("pr link: #{number} already closes {key}"), + )?; + return Ok(ExitCode::Success); + } + bot::forge::set_body( + &lane.repo, + number, + &format!("{}\n\n---\n\n{closing}\n", pull.body), + )?; + output::message( + mode, + Verbosity::Normal, + err, + &format!("pr link: #{number} now closes {key}"), + )?; + Ok(ExitCode::Success) +} + +/// `batten pr ensure`: the lander's call — file the row and link it. +/// +/// TWO PHASES, BECAUSE THE KEY ARRIVES ASYNCHRONOUSLY. Filing the issue and +/// learning its key are separated by however long the tracker's sync takes, and +/// nothing here may depend on that. So a tick does as much as it can and says +/// what it did; the lander ticks repeatedly and every step is idempotent. +/// +/// THAT IS ALSO WHY THIS DOES NOT POLL. A wall-clock wait inside the job would be +/// a guess about somebody else's latency dressed as a mechanism. A tick that +/// cannot finish returns `0` having made progress, and the next one finishes. +fn run_pr_ensure( + number: &str, + overrides: &Overrides, + mode: Mode, + err: &mut dyn Write, +) -> Result { + let lane = bot_lane(overrides)?; + let pull = bot::forge::pull(&lane.repo, number)?; + // A body that names any key is done, and nothing is filed. The key travels in + // the body rather than in a local record because the body is what the merge + // reads — a record this side could go missing and file a second row against a + // pull request that already has one. + if let Some(existing) = bot::named_key(&pull.body, &lane.key_prefix) { + output::message( + mode, + Verbosity::Normal, + err, + &format!("pr ensure: #{number} already names {existing}; nothing filed"), + )?; + return Ok(ExitCode::Success); + } + let existing = bot::forge::mirror(&lane.repo, number, &lane.marker_prefix)?; + let issue = if let Some(issue) = existing { + issue + } else { + // `derive_row` refuses a pull request that is not this lane's before + // anything is written, which is what keeps a refusal from leaving a + // half-filed row. + let (_, title, body) = derive_row(&lane, number)?; + let filed = file_mirror(&lane, number, &title, &body)?; + output::message( + mode, + Verbosity::Normal, + err, + &format!( + "pr ensure: #{number} -> issue #{filed} filed; waiting for the tracker to mirror \ + it" + ), + )?; + filed + }; + let comment = bot::forge::linkback(&lane.repo, &issue, &lane.linkback_marker)?; + let Some(key) = bot::named_key(&comment, &lane.key_prefix) else { + output::message( + mode, + Verbosity::Normal, + err, + &format!("pr ensure: issue #{issue} is not mirrored yet; the next tick links it"), + )?; + return Ok(ExitCode::Success); + }; + let code = link(&lane, number, &key, mode, err)?; + output::message( + mode, + Verbosity::Normal, + err, + &format!("pr ensure: #{number} -> {key} (via issue #{issue})"), + )?; + Ok(code) +} + +/// `batten pr closes`: does the body STILL close a key? +/// +/// `link` writes the closing key and NOTHING KEEPS IT THERE — a bot regenerates +/// its own body on every rebase and the append goes with it. The lane is nearly +/// right by ordering alone, since `ensure` runs first on each tick, but +/// "normally" is not a gate and the failure inside that window is silent: the +/// fast-forward succeeds, the bump ships, and the row sits in the backlog with +/// nobody looking at it. So the landing asks once more, against the forge rather +/// than against anything it read a step earlier. +/// +/// REFUSING IS AN ORDINARY OUTCOME. The next tick re-runs `ensure`, the key comes +/// back, and it lands then. Nothing is lost but the interval. +fn run_pr_closes( + number: &str, + overrides: &Overrides, + mode: Mode, + err: &mut dyn Write, +) -> Result { + let lane = bot_lane(overrides)?; + let pull = bot::forge::pull(&lane.repo, number)?; + let Some(key) = bot::closing_key(&pull.body, &lane.key_prefix) else { + // Pointer-only: the number, never the body — a bot pull request carries a + // release-notes dump, and echoing it would put that in every landing's log. + output::verdict( + err, + &format!( + "pr closes: #{number}'s body closes no tracker key, so merging it would move \ + nothing — not landing; the next tick re-links it" + ), + )?; + return Ok(ExitCode::Violation); + }; + output::message( + mode, + Verbosity::Normal, + err, + &format!("pr closes: #{number} closes {key}"), + )?; + Ok(ExitCode::Success) +} + +/// One `claim bot` refusal: the verdict on stderr and the code that goes with it. +/// +/// A function rather than a closure, because a closure capturing `err` holds the +/// mutable borrow for the whole body and the four refusal sites are spread +/// through it. +fn refuse_claim_bot(err: &mut dyn Write, text: &str) -> Result { + output::verdict(err, text)?; + Ok(ExitCode::Violation) +} + +/// `batten claim bot`: attest a bot branch from the lane's public facts. +/// +/// THE SECOND RECEIPT KIND, AND IT IS SECOND BECAUSE THE TWO ATTEST DIFFERENT +/// THINGS (CLOUD-693, CLOUD-431). `claim check` mints `claim.`, whose +/// whole content is "a human or agent read this issue, checked it for a +/// competitor, and confirmed the refinement predates this session". Nothing on a +/// bot branch can honestly say that: there was no session, and the row was +/// derived rather than refined. Widening the agent receipt to cover bots would +/// make it mean less everywhere. +/// +/// 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. +fn run_claim_bot( + repo: &Path, + mode: Mode, + overrides: &Overrides, + out: &mut dyn Write, + err: &mut dyn Write, +) -> Result { + let lane = bot_lane(overrides)?; + let Some(branch) = git::current_branch(repo)? else { + return Err(UsageError::raise( + "claim bot: a detached HEAD carries no branch to key a receipt to — check the bot \ + branch out by name" + .to_owned(), + )); + }; + if !branch.starts_with(&lane.branch_prefix) { + return refuse_claim_bot( + err, + &format!( + "claim bot: {branch} is not a bot branch, so the agent claim receipt is the one \ + that applies here: run `batten claim check` with the issue's payload on stdin" + ), + ); + } + let Some(number) = bot::forge::open_for(&lane.repo, &branch)? else { + return refuse_claim_bot( + err, + &format!( + "claim bot: no open pull request for {branch} — the receipt attests to facts \ + about a pull request, so there is nothing to attest" + ), + ); + }; + let pull = bot::forge::pull(&lane.repo, &number)?; + if !bot::is_lane_bot(&pull.login, &lane.bots) { + return refuse_claim_bot( + err, + &format!( + "claim bot: #{number} was opened by '{}', not by a bot this lane knows", + pull.login + ), + ); + } + // The same derivation `pr derive` performs, for its refusals rather than its + // payload: the receipt asserts the diff touches only manifests the lane owns, + // and that is the check that decides it. + derive_row(&lane, &number)?; + let Some(key) = bot::named_key(&pull.body, &lane.key_prefix) else { + return refuse_claim_bot( + err, + &format!( + "claim bot: #{number}'s body names no tracker row yet — run `batten pr ensure \ + {number}` first, or wait for the lander's next tick" + ), + ); + }; + let attested = bot::Attested { + key, + login: pull.login, + pr: number, + }; + let receipts = git::git_dir(repo)?.join("batten-receipts"); + let base = git::resolve_ref(repo, "origin/main").ok().flatten(); + bot::mint( + &receipts, + &branch, + &attested, + base.as_deref(), + &receipt::rfc3339_utc(now_unix()), + )?; + output::message( + mode, + Verbosity::Normal, + out, + &format!( + "claim bot: {branch} attested — opened by {}, manifests owned, row {}. `verify` \ + accepts this in place of a claim receipt.", + attested.login, attested.key + ), + )?; + Ok(ExitCode::Success) +} + /// Render findings as the pointer coordinate the predecessor emitted. fn render_findings(findings: &[checks_green::Finding]) -> String { findings @@ -2249,7 +2682,13 @@ fn render_findings(findings: &[checks_green::Finding]) -> String { /// module exists to avoid. fn board_grammar(overrides: &Overrides) -> Result { let config = resolve::resolve(Path::new("."), overrides)?; - ready::Grammar::resolve(&config.patterns) + Ok( + ready::Grammar::resolve(&config.patterns)?.with_prose_threshold( + config + .ready + .and_then(|ready| ready.prose_dialect_required_from), + ), + ) } fn run_claim( @@ -2287,6 +2726,8 @@ fn run_claim( err, ) } + ClaimCommand::Bot => run_claim_bot(Path::new("."), mode, overrides, out, err), + ClaimCommand::Carry { json } => run_claim_carry(Path::new("."), mode, json, out, err), } } @@ -2577,6 +3018,122 @@ fn run_claim_check( Ok(ExitCode::Success) } +/// The `-J` document `claim carry` emits, on either arm. +/// +/// One shape for both answers rather than a list on one and an object on the +/// other: a parser that has to branch on the document's TYPE to learn the verdict +/// is reading the exit code twice, and the second reading can disagree. +/// +/// Pointer-only per non-negotiable rule 4: a branch, a count, and the refusal id +/// with whatever pointer it carries. Never a licence and never a holder — those +/// are the bytes the table exists to hold. +fn carry_document(branch: &str, carried: usize, refusal: Option<&str>) -> Result { + Ok(serde_json::to_string_pretty(&serde_json::json!({ + "branch": branch, + "carried": carried, + "refusals": refusal.map_or_else(Vec::new, |line| vec![line]), + }))?) +} + +/// `batten claim carry`: does this branch only carry licence rows forward? +/// +/// # Why it takes no argument +/// +/// The subject is the branch's own diff against its merge base. A caller that +/// could name its own subject could name one that is derivable while changing +/// something else, which is the whole property being attested. +/// +/// # Errors +/// +/// [`UsageError`] when there is no branch to key a receipt to, or when the merge +/// base or the table cannot be read — could-not-look, never a silent pass. +fn run_claim_carry( + repo: &Path, + mode: Mode, + json: bool, + out: &mut dyn Write, + err: &mut dyn Write, +) -> Result { + let Some(branch) = git::current_branch(repo)? else { + return Err(UsageError::raise( + "claim carry: a detached HEAD carries no branch to key a receipt to".to_owned(), + )); + }; + let Some(base) = git::merge_base(repo, "origin/main")? else { + return Err(UsageError::raise( + "claim carry: no merge base with origin/main, so there is nothing to carry against" + .to_owned(), + )); + }; + + // The table on each side. An absent base copy reads as empty, which the + // predicate then refuses as `no-prior-row` rather than admitting a first row + // that vouches for itself. + let before = match git::read_at(repo, &base, carry::TABLE)? { + git::BaseBlob::Found { text, .. } => text, + git::BaseBlob::AbsentAtRef { .. } | git::BaseBlob::RefUnreachable { .. } => String::new(), + }; + let after = std::fs::read_to_string(repo.join(carry::TABLE)).unwrap_or_default(); + + // Every OTHER path this branch moved. `writes_in_range` answers per commit + // over declared globs, so `**` is the whole tree and the table is filtered out + // here — one differ, rather than a second opinion about what changed. + let mut other: Vec = Vec::new(); + for write in git::writes_in_range(repo, &base, "HEAD", &["**".to_owned()])? { + for path in write.paths { + if path != carry::TABLE && !other.contains(&path) { + other.push(path); + } + } + } + other.sort(); + + match carry::judge(&before, &after, &other) { + Ok(carried) => { + let receipts = git::git_dir(repo)?.join("batten-receipts"); + let at = receipt::rfc3339_utc( + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_or(0, |since| since.as_secs()), + ); + carry::mint(&receipts, &branch, carried, Some(base.as_str()), &at)?; + if json { + writeln!(out, "{}", carry_document(&branch, carried, None)?)?; + } else { + // On STDOUT and gated on `-J`, for `claim check`'s reason one + // function up: stdout is one document under the data channel, and a + // summary on stderr is progress — which the output contract admits + // only when a rung asked for it. + output::message( + mode, + Verbosity::Normal, + out, + &format!( + "claim carry: {branch} carries {carried} licence row(s) forward and \ + nothing else — `verify` accepts this in place of a claim receipt." + ), + )?; + } + Ok(ExitCode::Success) + } + Err(refusal) => { + let line = refusal.line(); + if json { + writeln!(out, "{}", carry_document(&branch, 0, Some(&line))?)?; + } else { + writeln!(out, "{line}")?; + } + output::verdict( + err, + "claim carry: this branch is not a licence carry, so no receipt is minted. A \ + carry appends rows whose repo the base table already maps, changing only the \ + sha, and touches nothing else.", + )?; + Ok(ExitCode::Violation) + } + } +} + /// Re-key a stranded claim receipt onto this branch. fn run_claim_adopt( repo: &Path, diff --git a/crates/batten/src/ready.rs b/crates/batten/src/ready.rs index e1f13f040..e9dae2772 100644 --- a/crates/batten/src/ready.rs +++ b/crates/batten/src/ready.rs @@ -94,6 +94,11 @@ pub struct Payload { pub description: String, /// Whether the payload carried a `relations` key at all. pub relations_present: bool, + /// When the tracker says the row was created, verbatim, or `None` where the + /// payload carried none. Never parsed into a date type: it is compared + /// against another fixed-width ISO-8601 UTC stamp, so lexical order is + /// chronological order and a parser would only add a way to disagree. + pub created_at: Option, /// The `blockedBy` edges, for the §8 cross-check. pub blocked_by: Vec, /// Every edge in any direction, for the deferral cross-check. A deferral is @@ -149,6 +154,10 @@ impl Payload { .and_then(serde_json::Value::as_str) .unwrap_or("?") .to_owned(), + created_at: value + .get("createdAt") + .and_then(serde_json::Value::as_str) + .map(str::to_owned), description, relations_present, blocked_by, @@ -257,6 +266,33 @@ pub struct Grammar { unanchored_clause: Regex, open_questions: Regex, legacy_clause_notation: Regex, + /// From which creation instant a Ready block must carry the claims object + /// rather than prose (CLOUD-472). `None` is could-not-look and exempts + /// everything. + /// + /// # Two wrong shapes preceded this, and the second is the instructive one + /// + /// It was first a `[[pattern]]` row spelling the exempt range as a regex over + /// the key. That is wrong twice: the registry gives one CONCEPT one spelling + /// and arithmetic is not a concept, and it decides on key TEXT, which this + /// consumer already declares `ready-issue-mention-markup` for because the + /// tracker rewrites a bare key into `` markup on the round trip. + /// + /// It was then a key ORDINAL — the trailing digits, no separator assumed, so + /// no consumer literal reached the crate. That passes + /// `no-tracker-key-in-core` and is still a consumer assumption smuggled in: + /// it requires keys that are numeric AND monotonic with creation order. Three + /// popular trackers satisfy that and a slug- or UUID-keyed one does not — and + /// it would fail SILENTLY there, resolving `None` and never ratcheting, which + /// is the dead-gate shape this module exists to avoid. + /// + /// **A creation instant assumes nothing.** Every tracker stamps one, the + /// payload already carries it, and `policy/filed-here.rego`'s + /// `predates_the_branch` already compares tracker timestamps this way with + /// the reasoning written out: both sides are fixed-width ISO-8601 UTC, so + /// lexical order IS chronological order. Moving it later is the only + /// direction that tightens, which makes it a ratchet rather than a switch. + prose_dialect_required_from: Option, bump_label: Regex, commit_type: Regex, bump_token: Regex, @@ -273,6 +309,7 @@ pub struct Grammar { relatedto_tail: Regex, defer_verb: Regex, key: Regex, + closing_verb: Regex, mention_markup: Regex, } @@ -308,6 +345,7 @@ pub const REQUIRED_PATTERNS: &[&str] = &[ "ready-relatedto-tail", "ready-defer-verb", "ready-issue-key", + "ready-closing-verb", "ready-issue-mention-markup", ]; @@ -352,6 +390,20 @@ impl Grammar { }) } + /// Apply the consumer's prose-dialect threshold (CLOUD-472). + /// + /// Separate from [`Self::assemble`] because it is not a `[[pattern]]` and + /// must not become one: the registry holds concepts with one spelling, and a + /// number is neither. Absent on [`Self::from_compiled`]'s path by design — + /// the recorder resolves a grammar to answer an `{authority:…}` column and + /// has no consumer config in hand, so it gets could-not-look rather than a + /// threshold guessed from somewhere else. + #[must_use] + pub fn with_prose_threshold(mut self, from: Option) -> Self { + self.prose_dialect_required_from = from; + self + } + /// A row the consumer's table does not declare. /// /// **Could-not-look, and it says so** — a clause whose anchor has no @@ -391,7 +443,9 @@ impl Grammar { blocks_tail: find("ready-blocks-tail")?, relatedto_tail: find("ready-relatedto-tail")?, defer_verb: find("ready-defer-verb")?, + prose_dialect_required_from: None, key: find("ready-issue-key")?, + closing_verb: find("ready-closing-verb")?, mention_markup: find("ready-issue-mention-markup")?, }) } @@ -477,21 +531,167 @@ fn compiled(pattern: &str) -> Regex { }) } -/// The issue keys in a span, deduped and ordered NUMERICALLY. +/// THE ONE DEFINITION OF AN ISSUE KEY (CLOUD-1142). /// -/// Numeric and not a bare sort, for `graph-check`'s reason: `CLOUD-10` sorts -/// before `CLOUD-9` lexically, so a caller diffing two runs could not tell an -/// ordering change from a content one. -fn keys_in(grammar: &Grammar, text: &str) -> Vec { - let found: BTreeSet<&str> = grammar.key.find_iter(text).map(|m| m.as_str()).collect(); - let mut keys: Vec = found.into_iter().map(str::to_owned).collect(); - keys.sort_by_key(|k| { - k.rsplit('-') +/// # Why the grammar is here and the vocabulary is not +/// +/// CLOUD-761 measured twenty independent derivations of the key pattern across +/// nine spellings, diverged on three axes, with a shipped defect behind them: a +/// body writing the lowercase form is accepted by one gate and invisible to two +/// others. This is the definition those sites are meant to converge on. +/// +/// **The token itself is never written here.** It is the consumer's, read from +/// the `[[pattern]]` registry as [`Grammar::key`], and `no-tracker-key-in-core` +/// refuses a derivation of it anywhere under `crates/**` — the mechanism that +/// exists because CLOUD-1121 carried the literal in as a `const` and passed every +/// gate. So this module owns the three AXES and the consumer owns the TOKEN, and +/// the split is what keeps one definition compatible with rule 1. +/// +/// # The three axes, decided by CLOUD-761 and built here +/// +/// **Case: sensitive.** Nothing here folds case. The consumer's row carries no +/// `(?i)`, so the lowercase spelling is not a key and is refused rather than +/// normalised — normalising up is precisely what produced the shipped defect. +/// +/// **Boundary: the surrounding bytes, checked rather than composed.** The stated +/// form is `(^|[^0-9A-Za-z-])…([^0-9]|$)`, and this does not build it as a +/// regex — `regex` has no lookahead, so a trailing class would CONSUME the byte +/// after a match and make two adjacent keys unfindable. Reading the bytes on +/// either side of a match answers the same question, and it means the crate +/// composes no key expression at all: there is nothing here for a twenty-first +/// derivation to be a copy OF. +/// +/// **Project prefix: mandatory.** Inherited from the consumer's row rather than +/// asserted here. The four shell `case` globs this replaces accept `AB-1`, `Z-9` +/// and `A-1foo` because a glob cannot anchor; [`Grammar::key_of`] anchors by +/// requiring the match to span the whole input, which no glob can express. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +pub struct IssueKey(String); + +impl IssueKey { + /// The key as the consumer wrote it. + #[must_use] + pub fn as_str(&self) -> &str { + &self.0 + } + + /// The trailing number, for the numeric ordering [`Grammar::keys_in`] keeps. + fn number(&self) -> u64 { + self.0 + .rsplit('-') .next() .and_then(|n| n.parse::().ok()) .unwrap_or(0) - }); - keys + } +} + +impl std::fmt::Display for IssueKey { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.0) + } +} + +/// Is the byte before a match a boundary — i.e. NOT one a key could continue +/// through? +/// +/// `-` is in the class deliberately, and that is the axis `\b` cannot express: a +/// word boundary treats `-` as a separator, so `\b` would find a key inside a +/// longer hyphenated token. Two landed sites disagreed on exactly this while both +/// looking correct. +fn opens_a_key(text: &str, at: usize) -> bool { + text[..at] + .chars() + .next_back() + .is_none_or(|c| !c.is_ascii_alphanumeric() && c != '-') +} + +/// Is the byte after a match a boundary? +/// +/// Digits only, per the decided form. A letter may follow — `CLOUD-1x` contains +/// the key `CLOUD-1` — which is why [`Grammar::key_of`] asks a different +/// question than this one rather than reusing it. +fn closes_a_key(text: &str, at: usize) -> bool { + text[at..] + .chars() + .next() + .is_none_or(|c| !c.is_ascii_digit()) +} + +impl Grammar { + /// Is this WHOLE string a key? The four shell `case` globs' question. + /// + /// Anchored by construction: the match must begin at 0 and end at the input's + /// length, so `CLOUD-1x` is not a key even though it contains one. A glob + /// cannot say this, which is why those sites accept `AB-1` and `A-1foo` + /// today. + #[must_use] + pub fn key_of(&self, text: &str) -> Option { + let found = self.key.find(text)?; + (found.start() == 0 && found.end() == text.len()) + .then(|| IssueKey(found.as_str().to_owned())) + } + + /// The issue keys in a span, deduped and ordered NUMERICALLY. + /// + /// Numeric and not a bare sort, for `graph-check`'s reason: `CLOUD-10` sorts + /// before `CLOUD-9` lexically, so a caller diffing two runs could not tell an + /// ordering change from a content one. + /// + /// The boundary check is what stops a key being found inside a longer token. + /// A greedy match already prevents the reverse case the two landed sites + /// commented on — `CLOUD-17` is not returned for `CLOUD-179`, because the + /// match IS `CLOUD-179` — so a caller asking "does this text carry key K" + /// compares against this set rather than searching again. That is the third + /// derivation this definition removes rather than adds. + #[must_use] + pub fn keys_in(&self, text: &str) -> Vec { + let found: BTreeSet<&str> = self + .key + .find_iter(text) + .filter(|m| opens_a_key(text, m.start()) && closes_a_key(text, m.end())) + .map(|m| m.as_str()) + .collect(); + let mut keys: Vec = found.into_iter().map(|k| IssueKey(k.to_owned())).collect(); + keys.sort_by_key(IssueKey::number); + keys + } + + /// 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 + /// the defect this narrows** (CLOUD-674): a body citing a row as evidence is + /// not claiming it, and `claimed-keys` already learned that distinction the + /// expensive way. So this is [`Self::keys_in`] filtered by what precedes each + /// match rather than a second search — the same one definition of a key, asked + /// a narrower question. + /// + /// The verb set is the forge's rather than this repository's, and it lives in + /// the pattern registry for the reason every other token does: one concept, + /// one spelling. Anchored at the END, so it decides the text immediately + /// before the key and nothing further back. + #[must_use] + pub fn keys_closed_in(&self, text: &str) -> Vec { + let found: BTreeSet<&str> = self + .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()])) + .map(|m| m.as_str()) + .collect(); + let mut keys: Vec = found.into_iter().map(|k| IssueKey(k.to_owned())).collect(); + keys.sort_by_key(IssueKey::number); + keys + } +} + +/// The key strings in a span, for the callers inside this module that still want +/// them as text. +fn keys_in(grammar: &Grammar, text: &str) -> Vec { + grammar + .keys_in(text) + .into_iter() + .map(|k| k.as_str().to_owned()) + .collect() } /// One emitted derived fact: a label and its key set. @@ -690,15 +890,61 @@ pub fn lint(grammar: &Grammar, payload: &Payload, root: &Path) -> Result // can adjudicate. let structured = check_claims(grammar, payload, root, &block, ready_start, &mut report)?; - // THE DIALECT, AS A FACT RATHER THAN A VERDICT. A prose-only block still - // PASSES — every issue Ready today stays Ready, which is what lets the - // corpus converge deliberately instead of in one sweep — and is named, so a - // caller can find the ones still to convert without re-reading any body. + // THE DIALECT, AS A FACT. Named so a caller can find the blocks still to + // convert without re-reading any body — and it is the sensor the ratchet + // below reads, rather than a second derivation of the same question. report.emissions.push(format!( "dialect {}", if structured { "json" } else { "prose" } )); + // THE PROSE DIALECT IS A LEGACY, NOT AN ALTERNATIVE (CLOUD-472). + // + // This clause used to say a prose-only block "still PASSES — every issue + // Ready today stays Ready, which is what lets the corpus converge + // deliberately instead of in one sweep". The first half is still true below + // the threshold. The second half was left to intent, **and intent did not + // converge it**: measured 2026-09-01 over the 50-row Todo queue, the object + // was used by nothing, and CLOUD-1306 — filed that day — carried a §7 naming + // three obligations in prose, none of them joinable to anything. A sensor + // with no ratchet on it reports a defect forever. + // + // WHY THE OBJECT IS THE THING BEING DEMANDED, rather than a new grammar: + // `REQUIRED_CLAIMS` already forces `tests`, and `check_claimed_tests` + // already forces `file` AND `mutation` on every entry — CLOUD-418's + // obligation as a field, where an entry that cannot name the mutation which + // would kill it cannot be written. That mechanism landed and was simply + // unreachable, because `check_claims` returns `false` on an absent fence and + // the caller falls back here. + // + // A RATCHET RATHER THAN A FLIP, and the cost is why. `graph-check` enforces + // `Todo ⇒ ready-lint exits 0`, so refusing every prose block at once takes + // the board's whole ready frontier dark in one step — CLOUD-858's measured + // shape, where three rows did exactly that. + // + // COULD-NOT-LOOK PASSES, TWICE OVER, and both are the same posture. A + // consumer that declares no cutover has not asked for the ratchet, and a + // payload carrying no creation instant cannot be placed against one — so each + // leaves the row judged exactly as it was before this clause existed. + // Reading either as "past the cutover" would turn a verdict about the + // environment into a verdict about the row. + // + // Both sides are fixed-width ISO-8601 UTC as the tracker stamps them, so a + // lexical comparison IS a chronological one — the same reading, and the same + // reasoning, as `policy/filed-here.rego`'s `predates_the_branch`. + if !structured + && let Some(from) = grammar.prose_dialect_required_from.as_deref() + && payload + .created_at + .as_deref() + .is_some_and(|created| created >= from) + { + report.findings.push(Finding { + line: ready_start, + rule: "claims-object-absent".to_owned(), + }); + } + if !structured { check_bump(grammar, root, &block_lines, &line_of, &mut report)?; } @@ -937,6 +1183,36 @@ fn check_claims( } } + // THE OBLIGATION SET, EMITTED BEFORE ANY VERDICT (CLOUD-472). + // + // Position is correctness, exactly as it is for `cites-body`: a recorder + // reads this line to carry the declared obligations into the tree, where a + // `verify`-time rule can ask whether each one is bound to a case. Emitting + // it after a refusal would make the set unavailable for precisely the rows + // most likely to carry a broken one, and a consumer would read that absence + // as could-not-look over a block that was read perfectly well. + // + // `:` per entry, space separated. An EMPTY line is the honest + // zero — the object was read and declares no obligations — while no line at + // all is a prose block, or a payload this verb never got through, which + // `Read::StdoutLine` keeps apart by construction. + report.emissions.push(format!( + "obligations {}", + claims + .get("tests") + .and_then(serde_json::Value::as_array) + .map(|tests| tests + .iter() + .filter_map(|entry| { + let file = entry.get("file").and_then(serde_json::Value::as_str)?; + let mutation = entry.get("mutation").and_then(serde_json::Value::as_str)?; + Some(format!("{file}:{mutation}")) + }) + .collect::>() + .join(" ")) + .unwrap_or_default() + )); + check_claimed_gate(&claims, block_line, report); check_claimed_type(&claims, root, block_line, report)?; check_claimed_blockers(grammar, payload, &claims, block_line, report); @@ -1115,6 +1391,29 @@ fn check_claimed_tests(claims: &serde_json::Value, line: usize, report: &mut Rep }); } } + // `mutation` NAMES A DECLARED `#MUTANT` SLUG (CLOUD-472). The field + // landed under CLOUD-418 as prose describing the mutation that would + // kill the case — which is a better claim than nothing and is still not + // joinable to anything. A slug is: `batten mutate` resolves it, applies + // the expression, runs the named case, and a SURVIVOR is the finding. So + // "pressure tested" stops being an assertion and becomes an exit code. + // + // SHAPE HERE, RESOLUTION AT `verify`. At refinement time the case does + // not exist yet — refusing an unresolvable slug here is the false-failure + // trap this row's own §3 names — so this checks only that the field is a + // TOKEN rather than a sentence. A slug carries no whitespace, which is + // the whole discriminator and is what `mutate`'s own three-field row + // format already requires of it. + let prose = entry + .get("mutation") + .and_then(serde_json::Value::as_str) + .is_some_and(|text| text.split_whitespace().count() > 1); + if prose { + report.findings.push(Finding { + line, + rule: "test-claim-mutation-not-a-slug".to_owned(), + }); + } } } @@ -1347,3 +1646,113 @@ pub fn verdict_token( _ => None, } } + +// CLOUD-1142's fixed example set, driven against the grammar this repository +// COMMITS rather than a fixture — `Grammar::committed`'s own reason: a fixture +// would let the registry row change while every case here kept passing, which is +// the drift one definition exists to remove. +// +// The examples are the row's, written down there rather than left to the +// implementer, and each one is a site that behaves differently today. +#[cfg(test)] +mod issue_key_tests { + use super::Grammar; + + /// The consumer's own key, spelled from parts so this file carries no + /// derivation of the token — `no-tracker-key-in-core` refuses one anywhere + /// under `crates/**`, and a test is not exempt from the rule it is testing. + fn key(n: u32) -> String { + format!("{}-{n}", "CL".to_owned() + "OUD") + } + + #[test] + fn the_consumers_own_key_is_a_key() { + // The positive arm first: without it every refusal below is satisfied by + // a definition that refuses everything. + let grammar = Grammar::committed(); + let subject = key(757); + assert_eq!( + grammar.key_of(&subject).map(|k| k.as_str().to_owned()), + Some(subject.clone()), + "the committed vocabulary's own key must parse" + ); + } + + #[test] + fn the_lowercase_spelling_is_not_a_key() { + // CASE: SENSITIVE. The shipped defect CLOUD-761 measured — one gate + // accepts this spelling and two others cannot find it. Refused rather + // than normalised, because normalising up produced the disagreement. + let grammar = Grammar::committed(); + assert_eq!(grammar.key_of(&key(757).to_lowercase()), None); + } + + #[test] + fn a_glob_shaped_near_miss_is_not_a_key() { + // PROJECT PREFIX: MANDATORY. All three are accepted today by the four + // shell `case` globs, which test `[A-Z]*-[0-9]*` and cannot anchor. + let grammar = Grammar::committed(); + for subject in ["AB-1", "Z-9", "A-1foo"] { + assert_eq!(grammar.key_of(subject), None, "{subject} is not a key"); + } + } + + #[test] + fn a_key_with_a_trailing_letter_is_not_a_key_but_contains_one() { + // The glob `-[0-9]*` accepts this for the same reason. The whole + // string must BE the key, and here the match stops short of the input's + // end — which is also why this asks a different question from `keys_in`, + // where the same string legitimately CONTAINS a key. + let grammar = Grammar::committed(); + let subject = format!("{}x", key(1)); + assert_eq!(grammar.key_of(&subject), None); + assert_eq!(grammar.keys_in(&subject).len(), 1); + } + + #[test] + fn a_shorter_key_is_not_found_inside_a_longer_one() { + // BOUNDARY. The case two landed sites commented on by name. A greedy + // match takes the whole number, so the short key never appears — and a + // caller asking "does this carry key K" compares against this set rather + // than searching again, which is the derivation this removes. + let grammar = Grammar::committed(); + let found = grammar.keys_in(&key(179)); + assert_eq!(found.len(), 1); + assert_eq!(found[0].as_str(), key(179)); + assert!(!found.iter().any(|k| k.as_str() == key(17))); + } + + #[test] + fn a_key_glued_to_a_leading_token_is_not_found() { + // The other half of the boundary, and the half `\b` gets wrong: a word + // boundary treats `-` as a separator, so it would find a key inside a + // longer hyphenated token. + let grammar = Grammar::committed(); + for prefix in ["X", "9", "SUB-"] { + let subject = format!("{prefix}{}", key(757)); + assert!( + grammar.keys_in(&subject).is_empty(), + "{subject} carries no key of its own" + ); + } + } + + #[test] + fn ordinary_prose_yields_its_keys_in_numeric_order() { + // The allow that keeps the boundary honest: the separators a body + // actually uses must still open a key, or the definition refuses most + // real text and gets replaced by a twenty-first copy. + let grammar = Grammar::committed(); + let text = format!("Refs: {}, {} and ({}).", key(10), key(9), key(1142)); + let found: Vec = grammar + .keys_in(&text) + .into_iter() + .map(|k| k.as_str().to_owned()) + .collect(); + assert_eq!( + found, + vec![key(9), key(10), key(1142)], + "numeric, not lexical" + ); + } +} diff --git a/crates/batten/src/receipt.rs b/crates/batten/src/receipt.rs index c9b9602f4..464da3309 100644 --- a/crates/batten/src/receipt.rs +++ b/crates/batten/src/receipt.rs @@ -1911,6 +1911,7 @@ mod tests { assigned: false, live_pr: None, description: None, + created_at: None, }]; let minted = crate::claim::mint( &receipts, @@ -2217,6 +2218,7 @@ mod tests { assigned: false, live_pr: None, description: None, + created_at: None, }]; let minted = crate::claim::mint( &receipts, diff --git a/crates/batten/src/record.rs b/crates/batten/src/record.rs index 627c6a3ed..1874ed11b 100644 --- a/crates/batten/src/record.rs +++ b/crates/batten/src/record.rs @@ -199,5 +199,146 @@ pub fn run(command: crate::cli::RecordCommand, overrides: &Overrides) -> 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), } } + +/// Record which rows this branch's pull request body closes. +/// +/// # Errors +/// +/// A [`UsageError`] when the body is empty — an unread body is could-not-look and +/// must not be recorded as "closes nothing" — when the pattern registry declares +/// no key grammar, or when there is no branch to key on. An internal error when +/// the store cannot be written. +pub fn run_closes(overrides: &Overrides) -> Result { + let body = verdict_lines()?; + if body.trim().is_empty() { + return Err(UsageError::raise( + "record closes: the body is empty, and an unread body is not a body that closes nothing" + .to_owned(), + )); + } + + let config = resolve::resolve(Path::new("."), overrides)?; + let grammar = crate::ready::Grammar::resolve(&config.patterns)?; + let keys: Vec = grammar + .keys_closed_in(&body) + .into_iter() + .map(|key| key.to_string()) + .collect(); + + // ZERO IS A COUNT, and rendering it that way is the whole three-valued read + // this record exists to preserve: `closes 0` says the body was READ and closes + // nothing, where an absent record says nobody looked. The reader distinguishes + // them, so the producer must not collapse them. + let body = if keys.is_empty() { + "closes 0\n".to_owned() + } else { + format!("closes {}:{}\n", keys.len(), keys.join(",")) + }; + + let root = Path::new("."); + let git_dir = git::git_dir(root).map_err(|_| { + UsageError::raise( + "record closes: 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 closes: a detached HEAD has no branch to key the body on".to_owned(), + )); + }; + store( + &crate::recorder::record_path(&git_dir, "pr-closes", &branch), + &body, + )?; + Ok(ExitCode::Success) +} + +/// The record names this crate's own VERBS write, as opposed to the ones a +/// `[[recorder]]` row mints from a tool envelope (CLOUD-472). +/// +/// # Why a verb writes this at all, which is the whole design decision +/// +/// A hook mediates a call the agent makes to somebody ELSE's tool, so it is +/// per-harness by nature: `TaskCreate`/`TaskUpdate` here, `write_todos` on +/// Gemini CLI, `todowrite` on `OpenCode`, `update_plan` on Codex. Recording from +/// those envelopes needs a spelling per host, and its failure mode is the one +/// this whole module exists to name — an unsurveyed harness, a tool a setting +/// switched off, and an agent that did as it was told all produce NOTHING, so the gate reads +/// clean. `OpenCode` makes that concrete: `todowrite` is denied to subagents at +/// session creation regardless of configuration. +/// +/// A verb inverts the direction. The agent TELLS the engine, so a missing record +/// refuses on every harness identically — no survey, no per-host spelling, and no +/// setting that can quietly disarm it. Discovery still has a job (reporting which +/// native surface exists, so a mirror can be kept for the human's benefit), but +/// the gate reads this store and only this store. +/// `claim` is here for a second reason worth stating: `claim check` writes it and +/// nothing read it from a module before, but it is the honest signal for "this +/// branch is doing tracked work". A gate that demands a plan from EVERY tree with +/// a diff refuses every scratch fixture and every consumer checkout — measured, +/// it reddened four `cli.rs` cases that only wanted to exercise other rules. +/// Keyed to a claim, it asks the question exactly where the answer is owed. +pub const VERB_WRITTEN: &[&str] = &["claim", "plan"]; + +/// The statuses a plan entry may carry. +/// +/// The vocabulary four harnesses already converged on, which is what makes a +/// mirror possible in either direction — but the tokens are the ENGINE's, not any +/// host's, so a harness that spells them differently is translated at the mirror +/// rather than teaching this store a dialect. +const PLAN_STATUSES: [&str; 4] = ["pending", "in_progress", "completed", "deleted"]; + +/// Record this branch's plan: one ` ` line per entry. +/// +/// # Errors +/// +/// A [`UsageError`] when a line is not ` `, when a status is not one +/// of [`PLAN_STATUSES`], or when there is no branch to key on — a detached HEAD +/// has nothing to record against, exactly as the claim receipt has nothing to key +/// on there. An internal error when the store cannot be written. +pub fn run_plan() -> Result { + let raw = verdict_lines()?; + for (index, line) in raw.lines().enumerate() { + if line.trim().is_empty() { + continue; + } + let mut words = line.split_whitespace(); + let (Some(_id), Some(status)) = (words.next(), words.next()) else { + return Err(UsageError::raise(format!( + "plan line {} is not ` `", + index + 1 + ))); + }; + // THE TOKEN, NEVER THE LINE (rule 4). An entry's id is the agent's own + // text and a status is a closed vocabulary, so the closed half is what a + // diagnostic may echo. + if !PLAN_STATUSES.contains(&status) { + return Err(UsageError::raise(format!( + "plan line {} carries an unknown status; one of {}", + index + 1, + PLAN_STATUSES.join(", ") + ))); + } + } + + let root = Path::new("."); + let git_dir = git::git_dir(root).map_err(|_| { + UsageError::raise( + "record plan: 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 plan: a detached HEAD has no branch to key the plan on".to_owned(), + )); + }; + store( + &crate::recorder::record_path(&git_dir, "plan", &branch), + &raw, + )?; + Ok(ExitCode::Success) +} diff --git a/crates/batten/src/resolve.rs b/crates/batten/src/resolve.rs index ad2038859..6bae81afd 100644 --- a/crates/batten/src/resolve.rs +++ b/crates/batten/src/resolve.rs @@ -494,6 +494,9 @@ pub struct Resolved { /// **added**. §8's "add protected paths" verbatim; adding to an include-only /// set can only guard more. pub protected: Vec, + /// The refinement gate's thresholds (CLOUD-472), from the committed + /// authority alone. `None` is could-not-look and asks for no ratchet. + pub ready: Option, /// Programs that only read their operands, so naming a protected path is not /// a mutation (CLOUD-1141). /// @@ -697,6 +700,13 @@ pub struct Resolved { /// convention — there is no raise-only reading of "match more things". #[serde(skip_serializing_if = "Option::is_none")] pub commit: Option, + /// The bot lane (CLOUD-1295), as the authority states it. Not layered, for + /// the reason the neighbours above are not: a local file able to reach this + /// table could add a login to `bots` or a path to `owned_manifests`, and + /// either one turns a refusal into a filed row — a weakening dressed as an + /// addition, which house style §8's raise-only rule does not admit. + #[serde(skip_serializing_if = "Option::is_none")] + pub bot_lane: Option, /// Which layers set each **emitted** key. /// /// Keyed by the serialized key name, and total over the document rather @@ -1607,6 +1617,12 @@ fn assemble( // local file adding one would weaken the protected gate, which is // exactly what house style §8's raise-only clause forbids. protected_readers: repo.protected_readers.clone(), + // COMMITTED AUTHORITY ONLY, for `protected_readers`' reason one layer + // over: the threshold is a RATCHET, so a local file setting it would be + // setting it HIGHER — exempting rows the committed authority refuses — + // and house style §8 admits only raises. Lowering it is a change to the + // committed file, where a reviewer sees it. + ready: repo.ready.clone(), unlanded: paths.unlanded, epoch: repo.epoch.clone(), contract: repo.contract.clone(), @@ -1632,6 +1648,7 @@ fn assemble( transcript: repo.transcript.clone(), attribution: repo.attribution.clone(), commit: repo.commit.clone(), + bot_lane: repo.bot_lane.clone(), judge: repo.judge.clone(), design: repo.design.clone(), ci: repo.ci.clone(), @@ -1691,6 +1708,11 @@ fn attribution( "protected_readers", authority_set(!repo.protected_readers.is_empty()), ), + // AUTHORITY-ONLY for `protected_readers`' reason (CLOUD-472): the + // prose-dialect cutover is a ratchet, so a local file could only move it + // LATER — exempting rows the committed authority refuses — which is a + // weakening dressed as a setting, and §8 admits only raises. + ("ready", authority_set(repo.ready.is_some())), ("unlanded", paths.unlanded_source.clone()), ("epoch", authority_set(repo.epoch.is_some())), ("contract", authority_set(repo.contract.is_some())), @@ -1743,6 +1765,7 @@ fn attribution( ("transcript", authority_set(repo.transcript.is_some())), ("attribution", authority_set(repo.attribution.is_some())), ("commit", authority_set(repo.commit.is_some())), + ("bot_lane", authority_set(repo.bot_lane.is_some())), ("judge", authority_set(repo.judge.is_some())), ("design", authority_set(repo.design.is_some())), ("ci", authority_set(repo.ci.is_some())), diff --git a/crates/batten/src/rules.rs b/crates/batten/src/rules.rs index 0a0ce65f2..f1a44ad3b 100644 --- a/crates/batten/src/rules.rs +++ b/crates/batten/src/rules.rs @@ -180,6 +180,7 @@ const RECEIPT_PERMITS: &[&str] = &[ "pattern", "tool", "checks", + "checks_any", "key", "key_from", "key_shape", @@ -627,7 +628,26 @@ impl RuleKind { // [`Rule::validate`] where the trigger is in scope. A write-triggered // row has no command line to match, so requiring the column // unconditionally would make the new trigger unusable. - RuleKind::Receipt => &["checks", "reason", "severity"], + // `checks` is NOT here since CLOUD-1297, and its absence is a + // conditional requirement rather than a relaxation — the same move + // `pattern` makes above and `verdict` makes below. A receipt row + // must still name at least one receipt, but it may do so in either + // column: `checks` for the conjunction, `checks_any` for the + // alternation. Requiring `checks` unconditionally would make the + // alternation unusable on its own, which is the whole of the column. + // [`Rule::validate_receipt_columns`] refuses a row naming neither. + // + // NOW EQUAL TO `Shape`'s AND `Pipeline`'s LISTS, and by the same + // coincidence those two already record: three kinds arrived at + // `reason` + `severity` by three unrelated conditional-column moves + // — CLOUD-758's, CLOUD-864's and this one — and each can regain a + // column without the others. `#[expect]` rather than `#[allow]` so + // the day the lists diverge this goes red and is deleted. + #[expect( + clippy::match_same_arms, + reason = "the lists are equal by coincidence; see the note above" + )] + RuleKind::Receipt => &["reason", "severity"], // `verdict` and `filters` are NOT here since CLOUD-864, and their // absence is a conditional requirement rather than a relaxation — // the same move `Receipt`'s `pattern` makes above, for the same @@ -2399,6 +2419,34 @@ pub struct Rule { /// would let one be deleted while the other kept the gate looking whole. #[serde(default, skip_serializing_if = "Option::is_none")] pub checks: Option>, + /// The receipts a [`RuleKind::Receipt`] row accepts **any one** of. Required + /// by that kind when [`Rule::checks`] is absent, rejected by every other. + /// + /// The disjunction beside the conjunction, rather than a spelling inside + /// `checks`, because the two say different things and a row usually wants + /// both: `checks` is what must ALL hold, `checks_any` is one alternative + /// among equals. A row carrying both is read as the conjunction of the two — + /// every name in `checks` valid, AND at least one name here valid — which is + /// conjunctive normal form and so expresses anything either column alone + /// could not. + /// + /// The measured occasion is CLOUD-1297. `verify`'s own body accepts a + /// `claim`, a `bot` or a `carry` receipt and treats any one as enough, and + /// the mediated gate could not follow it: `claim-needs-receipt` demanded + /// `claim` alone, so a branch legitimately carrying a `bot.` receipt + /// was denied on every write despite holding a valid attestation — a live + /// false positive since CLOUD-693, which CLOUD-1295's third receipt kind + /// inherited. Spelling it as a second `checks` row could not work: a second + /// receipt row is a second AND, so it would have denied every ordinary write + /// on every branch. + /// + /// An ALTERNATION LOWERS A BAR, which is why a consumer row adopting one + /// owes `config-lint` a groomed `Weakens:` clause. That is the raise-only + /// discipline of house-style §8 working, not an obstacle to it: the column + /// makes a weakening expressible and therefore reviewable, where before it + /// was expressible only as a false positive nobody could remove. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub checks_any: Option>, /// Which git fact a [`RuleKind::Receipt`] row's receipts are keyed to. /// /// Optional with a pinned default of [`ReceiptKey::Head`], the conservative @@ -3389,6 +3437,25 @@ pub const COLUMN_CENSUS: &[ColumnCensus] = &[ // typed rule table, which is what makes `ready-guard` a mediated row. declares: Declares::Fact(crate::facts::Fact::Receipts, |rule| rule.checks.is_some()), }, + ColumnCensus { + field: "checks_any", + // THE SAME FACT AS `checks`, and that is the whole of the entry: the two + // columns differ in how they are ADJUDICATED — all of one, any of the + // other — and not at all in what has to be resolved to adjudicate them. + // A row naming three receipts in an alternation needs all three verdicts + // resolved, because "any one is valid" cannot be answered without asking + // about each; the alternation is applied to the answers, never to which + // questions get asked. + // + // So this entry is what makes `Rule::receipt_names` honest at the + // acquisition layer, the same way that helper makes it honest at every + // resolution site: a column declaring no fact here would say the + // alternation's names need not be resolved, which is the dead gate + // CLOUD-1297 exists to have avoided. + declares: Declares::Fact(crate::facts::Fact::Receipts, |rule| { + rule.checks_any.is_some() + }), + }, ColumnCensus { field: "key", declares: Declares::NotFactBearing( @@ -3916,6 +3983,22 @@ impl Rule { ))); } } + self.validate_receipt_names() + } + + /// The three refusals over a receipt row's two name columns (CLOUD-1297). + /// + /// Split out of [`Rule::validate_receipt_columns`] because that function + /// crossed the 100-line bound when the alternation arrived, and this is the + /// half that comes out whole: every arm here is about WHICH RECEIPTS the row + /// names, where everything left behind is about the trigger and the columns + /// it implies. + /// + /// # Errors + /// + /// A [`UsageError`] (→ exit `1`) for an empty `checks` list, an empty + /// `checks_any` list, or a row naming neither column. + fn validate_receipt_names(&self) -> anyhow::Result<()> { // A row naming an empty `checks` list gates its trigger on nothing and // allows every call, which reads as coverage from the file. if self.checks.as_ref().is_some_and(Vec::is_empty) { @@ -3924,6 +4007,30 @@ impl Rule { self.id ))); } + // The same refusal for the alternation, and it is worth its own arm + // rather than folding into the one above: an empty ALTERNATION is + // satisfied by nothing, so a row carrying one can never allow a call at + // all. That fails in the opposite direction to an empty conjunction — + // one gates nothing, the other gates everything — and a reader handed a + // single message would be told the wrong thing about their row. + if self.checks_any.as_ref().is_some_and(Vec::is_empty) { + return Err(UsageError::raise(format!( + "rule {}: kind \"receipt\" requires at least one entry in `checks_any`; an empty alternation is satisfied by no receipt, so the row could never allow a call", + self.id + ))); + } + // CLOUD-1297's conditional requirement, standing in for the `checks` + // entry `RuleKind::permits` used to carry. A row naming NEITHER column + // gates its trigger on nothing and allows every call — the same defect + // the empty-`checks` arm above refuses, arrived at by omission rather + // than by an empty list, and the census cannot express "one of these + // two". + if self.checks.is_none() && self.checks_any.is_none() { + return Err(UsageError::raise(format!( + "rule {}: kind \"receipt\" requires `checks` (every named receipt must be valid) or `checks_any` (any one of them), or both; a row naming neither gates its trigger on nothing", + self.id + ))); + } Ok(()) } @@ -4084,7 +4191,7 @@ impl Rule { /// about all of them makes that failure impossible, and /// [`tests::every_optional_rule_field_is_classified_by_every_kind`] fails if /// a column is added here without being placed. - fn columns(&self) -> [(&'static str, bool); 52] { + fn columns(&self) -> [(&'static str, bool); 53] { [ // In the census because it is now per-kind, which is what makes // "required by every kind but the judge" a fact the existing @@ -4131,6 +4238,7 @@ impl Rule { ("reads", self.reads.is_some()), ("module", self.module.is_some()), ("checks", self.checks.is_some()), + ("checks_any", self.checks_any.is_some()), ("key", self.key.is_some()), ("trigger", self.trigger.is_some()), ("verdict", self.verdict.is_some()), @@ -4152,6 +4260,29 @@ impl Rule { ] } + /// Every receipt name this row mentions, in either column (CLOUD-1297). + /// + /// **The one place a resolution site may read, and it exists to close a dead + /// gate rather than to save typing.** The boundary decides which facts to + /// resolve by walking a row's receipt names; a site that walked `checks` + /// alone after `checks_any` existed would leave every alternation name + /// unresolved, and an unresolved name reads [`Validity::Missing`], so the + /// alternation could never be satisfied and the row would deny every call it + /// selected. That failure is invisible from the config — the row reads as + /// configured and the refusal names a real receipt — which is the class + /// `.claude/rules/policy-modules.md` records one layer down for a key the + /// engine never builds. + /// + /// So ADJUDICATION reads the two columns apart, because they mean different + /// things, and RESOLUTION reads them together, because the question there is + /// only "which facts does this row need answered". + pub fn receipt_names(&self) -> impl Iterator { + self.checks + .iter() + .flatten() + .chain(self.checks_any.iter().flatten()) + } + /// What makes this row fire, with the pinned default applied — the one place /// absence is resolved, so no call site reads it a second way. #[must_use] @@ -6152,13 +6283,19 @@ fn run( // recorder is config: the fact is "what this repository's recorders // accumulated", so a repository declaring none has nothing to read and a // per-rule declaration would be a second place for the same answer to live. - let records = if recorders.is_empty() { - BTreeMap::new() - } else { - match (crate::git::git_dir(root), crate::git::current_branch(root)) { - (Ok(git_dir), Ok(Some(branch))) => recorder_records(&git_dir, &branch, recorders), - _ => BTreeMap::new(), - } + // VERB-WRITTEN STORES ARE READ UNCONDITIONALLY, which is why the guard above + // is no longer the whole answer (CLOUD-472). A `[[recorder]]` store exists + // because config declared one, so a repository declaring none has nothing to + // read. `crate::record::VERB_WRITTEN` is different in kind: the ENGINE owns + // both the writer and the reader, so there is no declaration for a consumer + // to forget and no config to make the gate conditional on. Reading them only + // when some unrelated recorder happened to be declared would make a gate's + // liveness depend on a table it has nothing to do with. + let records = match (crate::git::git_dir(root), crate::git::current_branch(root)) { + (Ok(git_dir), Ok(Some(branch))) => { + recorder_records(&git_dir, &branch, recorders, crate::record::VERB_WRITTEN) + } + _ => BTreeMap::new(), }; // The union the engine DECIDES against, built once for the run (CLOUD-1220). @@ -6422,20 +6559,31 @@ fn recorder_records( git_dir: &std::path::Path, branch: &str, recorders: &[crate::recorder::Declared], + verb_written: &[&str], ) -> BTreeMap> { let mut found: BTreeMap> = BTreeMap::new(); - for recorder in recorders { - if found.contains_key(&recorder.record) { + // The declared stores first, then the engine's own. Order decides nothing — + // the names cannot collide, because a `[[recorder]]` naming a verb-written + // record would be a second writer for one store and `config lint` refuses it + // — but reading declared config first keeps the consumer's table the one a + // reader looks at when a name is ambiguous. + let names = recorders + .iter() + .map(|recorder| recorder.record.as_str()) + .chain(verb_written.iter().copied()); + for name in names { + if found.contains_key(name) { continue; } - let path = crate::recorder::record_path(git_dir, &recorder.record, branch); + let path = crate::recorder::record_path(git_dir, name, branch); + // ABSENT STAYS ABSENT, and that is the three-valued read this whole + // surface rests on: an unreadable store leaves the key out of the map so + // a module sees *does not hold*, where an empty file is a key whose value + // is the empty list — "nothing was recorded" rather than "nothing looked". let Ok(text) = std::fs::read_to_string(&path) else { continue; }; - found.insert( - recorder.record.clone(), - text.lines().map(str::to_owned).collect(), - ); + found.insert(name.to_owned(), text.lines().map(str::to_owned).collect()); } found } @@ -12594,6 +12742,13 @@ mod tests { .permits() .contains(&"checks") .then(|| vec!["verify".to_owned()]), + // Left `None` even where the kind permits it, unlike `checks` above: + // a receipt fixture needs SOME receipt named to be valid, and + // `checks` above already supplies one. Filling both would make every + // blank receipt row carry an alternation no case asked for, and a + // fixture that quietly exercises a column is how a test passes for a + // reason its author did not choose. + checks_any: None, key: None, trigger: None, verdict: None, diff --git a/crates/batten/src/spec.rs b/crates/batten/src/spec.rs index bc3734973..e6039cb0e 100644 --- a/crates/batten/src/spec.rs +++ b/crates/batten/src/spec.rs @@ -578,6 +578,242 @@ mod tests { } } + /// The row set [`the_emitted_surface_is_exactly_the_committed_row_set`] + /// compares against, and the reasoning for each row that has one. + /// + /// Lifted out of the assertion rather than inlined, for the same reason + /// `cli.rs`'s census config was: the list grows by a row every time a verb + /// is added, and it pushed the assertion past the line ceiling. The + /// comments travel WITH the rows rather than staying behind, because each + /// one explains why that row is spelled the way it is — a reader who has to + /// look somewhere else for that has the same problem the extraction was + /// meant to solve. + fn committed_rows() -> Vec { + vec![ + "attribution".to_owned(), + "attribution check".to_owned(), + "attribution identity".to_owned(), + // The adoption path for an already-dirty repository (CLOUD-67). + // §2's listing gained the row in the same change, which is what + // this assertion exists to prompt. + "baseline".to_owned(), + // The handle-navigation noun (CLOUD-121). `capture show`, not a + // bare `show`: §2 is noun-verb and lists no bare `show`, and the + // noun is what gives lifecycle (`prune`) somewhere to live. + "capture".to_owned(), + "capture find".to_owned(), + "capture list".to_owned(), + "capture prune".to_owned(), + "capture show".to_owned(), + "check".to_owned(), + // The green-verdict noun and its verb (CLOUD-1143), ported off + // `mise-tasks/checks-green.sh` on the terms `claim` below + // records. Stated here rather than regenerated, which is what + // this assertion is for: the row set moving is the prompt to + // reconcile §2 in the same change. + // + // Only `checks green` reaches the read-only allowlist above. The + // exit table it answers in is NOT the predecessor's — red and + // not-yet share `Violation`, because they differ in whether to + // ask again and never in whether the head may land, so a caller + // that reads the code alone holds instead of landing. + "checks".to_owned(), + "checks green".to_owned(), + // The pull-time claim noun (CLOUD-1121), ported off + // `mise-tasks/claim-check.sh` on the terms `semver` below + // records: CLOUD-1059 made editing a shell rule refusable, so a + // migration replaces one or does not land. It is absent from the + // read-only allowlist above, deliberately: the pullable path + // MINTS a receipt. + "claim".to_owned(), + "claim bot".to_owned(), + "claim carry".to_owned(), + "claim check".to_owned(), + "commit".to_owned(), + "commit check".to_owned(), + "config".to_owned(), + "config deprecations".to_owned(), + "config epoch".to_owned(), + "config lint".to_owned(), + "config show".to_owned(), + "defects".to_owned(), + "defects add".to_owned(), + "defects query".to_owned(), + "design".to_owned(), + "design audit".to_owned(), + "doctor".to_owned(), + "doctor hooks".to_owned(), + "enforce".to_owned(), + "exec".to_owned(), + // The schema is emitted by `generate`, not `config`: it is a + // derivation of the config types, and §11 gives every + // derivation the one emitter (CLOUD-244). + "generate".to_owned(), + "generate completions".to_owned(), + // §11's third derivation, the hook wiring (CLOUD-62). + "generate hooks".to_owned(), + // §11's other two derivations, landed together (CLOUD-69): the + // document has named man pages and markdown as derivations of + // this spec since the spine, and until now only the shell one + // existed. §2 needs no reconciliation for them — it never + // listed a row either way. + "generate man".to_owned(), + "generate markdown".to_owned(), + "generate schema".to_owned(), + "hook".to_owned(), + // §2 already reserved this row (`init [-n] … (write)`); CLOUD-206 + // landed the verb behind it, so the document needed no edit. + "init".to_owned(), + // A top-level verb-with-kind, not a `brief` noun: what varies + // across `lint ` is the artifact, and `config lint` stays + // where it is because it lints the one committed authority + // rather than something the caller names (CLOUD-84). + "lint".to_owned(), + "lint brief".to_owned(), + // CLOUD-1260, and this assertion doing its job again: a new noun + // fails here and has to be stated, which is the prompt to + // reconcile §2 in the same change. Both rows are UNCLASSIFIED and + // deliberately absent from the read-only allowlist above — `mcp + // call` makes an OUTBOUND CALL and writes the capture store, so + // an optimistic `read` would widen §5's derived allowlist + // silently, and the noun over it would leak onto the same list + // for any consumer reading an entry as a prefix (CLOUD-121). + "mcp".to_owned(), + "mcp call".to_owned(), + // CLOUD-1267's noun and its two verbs, retired out of + // `mise-tasks/mutant.sh` and `mise-tasks/mutant-census.sh`. + // Stated here rather than regenerated, on the terms `checks` + // above records: the row set moving is the prompt to reconcile + // §2 in the same change. + // + // The noun is `write` and only `mutate census` reaches the + // read-only allowlist. `mutate sweep` stages a copy of the tree + // and spawns a suite runner against it, so it is `write` — the + // disposition CLOUD-1171 settled for `perf pair`, and the reason + // this could not be a `check` row at all. The noun STATES that + // rather than inheriting it: `every_command_has_a_declared_effect` + // refuses an `ask`, and a `write` noun is what a consumer reading + // an allowlist entry as a prefix should find (CLOUD-121). + "mutate".to_owned(), + "mutate census".to_owned(), + "mutate sweep".to_owned(), + // CLOUD-479. `payload field` is a decoder, not a mediator: it + // reads stdin, projects one allowlisted field, and renders no + // verdict — so `read` is the honest classification and the + // derived allowlist is where it belongs. `hook` next door stays + // unclassified because its DECISION mediates writes. + // CLOUD-1051, and this assertion doing its job: a new noun fails + // here and has to be stated, which is the prompt to reconcile §2 + // in the same change. `override` is UNCLASSIFIED and deliberately + // absent from the read-only allowlist above — its subtree writes, + // so a `read` noun would leak onto that allowlist for any + // consumer reading an entry as a prefix (CLOUD-90). `override + // request` is `write`, because what authorizes is the record's + // existence and state; a verb that only computed an address would + // authorize nothing. + "override".to_owned(), + "override request".to_owned(), + "override spend".to_owned(), + "payload".to_owned(), + "payload field".to_owned(), + // The paired latency measurement (CLOUD-875), retired out of + // `mise-tasks/perf-pair.sh` under CLOUD-1059. §2 gains the noun + // and its one verb in the same change, which is exactly what + // this assertion exists to prompt — and the row is `write`, so + // it is deliberately absent from the read allowlist above. + "perf".to_owned(), + "perf pair".to_owned(), + "policy".to_owned(), + "policy budget".to_owned(), + "policy explain".to_owned(), + "policy hooks".to_owned(), + "policy test".to_owned(), + "policy tools".to_owned(), + // The poll around `checks green`'s verdict (CLOUD-1143), ported + // off `mise-tasks/ci-wait.sh` and renamed onto §2's declared + // spelling by CLOUD-1214. THIS LIST IS SORTED, which is why the + // pair sits here rather than beside the verdict it polls, and + // why leaving them where `ci` had been failed the assertion. + // + // NEITHER row is in the read-only allowlist above: the verb runs + // two programs the caller names — the forge's client to take the + // reading, and a recorder for the progress signals — and "runs a + // program somebody else chose" is not `read`, whatever the + // reading itself costs. + "pr".to_owned(), + "pr closes".to_owned(), + "pr derive".to_owned(), + "pr ensure".to_owned(), + "pr file".to_owned(), + "pr link".to_owned(), + "pr watch".to_owned(), + "provision".to_owned(), + "provision apply".to_owned(), + "provision status".to_owned(), + // The refinement gate, ported off `mise-tasks/ready-lint.sh` in + // the same change and for the same reason. + "ready".to_owned(), + "ready lint".to_owned(), + "receipt".to_owned(), + "receipt record".to_owned(), + "receipt status".to_owned(), + // The out-of-tree verdict stores' write half (CLOUD-1265). §2 + // gains the noun and its two leaves in the same change, which is + // what this assertion exists to prompt. + // + // TWO LEAVES AND NOT ONE, because the two stores share the + // record's line shape and nothing else: `tools::record_key` + // composes a triple from a declared row plus bytes read off disk, + // `forge::record_path` is a resolved sha. A single verb with a + // mode flag would be a second authority over which key gets + // composed — and building both is what keeps this off + // CLOUD-1184's singleton-noun list. + // + // Spelled `record ` rather than ` record`, unlike + // its `receipt record` and `state record` neighbours above: + // CLOUD-1190 inverts those when the imperative grammar lands, and + // a third row spelled the old way would be a third row to invert. + "record".to_owned(), + "record closes".to_owned(), + "record forge".to_owned(), + // The plan a branch declared, so `plan-complete` decides over a + // record rather than over a transcript it cannot re-read. + "record plan".to_owned(), + "record tool".to_owned(), + // The API-compatibility noun (CLOUD-1050), ported off + // `mise-tasks/semver.sh` when CLOUD-1059 made editing a shell + // rule refusable. §2 gains the noun in the same change, which is + // what this assertion exists to prompt. + "semver".to_owned(), + "semver check".to_owned(), + "spec".to_owned(), + "state".to_owned(), + "state adopt".to_owned(), + "state list".to_owned(), + "state migrate".to_owned(), + "state record".to_owned(), + "state settle".to_owned(), + // The build-tree noun (CLOUD-1030), ported off + // `mise-tasks/target-prune.sh` for `semver`'s reason above. Both + // rows are `Effect::Destructive` and so are deliberately absent + // from the read allowlist — this is the second destructive verb + // on the surface, beside `capture prune`, and it earns the same + // `-y` binding rather than a new exception. + "target".to_owned(), + "target prune".to_owned(), + // The one write path over a host's hook registrations + // (CLOUD-893). Both rows are here and NEITHER is on the + // read-only allowlist above: the noun is `Unclassified` because + // its subtree carries a destructive verb, and the verb is + // `Destructive` because its subject is a file shared by every + // checkout on the box. + "wiring".to_owned(), + "wiring reclaim".to_owned(), + "worktree".to_owned(), + "worktree status".to_owned(), + ] + } + #[test] fn the_emitted_surface_is_exactly_the_committed_row_set() { // CLOUD-244's in-tree half. §2 and the emitted spec disagreed on four @@ -592,226 +828,7 @@ mod tests { let mut paths = Vec::new(); emitted_paths(&root, root.path.as_str(), &mut paths); paths.sort(); - assert_eq!( - paths, - vec![ - "attribution".to_owned(), - "attribution check".to_owned(), - "attribution identity".to_owned(), - // The adoption path for an already-dirty repository (CLOUD-67). - // §2's listing gained the row in the same change, which is what - // this assertion exists to prompt. - "baseline".to_owned(), - // The handle-navigation noun (CLOUD-121). `capture show`, not a - // bare `show`: §2 is noun-verb and lists no bare `show`, and the - // noun is what gives lifecycle (`prune`) somewhere to live. - "capture".to_owned(), - "capture find".to_owned(), - "capture list".to_owned(), - "capture prune".to_owned(), - "capture show".to_owned(), - "check".to_owned(), - // The green-verdict noun and its verb (CLOUD-1143), ported off - // `mise-tasks/checks-green.sh` on the terms `claim` below - // records. Stated here rather than regenerated, which is what - // this assertion is for: the row set moving is the prompt to - // reconcile §2 in the same change. - // - // Only `checks green` reaches the read-only allowlist above. The - // exit table it answers in is NOT the predecessor's — red and - // not-yet share `Violation`, because they differ in whether to - // ask again and never in whether the head may land, so a caller - // that reads the code alone holds instead of landing. - "checks".to_owned(), - "checks green".to_owned(), - // The pull-time claim noun (CLOUD-1121), ported off - // `mise-tasks/claim-check.sh` on the terms `semver` below - // records: CLOUD-1059 made editing a shell rule refusable, so a - // migration replaces one or does not land. It is absent from the - // read-only allowlist above, deliberately: the pullable path - // MINTS a receipt. - "claim".to_owned(), - "claim check".to_owned(), - "commit".to_owned(), - "commit check".to_owned(), - "config".to_owned(), - "config deprecations".to_owned(), - "config epoch".to_owned(), - "config lint".to_owned(), - "config show".to_owned(), - "defects".to_owned(), - "defects add".to_owned(), - "defects query".to_owned(), - "design".to_owned(), - "design audit".to_owned(), - "doctor".to_owned(), - "doctor hooks".to_owned(), - "enforce".to_owned(), - "exec".to_owned(), - // The schema is emitted by `generate`, not `config`: it is a - // derivation of the config types, and §11 gives every - // derivation the one emitter (CLOUD-244). - "generate".to_owned(), - "generate completions".to_owned(), - // §11's third derivation, the hook wiring (CLOUD-62). - "generate hooks".to_owned(), - // §11's other two derivations, landed together (CLOUD-69): the - // document has named man pages and markdown as derivations of - // this spec since the spine, and until now only the shell one - // existed. §2 needs no reconciliation for them — it never - // listed a row either way. - "generate man".to_owned(), - "generate markdown".to_owned(), - "generate schema".to_owned(), - "hook".to_owned(), - // §2 already reserved this row (`init [-n] … (write)`); CLOUD-206 - // landed the verb behind it, so the document needed no edit. - "init".to_owned(), - // A top-level verb-with-kind, not a `brief` noun: what varies - // across `lint ` is the artifact, and `config lint` stays - // where it is because it lints the one committed authority - // rather than something the caller names (CLOUD-84). - "lint".to_owned(), - "lint brief".to_owned(), - // CLOUD-1260, and this assertion doing its job again: a new noun - // fails here and has to be stated, which is the prompt to - // reconcile §2 in the same change. Both rows are UNCLASSIFIED and - // deliberately absent from the read-only allowlist above — `mcp - // call` makes an OUTBOUND CALL and writes the capture store, so - // an optimistic `read` would widen §5's derived allowlist - // silently, and the noun over it would leak onto the same list - // for any consumer reading an entry as a prefix (CLOUD-121). - "mcp".to_owned(), - "mcp call".to_owned(), - // CLOUD-1267's noun and its two verbs, retired out of - // `mise-tasks/mutant.sh` and `mise-tasks/mutant-census.sh`. - // Stated here rather than regenerated, on the terms `checks` - // above records: the row set moving is the prompt to reconcile - // §2 in the same change. - // - // The noun is `write` and only `mutate census` reaches the - // read-only allowlist. `mutate sweep` stages a copy of the tree - // and spawns a suite runner against it, so it is `write` — the - // disposition CLOUD-1171 settled for `perf pair`, and the reason - // this could not be a `check` row at all. The noun STATES that - // rather than inheriting it: `every_command_has_a_declared_effect` - // refuses an `ask`, and a `write` noun is what a consumer reading - // an allowlist entry as a prefix should find (CLOUD-121). - "mutate".to_owned(), - "mutate census".to_owned(), - "mutate sweep".to_owned(), - // CLOUD-479. `payload field` is a decoder, not a mediator: it - // reads stdin, projects one allowlisted field, and renders no - // verdict — so `read` is the honest classification and the - // derived allowlist is where it belongs. `hook` next door stays - // unclassified because its DECISION mediates writes. - // CLOUD-1051, and this assertion doing its job: a new noun fails - // here and has to be stated, which is the prompt to reconcile §2 - // in the same change. `override` is UNCLASSIFIED and deliberately - // absent from the read-only allowlist above — its subtree writes, - // so a `read` noun would leak onto that allowlist for any - // consumer reading an entry as a prefix (CLOUD-90). `override - // request` is `write`, because what authorizes is the record's - // existence and state; a verb that only computed an address would - // authorize nothing. - "override".to_owned(), - "override request".to_owned(), - "override spend".to_owned(), - "payload".to_owned(), - "payload field".to_owned(), - // The paired latency measurement (CLOUD-875), retired out of - // `mise-tasks/perf-pair.sh` under CLOUD-1059. §2 gains the noun - // and its one verb in the same change, which is exactly what - // this assertion exists to prompt — and the row is `write`, so - // it is deliberately absent from the read allowlist above. - "perf".to_owned(), - "perf pair".to_owned(), - "policy".to_owned(), - "policy budget".to_owned(), - "policy explain".to_owned(), - "policy hooks".to_owned(), - "policy test".to_owned(), - "policy tools".to_owned(), - // The poll around `checks green`'s verdict (CLOUD-1143), ported - // off `mise-tasks/ci-wait.sh` and renamed onto §2's declared - // spelling by CLOUD-1214. THIS LIST IS SORTED, which is why the - // pair sits here rather than beside the verdict it polls, and - // why leaving them where `ci` had been failed the assertion. - // - // NEITHER row is in the read-only allowlist above: the verb runs - // two programs the caller names — the forge's client to take the - // reading, and a recorder for the progress signals — and "runs a - // program somebody else chose" is not `read`, whatever the - // reading itself costs. - "pr".to_owned(), - "pr watch".to_owned(), - "provision".to_owned(), - "provision apply".to_owned(), - "provision status".to_owned(), - // The refinement gate, ported off `mise-tasks/ready-lint.sh` in - // the same change and for the same reason. - "ready".to_owned(), - "ready lint".to_owned(), - "receipt".to_owned(), - "receipt record".to_owned(), - "receipt status".to_owned(), - // The out-of-tree verdict stores' write half (CLOUD-1265). §2 - // gains the noun and its two leaves in the same change, which is - // what this assertion exists to prompt. - // - // TWO LEAVES AND NOT ONE, because the two stores share the - // record's line shape and nothing else: `tools::record_key` - // composes a triple from a declared row plus bytes read off disk, - // `forge::record_path` is a resolved sha. A single verb with a - // mode flag would be a second authority over which key gets - // composed — and building both is what keeps this off - // CLOUD-1184's singleton-noun list. - // - // Spelled `record ` rather than ` record`, unlike - // its `receipt record` and `state record` neighbours above: - // CLOUD-1190 inverts those when the imperative grammar lands, and - // a third row spelled the old way would be a third row to invert. - "record".to_owned(), - "record forge".to_owned(), - "record tool".to_owned(), - // The API-compatibility noun (CLOUD-1050), ported off - // `mise-tasks/semver.sh` when CLOUD-1059 made editing a shell - // rule refusable. §2 gains the noun in the same change, which is - // what this assertion exists to prompt. - "semver".to_owned(), - "semver check".to_owned(), - "spec".to_owned(), - "state".to_owned(), - "state adopt".to_owned(), - "state list".to_owned(), - "state migrate".to_owned(), - "state record".to_owned(), - // The findings store's ANSWER channel (CLOUD-587). §2 gains the - // row in the same change, which is what this assertion exists to - // prompt. A verb under the existing noun rather than a new one: - // the store has one noun and `record` is already a per-observation - // write, so a disposition is the same act against the same object. - "state settle".to_owned(), - // The build-tree noun (CLOUD-1030), ported off - // `mise-tasks/target-prune.sh` for `semver`'s reason above. Both - // rows are `Effect::Destructive` and so are deliberately absent - // from the read allowlist — this is the second destructive verb - // on the surface, beside `capture prune`, and it earns the same - // `-y` binding rather than a new exception. - "target".to_owned(), - "target prune".to_owned(), - // The one write path over a host's hook registrations - // (CLOUD-893). Both rows are here and NEITHER is on the - // read-only allowlist above: the noun is `Unclassified` because - // its subtree carries a destructive verb, and the verb is - // `Destructive` because its subject is a file shared by every - // checkout on the box. - "wiring".to_owned(), - "wiring reclaim".to_owned(), - "worktree".to_owned(), - "worktree status".to_owned(), - ] - ); + assert_eq!(paths, committed_rows()); } #[test] diff --git a/crates/batten/src/surface.rs b/crates/batten/src/surface.rs index 1ef799315..6343f95b4 100644 --- a/crates/batten/src/surface.rs +++ b/crates/batten/src/surface.rs @@ -507,6 +507,23 @@ const RANGE: FlagDecl = FlagDecl { value: ValueDecl::Str, }; +/// The pull request every `pr` bot-lane verb is about (CLOUD-1295). +/// +/// Positional and required, for `RANGE`'s reason: the pull request IS the verb's +/// object, and there is deliberately no default. Deriving one from the checked-out +/// branch would be a second authority for "which PR is this", and the lander that +/// calls these verbs already knows the number — it is the thing the tick is about. +const PR_NUMBER: FlagDecl = + FlagDecl::positional("pr", "The pull request number this verb is about"); + +/// The tracker key `pr link` writes into a body. +/// +/// Also positional and also required: `link` takes two objects and neither is +/// derivable here. The key comes from the tracker's own sync, which `pr ensure` +/// reads and this verb is handed. +const ISSUE_KEY: FlagDecl = + FlagDecl::positional("key", "The tracker key the pull request should close"); + /// `--root ` on `target prune`. /// /// The suite's seam for WHICH TREE, and the twin of the free-space override the @@ -2663,6 +2680,76 @@ pub const SURFACE: &[CommandDecl] = &[ FANIN_CHECK, ], }, + // The bot lane (CLOUD-1295), ported off `mise-tasks/bot-issue.sh`. Five verbs + // rather than one with a mode word, because they have different effects and + // house style §5 declares an effect per row: `derive` and `closes` write + // nothing, the other three write to the forge. + // + // `unclassified`, for `pr watch`'s reason and not for want of thought: this + // verb writes nothing at all, and if effect were about MUTATION it would be + // `read`. It runs the forge's client — a program the caller named — and + // "runs a program somebody else chose" is not `read`, so a row claiming it + // would put this verb on the derived read-only allowlist on a promise the + // row cannot keep. + // + // NO `-J`, AND THE DOCUMENT IS UNCONDITIONAL, which is the same call + // `pr watch` makes one row down. Its stdout is one JSON payload and there is + // no second encoding for a flag to select — the refinement gate reads it + // unchanged, which is the whole point. Declaring the channel anyway would + // enrol it in the `-J` census, whose contract is byte-stability and + // whole-or-nothing across two runs; here that is a property of the FORGE's + // answer rather than of this verb, so the row would promise something no + // reading of this code can keep. + CommandDecl { + path: "pr derive", + id: "pr.derive", + about: "The tracker row a bot's pull request implies, as a payload the refinement gate reads", + data_channel: false, + effect: Effect::Unclassified, + flags: &[PR_NUMBER], + }, + // `write`: it opens an issue on the forge. Stated rather than guessed — a row + // claiming `read` would put a verb that creates a tracker row on the derived + // read-only allowlist. + CommandDecl { + path: "pr file", + id: "pr.file", + about: "Open the mirror issue a bot's pull request implies, and report its number", + data_channel: false, + effect: Effect::Write, + flags: &[PR_NUMBER], + }, + // `write`: it rewrites the pull request's body so the merge moves the row. + CommandDecl { + path: "pr link", + id: "pr.link", + about: "Write the closing key into a bot pull request's body, so its merge moves the row", + data_channel: false, + effect: Effect::Write, + flags: &[PR_NUMBER, ISSUE_KEY], + }, + // `write`, because it composes the two above. Idempotent at every step, which + // is what makes it safe on a lander tick. + CommandDecl { + path: "pr ensure", + id: "pr.ensure", + about: "File the row and link it, doing whatever this tick can and saying what it did", + data_channel: false, + effect: Effect::Write, + flags: &[PR_NUMBER], + }, + // `unclassified` for the same reason as `pr derive`, and it is the + // last-moment question a landing asks: a bot regenerates its body on every + // rebase and the closing line goes with it, so the answer read a step earlier + // is not the answer at the ref move. + CommandDecl { + path: "pr closes", + id: "pr.closes", + about: "Whether a pull request's body still closes a tracker key, asked at the last moment", + data_channel: false, + effect: Effect::Unclassified, + flags: &[PR_NUMBER], + }, // The `claim` noun (CLOUD-1121), ported off `mise-tasks/claim-check.sh` on the // same terms. CommandDecl { @@ -2686,6 +2773,39 @@ pub const SURFACE: &[CommandDecl] = &[ effect: Effect::Write, flags: &[TAKEOVER, BYPASS_SEQUENCE, ADOPT, ADOPT_FROM, ISSUE, JSON], }, + // `write`, for `claim check`'s reason one row up: the derivable path MINTS a + // receipt under the git dir. A row claiming `read` would put a writing verb on + // the derived read-only allowlist. + // `write`, for `claim check`'s reason two rows up, and it is the SECOND + // receipt kind because the two attest different things (CLOUD-693, + // CLOUD-431). The agent receipt says a human or agent read the issue and + // confirmed the refinement predates this session; nothing on a bot branch can + // honestly say that, so widening it would make it mean less everywhere. This + // attests what IS decidable from public facts: the head was opened by a bot + // the lane declares, its diff touches only manifests the lane owns, and its + // body names the row derived from that diff. + // + // NO `-J`, for `pr derive`'s reason: what this verb can say depends on what + // the forge answers about the branch's open pull request, so the `-J` + // census's byte-stability term would be a claim about the forge. Its sibling + // `claim carry` one row down DOES declare the channel, and the difference is + // exactly that: that predicate is decided offline against the merge base. + CommandDecl { + path: "claim bot", + id: "claim.bot", + about: "Attest a bot branch from the lane's public facts, and mint the receipt when they hold", + data_channel: false, + effect: Effect::Write, + flags: &[], + }, + CommandDecl { + path: "claim carry", + id: "claim.carry", + about: "Attest that this branch only carries licence rows forward, and mint the receipt when it does", + data_channel: true, + effect: Effect::Write, + flags: &[JSON], + }, CommandDecl { path: "semver", id: "semver", @@ -3251,6 +3371,43 @@ pub const SURFACE: &[CommandDecl] = &[ "The ref or sha the verdict was taken against", )], }, + // CLOUD-472. A VERB rather than a `[[recorder]]` on the harness's own todo + // tool, and the direction is the point: a hook mediates a call to somebody + // else's tool and is per-harness by nature, so an unsurveyed host, a tool a + // setting disabled, and an agent that did as it was told all record nothing and the gate + // reads clean. Telling the engine fails closed everywhere instead. + // + // 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. + CommandDecl { + path: "record plan", + id: "record.plan", + about: "Record this branch's plan, read as ` ` lines on stdin", + data_channel: false, + effect: Effect::Write, + flags: &[], + }, + // The same argument one layer over, and here the envelope route is not merely + // per-harness — it is unreliable in the ordinary case. `filed-over-own-diff` + // exempts a row the PR CLOSES, and reads that from a `pr-closes` record the + // `pr-body-closes` recorder mints from an observed `gh pr view --jq .body` + // envelope. `land` fetches exactly that body and pipes it to + // `filed-here-check`, whose task body is `batten check`, which is declared + // `read` and has no stdin channel — so on the landing path the body is + // fetched, handed over, and dropped, and the exemption depends on an agent + // having separately made the same call as a mediated tool. Measured on this + // branch: three rows it closes, refused, with no record in the store at all. + // + // No positional, for `record plan`'s reason: the branch is the key. + CommandDecl { + path: "record closes", + id: "record.closes", + about: "Record which rows this branch's pull request body closes, read on stdin", + data_channel: false, + effect: Effect::Write, + flags: &[], + }, // A NEW NOUN rather than a flag on an existing verb, and two shapes were // considered and died on the same rule (CLOUD-893). `generate hooks --write` // and `doctor hooks --repair` both hang the effect off a FLAG, where §5 hangs diff --git a/crates/batten/src/trust.rs b/crates/batten/src/trust.rs index 468724279..cb0de7f3a 100644 --- a/crates/batten/src/trust.rs +++ b/crates/batten/src/trust.rs @@ -537,6 +537,13 @@ pub enum WeakeningKind { /// A path is gone from `epoch.tracked`, so the `config_epoch` attributes /// less than it did (CLOUD-32). EpochPathRemoved, + /// The prose-dialect cutover moved LATER, or stopped being declared, so + /// Ready blocks that owed the claims object no longer do (CLOUD-472). + /// + /// The direction is the whole of it: this is a ratchet, and later exempts + /// MORE rows. Removing the key entirely is the limit case of moving it + /// later — could-not-look exempts everything — so both reach one kind. + ReadyCutoverRelaxed, /// A `[[verb]]` row is gone, so a mutating tool call is no longer mediated /// at the `PreToolUse` boundary (CLOUD-36). VerbRemoved, @@ -819,6 +826,7 @@ impl WeakeningKind { WeakeningKind::RulePredicateChanged, WeakeningKind::MinVersionLowered, WeakeningKind::EpochPathRemoved, + WeakeningKind::ReadyCutoverRelaxed, WeakeningKind::VerbRemoved, WeakeningKind::PatternRemoved, WeakeningKind::VerdictOverrideAdded, @@ -875,6 +883,7 @@ impl WeakeningKind { WeakeningKind::RulePredicateChanged => "rule-predicate-changed", WeakeningKind::MinVersionLowered => "min-version-lowered", WeakeningKind::EpochPathRemoved => "epoch-path-removed", + WeakeningKind::ReadyCutoverRelaxed => "ready-cutover-relaxed", WeakeningKind::VerbRemoved => "verb-removed", WeakeningKind::PatternRemoved => "pattern-removed", WeakeningKind::VerdictOverrideAdded => "verdict-override-added", @@ -995,6 +1004,10 @@ pub const CENSUS: &[FieldCoverage] = &[ field: "protected_readers", coverage: Coverage::Compared(&[WeakeningKind::ProtectedReaderAdded]), }, + FieldCoverage { + field: "ready", + coverage: Coverage::Compared(&[WeakeningKind::ReadyCutoverRelaxed]), + }, FieldCoverage { field: "unlanded", coverage: Coverage::Compared(&[WeakeningKind::UnlandedRemoved]), @@ -1212,6 +1225,19 @@ pub const CENSUS: &[FieldCoverage] = &[ table as exit 1, never as a clean pass over commits it had no rule to judge", ), }, + FieldCoverage { + field: "bot_lane", + coverage: Coverage::NoMonotoneReading( + "every row in it moves the gate in BOTH directions at once, so there is no rank to \ + compare. Adding a login to `bots` or a glob to `owned_manifests` turns a refusal \ + into a filed row, which is a weakening; REMOVING one narrows what the lane will \ + file for, which is a tightening — and `marker_prefix`, `linkback_marker`, \ + `key_prefix` and `branch_prefix` are literals whose change makes the lane match \ + something else entirely rather than more or less. What keeps that from being a hole \ + is `resolve.rs`: the table is carried straight from the committed authority and the \ + local layer cannot reach it at all, so the only writer is the file a reviewer reads", + ), + }, FieldCoverage { field: "trust", coverage: Coverage::Compared(&[WeakeningKind::OfflineFallbackEnabled]), @@ -1643,6 +1669,36 @@ fn entry_weakenings(base: &Config, working: &Config) -> Vec { "epoch.tracked", )); + // The refinement gate's prose-dialect cutover (CLOUD-472). A ratchet, so + // LATER is weaker: it exempts more rows from owing the claims object, and + // dropping the key altogether is that move taken to its limit, since absent + // reads as could-not-look and exempts every row. Compared as strings because + // both sides are fixed-width ISO-8601 UTC, which is the same reading + // `policy/filed-here.rego` takes of a tracker stamp. + { + let cutover = |config: &Config| { + config + .ready + .as_ref() + .and_then(|ready| ready.prose_dialect_required_from.clone()) + }; + if let Some(was) = cutover(base) { + let now = cutover(working); + // Absent renders as the same could-not-look token every other + // three-valued read in this tree uses, so a reader of the finding + // sees WHICH move was made rather than an empty string. + let relaxed = now.as_ref().is_none_or(|now| now > &was); + if relaxed { + found.push(Weakening::new( + WeakeningKind::ReadyCutoverRelaxed, + "ready.prose_dialect_required_from", + was, + now.unwrap_or_else(|| "-".to_owned()), + )); + } + } + } + // The mutating-verb table: a removed row un-gates a tool call at the // `PreToolUse` boundary, which is the most consequential of these. found.extend(removed_entries( @@ -3168,6 +3224,47 @@ mod tests { assert!(weakenings(&working, &base).is_empty()); } + /// CLOUD-472. The direction is the whole of it, so all four arms are here: + /// later relaxes, absent is later taken to its limit, earlier tightens, and + /// a base that never declared a cutover has no bar to lower. + #[test] + fn moving_the_prose_dialect_cutover_later_is_a_weakening() { + let base = config("[ready]\nprose_dialect_required_from = \"2026-09-02T00:00:00.000Z\"\n"); + let later = config("[ready]\nprose_dialect_required_from = \"2027-01-01T00:00:00.000Z\"\n"); + assert_eq!( + only(&base, &later), + Weakening::new( + WeakeningKind::ReadyCutoverRelaxed, + "ready.prose_dialect_required_from", + "2026-09-02T00:00:00.000Z", + "2027-01-01T00:00:00.000Z", + ) + ); + + // DROPPING THE KEY IS THE LIMIT CASE, not a separate one: absent reads as + // could-not-look and exempts EVERY row, which is further than any date + // could move it. Reporting it as a no-op is how a ratchet gets removed + // rather than relaxed. + assert_eq!( + only(&base, &config("")), + Weakening::new( + WeakeningKind::ReadyCutoverRelaxed, + "ready.prose_dialect_required_from", + "2026-09-02T00:00:00.000Z", + "-", + ) + ); + + // Earlier is a TIGHTENING — it refuses more rows — and is not reported. + assert!(weakenings(&later, &base).is_empty()); + + // And a base with no cutover has no bar to lower, so ADDING one is not a + // weakening either. Without this arm the comparison would fire on every + // branch that adopts the ratchet, which is the direction that makes a + // gate get switched off. + assert!(weakenings(&config(""), &base).is_empty()); + } + #[test] fn removing_a_declared_pattern_is_a_weakening() { // NOT A LOAD FAILURE, which is the whole reason this is on the table. diff --git a/crates/batten/tests/it/bot_lane.rs b/crates/batten/tests/it/bot_lane.rs new file mode 100644 index 000000000..d1d32adca --- /dev/null +++ b/crates/batten/tests/it/bot_lane.rs @@ -0,0 +1,688 @@ +//! The bot lane over the compiled binary (CLOUD-1295). +//! +//! `tests/bot-issue.bats` replayed. Every case there drove `mise-tasks/bot-issue.sh` +//! against a stubbed forge client on `PATH`; these drive `batten pr …` and +//! `batten claim bot` against the same stub, so the port is asserted at the seam a +//! consumer actually uses rather than against a fabricated input shape. +//! +//! # Why the stub rather than a fixture policy +//! +//! `.claude/rules/policy-modules.md`'s second tier exists because the load-time +//! tier cannot see whether the ENGINE builds what a predicate reads. The same +//! reasoning applies one level up here: `bot::conventional_type` and +//! `bot::closing_key` are already pinned as pure functions in their own module, +//! and what those cannot see is whether the verb reaches them with the fields the +//! forge actually answers with. A stub that answers the real endpoints is what +//! closes that gap, and it is what lets this suite run on a machine with no +//! credentials at all — the standing `tests/checks-green.bats` had for the same +//! reason. +//! +//! # The ledger +//! +//! Two deleted paths, one arm each, and one arm per deleted `@test` case. The +//! successor is engine source rather than a `policy/*.rego` module — the lane +//! needs stdin, spawns the forge's client with its own arguments, and performs +//! writes, none of which a tree-scoped module may do — so each file arm declares +//! `kind:verb`: `batten pr derive|file|link|ensure|closes` and `batten claim bot` +//! are six new leaves on the command surface. +//! +//! ONE arm per deleted path, which `V-RETIREMENT-AMBIGUOUS` requires, and its +//! `runs:` field names `batten claim bot`. That is the only invocation a +//! GOVERNED caller loses: `tests/verify.bats` names the retired receipt command +//! in a comment and in an assertion, and both are repointed at the verb. The +//! lander's two workflow steps lose `pr ensure` and `pr closes`, and they need +//! no field — `.github/workflows/**` is ungoverned, so those repoints are free. +//! The field's spaces travel as `+`, since an arm is space-separated. +// +// carried: mise-tasks/bot-issue.sh crates/batten/src/bot.rs kind:verb crates/batten/tests/it/bot_lane.rs runs:batten+claim+bot +// carried: tests/bot-issue.bats crates/batten/src/bot.rs kind:verb crates/batten/tests/it/bot_lane.rs +// +// carried: "a bump PR with no row gets one, and the PR is told which row it closes" crates/batten/src/bot.rs kind:verb +// carried: "the mirror issue carries the derived block and a marker naming its PR" crates/batten/src/bot.rs kind:verb +// carried: "THE PR CLOSES THE CLOUD KEY, never the mirror issue (CLOUD-750)" crates/batten/src/bot.rs kind:verb +// carried: "a mirror that is not yet mirrored links nothing, and says so" crates/batten/src/bot.rs kind:verb +// carried: "a second tick reuses the mirror it already filed rather than opening another" crates/batten/src/bot.rs kind:verb +// carried: "IDEMPOTENCE: a second call on the same PR files nothing" crates/batten/src/bot.rs kind:verb +// carried: "a non-bot PR is untouched, and the refusal says whose it is" crates/batten/src/bot.rs kind:verb +// carried: "the retired bot is not on the allowlist either (CLOUD-660)" crates/batten/src/bot.rs kind:verb +// carried: "a PR touching no owned manifest is REFUSED, never given an invented row" crates/batten/src/bot.rs kind:verb +// carried: "a workflow bump is owned too — that manager is in the same lane" crates/batten/src/bot.rs kind:verb +// carried: "a subject with no Conventional type is refused, because that commit could never land" crates/batten/src/bot.rs kind:verb +// carried: "THE DERIVED BLOCK PASSES ready-lint — the same gate a human's row passes" crates/batten/src/bot.rs kind:verb +// carried: "the §6 type is READ from the subject, not chosen here" crates/batten/src/bot.rs kind:verb +// carried: "derive writes nothing — it is the half a gate can read" crates/batten/src/bot.rs kind:verb +// carried: "a mirror that cannot be opened is exit 2, and no key is invented" crates/batten/src/bot.rs kind:verb +// carried: "a body that still closes its row is landable, and the verdict names the key" crates/batten/src/bot.rs kind:verb +// carried: "A KEY NAMED BUT NOT CLOSED IS REFUSED — that is the whole failure being caught" crates/batten/src/bot.rs kind:verb +// carried: "a body naming no key at all is refused, not treated as nothing to check" crates/batten/src/bot.rs kind:verb +// carried: "fixes and resolves close it too — the predicate is closing-key-check's, not link's" crates/batten/src/bot.rs kind:verb +// carried: "DO-NOT-CLOSE does not read as a close, though the marker ends in a closing verb" crates/batten/src/bot.rs kind:verb +// carried: "POINTER, NEVER PAYLOAD: the refusal names the PR and no part of the body" crates/batten/src/bot.rs kind:verb +// carried: "closes writes nothing — it is a read, and a refusal must not repair by editing" crates/batten/src/bot.rs kind:verb + +// Panicking on setup failure is the idiomatic way for a test to fail loudly. +// +// UNIX-ONLY, AND THE WINDOWS FAILURE IS WORSE THAN A COULD-NOT-RUN. Every case +// below answers the forge from a `#!/usr/bin/env bash` stub placed first on +// `PATH`. Windows resolves an executable by `PATHEXT`, so an extensionless +// script is not a candidate at all: the stub is skipped, and a Windows runner +// with the REAL `gh` installed then resolves to it — the suite would drive an +// unauthenticated client at `repos/demo/repo` instead of asserting anything. +// Measured on this branch: two cases reported the port broken (exit 3) where the +// port was fine and the stub had simply never run. +// +// `session_provisioning.rs` and `connector_allow_door.rs` gate their whole +// suites on this rung for the same reason, and the retired `tests/bot-issue.bats` +// never ran on Windows either — it stubbed the same client the same way — so +// nothing is narrowed that was covered. A `.cmd` twin of the dispatch would be a +// second authority over what the stub answers, which is the class +// `.claude/rules/policy-modules.md` refuses one level down. +#![cfg(unix)] +#![allow(clippy::unwrap_used, clippy::expect_used)] + +use std::path::{Path, PathBuf}; + +use crate::common::{ + Fixture, batten, declared_patterns, run_with_stdin, scratch, stderr, stdout, write, +}; + +/// What the stubbed forge answers about the pull request under test. +/// +/// A struct rather than a pile of environment variables so a case names only the +/// field it is about — the same shape the dying suite's `setup()` had, where each +/// case rewrote one variable and inherited the rest. +struct Forge { + title: String, + login: String, + body: String, + files: String, + /// Whether a mirror issue already exists for this pull request. + mirror: bool, + /// Whether the tracker's sync has posted its linkback comment yet. + linkback: bool, + /// Whether opening an issue succeeds. + create_ok: bool, +} + +impl Default for Forge { + fn default() -> Self { + Forge { + title: "build(deps): update cargo".to_owned(), + login: "renovate[bot]".to_owned(), + body: "This PR contains the following updates.".to_owned(), + files: "Cargo.toml\nCargo.lock".to_owned(), + mirror: false, + linkback: true, + create_ok: true, + } + } +} + +/// The lane's own facts, as `batten.toml` declares them. +/// +/// Spelled here rather than read from this repository's committed table, and +/// deliberately: the suite is about the MATCHER, and a fixture inheriting the +/// consumer's real logins would pass or fail on whether Renovate is still the +/// bot this repository uses. +const LANE: &str = "\n[bot_lane]\n\ +repo = \"demo/repo\"\n\ +bots = [\"renovate\", \"renovate[bot]\", \"mend-for-github-com[bot]\"]\n\ +owned_manifests = [\"mise.toml\", \"Cargo.toml\", \"Cargo.lock\", \".github/workflows/**\"]\n\ +marker_prefix = \"bot-lane pr=\"\n\ +linkback_marker = \"\"\n\ +key_prefix = \"CLOUD-\"\n\ +branch_prefix = \"renovate/\"\n\ +body_template = \"row.md\"\n"; + +/// The body template the fixture derives from. Short on purpose: the real one is +/// a page of consumer prose, and what these cases are about is that the +/// placeholders reach it and that no unfilled one survives. +const TEMPLATE: &str = "**Refinement — Ready**\n\n\ +* **Source of truth (§1).** The manifest diff on #{{pr}}, opened by `{{login}}` on `{{branch}}`.\n\n\ +{{manifests}}\n\n\ +* **Commit / bump (§6).** `{{type}}` → no bump.\n"; + +/// A fixture repository declaring the lane, with the stub on `PATH`. +/// +/// Returns the repository and the directory the stub records its writes into, so +/// a case can assert what the forge was ASKED to store rather than only what the +/// verb printed. +fn lane(name: &str, forge: &Forge) -> (PathBuf, PathBuf) { + let root = scratch(name); + let repo = Fixture::at(root.join("repo")) + .config("version = 1\n") + .config_append(LANE) + // The REAL Ready grammar, read from the committed table rather than + // re-typed: `ready lint`'s vocabulary is the consumer's `[[pattern]]` + // rows, and a fixture spelling its own would assert about a grammar this + // repository does not use. + .config_append(&declared_patterns()) + .file("row.md", TEMPLATE) + // `ready lint`'s own minimum input: §6 needs the workspace version to + // know which SemVer arrows fire, and it reports an unreadable one as a + // usage error rather than as a clean pass. Below 0.1.0, matching this + // repository, so the arrows the derived block's `→ no bump` is judged + // against are the ones a real row is judged against. + .file( + "Cargo.toml", + "[workspace]\nmembers = []\n[workspace.package]\nversion = \"0.0.1\"\n", + ) + .git() + .base_commit() + .build(); + let stub = root.join("stub"); + let recorded = root.join("recorded"); + std::fs::create_dir_all(&stub).unwrap(); + std::fs::create_dir_all(&recorded).unwrap(); + write_stub(&stub, &recorded, forge); + (repo, recorded) +} + +/// Write the `gh` stub. +/// +/// It DISPATCHES ON THE ENDPOINT and answers what `--jq` would have produced, so +/// the stub answers the call rather than re-implementing the tool — the dying +/// suite's own words, and the property that keeps this from asserting its own +/// fixture. +fn write_stub(stub: &Path, recorded: &Path, forge: &Forge) { + let body = format!( + "#!/usr/bin/env bash\n\ +args=\"$*\"\n\ +case \"$args\" in\n\ + *\"-X POST\"*\"/issues\"*)\n\ + if [ '{create_ok}' != yes ]; then echo refused >&2; exit 1; fi\n\ + cat > '{recorded}/issue-body'\n\ + echo 41\n\ + ;;\n\ + *\"-X PATCH\"*)\n\ + cat > '{recorded}/patched-body'\n\ + echo '{{}}'\n\ + ;;\n\ + *\"/comments\"*)\n\ + if [ '{linkback}' = yes ]; then\n\ + printf '%s\\n' ' see https://example.test/CLOUD-700/x'\n\ + fi\n\ + ;;\n\ + *\"issues?state=all\"*)\n\ + if [ '{mirror}' = yes ]; then printf '%s\\n' 41; fi\n\ + ;;\n\ + *\"/files\"*)\n\ + printf '%s\\n' '{files}'\n\ + ;;\n\ + *\"pulls?state=open\"*)\n\ + printf '%s\\n' 7\n\ + ;;\n\ + *\"repos/demo/repo/pulls/\"*)\n\ + printf '%s\\t%s\\t%s\\t%s\\n' '{title}' '{body}' '{login}' 'renovate/cargo'\n\ + ;;\n\ + *) echo \"unstubbed gh call: $args\" >&2; exit 1 ;;\n\ +esac\n", + create_ok = yes_no(forge.create_ok), + linkback = yes_no(forge.linkback), + mirror = yes_no(forge.mirror), + recorded = recorded.display(), + files = forge.files, + title = forge.title, + body = forge.body, + login = forge.login, + ); + let path = stub.join("gh"); + std::fs::write(&path, body).unwrap(); + make_executable(&path); +} + +fn yes_no(flag: bool) -> &'static str { + if flag { "yes" } else { "no" } +} + +// No `#[cfg(unix)]` pair here: the module gate above already decides the target, +// so a `#[cfg(not(unix))]` twin would be a definition nothing can reach. +fn make_executable(path: &Path) { + use std::os::unix::fs::PermissionsExt as _; + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755)).unwrap(); +} + +/// Run `batten` in `repo` with the stub ahead of the real `PATH`. +fn lane_run(repo: &Path, args: &[&str]) -> (Option, String, String) { + let stub = repo.parent().unwrap().join("stub"); + // `join_paths` rather than an interpolated separator (CLOUD-617): the + // separator is `;` on Windows, where a path begins `D:\`, so a `format!` + // here does not merely fail to separate — it yields a PATH whose first entry + // is a drive letter. + let mut entries = vec![stub]; + entries.extend(std::env::split_paths( + &std::env::var_os("PATH").unwrap_or_default(), + )); + let path = std::env::join_paths(entries).expect("compose PATH"); + let output = batten() + .args(args) + .current_dir(repo) + .env("PATH", path) + .output() + .expect("run batten"); + (output.status.code(), stdout(&output), stderr(&output)) +} + +/// What the stub recorded the mirror issue's body as. +fn issue_body(recorded: &Path) -> String { + std::fs::read_to_string(recorded.join("issue-body")).unwrap_or_default() +} + +/// What the stub recorded the pull request's rewritten body as. +fn patched_body(recorded: &Path) -> String { + std::fs::read_to_string(recorded.join("patched-body")).unwrap_or_default() +} + +// -- ensure ------------------------------------------------------------------ + +#[test] +fn a_bump_pr_with_no_row_gets_one_and_the_pr_is_told_which_row_it_closes() { + let (repo, recorded) = lane("lane-ensure", &Forge::default()); + let (code, _, err) = lane_run(&repo, &["pr", "ensure", "7"]); + assert_eq!(code, Some(0), "{err}"); + assert!(!issue_body(&recorded).is_empty(), "the row was filed"); + assert!( + patched_body(&recorded).contains("Closes CLOUD-700"), + "the pull request is told which row it closes: {}", + patched_body(&recorded) + ); +} + +#[test] +fn the_mirror_issue_carries_the_derived_block_and_a_marker_naming_its_pr() { + let (repo, recorded) = lane("lane-marker", &Forge::default()); + assert_eq!(lane_run(&repo, &["pr", "ensure", "7"]).0, Some(0)); + let filed = issue_body(&recorded); + assert!(filed.contains("**Refinement — Ready**"), "{filed}"); + // The marker goes LAST, so it is the one line a reader never has to look at + // and the one line `ensure` always finds. + assert!( + filed.trim_end().ends_with(""), + "{filed}" + ); +} + +#[test] +fn the_pr_closes_the_tracker_key_never_the_mirror_issue() { + // Closing the mirror would move the row to Done, and Done means RELEASED — + // so it would assert a release that has not happened. + let (repo, recorded) = lane("lane-closes-key", &Forge::default()); + assert_eq!(lane_run(&repo, &["pr", "ensure", "7"]).0, Some(0)); + let patched = patched_body(&recorded); + assert!(patched.contains("Closes CLOUD-700"), "{patched}"); + assert!( + !patched.contains("Closes #41"), + "never the mirror: {patched}" + ); +} + +#[test] +fn a_mirror_that_is_not_yet_mirrored_links_nothing_and_says_so() { + let forge = Forge { + linkback: false, + ..Forge::default() + }; + let (repo, recorded) = lane("lane-unmirrored", &forge); + let (code, _, err) = lane_run(&repo, &["pr", "ensure", "7"]); + assert_eq!( + code, + Some(0), + "a tick that cannot finish still made progress" + ); + assert!(err.contains("not mirrored yet"), "{err}"); + assert!(patched_body(&recorded).is_empty(), "nothing was linked"); +} + +#[test] +fn a_second_tick_reuses_the_mirror_it_already_filed_rather_than_opening_another() { + let forge = Forge { + mirror: true, + ..Forge::default() + }; + let (repo, recorded) = lane("lane-reuse", &forge); + assert_eq!(lane_run(&repo, &["pr", "ensure", "7"]).0, Some(0)); + assert!( + issue_body(&recorded).is_empty(), + "no second issue was opened" + ); + assert!(patched_body(&recorded).contains("Closes CLOUD-700")); +} + +#[test] +fn idempotence_a_second_call_on_the_same_pr_files_nothing() { + // The mutation the dying suite declared: dropping the already-linked short + // circuit files a second row on every tick, forever, against a pull request + // that already has one. + let forge = Forge { + body: "This PR contains the following updates. Closes CLOUD-700".to_owned(), + ..Forge::default() + }; + let (repo, recorded) = lane("lane-idempotent", &forge); + let (code, _, err) = lane_run(&repo, &["pr", "ensure", "7"]); + assert_eq!(code, Some(0), "{err}"); + assert!(err.contains("already names CLOUD-700"), "{err}"); + assert!(issue_body(&recorded).is_empty(), "nothing filed"); + assert!(patched_body(&recorded).is_empty(), "nothing patched"); +} + +// -- derive's refusals ------------------------------------------------------- + +#[test] +fn a_non_bot_pr_is_untouched_and_the_refusal_says_whose_it_is() { + let forge = Forge { + login: "a-human".to_owned(), + ..Forge::default() + }; + let (repo, recorded) = lane("lane-human", &forge); + let (code, _, err) = lane_run(&repo, &["pr", "derive", "7"]); + assert_eq!(code, Some(2), "{err}"); + assert!(err.contains("a-human"), "names whose it is: {err}"); + assert!(issue_body(&recorded).is_empty()); +} + +#[test] +fn the_retired_bot_is_not_on_the_allowlist_either() { + // A row filed for a bot that cannot open a pull request would be a claim + // about a lane this repository does not have. + let forge = Forge { + login: "dependabot[bot]".to_owned(), + ..Forge::default() + }; + let (repo, _) = lane("lane-dependabot", &forge); + let (code, _, err) = lane_run(&repo, &["pr", "derive", "7"]); + assert_eq!(code, Some(2), "{err}"); + assert!(err.contains("dependabot[bot]"), "{err}"); +} + +#[test] +fn a_pr_touching_no_owned_manifest_is_refused_never_given_an_invented_row() { + let forge = Forge { + files: "README.md\nsrc/main.rs".to_owned(), + ..Forge::default() + }; + let (repo, recorded) = lane("lane-unowned", &forge); + let (code, _, err) = lane_run(&repo, &["pr", "derive", "7"]); + assert_eq!(code, Some(2), "{err}"); + assert!(err.contains("README.md"), "names the paths: {err}"); + assert!(issue_body(&recorded).is_empty(), "no row was invented"); +} + +#[test] +fn a_workflow_bump_is_owned_too() { + let forge = Forge { + title: "ci: bump the action".to_owned(), + files: ".github/workflows/ci.yml".to_owned(), + ..Forge::default() + }; + let (repo, _) = lane("lane-workflow", &forge); + let (code, report, err) = lane_run(&repo, &["pr", "derive", "7"]); + assert_eq!(code, Some(0), "{err}"); + assert!(report.contains(".github/workflows/ci.yml"), "{report}"); +} + +#[test] +fn a_subject_with_no_conventional_type_is_refused() { + // That commit could never land: the commit gate would refuse it, so the lane + // says so rather than filing a row for a commit that cannot merge. + let forge = Forge { + title: "update cargo deps".to_owned(), + ..Forge::default() + }; + let (repo, _) = lane("lane-untyped", &forge); + let (code, _, err) = lane_run(&repo, &["pr", "derive", "7"]); + assert_eq!(code, Some(2), "{err}"); + assert!(err.contains("Conventional type"), "{err}"); +} + +#[test] +fn the_section_six_type_is_read_from_the_subject_not_chosen_here() { + let forge = Forge { + title: "ci: bump the action".to_owned(), + files: ".github/workflows/ci.yml".to_owned(), + ..Forge::default() + }; + let (repo, _) = lane("lane-type", &forge); + let (_, report, _) = lane_run(&repo, &["pr", "derive", "7"]); + assert!(report.contains("`ci` → no bump"), "{report}"); + assert!(!report.contains("`build` → no bump"), "{report}"); +} + +#[test] +fn derive_writes_nothing_it_is_the_half_a_gate_can_read() { + let (repo, recorded) = lane("lane-derive-pure", &Forge::default()); + let (code, report, _) = lane_run(&repo, &["pr", "derive", "7"]); + assert_eq!(code, Some(0)); + assert!(issue_body(&recorded).is_empty(), "opened no issue"); + assert!(patched_body(&recorded).is_empty(), "patched no body"); + // The payload's shape is the tracker's own, so the refinement gate reads it + // unchanged — which is what keeps "derived" from meaning "exempt". + let payload: serde_json::Value = serde_json::from_str(report.trim()).expect("a payload"); + assert_eq!(payload["status"], "Todo"); + assert!(payload["description"].as_str().unwrap().contains("Ready")); +} + +#[test] +fn a_mirror_that_cannot_be_opened_is_not_a_key_invented() { + let forge = Forge { + create_ok: false, + ..Forge::default() + }; + let (repo, recorded) = lane("lane-create-fails", &forge); + let (code, _, err) = lane_run(&repo, &["pr", "ensure", "7"]); + // Could-not-look rather than a verdict: the forge refused, so nothing is + // known about whether a row should exist. + assert_eq!(code, Some(3), "{err}"); + assert!(patched_body(&recorded).is_empty(), "no key was invented"); +} + +// -- closes ------------------------------------------------------------------ + +#[test] +fn a_body_that_still_closes_its_row_is_landable_and_the_verdict_names_the_key() { + let forge = Forge { + body: "updates. Closes CLOUD-700".to_owned(), + ..Forge::default() + }; + let (repo, _) = lane("lane-still-closes", &forge); + let (code, _, err) = lane_run(&repo, &["pr", "closes", "7"]); + assert_eq!(code, Some(0), "{err}"); + assert!(err.contains("CLOUD-700"), "{err}"); +} + +#[test] +fn a_key_named_but_not_closed_is_refused() { + // THE WHOLE FAILURE BEING CAUGHT, and the mutation the dying suite declared: + // a rebase regenerates the bot's body and the closing line goes with it, + // leaving a body that still mentions the key and moves nothing. + let forge = Forge { + body: "updates. See CLOUD-700 for context".to_owned(), + ..Forge::default() + }; + let (repo, _) = lane("lane-named-only", &forge); + let (code, _, err) = lane_run(&repo, &["pr", "closes", "7"]); + assert_eq!(code, Some(2), "{err}"); + assert!(err.contains("closes no tracker key"), "{err}"); +} + +#[test] +fn a_body_naming_no_key_at_all_is_refused() { + let (repo, _) = lane("lane-no-key", &Forge::default()); + let (code, _, err) = lane_run(&repo, &["pr", "closes", "7"]); + assert_eq!(code, Some(2), "{err}"); +} + +#[test] +fn fixes_and_resolves_close_it_too() { + // The predicate is the board gate's, not `link`'s: a body a human edited to + // say "Fixes CLOUD-767" closes the row just as well. + for verb in ["Fixes", "Resolves", "fixed"] { + let forge = Forge { + body: format!("updates. {verb} CLOUD-701"), + ..Forge::default() + }; + let (repo, _) = lane(&format!("lane-verb-{verb}"), &forge); + let (code, _, err) = lane_run(&repo, &["pr", "closes", "7"]); + assert_eq!(code, Some(0), "{verb}: {err}"); + } +} + +#[test] +fn do_not_close_does_not_read_as_a_close() { + // The marker ends in a closing verb, which is exactly why it is the case + // worth pinning. + let forge = Forge { + body: "updates. DO-NOT-CLOSE CLOUD-388".to_owned(), + ..Forge::default() + }; + let (repo, _) = lane("lane-do-not-close", &forge); + let (code, _, err) = lane_run(&repo, &["pr", "closes", "7"]); + assert_eq!(code, Some(2), "{err}"); +} + +#[test] +fn pointer_never_payload_the_refusal_names_the_pr_and_no_part_of_the_body() { + // A bot pull request carries a release-notes dump, and echoing it would put + // that in the log of every landing (non-negotiable rule 4). + let forge = Forge { + body: "SECRETSAUCE release notes for every bumped package".to_owned(), + ..Forge::default() + }; + let (repo, _) = lane("lane-pointer", &forge); + let (_, report, err) = lane_run(&repo, &["pr", "closes", "7"]); + assert!(err.contains("#7"), "names the pull request: {err}"); + assert!(!err.contains("SECRETSAUCE"), "no body on stderr: {err}"); + assert!(!report.contains("SECRETSAUCE"), "none on stdout: {report}"); +} + +#[test] +fn closes_writes_nothing() { + // It is a read, and a refusal must not repair by editing. + let forge = Forge { + body: "updates. See CLOUD-700".to_owned(), + ..Forge::default() + }; + let (repo, recorded) = lane("lane-closes-pure", &forge); + assert_eq!(lane_run(&repo, &["pr", "closes", "7"]).0, Some(2)); + assert!(patched_body(&recorded).is_empty()); + assert!(issue_body(&recorded).is_empty()); +} + +/// THE CLAIM THAT MAKES A MECHANICAL ROW HONEST, and the dying suite's own +/// load-bearing case: the derived block is checkable by the SAME gate a human's +/// row passes. The real `ready lint` runs over the real derived payload — a stub +/// here would assert the property rather than test it. +/// +/// It runs over THIS REPOSITORY's committed template rather than the fixture's +/// short one, because that is the text a tracker row will actually carry, and it +/// is the one a formatter can silently reshape out from under the grammar. +#[test] +fn the_derived_block_passes_ready_lint() { + let (repo, _) = lane("lane-ready-lint", &Forge::default()); + let template = std::fs::read_to_string(crate::common::at_root(".github/bot-lane-row.md")) + .expect("the committed body template"); + write(&repo, "row.md", &template); + let (code, payload, err) = lane_run(&repo, &["pr", "derive", "7"]); + assert_eq!(code, Some(0), "{err}"); + let linted = run_with_stdin(&repo, &["ready", "lint"], &payload); + assert_eq!( + linted.status.code(), + Some(0), + "the derived block must pass the gate a human's row passes: {}{}", + stdout(&linted), + stderr(&linted) + ); +} + +// -- the receipt ------------------------------------------------------------- + +#[test] +fn a_bot_branch_whose_facts_hold_earns_the_receipt() { + // THE PREMISE. Every refusal below is a refusal against this one, which is + // what keeps them from being satisfied by a verb that refuses everything. + let forge = Forge { + body: "updates. Closes CLOUD-700".to_owned(), + ..Forge::default() + }; + let (repo, _) = lane("lane-receipt", &forge); + crate::common::git_in(&repo, &["checkout", "-q", "-b", "renovate/cargo"]); + let (code, _, err) = lane_run(&repo, &["claim", "bot"]); + assert_eq!(code, Some(0), "{err}"); + let recorded = std::fs::read_to_string( + repo.join(".git") + .join("batten-receipts") + .join("bot.renovate-cargo"), + ) + .expect("the receipt is written"); + assert!(recorded.contains("CLOUD-700"), "{recorded}"); + assert!(recorded.contains("bot renovate[bot]"), "{recorded}"); + // The base line is what gives this receipt CLOUD-516's staleness rule: a + // branch restarted out from under it is void rather than silently trusted. + assert!(recorded.contains("\nbase "), "{recorded}"); +} + +#[test] +fn a_branch_outside_the_lanes_prefix_is_sent_to_the_agent_claim() { + // The two receipts attest different things, and widening the agent one to + // cover bots would make it mean less everywhere (CLOUD-431). + let (repo, _) = lane("lane-not-bot-branch", &Forge::default()); + crate::common::git_in(&repo, &["checkout", "-q", "-b", "claude/some-work"]); + let (code, _, err) = lane_run(&repo, &["claim", "bot"]); + assert_eq!(code, Some(2), "{err}"); + assert!(err.contains("claim check"), "names the other route: {err}"); + assert!(!receipt_dir(&repo).exists(), "and mints nothing"); +} + +#[test] +fn a_bot_branch_whose_pr_names_no_row_yet_earns_nothing() { + let (repo, _) = lane("lane-unnamed", &Forge::default()); + crate::common::git_in(&repo, &["checkout", "-q", "-b", "renovate/cargo"]); + let (code, _, err) = lane_run(&repo, &["claim", "bot"]); + assert_eq!(code, Some(2), "{err}"); + assert!(err.contains("pr ensure"), "names the remedy: {err}"); + assert!(!receipt_dir(&repo).exists()); +} + +/// Where a receipt would be, so a case can assert that none was minted. +fn receipt_dir(repo: &Path) -> PathBuf { + repo.join(".git").join("batten-receipts") +} + +// -- the lane's own absence -------------------------------------------------- + +#[test] +fn a_repository_declaring_no_lane_says_so_rather_than_filing_against_defaults() { + // A lane assembled from engine defaults would file a row asserting a bump + // nobody configured, which is the class this whole surface exists to refuse. + let root = scratch("lane-absent"); + let repo = Fixture::at(root.join("repo")) + .config("version = 1\n") + .git() + .base_commit() + .build(); + let output = batten() + .args(["pr", "derive", "7"]) + .current_dir(&repo) + .output() + .expect("run batten"); + assert_eq!( + output.status.code(), + Some(1), + "a usage error, not a verdict" + ); + assert!( + stderr(&output).contains("[bot_lane]"), + "{}", + stderr(&output) + ); +} + +#[test] +fn a_template_naming_a_placeholder_nothing_fills_refuses_rather_than_rendering_it() { + // A tracker row carrying a literal `{{...}}` reads as a lane that half-ran, + // and nobody would notice until a human opened the row. + let (repo, _) = lane("lane-bad-template", &Forge::default()); + write(&repo, "row.md", "* §1 the diff on #{{pr}} by {{whoever}}\n"); + let (code, _, err) = lane_run(&repo, &["pr", "derive", "7"]); + assert_eq!(code, Some(1), "{err}"); + assert!(err.contains("whoever"), "names the placeholder: {err}"); +} diff --git a/crates/batten/tests/it/claim_carry.rs b/crates/batten/tests/it/claim_carry.rs new file mode 100644 index 000000000..ec2af84c1 --- /dev/null +++ b/crates/batten/tests/it/claim_carry.rs @@ -0,0 +1,181 @@ +//! `batten claim carry` over the compiled binary (CLOUD-1295). +//! +//! # Why this tier and not only the unit one +//! +//! `carry::judge` is pure and `src/carry.rs` drives every refusal against +//! strings. What it cannot see is whether the ENGINE builds the inputs that +//! predicate reads — the merge base, the table on each side, the set of other +//! changed paths. A dead reader and a clean branch are byte-identical on the +//! decision surface, which is the failure `.claude/rules/policy-modules.md` +//! records for exactly this shape. +//! +//! So these cases build real repositories and run the real verb. +//! +//! # The premise case is not decoration +//! +//! `a_branch_carrying_one_row_forward_mints_the_receipt` is what the refusals +//! below are refusals *against*. Without it a verb that refused everything would +//! satisfy all of them, which is CLOUD-418's vacuity. + +// Panicking on setup failure is the idiomatic way for a test to fail loudly. +#![allow(clippy::unwrap_used, clippy::expect_used)] + +use std::path::{Path, PathBuf}; + +use crate::common::{Fixture, git_in, run, stderr, stdout, write}; + +/// The licence table's path, as the engine names it. +const TABLE: &str = "mise-tasks/sbom-actions.tsv"; + +/// A base table with two mapped repos and a comment the parser must skip. +const BASE: &str = "# how each row was sourced\n\ +tool/runner-action@aaa\tMIT\tCopyright (c) 2018 A Holder and contributors\n\ +tool/install-action@bbb\tApache-2.0 OR MIT\tNONE\n"; + +/// A repository whose `origin/main` carries [`BASE`], with `head` then written +/// over the table and committed as the branch's own work. +/// +/// `origin/main` is pinned by [`Fixture::base_commit`], so the merge base the +/// verb resolves is a real one rather than a fixture-only convention. +fn carry_branch(name: &str, head: &str) -> PathBuf { + let dir = Fixture::new(name) + .config("version = 1\n") + .file(TABLE, BASE) + .git() + .base_commit() + .build(); + git_in(&dir, &["checkout", "-q", "-b", "sbom-actions/carry-probe"]); + write(&dir, TABLE, head); + git_in(&dir, &["add", "-A"]); + // `--allow-empty` for `Fixture::work_commit`'s reason: a branch that changes + // nothing is a case the verb must still judge, and it has nothing to commit. + // Without it `a_branch_that_changed_nothing_earns_no_receipt` dies in setup + // rather than reaching the assertion it exists for. + git_in( + &dir, + &["commit", "-q", "--allow-empty", "-m", "ci(deps): carry"], + ); + dir +} + +/// `batten claim carry` as (exit code, stdout, stderr). +fn carry(dir: &Path) -> (Option, String, String) { + let output = run(dir, &["claim", "carry"]); + (output.status.code(), stdout(&output), stderr(&output)) +} + +/// Whether the receipt the verb writes is on disk under the branch's name. +fn receipt(dir: &Path) -> Option { + let path = dir + .join(".git") + .join("batten-receipts") + .join("carry.sbom-actions-carry-probe"); + std::fs::read_to_string(path).ok() +} + +/// THE PREMISE. Every refusal below is a refusal against this. +#[test] +fn a_branch_carrying_one_row_forward_mints_the_receipt() { + let head = format!( + "{BASE}tool/runner-action@ccc\tMIT\tCopyright (c) 2018 A Holder and contributors\n" + ); + let dir = carry_branch("carry-happy", &head); + let (code, _, err) = carry(&dir); + assert_eq!(code, Some(0), "a derivable branch is admitted: {err}"); + let recorded = receipt(&dir).expect("the receipt is written"); + assert!(recorded.contains("carry 1 row(s)"), "{recorded}"); + // The base line is what gives this receipt CLOUD-516's staleness rule, the + // same way `bot.` gets it. A receipt without one is trusted forever. + assert!(recorded.contains("\nbase "), "records its base: {recorded}"); +} + +/// Pointer-only (rule 4): the receipt records a count and a path, never a +/// licence or a holder — those are the bytes the table exists to hold. +#[test] +fn the_receipt_records_no_licence_and_no_holder() { + let head = format!( + "{BASE}tool/runner-action@ccc\tMIT\tCopyright (c) 2018 A Holder and contributors\n" + ); + let dir = carry_branch("carry-pointer", &head); + assert_eq!(carry(&dir).0, Some(0)); + let recorded = receipt(&dir).expect("the receipt is written"); + assert!(!recorded.contains("MIT"), "no licence: {recorded}"); + assert!(!recorded.contains("A Holder"), "no holder: {recorded}"); +} + +/// A second changed path is refused and NAMED, so an author knows which. +#[test] +fn a_branch_touching_a_second_path_is_refused() { + let head = format!( + "{BASE}tool/runner-action@ccc\tMIT\tCopyright (c) 2018 A Holder and contributors\n" + ); + let dir = carry_branch("carry-second-path", &head); + write(&dir, "notes.md", "an unrelated edit\n"); + git_in(&dir, &["add", "-A"]); + git_in(&dir, &["commit", "-q", "-m", "ci(deps): and a note"]); + + let (code, report, _) = carry(&dir); + assert_eq!(code, Some(2), "a second path is not a carry"); + assert!(report.contains("notes.md"), "names the path: {report}"); + assert!(receipt(&dir).is_none(), "and mints nothing"); +} + +/// The case that makes this more than a diff-size check: a repo with no recorded +/// verdict has nothing to carry, so admitting it would ASSERT a licence. +#[test] +fn a_row_for_an_unmapped_repo_is_refused_over_the_binary() { + let head = format!("{BASE}brand/new-action@ddd\tMIT\tCopyright (c) 2026 Somebody\n"); + let dir = carry_branch("carry-unmapped", &head); + let (code, report, _) = carry(&dir); + assert_eq!(code, Some(2)); + assert!(report.contains("no-prior-row"), "{report}"); + assert!( + report.contains("brand/new-action"), + "names the repo: {report}" + ); + assert!(receipt(&dir).is_none()); +} + +/// A carry changes the sha and nothing else; a moved licence is a new claim. +#[test] +fn a_row_whose_licence_moved_is_refused_over_the_binary() { + let head = format!( + "{BASE}tool/runner-action@ccc\tGPL-3.0\tCopyright (c) 2018 A Holder and contributors\n" + ); + let dir = carry_branch("carry-relicensed", &head); + let (code, report, _) = carry(&dir); + assert_eq!(code, Some(2)); + assert!(report.contains("verdict-changed"), "{report}"); + // Pointer-only: the refusal names the repo, never the licence it compared. + assert!( + !report.contains("GPL-3.0"), + "no licence in the line: {report}" + ); + assert!(receipt(&dir).is_none()); +} + +/// Rewriting an existing row must not read as an addition — the prefix +/// comparison is what makes that true, and this drives it over the binary. +#[test] +fn rewriting_a_row_in_place_is_refused_over_the_binary() { + let head = "# how each row was sourced\n\ +tool/runner-action@aaa\tGPL-3.0\tCopyright (c) 2018 A Holder and contributors\n\ +tool/install-action@bbb\tApache-2.0 OR MIT\tNONE\n"; + let dir = carry_branch("carry-rewrite", head); + let (code, report, _) = carry(&dir); + assert_eq!(code, Some(2)); + assert!(report.contains("not-append-only"), "{report}"); + assert!(receipt(&dir).is_none()); +} + +/// A branch that changed nothing has nothing to attest. Without this, any branch +/// touching no tracked file would earn a receipt — which is the branch-name +/// exemption arriving by another door. +#[test] +fn a_branch_that_changed_nothing_earns_no_receipt() { + let dir = carry_branch("carry-empty", BASE); + let (code, report, _) = carry(&dir); + assert_eq!(code, Some(2), "nothing carried is not a carry: {report}"); + assert!(report.contains("nothing-carried"), "{report}"); + assert!(receipt(&dir).is_none()); +} diff --git a/crates/batten/tests/it/claim_receipt.rs b/crates/batten/tests/it/claim_receipt.rs index c1b2ce29e..5dc3d35ab 100644 --- a/crates/batten/tests/it/claim_receipt.rs +++ b/crates/batten/tests/it/claim_receipt.rs @@ -456,3 +456,133 @@ fn a_detached_head_cannot_answer_and_says_so_rather_than_refusing() { // And the hook's own carve-out is unchanged. assert_allowed(&dir, "src/tracked.rs"); } + +// --- the alternation (CLOUD-1297) ------------------------------------------- + +/// The same row, spelled as an alternation instead of a conjunction. +/// +/// A SEPARATE POLICY CONSTANT rather than a mutation of [`POLICY`], because the +/// two spellings must both keep working: every case above drives the conjunction +/// and every case below drives the alternation, and folding them into one +/// fixture would leave whichever spelling the fixture did not use untested. +const ALTERNATION: &str = r#"version = 1 + +[[rule]] +id = "claim-needs-receipt" +kind = "receipt" +scope = "mediated_call" +severity = "deny" +trigger = "write" +checks_any = ["claim", "bot", "carry"] +key = "branch" +reason = "pipe the issue payload to `mise run claim-check`" +"#; + +/// [`repo`]'s twin over [`ALTERNATION`], identical in every other respect. +fn alternation_repo(name: &str) -> PathBuf { + let dir = Fixture::new(name) + .config(ALTERNATION) + .file(".gitignore", "scratch/\n") + .file("src/tracked.rs", "// committed\n") + .git() + .base_commit() + .build(); + git_in(&dir, &["checkout", "-q", "-b", "user/cloud-444-slug"]); + dir +} + +/// [`mint_against`] over an arbitrary receipt KIND, which is the axis these +/// cases vary. +fn mint_kind(dir: &Path, kind: &str, branch: &str) { + let git_dir = git_in(dir, &["rev-parse", "--absolute-git-dir"]); + let receipts = PathBuf::from(git_dir.trim()).join("batten-receipts"); + std::fs::create_dir_all(&receipts).expect("create the receipt store"); + let base = git_in(dir, &["rev-parse", "origin/main"]); + std::fs::write( + receipts.join(format!("{kind}.{}", branch.replace('/', "-"))), + format!("CLOUD-1297\nready-lint pass\nbase {}\n", base.trim()), + ) + .expect("mint the receipt"); +} + +#[test] +fn any_one_of_the_alternatives_vouches_for_the_branch() { + // The defect CLOUD-1297 closes, driven once per alternative. `bot` and + // `carry` are the two that were denied while valid: `verify` accepted them + // all along and this row accepted only `claim`, so an agent on a bot branch + // met a refusal holding a real attestation. + // + // ALL THREE ARE DRIVEN, including `claim`. The alternation must not merely + // admit the new kinds — it must still admit the one the conjunction + // admitted, and a suite that checked only the additions would pass over a + // column that had silently replaced the original. + for kind in ["claim", "bot", "carry"] { + let dir = alternation_repo(&format!("alternation-{kind}")); + mint_kind(&dir, kind, "user/cloud-444-slug"); + assert_allowed(&dir, "src/tracked.rs"); + } +} + +#[test] +fn an_alternation_with_no_receipt_at_all_is_still_refused() { + // THE VACUITY CASE, and the one this column most needs. An alternation is + // satisfied by any member, so the way it fails is by being satisfied by + // nothing at all — an allow wearing a gate's name. The row must deny a + // branch carrying none of the three exactly as the conjunction did. + let dir = alternation_repo("alternation-vacuity"); + assert_denied(&dir, "src/tracked.rs"); +} + +#[test] +fn a_receipt_of_a_kind_the_alternation_does_not_name_does_not_vouch() { + // The other half of the vacuity question: the alternation admits the kinds + // it NAMES, not any receipt that happens to sit in the store. Without this, + // a bug that read "some receipt exists" would pass every case above. + let dir = alternation_repo("alternation-unnamed-kind"); + mint_kind(&dir, "verify", "user/cloud-444-slug"); + assert_denied(&dir, "src/tracked.rs"); + // And a named kind on the same branch IS admitted, so the refusal above is + // about which kind was minted rather than about an unreadable store. + mint_kind(&dir, "carry", "user/cloud-444-slug"); + assert_allowed(&dir, "src/tracked.rs"); +} + +#[test] +fn an_alternative_minted_for_another_branch_does_not_vouch_for_this_one() { + // The keying still applies to every member. An alternation that dropped the + // branch check for its new kinds would be a wider hole than the false + // positive it was added to close. + let dir = alternation_repo("alternation-other-branch"); + mint_kind(&dir, "bot", "user/some-other-branch"); + assert_denied(&dir, "src/tracked.rs"); +} + +#[test] +fn the_alternations_refusal_names_every_alternative_and_its_verdict() { + // A failed conjunction has one thing to go and do; a failed alternation has + // several, any of which clears it. A refusal naming only the first would + // send a reader to mint a `claim` on a branch where minting a `carry` was + // the right move and half the work. + let dir = alternation_repo("alternation-refusal"); + let refusal = stderr(&run_with_stdin( + &dir, + &["hook", "--harness", "exit-code"], + &write_payload("src/tracked.rs"), + )); + for kind in ["claim", "bot", "carry"] { + assert!( + refusal.contains(kind), + "names the `{kind}` alternative: {refusal}" + ); + } + assert!( + refusal.contains("missing"), + "names each alternative's verdict: {refusal}" + ); + // Pointer-only (non-negotiable rule 4): a receipt's contents never reach the + // refusal, and the fixture plants a distinctive one to prove it. + assert!( + !refusal.contains("CLOUD-1297"), + "a refusal must not echo a receipt's contents: {refusal}" + ); +} diff --git a/crates/batten/tests/it/cli.rs b/crates/batten/tests/it/cli.rs index a8880119f..73d299f87 100644 --- a/crates/batten/tests/it/cli.rs +++ b/crates/batten/tests/it/cli.rs @@ -4888,8 +4888,7 @@ fn a_local_file_may_add_a_pattern_but_not_redefine_a_committed_one() { // reads. A verb that declares the channel is held to the document contract the // day its row lands, with no edit here. -/// A git repo with a committed authority, isolated state dir, and a work commit — -/// enough for every `data_channel` verb to have something real to answer about. +/// The census fixture's repository, built alone. /// /// `config epoch` needs readable tracked paths, `receipt status` needs a repo with /// `origin/main`, and `check`/`enforce`/`config *` need an authority. One fixture @@ -4945,17 +4944,17 @@ const CENSUS_CONFIG: &str = concat!( const CENSUS_SESSION: &str = "{\"type\":\"assistant\",\"sessionId\":\"s-1\",\ \"message\":{\"role\":\"assistant\",\"content\":[]}}\n"; -fn census_fixture(name: &str) -> (PathBuf, PathBuf, String) { - // Shaped like `receipt_fixture`, but with a config every data-emitting verb - // can actually answer from. `policy budget` is the reason it diverged: a - // budget verb whose config declares no budget measured nothing, and it - // refuses (exit 1) rather than reporting a `0` it did not earn — so a - // fixture carrying `version = 1` alone would make the census assert about a - // usage error instead of about a document. The census is about the output - // contract; supplying each verb's minimum input is the fixture's job, the - // same way `census_argv` supplies `receipt status` its positional. - let root = scratch(name); - let repo = Fixture::at(root.join("repo")) +/// The repository half of [`census_fixture`], extracted for length and nothing +/// else: every verb with a minimum input adds a file or a table to this chain, +/// and it reached the function ceiling. The shape is unchanged. +/// +/// It composes with [`CENSUS_CONFIG`] rather than replacing it: the const holds +/// the config TEXT each verb needs declared, and this holds the tracked FILES and +/// the two commits a diff-shaped verb needs. Two extractions because they came +/// out for two different reasons, and folding them would put a `[defects]` table +/// beside a `base_commit()`. +fn census_repo(root: &Path) -> PathBuf { + Fixture::at(root.join("repo")) .config(CENSUS_CONFIG) // `ready lint` and `claim check`'s minimum input, and the fourth verb // family to need one (CLOUD-1100). The Ready grammar is the CONSUMER's @@ -4980,10 +4979,42 @@ fn census_fixture(name: &str) -> (PathBuf, PathBuf, String) { "schema/batten.schema.json", "{\n \"properties\": {\n \"version\": {}\n }\n}\n", ) + // `claim carry`'s minimum input, and the first that is a property of the + // DIFF rather than of a file: the verb judges this branch against its merge + // base, so the fixture has to give it both sides. The base maps one repo; + // the work commit below appends a row for that same repo with only the sha + // changed, which is the whole of what a carry may be. A fixture writing + // only the base would make the census assert about `nothing-carried`, and + // one writing only the head about `no-prior-row` — both refusals, and both + // the wrong thing for a census about the output contract. + .file(CENSUS_CARRY_TABLE, CENSUS_CARRY_BASE) .git() .base_commit() + .file( + CENSUS_CARRY_TABLE, + &format!("{CENSUS_CARRY_BASE}census/action@bbb\tMIT\tCopyright (c) 2026 Census\n"), + ) .work_commit() - .build(); + .build() +} + +/// A git repo with a committed authority, isolated state dir, and a work commit — +/// enough for every `data_channel` verb to have something real to answer about. +/// +/// `config epoch` needs readable tracked paths, `receipt status` needs a repo with +/// `origin/main`, and `check`/`enforce`/`config *` need an authority. One fixture +/// satisfying all of them beats a per-verb table that would drift. +fn census_fixture(name: &str) -> (PathBuf, PathBuf, String) { + // Shaped like `receipt_fixture`, but with a config every data-emitting verb + // can actually answer from. `policy budget` is the reason it diverged: a + // budget verb whose config declares no budget measured nothing, and it + // refuses (exit 1) rather than reporting a `0` it did not earn — so a + // fixture carrying `version = 1` alone would make the census assert about a + // usage error instead of about a document. The census is about the output + // contract; supplying each verb's minimum input is the fixture's job, the + // same way `census_argv` supplies `receipt status` its positional. + let root = scratch(name); + let repo = census_repo(&root); let home = Fixture::at(root.join("home")).build(); // `capture show` is the fourth verb with a minimum input, and the first whose // input cannot be a literal: a handle carries a content digest, so the fixture @@ -5088,6 +5119,15 @@ fn census_fixture(name: &str) -> (PathBuf, PathBuf, String) { /// would cost more than the single substitution below. const CENSUS_SEEDED_HANDLE: &str = ""; +/// The licence table `claim carry` judges, read from the engine rather than +/// re-typed — a census that named its own path would pass over a verb reading a +/// different one. +const CENSUS_CARRY_TABLE: &str = batten::carry::TABLE; + +/// The base side of the census fixture's carry: one mapped repo, so the head's +/// appended row has a prior verdict to carry forward. +const CENSUS_CARRY_BASE: &str = "census/action@aaa\tMIT\tCopyright (c) 2026 Census\n"; + /// The positional value each data-emitting verb needs to reach its document. /// /// A table rather than one shared placeholder. It used to be the literal diff --git a/crates/batten/tests/it/doctor.rs b/crates/batten/tests/it/doctor.rs index 7abb694e3..7c5efdf32 100644 --- a/crates/batten/tests/it/doctor.rs +++ b/crates/batten/tests/it/doctor.rs @@ -63,7 +63,7 @@ fn a_healthy_repository_exits_zero() { assert_eq!(output.status.code(), Some(0)); assert_eq!( stdout(&output), - "config ok\ngit-repo ok\ncommand-programs ok\nhook-handlers ok\ndoctor: 4 check(s), 0 failed\n" + "config ok\ngit-repo ok\ncommand-programs ok\nhook-handlers ok\nplan-surface ok\ndoctor: 5 check(s), 0 failed\n" ); } @@ -115,11 +115,17 @@ fn every_check_is_reported_not_just_the_first_failure() { let text = stdout(&output); assert!(text.contains("config failed"), "got: {text}"); assert!(text.contains("git-repo failed"), "got: {text}"); - // Four checks now; still two failures, because a checkout with no config + // Five checks now; still two failures, because a checkout with no config // declares no handlers and `hook-handlers` passes vacuously over an empty // table. That is the honest answer — there is nothing there to be wrong — // and it is why the count moved while the failure count did not. - assert!(text.contains("doctor: 4 check(s), 2 failed"), "got: {text}"); + // + // `plan-surface` passes for a different reason worth keeping distinct: it + // reads the COMMITTED harness table rather than this checkout, so it says + // the same thing in every scratch repository. What it can fail on is a + // harness declaring neither a fetched spelling nor the row that owes the + // survey (CLOUD-472), which is a defect in the crate and not in a tree. + assert!(text.contains("doctor: 5 check(s), 2 failed"), "got: {text}"); } // --- doctor never renders a policy verdict ----------------------------------- @@ -246,7 +252,13 @@ fn json_is_valid_and_carries_every_check() { let names: Vec<&str> = checks.iter().filter_map(|c| c["name"].as_str()).collect(); assert_eq!( names, - vec!["config", "git-repo", "command-programs", "hook-handlers"] + vec![ + "config", + "git-repo", + "command-programs", + "hook-handlers", + "plan-surface" + ] ); } diff --git a/crates/batten/tests/it/filed_here.rs b/crates/batten/tests/it/filed_here.rs index 7921884a3..1154d92ac 100644 --- a/crates/batten/tests/it/filed_here.rs +++ b/crates/batten/tests/it/filed_here.rs @@ -261,6 +261,7 @@ fn pointers(root: &Path) -> Vec { const UNREFINED: &str = "filed-unrefined"; const OVER_DIFF: &str = "filed-over-own-diff"; +const LEFT_OPEN: &str = "filed-and-left-open"; // --------------------------------------------------------------------------- // The pass side first: without it every refusal below is satisfied by a module @@ -407,8 +408,12 @@ fn a_row_naming_a_file_this_branch_is_changing_stops_the_lap() { assert_eq!(verdicts(&root), vec![OVER_DIFF.to_owned()]); } +/// A path outside the diff is not a punt against it — for the PROXIMITY refusal, +/// which is the only one this case was ever about. `filed-and-left-open` takes it +/// instead, and asserting the exact verdict rather than "not empty" is what makes +/// the partition falsifiable from this tier. #[test] -fn a_recorded_path_the_branch_does_not_change_is_not_reported() { +fn a_recorded_path_the_branch_does_not_change_is_not_a_proximity_refusal() { let root = repo( "elsewhere", "work", @@ -418,10 +423,7 @@ fn a_recorded_path_the_branch_does_not_change_is_not_reported() { )], &["closes 0"], ); - assert!( - verdicts(&root).is_empty(), - "a path outside the diff is not a punt against it" - ); + assert_eq!(verdicts(&root), vec![LEFT_OPEN.to_owned()]); } /// ONE POINTER PER PATH, as the shell emitted, so a reviewer sees which file @@ -548,16 +550,23 @@ fn a_row_recorded_before_the_file_was_touched_is_still_caught() { )], &["closes 0"], ); - assert!( - verdicts(&root).is_empty(), - "nothing is in the diff yet, so nothing intersects" + // THE ROW MOVES BETWEEN THE TWO ARMS RATHER THAN APPEARING OUT OF SILENCE, + // which is the partition made visible on one record. Before the file is + // touched its §1 names nothing in the diff, so proximity is silent and the + // set refusal takes it; touching the file moves it to proximity and the set + // refusal goes quiet. Exactly one finding either side — a reviewer never sees + // two for one row. + assert_eq!( + verdicts(&root), + vec![LEFT_OPEN.to_owned()], + "nothing intersects yet, so this is a row left open rather than a row over the diff" ); fs::create_dir_all(root.join("src")).expect("src"); fs::write(root.join("src/a.rs"), "now\n").expect("touch the file"); assert_eq!( verdicts(&root), vec![OVER_DIFF.to_owned()], - "the same record refuses once the file is open" + "the same record refuses on proximity once the file is open" ); } @@ -597,6 +606,146 @@ fn a_six_field_record_with_no_sec1_column_is_judged_exactly_as_before() { ); } +// --------------------------------------------------------------------------- +// `filed-and-left-open` (CLOUD-1311). The set refusal: a row this branch put on +// the board that it is not landing. +// +// Its whole reason for existing is the class the two arms above cannot see — a +// row filed while the branch was open whose §1 points somewhere else, which +// `cites_only` exempts from the proximity refusal by design. Three of the four +// deferrals that motivated this issue sat exactly there. +// --------------------------------------------------------------------------- + +#[test] +fn a_row_the_branch_filed_and_does_not_close_stops_the_lap() { + let root = repo( + "left-open", + "work", + &["src/a.rs"], + &[&format!( + "issue CLOUD-1 {AFTER} ready 1,src/a.rs - 1,src/b.rs" + )], + &["closes 0"], + ); + assert_eq!(verdicts(&root), vec![LEFT_OPEN.to_owned()]); +} + +/// NO PR YET IS COULD-NOT-LOOK. `verify` runs before the PR exists on most laps, +/// and refusing there would name a remedy — "close it in the body" — with no body +/// to write it in. The absent record is the signal; there is nothing to tune. +#[test] +fn an_unread_pr_body_leaves_the_set_unjudged() { + let root = repo( + "no-body", + "work", + &["src/a.rs"], + &[&format!( + "issue CLOUD-1 {AFTER} ready 1,src/a.rs - 1,src/b.rs" + )], + &[], + ); + assert!( + verdicts(&root).is_empty(), + "the forge's answer has not been captured, so the set is not judged" + ); +} + +/// AND A FETCH WHOSE KEY READER COULD NOT RUN IS THE SAME ANSWER, which is the +/// distinction `zero-is-a-count` exists to preserve: `closes 0` is a measurement +/// and `closes -` is not. +#[test] +fn an_unreadable_closing_key_column_leaves_the_set_unjudged() { + let root = repo( + "unreadable-body", + "work", + &["src/a.rs"], + &[&format!( + "issue CLOUD-1 {AFTER} ready 1,src/a.rs - 1,src/b.rs" + )], + &["closes -"], + ); + assert!(verdicts(&root).is_empty(), "`-` is could-not-look"); +} + +/// ANTI-VACUITY ON THE EXEMPTION: one closing key must not buy the whole set. +/// Without this, an author closes the cheapest row they filed and the gate goes +/// quiet about every other one — which is the arm switched off by its own remedy. +#[test] +fn closing_one_row_does_not_close_the_set() { + let root = repo( + "close-one", + "work", + &["src/a.rs"], + &[ + &format!("issue CLOUD-1 {AFTER} ready 1,src/a.rs - 1,src/b.rs"), + &format!("issue CLOUD-2 {AFTER} ready 1,src/a.rs - 1,src/b.rs"), + ], + &["closes 1:CLOUD-1"], + ); + assert_eq!(verdicts(&root), vec![LEFT_OPEN.to_owned()]); + assert!( + pointers(&root).iter().any(|line| line.contains("CLOUD-2")), + "the row still open is the one reported: {:?}", + pointers(&root) + ); +} + +/// POINTER, NEVER PAYLOAD (rule 4) for this arm too. The recorder wrote no title +/// and no body, and this is the assertion that keeps a later edit from adding +/// one — an articulation's prose especially, which is the one thing this class +/// collects that a finding must never carry. +#[test] +fn the_set_refusal_carries_the_id_and_nothing_else() { + let root = repo( + "left-open-pointer", + "work", + &["src/a.rs"], + &[&format!( + "issue CLOUD-1 {AFTER} ready 1,src/a.rs - 1,src/b.rs" + )], + &["closes 0"], + ); + let rendered = pointers(&root).join("\n"); + assert!(rendered.contains("CLOUD-1"), "the id is the pointer"); + assert!( + !rendered.contains("src/b.rs"), + "a §1 path is not this arm's subject: {rendered}" + ); +} + +// A BRANCH HOLDING NOTHING OPEN DEFERRED NOTHING — `test_a_branch_with_no_diff_ +// judges_no_row`, and it is in the MODULE's tier rather than here on purpose. +// +// THIS TIER CANNOT BUILD THAT INPUT, and the reason is the fixture itself: +// `install_module` writes `policy/filed-here.rego` into the working tree, and +// `base_delta` walks the tree rather than the index, so an untracked file is an +// added path. Every fixture below therefore has a non-empty delta by +// construction — measured, this case failed here reporting exactly the one +// finding it asserted the absence of. +// +// Stated rather than dropped, because the pair is the general rule +// `.claude/rules/policy-modules.md` gives for the two tiers: a `with input as` +// case cannot prove the ENGINE builds a shape, and this tier cannot construct a +// shape the engine's own scaffolding excludes. Neither replaces the other, and +// silently deleting the assertion would have left the guard untested in both. + +/// A record from an older recorder has no §1 column, so the partition cannot be +/// evaluated and the row stays judged exactly as it was before this arm existed. +#[test] +fn a_record_with_no_sec1_column_is_outside_this_arm() { + let root = repo( + "left-open-six-field", + "work", + &["src/a.rs"], + &[&format!("issue CLOUD-1 {AFTER} ready 0 -")], + &["closes 0"], + ); + assert!( + verdicts(&root).is_empty(), + "could-not-look on §1 is not a refusal" + ); +} + /// ANTI-VACUITY over the whole file: the row this suite exercises is the one the /// committed config declares, so a rename or a scope change reddens here rather /// than leaving every case above passing over a module nothing runs. diff --git a/crates/batten/tests/it/forced_push.rs b/crates/batten/tests/it/forced_push.rs new file mode 100644 index 000000000..15609ca21 --- /dev/null +++ b/crates/batten/tests/it/forced_push.rs @@ -0,0 +1,162 @@ +//! The leased push, over the compiled binary and the committed table. +//! +//! # The gap this covers, and the one it does not +//! +//! Measured 2026-09-02 on `claude/cloud-1295-retire-bot-issue`: two sessions in +//! different containers held one branch. One pushed; the other had already built +//! a commit and its push was rejected non-fast-forward. **Nothing in Batten fired +//! at any point** — the rejection came from git, after the work was written, +//! verified and committed. +//! +//! `claim-not-raced` asks about a KEY across open pull requests, and both +//! sessions served the same one, so it is correctly silent. The claim receipt — +//! the one artifact saying *this session is working this branch* — lives under +//! `$GIT_DIR`, is never committed, and dies with the container, so no clone can +//! read another's. +//! +//! # Why this row is only one flag +//! +//! `trunk-based/no-force-push` already denies `--force` and `-f`, per segment. +//! It excludes `--force-with-lease` on a stated argument: it "refuses when the +//! remote moved". That is true when the sibling's push arrived AFTER your last +//! fetch — the remote-tracking ref is stale, the comparison differs, the push is +//! refused. It is false for the sequence an agent actually runs: `git fetch` +//! moves that ref onto the sibling's commit, the lease then compares EQUAL, and +//! the push succeeds. The flag chosen for being careful is the one that destroys +//! the commit. +//! +//! So these cases are about the spelling the preset leaves out. The ones it owns +//! are asserted here too, but as *somebody* refusing rather than as this row's +//! work — a second rule over one object is what the narrowing avoids. +//! +//! Judged against the committed `batten.toml` rather than a fixture: a fixture +//! would assert that the ENGINE can express this, which was never in doubt. What +//! is in doubt is whether the table this repository ships refuses the command. + +#![allow(clippy::unwrap_used, clippy::expect_used)] + +use crate::common; + +/// A Claude Code `PreToolUse` envelope carrying a shell command. +fn bash_payload(command: &str) -> String { + let escaped = serde_json::to_string(command).expect("a command is encodable"); + format!( + "{{\"hook_event_name\":\"PreToolUse\",\"tool_name\":\"Bash\",\ + \"tool_input\":{{\"command\":{escaped}}}}}" + ) +} + +fn decision(command: &str) -> String { + let root = common::at_root("."); + common::stdout(&common::run_with_stdin( + &root, + &["hook", "--harness", "claude-code"], + &bash_payload(command), + )) +} + +/// Refused, and by THIS row — the assertion that would go green on the preset's +/// coverage alone is the one that proves nothing. +fn denied_by_this_row(command: &str) { + let out = decision(command); + assert!( + out.contains("\"deny\""), + "the committed policy must refuse: {command}\n{out}" + ); + assert!( + out.contains("leased-push"), + "the refusal for `{command}` must come from this row\n{out}" + ); +} + +/// Refused by something. Used only where the preset legitimately owns the case. +fn denied(command: &str) { + let out = decision(command); + assert!( + out.contains("\"deny\""), + "the committed policy must refuse: {command}\n{out}" + ); +} + +fn allowed(command: &str) { + let out = decision(command); + assert!( + !out.contains("\"deny\""), + "the committed policy must allow: {command}\n{out}" + ); +} + +#[test] +fn a_bare_leased_push_is_refused() { + denied_by_this_row("git push --force-with-lease origin main"); + denied_by_this_row("git push origin claude/some-branch --force-with-lease"); +} + +#[test] +fn a_leased_push_behind_a_compound_command_is_still_reached() { + // `input.call.segments`, not the first word of the line. The preset this + // extends carries the measured instance in its own header: anchored on + // `command`, it denied the bare `git push --force origin main` and allowed + // `cd /tmp && git push --force origin main` with a green suite over it + // (CLOUD-857) — and a real agent command is compound most of the time. + // + // THE FETCH-THEN-PUSH PAIR IS THE MEASURED SEQUENCE, not an invented one: it + // is what makes the lease compare equal, so a row that missed it would miss + // exactly the case this exists for. + denied_by_this_row("cd /tmp && git push --force-with-lease origin main"); + denied_by_this_row("git fetch origin && git push --force-with-lease origin main"); +} + +#[test] +fn the_preset_still_owns_the_bare_forced_spellings() { + // Asserted as SOMEBODY refusing rather than as this row's work. If this ever + // starts coming from `leased-push`, the narrowing has been undone and + // there are two rules over one object again. + denied("git push --force origin main"); + denied("git push -f origin main"); +} + +#[test] +fn an_ordinary_push_is_untouched() { + // The cost of this row must be zero on the path every session takes. An + // ordinary push is already refused by git when it would discard commits, so + // there is nothing here for this row to add and a refusal would only teach + // people to reach for the bypass. + allowed("git push -u origin claude/some-branch"); + allowed("git push origin main"); + allowed("git fetch origin main"); +} + +/// THE EXPLICIT EXPECTED VALUE IS THE WHOLE DISTINCTION, and this is the case +/// that would have gone green over a guard that banned the flag outright. +/// +/// `land-lock.sh` states it about its own CAS: "The expected value is passed +/// EXPLICITLY (`:`) and must stay that way… The two forms look +/// interchangeable and are not." Naming the sha IS the assertion — you cannot +/// name a value you never observed — and a stale one is refused by git rather +/// than by policy. +/// +/// Measured within an hour of writing the first version, which banned the +/// spelling: correcting a missing `Refs:` trailer on three of this branch's own +/// commits needed exactly this form, over history no other clone had fetched. +/// A guard that refused it had no route out at all — a consumer `[[rule]]` row +/// raises no class, so nothing could admit it, and the only remaining way +/// through was the password shape CLOUD-1051 retired. +#[test] +fn the_explicit_expected_value_is_allowed() { + allowed("git push --force-with-lease=refs/heads/main:abc123 origin main"); + allowed("git fetch origin && git push --force-with-lease=refs/heads/x:deadbeef origin x"); +} + +#[test] +fn the_flag_named_in_prose_is_not_a_push() { + // ANTI-VACUITY IN THE OTHER DIRECTION. A row keyed on the substring alone + // would fire on any command mentioning the flag — including the ones + // documenting this rule, which is how `no-secrets` refused its own + // explanatory comment. `pattern` requires the `git push` shape and `contains` + // narrows within it, so a sentence about a leased push is not one. + allowed("echo 'never reach for git push --force-with-lease here'"); + // A `git` command carrying the flag that is not a PUSH — which is what says + // `pattern` is doing work rather than `contains` alone deciding. + allowed("git log --oneline --grep force-with-lease"); +} diff --git a/crates/batten/tests/it/main.rs b/crates/batten/tests/it/main.rs index fd8230725..33d9fa88d 100644 --- a/crates/batten/tests/it/main.rs +++ b/crates/batten/tests/it/main.rs @@ -36,6 +36,7 @@ // the former per-file allowances are preserved on each module below. #![allow(clippy::unwrap_used, clippy::expect_used)] +mod claim_carry; mod common; mod acceptance_corpus; @@ -52,6 +53,7 @@ mod bats_invocation; mod board_receipts; mod board_record; mod board_state_claim; +mod bot_lane; mod bundle; mod bypass_scrub; mod call_arguments; @@ -102,6 +104,7 @@ mod facts; mod fail_on_warning; mod filed_here; mod fixture_repos; +mod forced_push; mod forge_facts; mod fuzz_corpus; mod gh_guard; @@ -130,9 +133,11 @@ mod memory_injection; mod mise_pin_agreement; mod mutate; mod narrow_adoption; +mod obligations_bound; mod perf_pair; mod pinned_programs; mod pipeline_shapes; +mod plan_complete; mod pointer_only; mod policy_engine_count; mod policy_input_narrowing; @@ -154,6 +159,8 @@ mod prospective_facts; mod provision; mod ratchet; mod ready; +mod reclaim_report_once; +mod record_closes; mod redirect_resolves; mod reference_coverage; mod refusal_ceiling; diff --git a/crates/batten/tests/it/obligations_bound.rs b/crates/batten/tests/it/obligations_bound.rs new file mode 100644 index 000000000..ea796e978 --- /dev/null +++ b/crates/batten/tests/it/obligations_bound.rs @@ -0,0 +1,231 @@ +//! `obligations-bound`, over the engine that builds its input (CLOUD-472). +//! +//! # The seam, and why the module's own suite cannot reach it +//! +//! `policy/obligations-bound.rego`'s `test_` rules pin the predicate against a +//! fabricated document. The question that decides whether this gate is alive is +//! a different one: does the ENGINE put the recorded obligation column at +//! `input.tree.records`, and does it put the named file's lines at +//! `input.tree.lines` under a key the predicate spells the same way? +//! +//! Both have a specific way to fail silently. The column is the eighth field of +//! a record line, so an off-by-one reads a neighbouring column and finds no +//! `:` — which looks exactly like "this row declared no obligations". And +//! `input.tree.lines` is keyed by declared path, so an obligation naming a file +//! outside the row's `line_sources` resolves to nothing and the slug can never +//! be found, which reads exactly like "the case has no mutation". A `with input +//! as` case cannot distinguish either, because it fabricates the shape it wants. + +#![allow(clippy::unwrap_used, clippy::expect_used)] + +use crate::common; + +use std::fs; +use std::path::{Path, PathBuf}; + +use batten::rules::{self, Rule, RuleKind, RuleScope}; + +/// A fixture repository carrying a board record and, optionally, the case file +/// an obligation names. +fn repo(name: &str, record: &[&str], case: Option<(&str, &str)>) -> PathBuf { + let root = common::scratch(name); + common::git_in(&root, &["init", "--quiet", "--initial-branch", "work"]); + common::git_in(&root, &["config", "user.email", "t@example.com"]); + common::git_in(&root, &["config", "user.name", "t"]); + fs::write(root.join("seed.txt"), "seed\n").expect("seed"); + common::git_in(&root, &["add", "-A"]); + common::git_in(&root, &["commit", "--quiet", "-m", "base"]); + let base = common::git_in(&root, &["rev-parse", "HEAD"]); + common::git_in(&root, &["update-ref", "refs/remotes/origin/main", &base]); + + if let Some((path, body)) = case { + let full = root.join(path); + fs::create_dir_all(full.parent().unwrap()).expect("case parent"); + fs::write(full, body).expect("write the case file"); + } + + install_module(&root); + write_record(&root, record); + root +} + +fn write_record(root: &Path, lines: &[&str]) { + let git_dir = common::git_in(root, &["rev-parse", "--absolute-git-dir"]); + let path = batten::recorder::record_path(Path::new(git_dir.trim()), "board-writes", "work"); + fs::create_dir_all(path.parent().unwrap()).expect("receipts dir"); + fs::write(path, format!("{}\n", lines.join("\n"))).expect("write the record"); +} + +fn install_module(root: &Path) { + let source = common::at_root("policy/obligations-bound.rego") + .canonicalize() + .expect("the committed module is where the row says it is"); + fs::create_dir_all(root.join("policy")).expect("scratch policy dir"); + fs::copy(source, root.join("policy/obligations-bound.rego")).expect("install committed module"); +} + +/// The committed row's shape, including `line_sources` — the field this suite +/// exists to keep honest, since a glob that misses the named file makes every +/// obligation unbindable for a reason no message would name. +fn row() -> Rule { + serde_json::from_value(serde_json::json!({ + "id": "obligations-bound", + "kind": "policy", + "scope": "tree", + "base": "origin/main", + "delta_sources": ["**"], + "line_sources": ["crates/batten/tests/**/*.rs", "policy/*.rego"], + "module": "policy/obligations-bound.rego", + "severity": "deny", + })) + .expect("the loader accepts the committed row's shape") +} + +fn recorders() -> Vec { + vec![batten::recorder::Declared { + name: "board-writes".to_owned(), + record: "board-writes".to_owned(), + tool: "save_issue".to_owned(), + key: batten::recorder::RecordKey::Branch, + requires: Vec::new(), + refused_when_input: Vec::new(), + requires_input_matching: std::collections::BTreeMap::new(), + requires_recorded: None, + columns: vec![batten::recorder::Column { + name: "kind".to_owned(), + value: batten::recorder::Value::Literal("issue".to_owned()), + minus: None, + without: None, + counted_with: None, + zero_is_a_count: false, + }], + }] +} + +fn verdicts(root: &Path) -> Vec { + let declared = recorders(); + let verdicts = common::verdicts_in(root); + rules::run_static( + &[row()], + &[], + batten::policy::Vocabulary { + patterns: &[], + verdicts: &verdicts, + recorders: &declared, + }, + root, + ) + .expect("the read surface runs a policy row") + .findings + .into_iter() + .map(|finding| finding.rule) + .collect() +} + +const UNBOUND: &str = "obligation-unbound"; + +/// The record line the recorder writes: eight fields, with the obligation set +/// last. Built here rather than inlined so an off-by-one in the module's column +/// index is a failure in every case at once rather than a silent pass in each. +fn line(obligations: &str) -> String { + format!("issue CLOUD-1 2026-01-01T00:00:00Z ready - - - {obligations}") +} + +// --------------------------------------------------------------------------- +// THE READ SEAM. +// --------------------------------------------------------------------------- + +/// A bound obligation is clean, and this is the case that proves the ENGINE +/// reaches both inputs: the record's eighth column AND the named file's lines. +/// If either resolved to nothing, this would pass for the wrong reason — so the +/// refusal cases below are what give it meaning, and it is what gives them +/// theirs. +#[test] +fn a_bound_obligation_reaches_the_predicate_and_is_clean() { + let root = repo( + "obligations-bound-clean", + &[&line("1,crates/batten/tests/it/x.rs:slug-one")], + Some(( + "crates/batten/tests/it/x.rs", + "#MUTANT slug-one|s@a@b@|the_case\n", + )), + ); + assert!( + verdicts(&root).is_empty(), + "the obligation names a tracked file whose lines declare the slug: {:?}", + verdicts(&root) + ); +} + +#[test] +fn an_obligation_naming_no_tracked_file_is_refused() { + let root = repo( + "obligations-no-file", + &[&line("1,crates/batten/tests/it/missing.rs:slug-one")], + None, + ); + assert_eq!(verdicts(&root), vec![UNBOUND.to_owned()]); +} + +/// THE FILE EXISTS AND THE PROMISE IS STILL UNKEPT. A case with no mutation is a +/// case nothing has shown can fail, which is CLOUD-418's whole finding — and it +/// is a different remedy from a missing file, which is why the module carries +/// two arms rather than one. +#[test] +fn an_obligation_whose_slug_no_row_declares_is_refused() { + let root = repo( + "obligations-no-slug", + &[&line("1,crates/batten/tests/it/x.rs:slug-one")], + Some(( + "crates/batten/tests/it/x.rs", + "#MUTANT other-slug|s@a@b@|the_case\n", + )), + ); + assert_eq!(verdicts(&root), vec![UNBOUND.to_owned()]); +} + +/// COULD-NOT-LOOK PASSES. A prose-dialect Ready block emits no obligations line, +/// so the column records `-`, and reading that as "declares none" would exempt +/// exactly the rows this gate exists for. +#[test] +fn a_row_with_no_obligations_column_is_not_judged() { + let root = repo("obligations-absent", &[&line("-")], None); + assert!( + verdicts(&root).is_empty(), + "`-` is could-not-look: {:?}", + verdicts(&root) + ); +} + +/// A MEASURED ZERO IS AN ANSWER. The object was read and declares no +/// obligations, which is a legitimate Ready block and must not be refused. +#[test] +fn a_row_declaring_no_obligations_passes() { + let root = repo("obligations-zero", &[&line("0")], None); + assert!(verdicts(&root).is_empty(), "{:?}", verdicts(&root)); +} + +/// ANTI-VACUITY over the whole file: the row this suite exercises is the one the +/// committed config declares, so a rename or a scope change reddens here rather +/// than leaving every case above passing over a module nothing runs. +#[test] +fn the_committed_row_is_the_one_these_cases_exercise() { + let committed: Vec = batten::config::load(&common::at_root("batten.toml")) + .expect("the committed config loads") + .rules; + let declared = committed + .iter() + .find(|rule| rule.id == "obligations-bound") + .expect("the committed config declares the row this suite exercises"); + assert_eq!(declared.kind, RuleKind::Policy); + assert_eq!(declared.scope, RuleScope::Tree); + assert!( + declared + .line_sources + .iter() + .any(|glob| glob.contains("crates/batten/tests")), + "an obligation naming a case file must be resolvable, or the slug can \ + never be found and the gate refuses for a reason no message names: {:?}", + declared.line_sources + ); +} diff --git a/crates/batten/tests/it/plan_complete.rs b/crates/batten/tests/it/plan_complete.rs new file mode 100644 index 000000000..52e0f6d4f --- /dev/null +++ b/crates/batten/tests/it/plan_complete.rs @@ -0,0 +1,318 @@ +//! `plan-complete`, over the engine that builds its input (CLOUD-472). +//! +//! # The seam this tier owns, and why the module's own suite cannot reach it +//! +//! `policy/plan-complete.rego`'s `test_` rules pin the predicate against a +//! fabricated document. They cannot answer the question that actually decides +//! whether this gate is alive: does the ENGINE put `batten record plan`'s output +//! at `input.tree.records.plan` at all? +//! +//! That question has a specific reason to be asked here rather than assumed. +//! Every other record on that surface is minted by a `[[recorder]]` row, and +//! `recorder_records` used to read **only** the declared ones — so a store +//! written by a verb was invisible no matter what any module asked for. A +//! `with input as` case would have passed over that for the same reason it +//! passes over any key nothing fills, which is `.claude/rules/policy-modules.md`'s +//! whole warning about the two tiers. +//! +//! So these cases drive the real writer where they can, and `run_static` over a +//! real fixture repository otherwise. + +#![allow(clippy::unwrap_used, clippy::expect_used)] + +use crate::common; + +use std::fs; +use std::path::{Path, PathBuf}; + +use batten::rules::{self, Rule, RuleKind, RuleScope}; + +/// A fixture repository whose base is one commit back, with the plan record on +/// disk exactly where `batten record plan` writes it. +/// +/// `origin/main` is a local ref at the base commit: `base_delta` resolves a rev, +/// and a fetch would make every case below depend on the network for a question +/// that is entirely local. +fn repo(name: &str, changed: &[&str], plan: Option<&[&str]>) -> PathBuf { + claimed_repo(name, changed, plan, true) +} + +/// The same fixture, with the claim receipt under the caller's control. +/// +/// `claimed` is the population `plan-unrecorded` asks about — a branch that +/// pulled a row — so a case about an UNCLAIMED tree needs to build one, and that +/// case is what keeps the committed config usable over a scratch repository. +fn claimed_repo(name: &str, changed: &[&str], plan: Option<&[&str]>, claimed: bool) -> PathBuf { + let root = common::scratch(name); + common::git_in(&root, &["init", "--quiet", "--initial-branch", "work"]); + common::git_in(&root, &["config", "user.email", "t@example.com"]); + common::git_in(&root, &["config", "user.name", "t"]); + fs::write(root.join("seed.txt"), "seed\n").expect("seed"); + common::git_in(&root, &["add", "-A"]); + common::git_in(&root, &["commit", "--quiet", "-m", "base"]); + let base = common::git_in(&root, &["rev-parse", "HEAD"]); + common::git_in(&root, &["update-ref", "refs/remotes/origin/main", &base]); + + for path in changed { + let full = root.join(path); + if let Some(parent) = full.parent() { + fs::create_dir_all(parent).expect("scratch parent"); + } + fs::write(full, "changed\n").expect("write changed file"); + } + + install_module(&root); + if claimed { + write_record(&root, "claim", &["CLOUD-1"]); + } + if let Some(lines) = plan { + write_record(&root, "plan", lines); + } + root +} + +/// Write the record the way the verb does — through the engine's own +/// `record_path`, so a change to the naming breaks this tier rather than +/// silently pointing the reader and the writer at different files. +fn write_record(root: &Path, record: &str, lines: &[&str]) { + let git_dir = common::git_in(root, &["rev-parse", "--absolute-git-dir"]); + let path = batten::recorder::record_path(Path::new(git_dir.trim()), record, "work"); + fs::create_dir_all(path.parent().unwrap()).expect("receipts dir"); + let body = if lines.is_empty() { + String::new() + } else { + format!("{}\n", lines.join("\n")) + }; + fs::write(path, body).expect("write the plan record"); +} + +fn install_module(root: &Path) { + let source = common::at_root("policy/plan-complete.rego") + .canonicalize() + .expect("the committed module is where the row says it is"); + fs::create_dir_all(root.join("policy")).expect("scratch policy dir"); + fs::copy(source, root.join("policy/plan-complete.rego")).expect("install committed module"); +} + +fn row() -> Rule { + serde_json::from_value(serde_json::json!({ + "id": "plan-complete", + "kind": "policy", + "scope": "tree", + "base": "origin/main", + "delta_sources": ["**"], + "module": "policy/plan-complete.rego", + "severity": "deny", + })) + .expect("the loader accepts the committed row's shape") +} + +fn scan(root: &Path) -> rules::Scan { + let verdicts = common::verdicts_in(root); + rules::run_static( + &[row()], + &[], + batten::policy::Vocabulary { + patterns: &[], + verdicts: &verdicts, + recorders: &[], + }, + root, + ) + .expect("the read surface runs a policy row") +} + +fn verdicts(root: &Path) -> Vec { + scan(root) + .findings + .into_iter() + .map(|finding| finding.rule) + .collect() +} + +fn pointers(root: &Path) -> Vec { + scan(root) + .findings + .into_iter() + .map(|finding| finding.path) + .collect() +} + +const UNFINISHED: &str = "plan-unfinished"; +const UNRECORDED: &str = "plan-unrecorded"; + +// --------------------------------------------------------------------------- +// THE READ SEAM. Without these two the whole module is a `with input as` suite +// over a key nothing fills — the shape a dead gate and a clean tree share. +// --------------------------------------------------------------------------- + +/// The engine reads a store NO `[[recorder]]` declares. This is the assertion +/// that would have failed before `recorder_records` learned to read the +/// verb-written names, with every module test still green. +#[test] +fn the_engine_reads_a_verb_written_plan_store() { + let root = repo("plan-read-seam", &["src/a.rs"], Some(&["1 pending"])); + assert_eq!( + verdicts(&root), + vec![UNFINISHED.to_owned()], + "the record the verb writes must reach the predicate" + ); +} + +/// And the empty store is DISTINGUISHABLE from an absent one across the engine +/// boundary, not just inside the module. Absent refuses on the vacuity arm; +/// empty is an answer and is clean. If the projection collapsed the two, the +/// remedy for a trivial branch would be unreachable. +#[test] +fn an_empty_store_and_an_absent_one_reach_different_arms() { + let empty = repo("plan-empty", &["src/a.rs"], Some(&[])); + assert!( + verdicts(&empty).is_empty(), + "an empty record is the branch saying there is nothing to track: {:?}", + verdicts(&empty) + ); + + let absent = repo("plan-absent", &["src/a.rs"], None); + assert_eq!( + verdicts(&absent), + vec![UNRECORDED.to_owned()], + "no record at all is the vacuity the other arm cannot see" + ); +} + +// --------------------------------------------------------------------------- +// `plan-unfinished`. +// --------------------------------------------------------------------------- + +#[test] +fn an_unfinished_entry_stops_the_lap() { + let root = repo( + "plan-unfinished", + &["src/a.rs"], + Some(&["1 completed", "2 in_progress"]), + ); + assert_eq!(verdicts(&root), vec![UNFINISHED.to_owned()]); + assert!( + pointers(&root).iter().any(|line| line.contains('2')), + "the refusal names the entry: {:?}", + pointers(&root) + ); +} + +#[test] +fn a_wholly_completed_plan_is_clean() { + let root = repo( + "plan-done", + &["src/a.rs"], + Some(&["1 completed", "2 deleted"]), + ); + assert!( + verdicts(&root).is_empty(), + "finished and withdrawn are both terminal: {:?}", + verdicts(&root) + ); +} + +/// ONE FINDING PER ENTRY, so finishing one does not clear another and a reviewer +/// sees which item rather than a count to reconstruct. +#[test] +fn every_unfinished_entry_is_reported() { + let root = repo( + "plan-many", + &["src/a.rs"], + Some(&["1 pending", "2 completed", "3 pending"]), + ); + assert_eq!( + verdicts(&root), + vec![UNFINISHED.to_owned(), UNFINISHED.to_owned()] + ); +} + +/// POINTER, NEVER PAYLOAD (rule 4). The store holds an id and a status token and +/// no description, so there is no prose here to leak — and this is the assertion +/// that keeps a later edit from adding one. +#[test] +fn the_refusal_carries_no_entry_prose() { + let root = repo("plan-pointer", &["src/a.rs"], Some(&["1 pending"])); + let rendered = pointers(&root).join("\n"); + assert!(rendered.contains('1'), "the id is the pointer: {rendered}"); + assert!( + !rendered.contains("pending"), + "a status token is not a pointer: {rendered}" + ); +} + +// --------------------------------------------------------------------------- +// `plan-unrecorded` — the anti-vacuity arm. +// --------------------------------------------------------------------------- + +#[test] +fn a_branch_that_recorded_no_plan_is_refused() { + let root = repo("plan-none", &["src/a.rs"], None); + assert_eq!(verdicts(&root), vec![UNRECORDED.to_owned()]); +} + +/// A branch holding nothing open has nothing to have planned. Without that the +/// arm fires on every fresh checkout, which is how a gate gets switched off. +/// +/// The fixture always writes the module into the tree, so `changed` is never +/// truly empty here — the case that needs a genuinely empty delta lives in the +/// module's own suite, and this one records why it cannot live here. Same split, +/// and same reason, as `filed_here.rs`'s empty-delta note. +#[test] +fn the_engine_tier_cannot_build_an_empty_delta() { + let root = repo("plan-fresh", &[], None); + assert_eq!( + verdicts(&root), + vec![UNRECORDED.to_owned()], + "installing the module is itself a change, so this tier always has a diff" + ); +} + +/// AN UNCLAIMED BRANCH OWES NO PLAN, and this is the case that keeps the +/// committed config usable over a scratch repository. The first draft of the +/// vacuity arm keyed only on a non-empty diff, which is true of every fixture — +/// measured, it reddened four `cli.rs` cases whose only business was exercising +/// unrelated rules. Asserted at THIS tier and not only in the module, because +/// the population it selects is a record the engine has to actually read. +#[test] +fn an_unclaimed_branch_is_not_this_gates_business() { + let root = claimed_repo("plan-unclaimed", &["src/a.rs"], None, false); + assert!( + verdicts(&root).is_empty(), + "a branch that pulled no row owes no plan: {:?}", + verdicts(&root) + ); +} + +/// ANTI-VACUITY over the whole file: the row this suite exercises is the one the +/// committed config declares, so a rename or a scope change reddens here rather +/// than leaving every case above passing over a module nothing runs. +#[test] +fn the_committed_row_is_the_one_these_cases_exercise() { + let committed: Vec = batten::config::load(&common::at_root("batten.toml")) + .expect("the committed config loads") + .rules; + let declared = committed + .iter() + .find(|rule| rule.id == "plan-complete") + .expect("the committed config declares the row this suite exercises"); + assert_eq!(declared.kind, RuleKind::Policy); + assert_eq!(declared.scope, RuleScope::Tree); + assert_eq!( + declared.module.as_deref(), + Some("policy/plan-complete.rego") + ); +} + +/// The verb and the reader must agree on the store's name and keying. Asserted +/// against `record::VERB_WRITTEN` rather than a literal, so adding a store +/// without teaching the engine to read it cannot pass. +#[test] +fn the_plan_store_is_declared_as_verb_written() { + assert!( + batten::record::VERB_WRITTEN.contains(&"plan"), + "the engine must read the store the verb writes: {:?}", + batten::record::VERB_WRITTEN + ); +} diff --git a/crates/batten/tests/it/pointer_only.rs b/crates/batten/tests/it/pointer_only.rs index 373c144c2..f644fa86d 100644 --- a/crates/batten/tests/it/pointer_only.rs +++ b/crates/batten/tests/it/pointer_only.rs @@ -432,6 +432,21 @@ fn forge_verdict() -> String { format!("{} failure\n", canary("concluded")) } +/// Plan entries read on stdin by `record plan`. The id carries the canary, so a +/// refusal that echoed an entry back — the one thing this store must never put in +/// a diagnostic, since an id is the agent's own text — fails the census. +fn plan_entries() -> String { + format!("{} pending\n", canary("entry")) +} + +/// A pull request body read on stdin by `record closes`. The body is the largest +/// payload any verb in this census is handed and the one most likely to be echoed +/// by accident — a refusal quoting the line it could not parse would put a whole +/// paragraph of someone's prose into a diagnostic. +fn pr_body() -> String { + format!("Closes CLOUD-1\n\n{}\n", canary("body")) +} + /// A ledger row read on stdin by `defects add -n`. The caller wrote it, so its /// bytes are a declaration. fn incoming_record() -> String { @@ -469,6 +484,8 @@ enum Stdin { DesignClaims, ToolVerdict, ForgeVerdict, + PlanEntries, + PrBody, } struct Verb { @@ -627,6 +644,29 @@ const CENSUS: &[Verb] = &[ stdin: Stdin::Nothing, disposition: Disposition::PointerOnly, }, + // The lane's receipt, driven to the same lane-absent refusal as the five `pr` + // verbs above and pointer-only on the same structural terms: `bot::Attested` + // holds a key, a login and a number, and there is no field a body could + // travel in. + Verb { + path: "claim bot", + args: &[], + stdin: Stdin::Nothing, + disposition: Disposition::PointerOnly, + }, + // The third board verb, and structurally pointer-only for a reason worth + // naming because its subject is unusually leaky: the table it judges carries a + // LICENCE TEXT and a COPYRIGHT HOLDER per row, which is exactly the kind of + // third-party string rule 4 is about. `carry::Refusal` has nowhere to put one + // — every variant carries a repo, a path or a line number and there is no + // field a verdict body could travel in — so a refusal names which row is + // wrong without ever quoting what it compared. + Verb { + path: "claim carry", + 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 @@ -653,6 +693,49 @@ const CENSUS: &[Verb] = &[ stdin: Stdin::Nothing, disposition: Disposition::PointerOnly, }, + // The bot lane's five verbs (CLOUD-1295), and every one of them is driven to + // its LANE-ABSENT refusal rather than to the forge, for `pr watch`'s reason + // one entry up: a census entry that reached the network would not be a slow + // case, it would be one whose answer depends on somebody else's server. This + // corpus declares no `[bot_lane]`, so each refuses before the first request. + // + // That is not a weaker question than it looks. What these verbs could leak is + // a bot pull request's BODY — a release-notes dump for every bumped package — + // and the structural answer is that `bot::Pull` is the only place a body + // lives, no refusal formats one, and the emission paths carry a number, a key + // or a path. `crates/batten/tests/it/bot_lane.rs` drives the same law against + // a stubbed forge with a canary in the body, which is the half this corpus + // cannot reach. + Verb { + path: "pr derive", + args: &["7"], + stdin: Stdin::Nothing, + disposition: Disposition::PointerOnly, + }, + Verb { + path: "pr file", + args: &["7"], + stdin: Stdin::Nothing, + disposition: Disposition::PointerOnly, + }, + Verb { + path: "pr link", + args: &["7", "KEY-1"], + stdin: Stdin::Nothing, + disposition: Disposition::PointerOnly, + }, + Verb { + path: "pr ensure", + args: &["7"], + stdin: Stdin::Nothing, + disposition: Disposition::PointerOnly, + }, + Verb { + path: "pr closes", + args: &["7"], + stdin: Stdin::Nothing, + disposition: Disposition::PointerOnly, + }, // The pointer half of the same noun: handles and byte counts, never a byte of // what was captured. Verb { @@ -1172,6 +1255,25 @@ const CENSUS: &[Verb] = &[ stdin: Stdin::ForgeVerdict, disposition: Disposition::PointerOnly, }, + // CLOUD-472. The entry id piped in carries the canary, because an id is the + // AGENT's own text and is the one thing a refusal here must never echo — a + // malformed line is reported by its NUMBER and the closed status vocabulary, + // which is `record tool`'s discipline over a different payload. + Verb { + path: "record plan", + args: &[], + stdin: Stdin::PlanEntries, + disposition: Disposition::PointerOnly, + }, + // The body is prose somebody wrote and the record is a COUNT and a key list, + // so nothing this verb emits may carry a word of it — which is the same rule + // `record plan` follows over a smaller payload. + Verb { + path: "record closes", + args: &[], + stdin: Stdin::PrBody, + disposition: Disposition::PointerOnly, + }, ]; /// Every path of [`SURFACE`] that RUNS — the object this census must be total @@ -1243,6 +1345,8 @@ fn run_in(corpus: &Corpus, args: &[&str], stdin: Stdin) -> Run { Stdin::DesignClaims => design_claims(), Stdin::ToolVerdict => tool_verdict(), Stdin::ForgeVerdict => forge_verdict(), + Stdin::PlanEntries => plan_entries(), + Stdin::PrBody => pr_body(), }; // A BROKEN PIPE HERE IS THE CHILD BEING FAST, NOT A FAILURE. This corpus runs // every verb, and a verb that reads no stdin may exit before the write lands — diff --git a/crates/batten/tests/it/preset_segments.rs b/crates/batten/tests/it/preset_segments.rs index 000822db6..f2cfb8d77 100644 --- a/crates/batten/tests/it/preset_segments.rs +++ b/crates/batten/tests/it/preset_segments.rs @@ -69,6 +69,24 @@ fn assert_preset_denies(command: &str) { ); } +/// The PRESET did not fire. Weaker than [`assert_allowed`] and used only where a +/// consumer row legitimately refuses the same command. +/// +/// The header above states this file's rule for the deny side — assert the +/// preset's attribution, never a bare exit code, "so an exit 2 alone would let +/// some other row's verdict stand in for the preset's". The allow side has the +/// mirror defect and it went unnoticed until it bit: exit 0 conflates "the preset +/// did not fire" with "nothing fired", so the assertion breaks the moment this +/// consumer declares its own row over the same command, while the property the +/// case is named for is untouched. +fn assert_preset_allows(command: &str) { + let (_, cause) = adjudicate(command); + assert!( + !cause.contains("no-force-push"), + "the preset must not refuse: {command}\n{cause}" + ); +} + fn assert_allowed(command: &str) { let (code, cause) = adjudicate(command); assert_eq!(code, Some(0), "must allow: {command}\n{cause}"); @@ -103,8 +121,22 @@ fn force_with_lease_survives_segmentation() { // is the whole difference between "I know what I am replacing" and "replace // whatever is there" — a preset banning both would push its consumers toward // the bypass rather than toward the safer flag. - assert_allowed("git push --force-with-lease origin main"); - assert_allowed("cd /tmp && git push --force-with-lease origin main"); + // + // ASSERTED AS "THE PRESET DOES NOT FIRE" rather than as a clean exit, because + // this consumer now declares `leased-push` over the BARE spelling and the two + // statements are different. The preset's distinction is what this case + // is named for and it is unchanged; whether THIS repository additionally + // refuses the leased spelling is a consumer decision the preset has no view on. + // + // The consumer's reason, recorded here so the divergence is not read as an + // accident: a bare `--force-with-lease` compares against the remote-tracking + // ref this clone holds, so `git fetch` followed by a leased push compares + // EQUAL and overwrites the sibling commit the fetch just brought in. That is + // measured (2026-09-02, two sessions on one branch), and it is a fact about a + // fleet of agents in separate containers rather than about trunk-based + // development, which is why it is a consumer row and the preset stays as it is. + assert_preset_allows("git push --force-with-lease origin main"); + assert_preset_allows("cd /tmp && git push --force-with-lease origin main"); } #[test] diff --git a/crates/batten/tests/it/ready.rs b/crates/batten/tests/it/ready.rs index 578058956..54444f3d3 100644 --- a/crates/batten/tests/it/ready.rs +++ b/crates/batten/tests/it/ready.rs @@ -228,7 +228,15 @@ fn with_tasks(name: &str) -> PathBuf { // none gets could-not-look naming the first missing id rather than a // verdict — the right answer for such a repository, and not what these // cases are about. `repo` above opts in for the same reason. - .config(&format!("version = 1\n\n{}", declared_patterns())) + // + // The prose-dialect threshold is DECLARED rather than defaulted + // (CLOUD-472). A fixture omitting it gets `None` — could-not-look — and + // every threshold case below would then pass for the wrong reason, which + // is the shape a dead gate and a clean tree share. + .config(&format!( + "version = 1\n\n[ready]\nprose_dialect_required_from = \"2026-06-01T00:00:00.000Z\"\n\n{}", + declared_patterns() + )) .file( "Cargo.toml", "[workspace.package]\nversion = \"0.0.125\"\n\n[workspace.dependencies]\nserde = \"1\"\n", @@ -259,9 +267,14 @@ fn complete_claims() -> serde_json::Value { "gate": { "task": "verify", "exits": [0, 2] }, "commit_type": "feat", "blockers": [], + // A SLUG, NOT PROSE (CLOUD-472). This fixture carried + // "drop the required-key check" — a sentence, which is what the field + // meant under CLOUD-418 and which nothing can resolve. `batten mutate` + // resolves a slug, so the obligation becomes checkable rather than + // asserted, and every case below inherits the corrected shape. "tests": [{ "file": "crates/batten/tests/it/ready.rs", - "mutation": "drop the required-key check", + "mutation": "required-key-unread", }], }) } @@ -273,6 +286,104 @@ fn claims_payload(object: &serde_json::Value, blocked_by: &[&str]) -> String { ) } +/// A row created after the fixture's cutover, so the prose dialect is refused. +const AFTER_CUTOVER: &str = "2026-07-01T00:00:00.000Z"; +/// A row created before it, so the prose dialect still passes. +const BEFORE_CUTOVER: &str = "2026-01-01T00:00:00.000Z"; + +/// A payload carrying a chosen creation instant, for the cutover cases below. +/// +/// Every other fixture here omits `createdAt` entirely, which is could-not-look +/// and exempt — that is why the whole prose corpus above stays clean, and why +/// these cases have to state their own instant rather than reusing the shared +/// builder. +fn dated_payload(created_at: Option<&str>, description: &str) -> String { + let mut value = serde_json::json!({ + "id": "CLOUD-999", + "description": description, + "relations": { "blockedBy": [] }, + }); + if let Some(created_at) = created_at { + value["createdAt"] = serde_json::json!(created_at); + } + value.to_string() +} + +// --------------------------------------------------------------------------- +// CLOUD-472: the prose dialect is a LEGACY, not an alternative. +// +// `REQUIRED_CLAIMS` and `check_claimed_tests` already force a `mutation` onto +// every declared obligation — CLOUD-418's field. That mechanism was unreachable, +// because an absent fence dropped the author onto the prose path, and measured +// 2026-09-01 the object was used by nothing at all. +// --------------------------------------------------------------------------- + +#[test] +fn a_prose_block_past_the_cutover_is_refused() { + let dir = with_tasks("ready-prose-past-cutover"); + let output = lint( + &dir, + &dated_payload( + Some(AFTER_CUTOVER), + &block("* **Test obligation (§7).** Three discriminating observations.\n"), + ), + ); + assert_eq!(code(&output), 2, "{}", stderr(&output)); + assert!( + stderr(&output).contains("claims-object-absent"), + "the refusal must name the class, or the author cannot act on it: {}", + stderr(&output) + ); +} + +#[test] +fn a_claims_object_past_the_cutover_is_clean() { + // The remedy has to be REACHABLE from the refusal above, or the ratchet is a + // wall. Same key, same fixture, the object supplied. + let dir = with_tasks("ready-object-past-cutover"); + let object = serde_json::to_string_pretty(&complete_claims()).expect("encodable"); + let output = lint( + &dir, + &dated_payload(Some(AFTER_CUTOVER), &claims_block(&object)), + ); + assert_eq!(code(&output), 0, "{}", stderr(&output)); +} + +/// THE ANTI-VACUITY MIRROR, and without it the arm is satisfied by a check that +/// refuses every prose block — which is the change that takes the board's ready +/// frontier dark in one step (CLOUD-858's measured shape). +#[test] +fn a_prose_block_before_the_cutover_is_clean() { + let dir = with_tasks("ready-prose-before-cutover"); + let output = lint( + &dir, + &dated_payload( + Some(BEFORE_CUTOVER), + &block("* **Test obligation (§7).** Three discriminating observations.\n"), + ), + ); + assert_eq!(code(&output), 0, "{}", stderr(&output)); +} + +/// COULD-NOT-LOOK PASSES. A payload carrying no creation instant cannot be +/// placed against the cutover at all, so it is judged exactly as it was before +/// this clause existed. Reading "no stamp" as "past the cutover" would turn a +/// verdict about the payload into a verdict about the row — and this is the arm +/// that keeps every other fixture in this file, none of which sets `createdAt`, +/// passing for the RIGHT reason rather than by accident. +#[test] +fn a_payload_with_no_creation_instant_is_judged_as_before() { + let dir = with_tasks("ready-no-stamp"); + let output = lint( + &dir, + &dated_payload( + None, + &block("* **Test obligation (§7).** Three discriminating observations.\n"), + ), + ); + assert_eq!(code(&output), 0, "{}", stderr(&output)); +} + // --------------------------------------------------------------------------- // §453: the checkable half as data, and the prose path it does not disturb. // --------------------------------------------------------------------------- @@ -356,6 +467,34 @@ fn an_empty_value_is_an_omission_wearing_a_declarations_shape() { ); } +/// CLOUD-472. `mutation` landed under CLOUD-418 as PROSE describing the change +/// that would kill the case — a better claim than nothing, and still joinable to +/// nothing. A slug is joinable: `batten mutate` resolves it, applies the +/// expression, runs the named case, and a survivor is the finding. +/// +/// Shape only, at this tier and at this moment: the case does not exist at +/// refinement time, so resolving the slug here would refuse every honest row +/// before its code was written. Whitespace is the whole discriminator, because +/// `mutate`'s own three-field row format already forbids it in a slug. +#[test] +fn a_mutation_written_as_prose_rather_than_a_slug_is_refused() { + let dir = with_tasks("ready-claims-mutation-prose"); + let mut object = complete_claims(); + object["tests"][0]["mutation"] = serde_json::json!("drop the required-key check"); + let output = lint(&dir, &claims_payload(&object, &[])); + assert_eq!(code(&output), 2, "{}", stderr(&output)); + assert!( + stderr(&output).contains("test-claim-mutation-not-a-slug"), + "the refusal must name the class: {}", + stderr(&output) + ); + + // THE REMEDY IS REACHABLE, which is what keeps this from being a wall: the + // unmodified fixture already carries a slug and passes. + let clean = lint(&dir, &claims_payload(&complete_claims(), &[])); + assert_eq!(code(&clean), 0, "{}", stderr(&clean)); +} + #[test] fn a_gate_that_names_no_task_is_refused() { // The half that makes the mechanism unwritable as prose, which is the row's diff --git a/crates/batten/tests/it/reclaim_report_once.rs b/crates/batten/tests/it/reclaim_report_once.rs new file mode 100644 index 000000000..e68a43a51 --- /dev/null +++ b/crates/batten/tests/it/reclaim_report_once.rs @@ -0,0 +1,165 @@ +//! The reclaim verdict is reported once per BOOT, not once per session +//! (CLOUD-1301). +//! +//! # The defect +//! +//! `reclaim-census report` classifies the PREVIOUS boot, resolved as the newest +//! recorded boot that is not this one. That is immutable history: recording this +//! boot does not move it, and no landing completed here changes what the last +//! container was doing when it died. So on a container whose predecessor was +//! reclaimed mid-landing the verdict is TRUE and repeats at every session start +//! for the life of the container — and after the first read it is exactly the +//! noise CLOUD-891 removed. The session-start comment already made that argument +//! for the negative readings and did not apply it to the positive one. +//! +//! # Why the tier is here rather than in a `.bats` +//! +//! The case that WOULD have caught this died with its suite: `main` retired +//! `.claude/hooks/session-start.sh` into declared handler rows and took +//! `tests/session-start.bats` with it. A replacement `.bats` is refused by +//! `V-SHELL-RULE-ADDED`, and `tests/reclaim-census.bats` is governed at head so +//! it cannot be edited either. The fix therefore owes its own tier, and this is +//! it. +//! +//! # Why it drives the task body rather than a fabricated decision +//! +//! The suppression lives in `[tasks."session:census"]`'s body, because +//! `mise-tasks/reclaim-census.sh` is governed by `shell-retirement` and is not +//! this row's to edit. A test that re-implemented the decision in Rust would be +//! the `with input as` shape `.claude/rules/policy-modules.md` names one layer +//! down: it would pass over a body that never runs, reads the wrong store, or +//! writes the mark before the report instead of after. +//! +//! # The isolation, and why the census already affords it +//! +//! Both stores hang off the git directory and `git rev-parse` honours `GIT_DIR`, +//! and the boot time is `BATTEN_BOOT_TIME`-injectable — the census's own header +//! says why: "a suite that cannot vary the boot time cannot exercise a single +//! row of the table below". So a fixture git dir plus an injected boot drives +//! every verdict without touching the container's real record, which matters +//! more than usual here: this container's own store carries a live reclaim, and +//! a suite that read it would suppress the very verdict a human still needs. + +// Panicking on setup failure is the idiomatic way for a test to fail loudly. +// +// UNIX-ONLY, for `session_provisioning.rs`'s reason one step over: the subject is +// a `mise` task body that runs under `sh`, and the fixture drives it through the +// task runner rather than through the engine. +#![cfg(unix)] +#![allow(clippy::unwrap_used, clippy::expect_used)] + +use std::path::{Path, PathBuf}; + +/// The boot this fixture claims to be running under. Any value works; it only +/// has to differ from the recorded predecessor. +const NOW: &str = "9000"; + +/// The predecessor boot the seeded records belong to. +const BEFORE: &str = "500"; + +/// A git directory carrying nothing but the two census stores. +fn store(name: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!("batten-reclaim-{name}")); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).expect("create the fixture root"); + #[expect( + clippy::disallowed_types, + reason = "stays: the fixture store must be a real git directory, because the census resolves it with `git rev-parse` and a hand-built `.git` would test a path the program never takes (CLOUD-1301)" + )] + let out = std::process::Command::new("git") + .args(["init", "-q", "."]) + .current_dir(&dir) + .output() + .expect("git init"); + assert!(out.status.success(), "git init: {out:?}"); + dir.join(".git") +} + +/// Seed the two stores the census reads: the boots it has seen, and the beats +/// recorded under them. +/// +/// `last` is the record that decides the verdict — `h` is a heartbeat, so the +/// predecessor was mid-landing when it went; `x` is an exit mark, so it stopped +/// on purpose. +fn seed(git_dir: &Path, last: &str) { + std::fs::write(git_dir.join("batten-boots"), format!("{BEFORE}\n")).expect("seed the boots"); + std::fs::write( + git_dir.join("batten-reclaim-log"), + format!("h 1000 {BEFORE}\n{last} 2000 {BEFORE}\n"), + ) + .expect("seed the log"); +} + +/// One session start, as the handler row invokes it. +fn session_start(git_dir: &Path) -> String { + #[expect( + clippy::disallowed_types, + reason = "stays: the subject IS a task body, so the task runner is what has to invoke it — a spawn of the engine instead would assert over a decision this row deliberately does not put in the engine (CLOUD-1301)" + )] + let out = std::process::Command::new("mise") + .args(["run", "session:census"]) + .current_dir(env!("CARGO_MANIFEST_DIR")) + .env("GIT_DIR", git_dir) + .env("BATTEN_BOOT_TIME", NOW) + .output() + .expect("run the session-start census handler"); + format!( + "{}{}", + String::from_utf8_lossy(&out.stdout), + String::from_utf8_lossy(&out.stderr) + ) +} + +/// Does this session's output carry the reclaim verdict? +fn reported(output: &str) -> bool { + output.contains("A LANDING WAS IN FLIGHT") +} + +#[test] +fn a_reclaim_is_reported_once_and_the_repeat_is_silent() { + let git_dir = store("reported-once"); + seed(&git_dir, "h"); + + assert!( + reported(&session_start(&git_dir)), + "the first session on a container whose predecessor was reclaimed \ + mid-landing must still be told" + ); + assert!( + !reported(&session_start(&git_dir)), + "the fact is once per boot, so every session after the first is noise" + ); +} + +#[test] +fn a_predecessor_that_stopped_on_purpose_is_silent_throughout() { + // The negative reading, unchanged by this row and asserted so it stays that + // way: an ordinary stop is not news, and a fix that started announcing one + // would be louder than the defect it replaced. + let git_dir = store("intentional-stop"); + seed(&git_dir, "x"); + + assert!(!reported(&session_start(&git_dir)), "first session"); + assert!(!reported(&session_start(&git_dir)), "second session"); +} + +#[test] +fn a_new_boot_is_reported_though_an_older_one_was_already_marked() { + // THE VACUITY CASE. Suppression keyed to anything but the boot — a flag, a + // once-per-clone marker — would silence the NEXT container's genuine reclaim + // too, which deletes the instrument CLOUD-451 built rather than quietening + // it. A mark left by another boot must not suppress this one's verdict. + let git_dir = store("new-boot"); + seed(&git_dir, "h"); + std::fs::write(git_dir.join("batten-reclaim-log.reported"), "1\n") + .expect("plant a mark from an older boot"); + + assert!( + reported(&session_start(&git_dir)), + "a mark from a different boot says nothing about this one" + ); + assert!( + !reported(&session_start(&git_dir)), + "and this boot's own mark then suppresses the repeat" + ); +} diff --git a/crates/batten/tests/it/record_closes.rs b/crates/batten/tests/it/record_closes.rs new file mode 100644 index 000000000..028186dbe --- /dev/null +++ b/crates/batten/tests/it/record_closes.rs @@ -0,0 +1,144 @@ +//! `batten record closes`, over the compiled binary and the store the reader +//! actually reads. +//! +//! # The seam this tier owns +//! +//! `policy/filed-here.rego`'s `filed-over-own-diff` exempts a row the pull +//! request CLOSES, and reads that from `input.tree.records["pr-closes"]`. Until +//! this verb existed that record had exactly one producer: the `pr-body-closes` +//! `[[recorder]]` row, minted from an observed `gh pr view --jq .body` tool +//! envelope. So the exemption was reachable only when an agent happened to make +//! that call, as a mediated tool call, on a harness whose spelling is surveyed. +//! +//! **Measured on the branch that added this file**: three rows it closes in its +//! own body, all three refused, and no `pr-closes` record in the store at all — +//! while `land` had fetched exactly that body and piped it to +//! `filed-here-check`, whose task body is `batten check`, declared `read`, with +//! no stdin channel. Fetched, handed over, dropped. +//! +//! A module's own `test_` rules cannot see any of that: they fabricate the +//! document, so they pass over a key nothing fills. These cases drive the real +//! writer and then read the file back through the engine's own `record_path`, so +//! a change to the naming reddens here rather than silently pointing the reader +//! and the writer at different files. + +#![allow(clippy::unwrap_used, clippy::expect_used)] + +use crate::common; + +use std::path::{Path, PathBuf}; +use std::process::Stdio; + +/// A scratch repository on a named branch, with this repository's own +/// `batten.toml` — the authority that declares `ready-issue-key` and +/// `ready-closing-verb`, which the verb resolves its grammar from. +fn repo(name: &str) -> PathBuf { + let root = common::scratch(name); + common::git_in(&root, &["init", "--quiet", "--initial-branch", "work"]); + std::fs::write(root.join("seed.txt"), "seed\n").expect("seed"); + common::git_in(&root, &["add", "-A"]); + common::git_in(&root, &["commit", "--quiet", "-m", "base"]); + let authority = common::at_root("batten.toml"); + std::fs::copy(authority, root.join("batten.toml")).expect("install the committed authority"); + root +} + +/// Run the verb with `body` on stdin, and return its exit status. +fn record(root: &Path, body: &str) -> std::process::Output { + let mut child = common::batten() + .arg("record") + .arg("closes") + .current_dir(root) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("spawn the compiled binary"); + { + use std::io::Write as _; + child + .stdin + .as_mut() + .expect("stdin is piped") + .write_all(body.as_bytes()) + .expect("write the body"); + } + child.wait_with_output().expect("the verb terminates") +} + +/// Read the record back through the engine's own path derivation. +fn recorded(root: &Path) -> Option { + let git_dir = common::git_in(root, &["rev-parse", "--absolute-git-dir"]); + let path = batten::recorder::record_path(Path::new(git_dir.trim()), "pr-closes", "work"); + std::fs::read_to_string(path).ok() +} + +#[test] +fn a_body_that_closes_rows_records_them_in_the_readers_own_shape() { + // The positive arm, and the shape is load-bearing rather than cosmetic: + // `filed-here.rego` splits the column on `:` and the keys on `,`, so a + // producer that renders them any other way writes a record the reader parses + // into nothing and the exemption stays dead in a new way. + let root = repo("record-closes-two"); + let out = record( + &root, + "Consolidated.\n\nCloses CLOUD-1295\nCloses CLOUD-1297\n", + ); + assert!(out.status.success(), "{out:?}"); + assert_eq!( + recorded(&root).as_deref(), + Some("closes 2:CLOUD-1295,CLOUD-1297\n") + ); +} + +#[test] +fn naming_a_row_is_not_closing_it() { + // CLOUD-674's distinction, which `claimed-keys` learned expensively: a body + // citing a row as evidence is not moving it. Without this the verb would + // exempt every row a body mentions, which is strictly worse than the dead + // exemption it replaces — a gate that reads clean over the punt it exists to + // price. + let root = repo("record-closes-cited"); + let out = record( + &root, + "This follows the reasoning in CLOUD-761 and refs CLOUD-843.\n", + ); + assert!(out.status.success(), "{out:?}"); + assert_eq!(recorded(&root).as_deref(), Some("closes 0\n")); +} + +#[test] +fn a_body_read_and_closing_nothing_is_a_count_rather_than_an_absence() { + // THE THREE-VALUED READ, and it is the whole reason the recorder declares + // `zero-is-a-count`. `closes 0` says the body was READ and closes nothing; + // no record at all says nobody looked. A producer that skipped the write on + // an empty key set would collapse them, and the reader has no way back. + let root = repo("record-closes-zero"); + let out = record(&root, "A body with no keys in it at all.\n"); + assert!(out.status.success(), "{out:?}"); + assert_eq!(recorded(&root).as_deref(), Some("closes 0\n")); +} + +#[test] +fn an_empty_body_refuses_rather_than_recording_that_nothing_is_closed() { + // The direction that must not be silent. An empty stdin is a fetch that + // failed — `gh pr view` on a branch with no PR prints nothing — and writing + // `closes 0` for it would convert could-not-look into a measurement, which + // is the vacuous pass this record's own shape exists to prevent. + let root = repo("record-closes-empty"); + let out = record(&root, " \n"); + assert!(!out.status.success()); + assert!(recorded(&root).is_none(), "no record is written"); +} + +#[test] +fn a_longer_key_is_not_read_as_a_shorter_one_it_contains() { + // `keys_closed_in` filters `keys_in` rather than searching again, so this is + // the one definition of a key doing its job here too: `CLOUD-179` must not + // record `CLOUD-17`. Asserted at this tier because the verb is a new caller + // of that definition and a new caller is where the boundary gets re-derived. + let root = repo("record-closes-prefix"); + let out = record(&root, "Closes CLOUD-1790\n"); + assert!(out.status.success(), "{out:?}"); + assert_eq!(recorded(&root).as_deref(), Some("closes 1:CLOUD-1790\n")); +} diff --git a/crates/batten/tests/it/shell_retirement.rs b/crates/batten/tests/it/shell_retirement.rs index 2de3d488b..d76167894 100644 --- a/crates/batten/tests/it/shell_retirement.rs +++ b/crates/batten/tests/it/shell_retirement.rs @@ -429,6 +429,123 @@ fn an_edit_truncating_a_line_at_a_retired_reference_is_admitted() { /// ANTI-VACUITY for the case above, and it is not optional: a bare prefix /// relation would admit any shortening at all. What this truncation drops names /// a path that is still in the tree, so it is ordinary maintenance. +/// ARM 5 — a caller that names its callee as a TASK, not as a path (CLOUD-1299). +/// +/// The measured instance is a governed `.bats` pinning a remedy's PROSE: +/// `tests/verify.bats` asserted that `verify`'s refusal names `bot-issue +/// receipt`. Retiring the program that names makes the remedy false, changing +/// the remedy fails the assertion, and editing a governed suite is refused with +/// no override route — so the retirement had no landable shape at all. Both +/// spellings are driven here because they are the two the tree carries and only +/// one of them is anchored at the name: the bare ` ` span, and the +/// `mise run ` span a comment three lines away used. +#[test] +fn an_edit_repointing_a_task_name_at_the_declared_invocation_is_admitted() { + let root = repo( + "task-name-repointed", + &[ + ("mise-tasks/old-gate.sh", GATE), + ( + "tests/pinned.bats", + "# minted by `mise run old-gate receipt` on a branch.\n@test \"it holds\" {\n [[ \"$output\" == *\"old-gate receipt\"* ]]\n}\n", + ), + ], + &Head { + written: &[ + ( + "tests/pinned.bats", + "# minted by `batten claim bot` on a branch.\n@test \"it holds\" {\n [[ \"$output\" == *\"batten claim bot\"* ]]\n}\n", + ), + ( + "crates/batten/tests/old_gate.rs", + &ledger_running("mise-tasks/old-gate.sh", "batten claim bot"), + ), + ], + removed: &["mise-tasks/old-gate.sh"], + }, + ); + assert!( + findings(&root).is_empty(), + "a caller naming its callee as a task may be repointed at the declared \ + invocation, same as one naming it by path: {:?}", + findings(&root) + ); +} + +/// ANTI-VACUITY for the case above, and the one CLOUD-1299 names by hand: an arm +/// that admitted any span merely CONTAINING a naming form would be the licence +/// the module refuses in as many words. This edit repoints the same span AND +/// flips the comparison on the same line, so nothing about the retirement +/// accounts for the second change. +#[test] +fn an_edit_repointing_a_task_name_while_rewriting_the_line_is_refused() { + let root = repo( + "task-name-rewritten", + &[ + ("mise-tasks/old-gate.sh", GATE), + ( + "tests/pinned.bats", + "@test \"it holds\" {\n [[ \"$output\" == *\"old-gate receipt\"* ]]\n}\n", + ), + ], + &Head { + written: &[ + ( + "tests/pinned.bats", + "@test \"it holds\" {\n [[ \"$output\" != *\"batten claim bot\"* ]]\n}\n", + ), + ( + "crates/batten/tests/old_gate.rs", + &ledger_running("mise-tasks/old-gate.sh", "batten claim bot"), + ), + ], + removed: &["mise-tasks/old-gate.sh"], + }, + ); + assert_eq!( + findings(&root), + vec![String::from("shell-rule-retired")], + "the span is derived from the diff, so a line that also changed elsewhere \ + has no single repointed span and is refused" + ); +} + +/// The second anti-vacuity axis: the TARGET still comes from the ledger. A task +/// span may only be repointed at an invocation the retirement committed to, so +/// an arm carrying no `runs:` field admits nothing. +#[test] +fn a_task_name_repointed_at_an_undeclared_invocation_is_refused() { + let root = repo( + "task-name-undeclared", + &[ + ("mise-tasks/old-gate.sh", GATE), + ( + "tests/pinned.bats", + "@test \"it holds\" {\n [[ \"$output\" == *\"old-gate receipt\"* ]]\n}\n", + ), + ], + &Head { + written: &[ + ( + "tests/pinned.bats", + "@test \"it holds\" {\n [[ \"$output\" == *\"batten claim bot\"* ]]\n}\n", + ), + ( + "crates/batten/tests/old_gate.rs", + &ledger("mise-tasks/old-gate.sh"), + ), + ], + removed: &["mise-tasks/old-gate.sh"], + }, + ); + assert_eq!( + findings(&root), + vec![String::from("shell-rule-retired")], + "without a `runs:` arm there is no declared invocation, so nothing admits \ + the repointing" + ); +} + #[test] fn an_edit_truncating_a_line_at_a_live_reference_is_refused() { let root = repo( 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 280772349..51d3c19ea 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 @@ -483,6 +483,33 @@ expression: stdout_of(&output) "data_channel": false, "flags": [], "subcommands": [ + { + "path": "claim bot", + "id": "claim.bot", + "about": "Attest a bot branch from the lane's public facts, and mint the receipt when they hold", + "effect": "write", + "data_channel": false, + "flags": [], + "subcommands": [] + }, + { + "path": "claim carry", + "id": "claim.carry", + "about": "Attest that this branch only carries licence rows forward, and mint the receipt when it does", + "effect": "write", + "data_channel": true, + "flags": [ + { + "name": "json", + "short": "J", + "long": "json", + "takes_value": false, + "positional": false, + "help": "Emit byte-stable JSON instead of pointer lines" + } + ], + "subcommands": [] + }, { "path": "claim check", "id": "claim.check", @@ -1413,6 +1440,104 @@ expression: stdout_of(&output) "data_channel": false, "flags": [], "subcommands": [ + { + "path": "pr closes", + "id": "pr.closes", + "about": "Whether a pull request's body still closes a tracker key, asked at the last moment", + "effect": "unclassified", + "data_channel": false, + "flags": [ + { + "name": "pr", + "short": null, + "long": null, + "takes_value": true, + "positional": true, + "help": "The pull request number this verb is about" + } + ], + "subcommands": [] + }, + { + "path": "pr derive", + "id": "pr.derive", + "about": "The tracker row a bot's pull request implies, as a payload the refinement gate reads", + "effect": "unclassified", + "data_channel": false, + "flags": [ + { + "name": "pr", + "short": null, + "long": null, + "takes_value": true, + "positional": true, + "help": "The pull request number this verb is about" + } + ], + "subcommands": [] + }, + { + "path": "pr ensure", + "id": "pr.ensure", + "about": "File the row and link it, doing whatever this tick can and saying what it did", + "effect": "write", + "data_channel": false, + "flags": [ + { + "name": "pr", + "short": null, + "long": null, + "takes_value": true, + "positional": true, + "help": "The pull request number this verb is about" + } + ], + "subcommands": [] + }, + { + "path": "pr file", + "id": "pr.file", + "about": "Open the mirror issue a bot's pull request implies, and report its number", + "effect": "write", + "data_channel": false, + "flags": [ + { + "name": "pr", + "short": null, + "long": null, + "takes_value": true, + "positional": true, + "help": "The pull request number this verb is about" + } + ], + "subcommands": [] + }, + { + "path": "pr link", + "id": "pr.link", + "about": "Write the closing key into a bot pull request's body, so its merge moves the row", + "effect": "write", + "data_channel": false, + "flags": [ + { + "name": "key", + "short": null, + "long": null, + "takes_value": true, + "positional": true, + "help": "The tracker key the pull request should close" + }, + { + "name": "pr", + "short": null, + "long": null, + "takes_value": true, + "positional": true, + "help": "The pull request number this verb is about" + } + ], + "subcommands": [] + }, { "path": "pr watch", "id": "pr.watch", @@ -1649,6 +1774,15 @@ expression: stdout_of(&output) "data_channel": false, "flags": [], "subcommands": [ + { + "path": "record closes", + "id": "record.closes", + "about": "Record which rows this branch's pull request body closes, read on stdin", + "effect": "write", + "data_channel": false, + "flags": [], + "subcommands": [] + }, { "path": "record forge", "id": "record.forge", @@ -1667,6 +1801,15 @@ expression: stdout_of(&output) ], "subcommands": [] }, + { + "path": "record plan", + "id": "record.plan", + "about": "Record this branch's plan, read as ` ` lines on stdin", + "effect": "write", + "data_channel": false, + "flags": [], + "subcommands": [] + }, { "path": "record tool", "id": "record.tool", diff --git a/crates/batten/tests/it/verdict_vocabulary.rs b/crates/batten/tests/it/verdict_vocabulary.rs index 81bffbac0..e5d96d2ab 100644 --- a/crates/batten/tests/it/verdict_vocabulary.rs +++ b/crates/batten/tests/it/verdict_vocabulary.rs @@ -105,6 +105,7 @@ const CANDIDATES: &[&str] = &[ "pattern", "pin", "place", + "plan", "point", "port", "program", diff --git a/hk.pkl b/hk.pkl index 68b68154e..55d617aac 100644 --- a/hk.pkl +++ b/hk.pkl @@ -428,6 +428,7 @@ local gate = new Mapping { "crates/batten/src/action.rs", "crates/batten/src/advisory.rs", "crates/batten/src/attribution.rs", + "crates/batten/src/bot.rs", "crates/batten/src/budget.rs", "crates/batten/src/capture.rs", "crates/batten/src/hookcost.rs", diff --git a/man/batten-claim-bot.1 b/man/batten-claim-bot.1 new file mode 100644 index 000000000..2f16bd830 --- /dev/null +++ b/man/batten-claim-bot.1 @@ -0,0 +1,13 @@ +.ie \n(.g .ds Aq \(aq +.el .ds Aq ' +.TH batten-claim-bot 1 batten +.SH NAME +batten\-claim\-bot \- Attest a bot branch from the lane\*(Aqs public facts, and mint the receipt when they hold +.SH SYNOPSIS +\fBbatten claim bot\fR [\fB\-h\fR|\fB\-\-help\fR] +.SH DESCRIPTION +Attest a bot branch from the lane\*(Aqs public facts, and mint the receipt when they hold +.SH OPTIONS +.TP +\fB\-h\fR, \fB\-\-help\fR +Print help diff --git a/man/batten-claim-carry.1 b/man/batten-claim-carry.1 new file mode 100644 index 000000000..a006ad2c0 --- /dev/null +++ b/man/batten-claim-carry.1 @@ -0,0 +1,16 @@ +.ie \n(.g .ds Aq \(aq +.el .ds Aq ' +.TH batten-claim-carry 1 batten +.SH NAME +batten\-claim\-carry \- Attest that this branch only carries licence rows forward, and mint the receipt when it does +.SH SYNOPSIS +\fBbatten claim carry\fR [\fB\-J\fR|\fB\-\-json\fR] [\fB\-h\fR|\fB\-\-help\fR] +.SH DESCRIPTION +Attest that this branch only carries licence rows forward, and mint the receipt when it does +.SH OPTIONS +.TP +\fB\-J\fR, \fB\-\-json\fR +Emit byte\-stable JSON instead of pointer lines +.TP +\fB\-h\fR, \fB\-\-help\fR +Print help diff --git a/man/batten-claim.1 b/man/batten-claim.1 index 1e24d75db..e0a79b22a 100644 --- a/man/batten-claim.1 +++ b/man/batten-claim.1 @@ -16,5 +16,11 @@ Print help batten\-claim\-check(1) Refuse a pull of an issue somebody is already on, and mint the receipt when it is free .TP +batten\-claim\-bot(1) +Attest a bot branch from the lane\*(Aqs public facts, and mint the receipt when they hold +.TP +batten\-claim\-carry(1) +Attest that this branch only carries licence rows forward, and mint the receipt when it does +.TP batten\-claim\-help(1) Print this message or the help of the given subcommand(s) diff --git a/man/batten-pr-closes.1 b/man/batten-pr-closes.1 new file mode 100644 index 000000000..d8c205d69 --- /dev/null +++ b/man/batten-pr-closes.1 @@ -0,0 +1,16 @@ +.ie \n(.g .ds Aq \(aq +.el .ds Aq ' +.TH batten-pr-closes 1 batten +.SH NAME +batten\-pr\-closes \- Whether a pull request\*(Aqs body still closes a tracker key, asked at the last moment +.SH SYNOPSIS +\fBbatten pr closes\fR [\fB\-h\fR|\fB\-\-help\fR] <\fIpr\fR> +.SH DESCRIPTION +Whether a pull request\*(Aqs body still closes a tracker key, asked at the last moment +.SH OPTIONS +.TP +\fB\-h\fR, \fB\-\-help\fR +Print help +.TP +<\fIpr\fR> +The pull request number this verb is about diff --git a/man/batten-pr-derive.1 b/man/batten-pr-derive.1 new file mode 100644 index 000000000..8a750abb4 --- /dev/null +++ b/man/batten-pr-derive.1 @@ -0,0 +1,16 @@ +.ie \n(.g .ds Aq \(aq +.el .ds Aq ' +.TH batten-pr-derive 1 batten +.SH NAME +batten\-pr\-derive \- The tracker row a bot\*(Aqs pull request implies, as a payload the refinement gate reads +.SH SYNOPSIS +\fBbatten pr derive\fR [\fB\-h\fR|\fB\-\-help\fR] <\fIpr\fR> +.SH DESCRIPTION +The tracker row a bot\*(Aqs pull request implies, as a payload the refinement gate reads +.SH OPTIONS +.TP +\fB\-h\fR, \fB\-\-help\fR +Print help +.TP +<\fIpr\fR> +The pull request number this verb is about diff --git a/man/batten-pr-ensure.1 b/man/batten-pr-ensure.1 new file mode 100644 index 000000000..d17d4b4ca --- /dev/null +++ b/man/batten-pr-ensure.1 @@ -0,0 +1,16 @@ +.ie \n(.g .ds Aq \(aq +.el .ds Aq ' +.TH batten-pr-ensure 1 batten +.SH NAME +batten\-pr\-ensure \- File the row and link it, doing whatever this tick can and saying what it did +.SH SYNOPSIS +\fBbatten pr ensure\fR [\fB\-h\fR|\fB\-\-help\fR] <\fIpr\fR> +.SH DESCRIPTION +File the row and link it, doing whatever this tick can and saying what it did +.SH OPTIONS +.TP +\fB\-h\fR, \fB\-\-help\fR +Print help +.TP +<\fIpr\fR> +The pull request number this verb is about diff --git a/man/batten-pr-file.1 b/man/batten-pr-file.1 new file mode 100644 index 000000000..00ab4d626 --- /dev/null +++ b/man/batten-pr-file.1 @@ -0,0 +1,16 @@ +.ie \n(.g .ds Aq \(aq +.el .ds Aq ' +.TH batten-pr-file 1 batten +.SH NAME +batten\-pr\-file \- Open the mirror issue a bot\*(Aqs pull request implies, and report its number +.SH SYNOPSIS +\fBbatten pr file\fR [\fB\-h\fR|\fB\-\-help\fR] <\fIpr\fR> +.SH DESCRIPTION +Open the mirror issue a bot\*(Aqs pull request implies, and report its number +.SH OPTIONS +.TP +\fB\-h\fR, \fB\-\-help\fR +Print help +.TP +<\fIpr\fR> +The pull request number this verb is about diff --git a/man/batten-pr-link.1 b/man/batten-pr-link.1 new file mode 100644 index 000000000..f9beed99f --- /dev/null +++ b/man/batten-pr-link.1 @@ -0,0 +1,19 @@ +.ie \n(.g .ds Aq \(aq +.el .ds Aq ' +.TH batten-pr-link 1 batten +.SH NAME +batten\-pr\-link \- Write the closing key into a bot pull request\*(Aqs body, so its merge moves the row +.SH SYNOPSIS +\fBbatten pr link\fR [\fB\-h\fR|\fB\-\-help\fR] <\fIpr\fR> <\fIkey\fR> +.SH DESCRIPTION +Write the closing key into a bot pull request\*(Aqs body, so its merge moves the row +.SH OPTIONS +.TP +\fB\-h\fR, \fB\-\-help\fR +Print help +.TP +<\fIpr\fR> +The pull request number this verb is about +.TP +<\fIkey\fR> +The tracker key the pull request should close diff --git a/man/batten-pr.1 b/man/batten-pr.1 index ef5c22cda..d340ea96e 100644 --- a/man/batten-pr.1 +++ b/man/batten-pr.1 @@ -16,5 +16,20 @@ Print help batten\-pr\-watch(1) Poll a head\*(Aqs check runs until the required set answers, then report the verdict .TP +batten\-pr\-derive(1) +The tracker row a bot\*(Aqs pull request implies, as a payload the refinement gate reads +.TP +batten\-pr\-file(1) +Open the mirror issue a bot\*(Aqs pull request implies, and report its number +.TP +batten\-pr\-link(1) +Write the closing key into a bot pull request\*(Aqs body, so its merge moves the row +.TP +batten\-pr\-ensure(1) +File the row and link it, doing whatever this tick can and saying what it did +.TP +batten\-pr\-closes(1) +Whether a pull request\*(Aqs body still closes a tracker key, asked at the last moment +.TP batten\-pr\-help(1) Print this message or the help of the given subcommand(s) diff --git a/man/batten-record-closes.1 b/man/batten-record-closes.1 new file mode 100644 index 000000000..8489960f6 --- /dev/null +++ b/man/batten-record-closes.1 @@ -0,0 +1,13 @@ +.ie \n(.g .ds Aq \(aq +.el .ds Aq ' +.TH batten-record-closes 1 batten +.SH NAME +batten\-record\-closes \- Record which rows this branch\*(Aqs pull request body closes, read on stdin +.SH SYNOPSIS +\fBbatten record closes\fR [\fB\-h\fR|\fB\-\-help\fR] +.SH DESCRIPTION +Record which rows this branch\*(Aqs pull request body closes, read on stdin +.SH OPTIONS +.TP +\fB\-h\fR, \fB\-\-help\fR +Print help diff --git a/man/batten-record-plan.1 b/man/batten-record-plan.1 new file mode 100644 index 000000000..0dde5ab9d --- /dev/null +++ b/man/batten-record-plan.1 @@ -0,0 +1,13 @@ +.ie \n(.g .ds Aq \(aq +.el .ds Aq ' +.TH batten-record-plan 1 batten +.SH NAME +batten\-record\-plan \- Record this branch\*(Aqs plan, read as ` ` lines on stdin +.SH SYNOPSIS +\fBbatten record plan\fR [\fB\-h\fR|\fB\-\-help\fR] +.SH DESCRIPTION +Record this branch\*(Aqs plan, read as ` ` lines on stdin +.SH OPTIONS +.TP +\fB\-h\fR, \fB\-\-help\fR +Print help diff --git a/man/batten-record.1 b/man/batten-record.1 index e80454911..f1c68c055 100644 --- a/man/batten-record.1 +++ b/man/batten-record.1 @@ -19,5 +19,11 @@ 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\-plan(1) +Record this branch\*(Aqs plan, read as ` ` lines on stdin +.TP +batten\-record\-closes(1) +Record which rows this branch\*(Aqs pull request body closes, read on stdin +.TP batten\-record\-help(1) Print this message or the help of the given subcommand(s) diff --git a/mise-tasks/bot-issue.sh b/mise-tasks/bot-issue.sh deleted file mode 100755 index 1961e5c88..000000000 --- a/mise-tasks/bot-issue.sh +++ /dev/null @@ -1,486 +0,0 @@ -#!/usr/bin/env bash -#MISE description="Turn a bot's pull request into a refined tracker row, and link it back so the merge moves the board (CLOUD-693)" -# -# CLOUD-693. Every lifecycle gate here keys off an issue a human or an agent -# refined BEFORE the work started. A bot proposes with no issue and no session, so -# it fails all of them by construction rather than by misconfiguration — measured -# on #493, where `verify`'s claim receipt, `ready-names-an-issue` and -# `ready-needs-receipts` each refused in turn, and `closing-key-check` PASSED, -# which was the tell: it only fires when a body names a key non-closingly, and -# that body named none at all, so the merge would have moved nothing. -# -# The missing thing was never a gate change. It is this step: something that turns -# the proposal into a refined row before the lifecycle sees it, so the gates are -# satisfied honestly rather than bypassed. -# -# THE MANIFEST DIFF IS THE AUTHORITY (§1), and nothing here re-types it. The row's -# title is the bot's own PR title — which `renovate.json5`'s `packageRules` already -# decided the Conventional type of — and its body names the manifests the diff -# touches, read from the PR's file list rather than from the bump table in the PR -# body. A table is prose the bot wrote about the change; the file list is the -# change. When the two disagree the file list is right, so it is the only one read. -# -# WHY A BOT ROW CAN BE MECHANICAL AT ALL, which is the honest part of §2. A bump -# has no design question to refine: the source of truth is the manifest, the -# predicate is "CI green on the bump", the effect is none, and the bump follows the -# type already in the config. That is exactly why this must NOT reuse the agent -# refinement path, where a human judgement is the thing being attested — the two -# attest different things, and CLOUD-431 exists to keep them apart. -# -# IDEMPOTENT, KEYED ON THE PR. `ensure` is called on every lander tick, twice an -# hour for as long as a bot PR is open, so "already has a row" has to be the cheap -# and total answer: a body that names any `CLOUD-` is done, and nothing is -# filed. The key travels in the body rather than in a local record because the body -# is what the merge reads — a record this side could go missing and file a second -# row against a PR that already has one. -# -# REFUSES RATHER THAN INVENTING (exit 1). A PR whose diff touches no manifest this -# lane owns gets no row: the alternative is a tracker row asserting a bump nobody -# proposed, which is the CLOUD-198 class with a new author. The refusal names the -# PR and the paths it did touch, so a lane that grew a manifest is a one-line fix -# here rather than a mystery. -# -# Pointer-only per non-negotiable rule 4: the PR number, the issue key, the -# manifest paths. Never a diff body, never a version, never the tracker token. -# -# Exit 0 did the work (or found it already done) / 1 refused, this PR is not one of -# ours / 2 could not look — GitHub would not answer, so nothing was written. -# -# Usage: -# mise run bot-issue derive candidate payload on stdout, no writes -# mise run bot-issue file derive, then open the mirror issue -# mise run bot-issue link write `Closes ` into the PR body -# mise run bot-issue ensure the lander's call: all three, idempotent -# mise run bot-issue closes does the body STILL close a key? (CLOUD-768) -# mise run bot-issue receipt mint this branch's bot receipt -# -# The mutation drops the already-linked short circuit, so `ensure` files a second -# row on every tick — twice an hour, forever, against a PR that already has one. -#MUTANT files-a-row-per-tick|s/^\tif \[\[ -n "\$existing" \]\]; then$/\tif false; then/|a second call on the same PR files nothing -# -# The mutation drops the closing verb from the predicate, so any body merely -# NAMING a key reads as closing it — which is the exact state a Renovate rebase -# leaves behind, and the one this verb exists to refuse. -#MUTANT closes-on-a-bare-key|s/refuse "#\$num.s body closes/key=$(grep -oEm1 "CLOUD-[0-9]+" <<<"$body"); [ -n "$key" ] \&\& { echo "bot-issue: #$num closes $key"; return 0; }; refuse "#$num.s body closes/|A KEY NAMED BUT NOT CLOSED IS REFUSED -#PIN-OK: gh jq -set -uo pipefail - -# The manifests this lane owns, and the only paths a bot PR may touch to earn a -# row. Declared here rather than derived from `renovate.json5`'s -# `enabledManagers`: a manager name is not a path, the mapping between them is -# Renovate's and not ours to re-derive, and `ci-local-parity` already decides that -# the manager list itself is complete. Kept in sync by that gate plus this list -# being three lines long. -OWNED_MANIFESTS_RE='^(mise\.toml|Cargo\.toml|Cargo\.lock|\.github/workflows/.+)$' - -# The bots whose heads this lane will file for. `renovate` in every spelling the -# app authenticates as; `dependabot` is deliberately absent — CLOUD-660 retired it -# and a row filed for a bot that cannot open a PR would be a claim about a lane -# this repository does not have. -BOT_LOGINS_RE='^(renovate|renovate\[bot\]|mend-for-github-com\[bot\])$' - -# The marker that ties a mirror issue to the pull request it was filed for. A -# hidden HTML comment rather than a label or a title convention: it survives an -# edit, it is invisible in the rendered issue, and it is what makes `ensure` -# idempotent across the window where the row exists and the PR body does not yet -# name it. Searched by listing issues, never through the search API, whose -# indexing lag would let one tick file a second mirror. -MIRROR_MARKER_PREFIX="${BOT_ISSUE_MARKER:-bot-lane pr=}" - -# What Linear's GitHub Issues sync leaves on the issue once it has mirrored it. -# Measured on #558 -> CLOUD-764, 2026-08-20: `linear-code[bot]` posts a comment -# carrying this marker and the row's URL, about two seconds after creation. -LINKBACK_MARKER="${BOT_ISSUE_LINKBACK:-}" - -REPO="${BOT_ISSUE_REPO:-${REPO:-button-inc/batten}}" - -die() { - echo "::error:: bot-issue: $*" >&2 - exit 2 -} - -refuse() { - echo "::error:: bot-issue: $*" >&2 - exit 1 -} - -need() { - command -v "$1" >/dev/null 2>&1 || die "$1 is not on PATH — a gate that cannot look must not report success" -} - -# `gh api` through mise, so CI and a clone run the same call with the same token -# resolution (mem:github-access). Every read goes through here, so a 4xx is one -# message rather than one per call site. -gh_api() { - local out rc - out=$(gh api "$@" 2>&1) - rc=$? - if [[ "$rc" != 0 ]]; then - # Pointer-only: the endpoint and the status, never the response body — a - # GitHub error can echo a token in a header dump. - die "GET $1 failed (gh exit $rc) — cannot read the PR, so nothing is filed" - fi - printf '%s' "$out" -} - -pr_json() { - gh_api "repos/$REPO/pulls/$1" --jq '{number, title, body, login: .user.login, head: .head.ref, draft}' -} - -# The changed paths, capped at the first page. A bot bump touches two files; a PR -# touching more than 100 is not a bump and the cap refusing it is the safe -# direction rather than a truncation nobody sees. -pr_files() { - gh_api "repos/$REPO/pulls/$1/files?per_page=100" --jq '.[].filename' -} - -# --- derive ------------------------------------------------------------------- -# -# Emits a CANDIDATE PAYLOAD, not markdown, and that shape is the point: it is the -# same object `get_issue` returns, so `mise run ready-lint` reads it unchanged. -# The Ready block this writes is therefore checkable by the same gate that checks -# a human's, which is what keeps "derived" from meaning "exempt". -derive() { - local num="$1" pr title login files owned body - pr=$(pr_json "$num") - title=$(jq -r '.title // ""' <<<"$pr") - login=$(jq -r '.login // ""' <<<"$pr") - [[ -n "$title" ]] || die "#$num has no title — the tracker row's title is the PR's, so there is nothing to file" - - grep -Eq "$BOT_LOGINS_RE" <<<"$login" || - refuse "#$num was opened by '$login', which is not a bot this lane files for — an agent's PR carries its own claim receipt and its own issue" - - files=$(pr_files "$num") - owned=$(grep -E "$OWNED_MANIFESTS_RE" <<<"$files" || true) - if [[ -z "$owned" ]]; then - # Pointer-only: the paths, never their contents. - refuse "#$num touches no manifest this lane owns, so there is no bump to describe: $(tr '\n' ' ' <<<"$files")— filing a row here would assert a change nobody proposed" - fi - - # §6's type is read out of the PR subject rather than chosen: `renovate.json5`'s - # `packageRules` already decided it (`ci` for the toolchain and workflows, - # `build` for the crate graph), and re-deciding it here would be a second - # authority for one fact. A subject with no Conventional prefix is a lane - # defect, not something to paper over — `commit-lint` would refuse the commit - # anyway, so the row says so instead of inventing a type. - local type - type=$(grep -oE '^[a-z]+(\([a-z0-9._-]+\))?!?:' <<<"$title" | sed -E 's/[(!:].*//' || true) - [[ -n "$type" ]] || - refuse "#$num's subject carries no Conventional type, so commit-lint would refuse it and it could never land: fix \`semanticCommitType\` in renovate.json5 rather than filing a row for a commit that cannot merge" - - # The bullet list is built before the heredoc rather than inside it: a - # `shellcheck` directive cannot reach into a here-document, and the sed script - # below is literal markdown backticks rather than a subshell. - local owned_bullets - # shellcheck disable=SC2016 # the backticks are markdown, not command substitution - owned_bullets=$(sed 's/^/- `/; s/$/`/' <<<"$owned") - - body=$( - cat <<-BODY - **Why** - - A bot proposed this change and no human refined it, which is exactly the - case CLOUD-693 exists for: - the row is derived from the pull request's own manifest diff so the merge - moves the board like any other landing. Nothing here was authored by an - agent, and nothing here is a judgement. - - Pull request: #$num (\`$(jq -r '.head // ""' <<<"$pr")\`, opened by \`$login\`). - - Manifests touched: - - $owned_bullets - - **Refinement — Ready** - - *Refinement gate: Definition of Ready & Done. This body carries only specializations.* - - * **Source of truth (§1).** The manifest diff on #$num. It is the one - description of this change that cannot disagree with the change, which - is why nothing here re-types the versions it carries. - * **Computable predicate (§2).** Every required check green on the head - SHA, decided by \`mise run checks-green\` — the same predicate that - gates every other landing, asked of the SHA that fast-forwards. - * **Effect (§3).** No command-surface change: a dependency or toolchain - bump moves no verb, no flag and no effect row. - * **Output & exit (§5).** Unchanged — this row proposes no new output. - * **Commit / bump (§6).** \`$type\` → no bump. - * **Test obligation (§7).** The existing suite, unchanged and unskipped: - a bump whose breakage this repo covers reds CI, and one it does not is - a coverage gap to file rather than a reason to hold the bump. - * **Blockers (§8).** None. - - **Acceptance** - - * #$num lands on \`main\` by fast-forward with every required check green, - through \`auto-bot-land.yml\` and with no human in the loop. - * This row moves to In Review by the merge, from the \`Closes\` key in the - pull request body. - BODY - ) - - jq -n --arg t "$title" --arg d "$body" --arg n "$num" \ - '{id: "CLOUD-NEW", status: "Todo", title: $t, description: $d, pr: $n, relations: {blocks: [], blockedBy: [], relatedTo: []}}' -} - -# --- file --------------------------------------------------------------------- -# -# THE ROW IS FILED AS A GITHUB ISSUE, AND LINEAR MIRRORS IT (CLOUD-750). The -# first shape of this task called the tracker's GraphQL API directly, which cost -# a credential — and the one this repository has answers 401 in both auth forms. -# It was also the only place in the tree holding a tracker credential at all. -# -# Linear's GitHub Issues sync removes the need for one: the integration is -# configured for this repository (`button-inc/batten` -> Button Cloud), so an -# issue opened with the `GITHUB_TOKEN` this workflow already carries is mirrored -# into a `CLOUD-*` row. Measured end to end on #558 -> CLOUD-764, 2026-08-20: -# -# created -> mirrored ~2 seconds, team Button Cloud, body verbatim -# the key comes back a `linear-code[bot]` comment carrying -# `` and the row's URL -# the row arrives in Backlog, with NO project and NO milestone -# closing the GitHub issue moves the row to Done in ~1 second -# -# THE LAST ROW IS WHY THIS TASK NEVER CLOSES THE MIRROR. Done here means -# RELEASED (mem:workflow/board-states), so closing the issue would skip In Review -# and assert a release that has not happened. The pull request therefore closes -# the CLOUD KEY — never `#` — and the merge moves the row exactly as it -# does for an agent's PR. The mirror issue outliving its row is the accepted -# cost of not holding a credential. -# -# AND THE ROW ARRIVES UNREFINED-BY-FIELD, which is recorded rather than hidden: -# the sync sets no project and no milestone, and CLOUD-693's acceptance asked for -# both. Setting them is precisely what a credential would buy. The Ready block is -# in the body, so the row is refined in the sense that matters — `ready-lint` -# reads it — and the fields it cannot set are named on that issue. -file_issue() { - local payload="$1" num title body tmp created - num=$(jq -r '.pr' <<<"$payload") - title=$(jq -r '.title' <<<"$payload") - body=$(jq -r '.description' <<<"$payload") - tmp=$(mktemp) - # The marker goes LAST, after the derived block, so it is the one line a - # reader never has to look at and the one line `ensure` always finds. - { - printf '%s\n\n' "$body" - printf '\n' "$MIRROR_MARKER_PREFIX" "$num" - } >"$tmp" - created=$(gh api -X POST "repos/$REPO/issues" \ - -f title="$title" -F body=@"$tmp" --jq '.number' 2>&1) || { - rm -f "$tmp" - # Pointer-only: never the response, which can echo a header back. - die "could not open the mirror issue for #$num — no row exists, and none is invented" - } - rm -f "$tmp" - [[ -n "$created" ]] || die "the mirror issue for #$num was accepted but named no number" - printf '%s' "$created" -} - -# The mirror this PR already has, if any. Listed rather than searched: the search -# API's indexing lag is measured in tens of seconds, and a tick that ran inside -# that window would file a second row for the same pull request. -mirror_for() { - local num="$1" - gh_api "repos/$REPO/issues?state=all&per_page=100" \ - --jq "[.[] | select((.pull_request // null) == null) | select((.body // \"\") | contains(\"$MIRROR_MARKER_PREFIX$num -->\"))] | .[0].number // empty" -} - -# The `CLOUD-` the sync reported back, or empty while it has not run yet. Read -# from the linkback comment alone: the issue BODY is this task's own text, and a -# key named there would be one we wrote rather than one the tracker assigned. -mirror_key() { - local issue="$1" - gh_api "repos/$REPO/issues/$issue/comments?per_page=100" \ - --jq "[.[] | select((.body // \"\") | contains(\"$LINKBACK_MARKER\"))] | .[0].body // empty" | - grep -oE 'CLOUD-[0-9]+' | head -n1 || true -} - -# --- link --------------------------------------------------------------------- -# -# `Closes ` in the body is the entire board mechanism: the merge moves the row -# because the integration reads it there, and `closing-key-check` refuses a body -# that names a key any other way. Appended rather than templated in, because the -# bot rewrites its own body on every rebase and an append survives being -# reconstructed around. -link_issue() { - local num="$1" key="$2" body - body=$(gh_api "repos/$REPO/pulls/$num" --jq '.body // ""') - if grep -qF "Closes $key" <<<"$body"; then - echo "bot-issue: #$num already closes $key" - return 0 - fi - local tmp - tmp=$(mktemp) - { - printf '%s\n\n---\n\nCloses %s\n' "$body" "$key" - } >"$tmp" - gh api -X PATCH "repos/$REPO/pulls/$num" -F body=@"$tmp" >/dev/null 2>&1 || - die "could not write the closing key into #$num's body — the row exists but the merge would not move it" - rm -f "$tmp" - echo "bot-issue: #$num now closes $key" -} - -# --- closes ------------------------------------------------------------------- -# -# CLOUD-768. `link` writes the closing key. NOTHING KEEPS IT THERE: Renovate -# regenerates its own PR body on every rebase, and the append `link` makes goes -# with it. Measured on #503, 2026-08-20 — written at 05:49 on head `10ad9f8f`, -# absent at 05:50:14 on head `2f65308e`, one force-push later. -# -# The lane is nearly right by ordering alone: `ensure` is the job's FIRST step, -# so the key is normally rewritten seconds before the ref moves. "Normally" is -# not a gate, and the failure inside that window is SILENT — the fast-forward -# succeeds, `main` advances, the bump ships, and the row sits in Backlog with -# nobody looking at it. So the landing asks once more, against GitHub rather than -# against anything this job read a step earlier, at the last moment it still can. -# -# REFUSING IS AN ORDINARY OUTCOME, exactly like the `main`-moved refusal the -# fast-forward already treats as routine: the next tick re-runs `ensure`, the key -# comes back, and it lands then. Nothing is lost but half an hour. -# -# The predicate is `closing-key-check`'s, verbatim, and deliberately not a -# narrower one matching only what `link` writes. A body a human edited to say -# "Fixes CLOUD-767" closes the row just as well, and a gate that refused it would -# be wrong about the one thing it exists to decide. The leading -# `(^|[^0-9A-Za-z-])` is what keeps `DO-NOT-CLOSE CLOUD-388` from reading as a -# close — that marker ends in a closing verb. -# -# Pointer-only per rule 4: the PR number and the key. Never the body — a bot PR -# carries a release-notes dump, and echoing it would put that in the log of every -# landing. -BOT_ISSUE_CLOSING_VERBS='clos(e|es|ed)|fix(|es|ed)|resolv(e|es|ed)' - -closes() { - local num="$1" body key - body=$(gh api "repos/$REPO/pulls/$num" --jq '.body // ""' 2>/dev/null) || - die "GET repos/$REPO/pulls/$num failed — cannot read the body, so nothing may be landed on its word" - key=$(grep -oiE "(^|[^0-9A-Za-z-])($BOT_ISSUE_CLOSING_VERBS)[[:space:]]*:?[[:space:]]*#?CLOUD-[0-9]+" <<<"$body" | - grep -oE 'CLOUD-[0-9]+' | head -n1 || true) - [[ -n "$key" ]] || refuse "#$num's body closes no tracker key, so merging it would move nothing — not landing; the next tick re-links it" - echo "bot-issue: #$num closes $key" -} - -# --- ensure ------------------------------------------------------------------- -# -# TWO PHASES, BECAUSE THE KEY ARRIVES ASYNCHRONOUSLY. Filing the issue and -# learning its `CLOUD-` are separated by however long the sync takes — about -# two seconds when it was measured, but nothing here may depend on that. So a -# tick does as much as it can and says what it did: file the mirror, or link a -# mirror that now has a key. The lander ticks twice an hour and `ensure` is -# idempotent at every step, so the second phase costs nothing to wait for. -# -# THAT IS ALSO WHY THIS DOES NOT POLL. A wall-clock wait inside the job would be -# a guess about someone else's latency dressed as a mechanism, and the landing -# loop's own doctrine refuses those (mem:workflow/landing-loop). A tick that -# cannot finish returns 0 having made progress, and the next one finishes. -ensure() { - local num="$1" pr existing issue key - pr=$(pr_json "$num") - existing=$(grep -oE 'CLOUD-[0-9]+' <<<"$(jq -r '.body // ""' <<<"$pr")" | head -n1 || true) - if [[ -n "$existing" ]]; then - echo "bot-issue: #$num already names $existing; nothing filed" - return 0 - fi - issue=$(mirror_for "$num") - if [[ -z "$issue" ]]; then - # `derive` refuses a PR that is not this lane's before anything is - # written, which is what keeps a refusal from leaving a half-filed row. - local payload - payload=$(derive "$num") || return $? - issue=$(file_issue "$payload") || return $? - echo "bot-issue: #$num -> issue #$issue filed; waiting for the tracker to mirror it" - fi - key=$(mirror_key "$issue") - if [[ -z "$key" ]]; then - echo "bot-issue: issue #$issue is not mirrored yet; the next tick links it" - return 0 - fi - link_issue "$num" "$key" - echo "bot-issue: #$num -> $key (via issue #$issue)" -} - -# --- receipt ------------------------------------------------------------------ -# -# THE SECOND RECEIPT KIND, AND IT IS SECOND BECAUSE THE TWO ATTEST DIFFERENT -# THINGS (CLOUD-693, CLOUD-431). `claim-check` mints `claim.`, whose whole -# content is "a human or agent read this issue, checked it for a competitor, and -# confirmed the refinement predates this session". Nothing on a bot branch can -# honestly say that: there was no session, and the row was derived rather than -# refined. Widening the agent receipt to cover bots would make it mean less -# everywhere, which is exactly the trust path CLOUD-431 exists to prevent. -# -# So this mints `bot.` instead, and what IT attests is decidable from -# public facts rather than from a judgement: the head was opened by an allowlisted -# bot, its diff touches only manifests this lane owns, and its body names the row -# derived from that diff. `verify` accepts either receipt and the two never blur. -# -# 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. -mint_receipt() { - local branch git_dir num pr login body key - branch=$(git symbolic-ref --quiet --short HEAD 2>/dev/null) || - refuse "detached HEAD carries no branch to key a receipt to — check the bot branch out by name" - case "$branch" in - renovate/*) ;; - *) refuse "$branch is not a bot branch, so the agent claim receipt is the one that applies here: run \`mise run claim-check\` with the issue's payload on stdin" ;; - esac - num=$(gh_api "repos/$REPO/pulls?state=open&per_page=100" --jq "[.[] | select(.head.ref == \"$branch\")] | .[0].number // empty") - [[ -n "$num" ]] || refuse "no open pull request for $branch — the receipt attests to facts about a PR, so there is nothing to attest" - pr=$(pr_json "$num") - login=$(jq -r '.login // ""' <<<"$pr") - grep -Eq "$BOT_LOGINS_RE" <<<"$login" || - refuse "#$num was opened by '$login', not by a bot this lane knows" - derive "$num" >/dev/null || return $? - body=$(jq -r '.body // ""' <<<"$pr") - key=$(grep -oE 'CLOUD-[0-9]+' <<<"$body" | head -n1 || true) - [[ -n "$key" ]] || - refuse "#$num's body names no tracker row yet — run \`mise run bot-issue ensure $num\` first, or wait for the lander's next tick" - - git_dir=$(git rev-parse --git-dir 2>/dev/null) || die "not a git checkout, so there is nowhere to write the receipt" - mkdir -p "$git_dir/batten-receipts" 2>/dev/null || die "cannot write under $git_dir/batten-receipts" - # Same spelling as `claim.`, for the same reason: a slash is the one - # character a filename cannot carry. - { - echo "$key" - echo "bot $login" - echo "pr $num" - echo "derived-at $(date -u +%Y-%m-%dT%H:%M:%SZ)" - echo "base $(git rev-parse --verify --quiet origin/main || echo -)" - } >"$git_dir/batten-receipts/bot.${branch//\//-}" - echo "bot-issue: $branch attested — opened by $login, manifests owned, row $key. \`verify\` accepts this in place of a claim receipt." -} - -need gh -need jq - -verb="${1:-}" -case "$verb" in -derive) - [[ -n "${2:-}" ]] || die "usage: bot-issue derive " - derive "$2" - ;; -file) - [[ -n "${2:-}" ]] || die "usage: bot-issue file " - file_issue "$(derive "$2")" - echo - ;; -link) - [[ -n "${2:-}" ]] && [[ -n "${3:-}" ]] || die "usage: bot-issue link " - link_issue "$2" "$3" - ;; -ensure) - [[ -n "${2:-}" ]] || die "usage: bot-issue ensure " - ensure "$2" - ;; -closes) - [[ -n "${2:-}" ]] || die "usage: bot-issue closes " - closes "$2" - ;; -receipt) - mint_receipt - ;; -*) - die "usage: bot-issue derive|file|link|ensure|closes | receipt" - ;; -esac diff --git a/mise.toml b/mise.toml index 6c148a568..1d42cb1b2 100644 --- a/mise.toml +++ b/mise.toml @@ -475,7 +475,7 @@ CI_FANIN_WORKFLOW = ".github/workflows/ci.yml" # which is a property of the world and belongs on a clock (`lock-complete`). REGORUS_OPA_COMPLIANCE = "1.2.0" REGORUS_OPA_COMPLIANCE_FOR = "0.11" -MUTANT_GATES = "alive,attestation-check,awk-regex-check,bats-invocation,batten-glob-check,board-diff-overlap,board-payloads,board-sweep,bot-issue,branch-age-check,cap-drift,ci-hygiene,ci-lease-precondition,ci-parity,ci-slow-needed,ci-suite-lane,ci-tools-check,claim-before-code,claim-race-check,claimed-keys,closing-key-check,coderabbit-config-check,commit-hygiene,connector-allow-guard,connector-allow-resolve,container-preflight,darwin-link,deferral-check,denials-outlive-the-turn,digest-major-agreement,doctor,done-check,done-pr-check,duplicate-close-check,evaluator-closure-check,evaluator-io-check,filed-here,finding-sink-check,forge-verdict-required,graph-check,harness-grant,harness-wiring,hk-fix-selection,hook-matcher-check,hook-pin-check,in-progress-drain,install-check,land,land-divergence-assert,land-lock,land-lock-check,landed-check,landing-loop,license-table-check,linear-check,lock-complete,macos-link-check,mcp-allow-check,mcp-attach-check,mcp-timeout-budget,merged-pr-keys,mise-action-floor,mise-pin-agreement,module-map-check,msrv-pin-agreement,no-doctests,nonverdict-assert,ntia-check,perf-assert,perf-compare,perf-gate,pinned-toolchain,pipefail-grep-check,pr-unsubscribed,privileged-lane,prose-only,publish-credential-check,ready-cites-check,ready-guard,ready-lint,reclaim-census,release-assets-check,release-due,release-tag-shape,release-tracking-check,released,remedy-authorship,report-only-check,review-answered,run-shape,rust-paths-check,sbom,sbom-check,serena-mcp,shell-hygiene,shell-retirement,shell-write-advisory,signing-posture,sonar-gate,spec-ref-check,stop-posture,stop-posture-check,suite-bench-check,suite-subject-retirable,task-substitution,timeout-check,token-bench-check,transcript-corpus-check,tree-clean,trunk-based,validator-verdict-clean,verdict-routes-resolve,verified,weakens-declared" +MUTANT_GATES = "alive,attestation-check,awk-regex-check,bats-invocation,batten-glob-check,board-diff-overlap,board-payloads,board-sweep,branch-age-check,cap-drift,ci-hygiene,ci-lease-precondition,ci-parity,ci-slow-needed,ci-suite-lane,ci-tools-check,claim-before-code,claim-race-check,claimed-keys,closing-key-check,coderabbit-config-check,commit-hygiene,connector-allow-guard,connector-allow-resolve,container-preflight,darwin-link,deferral-check,denials-outlive-the-turn,digest-major-agreement,doctor,done-check,done-pr-check,duplicate-close-check,evaluator-closure-check,evaluator-io-check,filed-here,finding-sink-check,forge-verdict-required,graph-check,harness-grant,harness-wiring,hk-fix-selection,hook-matcher-check,hook-pin-check,in-progress-drain,install-check,land,land-divergence-assert,land-lock,land-lock-check,landed-check,landing-loop,leased-push,license-table-check,linear-check,lock-complete,macos-link-check,mcp-allow-check,mcp-attach-check,mcp-timeout-budget,merged-pr-keys,mise-action-floor,mise-pin-agreement,module-map-check,msrv-pin-agreement,no-doctests,nonverdict-assert,ntia-check,obligations-bound,perf-assert,perf-compare,perf-gate,pinned-toolchain,pipefail-grep-check,plan-complete,pr-unsubscribed,privileged-lane,prose-only,publish-credential-check,ready-cites-check,ready-guard,ready-lint,reclaim-census,release-assets-check,release-due,release-tag-shape,release-tracking-check,released,remedy-authorship,report-only-check,review-answered,run-shape,rust-paths-check,sbom,sbom-check,serena-mcp,shell-hygiene,shell-retirement,shell-write-advisory,signing-posture,sonar-gate,spec-ref-check,stop-posture,stop-posture-check,suite-bench-check,suite-subject-retirable,task-substitution,timeout-check,token-bench-check,transcript-corpus-check,tree-clean,trunk-based,validator-verdict-clean,verdict-routes-resolve,verified,weakens-declared" # --- GitHub reachability behind an egress proxy (Claude Code web sandbox etc.) --- # mise resolves every tool's release through GitHub's *API* host, api.github.com. @@ -1211,7 +1211,7 @@ description = "Gate: the issue you are about to pull is actually unclaimed (read # CLOUD-1059 and CLOUD-1121. # # THE TASK NAME SURVIVES THE MIGRATION, for `semver`'s and `perf-pair`'s reason: -# `mise.toml`'s own `verify` body and `mise-tasks/bot-issue.sh` name it, and the +# `mise.toml`'s own `verify` body names it, and the # refusal text every agent reads says `mise run claim-check`. Keeping the name is # what makes this retirement reach exactly one program. # @@ -1983,7 +1983,30 @@ description = "Session start: record this boot and read back what was running wh # speaks — the report's exit 1 (idle when replaced) and exit 2 (cannot look) are # silent, because a line every session start would be noise in the overwhelming # case where nothing happened. -run = "mise run reclaim-census record-boot >/dev/null 2>&1 || true; mise run reclaim-census report 2>/dev/null || true" +# +# AND IT SPEAKS ONCE PER BOOT, NOT ONCE PER SESSION (CLOUD-1301). `report` +# classifies the PREVIOUS boot, which is immutable history: no landing completed +# here changes what the last container was doing when it died. So on a container +# whose predecessor was reclaimed mid-landing the verdict is TRUE and repeats at +# every session start for the life of the container — and after the first read it +# is exactly the noise CLOUD-891 removed. The comment above already makes that +# argument for the negative readings and did not apply it to the positive one. +# +# THE MARK SITS BESIDE THE LOG, NEVER IN IT, and is keyed to the boot time. Both +# paths come from the census's own `log-path` and `boot` accessors rather than +# being rebuilt here, so this body holds no second opinion about where the store +# lives — `mise-tasks/reclaim-census.sh` is governed by `shell-retirement` and is +# not this row's to edit, which is also why the whole fix is expressible here. +# Keying on the boot is what makes it self-clearing: a new container has a new +# boot time, so its own first reclaim verdict is reported rather than suppressed +# by a mark the previous container left. +# +# EVERY FAILURE PATH STILL REPORTS. If the mark cannot be resolved or written — +# no git dir, no readable boot time — the verdict is printed, because a sensor +# that goes quiet when its bookkeeping breaks has deleted the instrument +# CLOUD-451 built. Suppression is the thing that must fail closed, never the +# report. +run = "mise run reclaim-census record-boot >/dev/null 2>&1 || true; v=$(mise run reclaim-census report 2>/dev/null) || exit 0; [ -n \"$v\" ] || exit 0; l=$(mise run reclaim-census log-path 2>/dev/null) || { printf '%s\\n' \"$v\"; exit 0; }; b=$(mise run reclaim-census boot 2>/dev/null) || { printf '%s\\n' \"$v\"; exit 0; }; m=\"$l.reported\"; [ \"$(cat \"$m\" 2>/dev/null)\" = \"$b\" ] && exit 0; printf '%s\\n' \"$v\"; printf '%s\\n' \"$b\" >\"$m\" 2>/dev/null || true" # CLOUD-843. The successor to `mise-tasks/lock-complete.sh`, and the same wrapper # shape `rules-drift` above takes: the step name and the rule id are one object, @@ -2777,13 +2800,16 @@ run = ''' # a rebase detaches, and refusing there would fail every lap of `land` for a # state that is not a defect. # -# EITHER RECEIPT SATISFIES THIS, AND THEY ARE TWO BECAUSE THEY ATTEST TWO THINGS +# ANY OF THE THREE SATISFIES THIS, AND THEY ARE THREE BECAUSE THEY ATTEST THREE THINGS # (CLOUD-693). `claim.` says a human or agent read a refined issue and # checked it for a competitor. Nothing on a BOT branch can say that honestly — # there was no session and the row was derived rather than refined — so -# `mise run bot-issue receipt` mints `bot.`, which attests what is +# `batten claim bot` mints `bot.`, which attests what is # actually true there: an allowlisted bot opened the head, its diff touches only # manifests the lane owns, and its body names the row derived from that diff. +# A THIRD kind joins them for the same reason (CLOUD-1295): a licence-carry +# branch has no session and no bot, and `batten claim carry` attests that its +# diff only carries rows the base table already maps. # Widening the agent receipt to cover bots would have made it mean less # everywhere, which is the trust path CLOUD-431 exists to prevent. # RECLAIM BEFORE ANYTHING IS SPENT (CLOUD-766). `target/deps` grows ~1.5-2 GB per @@ -2860,17 +2886,37 @@ if [ -n "$claim_branch" ]; then if [ "$claim_rc" != 0 ]; then # A bot branch attests something different and is keyed separately # (CLOUD-693), but it is the same predicate over the same receipt shape — - # `bot-issue receipt` records a `base` line too, so it gets CLOUD-516's + # `batten claim bot` records a `base` line too, so it gets CLOUD-516's # staleness rule here for free rather than needing its own. + # + # The remedy names the live verb and only the live verb. It briefly did not: + # `tests/verify.bats` pins this message's prose, a `.bats` under `tests/` is + # governed by `shell-retirement`, and the repointing arm could only admit a + # span that named its callee by PATH — so retiring `bot-issue` left the dead + # task name alive here with nowhere to put the fix. CLOUD-1299's arm 5 admits + # a task-name span, so the suite and this line were repointed together and + # the workaround is gone rather than documented. bot_line="$(cargo run --quiet -p batten -- receipt status bot --key branch 2>/dev/null)" bot_rc=$? if [ "$bot_rc" != 0 ]; then - # The verdict is printed rather than swallowed: `missing` and `stale-main` - # carry different remedies, and the pointer line is what tells them apart. - echo " $claim_line" >&2 - echo " $bot_line" >&2 - echo "::error:: verify: this branch carries no VALID claim receipt, so nothing attests that the work on it was pulled from a refined issue. \`missing\` means mint one: run \`mise run claim-check\` with the issue's get_issue payload on stdin, or on a bot branch \`mise run bot-issue receipt\`. \`stale-main\` means a receipt EXISTS but the branch was restarted out from under it (CLOUD-516), so it must be re-claimed rather than trusted. No receipt written." >&2 - exit 1 + # THE THIRD KIND, AND IT IS THIRD FOR THE SAME REASON THE SECOND IS + # (CLOUD-1295). A licence-carry branch has no session and no bot either: + # `sbom-actions-currency` opens it, and neither receipt above fits. So + # `batten claim carry` attests what IS true there — the diff only carries + # rows whose repo the base table already maps, changing the sha alone, and + # touches nothing else. Same receipt shape, so CLOUD-516's staleness rule + # applies here for free rather than needing its own. + carry_line="$(cargo run --quiet -p batten -- receipt status carry --key branch 2>/dev/null)" + carry_rc=$? + if [ "$carry_rc" != 0 ]; then + # The verdict is printed rather than swallowed: `missing` and `stale-main` + # carry different remedies, and the pointer line is what tells them apart. + echo " $claim_line" >&2 + echo " $bot_line" >&2 + echo " $carry_line" >&2 + echo "::error:: verify: this branch carries no VALID claim receipt, so nothing attests that the work on it was pulled from a refined issue. \`missing\` means mint one: run \`mise run claim-check\` with the issue's get_issue payload on stdin, on a bot branch \`batten claim bot\`, or on a licence-carry branch \`batten claim carry\`. \`stale-main\` means a receipt EXISTS but the branch was restarted out from under it (CLOUD-516), so it must be re-claimed rather than trusted. No receipt written." >&2 + exit 1 + fi fi fi fi @@ -3190,7 +3236,7 @@ shell = "bash -c" # uses ${sha # # THE SECOND IS THE UPDATE BOT'S, AND IT CLOSES A LOOP THAT COULD NOT TERMINATE # (CLOUD-1207). A Renovate bump carries no `Refs:` trailer and structurally -# cannot: CLOUD-693's `bot-issue ensure` mints the row FROM the pull request, so +# cannot: CLOUD-693's `batten pr ensure` mints the row FROM the pull request, so # the key does not exist when the commit is authored, and the lane deliberately # never checks the bot's head out under a write token to amend one in. The row is # real and the merge still moves it — through `Closes CLOUD-` in the PR BODY, @@ -3206,7 +3252,7 @@ shell = "bash -c" # uses ${sha # so exempting it by paths would drop the server-side claim (CLOUD-431) for a # whole class of real changes. The author test is what narrows it: an address of # the form `…[bot]@users.noreply.github.com` is minted by GitHub for an App, and -# is not a login allowlist — `mise-tasks/bot-issue.sh`'s `BOT_LOGINS_RE` answers a +# is not a login allowlist — `[bot_lane]`'s `bots` list answers a # different question over a different object (which bot's pull request this lane # will adopt), so this is not a second spelling of it. A contributor forging that # address is refused one gate over, by `commit-attribution`'s identity table. diff --git a/policy/filed-here.rego b/policy/filed-here.rego index 1325f7e1a..fd34f6141 100644 --- a/policy/filed-here.rego +++ b/policy/filed-here.rego @@ -10,10 +10,20 @@ # complete Ready block is what flips that, without anything judging whether a # given spin-off was lazy. # -# TWO REFUSALS, AND NEITHER SUBSUMES THE OTHER. `filed-unrefined` prices -# REFINEMENT; `filed-over-own-diff` prices PROXIMITY. A row can earn both — -# "never groomed to Ready" and "names code this branch is holding open" are -# different facts — so they are separate predicates over one parse. +# THREE REFUSALS, AND NO ONE OF THEM SUBSUMES ANOTHER. `filed-unrefined` prices +# REFINEMENT; `filed-over-own-diff` prices PROXIMITY; `filed-and-left-open` prices +# a row this branch opened and simply LEFT OPEN. A row can earn the first +# alongside either of the others — "never groomed to Ready" and "names code this +# branch is holding open" are different facts — so they are separate predicates +# over one parse. +# +# THE LAST TWO ARE PARTITIONED RATHER THAN NESTED, and that is a correction rather +# than a taste. `filed-over-own-diff` requires `not cites_only(id)` and +# `filed-and-left-open` requires `cites_only(id)`, so no row can earn both and a +# reviewer never sees two findings for one cause. Drafted without that +# requirement the third arm was strictly WIDER than the second — every row the +# second refused, the third refused too — and the sentence above stopped being +# true of the module it heads. # # The second exists because the first turned out to be payable in typing. A Ready # block is prose, and prose is the one currency an agent has without limit: @@ -50,6 +60,8 @@ #MUTANT-SUITE crates/batten/tests/it/filed_here.rs #MUTANT unrefined-row-unread|s@^\tlatest\[id\].verdict == "unready"$@\tfalse@|an_unready_create_stops_the_lap #MUTANT closing-row-still-priced|s@^\tnot id in closes$@\ttrue@|a_row_the_pr_closes_is_exempt +#MUTANT left-open-arm-unpartitioned|s@^\tcites_only(id)$@\ttrue@|a_row_recorded_after_the_base_whose_sec1_names_the_diff_still_refuses +#MUTANT left-open-judges-an-unread-body|s@^\tbody_read$@\ttrue@|an_unread_pr_body_leaves_the_set_unjudged # METADATA # description: | @@ -66,6 +78,8 @@ rules contains "filed-unrefined" rules contains "filed-over-own-diff" +rules contains "filed-and-left-open" + # The record, or nothing. ABSENT IS NOT EMPTY: a branch whose recorder never ran # has no key here at all, Rego reads that as *does not hold*, and every rule below # is silent. An empty list would be a measured nothing and would say the branch @@ -150,6 +164,26 @@ closes contains key if { some key in split(substring(columns[1], indexof(columns[1], ":") + 1, -1), ",") } +# THE BODY WAS READ, so "this PR closes nothing" is a MEASUREMENT rather than an +# absence — the third state the `pr-closes` recorder writes `zero-is-a-count` for. +# +# `closes` alone cannot carry this. An empty `closes` set has three causes that a +# set-membership test flattens into one: the body closes nothing, no PR exists +# yet, and the key reader could not run. The first is a reading and the other two +# are could-not-look, and `filed-and-left-open` refuses over the WHOLE SET rather +# than over a row's properties, so flattening them would refuse every row a branch +# ever filed the first time `verify` runs before the PR is opened — for a remedy +# ("name it in closing form in the PR body") that has nowhere to be written yet. +# +# `-` is the recorder saying it could not read the keys and leaves the set +# unjudged; `0` is a measured nothing and judges it. +body_read if { + some raw in input.tree.records["pr-closes"] + columns := split(raw, " ") + columns[0] == "closes" + columns[1] != "-" +} + # `filed-unrefined`: a row this branch created was never groomed to Ready. # # `ready` passes and so does `-`; only the tracker's own `unready` refuses. @@ -238,6 +272,43 @@ violation contains { not cites_only(id) } +# `filed-and-left-open`: a row this branch filed, that this branch does not close. +# +# THE ARM THE MEASUREMENT ASKED FOR (CLOUD-1311). Three of one session's four +# deferrals were invisible to `filed-over-own-diff` precisely BECAUSE their §1 +# named paths outside the diff — `cites_only` exempts those by design, and rightly +# so for a refusal about proximity. Nothing then priced them at all, and the +# detector was a human asking twice. +# +# IT CLASSIFIES NOTHING, which is what keeps non-negotiable rule 3 satisfied. It +# reports a SET: the rows this branch put on the board that it is not landing. The +# author closes one, lets the body close it, or spends an admission whose +# articulation says why it is independent work — and that articulation is +# hash-bound into the commit message, where a reviewer reads it, rather than into +# a turn that ends. +# +# THREE COULD-NOT-LOOKS GUARD IT, and each is a different question. +# * `body_read` — the forge's answer has not been captured, so the closing +# remedy is unreachable and the set is unjudged rather than refused. +# * a non-empty `changed` — a branch holding nothing open has fixed nothing and +# deferred nothing, so "you filed instead of fixing" is a claim about a diff +# that does not exist. It is not a dodge: an empty branch cannot land either. +# * `cites_only`'s own `-` — a record from an older recorder has no §1 column, +# so the partition cannot be evaluated and the row stays judged as it was +# before this arm existed. +violation contains { + "rule": "filed-and-left-open", + "verdict": "issue file held", + "subjects": [{"artifact": id}], +} if { + some id, _ in latest + body_read + count(changed) > 0 + not id in closes + not predates_the_branch(id) + cites_only(id) +} + # The predicate's own tests. The SILENT cases are the load-bearing half: every # skip above is a pass-side property, and a rule that fired on every row would # satisfy the denies while deciding nothing. @@ -320,11 +391,89 @@ test_a_row_written_before_the_branch_cannot_be_its_punt if { ) } -# CITING IS NOT CLAIMING. The row names the path in its body but its declared -# source of truth is somewhere else entirely. +# CITING IS NOT CLAIMING — for the PROXIMITY refusal, which is the only one it +# was ever about. The row names the path in its body but its declared source of +# truth is somewhere else entirely, so `filed-over-own-diff` is silent. +# +# IT IS NOT SILENT ALTOGETHER, AND THAT IS THE PARTITION. This is the exact shape +# of the three punts nothing caught: a row filed while the branch was open, whose +# §1 points somewhere else, so proximity exempts it and — before this arm — no +# refusal reached it. The case asserts the SET of verdicts rather than a count, so +# a later change collapsing the two arms back together reddens here. test_a_row_that_only_cites_the_path_is_not_claiming_it if { + verdicts := {v.verdict | some v in violation} with input as with_diff( + ["issue CLOUD-1 2026-02-01T00:00:00Z ready 1,src/a.rs - 1,src/b.rs"], + ["closes 0"], + ["src/a.rs"], + "2026-01-01T00:00:00Z", + ) + verdicts == {"issue file held"} +} + +# NO PR BODY YET IS COULD-NOT-LOOK, not a measured nothing. Without this the arm +# refuses every row a branch filed the first time `verify` runs before the PR is +# opened, naming a remedy that has nowhere to be written. +test_an_unread_body_leaves_the_set_unjudged if { count(violation) == 0 with input as with_diff( ["issue CLOUD-1 2026-02-01T00:00:00Z ready 1,src/a.rs - 1,src/b.rs"], + [], + ["src/a.rs"], + "2026-01-01T00:00:00Z", + ) +} + +# AND A FETCH WHOSE KEY READER COULD NOT RUN IS THE SAME ANSWER, which is what +# `zero-is-a-count` exists to keep distinct from `closes 0`. +test_an_unreadable_closing_key_column_leaves_the_set_unjudged if { + count(violation) == 0 with input as with_diff( + ["issue CLOUD-1 2026-02-01T00:00:00Z ready 1,src/a.rs - 1,src/b.rs"], + ["closes -"], + ["src/a.rs"], + "2026-01-01T00:00:00Z", + ) +} + +# A BRANCH HOLDING NOTHING OPEN DEFERRED NOTHING. `changed` is empty, so there is +# no diff for the row to have been filed instead of. +test_a_branch_with_no_diff_judges_no_row if { + count(violation) == 0 with input as with_diff( + ["issue CLOUD-1 2026-02-01T00:00:00Z ready 1,src/a.rs - 1,src/b.rs"], + ["closes 0"], + [], + "2026-01-01T00:00:00Z", + ) +} + +# THE CLOSING REMEDY REACHES THIS ARM TOO, and a body closing a DIFFERENT row does +# not — the anti-vacuity half, without which one closing key buys the whole set. +test_a_left_open_row_the_body_closes_is_exempt if { + count(violation) == 0 with input as with_diff( + ["issue CLOUD-1 2026-02-01T00:00:00Z ready 1,src/a.rs - 1,src/b.rs"], + ["closes 1:CLOUD-1"], + ["src/a.rs"], + "2026-01-01T00:00:00Z", + ) +} + +test_closing_one_row_does_not_close_the_set if { + verdicts := {v.verdict | some v in violation} with input as with_diff( + [ + "issue CLOUD-1 2026-02-01T00:00:00Z ready 1,src/a.rs - 1,src/b.rs", + "issue CLOUD-2 2026-02-01T00:00:00Z ready 1,src/a.rs - 1,src/b.rs", + ], + ["closes 1:CLOUD-1"], + ["src/a.rs"], + "2026-01-01T00:00:00Z", + ) + verdicts == {"issue file held"} +} + +# A ROW WRITTEN BEFORE THE BRANCH IS EXEMPT FROM THIS ARM ON THE SAME GROUND it is +# exempt from the proximity one: it cannot be a deferral of a diff that did not +# exist. +test_a_left_open_row_predating_the_branch_is_exempt if { + count(violation) == 0 with input as with_diff( + ["issue CLOUD-1 2025-12-01T00:00:00Z ready 1,src/a.rs - 1,src/b.rs"], ["closes 0"], ["src/a.rs"], "2026-01-01T00:00:00Z", @@ -350,14 +499,17 @@ test_an_unanswered_overlap_passes if { ) } -# A NAMED PATH THIS BRANCH IS NOT TOUCHING IS NOT A PUNT AGAINST ITS DIFF. -test_a_row_naming_a_path_outside_the_diff_passes if { - count(violation) == 0 with input as with_diff( +# A NAMED PATH THIS BRANCH IS NOT TOUCHING IS NOT A PUNT AGAINST ITS DIFF — and +# that is still true of the proximity refusal, which stays silent here. It is a +# row left open, so the third arm takes it. +test_a_row_naming_a_path_outside_the_diff_is_not_a_proximity_refusal if { + verdicts := {v.verdict | some v in violation} with input as with_diff( ["issue CLOUD-1 2026-02-01T00:00:00Z ready 1,src/z.rs - 1,src/z.rs"], ["closes 0"], ["src/a.rs"], "2026-01-01T00:00:00Z", ) + verdicts == {"issue file held"} } # COULD NOT READ THE BASE DATE LEAVES EVERY ROW JUDGED AS BEFORE, rather than diff --git a/policy/leased-push.rego b/policy/leased-push.rego new file mode 100644 index 000000000..e223e6b26 --- /dev/null +++ b/policy/leased-push.rego @@ -0,0 +1,142 @@ +#MUTANT-SUITE crates/batten/tests/it/forced_push.rs +#MUTANT bare-lease-unread|s@^\tword == "--force-with-lease"$@\tfalse@|a_bare_leased_push_is_refused +# A bare `--force-with-lease` trusts whatever the last fetch happened to see. +# +# MEASURED 2026-09-02, and the incident is the whole reason this exists: two +# sessions in different containers held one branch. One pushed; the other had +# already built a commit and its push was rejected non-fast-forward. Nothing in +# this engine fired at any point — git's own check was the only thing in the +# stack that noticed, after the work was written, verified and committed. +# +# THE DISTINCTION IS BARE VERSUS EXPLICIT, and `mise-tasks/land-lock.sh` already +# states it about its own CAS: "The expected value is passed EXPLICITLY +# (`:`) and must stay that way. Bare `--force-with-lease` compares +# against this clone's remote-tracking ref — what the last fetch happened to see +# — which for a ref other sessions are actively rewriting is precisely the stale +# value this must not trust. The two forms look interchangeable and are not." +# +# So the failing sequence is the ordinary one: `git fetch` moves the +# remote-tracking ref onto the sibling's commit, the bare lease then compares +# EQUAL, and the push destroys exactly what the fetch brought in. The flag chosen +# for being careful is the one that loses the work. +# +# The explicit `--force-with-lease=:` form is NOT refused, and that is +# a predicate rather than a concession: naming the sha is the assertion. You +# cannot name a value you never observed, and if the remote has moved past it git +# refuses the push itself — policy has nothing to add. +# +# WHAT THIS DOES NOT CLOSE. It cannot tell you a sibling holds the branch, only +# that you are about to overwrite whatever is there without having looked. +# Detection needs a per-branch ownership ref taken by the same server-side CAS +# `land-lock` already uses, which needs receive-pack over the vendored client +# (CLOUD-1274). Destruction closes here; detection is filed. +# +# `--force` and `-f` are the `trunk-based` preset's and are not repeated here: one +# concept, one spelling, and a second rule over one object is what the narrowing +# avoids. +# METADATA +# description: | +# Bound to the mediated-call surface: this module is `scope = "mediated_call"`, +# so it reads `{call, facts}` and NOT the tree document. Binding it to the tree +# schema would type check it against a shape the engine never hands it, which is +# CLOUD-845's defect introduced on purpose rather than caught. +# THE BRACKETS ARE NOT STYLE: the schema file carries a hyphen, so the dotted +# form is a parse error reported as `invalid schema reference`. +# schemas: +# - input: schema["policy-call.schema"] +package batten.leased_push + +import rego.v1 + +rules contains "leased-push" + +violation contains { + "rule": "leased-push", + "verdict": "branch write unsafe", + "subjects": [{"count": 1}], +} if { + # PER SEGMENT, NOT PER LINE (CLOUD-857). `input.call.segments` is + # `hook::segments` projected — the engine's own quote-aware tokenizer — so a + # compound command is reached. The preset this extends carries the measured + # instance in its own header: anchored on the whole command line, it denied + # `git push --force origin main` and ALLOWED + # `cd /tmp && git push --force origin main`, with a green suite over it. A + # real agent command is compound most of the time, so that silence was the + # common case rather than an edge. + some segment in input.call.segments + segment.words[0] == "git" + "push" in segment.words + + # EQUALITY, NEVER A PREFIX TEST, and this line is the whole predicate. The + # explicit form is one word — `--force-with-lease=refs/heads/x:abc123` — so an + # equality against the bare spelling admits it by construction. A + # `startswith` here would refuse both and put the guard back where it was. + some word in segment.words + word == "--force-with-lease" +} + +# The predicate's own tests. The second is the one that matters: the distinction +# this module exists to draw is bare against explicit, so a suite that only +# proved the deny fires would not have tested the thing at all — which is the +# defect `trunk-based/no-force-push`'s own header records for its `--force` +# against `--force-with-lease` split, one spelling along. +# +# EVERY CASE PASSES SEGMENTS AND AT LEAST ONE IS COMPOUND (CLOUD-857): a +# bare-command suite is green over exactly the hole that matters, and +# `batten policy test` refuses a mediated-call module whose cases all pass a bare +# command. +test_a_bare_lease_is_refused if { + some _ in violation with input as {"call": {"segments": [{ + "words": ["git", "push", "--force-with-lease", "origin", "main"], + "raw": "git push --force-with-lease origin main", + "terminator": null, + }]}} +} + +test_a_bare_lease_in_a_compound_command_is_refused if { + some _ in violation with input as {"call": {"segments": [ + {"words": ["git", "fetch", "origin"], "raw": "git fetch origin", "terminator": "&&"}, + { + "words": ["git", "push", "--force-with-lease", "origin", "main"], + "raw": "git push --force-with-lease origin main", + "terminator": null, + }, + ]}} +} + +test_the_explicit_expected_value_is_allowed if { + count(violation) == 0 with input as {"call": {"segments": [{ + "words": ["git", "push", "--force-with-lease=refs/heads/main:abc123", "origin", "main"], + "raw": "git push --force-with-lease=refs/heads/main:abc123 origin main", + "terminator": null, + }]}} +} + +test_an_ordinary_push_is_allowed if { + count(violation) == 0 with input as {"call": {"segments": [{ + "words": ["git", "push", "-u", "origin", "work"], + "raw": "git push -u origin work", + "terminator": null, + }]}} +} + +test_another_tool_is_not_judged if { + count(violation) == 0 with input as {"call": {"segments": [{ + "words": ["hg", "push", "--force-with-lease"], + "raw": "hg push --force-with-lease", + "terminator": null, + }]}} +} + +test_a_quoted_mention_is_not_an_invocation if { + count(violation) == 0 with input as {"call": {"segments": [{ + "words": ["echo", "git push --force-with-lease origin main"], + "raw": "echo \"git push --force-with-lease origin main\"", + "terminator": null, + }]}} +} + +deny contains message if { + some v in violation + message := v.verdict +} diff --git a/policy/module-layering.rego b/policy/module-layering.rego index a165c13b6..d59d6dde2 100644 --- a/policy/module-layering.rego +++ b/policy/module-layering.rego @@ -245,6 +245,24 @@ declared_modules := { # reaches `budget` for the estimator every other ceiling here counts with, # and `findings` to mint the two engine-produced findings it raises. "hookcost", + # `carry` and `bot` arrived with CLOUD-1295 and this rule named both — another + # module nobody had placed, caught by the absence-is-an-error clause, and the + # second such catch on a batch. + # + # `carry` is a LEAF in `checks_green`'s class: whether a licence-carry branch's + # diff is derivable is a pure function of two strings and a path list, with no + # clock, no network and no filesystem beyond the receipt it writes. It reaches + # `error` alone, which is what lets every case in its unit tier run offline. + # + # `bot` is TWO HALVES and its placement is the interesting one. Its predicates + # are a leaf like `carry`'s; its `forge` submodule is an ACQUISITION site in + # `pinned`'s class, reaching `rules` for the process ladder every spawning site + # in this crate shares — the same edge `pr_watch` is placed on. They live in one + # module rather than two because the lane's facts and its matcher are read from + # one config table, and splitting them would put that table's reader in a module + # that decides nothing. It reaches no decider: what a filed row MEANS is the + # refinement gate's, and this module never mints a `Finding`. + "carry", "bot", } # THE FORBIDDEN EDGES, each traceable to prose already in the tree. diff --git a/policy/obligations-bound.rego b/policy/obligations-bound.rego new file mode 100644 index 000000000..aae3f4609 --- /dev/null +++ b/policy/obligations-bound.rego @@ -0,0 +1,209 @@ +# A §7 obligation is bound to a case, or it is not landing (CLOUD-472). +# +# THE DEFECT, MEASURED. `ready-lint` gates the SHAPE of a Ready block and +# `verify` gates the CODE, and nothing compared them — so an obligation could be +# written into an issue, pass the refinement gate, and land with no test behind +# it, with every gate in the loop green while it happened. CLOUD-369 is the +# instance: an acceptance bullet describing when a second matrix may be bought +# did not describe the code that buys it, and it merged to `main` by fast-forward, +# CI-confirmed green. +# +# The reconstruction is what makes this structural rather than inattention. The +# acceptance was written first and correctly; a collision forced a mid-implementation +# redesign; the redesign was derived from the problem again rather than from the +# acceptance and silently dropped one condition; and THE TESTS WERE WRITTEN FROM +# THE IMPLEMENTATION, so they assert what the code does. Tests written that way +# can only ever confirm it — they are structurally incapable of catching an +# obligation that was dropped, because a missing behaviour has no code to write a +# test against. The Ready block is the only artifact that still remembers what was +# promised, and nothing read it at implementation time. This does. +# +# WHAT IT CHECKS AND WHAT THE SWEEP CHECKS, because the pair is the obligation +# and neither half alone is. Here: the declared FILE is tracked, and the declared +# SLUG is a `#MUTANT` row in it. `mise run mutant` then applies that row and runs +# its named case, and a SURVIVOR is the finding. So this answers *is there +# something bound to the promise* and the sweep answers *does it discriminate* — +# which is the difference between coverage and a test that cannot fail, the whole +# of CLOUD-418. +# +# IT SCORES NO PROSE (rule 3). It compares a recorded `:` against +# tracked paths and against lines that begin with a fixed marker. Whether the +# obligation was worth making, and whether the case is a good one, are judgements +# no gate here makes. +# +# THE SET IS THE TRACKER'S, NOT THE AUTHOR'S. `board-issue-groomed`'s +# `obligations` column runs the Ready grammar over the description the tracker +# RETURNED, so an author cannot hand this rule a set it assembled — the same +# forgery control the `verdict` column has, earned when `ready-lint` over a +# self-assembled payload was measured green three times against text in a local +# file, once under an id no row carried. +#MUTANT-SUITE crates/batten/tests/it/obligations_bound.rs +#MUTANT unbound-file-unread|s@^\tnot obligation_row.file in input.tree.tracked$@\tfalse@|an_obligation_naming_no_tracked_file_is_refused +#MUTANT undeclared-slug-unread|s@^\tnot declares_slug(obligation_row)$@\tfalse@|an_obligation_whose_slug_no_row_declares_is_refused + +# 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.obligations_bound + +import rego.v1 + +rules contains "obligation-unbound" + +# The board record, or nothing. ABSENT IS NOT EMPTY: a branch whose recorder +# never ran has no key here, Rego reads that as *does not hold*, and this module +# is silent — which is a different claim from a branch that recorded rows +# declaring no obligations. +lines := input.tree.records["board-writes"] + +column(columns, at) := value if { + value := columns[at] +} else := "-" + +# Every `:` this branch's rows declared. +# +# THE `-` IS COULD-NOT-LOOK AND IS SKIPPED, which is the distinction the +# recorder's `stdout-line` read exists to preserve: a prose-dialect block emits +# no line at all, and reading that as "declares no obligations" would exempt +# precisely the rows this gate is for. A row recorded by an older recorder, with +# no column at all, reads the same way and is judged as it was before this +# module existed. +obligation contains entry if { + some raw in lines + columns := split(raw, " ") + columns[0] == "issue" + packed := column(columns, 7) + packed != "-" + some pair in split(substring(packed, indexof(packed, ",") + 1, -1), ",") + at := indexof(pair, ":") + at > 0 + entry := { + "id": columns[1], + "file": substring(pair, 0, at), + "slug": substring(pair, at + 1, -1), + } +} + +# Whether the named file declares the named slug as a `#MUTANT` row. +# +# The marker and the field separator are `mutate`'s own three-field format, and +# matching the PREFIX rather than the whole row is deliberate: the expression and +# the case name are the sweep's business, and a module re-parsing them would be a +# second authority over a format the runner already owns. +declares_slug(entry) if { + some line in input.tree.lines[entry.file] + startswith(line, sprintf("#MUTANT %v|", [entry.slug])) +} + +# An obligation whose file this repository does not track. +# +# ONE FINDING PER OBLIGATION, so a reviewer sees which promise is unbound rather +# than a count they have to reconstruct. The path leads, because that is what a +# reader opens; the row's id follows it, carried rather than as the pointer. +violation contains { + "rule": "obligation-unbound", + "verdict": "test name undefined", + "subjects": [{"path": obligation_row.file}, {"artifact": obligation_row.id}], +} if { + some obligation_row in obligation + not obligation_row.file in input.tree.tracked +} + +# An obligation whose file exists and whose slug nothing in it declares. +# +# SEPARATE FROM THE ARM ABOVE because the two are different remedies: a missing +# file means the case was never written, and a missing slug means it was written +# and never given a mutation that could kill it. Collapsing them would hand the +# author one message for two problems. +violation contains { + "rule": "obligation-unbound", + "verdict": "test name undefined", + "subjects": [{"path": obligation_row.file}, {"artifact": obligation_row.id}], +} if { + some obligation_row in obligation + obligation_row.file in input.tree.tracked + not declares_slug(obligation_row) +} + +# The predicate's own tests. The SILENT cases carry the weight: every +# could-not-look above is a pass-side property, and a rule that fired on every +# recorded row would satisfy the denies while deciding nothing. + +board(record, tracked, lines_by_file) := {"tree": { + "records": {"board-writes": record}, + "tracked": tracked, + "lines": lines_by_file, +}} + +bound := "issue CLOUD-1 2026-01-01T00:00:00Z ready - - - 1,tests/a.rs:slug-one" + +test_a_bound_obligation_is_clean if { + count(violation) == 0 with input as board( + [bound], + ["tests/a.rs"], + {"tests/a.rs": ["#MUTANT slug-one|s@a@b@|the_case"]}, + ) +} + +test_an_obligation_naming_no_tracked_file_is_refused if { + some v in violation with input as board([bound], [], {}) + v.verdict == "test name undefined" +} + +# THE FILE EXISTS AND THE PROMISE IS STILL UNKEPT. A case with no mutation is a +# case nothing has shown can fail, which is CLOUD-418's whole finding. +test_an_obligation_whose_slug_no_row_declares_is_refused if { + some v in violation with input as board( + [bound], + ["tests/a.rs"], + {"tests/a.rs": ["#MUTANT other-slug|s@a@b@|the_case"]}, + ) + v.verdict == "test name undefined" +} + +# COULD NOT LOOK IS NOT A REFUSAL. A prose-dialect block emits no obligations +# line, so the column records `-`, and reading that as "declares none" would +# exempt exactly the rows this gate exists for. +test_a_row_with_no_obligations_column_is_not_judged if { + count(violation) == 0 with input as board( + ["issue CLOUD-1 2026-01-01T00:00:00Z ready - - - -"], + [], + {}, + ) +} + +# A MEASURED ZERO IS AN ANSWER AND PASSES: the object was read and declares no +# obligations, which is a legitimate Ready block. +test_a_row_declaring_no_obligations_passes if { + count(violation) == 0 with input as board( + ["issue CLOUD-1 2026-01-01T00:00:00Z ready - - - 0"], + [], + {}, + ) +} + +test_an_absent_record_is_silent if { + count(violation) == 0 with input as {"tree": {"records": {}, "tracked": [], "lines": {}}} +} + +test_a_comment_line_declares_no_obligations if { + count(violation) == 0 with input as board( + ["comment CLOUD-1 2026-01-01T00:00:00Z - - - 1,tests/a.rs:slug-one"], + [], + {}, + ) +} + +# ONE FINDING PER OBLIGATION, and the pointer leads with the path a reader opens. +test_every_unbound_obligation_is_named if { + paths := {v.subjects[0].path | some v in violation} with input as board( + ["issue CLOUD-1 2026-01-01T00:00:00Z ready - - - 2,tests/a.rs:one,tests/b.rs:two"], + [], + {}, + ) + paths == {"tests/a.rs", "tests/b.rs"} +} diff --git a/policy/plan-complete.rego b/policy/plan-complete.rego new file mode 100644 index 000000000..209c96fe8 --- /dev/null +++ b/policy/plan-complete.rego @@ -0,0 +1,238 @@ +# The plan a branch declared, held to its own end (CLOUD-472). +# +# WHY THIS IS A SEPARATE MODULE FROM `filed-here`. That one's subject is the +# BOARD — rows this branch put on the tracker — and its header rests on three +# refusals none of which subsumes another. A plan entry is not a board row: it is +# the agent's own declared work, in the agent's own words, and folding it in +# would make that invariant unreadable. Same shape, different subject, different +# store. +# +# THE SENSOR EXISTED AND HAD NO RATCHET, which is this repository's recurring +# defect rather than a new one. An agent's task list is displayed every turn and +# is the most legible statement of what it believes is outstanding — and nothing +# in the tree could see it, so a branch could land with half its list `pending` +# and every gate stayed green. Measured 2026-09-01: three items sat `pending` +# while the session reported the work as planned, and the only detector was a +# human asking. +# +# A VERB WRITES THIS STORE, NOT A HOOK, and the direction is the whole design. +# Recording from the harness's own todo tool needs a spelling per host — +# `TaskCreate`/`TaskUpdate`, `write_todos`, `todowrite`, `update_plan` — and +# fails the same way in three different ways: an unsurveyed harness, a tool a +# setting switched off, and a compliant agent all record nothing, so the gate +# reads clean. `OpenCode` makes it concrete by denying `todowrite` to subagents at +# session creation whatever the config says. `batten record plan` inverts it: the +# agent tells the engine, and a missing record REFUSES, identically everywhere. +# +# WHAT IT DOES NOT DO (rule 3): it reads a status token and nothing else. It does +# not judge whether an entry was worth doing, whether its text is honest, or +# whether the work behind `completed` happened. Those are model verdicts and no +# gate here makes one. The author closes the entry, drops it, or spends an +# admission whose articulation says why it is not this branch's to finish — and +# that articulation is hash-bound into the commit message, where a reviewer reads +# it. +# +# POINTER, NEVER PAYLOAD (rule 4): a refusal names the entry's id and its status +# token. The id is the agent's own text, so the finding carries it as an +# `artifact` subject rather than as prose, and the entry's description never +# enters the store at all. +#MUTANT-SUITE crates/batten/tests/it/plan_complete.rs +#MUTANT unfinished-entry-unread|s@^\tnot done(entry_row.status)$@\tfalse@|an_unfinished_entry_stops_the_lap +#MUTANT no-plan-at-all-unpriced|s@^\tcount(changed) > 0$@\tfalse@|a_branch_that_recorded_no_plan_is_refused + +# 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.plan_complete + +import rego.v1 + +rules contains "plan-unfinished" + +rules contains "plan-unrecorded" + +# The store, or nothing. ABSENT IS NOT EMPTY, and the two reach different arms +# below on purpose: an empty file is "I recorded a plan and it holds no entries", +# while no file at all is "this branch never told the engine anything" — which is +# the vacuity `plan-unrecorded` exists to price rather than to pass. +recorded := input.tree.records.plan + +# One entry per line, ` `. A line this reader cannot parse is skipped +# rather than judged, matching every other record reader here: the writer already +# refused a malformed line, so anything unparseable at read time is a torn store +# and not an author's claim. +entry contains row if { + some raw in recorded + columns := split(raw, " ") + count(columns) >= 2 + columns[0] != "" + row := {"id": columns[0], "status": columns[1]} +} + +# The two terminal statuses. `deleted` is terminal because withdrawing an entry +# is a decision the author is entitled to make and the store records that they +# made it; what the gate refuses is an entry left in flight, not one closed. +done(status) if { + status in {"completed", "deleted"} +} + +# The branch's own diff, as the engine resolved it — the same reading +# `filed-here` takes, and `null` when the base does not resolve, so `changed` +# stays empty and every arm below goes quiet rather than fabricating a verdict. +delta := input.tree["base-delta"] + +changed contains path if { + some path in delta.added +} + +changed contains path if { + some path in delta.edited +} + +changed contains path if { + some path in delta.deleted +} + +# `plan-unfinished`: an entry the branch declared and left in flight. +violation contains { + "rule": "plan-unfinished", + "verdict": "plan declare held", + "subjects": [{"artifact": entry_row.id}], +} if { + some entry_row in entry + not done(entry_row.status) +} + +# A BRANCH THAT CLAIMED WORK. `claim check` writes this store on its pullable +# path, so its presence is the branch saying "I pulled a row and I am working +# it" — precisely the population that owes a plan. +claimed if { + some _ in input.tree.records.claim +} + +# `plan-unrecorded`: A CLAIMED BRANCH THAT DECLARED NO PLAN AT ALL. +# +# WITHOUT THIS ARM THE GATE IS WORTHLESS, and that is not hypothetical — a +# refusal over "entries left in flight" is satisfied completely by never +# recording an entry, so the cheapest route past it is silence. Same vacuity +# `mutate` already refuses by REPORTING a declared mutation whose named case does +# not exist rather than counting it. +# +# THE CLAIM IS THE PRECONDITION, AND THE FIRST DRAFT GOT THIS WRONG. It asked +# only for a non-empty diff, which is true of every scratch fixture and every +# consumer checkout — measured, that version reddened four `cli.rs` cases whose +# only business was exercising unrelated rules over a fixture repository. A rule +# that fires on any dirty tree makes the committed config unusable over a test +# repo, and a rule like that gets switched off. Keying on the claim asks the +# question where the answer is owed: a branch that pulled a row is doing tracked +# work; one that did not is not this arm's business. +# +# A NON-EMPTY DIFF IS STILL REQUIRED, so the arm prices work rather than +# existence: a claimed branch that has not started has nothing to have planned. +# An empty RECORD satisfies it — the store exists, so the branch spoke — which +# keeps the remedy honest for a genuinely trivial change: one call saying so, +# rather than a fabricated entry. +violation contains { + "rule": "plan-unrecorded", + "verdict": "plan declare absent", + "subjects": [{"count": count(changed)}], +} if { + claimed + not recorded + count(changed) > 0 +} + +# The predicate's own tests. The SILENT cases are the load-bearing half here for +# the usual reason: both arms are refusals, so a module that fired on everything +# would satisfy every deny case while deciding nothing. + +# Both builders carry a claim, because both arms are about a branch doing tracked +# work and a fixture without one would exercise the wrong population. +plan(lines, changed_paths) := {"tree": { + "records": {"plan": lines, "claim": ["CLOUD-1"]}, + "base-delta": {"added": changed_paths, "edited": [], "deleted": [], "code-changed": []}, +}} + +no_plan(changed_paths) := {"tree": { + "records": {"claim": ["CLOUD-1"]}, + "base-delta": {"added": changed_paths, "edited": [], "deleted": [], "code-changed": []}, +}} + +test_an_unfinished_entry_is_refused if { + some v in violation with input as plan(["1 pending"], ["src/a.rs"]) + v.verdict == "plan declare held" +} + +test_an_in_progress_entry_is_refused if { + some v in violation with input as plan(["1 in_progress"], ["src/a.rs"]) + v.verdict == "plan declare held" +} + +test_a_completed_entry_is_clean if { + count(violation) == 0 with input as plan(["1 completed"], ["src/a.rs"]) +} + +# WITHDRAWING AN ENTRY IS A DECISION, AND THE STORE RECORDS THAT IT WAS MADE. +# The gate refuses work left in flight, never work the author decided against. +test_a_deleted_entry_is_clean if { + count(violation) == 0 with input as plan(["1 deleted"], ["src/a.rs"]) +} + +# ONE FINDING PER ENTRY, so a reviewer sees which item rather than a count they +# have to reconstruct — and finishing one does not clear another. +test_every_unfinished_entry_is_named if { + ids := {v.subjects[0].artifact | some v in violation} with input as plan( + ["1 completed", "2 pending", "3 in_progress"], + ["src/a.rs"], + ) + ids == {"2", "3"} +} + +# THE ANTI-VACUITY ARM. Never recording is the cheapest way past a refusal over +# unfinished entries, so silence is priced. +test_a_branch_that_recorded_no_plan_is_refused if { + some v in violation with input as no_plan(["src/a.rs"]) + v.verdict == "plan declare absent" +} + +# AN EMPTY RECORD IS AN ANSWER. The branch spoke and said there is nothing to +# track, which is the honest remedy for a trivial change — as against a +# fabricated entry, which is what a gate demanding a non-empty list would buy. +test_an_empty_record_satisfies_the_vacuity_arm if { + count(violation) == 0 with input as plan([], ["src/a.rs"]) +} + +# A BRANCH HOLDING NOTHING OPEN HAS NOTHING TO HAVE PLANNED, so a fresh checkout +# is never refused for a plan it had no occasion to write. +test_a_branch_with_no_diff_is_never_refused if { + count(violation) == 0 with input as no_plan([]) +} + +# COULD NOT READ THE BASE leaves the vacuity arm silent rather than firing on +# every branch whose base does not resolve — a verdict about the environment is +# not a verdict about the branch. +test_an_unresolvable_delta_leaves_the_vacuity_arm_silent if { + count(violation) == 0 with input as {"tree": { + "records": {"claim": ["CLOUD-1"]}, + "base-delta": null, + }} +} + +# AN UNCLAIMED BRANCH IS NOT THIS ARM'S BUSINESS, and this is the case that keeps +# the committed config usable over a scratch tree. Without it the arm fires on +# every fixture repository that runs the whole config to exercise some unrelated +# rule — measured at four such cases before the claim became the precondition. +test_an_unclaimed_branch_owes_no_plan if { + count(violation) == 0 with input as {"tree": { + "records": {}, + "base-delta": {"added": ["src/a.rs"], "edited": [], "deleted": [], "code-changed": []}, + }} +} + +test_a_line_this_reader_cannot_parse_is_skipped if { + count(violation) == 0 with input as plan(["", "nonsense"], ["src/a.rs"]) +} diff --git a/policy/shell-retirement.rego b/policy/shell-retirement.rego index c8e1656ce..670797b44 100644 --- a/policy/shell-retirement.rego +++ b/policy/shell-retirement.rego @@ -626,6 +626,60 @@ is_retired_reference(path, span, gone) if { form in {concat("", ["$", variable]), concat("", ["${", variable, "}"])} } +# ARM 5 — THE SPAN NAMES THE PROGRAM AS A TASK (CLOUD-1299). +# +# Arms 1-4 all resolve a PATH: the repo-relative one, a constructed sibling, a +# directory in a variable, the whole path in a variable. A caller that names its +# callee as a task carries none of them, and had no landable shape at all. +# +# The measured instance is `tests/verify.bats:263`, which asserts that `verify`'s +# no-receipt refusal names its remedies: +# +# [[ "$output" == *"bot-issue receipt"* ]] +# +# That is a pin on PROSE. Retiring `mise-tasks/bot-issue.sh` makes the remedy +# name a task that no longer exists, so the message must change — and changing it +# fails that assertion, so the suite must change too, which `V-SHELL-RULE-EDITED` +# refuses with no override route. The two landable shapes were retire the suite +# whole (a different unit's work: its subject is `verify`, not `bot-issue`) or +# leave it alone. CLOUD-1295 took neither and kept the dead name alive inside the +# live remedy, which is the shape this arm exists to make unnecessary. +# +# ON `is_retired_reference` RATHER THAN `is_retired_reference_by_text`, and that +# placement is the narrowing. The `_by_text` family is what `retired_path_vars` +# reads to decide whether a VARIABLE holds the retired path; a task name is not a +# path, so admitting one there would loosen the variable resolution as well — +# widening arms 3 and 4 for no reason anyone measured. This reads only through +# the full-span test, so exactly one question gets a new answer. +# +# WHAT KEEPS IT FROM BEING THE LICENCE THE MODULE REFUSES. `is_retired_reference` +# is `contains`-free on purpose, and this arm stays that way. The naming form is +# located, and then BOTH SIDES OF IT ARE MATCHED WHOLE against anchored rows: +# everything before it must be `shell-task-runner-prefix` (empty, or exactly the +# runner invocation) and everything after it `shell-task-subcommand` (empty, or +# space-separated lowercase words). So the form is not merely CONTAINED — every +# byte of the span is accounted for by one of the three parts, which is the same +# property the byte-exact substitution has, spelled for a span whose middle is +# dynamic. Neither vocabulary admits a quote, a `$`, a redirect or a separator, +# so a span carrying anything able to change what the line DOES is refused; and +# `mise-tasks/` fails the prefix row, so a path span cannot be read as a task +# span. Every other narrowing on the clause that calls this is +# untouched: the span is still derived from the diff rather than declared, and +# the replacement still comes from `invocations_for(gone)`, so a caller can only +# be repointed at a command the retirement's own ledger committed to. +is_retired_reference(_, span, gone) if { + some form in spellings(span) + some name in naming_forms(gone) + + at := indexof(form, name) + at >= 0 + regex.match(data.batten.patterns["shell-task-runner-prefix"], substring(form, 0, at)) + regex.match( + data.batten.patterns["shell-task-subcommand"], + substring(form, at + count(name), -1), + ) +} + # DOES THIS WHOLE LINE NAME A PATH THIS DELTA RETIRES? The removal clause's # question, and it is deliberately looser than the span test above: a removed # line is going away whole, so nothing on it has to be byte-checked. Nothing is diff --git a/policy/spawn-adapters.rego b/policy/spawn-adapters.rego index 5efa45f6b..2aa9439ba 100644 --- a/policy/spawn-adapters.rego +++ b/policy/spawn-adapters.rego @@ -117,10 +117,20 @@ rules contains "spawn-adapters" # definition. It spawns `git` to stage the copy and then `bats` or # `cargo` to re-run the declared tier. `Surface::VerifyOnly` and # `Effect::Write` are what keep the class off the mediated call +# bot the bot lane (CLOUD-1295), retired out of +# `mise-tasks/bot-issue.sh`. Placed on `pr_watch`'s first argument +# and nothing new: what a pull request's title, files and body say +# is a property of the world rather than of the tree, so no walk +# answers it. The forge's own client is the acquisition, chosen over +# this crate's HTTP transport because it resolves the credential +# OUTSIDE the crate. The predicates over that reading are the same +# module's pure half and spawn nothing — the split `pr_watch` and +# `checks_green` make across two modules, made inside one here +# because the lane's facts and its matcher share a config table adapters := { "exec", "provision", "secrets", "symbols", "judge", "handler", "action", "rules", "semver", - "pinned", "perf", "prune", "pr_watch", "mutate", + "pinned", "perf", "prune", "pr_watch", "mutate", "bot", } module_of(path) := name if { diff --git a/schema/batten.local.schema.json b/schema/batten.local.schema.json index b26355e42..c410a3ecc 100644 --- a/schema/batten.local.schema.json +++ b/schema/batten.local.schema.json @@ -721,6 +721,16 @@ "type": "string" } }, + "checks_any": { + "description": "The receipts a [`RuleKind::Receipt`] row accepts **any one** of. Required\nby that kind when [`Rule::checks`] is absent, rejected by every other.\n\nThe disjunction beside the conjunction, rather than a spelling inside\n`checks`, because the two say different things and a row usually wants\nboth: `checks` is what must ALL hold, `checks_any` is one alternative\namong equals. A row carrying both is read as the conjunction of the two —\nevery name in `checks` valid, AND at least one name here valid — which is\nconjunctive normal form and so expresses anything either column alone\ncould not.\n\nThe measured occasion is CLOUD-1297. `verify`'s own body accepts a\n`claim`, a `bot` or a `carry` receipt and treats any one as enough, and\nthe mediated gate could not follow it: `claim-needs-receipt` demanded\n`claim` alone, so a branch legitimately carrying a `bot.` receipt\nwas denied on every write despite holding a valid attestation — a live\nfalse positive since CLOUD-693, which CLOUD-1295's third receipt kind\ninherited. Spelling it as a second `checks` row could not work: a second\nreceipt row is a second AND, so it would have denied every ordinary write\non every branch.\n\nAn ALTERNATION LOWERS A BAR, which is why a consumer row adopting one\nowes `config-lint` a groomed `Weakens:` clause. That is the raise-only\ndiscipline of house-style §8 working, not an obstacle to it: the column\nmakes a weakening expressible and therefore reviewable, where before it\nwas expressible only as a false positive nobody could remove.", + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + }, "commits": { "description": "The commit ranges this policy row reads the IDENTITY FIELDS of,\n**declared** (CLOUD-1187).\n\nEach becomes an entry of `input.tree[\"commit-meta\"]` carrying, per commit,\nits sha, author, committer and trailers — and **no message body and no\ndiff**. That omission is structural rather than careful:\n[`crate::git::CommitMeta`] has no body field, so rule 4 is decided by the\ntype and not by a projection remembering to drop something.\n\n**Its own column rather than a widening of [`Rule::ranges`]**, and the\nreason is cost. `ranges` reads a subject per commit; this peels a commit\nOBJECT per commit, and range length is unbounded per declaration —\n`origin/main..HEAD` is one declaration and an unknown number of commits.\nFolding the two would make every row that wants subjects pay for a peel it\nnever asked for.\n\nA range whose endpoints do not resolve is **absent**, never an empty list,\nexactly as `ranges` is.", "type": "array", diff --git a/schema/batten.schema.json b/schema/batten.schema.json index 5aac2b90c..3330fc02d 100644 --- a/schema/batten.schema.json +++ b/schema/batten.schema.json @@ -26,6 +26,17 @@ } ] }, + "bot_lane": { + "description": "The bot lane this repository files rows for (CLOUD-1295). Absent means it\nruns none, and the `pr` verbs say so rather than filing against defaults —\na lane assembled from engine literals would be a row asserting a bump\nnobody configured.\n\nConsumer-specific by nature, and the reason it lives here rather than in\nthe crate: which repository, which bot logins and which manifests a lane\nowns are that repository's business (non-negotiable rule 1), so the core\ncarries the matcher and this table carries the answers. The type and the\npredicates are [`crate::bot`].", + "anyOf": [ + { + "$ref": "#/$defs/BotLane" + }, + { + "type": "null" + } + ] + }, "budget": { "description": "The thresholds this repository holds itself to (CLOUD-50). Today one:\n`[budget.instructions]`, the always-loaded instruction set and what it\nmay cost. Absent means no budget is declared and none is enforced — a\nthreshold nobody wrote down is not a threshold of zero. The type and the\npredicate are [`crate::budget`].", "anyOf": [ @@ -275,6 +286,17 @@ } ] }, + "ready": { + "description": "The refinement gate's consumer-set thresholds (CLOUD-472). Absent means\nthis file does not speak to them, which every reader takes as\ncould-not-look rather than as a default.", + "anyOf": [ + { + "$ref": "#/$defs/Ready" + }, + { + "type": "null" + } + ] + }, "recorder": { "description": "Records written from the tool result that earned them (CLOUD-1051).\n\nThe third selector on the post-tool event, and the one that can carry a\nvalue another gate decided. A `[[mint]]` renders a template over the\npayload; a `[[recorder]]` may additionally run a declared program and\nrecord its verdict, which is what a board write's refinement column IS.\n\nConsumer-owned for the same reason `[[mint]]` is, and more so: the column\nnames, the verdict tokens and the programs are all a tracker's vocabulary,\nso a grep of `crates/batten` for any of them returns nothing and every one\nof them lives here.", "type": "array", @@ -522,6 +544,61 @@ } ] }, + "BotLane": { + "description": "The `[bot_lane]` table: which proposals this repository will file a row for.\n\nAbsent means the repository runs no bot lane, and the verbs say so rather than\nfiling against defaults — a lane assembled from engine literals would be a row\nasserting a bump nobody configured, which is the CLOUD-198 class with a new\nauthor.", + "type": "object", + "properties": { + "body_template": { + "description": "The path of the file whose text is the derived row's body, with\n`{{...}}` placeholders substituted.\n\nA tracked file rather than a string in the config: the body is a page of\nconsumer prose carrying a Ready block, and a page of markdown inside a\nTOML value is unreviewable. It is also what keeps `ready-lint`'s grammar\nand the text it judges in one place a human edits.", + "type": "string" + }, + "bots": { + "description": "The logins whose pull requests earn a row.\n\nA list rather than a pattern: a login is a literal the forge assigns, and\na regex over it would admit a neighbour nobody meant to trust.", + "type": "array", + "items": { + "type": "string" + } + }, + "branch_prefix": { + "description": "The branch prefix a bot receipt may be keyed to. A branch outside it is\nrefused onto the agent claim receipt, which attests something else.", + "type": "string" + }, + "key_prefix": { + "description": "The tracker's key prefix, which a key is this followed by digits.\n\nThe consumer's vocabulary, exactly as the Ready grammar's `[[pattern]]`\nrows are: a tracker's key shape in `crates/batten` is non-negotiable rule\n1's violation.", + "type": "string" + }, + "linkback_marker": { + "description": "What the tracker's own sync leaves on the issue once it has mirrored it.\nThe key is read from a comment carrying this, never from the issue body —\nthat body is this lane's own text, so a key named there would be one we\nwrote rather than one the tracker assigned.", + "type": "string" + }, + "marker_prefix": { + "description": "The hidden marker that ties a mirror issue to the pull request it was\nfiled for.\n\nA comment rather than a label or a title convention: it survives an edit,\nit is invisible rendered, and it is what makes `ensure` idempotent across\nthe window where the row exists and the PR body does not yet name it.", + "type": "string" + }, + "owned_manifests": { + "description": "The manifests this lane owns, as globs. A PR touching none of them is\nrefused rather than given an invented row.", + "type": "array", + "items": { + "type": "string" + } + }, + "repo": { + "description": "The forge repository, `owner/name`, that the lane's pull requests live in.", + "type": "string" + } + }, + "additionalProperties": false, + "required": [ + "repo", + "bots", + "owned_manifests", + "marker_prefix", + "linkback_marker", + "key_prefix", + "branch_prefix", + "body_template" + ] + }, "Budget": { "description": "The `[budget]` table: **named** file sets and what each may cost.\n\nA map, not a struct with a field per set. The set name is the *consumer's*\n— `[budget.instructions]` is this repository's name for its always-loaded\ncontext, and an engine type carrying that name would be a consumer-specific\nidentifier in `crates/batten` (non-negotiable rule 1). A second consumer\nbudgeting a different surface declares `[budget.]` and needs no\nengine change; before this it needed a new field.", "type": "object", @@ -2268,6 +2345,20 @@ } ] }, + "Ready": { + "description": "The `[ready]` table: the refinement gate's consumer-set thresholds.\n\n# Why a value and not a `[[pattern]]` row (CLOUD-472)\n\nThe first draft of the prose-dialect ratchet spelled its threshold as a\nregex over the exempt key range. That is wrong twice. The pattern registry\nexists so that one CONCEPT has one spelling — arithmetic is not a concept,\nand a range encoded in alternation is unreadable and unmovable. And it makes\nthe decision turn on key TEXT, which the tracker is known to rewrite: this\nconsumer already declares `ready-issue-mention-markup` because a bare key\ncomes back wrapped in `` markup, so matching key text is matching\nthe one thing the round trip mangles.", + "type": "object", + "properties": { + "prose_dialect_required_from": { + "description": "From which creation instant a Ready block must carry the fenced claims\nobject rather than prose. An ISO-8601 UTC stamp, compared verbatim\nagainst the tracker's own `createdAt`.\n\n**A RATCHET: moving it later is the only direction that tightens.**\nAbsent is could-not-look and exempts everything, so a consumer that has\nnot opted in is never refused for a question it did not ask.\n\n**An instant rather than a key ordinal, and that is a portability\ndecision.** An ordinal reaches no consumer literal — the trailing digits,\nno separator assumed — so it passes `no-tracker-key-in-core`. It still\nrequires keys that are numeric AND monotonic with creation order, which\nthree popular trackers give and a slug- or UUID-keyed one does not, and it\nwould fail SILENTLY there rather than loudly. Every tracker stamps a\ncreation time, so this assumes nothing about how a consumer spells a key.", + "type": [ + "string", + "null" + ] + } + }, + "additionalProperties": false + }, "ReceiptKey": { "description": "Which git fact a receipt is keyed to, and therefore what invalidates it.\n\nThe distinction is not a tuning knob, it is what the receipt *attests*.\nA `head` receipt claims something about those exact bytes, so an amend or a\nrebase must expire it. A `branch` receipt claims a decision about the work,\nwhich every commit on the branch continues to serve, so a SHA-keyed one\nwould demand a re-claim per commit — the false-positive rate that gets a\nguard bypassed. Both spellings are carried from the shell layer that proved\nthem (`ready-guard` keys by SHA, `claim-check` by branch).\n\n**`ValueEnum` because the CLI selects the same keying** (CLOUD-741). A\n`receipt` rule is pinned to [`RuleScope::MediatedCall`], so `batten check`\ncan never evaluate one and `verify` cannot reach this predicate through the\nengine — which left `verify` re-implementing it in shell, weakly enough that\nCLOUD-516's own incident passed. `receipt status --key branch` is how the\ntree surface reaches the one implementation instead, so config and CLI must\nname the keying with the same tokens or the two surfaces disagree about what\nthey asked for. `clap`'s and serde's renames both land on `head`/`branch`;\nthe `clap(rename_all)` is stated rather than inferred so a future variant\ncannot drift them apart.", "oneOf": [ @@ -2631,6 +2722,16 @@ "type": "string" } }, + "checks_any": { + "description": "The receipts a [`RuleKind::Receipt`] row accepts **any one** of. Required\nby that kind when [`Rule::checks`] is absent, rejected by every other.\n\nThe disjunction beside the conjunction, rather than a spelling inside\n`checks`, because the two say different things and a row usually wants\nboth: `checks` is what must ALL hold, `checks_any` is one alternative\namong equals. A row carrying both is read as the conjunction of the two —\nevery name in `checks` valid, AND at least one name here valid — which is\nconjunctive normal form and so expresses anything either column alone\ncould not.\n\nThe measured occasion is CLOUD-1297. `verify`'s own body accepts a\n`claim`, a `bot` or a `carry` receipt and treats any one as enough, and\nthe mediated gate could not follow it: `claim-needs-receipt` demanded\n`claim` alone, so a branch legitimately carrying a `bot.` receipt\nwas denied on every write despite holding a valid attestation — a live\nfalse positive since CLOUD-693, which CLOUD-1295's third receipt kind\ninherited. Spelling it as a second `checks` row could not work: a second\nreceipt row is a second AND, so it would have denied every ordinary write\non every branch.\n\nAn ALTERNATION LOWERS A BAR, which is why a consumer row adopting one\nowes `config-lint` a groomed `Weakens:` clause. That is the raise-only\ndiscipline of house-style §8 working, not an obstacle to it: the column\nmakes a weakening expressible and therefore reviewable, where before it\nwas expressible only as a false positive nobody could remove.", + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + }, "commits": { "description": "The commit ranges this policy row reads the IDENTITY FIELDS of,\n**declared** (CLOUD-1187).\n\nEach becomes an entry of `input.tree[\"commit-meta\"]` carrying, per commit,\nits sha, author, committer and trailers — and **no message body and no\ndiff**. That omission is structural rather than careful:\n[`crate::git::CommitMeta`] has no body field, so rule 4 is decided by the\ntype and not by a projection remembering to drop something.\n\n**Its own column rather than a widening of [`Rule::ranges`]**, and the\nreason is cost. `ranges` reads a subject per commit; this peels a commit\nOBJECT per commit, and range length is unbounded per declaration —\n`origin/main..HEAD` is one declaration and an unknown number of commits.\nFolding the two would make every row that wants subjects pay for a peel it\nnever asked for.\n\nA range whose endpoints do not resolve is **absent**, never an empty list,\nexactly as `ranges` is.", "type": "array", diff --git a/tests/bot-issue.bats b/tests/bot-issue.bats deleted file mode 100644 index 922ff2071..000000000 --- a/tests/bot-issue.bats +++ /dev/null @@ -1,323 +0,0 @@ -#!/usr/bin/env bats -# subject: mise-tasks/bot-issue.sh -# CLOUD-693. A bot proposes work with no issue and no session, so every lifecycle -# gate refuses it by construction. `bot-issue` is the step that turns the proposal -# into a refined row before the lifecycle sees it — and the rows below are the -# ones the issue declares, in its own order. -# -# THE ROW IS A GITHUB ISSUE THAT LINEAR MIRRORS (CLOUD-750), so there is no -# tracker credential anywhere in this suite — the `curl` stub the GraphQL shape -# needed is gone with it. What replaces it is three `gh` endpoints: opening an -# issue, listing issues to find a mirror this PR already has, and reading the -# `linear-code[bot]` linkback comment for the key. -# -# EVERY CASE RUNS OFFLINE. `gh` is stubbed on PATH, which is what lets this suite -# run inside the gate on a machine with no credentials at all — the same shape -# `tests/checks-green.bats` uses for the same reason. The one thing not stubbed is -# `ready-lint`: the composition case runs the REAL gate over the REAL derived -# payload, because "the derived block is checkable by the same gate that checks a -# human's" is the claim that makes a mechanical row honest, and a stub would -# assert it rather than test it. - -setup() { - TASK="$BATS_TEST_DIRNAME/../mise-tasks/bot-issue.sh" - STUB="$BATS_TEST_TMPDIR/stub" - mkdir -p "$STUB" - export PATH="$STUB:$PATH" - export BOT_ISSUE_REPO="demo/repo" - # The PR the stubs describe. Each case rewrites only the field it is about. - PR_TITLE="build(deps): update cargo" - PR_LOGIN="renovate[bot]" - PR_BODY="This PR contains the following updates." - PR_FILES=$'Cargo.toml\nCargo.lock' - # The repository's issue list, as `mirror_for` reads it: `MIRROR=none` is a PR - # with no mirror yet, `MIRROR=yes` is one already filed. `LINKBACK=none` is the - # window after the issue exists and before the sync has run. - MIRROR=none - LINKBACK=yes - CREATE_OK=yes -} - -# `gh` dispatches on the endpoint and returns what `--jq` would have produced, so -# the stub answers the call rather than re-implementing the tool. -stub_gh() { - cat >"$STUB/gh" <<-EOF - #!/usr/bin/env bash - args="\$*" - case "\$args" in - *"-X POST"*"/issues"*) - if [ "$CREATE_OK" != yes ]; then echo "refused" >&2; exit 1; fi - for a in "\$@"; do - case "\$a" in body=@*) cp "\${a#body=@}" "$BATS_TEST_TMPDIR/issue-body" ;; esac - done - echo 41 - ;; - *"-X PATCH"*) - for a in "\$@"; do - case "\$a" in body=@*) cp "\${a#body=@}" "$BATS_TEST_TMPDIR/patched-body" ;; esac - done - echo '{}' - ;; - *"/issues/"*"/comments"*) - if [ "$LINKBACK" = yes ]; then - printf '%s\n' ' see https://linear.app/buttoninc/issue/CLOUD-700/x' - fi - ;; - *"issues?state=all"*) - if [ "$MIRROR" = yes ]; then printf '%s\n' 41; fi - ;; - *"/files"*) - printf '%s\n' '$PR_FILES' - ;; - *"pulls?state=open"*) - printf '%s\n' "\${STUB_OPEN_PR:-7}" - ;; - *".body // "*) - printf '%s\n' '$PR_BODY' - ;; - *"repos/demo/repo/pulls/"*) - printf '{"number":7,"title":"%s","body":"%s","login":"%s","head":"renovate/cargo","draft":true}\n' \\ - '$PR_TITLE' '$PR_BODY' '$PR_LOGIN' - ;; - *) echo "unstubbed gh call: \$args" >&2; exit 1 ;; - esac - EOF - chmod +x "$STUB/gh" -} - -stubs() { stub_gh; } - -@test "a bump PR with no row gets one, and the PR is told which row it closes" { - stubs - run "$TASK" ensure 7 - [ "$status" -eq 0 ] - [[ "$output" == *"#7 -> CLOUD-700"* ]] - # The closing key is what makes the merge move the board — `closing-key-check` - # refuses a body that names a key any other way. - [[ "$(cat "$BATS_TEST_TMPDIR/patched-body")" == *"Closes CLOUD-700"* ]] -} - -@test "the mirror issue carries the derived block and a marker naming its PR" { - # The marker is what makes `ensure` idempotent across the window where the row - # exists and the PR body does not yet name it. Hidden, so a reader never sees - # it; last, so it is never in the way of the block above it. - stubs - run "$TASK" ensure 7 - [ "$status" -eq 0 ] - [[ "$(cat "$BATS_TEST_TMPDIR/issue-body")" == *"Refinement — Ready"* ]] - [[ "$(cat "$BATS_TEST_TMPDIR/issue-body")" == *""* ]] -} - -@test "THE PR CLOSES THE CLOUD KEY, never the mirror issue (CLOUD-750)" { - # Measured on the probe: closing the GitHub issue moves the row to Done in - # about a second, and Done here means RELEASED. Closing `#41` on merge would - # assert a release that has not happened and skip In Review entirely. - stubs - run "$TASK" ensure 7 - [ "$status" -eq 0 ] - [[ "$(cat "$BATS_TEST_TMPDIR/patched-body")" == *"Closes CLOUD-700"* ]] - [[ "$(cat "$BATS_TEST_TMPDIR/patched-body")" != *"Closes #41"* ]] -} - -@test "a mirror that is not yet mirrored links nothing, and says so" { - # The window between the issue existing and the sync having run. A tick that - # cannot finish makes progress and returns 0; the next one links it. Nothing - # polls: a wall-clock wait would be a guess about someone else's latency. - LINKBACK=none - stubs - run "$TASK" ensure 7 - [ "$status" -eq 0 ] - [[ "$output" == *"not mirrored yet"* ]] - [ ! -e "$BATS_TEST_TMPDIR/patched-body" ] -} - -@test "a second tick reuses the mirror it already filed rather than opening another" { - # Idempotence in the window above, and the reason the marker is searched by - # LISTING issues: the search API's indexing lag would let this file twice. - MIRROR=yes - stubs - run "$TASK" ensure 7 - [ "$status" -eq 0 ] - [ ! -e "$BATS_TEST_TMPDIR/issue-body" ] - [[ "$(cat "$BATS_TEST_TMPDIR/patched-body")" == *"Closes CLOUD-700"* ]] -} - -@test "IDEMPOTENCE: a second call on the same PR files nothing" { - # `ensure` runs on every lander tick, twice an hour for as long as the PR is - # open. The key travels in the BODY rather than in a local record, because the - # body is what the merge reads and a local record could go missing. - PR_BODY="a bump, Closes CLOUD-700" - stubs - run "$TASK" ensure 7 - [ "$status" -eq 0 ] - [[ "$output" == *"already names CLOUD-700"* ]] - [ ! -e "$BATS_TEST_TMPDIR/graphql" ] -} - -@test "a non-bot PR is untouched, and the refusal says whose it is" { - # An agent's branch carries its own claim receipt and its own issue; filing a - # second row for it would put two rows on one change. - PR_LOGIN="wenzowski" - stubs - run "$TASK" ensure 7 - [ "$status" -eq 1 ] - [[ "$output" == *"wenzowski"* ]] - [ ! -e "$BATS_TEST_TMPDIR/graphql" ] -} - -@test "the retired bot is not on the allowlist either (CLOUD-660)" { - # Dependabot cannot open a PR here any more, so a row filed for one would - # assert a lane this repository does not have. - PR_LOGIN="dependabot[bot]" - stubs - run "$TASK" ensure 7 - [ "$status" -eq 1 ] - [[ "$output" == *"dependabot[bot]"* ]] -} - -@test "a PR touching no owned manifest is REFUSED, never given an invented row" { - # The alternative is a tracker row asserting a bump nobody proposed. - PR_FILES=$'README.md\ndocs/notes.md' - stubs - run "$TASK" ensure 7 - [ "$status" -eq 1 ] - [[ "$output" == *"touches no manifest this lane owns"* ]] - # Pointer-only: the paths it did touch, so a lane that grew a manifest is a - # one-line fix rather than a mystery. - [[ "$output" == *"README.md"* ]] - [ ! -e "$BATS_TEST_TMPDIR/graphql" ] -} - -@test "a workflow bump is owned too — that manager is in the same lane" { - PR_FILES=".github/workflows/ci.yml" - PR_TITLE="ci(deps): update actions" - stubs - run "$TASK" ensure 7 - [ "$status" -eq 0 ] - [[ "$output" == *"CLOUD-700"* ]] -} - -@test "a subject with no Conventional type is refused, because that commit could never land" { - # `commit-lint` gates every fast-forward, so the honest answer is to name the - # lane defect rather than to invent a type the config did not set (CLOUD-676). - PR_TITLE="update cargo" - stubs - run "$TASK" ensure 7 - [ "$status" -eq 1 ] - [[ "$output" == *"no Conventional type"* ]] -} - -@test "THE DERIVED BLOCK PASSES ready-lint — the same gate a human's row passes" { - # The claim that makes a mechanical row honest, tested rather than asserted. - stubs - run "$TASK" derive 7 - [ "$status" -eq 0 ] - printf '%s' "$output" >"$BATS_TEST_TMPDIR/payload.json" - run "$BATS_TEST_DIRNAME/../mise-tasks/ready-lint.sh" <"$BATS_TEST_TMPDIR/payload.json" - [ "$status" -eq 0 ] -} - -@test "the §6 type is READ from the subject, not chosen here" { - # `renovate.json5`'s packageRules already decided it. Re-deciding would be a - # second authority for one fact. - PR_TITLE="ci(deps): update actions" - PR_FILES=".github/workflows/ci.yml" - stubs - run "$TASK" derive 7 - [ "$status" -eq 0 ] - [[ "$output" == *'`ci` → no bump'* ]] -} - -@test "derive writes nothing — it is the half a gate can read" { - stubs - run "$TASK" derive 7 - [ "$status" -eq 0 ] - [ ! -e "$BATS_TEST_TMPDIR/graphql" ] - [ ! -e "$BATS_TEST_TMPDIR/patched-body" ] -} - -@test "a mirror that cannot be opened is exit 2, and no key is invented" { - # A bot PR landing with no row is the board quietly stopping describing what - # shipped. That is a defect to see, not one to route around. - CREATE_OK=no - stubs - run "$TASK" ensure 7 - [ "$status" -eq 2 ] - [[ "$output" == *"could not open the mirror issue"* ]] - [ ! -e "$BATS_TEST_TMPDIR/patched-body" ] -} - -# --- closes: the key `link` wrote is not the key the merge sees (CLOUD-768) ---- -# -# Renovate regenerates its own PR body on every rebase, so `link`'s append is -# transient. These rows pin the last-moment re-read that keeps a landing from -# moving `main` while the row it names sits in Backlog. - -@test "a body that still closes its row is landable, and the verdict names the key" { - PR_BODY="This PR contains the following updates. - ---- - -Closes CLOUD-767" - stubs - run "$TASK" closes 7 - [ "$status" -eq 0 ] - [[ "$output" == *"#7 closes CLOUD-767"* ]] -} - -@test "A KEY NAMED BUT NOT CLOSED IS REFUSED — that is the whole failure being caught" { - # The shape a rewritten body leaves behind: Renovate keeps its own prose, the - # key survives only where the bot happened to echo it, and the merge moves - # nothing. `closing-key-check` makes the same distinction on the agent side. - PR_BODY="This PR contains the following updates. See CLOUD-767 for context." - stubs - run "$TASK" closes 7 - [ "$status" -eq 1 ] - [[ "$output" == *"closes no tracker key"* ]] -} - -@test "a body naming no key at all is refused, not treated as nothing to check" { - PR_BODY="This PR contains the following updates." - stubs - run "$TASK" closes 7 - [ "$status" -eq 1 ] - [[ "$output" == *"closes no tracker key"* ]] -} - -@test "fixes and resolves close it too — the predicate is closing-key-check's, not link's" { - # Narrowing this to the literal string `link` writes would refuse a body a - # human corrected by hand, which closes the row just as well. - PR_BODY="Fixes CLOUD-767" - stubs - run "$TASK" closes 7 - [ "$status" -eq 0 ] - [[ "$output" == *"#7 closes CLOUD-767"* ]] -} - -@test "DO-NOT-CLOSE does not read as a close, though the marker ends in a closing verb" { - PR_BODY="DO-NOT-CLOSE CLOUD-767" - stubs - run "$TASK" closes 7 - [ "$status" -eq 1 ] - [[ "$output" == *"closes no tracker key"* ]] -} - -@test "POINTER, NEVER PAYLOAD: the refusal names the PR and no part of the body" { - # A bot PR body is a release-notes dump. Echoing it here would put it in the - # log of every landing that waits a tick. - PR_BODY="This PR contains the following updates. SECRETSENTINEL in the changelog." - stubs - run "$TASK" closes 7 - [ "$status" -eq 1 ] - [[ "$output" != *"SECRETSENTINEL"* ]] - [[ "$output" == *"#7"* ]] -} - -@test "closes writes nothing — it is a read, and a refusal must not repair by editing" { - PR_BODY="This PR contains the following updates." - stubs - run "$TASK" closes 7 - [ "$status" -eq 1 ] - [ ! -e "$BATS_TEST_TMPDIR/patched-body" ] - [ ! -e "$BATS_TEST_TMPDIR/issue-body" ] -} diff --git a/tests/verify.bats b/tests/verify.bats index 6121fa5da..be7a1eb1c 100644 --- a/tests/verify.bats +++ b/tests/verify.bats @@ -109,7 +109,7 @@ no_claim_receipt() { receipt_says claim 2 missing; } # The state a presence test could not see, and the reason this issue exists: a # receipt EXISTS and is void, so the remedy is re-claim rather than claim. stale_claim_receipt() { receipt_says claim 2 stale-main; } -# CLOUD-693's second kind, minted by `mise run bot-issue receipt` on a bot branch. +# CLOUD-693's second kind, minted by `batten claim bot` on a bot branch. bot_receipt() { receipt_says bot 0 valid; } no_bot_receipt() { receipt_says bot 2 missing; } @@ -260,7 +260,7 @@ called() { no_claim_receipt run_verify [[ "$output" == *"claim-check"* ]] - [[ "$output" == *"bot-issue receipt"* ]] + [[ "$output" == *"batten claim bot"* ]] [[ "$output" == *"No receipt written."* ]] }