From 80f6d7707a220000c7e2e431bc72145378ffb041 Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Tue, 25 Aug 2026 16:47:47 +0000 Subject: [PATCH 01/38] feat(policy): a migration that touches a shell rule retires it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CLOUD-843's campaign is retiring 144 shell programs and 161 bats suites onto the policy engine, and the failure it cannot survive is a wave that EDITS a shell gate instead of replacing it. That reads as progress in every sensor the tree has: `bash-surface-not-growing` counts programs and an edit changes none, `bats-tests-not-deleted` counts `@test` cases and an edit changes none. The corpus stays exactly as large while the campaign reports movement. `shell-retirement` refuses three shapes over the changed-file set: an added authored shell rule or bats suite, one edited in place, and a deletion whose successor the retirement ledger does not record. The ledger is CLOUD-908's `[rule.conserves]` arms read one level up — this adds no second manifest and no hand-maintained shell inventory, because a hand-maintained census is what CLOUD-929 measured going stale. `Fact::BaseDelta` is the input, and nothing carried it before: `git-status` is the working tree against HEAD, `git-ranges` is commits rather than paths, and `landing` is shas. Three disjoint lists of repo-relative paths — added, edited, deleted — never a hunk and never a line, which is the bound `GitStatus` already holds and the one that matters here, since the question is WHICH files moved and never what they say. Built from `for_each_blob_at_rev` and the working-tree walk rather than a spawned `git diff`, because CLOUD-740 is taking the crate to zero `git` spawns. `delta_sources` requires `base` and reads the column `retires_with` already reads. Two spellings of "what did this branch change" is the drift a single column exists to prevent. The stated bound: `base` is a ref, so this is a tip diff — `verify` asserts the branch is rebased on current `origin/main`, where the tip and the merge base are the same commit. `null` when the base does not resolve, never an empty delta: "this branch changed nothing" and "I could not read the base" are the two answers a migration gate must keep apart, and Rego reads an undefined path as does-not-hold, so a fabricated empty set passes the gate on ignorance. THE SKIP GUARD COUNTS THE DELTA, and leaving it out was live the moment the column landed. The guard skips a row whose every declared selector matched nothing — right for glob-selected documents, wrong for a row whose real input is a git fact. The case where it bites is precisely the one this row exists for: a migration deleting the last `mise-tasks/*.sh` and writing no Rust successor leaves both line globs empty, so the gate refusing an unmapped deletion is the gate that deletion switches off. Found by the fixtures below, not by reading. Two committed-config fixtures pin their seeds into the base rather than writing them after `origin/main`, for the reason the `# subject:` header beside them already records: otherwise those seeds are files the fixture ADDS, and a refusal about them grows a second rule's pointer into an assertion about portability. REPLAYED BEFORE `deny`, over 1365 single-parent commits on origin/main: 560 commits fire, 2205 findings — 1528 edited, 489 added, 188 deleted-unmapped. That is the firing rate on history written before the rule existed, not a false-positive rate: every one is the predicate doing what it is specified to do. The clearest case is #699, merged today, which edited `prose-only-check.sh` rather than migrating it and under this gate owed a migration. One true false positive was found and fixed rather than tuned away — this repo's own portability fixture, above. Refs: CLOUD-1059 --- batten.toml | 32 ++ crates/batten/src/config.rs | 1 + crates/batten/src/facts.rs | 37 ++- crates/batten/src/git.rs | 106 +++++++ crates/batten/src/hook.rs | 9 + crates/batten/src/rules.rs | 82 ++++- crates/batten/tests/cli.rs | 26 ++ crates/batten/tests/facts.rs | 7 +- crates/batten/tests/shell_retirement.rs | 382 ++++++++++++++++++++++++ policy/shell-retirement.rego | 375 +++++++++++++++++++++++ schema/batten.local.schema.json | 7 + schema/batten.schema.json | 7 + schema/policy-input.schema.json | 28 ++ 13 files changed, 1093 insertions(+), 6 deletions(-) create mode 100644 crates/batten/tests/shell_retirement.rs create mode 100644 policy/shell-retirement.rego diff --git a/batten.toml b/batten.toml index 8551fcecb..54dc202f3 100644 --- a/batten.toml +++ b/batten.toml @@ -2434,6 +2434,38 @@ documents = ["mise.toml"] module = "policy/remedy-authorship.rego" severity = "deny" +# CLOUD-1059. A migration that TOUCHES an authored shell rule retires it, or it +# does not land. The module's header carries the measurement and the bounds; the +# reasoning is not restated here. +# +# `delta_sources` PLUS `base`, and the pair is the whole declaration: the fact is +# a comparison, so a glob with no rev to compare against is unanswerable rather +# than merely narrower. `base` is the column `retires_with` already reads, shared +# deliberately — two spellings of "what did this branch change" is exactly the +# second authority CLOUD-1050 is about, one layer down. +# +# `line_sources` SPANS TWO CORPORA because the predicate asks two different +# questions of them. `mise-tasks/*.sh` answers "is this changed path an authored +# shell rule", read from the file's own first bytes rather than guessed from its +# name. `crates/batten/tests/*.rs` is CLOUD-908's retirement ledger, and it is +# declared by glob for that column's own reason: enumerating which test files +# carry an arm is a list that goes stale the next time somebody adds one — +# silently and green, which is the failure a declaration bound exists to prevent. +# +# THE COST IS REAL AND IS MEASURED RATHER THAN ASSUMED. This roughly doubles the +# line corpus on the tree input against `remedy-authorship`'s 34,730 lines, so +# `perf-gate` is the sensor on whether that stays inside the budget, and the +# number is reported on the PR rather than asserted here. +[[rule]] +id = "shell-retirement" +kind = "policy" +scope = "tree" +base = "origin/main" +delta_sources = ["mise-tasks/**", "tests/**/*.bats"] +line_sources = ["mise-tasks/*.sh", "crates/batten/tests/*.rs"] +module = "policy/shell-retirement.rego" +severity = "deny" + # CLOUD-876. `opa check -s` type checks the Rego corpus at build time because # regorus ships no static undefined-field checker; regorus evaluates it at # runtime. Two implementations of one language, so the pins must name one release diff --git a/crates/batten/src/config.rs b/crates/batten/src/config.rs index 302d6a406..41b703701 100644 --- a/crates/batten/src/config.rs +++ b/crates/batten/src/config.rs @@ -1253,6 +1253,7 @@ fn default_rules() -> Vec { ranges: Vec::new(), landing: Vec::new(), symbols: false, + delta_sources: Vec::new(), run: None, verbatim: None, identity_key: None, diff --git a/crates/batten/src/facts.rs b/crates/batten/src/facts.rs index 74a41c284..8b6fe64c1 100644 --- a/crates/batten/src/facts.rs +++ b/crates/batten/src/facts.rs @@ -462,6 +462,9 @@ pub enum Fact { /// Where the crate uses a type a delegated analyser resolved by NAME, rather /// than by spelling (CLOUD-760). The first `Cost::Effect` fact. Symbols, + /// How the **declared** globs' paths differ from a **declared** base rev: + /// added, edited, deleted (CLOUD-1059). + BaseDelta, } /// [`Fact::Bypass`] — the hatch is an environment variable, and the kernel @@ -836,6 +839,20 @@ pub const LANDING: Class = Class::new(Cost::Read, Surface::Check); /// SECOND class of the same fact is buildable later, and would be a different /// `Class` rather than a quiet reinterpretation of this one. pub const SYMBOLS: Class = Class::new(Cost::Effect, Surface::Check); +/// [`Fact::BaseDelta`] — how the **declared** globs' paths differ from a +/// **declared** base rev (CLOUD-1059). +/// +/// `read` x **`check`**, for [`GIT_RANGE`]'s reason and one of its own. Declared +/// on both axes — which globs and which base — but neither declaration bounds the +/// cost: a glob is a selection over the whole tree, and answering it walks the +/// base tree once and the working tree once. That is a `check`-surface walk, not +/// something a ~100ms mediated call absorbs. +/// +/// Pointer-only at the boundary (non-negotiable rule 4): three lists of +/// repo-relative paths. Never a hunk, never a line, never the content that +/// changed — the same bound [`GIT_STATUS`] holds, and for the same reason, since +/// a migration gate needs to know WHICH files moved and never what they say. +pub const BASE_DELTA: Class = Class::new(Cost::Read, Surface::Check); impl Fact { /// Every fact the boundary resolves today, so [`Fact::class`] is total. @@ -860,6 +877,7 @@ impl Fact { Fact::Invocations, Fact::Uses, Fact::Symbols, + Fact::BaseDelta, ]; /// The stable lowercase token (§6) — the field name in `lib.rs`'s `Facts`. @@ -886,6 +904,7 @@ impl Fact { Fact::Invocations => "invocations", Fact::Uses => "uses", Fact::Symbols => "symbols", + Fact::BaseDelta => "base-delta", } } @@ -920,6 +939,7 @@ impl Fact { Fact::Invocations => INVOCATIONS, Fact::Uses => USES, Fact::Symbols => SYMBOLS, + Fact::BaseDelta => BASE_DELTA, } } @@ -976,6 +996,10 @@ impl Fact { // independent of the surface one, which is exactly what makes the // pair expressive rather than redundant. Fact::Symbols => Some("symbols"), + // Tree-only for the same reason (CLOUD-1059): the answer is a walk + // of the base tree and a walk of the working tree, which is a + // `check`-surface cost and not a mediated call's. + Fact::BaseDelta => Some("base-delta"), // Hook-surface facts. The tree engine resolves none of them, and // naming them here as `None` is what lets the correspondence test // assert the emitted key set in BOTH directions rather than only @@ -1102,6 +1126,16 @@ impl Fact { // arrived, and the ceiling is right: a match arm per fact is readable // and a match arm per fact for twenty facts is not. Split along the // seam that already exists rather than by line count. + Fact::BaseDelta => serde_json::json!({ + "type": ["object", "null"], + "description": "Fact::BaseDelta (CLOUD-1059). How the declared globs' paths differ from the declared base rev: `added` present now and not at base, `edited` present in both with different content, `deleted` present at base and not now. Repo-relative paths only -- never a hunk and never a line, non-negotiable rule 4. NULL when the base rev does not resolve, never an empty delta: `this branch changed nothing` and `I could not read the base` are the two answers a migration gate must keep apart, and a fabricated empty set passes the gate on ignorance.", + "properties": { + "added": {"type": "array", "items": {"type": "string"}}, + "edited": {"type": "array", "items": {"type": "string"}}, + "deleted": {"type": "array", "items": {"type": "string"}}, + }, + "additionalProperties": false, + }), Fact::GitHead | Fact::GitStatus | Fact::GitRemote @@ -1257,7 +1291,8 @@ impl Fact { | Fact::Produced | Fact::Invocations | Fact::Uses - | Fact::Symbols => serde_json::json!({ + | Fact::Symbols + | Fact::BaseDelta => serde_json::json!({ "description": "unrouted fact -- schema_fragment delegated a fact git_schema_fragment does not own", }), } diff --git a/crates/batten/src/git.rs b/crates/batten/src/git.rs index f80869a8d..acd8ff4fd 100644 --- a/crates/batten/src/git.rs +++ b/crates/batten/src/git.rs @@ -2058,6 +2058,107 @@ pub fn for_each_blob_at_rev( Ok(()) } +/// How a declared glob's paths differ between a base rev and the working tree +/// (CLOUD-1059). +/// +/// Three disjoint sets of repo-relative paths, never a hunk and never a line — +/// non-negotiable rule 4, the same bound [`changed_paths`] and +/// [`Fact::GitStatus`](crate::facts::Fact::GitStatus) already hold to. +#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize)] +pub struct BaseDelta { + /// Present now, absent at the base rev. + pub added: Vec, + /// Present in both, with different content. + pub edited: Vec, + /// Present at the base rev, absent now. + pub deleted: Vec, +} + +/// Classify every path a declared glob selects as added, edited or deleted +/// against `base` (CLOUD-1059). +/// +/// **Built from [`for_each_blob_at_rev`] and the working tree, never from a +/// spawned `git diff`.** CLOUD-740 is taking the crate to zero `git` spawns, so a +/// new one here would be a regression the same release is removing elsewhere. +/// Every primitive this needs is already gix-backed. +/// +/// **The base is a REF, so this is a tip diff rather than a merge-base diff.** +/// Stated as a bound rather than left to be discovered: `mise run verify` asserts +/// the branch is rebased on the current `origin/main`, so on the path that +/// matters the tip and the merge base are the same commit. On a stale branch they +/// are not, and this reports paths `main` moved as though this branch moved them. +/// That is the same reading [`Rule::retires_with`](crate::rules::Rule::retires_with) +/// already takes from its own `base` column, and sharing it is deliberate: two +/// answers to "what did this branch change" is the drift a single column exists +/// to prevent. +/// +/// Absent — `None` — when the base rev does not resolve, never an empty delta. +/// "This branch changed nothing" and "I could not read the base" are the two +/// answers a migration gate must keep apart, and Rego reads an undefined path as +/// *does not hold*, so a fabricated empty set would pass the gate on ignorance. +/// +/// # Errors +/// +/// Raises only when the repository cannot be opened at all. +pub fn base_delta(dir: &Path, base: &str, globs: &[String]) -> Result> { + let mut at_base: BTreeMap = BTreeMap::new(); + for glob in globs { + // A base that does not resolve is could-not-look for the WHOLE fact, not + // for one glob: a partial answer here would report every path the + // unresolvable glob would have covered as added. + match for_each_blob_at_rev(dir, base, glob, |path, text| { + at_base.insert(path.to_owned(), text.to_owned()); + }) { + Ok(()) => {} + Err(_) => return Ok(None), + } + } + + // ONE walk for every declared glob, matched against all of them, rather than + // a walk per glob: the working-tree half is the expensive side and the + // selectors are cheap, so re-walking would charge the tree once per + // declaration for an answer that does not change. + let selectors = globs + .iter() + .map(|glob| crate::rules::Selector::new(glob)) + .collect::>>()?; + let selects = |path: &str| selectors.iter().any(|selector| selector.matches(path)); + + let mut delta = BaseDelta::default(); + let mut present: BTreeSet = BTreeSet::new(); + for path in crate::rules::tree_files(dir)? { + if !selects(&path) { + continue; + } + match at_base.get(&path) { + None => delta.added.push(path.clone()), + Some(was) => { + // Read through the same UTF-8 lens the base half uses, so a file + // that side skipped for non-UTF-8 content is not reported as + // edited on every run. + let now = std::fs::read_to_string(dir.join(&path)).unwrap_or_default(); + if *was != now { + delta.edited.push(path.clone()); + } + } + } + present.insert(path); + } + // Deleted is decided against the WALK, not against `Path::exists`: the walk + // honours `.gitignore`, so a path the base tracked and the head ignores is a + // deletion from the selected set even though a file is still on disk. + delta.deleted = at_base + .keys() + .filter(|path| selects(path) && !present.contains(*path)) + .cloned() + .collect(); + + delta.added.sort(); + delta.edited.sort(); + delta.deleted.sort(); + Ok(Some(delta)) +} + /// The remote's default branch, as a full remote-tracking ref. /// /// The fallback landing target when a consumer declares no `must_land_on` @@ -2609,6 +2710,11 @@ pub struct GitFacts { /// The declared landing targets, if any row declared one (CLOUD-880). A /// target that could not be scanned is ABSENT from the map. pub landing: Option>, + /// How the declared globs differ from the declared base rev, if any row + /// declared both (CLOUD-1059). `None` when nobody asked AND when the base + /// did not resolve — the family's own could-not-look shape, one level up + /// from a range that is absent from `ranges`. + pub base_delta: Option, } /// Acquire [`HeadFact`]. diff --git a/crates/batten/src/hook.rs b/crates/batten/src/hook.rs index 2b859fbf0..a9b63924a 100644 --- a/crates/batten/src/hook.rs +++ b/crates/batten/src/hook.rs @@ -4869,6 +4869,14 @@ fn call_document(envelope: &Envelope, facts: &Facts<'_>) -> Result None, + // Two unbounded walks — the base tree and the working tree — so this + // sits with `GitStatus` above rather than with the cheap three, and + // for a sharper version of the same reason (CLOUD-1059). Its + // declaration bounds WHICH paths are reported and not what answering + // costs: a glob is a selection over the whole tree either way. The + // consumer is a migration gate, which is a `batten check` run by + // construction, so no mediated-call consumer is being turned away. + crate::facts::Fact::BaseDelta => None, }; if let Some(value) = projected { projected_facts.insert(fact.as_str().to_owned(), value); @@ -6137,6 +6145,7 @@ mod tests { refs: Vec::new(), ranges: Vec::new(), landing: Vec::new(), + delta_sources: Vec::new(), predicate_severity: None, criteria: None, tier: None, diff --git a/crates/batten/src/rules.rs b/crates/batten/src/rules.rs index 7ad3ba673..b99be1490 100644 --- a/crates/batten/src/rules.rs +++ b/crates/batten/src/rules.rs @@ -800,6 +800,17 @@ impl RuleKind { "sources", "lines", "line_sources", + // CLOUD-1059. `delta_sources` is a declared READ and so escapes + // this census with the git family, but the rev it reads against + // is `base` — which is in the census, because a ratchet's + // direction is judged against it. So the column is permitted + // here rather than duplicated under a second name: one spelling + // of "the rev this branch is compared to" is the point, and a + // `delta_base` beside it would be the second authority + // CLOUD-1050 is about, one layer down. Optional, and optional is + // its whole compatibility story — a policy row without it + // behaves exactly as it did before the column reached this kind. + "base", "invocations", "invocation_sources", "uses", @@ -2008,6 +2019,25 @@ pub struct Rule { /// resolution, and what makes `Cost::Read` an honest classification. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub landing: Vec, + /// The globs whose paths this policy row wants classified against + /// [`Rule::base`] — added, edited or deleted (CLOUD-1059). + /// + /// **Requires `base`**, and the load refuses the column without it, because a + /// delta with no rev to compare against is not a narrower question but an + /// unanswerable one. That is [`Rule::retires_with`]'s rule, and this column + /// shares the `base` it reads on purpose: two spellings of "what did this + /// branch change" is the drift a single column exists to prevent. + /// + /// Declaration bounds WHICH paths are reported and deliberately does not bound + /// what answering costs — a glob is a selection over the whole tree, so the + /// walk happens either way. That is why [`crate::facts::BASE_DELTA`] is + /// `Surface::Check` and never `Hook`. + /// + /// A base that does not resolve leaves the whole fact `None`, projected as + /// `null`, never an empty delta: "this branch changed nothing" and "I could + /// not read the base" are the two answers a migration gate must keep apart. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub delta_sources: Vec, /// The registered policy module this rule evaluates, as a repository-relative /// path (CLOUD-647). [`RuleKind::Policy`] only. /// @@ -5265,22 +5295,38 @@ fn git_facts(rules: &[Rule], root: &Path) -> crate::git::GitFacts { let mut declared_refs: BTreeSet = BTreeSet::new(); let mut declared_ranges: BTreeSet = BTreeSet::new(); let mut declared_landings: BTreeSet = BTreeSet::new(); + // The delta is ONE object, so the rows declaring it must agree on the rev it + // is against. Collected as a set rather than taking the first: two rows + // naming different bases is a question with two answers, and silently + // answering one of them is how a gate reports about a comparison nobody + // asked for. Disagreement leaves the fact `None` — could-not-look, which is + // the honest shape and the one a predicate reads as undefined. + let mut declared_deltas: BTreeSet = BTreeSet::new(); + let mut delta_bases: BTreeSet<&str> = BTreeSet::new(); for rule in rules { declared_reads.extend(rule.git.iter().copied()); declared_refs.extend(rule.refs.iter().cloned()); declared_ranges.extend(rule.ranges.iter().cloned()); declared_landings.extend(rule.landing.iter().cloned()); + if !rule.delta_sources.is_empty() + && let Some(base) = rule.base.as_deref() + { + declared_deltas.extend(rule.delta_sources.iter().cloned()); + delta_bases.insert(base); + } } if declared_reads.is_empty() && declared_refs.is_empty() && declared_ranges.is_empty() && declared_landings.is_empty() + && declared_deltas.is_empty() { return crate::git::GitFacts::default(); } let refs: Vec = declared_refs.into_iter().collect(); let ranges: Vec = declared_ranges.into_iter().collect(); let landings: Vec = declared_landings.into_iter().collect(); + let deltas: Vec = declared_deltas.into_iter().collect(); crate::git::GitFacts { head: declared_reads .contains(&GitRead::Head) @@ -5303,6 +5349,14 @@ fn git_facts(rules: &[Rule], root: &Path) -> crate::git::GitFacts { landing: (!landings.is_empty()) .then(|| crate::git::landing_facts(root, &landings).ok()) .flatten(), + base_delta: match (delta_bases.len(), deltas.is_empty()) { + (1, false) => delta_bases + .iter() + .next() + .and_then(|base| crate::git::base_delta(root, base, &deltas).ok()) + .flatten(), + _ => None, + }, } } @@ -5619,6 +5673,13 @@ pub(crate) fn tree_document( .collect::>(), }), }, + // CLOUD-1059, and `null` here carries BOTH could-not-look conditions + // the family already collapses: no row declared a delta, and a row + // declared one whose base did not resolve. A migration gate reads the + // second as "I could not read the base" rather than "this branch + // changed nothing", which is the distinction the whole fact exists + // to keep. + crate::facts::Fact::BaseDelta => serde_json::json!(git.base_delta), crate::facts::Fact::Bypass | crate::facts::Fact::Receipts | crate::facts::Fact::Keys @@ -5708,14 +5769,30 @@ fn policy_rule( // document instead of being skipped: the module then decides over nothing // and whatever it says is a verdict about a tree it never read. Measured // here on a fixture repo with no Rust in it. + // + // `delta_sources` COUNTS TOO, and leaving it out was live the moment the + // column landed (CLOUD-1059). The delta is a git fact rather than a + // glob-selected document, so a row whose only resolved input is the delta + // read `selected_nothing` as true and skipped — and the case where that + // happens is precisely the one the row exists for: a migration that deletes + // the last `mise-tasks/*.sh` and writes no Rust successor leaves both line + // globs matching nothing, so the gate refusing an unmapped deletion is the + // gate the deletion switches off. Measured on the fixtures in + // `crates/batten/tests/shell_retirement.rs`. + // + // A resolved delta is what counts, not a non-empty one: an empty delta is + // the row having looked and found nothing changed, which IS establishing + // something. `None` is could-not-look and still skips. let selectors_declared = !rule.sources.is_empty() || !rule.line_sources.is_empty() || !rule.invocation_sources.is_empty() - || !rule.use_sources.is_empty(); + || !rule.use_sources.is_empty() + || !rule.delta_sources.is_empty(); let selected_nothing = declared.is_empty() && declared_line_paths.is_empty() && declared_invocation_paths.is_empty() - && declared_use_paths.is_empty(); + && declared_use_paths.is_empty() + && !(!rule.delta_sources.is_empty() && git.base_delta.is_some()); if selected_nothing && selectors_declared { return Some(NotObserved::RuleSkipped); } @@ -8895,6 +8972,7 @@ mod tests { refs: Vec::new(), ranges: Vec::new(), landing: Vec::new(), + delta_sources: Vec::new(), predicate_severity: None, criteria: None, tier: None, diff --git a/crates/batten/tests/cli.rs b/crates/batten/tests/cli.rs index 673188b4d..5323cb20a 100644 --- a/crates/batten/tests/cli.rs +++ b/crates/batten/tests/cli.rs @@ -5482,6 +5482,23 @@ fn the_committed_portability_rules_fire_on_every_banned_shape() { ) .expect("write fixture suite"); + // THE SEEDS ARE COMMITTED INTO THE BASE, for the reason the `# subject:` + // header above already records one rule earlier (CLOUD-1059). The committed + // `shell-retirement` row decides over the delta against `origin/main`, and + // `committed_config_fixture_git` pins that ref at an EMPTY commit — so a seed + // written afterwards is an authored shell rule this fixture ADDS, which that + // row refuses, and the refusal would grow a second rule's pointer into an + // assertion about portability. Committing them and re-pointing the ref makes + // the two files unchanged rather than added, so the delta is empty and this + // test keeps asserting the PORTABILITY rules and nothing else. + // The two seeds by name rather than `-A`: this fixture also carries an + // untracked `batten.toml` and a provision cache, and sweeping those into the + // index would move `Fact::Tracked` under every other committed rule — a + // wider blast radius than the one path this needs to change. + git_in(&dirty, &["add", "mise-tasks/seed.sh", "tests/seed.bats"]); + git_in(&dirty, &["commit", "-q", "-m", "seed"]); + git_in(&dirty, &["update-ref", "refs/remotes/origin/main", "HEAD"]); + // `enforce`, not `check`: the committed ruleset carries `no-conflict-markers`, // a kind that runs a configured command, and the read-effect verb refuses the // whole config rather than silently skipping that one row (exit 1, pinned by @@ -5539,6 +5556,15 @@ fn the_committed_portability_rules_fire_on_every_banned_shape() { ) .expect("write clean suite"); + // Committed into the base for the reason the dirty fixture above records + // (CLOUD-1059): otherwise these two seeds are files this fixture ADDS, and + // `shell-retirement` refuses an added authored shell rule — which would make + // a tree that is portable by construction exit 2 for a reason that has + // nothing to do with portability. + git_in(&clean, &["add", "mise-tasks/seed.sh", "tests/seed.bats"]); + git_in(&clean, &["commit", "-q", "-m", "seed"]); + git_in(&clean, &["update-ref", "refs/remotes/origin/main", "HEAD"]); + let output = batten() .arg("enforce") .current_dir(&clean) diff --git a/crates/batten/tests/facts.rs b/crates/batten/tests/facts.rs index 19dbaae81..e859413ff 100644 --- a/crates/batten/tests/facts.rs +++ b/crates/batten/tests/facts.rs @@ -34,9 +34,9 @@ fn a_content_block_envelope_unwraps_to_the_payload_a_bare_one_carries() { } use batten::facts::{ - AGENT_SOURCED, BYPASS, Class, Cost, DOCUMENT, Fact, GIT_HEAD, GIT_RANGE, GIT_REF, GIT_REMOTE, - GIT_STATUS, INVOCATIONS, KEYS, LANDING, LINES, Look, PRODUCED, PROSPECTIVE, RECEIPTS, STOP, - SYMBOLS, Surface, TRACKED, USES, WAIVED, + AGENT_SOURCED, BASE_DELTA, BYPASS, Class, Cost, DOCUMENT, Fact, GIT_HEAD, GIT_RANGE, GIT_REF, + GIT_REMOTE, GIT_STATUS, INVOCATIONS, KEYS, LANDING, LINES, Look, PRODUCED, PROSPECTIVE, + RECEIPTS, STOP, SYMBOLS, Surface, TRACKED, USES, WAIVED, }; #[test] @@ -129,6 +129,7 @@ fn every_fact_returns_its_stated_const() { Fact::Invocations => INVOCATIONS, Fact::Uses => USES, Fact::Symbols => SYMBOLS, + Fact::BaseDelta => BASE_DELTA, } }; diff --git a/crates/batten/tests/shell_retirement.rs b/crates/batten/tests/shell_retirement.rs new file mode 100644 index 000000000..5f6b89423 --- /dev/null +++ b/crates/batten/tests/shell_retirement.rs @@ -0,0 +1,382 @@ +//! `policy/shell-retirement.rego` decides over the compiled engine rather than +//! over a fabricated input (CLOUD-1059). +//! +//! **This is the tier the module's own `test_` rules cannot be.** A `with input +//! as` block writes the shape it then reads, so it is green over a key the engine +//! never fills — CLOUD-845's defect, and `.claude/rules/policy-modules.md` records +//! both live instances of it being found by adding this tier rather than by +//! reading. `Fact::BaseDelta` is brand new here, so "the schema says +//! `input.tree["base-delta"]` exists" is exactly the untested claim about the +//! engine that this file exists to test. +//! +//! And it is not merely a projection test: every fixture below builds a **real +//! repository with a real base ref**, so the delta is computed by +//! `git::base_delta` from two trees rather than handed in. A test that stubbed +//! the delta would prove the predicate and nothing about the fact. +//! +//! The module read here is the COMMITTED one, copied into each scratch tree +//! rather than restated inline — an inline copy would drift from the shipped +//! module and pass while the real gate was broken, which is the +//! two-authorities-that-drift defect the campaign is about. + +// Panicking on setup failure is the idiomatic way for a test to fail loudly. +#![allow(clippy::unwrap_used, clippy::expect_used)] + +mod common; + +use std::fs; +use std::path::{Path, PathBuf}; + +use batten::rules::{self, Rule}; + +/// The row as `batten.toml` declares it, deserialized rather than +/// struct-literalled: `Rule` carries `deny_unknown_fields`, so this goes through +/// the same column census a consumer's config does and a row the loader would +/// refuse cannot be smuggled in by hand. +fn row() -> Rule { + serde_json::from_value(serde_json::json!({ + "id": "shell-retirement", + "kind": "policy", + "scope": "tree", + "base": "origin/main", + "delta_sources": ["mise-tasks/**", "tests/**/*.bats"], + "line_sources": ["mise-tasks/*.sh", "crates/batten/tests/*.rs"], + "module": "policy/shell-retirement.rego", + "severity": "deny", + })) + .expect("the row batten.toml declares") +} + +/// A repository whose `origin/main` carries `base`, with `head` applied on top. +/// +/// `origin/main` is a real remote-tracking ref rather than a local branch, +/// because that is the name the committed row declares and a fixture that +/// resolved a different one would be testing a different question. +fn repo(name: &str, base: &[(&str, &str)], head: &Head<'_>) -> PathBuf { + let root = common::scratch(&format!("shell-retirement-{name}")); + common::git_in(&root, &["init", "--initial-branch=main"]); + write_all(&root, base); + install_module(&root); + common::git_in(&root, &["add", "-A"]); + common::git_in(&root, &["commit", "-m", "base"]); + // The remote-tracking ref the row names, pointed at the base commit. No + // remote is configured: `base_delta` resolves a rev, and a fetch would make + // the fixture depend on the network for a question that is local. + let base_sha = common::git_in(&root, &["rev-parse", "HEAD"]); + common::git_in( + &root, + &["update-ref", "refs/remotes/origin/main", &base_sha], + ); + + for path in head.removed { + fs::remove_file(root.join(path)).expect("remove at head"); + } + write_all(&root, head.written); + root +} + +/// What the working tree does to the base: files written, files removed. +struct Head<'a> { + written: &'a [(&'a str, &'a str)], + removed: &'a [&'a str], +} + +fn write_all(root: &Path, files: &[(&str, &str)]) { + for (path, body) in files { + let full = root.join(path); + if let Some(parent) = full.parent() { + fs::create_dir_all(parent).expect("scratch parent"); + } + fs::write(full, body).expect("write fixture file"); + } +} + +fn install_module(root: &Path) { + let source = common::at_root("policy/shell-retirement.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/shell-retirement.rego")).expect("install committed module"); +} + +fn scan(root: &Path) -> rules::Scan { + rules::run_static(&[row()], &[], &[], root).expect("the read surface runs a policy row") +} + +fn findings(root: &Path) -> Vec { + scan(root) + .findings + .into_iter() + .map(|finding| finding.rule) + .collect() +} + +const GATE: &str = "#!/usr/bin/env bash\n#MISE description=\"a gate\"\necho hi\n"; +const SUITE: &str = "# subject: mise-tasks/old-gate.sh\n@test \"it holds\" {\n true\n}\n"; + +/// A mapped retirement, spelled the way the ledger spells one. +fn ledger(retired: &str) -> String { + format!("// carried: {retired} policy/old-gate.rego crates/batten/tests/old_gate.rs\n") +} + +// --------------------------------------------------------------------------- +// The positive arm first: without it every refusal below is satisfied by a +// module that refuses everything. +// --------------------------------------------------------------------------- + +#[test] +fn a_deleted_and_fully_mapped_shell_rule_passes() { + let root = repo( + "mapped", + &[("mise-tasks/old-gate.sh", GATE)], + &Head { + written: &[( + "crates/batten/tests/old_gate.rs", + &ledger("mise-tasks/old-gate.sh"), + )], + removed: &["mise-tasks/old-gate.sh"], + }, + ); + assert!( + findings(&root).is_empty(), + "a conforming migration passes: {:?}", + findings(&root) + ); +} + +#[test] +fn an_untouched_tree_is_silent() { + let root = repo( + "untouched", + &[("mise-tasks/old-gate.sh", GATE)], + &Head { + written: &[], + removed: &[], + }, + ); + assert!( + findings(&root).is_empty(), + "nothing changed, nothing to say" + ); +} + +// --------------------------------------------------------------------------- +// The refusals, one fixture each. +// --------------------------------------------------------------------------- + +#[test] +fn an_added_shell_rule_is_refused() { + let root = repo( + "added", + &[], + &Head { + written: &[("mise-tasks/new-gate.sh", GATE)], + removed: &[], + }, + ); + assert_eq!(findings(&root), vec!["shell-rule-retired".to_owned()]); +} + +#[test] +fn an_added_bats_suite_is_refused() { + let root = repo( + "added-bats", + &[], + &Head { + written: &[("tests/new-gate.bats", SUITE)], + removed: &[], + }, + ); + assert_eq!(findings(&root), vec!["shell-rule-retired".to_owned()]); +} + +/// The load-bearing arm: an edit is invisible to every other sensor in the tree. +#[test] +fn a_shell_rule_edited_in_place_is_refused() { + let root = repo( + "edited", + &[("mise-tasks/old-gate.sh", GATE)], + &Head { + written: &[( + "mise-tasks/old-gate.sh", + "#!/usr/bin/env bash\n#MISE description=\"a gate\"\necho changed\n", + )], + removed: &[], + }, + ); + assert_eq!(findings(&root), vec!["shell-rule-retired".to_owned()]); +} + +#[test] +fn a_bats_suite_edited_in_place_is_refused() { + let root = repo( + "edited-bats", + &[("tests/old-gate.bats", SUITE)], + &Head { + written: &[( + "tests/old-gate.bats", + "# subject: mise-tasks/old-gate.sh\n@test \"it holds\" {\n false\n}\n", + )], + removed: &[], + }, + ); + assert_eq!(findings(&root), vec!["shell-rule-retired".to_owned()]); +} + +#[test] +fn a_deletion_with_no_mapping_is_refused() { + let root = repo( + "unmapped", + &[("mise-tasks/old-gate.sh", GATE)], + &Head { + written: &[], + removed: &["mise-tasks/old-gate.sh"], + }, + ); + assert_eq!(findings(&root), vec!["shell-rule-retired".to_owned()]); +} + +#[test] +fn a_deletion_carrying_two_arms_is_refused() { + let two = format!( + "// carried: mise-tasks/old-gate.sh policy/old-gate.rego crates/batten/tests/old_gate.rs\n\ + // subsumed: mise-tasks/old-gate.sh policy/other.rego crates/batten/tests/other.rs\n" + ); + let root = repo( + "two-arms", + &[("mise-tasks/old-gate.sh", GATE)], + &Head { + written: &[("crates/batten/tests/old_gate.rs", &two)], + removed: &["mise-tasks/old-gate.sh"], + }, + ); + assert_eq!(findings(&root), vec!["shell-rule-retired".to_owned()]); +} + +#[test] +fn a_mapping_naming_no_policy_surface_is_refused() { + let root = repo( + "no-surface", + &[("mise-tasks/old-gate.sh", GATE)], + &Head { + written: &[( + "crates/batten/tests/old_gate.rs", + "// carried: mise-tasks/old-gate.sh crates/batten/tests/old_gate.rs\n", + )], + removed: &["mise-tasks/old-gate.sh"], + }, + ); + assert_eq!(findings(&root), vec!["shell-rule-retired".to_owned()]); +} + +#[test] +fn a_mapping_naming_no_compiled_binary_test_is_refused() { + let root = repo( + "no-test", + &[("mise-tasks/old-gate.sh", GATE)], + &Head { + written: &[( + "crates/batten/tests/old_gate.rs", + "// carried: mise-tasks/old-gate.sh policy/old-gate.rego\n", + )], + removed: &["mise-tasks/old-gate.sh"], + }, + ); + assert_eq!(findings(&root), vec!["shell-rule-retired".to_owned()]); +} + +// --------------------------------------------------------------------------- +// The boundaries, which is where a gate nobody can keep green comes from. +// --------------------------------------------------------------------------- + +/// A generated artifact and a non-shell path under `mise-tasks/` are excluded BY +/// PATH, so regenerating completions is not a retirement obligation. +#[test] +fn generated_and_non_shell_paths_are_not_governed() { + let root = repo( + "generated", + &[("mise-tasks/replay-pointers.py", "print('x')\n")], + &Head { + written: &[ + ("completions/batten.bash", "# generated\n"), + ("mise-tasks/replay-pointers.py", "print('y')\n"), + ("crates/batten/src/lib.rs", "// code\n"), + ], + removed: &[], + }, + ); + assert!( + findings(&root).is_empty(), + "derived output is not an authored shell rule: {:?}", + findings(&root) + ); +} + +/// An untouched shell rule elsewhere in the tree does not affect the result — +/// the predicate decides over the CHANGED set, not over the corpus. +#[test] +fn an_untouched_shell_rule_elsewhere_does_not_fire() { + let root = repo( + "bystander", + &[ + ("mise-tasks/old-gate.sh", GATE), + ("mise-tasks/bystander.sh", GATE), + ], + &Head { + written: &[( + "crates/batten/tests/old_gate.rs", + &ledger("mise-tasks/old-gate.sh"), + )], + removed: &["mise-tasks/old-gate.sh"], + }, + ); + assert!( + findings(&root).is_empty(), + "only the changed set is judged: {:?}", + findings(&root) + ); +} + +/// A file under `mise-tasks/` carrying neither a shebang nor a `#MISE +/// description=` is not an authored shell rule on the head side. +#[test] +fn a_mise_tasks_file_that_is_not_a_shell_rule_is_not_governed_at_head() { + let root = repo( + "not-a-rule", + &[], + &Head { + written: &[("mise-tasks/notes.sh", "just some text\n")], + removed: &[], + }, + ); + assert!( + findings(&root).is_empty(), + "classification is the file's own first bytes: {:?}", + findings(&root) + ); +} + +/// The could-not-look arm, and it is the one a vacuous pass would hide. A base +/// that does not resolve must leave the fact `null` and every predicate +/// undefined — never an empty delta, which reads as a clean tree. +#[test] +fn an_unresolvable_base_reports_nothing_rather_than_clean() { + let root = common::scratch("shell-retirement-no-base"); + common::git_in(&root, &["init", "--initial-branch=main"]); + install_module(&root); + write_all(&root, &[("mise-tasks/new-gate.sh", GATE)]); + common::git_in(&root, &["add", "-A"]); + common::git_in(&root, &["commit", "-m", "only commit"]); + // No `refs/remotes/origin/main` was ever created, so the declared base does + // not resolve. An added shell rule is present and would fire if the delta + // had been fabricated as empty-but-present. + assert!( + findings(&root).is_empty(), + "could-not-look yields no finding: {:?}", + findings(&root) + ); + assert!( + scan(&root).findings.is_empty(), + "and it is silence rather than a clean verdict" + ); +} diff --git a/policy/shell-retirement.rego b/policy/shell-retirement.rego new file mode 100644 index 000000000..9432ebbbe --- /dev/null +++ b/policy/shell-retirement.rego @@ -0,0 +1,375 @@ +# A migration that touches an authored shell rule retires it, or it does not land. +# +# CLOUD-1059, and it is a PREREQUISITE rather than a cleanup: CLOUD-843's campaign +# is retiring 144 shell programs and 161 bats suites onto the policy engine, and +# the failure that campaign cannot survive is a wave that EDITS a shell gate +# instead of replacing it. That reads as progress in every sensor the tree has — +# `bash-surface-not-growing` counts programs and an edit changes none, and +# `bats-tests-not-deleted` counts `@test` cases and an edit changes none — so the +# corpus stays exactly as large while the campaign reports movement. +# +# THE LEDGER IS CLOUD-908'S, NOT A SECOND MANIFEST. `[rule.conserves]` already +# declares the arms (`// carried:`, `// subsumed:`, `// changed:`) and already +# says where they live (`crates/batten/tests/*.rs`). This module reads that same +# declaration one level up: 908 conserves the CASES inside a dying suite, and this +# conserves the FILE. A second inventory of which shell programs exist is exactly +# the hand-maintained census CLOUD-929 measured going stale, so there is none. +# +# ─── WHAT A RETIREMENT ROW LOOKS LIKE ──────────────────────────────────────── +# +# // carried: mise-tasks/prose-only-check.sh policy/prose-only.rego crates/batten/tests/prose_only.rs +# +# The marker, the path that died, then its successors. Two obligations, because +# either alone is satisfiable by a port that does nothing: at least one successor +# must be a POLICY SURFACE (`policy/*.rego` or `crates/batten/src/*.rs`), and at +# least one must be a COMPILED-BINARY TEST (`crates/batten/tests/*.rs`). A port +# naming only a module has no evidence it works; a port naming only a test has +# nothing under test. +# +# ─── THE THREE REFUSALS, AND WHY EDIT IS THE LOAD-BEARING ONE ──────────────── +# +# ADDED is the obvious one and the cheapest to satisfy honestly. EDITED is the one +# this module exists for: it is indistinguishable from ordinary maintenance to +# every other gate in the tree, and it is how a migration silently does not +# happen. DELETED-WITHOUT-A-MAPPING is CLOUD-908's shape at file granularity — +# the deletion is admitted, and nothing records what now holds the predicate. +# +# ─── WHAT THIS DOES NOT DECIDE, STATED RATHER THAN DISCOVERED ──────────────── +# +# **A deleted path is classified BY PATH, never by its content.** It has no +# content at head — that is what deleted means — and reading the base tree to +# re-classify it would make the fact carry the bytes of a file the diff already +# says is gone. So `mise-tasks/**` minus the explicitly non-shell paths is the +# test on the deleted side, and a shebang/`#MISE description=` test on the added +# and edited sides where the lines exist. The asymmetry is deliberate and it errs +# toward DEMANDING a mapping, which is the safe direction: the cost of a wrong +# refusal is one ledger row, and the cost of a wrong pass is an unrecorded port. +# +# **Whether the successor actually holds the predicate is not decidable here.** +# That a named module exists and a named test exists is checkable; that the test +# discriminates is CLOUD-1020's question and `mutant`'s, and a gate over it would +# be the model verdict non-negotiable rule 3 forbids. +# +# **Generated artifacts are excluded by path and never judged.** Batten's own +# completions are derived output regenerated by `mise run completions`; treating +# one as an authored shell rule would refuse every change that regenerates them. +# +#MUTANT shell-added-unchecked|s@delta.added@[]@|an added authored shell rule is refused +#MUTANT shell-edited-unchecked|s@delta.edited@[]@|an edited-in-place shell rule is refused +#MUTANT shell-deletion-unmapped|s@count(arms_for(path)) == 1@true@|a deleted shell rule with no mapping is refused +#MUTANT shell-mapping-not-unique|s@count(arms_for(path)) == 1@count(arms_for(path)) > 0@|a deleted rule carrying two arms is refused +#MUTANT shell-successor-unproven|s@has_binary_test(path)@true@|a mapping naming no compiled-binary test is refused +# +#MUTANT-EXEMPT CLOUD-931|no `tests/shell-retirement.bats` exists and none may: this row's whole subject is that a migration ships no new bats suite, so a suite named for it would be the thing it refuses. `mutant` resolves a gate's suite as `tests/$gate.bats`, so there is no named case a mutation could turn red. The second tier is `crates/batten/tests/shell_retirement.rs`, which drives the compiled binary and is the stronger evidence + +# METADATA +# description: | +# Bound to the TREE surface: `scope = "tree"`, so it reads the tree document +# and never the mediated `{call, facts}` shape. +# THE BRACKETS ARE NOT STYLE: the schema file carries a hyphen, so the dotted +# form is a parse error reported as `invalid schema reference`. +# THIS BLOCK IS YAML AND MUST STAY THE LAST COMMENT BLOCK BEFORE `package`. +# schemas: +# - input: schema["policy-input.schema"] +package batten.shell_retirement + +import rego.v1 + +rules contains "shell-rule-retired" + +# --------------------------------------------------------------------------- +# The changed-file set, and the could-not-look channel. +# --------------------------------------------------------------------------- + +# `input.tree.base-delta` is NULL when the base rev did not resolve, and `null` +# is not `undefined` — `not input.tree["base-delta"]` would be false for it, the +# exact slip CLOUD-701's review caught in `spawn-adapters`. So the delta is bound +# through a rule that holds only for an object, and every predicate below is +# undefined without it rather than vacuously clean. +delta := d if { + d := input.tree["base-delta"] + is_object(d) +} + +# --------------------------------------------------------------------------- +# Which changed paths are the ones this row governs. +# --------------------------------------------------------------------------- + +# A bats suite, on either side of the diff: the extension IS the declaration. +is_bats(path) if { + startswith(path, "tests/") + endswith(path, ".bats") +} + +# Under `mise-tasks/`, excluding the paths that are not shell at all. Listed +# rather than pattern-matched because the set is small, closed, and a wrong +# guess here silently drops a program out of the campaign's scope. +under_mise_tasks(path) if { + startswith(path, "mise-tasks/") + not endswith(path, ".py") + not endswith(path, ".tsv") +} + +# On the added and edited sides the file exists, so the classification is its +# own first bytes rather than its path. +authored_shell(path) if { + under_mise_tasks(path) + some line in input.tree.lines[path] + startswith(line, "#MISE description=") +} + +authored_shell(path) if { + under_mise_tasks(path) + some line in input.tree.lines[path] + startswith(line, "#!") +} + +# The governed set on the head side. +governed_at_head(path) if authored_shell(path) + +governed_at_head(path) if is_bats(path) + +# The governed set on the deleted side, where there is nothing to read. +governed_when_deleted(path) if under_mise_tasks(path) + +governed_when_deleted(path) if is_bats(path) + +# --------------------------------------------------------------------------- +# A: a migration does not ADD an authored shell rule or a bats suite. +# --------------------------------------------------------------------------- + +violation contains { + "rule": "shell-rule-retired", + "msg": sprintf( + "%s is an authored shell rule or bats suite ADDED by this change. CLOUD-843's campaign is retiring this corpus onto the policy engine, so a new one moves the count the wrong way and nothing else in the tree refuses it: `bash-surface-not-growing` admits it with a `# stays-bash:` declaration and no gate asks whether a Rego surface could have carried it. Write the predicate as a policy module with a compiled-binary test, or declare it stays bash on its own row", + [path], + ), +} if { + some path in delta.added + governed_at_head(path) +} + +# --------------------------------------------------------------------------- +# B: a migration does not EDIT one in place. The load-bearing arm. +# --------------------------------------------------------------------------- + +violation contains { + "rule": "shell-rule-retired", + "msg": sprintf( + "%s is an authored shell rule or bats suite this change EDITED IN PLACE. A migration replaces a shell gate, it does not maintain one -- and an edit is invisible to every other sensor here, because `bash-surface-not-growing` counts programs and `bats-tests-not-deleted` counts cases, and an edit changes neither. Port the predicate to a policy module or Rust surface, delete this file, and record the successor on a `// carried:`, `// subsumed:` or `// changed:` row", + [path], + ), +} if { + some path in delta.edited + governed_at_head(path) +} + +# --------------------------------------------------------------------------- +# C: a DELETION owes exactly one mapping, and that mapping owes two successors. +# --------------------------------------------------------------------------- + +violation contains { + "rule": "shell-rule-retired", + "msg": sprintf( + "%s was DELETED by this change and the retirement ledger records no successor for it, so nothing in the tree names what now holds its predicate. That is CLOUD-908's defect at file granularity: the deletion is admitted and the logic evaporates. Add exactly one `// carried:`, `// subsumed:` or `// changed:` row naming this path under `crates/batten/tests/*.rs`", + [path], + ), +} if { + some path in delta.deleted + governed_when_deleted(path) + count(arms_for(path)) == 0 +} + +violation contains { + "rule": "shell-rule-retired", + "msg": sprintf( + "%s was deleted and carries MORE THAN ONE retirement arm. `carried`, `subsumed` and `changed` are three different claims about where the logic went -- preserved, absorbed by a general property, or deliberately diverged -- and a path asserting two of them has recorded no answer at all. Keep the one that is true", + [path], + ), +} if { + some path in delta.deleted + governed_when_deleted(path) + count(arms_for(path)) > 1 +} + +violation contains { + "rule": "shell-rule-retired", + "msg": sprintf( + "%s was deleted and its retirement arm names no POLICY SURFACE -- no `policy/*.rego` and no `crates/batten/src/*.rs`. A mapping that names only a test records where the assertions went and not where the predicate went, so the gate it replaced has no successor anything can run", + [path], + ), +} if { + some path in delta.deleted + governed_when_deleted(path) + count(arms_for(path)) == 1 + not has_policy_surface(path) +} + +violation contains { + "rule": "shell-rule-retired", + "msg": sprintf( + "%s was deleted and its retirement arm names no COMPILED-BINARY TEST under `crates/batten/tests/*.rs`. A module's own `test_` rules are the load-time tier and they fabricate their own input, so they cannot prove the engine builds the shape the predicate reads -- which is the tier `.claude/rules/policy-modules.md` records both live instances of that defect being found by", + [path], + ), +} if { + some path in delta.deleted + governed_when_deleted(path) + count(arms_for(path)) == 1 + not has_binary_test(path) +} + +# --------------------------------------------------------------------------- +# The ledger, read out of the declared lines. +# --------------------------------------------------------------------------- + +# The three arms, spelled here exactly as `[rule.conserves]` spells them. They +# are duplicated across two authorities and that is a known seam rather than an +# oversight: `rules-drift` holds this list to the TOML, so a rename that touched +# one and not the other is a finding rather than a silently dead predicate. +arm_markers := ["// carried:", "// subsumed:", "// changed:"] + +# Every ledger row naming `path`, as ` ...`. A row is +# matched on the path being the FIRST field after the marker, so a successor path +# that happens to equal another retired file's name does not claim its mapping. +arms_for(path) := rows if { + rows := {row | + some file, lines in input.tree.lines + startswith(file, "crates/batten/tests/") + some line in lines + some marker in arm_markers + trimmed := trim_space(line) + startswith(trimmed, marker) + fields := split(trim_space(substring(trimmed, count(marker), -1)), " ") + fields[0] == path + row := trimmed + } +} + +# The successors named on `path`'s one arm, as the fields after the retired path. +successors_for(path) := names if { + names := {name | + some row in arms_for(path) + some marker in arm_markers + startswith(row, marker) + fields := split(trim_space(substring(row, count(marker), -1)), " ") + some i, name in fields + i > 0 + name != "" + } +} + +has_policy_surface(path) if { + some name in successors_for(path) + startswith(name, "policy/") + endswith(name, ".rego") +} + +has_policy_surface(path) if { + some name in successors_for(path) + startswith(name, "crates/batten/src/") + endswith(name, ".rs") +} + +has_binary_test(path) if { + some name in successors_for(path) + startswith(name, "crates/batten/tests/") + endswith(name, ".rs") +} + +# NO `deny` RULE, deliberately. The engine reads both names, and they carry +# different shapes: `deny` is a set of bare STRINGS attributed to the enabling +# row, `violation` a set of objects carrying their own `rule` id. Publishing the +# objects below under `deny` too would put an object on the string channel, and +# every other module in this corpus declares `violation` alone for that reason. +# +# --------------------------------------------------------------------------- +# Load-time tier. These pin the predicate; `crates/batten/tests/shell_ +# retirement.rs` is the tier that proves the ENGINE builds the shape they read. +# --------------------------------------------------------------------------- + +test_added_shell_rule_is_refused if { + count(violation) == 1 with input as {"tree": { + "base-delta": {"added": ["mise-tasks/new-gate.sh"], "edited": [], "deleted": []}, + "lines": {"mise-tasks/new-gate.sh": ["#!/usr/bin/env bash", "#MISE description=\"x\""]}, + }} +} + +test_added_bats_suite_is_refused if { + count(violation) == 1 with input as {"tree": { + "base-delta": {"added": ["tests/new-gate.bats"], "edited": [], "deleted": []}, + "lines": {}, + }} +} + +test_edited_shell_rule_is_refused if { + count(violation) == 1 with input as {"tree": { + "base-delta": {"added": [], "edited": ["mise-tasks/old-gate.sh"], "deleted": []}, + "lines": {"mise-tasks/old-gate.sh": ["#MISE description=\"x\""]}, + }} +} + +test_deleted_and_fully_mapped_passes if { + count(violation) == 0 with input as {"tree": { + "base-delta": {"added": [], "edited": [], "deleted": ["mise-tasks/old-gate.sh"]}, + "lines": {"crates/batten/tests/old_gate.rs": ["// carried: mise-tasks/old-gate.sh policy/old-gate.rego crates/batten/tests/old_gate.rs"]}, + }} +} + +test_deleted_without_a_mapping_is_refused if { + count(violation) == 1 with input as {"tree": { + "base-delta": {"added": [], "edited": [], "deleted": ["mise-tasks/old-gate.sh"]}, + "lines": {}, + }} +} + +test_deleted_with_two_arms_is_refused if { + count(violation) == 1 with input as {"tree": { + "base-delta": {"added": [], "edited": [], "deleted": ["mise-tasks/old-gate.sh"]}, + "lines": {"crates/batten/tests/old_gate.rs": [ + "// carried: mise-tasks/old-gate.sh policy/old-gate.rego crates/batten/tests/old_gate.rs", + "// subsumed: mise-tasks/old-gate.sh policy/other.rego crates/batten/tests/other.rs", + ]}, + }} +} + +test_mapping_without_a_policy_surface_is_refused if { + count(violation) == 1 with input as {"tree": { + "base-delta": {"added": [], "edited": [], "deleted": ["mise-tasks/old-gate.sh"]}, + "lines": {"crates/batten/tests/old_gate.rs": ["// carried: mise-tasks/old-gate.sh crates/batten/tests/old_gate.rs"]}, + }} +} + +test_mapping_without_a_binary_test_is_refused if { + count(violation) == 1 with input as {"tree": { + "base-delta": {"added": [], "edited": [], "deleted": ["mise-tasks/old-gate.sh"]}, + "lines": {"crates/batten/tests/old_gate.rs": ["// carried: mise-tasks/old-gate.sh policy/old-gate.rego"]}, + }} +} + +# The anti-vacuity arm on the OTHER axis: an untouched tree, and a generated or +# non-shell path moving, must both be silent. Without this a module that refused +# everything would still pass every case above. +test_an_untouched_tree_is_silent if { + count(violation) == 0 with input as {"tree": { + "base-delta": {"added": [], "edited": [], "deleted": []}, + "lines": {}, + }} +} + +test_a_non_shell_path_is_not_governed if { + count(violation) == 0 with input as {"tree": { + "base-delta": { + "added": ["completions/batten.bash"], + "edited": ["mise-tasks/replay-pointers.py", "crates/batten/src/lib.rs"], + "deleted": ["mise-tasks/sbom-actions.tsv"], + }, + "lines": {}, + }} +} + +# A base that did not resolve is could-not-look: no findings, and deliberately +# not the same answer as a clean tree, because the engine emits `null` there and +# every predicate above is undefined rather than false. +test_an_unresolvable_base_reports_nothing if { + count(violation) == 0 with input as {"tree": {"base-delta": null, "lines": {}}} +} diff --git a/schema/batten.local.schema.json b/schema/batten.local.schema.json index 8c5569a6d..fc19859fe 100644 --- a/schema/batten.local.schema.json +++ b/schema/batten.local.schema.json @@ -518,6 +518,13 @@ "null" ] }, + "delta_sources": { + "description": "The globs whose paths this policy row wants classified against\n[`Rule::base`] — added, edited or deleted (CLOUD-1059).\n\n**Requires `base`**, and the load refuses the column without it, because a\ndelta with no rev to compare against is not a narrower question but an\nunanswerable one. That is [`Rule::retires_with`]'s rule, and this column\nshares the `base` it reads on purpose: two spellings of \"what did this\nbranch change\" is the drift a single column exists to prevent.\n\nDeclaration bounds WHICH paths are reported and deliberately does not bound\nwhat answering costs — a glob is a selection over the whole tree, so the\nwalk happens either way. That is why [`crate::facts::BASE_DELTA`] is\n`Surface::Check` and never `Hook`.\n\nA base that does not resolve leaves the whole fact `None`, projected as\n`null`, never an empty delta: \"this branch changed nothing\" and \"I could\nnot read the base\" are the two answers a migration gate must keep apart.", + "type": "array", + "items": { + "type": "string" + } + }, "derives": { "description": "The name this rule publishes its derived value under, for another rule to\nread (CLOUD-773). [`RuleKind::Document`] only.\n\n**A value, not a verdict.** Composition already exists in the layer this\nengine is absorbing: 57 of 126 tasks invoke a sibling and branch on its\nexit code. That channel carries three states and nothing else, so a\nconsumer that needs the producer's *structure* re-derives it — measured,\n`graph-check` spawns `ready-lint` and then re-spells the issue-key regex\nthree times. The re-derivation is caused by values not crossing the\nboundary, which is what this column is for.\n\nNames are global to the config and unique: two rows deriving one name is\nrefused at load, because \"which one did I read\" is not a question a\nreviewer should have to answer.", "type": [ diff --git a/schema/batten.schema.json b/schema/batten.schema.json index 8c6412608..ba299a2b2 100644 --- a/schema/batten.schema.json +++ b/schema/batten.schema.json @@ -1645,6 +1645,13 @@ "null" ] }, + "delta_sources": { + "description": "The globs whose paths this policy row wants classified against\n[`Rule::base`] — added, edited or deleted (CLOUD-1059).\n\n**Requires `base`**, and the load refuses the column without it, because a\ndelta with no rev to compare against is not a narrower question but an\nunanswerable one. That is [`Rule::retires_with`]'s rule, and this column\nshares the `base` it reads on purpose: two spellings of \"what did this\nbranch change\" is the drift a single column exists to prevent.\n\nDeclaration bounds WHICH paths are reported and deliberately does not bound\nwhat answering costs — a glob is a selection over the whole tree, so the\nwalk happens either way. That is why [`crate::facts::BASE_DELTA`] is\n`Surface::Check` and never `Hook`.\n\nA base that does not resolve leaves the whole fact `None`, projected as\n`null`, never an empty delta: \"this branch changed nothing\" and \"I could\nnot read the base\" are the two answers a migration gate must keep apart.", + "type": "array", + "items": { + "type": "string" + } + }, "derives": { "description": "The name this rule publishes its derived value under, for another rule to\nread (CLOUD-773). [`RuleKind::Document`] only.\n\n**A value, not a verdict.** Composition already exists in the layer this\nengine is absorbing: 57 of 126 tasks invoke a sibling and branch on its\nexit code. That channel carries three states and nothing else, so a\nconsumer that needs the producer's *structure* re-derives it — measured,\n`graph-check` spawns `ready-lint` and then re-spells the issue-key regex\nthree times. The re-derivation is caused by values not crossing the\nboundary, which is what this column is for.\n\nNames are global to the config and unique: two rows deriving one name is\nrefused at load, because \"which one did I read\" is not a question a\nreviewer should have to answer.", "type": [ diff --git a/schema/policy-input.schema.json b/schema/policy-input.schema.json index 98529ac06..9c9c98f16 100644 --- a/schema/policy-input.schema.json +++ b/schema/policy-input.schema.json @@ -7,6 +7,34 @@ "additionalProperties": false, "description": "The tree surface. `additionalProperties: false` is the load-bearing line: it is what turns a misspelled key, and CLOUD-845's documented-but-never-built one, into a build-time error instead of a silent undefined.", "properties": { + "base-delta": { + "additionalProperties": false, + "description": "Fact::BaseDelta (CLOUD-1059). How the declared globs' paths differ from the declared base rev: `added` present now and not at base, `edited` present in both with different content, `deleted` present at base and not now. Repo-relative paths only -- never a hunk and never a line, non-negotiable rule 4. NULL when the base rev does not resolve, never an empty delta: `this branch changed nothing` and `I could not read the base` are the two answers a migration gate must keep apart, and a fabricated empty set passes the gate on ignorance.", + "properties": { + "added": { + "items": { + "type": "string" + }, + "type": "array" + }, + "deleted": { + "items": { + "type": "string" + }, + "type": "array" + }, + "edited": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "type": [ + "object", + "null" + ] + }, "documents": { "additionalProperties": true, "description": "Fact::Document. Path -> the parsed node. Contents are arbitrary consumer TOML/YAML/JSON, so values are deliberately unconstrained; the schema's job here is the key set one level up, not the shape of somebody else's config.", From 7f35fa22defc75859953ee311230cb385a0ae589 Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Tue, 25 Aug 2026 17:51:28 +0000 Subject: [PATCH 02/38] style(policy): the two lints the shell-retirement row landed with `nonminimal_bool` over the skip guard's delta term and `useless_format` over a fixture's two-arm ledger. Both are what `mise run fix` reported on the commit that introduced them; neither changes what the row decides, and the guard's negated conjunction is now a named binding, which is the readable half of what clippy was objecting to. Refs: CLOUD-1059 --- crates/batten/src/rules.rs | 3 ++- crates/batten/tests/shell_retirement.rs | 8 ++++---- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/crates/batten/src/rules.rs b/crates/batten/src/rules.rs index b99be1490..9b0eb2dce 100644 --- a/crates/batten/src/rules.rs +++ b/crates/batten/src/rules.rs @@ -5788,11 +5788,12 @@ fn policy_rule( || !rule.invocation_sources.is_empty() || !rule.use_sources.is_empty() || !rule.delta_sources.is_empty(); + let delta_resolved = !rule.delta_sources.is_empty() && git.base_delta.is_some(); let selected_nothing = declared.is_empty() && declared_line_paths.is_empty() && declared_invocation_paths.is_empty() && declared_use_paths.is_empty() - && !(!rule.delta_sources.is_empty() && git.base_delta.is_some()); + && !delta_resolved; if selected_nothing && selectors_declared { return Some(NotObserved::RuleSkipped); } diff --git a/crates/batten/tests/shell_retirement.rs b/crates/batten/tests/shell_retirement.rs index 5f6b89423..4887a3bf7 100644 --- a/crates/batten/tests/shell_retirement.rs +++ b/crates/batten/tests/shell_retirement.rs @@ -238,15 +238,15 @@ fn a_deletion_with_no_mapping_is_refused() { #[test] fn a_deletion_carrying_two_arms_is_refused() { - let two = format!( - "// carried: mise-tasks/old-gate.sh policy/old-gate.rego crates/batten/tests/old_gate.rs\n\ - // subsumed: mise-tasks/old-gate.sh policy/other.rego crates/batten/tests/other.rs\n" + let two = concat!( + "// carried: mise-tasks/old-gate.sh policy/old-gate.rego crates/batten/tests/old_gate.rs\n", + "// subsumed: mise-tasks/old-gate.sh policy/other.rego crates/batten/tests/other.rs\n", ); let root = repo( "two-arms", &[("mise-tasks/old-gate.sh", GATE)], &Head { - written: &[("crates/batten/tests/old_gate.rs", &two)], + written: &[("crates/batten/tests/old_gate.rs", two)], removed: &["mise-tasks/old-gate.sh"], }, ); From bbd4a0c5be77504aecaa4c7b23ccccd770800f3c Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Tue, 25 Aug 2026 17:53:05 +0000 Subject: [PATCH 03/38] feat(transcript): the host's rule-injection event, and the census over it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CLOUD-1054 asked one question — does this host expose a stable, source-bearing event when it delivers a `.claude/rules/*.md` document — and the answer is yes: {"type":"attachment","attachment":{ "type":"nested_memory", "displayPath":".claude/rules/scanning.md", "content":{"type":"Project", ...}}} Two host-set typed fields, a tag and a source path, so nothing here reads message prose. `Event::MemoryInjection { path }` is emitted only when BOTH are present: the tag alone yields no event rather than an injection with an invented source. THE FIRST PROBE OF THIS SESSION SAID THE OPPOSITE, and that is why the fixture is captured rather than asserted. It was taken at 158 records, before any trigger had fired; at 1,915 there were six. An absence measured on too small a sample is indistinguishable from a capability that is not there, so the negative control is a real committed transcript too — prose naming a rules file, unrelated attachment types, and a `nested_memory` whose `displayPath` is null. That reverses CLOUD-1052's conditional: the census is buildable, so it ships. `Stream::memory_injections()` counts per resolved path, keyed lexicographically so a script reading it gets the same bytes twice (§6), and `reports_memory_injections()` is the discriminator that makes a zero readable — a zero from a host that DOES report is a fact about the session, and without the pair it is indistinguishable from an unsupported host. The measured census refutes the row's own estimate with a number: CLOUD-1052 guessed `toolchain.md` arrived "six or more times"; it arrived once, and `scanning.md` is the repeat offender at three. Six injections, 89,469 bytes. One bound stated rather than glossed: `nested_memory` is the host's mechanism for nested memory files generally, not solely `.claude/rules/*.md`. The census counts per resolved path and reports that; it does not claim the event is rules-specific. Rule 4 at the fixture: `content.content` and `rawContent` carry the delivered document in full — the richest payload this engine can be pointed at — and both are replaced with a fixed marker. The envelope, the tag and the source path survive, because those are the fields the predicate reads, and a test asserts the marker is in the file and never in a parsed record. Refs: CLOUD-1054, CLOUD-1052 --- crates/batten/src/bypass.rs | 5 +- crates/batten/src/completion.rs | 9 +- crates/batten/src/selfwrite.rs | 6 +- crates/batten/src/transcript.rs | 119 +++++++++++ .../memory-injection-unsupported.jsonl.in | 5 + .../transcripts/memory-injection.jsonl.in | 8 + crates/batten/tests/memory_injection.rs | 202 ++++++++++++++++++ 7 files changed, 351 insertions(+), 3 deletions(-) create mode 100644 crates/batten/tests/fixtures/transcripts/memory-injection-unsupported.jsonl.in create mode 100644 crates/batten/tests/fixtures/transcripts/memory-injection.jsonl.in create mode 100644 crates/batten/tests/memory_injection.rs diff --git a/crates/batten/src/bypass.rs b/crates/batten/src/bypass.rs index 598b92bfd..531ccd886 100644 --- a/crates/batten/src/bypass.rs +++ b/crates/batten/src/bypass.rs @@ -276,7 +276,10 @@ pub fn scan(stream: &Stream) -> Vec { Event::Turn(..) | Event::TurnEnd(_) | Event::ToolResult { .. } - | Event::HookDecision { .. } => {} + // Not a tool call and not a retry: an injection carries no + // enforcement posture at all (CLOUD-1054). + | Event::HookDecision { .. } + | Event::MemoryInjection { .. } => {} } } found.into_values().collect() diff --git a/crates/batten/src/completion.rs b/crates/batten/src/completion.rs index 43daf1106..7d018a316 100644 --- a/crates/batten/src/completion.rs +++ b/crates/batten/src/completion.rs @@ -259,7 +259,14 @@ pub fn signal(stream: &Stream) -> Option { // is the correct three-valued answer — "could not look", not // "declared done" — and it is strictly better than a constant, which // is what the old arm produced. - Event::HookDecision { .. } | Event::ToolResult { .. } => {} + // A DELIVERED MEMORY DOCUMENT IS MACHINERY, never the model + // speaking (CLOUD-1054), so it neither mints a claim nor retracts + // one — the same reading this arm already gives a hook run, and for + // the same reason. The host decided to inject it; nobody asserted + // anything about being done. + Event::HookDecision { .. } + | Event::ToolResult { .. } + | Event::MemoryInjection { .. } => {} } } latest diff --git a/crates/batten/src/selfwrite.rs b/crates/batten/src/selfwrite.rs index 586af0659..6808cc3d8 100644 --- a/crates/batten/src/selfwrite.rs +++ b/crates/batten/src/selfwrite.rs @@ -193,7 +193,11 @@ pub fn scan(stream: &Stream, memory_root: &str) -> Vec { Event::Turn(..) | Event::TurnEnd(_) | Event::ToolResult { .. } - | Event::HookDecision { .. } => {} + // An injection is the host delivering a document INTO context, not + // the agent writing one out (CLOUD-1054) — the opposite direction + // from the self-write this module detects. + | Event::HookDecision { .. } + | Event::MemoryInjection { .. } => {} } } detections diff --git a/crates/batten/src/transcript.rs b/crates/batten/src/transcript.rs index 5c4ee072d..7c61a841f 100644 --- a/crates/batten/src/transcript.rs +++ b/crates/batten/src/transcript.rs @@ -264,6 +264,33 @@ pub enum Event { /// The code the hook returned; [`DENY_EXIT`] is a refusal. exit_code: i64, }, + /// The host delivered a memory document into the context, naming its source + /// (CLOUD-1054). + /// + /// **APPENDED, never inserted.** A consumer that captured an ordinal must not + /// find it means something else, which is why this sits at the end of the + /// vocabulary rather than beside the other attachment-borne variant. + /// + /// **The source is a typed host field, never inferred from message prose.** + /// That is the whole of CLOUD-1054's question, and the distinction is + /// load-bearing: an assistant turn that merely *mentions* a rules path is not + /// an injection, and a predicate that pattern-matched text could not tell the + /// two apart. Measured on a captured transcript, the host records + /// `attachment.type = "nested_memory"` carrying `displayPath` — so this + /// variant exists because a field exists, not because a phrase does. + /// + /// **It is `nested_memory`, which is wider than the rules corpus**, and the + /// name here says `Memory` rather than `Rule` for that reason. The host uses + /// one record for every nested memory document it delivers; which of them are + /// `.claude/rules/*.md` is the CONSUMER's question, answered by the path, and + /// deciding it here would make the vocabulary claim a scope the host does not. + MemoryInjection { + /// The document's path as the host reported it, repo-relative where the + /// host gave one. A pointer, never the document — the delivered body is + /// exactly the payload rule 4 keeps off every channel, and it is dropped + /// at the parse rather than carried and filtered later. + path: String, + }, } /// One event and where it was found. @@ -398,6 +425,10 @@ impl Capability { impl Stream { /// Count the stream, which is all a caller may render. #[must_use] + #[expect( + clippy::match_same_arms, + reason = "two arms count nothing and they are not the same decision: a turn-end reason is a predicate's input, an injection belongs to a different document with a different reader. Merging them would put one comment over two unrelated reasons, which is what the next reader would have to un-merge to change either" + )] pub fn counts(&self) -> Counts { let mut counts = Counts::default(); for record in &self.records { @@ -421,10 +452,62 @@ impl Stream { counts.hook_denials += 1; } } + // Counted by nothing here, for `TurnEnd`'s reason above: + // `Counts` is the capability report's shape, and a per-file + // injection tally is a different document with a different + // reader. It is [`Stream::memory_injections`]' answer, and a + // scalar in this struct would be the total without the breakdown + // — the half nobody asked for (CLOUD-1054). + Event::MemoryInjection { .. } => {} } } counts } + + /// How many times each memory document was delivered into this session + /// (CLOUD-1052). + /// + /// **The count is the whole answer, and the byte volume is deliberately not + /// here.** CLOUD-415 owns the token sensor and this row does not wait for it; + /// a byte total would read as a cost and bytes are not tokens, so reporting + /// one here would be the unverified conversion CLOUD-732 refuses. + /// + /// Keyed by the host's own source path, so the breakdown is per document + /// rather than a bare total: measured, one session's six injections were + /// three of one file and one of another, and the total alone hides which + /// document is actually repeating. + /// + /// `BTreeMap` for §6 byte-stability — lexicographic by path, whatever order + /// the host wrote them in. + #[must_use] + pub fn memory_injections(&self) -> std::collections::BTreeMap { + let mut census = std::collections::BTreeMap::new(); + for record in &self.records { + if let Event::MemoryInjection { path } = &record.event { + *census.entry(path.clone()).or_insert(0) += 1; + } + } + census + } + + /// Whether this host reports memory injections at all (CLOUD-1052). + /// + /// **The positive discriminator, and the reason a zero is readable.** Without + /// it, "no injections in this session" and "this host never reports one" are + /// the same empty census, which is precisely the is-not versus could-not-look + /// confusion the exit contract exists to keep apart. A host that emits the + /// record type at all — even once, for any document — has demonstrated the + /// capability; from then on an empty census is a fact about the session. + /// + /// It is the presence of the EVENT, never a version check: a host string + /// would be a claim about a roster this module does not maintain, and would + /// go stale the first time a host renamed itself. + #[must_use] + pub fn reports_memory_injections(&self) -> bool { + self.records + .iter() + .any(|record| matches!(record.event, Event::MemoryInjection { .. })) + } } /// Resolve the capability for a configured path, relative to `root`. @@ -556,6 +639,20 @@ fn collect(parsed: &Line, line: usize, records: &mut Vec) { }, }); } + // BOTH typed fields, or no event (CLOUD-1054). The tag alone says a + // memory document was delivered and not which one, and a record naming + // no source is exactly what must not be counted as an injection — that + // is the difference between a census and a guess. An attachment carrying + // the tag without a path is therefore skipped rather than recorded with + // an invented source. + if attachment.kind.as_deref() == Some(NESTED_MEMORY) + && let Some(path) = &attachment.display_path + { + records.push(Record { + line, + event: Event::MemoryInjection { path: path.clone() }, + }); + } return; } let Some(message) = &parsed.message else { @@ -720,6 +817,21 @@ struct Message { #[derive(Debug, Deserialize)] struct Attachment { + /// The host's own tag for what this attachment is (CLOUD-1054). + /// + /// Captured, 2026-08-25: fourteen distinct values in one session, of which + /// exactly one — [`NESTED_MEMORY`] — names a memory document being delivered. + /// The rest mean nothing to any predicate here and yield no event, which is + /// the forward-compatibility posture the module header states. + #[serde(rename = "type")] + kind: Option, + /// Where the delivered document came from, repo-relative. + /// + /// The host also records an absolute `path`; this is the one taken, because a + /// finding must not carry a container's directory layout and a repo-relative + /// path is the pointer every other surface here uses. + #[serde(rename = "displayPath")] + display_path: Option, #[serde(rename = "hookEvent")] hook_event: Option, #[serde(rename = "toolUseID")] @@ -728,6 +840,13 @@ struct Attachment { exit_code: Option, } +/// The host's tag for a delivered memory document (CLOUD-1054). +/// +/// Named rather than spelled inline at the match, for [`DENY_EXIT`]'s reason: it +/// is a host vocabulary token this module adapts, and the one place it appears is +/// the one place that has to change if a host renames it. +const NESTED_MEMORY: &str = "nested_memory"; + #[derive(Debug, Deserialize)] struct Block { #[serde(rename = "type")] diff --git a/crates/batten/tests/fixtures/transcripts/memory-injection-unsupported.jsonl.in b/crates/batten/tests/fixtures/transcripts/memory-injection-unsupported.jsonl.in new file mode 100644 index 000000000..f8e7c8c60 --- /dev/null +++ b/crates/batten/tests/fixtures/transcripts/memory-injection-unsupported.jsonl.in @@ -0,0 +1,5 @@ +{"type":"user","sessionId":"s-fixture","cwd":"/w","message":{"role":"user","content":"a plain string turn"}} +{"type":"assistant","sessionId":"s-fixture","message":{"role":"assistant","content":[{"type":"text","text":"ordinary prose naming .claude/rules/toolchain.md, which is NOT an injection"}]}} +{"type":"attachment","sessionId":"s-fixture","attachment":{"type":"total_tokens_reminder","reminderType":"full"}} +{"type":"attachment","sessionId":"s-fixture","attachment":{"type":"hook_success","hookEvent":"PreToolUse","hookName":"PreToolUse:Bash","toolUseID":"t1","exitCode":0}} +{"type":"attachment","sessionId":"s-fixture","attachment":{"type":"nested_memory","displayPath":null,"path":"/w/.claude/rules/scanning.md","content":{"type":"Project"}}} diff --git a/crates/batten/tests/fixtures/transcripts/memory-injection.jsonl.in b/crates/batten/tests/fixtures/transcripts/memory-injection.jsonl.in new file mode 100644 index 000000000..98a36877d --- /dev/null +++ b/crates/batten/tests/fixtures/transcripts/memory-injection.jsonl.in @@ -0,0 +1,8 @@ +{"type":"user","sessionId":"s-fixture","cwd":"/w","message":{"role":"user","content":"a plain string turn"}} +{"type":"attachment","sessionId":"s-fixture","attachment":{"type":"nested_memory","path":"/w/.claude/rules/scanning.md","displayPath":".claude/rules/scanning.md","contentDiffersFromDisk":null,"globs":null,"content":{"type":"Project","path":"/w/.claude/rules/scanning.md","content":"RULE-DOCUMENT-BODY-REDACTED"},"rawContent":"RULE-DOCUMENT-BODY-REDACTED"}} +{"type":"attachment","sessionId":"s-fixture","attachment":{"type":"nested_memory","path":"/w/.claude/rules/toolchain.md","displayPath":".claude/rules/toolchain.md","contentDiffersFromDisk":null,"globs":null,"content":{"type":"Project","path":"/w/.claude/rules/toolchain.md","content":"RULE-DOCUMENT-BODY-REDACTED"},"rawContent":"RULE-DOCUMENT-BODY-REDACTED"}} +{"type":"attachment","sessionId":"s-fixture","attachment":{"type":"nested_memory","path":"/w/.claude/rules/scanning.md","displayPath":".claude/rules/scanning.md","contentDiffersFromDisk":null,"globs":null,"content":{"type":"Project","path":"/w/.claude/rules/scanning.md","content":"RULE-DOCUMENT-BODY-REDACTED"},"rawContent":"RULE-DOCUMENT-BODY-REDACTED"}} +{"type":"attachment","sessionId":"s-fixture","attachment":{"type":"nested_memory","path":"/w/.claude/rules/rust.md","displayPath":".claude/rules/rust.md","contentDiffersFromDisk":null,"globs":null,"content":{"type":"Project","path":"/w/.claude/rules/rust.md","content":"RULE-DOCUMENT-BODY-REDACTED"},"rawContent":"RULE-DOCUMENT-BODY-REDACTED"}} +{"type":"attachment","sessionId":"s-fixture","attachment":{"type":"nested_memory","path":"/w/.claude/rules/scanning.md","displayPath":".claude/rules/scanning.md","contentDiffersFromDisk":null,"globs":null,"content":{"type":"Project","path":"/w/.claude/rules/scanning.md","content":"RULE-DOCUMENT-BODY-REDACTED"},"rawContent":"RULE-DOCUMENT-BODY-REDACTED"}} +{"type":"attachment","sessionId":"s-fixture","attachment":{"type":"nested_memory","path":"/w/.claude/rules/rust.md","displayPath":".claude/rules/rust.md","contentDiffersFromDisk":null,"globs":null,"content":{"type":"Project","path":"/w/.claude/rules/rust.md","content":"RULE-DOCUMENT-BODY-REDACTED"},"rawContent":"RULE-DOCUMENT-BODY-REDACTED"}} +{"type":"assistant","sessionId":"s-fixture","message":{"role":"assistant","content":[{"type":"text","text":"ordinary assistant prose naming .claude/rules/toolchain.md, which is NOT an injection"}]}} diff --git a/crates/batten/tests/memory_injection.rs b/crates/batten/tests/memory_injection.rs new file mode 100644 index 000000000..1e50eada3 --- /dev/null +++ b/crates/batten/tests/memory_injection.rs @@ -0,0 +1,202 @@ +//! The host's rule-injection event, and the census over it (CLOUD-1054, CLOUD-1052). +//! +//! # What was actually established, and how +//! +//! CLOUD-1054 asked one question: does the host expose a stable, source-bearing +//! event when it delivers a `.claude/rules/*.md` document, or does it not? The +//! answer is **yes**, and `memory-injection.jsonl.in` is the evidence — a real +//! transcript from a real session, minimally redacted, not a shape invented here. +//! +//! The record is: +//! +//! ```text +//! {"type":"attachment","attachment":{ +//! "type":"nested_memory", +//! "displayPath":".claude/rules/scanning.md", +//! "content":{"type":"Project","content":""}}} +//! ``` +//! +//! Two typed host fields — a tag and a source path — so nothing here reads prose. +//! +//! # Why the earlier answer was the opposite, and why that matters here +//! +//! A first probe of the same session said the host exposed no such event. It was +//! taken at 158 records, before any trigger had fired; at 1,915 records there were +//! six. **That is the whole argument for a captured fixture over an assertion**, +//! and it is why the negative control below is a real file rather than a claim: +//! an absence measured on too small a sample is indistinguishable from a +//! capability that is not there. +//! +//! # The redaction, and what survives it +//! +//! `content.content` and `rawContent` carry the delivered document in full — the +//! richest payload this engine can be pointed at. Both are replaced with a fixed +//! marker, and the absolute paths are rewritten to `/w/` so the fixture is not +//! tied to one container. The envelope, the tag and the source path survive, +//! because those are the fields the predicate reads. + +// Panicking on setup failure is the idiomatic way for a test to fail loudly. +#![allow(clippy::unwrap_used, clippy::expect_used)] + +mod common; + +use std::fs; + +use batten::transcript::{self, Capability, Event}; + +/// Materialize a committed `.jsonl.in` fixture and parse it. +fn stream(fixture: &str) -> transcript::Stream { + let source = common::at_root(&format!( + "crates/batten/tests/fixtures/transcripts/{fixture}.jsonl.in" + )); + let dir = common::scratch(&format!("memory-injection-{fixture}")); + let path = dir.join("transcript.jsonl"); + fs::copy(&source, &path).expect("materialize the committed fixture"); + match transcript::resolve(&dir, Some("transcript.jsonl")) { + Capability::Present(stream) => stream, + other => panic!("fixture did not parse: {}", other.as_str()), + } +} + +// --------------------------------------------------------------------------- +// CLOUD-1054: the event exists, and it is source-bearing. +// --------------------------------------------------------------------------- + +#[test] +fn the_captured_host_transcript_carries_source_bearing_injections() { + let stream = stream("memory-injection"); + let injected: Vec<&str> = stream + .records + .iter() + .filter_map(|record| match &record.event { + Event::MemoryInjection { path } => Some(path.as_str()), + _ => None, + }) + .collect(); + + // The exact multiset the host emitted, in file order. Asserted whole rather + // than by shape: six-of-something and a `.md` suffix would both pass over a + // parser that read the wrong field, and the point of a CAPTURED fixture is + // that the expected value is known rather than described. + assert_eq!( + injected, + vec![ + ".claude/rules/scanning.md", + ".claude/rules/toolchain.md", + ".claude/rules/scanning.md", + ".claude/rules/rust.md", + ".claude/rules/scanning.md", + ".claude/rules/rust.md", + ], + "the captured session's injections, in order" + ); +} + +/// The discriminator that makes a zero readable. +#[test] +fn a_host_emitting_the_event_is_reported_as_reporting_them() { + assert!(stream("memory-injection").reports_memory_injections()); +} + +// --------------------------------------------------------------------------- +// The negative controls. Without these the parser could be matching anything. +// --------------------------------------------------------------------------- + +/// **The one that proves no prose is being read.** The unsupported fixture holds +/// an assistant turn that names `.claude/rules/toolchain.md` in ordinary text. A +/// predicate that pattern-matched message bodies would count it; this must not. +#[test] +fn prose_naming_a_rules_file_is_not_an_injection() { + let stream = stream("memory-injection-unsupported"); + assert!( + stream.memory_injections().is_empty(), + "a mention is not a delivery: {:?}", + stream.memory_injections() + ); +} + +/// An attachment carrying the tag but no source path records nothing, rather +/// than an injection with an invented source. The unsupported fixture's last +/// record is exactly that shape. +#[test] +fn the_tag_without_a_source_path_yields_no_event() { + assert!(!stream("memory-injection-unsupported").reports_memory_injections()); +} + +/// Other attachment types in the same file — a token reminder, a hook result — +/// yield no injection. Forward compatibility: an unrecognized tag is silent. +#[test] +fn unrelated_attachment_types_are_silent() { + let stream = stream("memory-injection-unsupported"); + assert!( + !stream.records.is_empty(), + "the fixture parsed to something, so silence is a decision and not an empty read" + ); + assert!(stream.memory_injections().is_empty()); +} + +// --------------------------------------------------------------------------- +// CLOUD-1052: the census. +// --------------------------------------------------------------------------- + +/// The measured census, per document. The breakdown is the deliverable — a bare +/// total would hide that one file repeats and another does not. +#[test] +fn the_census_counts_each_document_separately() { + let census = stream("memory-injection").memory_injections(); + assert_eq!( + census.get(".claude/rules/scanning.md").copied(), + Some(3), + "census: {census:?}" + ); + assert_eq!(census.get(".claude/rules/rust.md").copied(), Some(2)); + assert_eq!(census.get(".claude/rules/toolchain.md").copied(), Some(1)); + assert_eq!(census.values().sum::(), 6); +} + +/// Byte-stable under §6: keyed lexicographically, whatever order the host wrote. +#[test] +fn the_census_is_ordered_by_path() { + let census = stream("memory-injection").memory_injections(); + let keys: Vec<&String> = census.keys().collect(); + let mut sorted = keys.clone(); + sorted.sort(); + assert_eq!(keys, sorted, "a census a script reads must not reorder"); +} + +/// **A zero from a host that DOES report is a fact about the session**, and this +/// is the pairing that makes it readable. The same empty census means something +/// different depending on the discriminator, so both are asserted together +/// rather than one standing in for the other. +#[test] +fn an_empty_census_and_an_unsupported_host_are_distinguishable() { + let supported = stream("memory-injection"); + let unsupported = stream("memory-injection-unsupported"); + + assert!(supported.reports_memory_injections()); + assert!(!supported.memory_injections().is_empty()); + + assert!(!unsupported.reports_memory_injections()); + assert!(unsupported.memory_injections().is_empty()); +} + +/// Rule 4 at the fixture: the delivered document never reaches a record. The +/// redaction marker is in the file, so if the parser ever started carrying +/// bodies this would catch it. +#[test] +fn no_delivered_document_body_reaches_a_record() { + let raw = fs::read_to_string(common::at_root( + "crates/batten/tests/fixtures/transcripts/memory-injection.jsonl.in", + )) + .expect("read the committed fixture"); + assert!( + raw.contains("RULE-DOCUMENT-BODY-REDACTED"), + "the fixture still carries its redaction marker" + ); + + let rendered = format!("{:?}", stream("memory-injection").records); + assert!( + !rendered.contains("RULE-DOCUMENT-BODY-REDACTED"), + "a record carries the source path and never the delivered body" + ); +} From f2153a4fe61a27fbd8b426e1195228c041ac4d05 Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Tue, 25 Aug 2026 21:32:58 +0000 Subject: [PATCH 04/38] feat(policy)!: a refusal is {rule, verdict, subjects}, and there is no msg MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CLOUD-1050. `policy::Violation { rule, msg }` and native `Refusal { rule, reason, fix }` both carried prose no mechanism could check, so every remedy defect CLOUD-122 and CLOUD-871 named was expressible and none was checkable. Two were live in this repository when the row was written: a refusal naming a task that does not exist, and an override offered with no precondition. The registry. `[[verdict]]` rows declare a token, a one-line gloss, the class at length, and a closed route list; `crates/batten/src/verdict.rs` validates them at load. Registry equality runs in BOTH directions: a token no row declares is refused, and a consumer row nothing raises is refused too, because a class no gate reaches reads as coverage while its routes have never been walked. Tombstones are exempt from the second half, which is what they are for. The binary ships its own half — `verdict::VENDORED` — for its seven native classes and its four presets, so a preset loads in a tree that declares no `[[verdict]]` row at all. A collision between the two halves is refused at load, because which side is at fault is a question about the pair. The ABI. `msg` is gone from every emitter: nine `policy/*.rego` modules, the four vendored presets, every policy fixture, and all seven native sites. A module binding `msg`, raising an undeclared token, or composing one with `sprintf` fails to LOAD and says which key — a dead gate and a clean tree are byte-identical on the decision surface, so this had to be a load-time refusal rather than an adjudication-time finding. `subjects` are tagged pointers — {path}, {path,line}, {count}, {artifact} — never prose, which makes non-negotiable rule 4 structural rather than a habit each emitter keeps. The first path-bearing subject becomes the finding's own pointer, and finding identity is keyed on (rule, verdict, subjects) rather than on a message that any rewording moved. CLOUD-1053 rides the same registry: the hot path prints one line — token, gloss, pointers — and `batten policy explain` is where the class definition went. Its payload is a deliberate, stated exception to pointer-only output: `explain` is local documentation, and carrying the text the hot path no longer does is its whole purpose. The native half is where CLOUD-122's contract stops being convention. `Refusal::declared` takes a `verdict::Native` variant, and the remedy comes off the declared class — `validate` refuses a class with no route and refuses one whose only route is an override, so a site cannot construct a refusal with no way out even by omission. Two consumer tiers still win over it; what changed is that the floor under them names a verb instead of apologising. Also here, because the deletions above could not land without it: the mapped-successor arm on `retires_with`. A decrease was admitted only by subject death, which is the wrong question for a suite whose subject is a `.rego` module the migration KEEPS and rewrites — the module is alive, and `shell-retirement` refuses editing the suite in place, so both doors shut on a deletion whose logic is provably accounted for. A complete CLOUD-908 ledger is now the second admission. It is strictly more evidence than subject death, which asks nothing about where the cases went. Retired under CLOUD-1059, each with a file arm and a case arm: tests/run-shape.bats -> crates/batten/tests/run_shape.rs (14) tests/privileged-lane.bats -> crates/batten/tests/privileged_lane.rs (5) `crates/batten/src/admission.rs` lands here as CLOUD-1051's foundation: the content-addressed admission, its JCS canonicalization, the `fs4` compare-and-set the store had no locking for, and the questions generated from a class's declared `override.precondition`. Refs: CLOUD-1050, CLOUD-1053, CLOUD-1059, CLOUD-908, CLOUD-122, CLOUD-871 --- .claude/rules/policy-modules.md | 35 + .regal/config.yaml | 6 + .../refusal-is-typed/refusal_is_typed.rego | 99 ++ .../refusal_is_typed_test.rego | 89 ++ .serena/memories/core.md | 26 + batten.toml | 448 +++++++ bench/suites/RESULTS.md | 284 +++-- completions/batten.bash | 69 +- completions/batten.fish | 66 +- completions/batten.zsh | 28 +- crates/batten/src/admission.rs | 811 +++++++++++++ crates/batten/src/cli.rs | 21 + crates/batten/src/config.rs | 23 + crates/batten/src/hook.rs | 199 +++- crates/batten/src/lib.rs | 186 ++- crates/batten/src/lint.rs | 2 +- crates/batten/src/policy.rs | 538 ++++++++- .../commit-hygiene/no-empty-commit.rego | 2 +- .../shebang-names-its-language.rego | 6 +- .../shell-hygiene/sibling-resolves.rego | 10 +- .../presets/trunk-based/no-force-push.rego | 2 +- crates/batten/src/receipt.rs | 7 +- crates/batten/src/refusal.rs | 122 +- crates/batten/src/resolve.rs | 17 + crates/batten/src/rules.rs | 268 ++++- crates/batten/src/secrets.rs | 15 +- crates/batten/src/spec.rs | 2 + crates/batten/src/stop.rs | 18 +- crates/batten/src/surface.rs | 41 + crates/batten/src/trust.rs | 119 ++ crates/batten/src/verdict.rs | 1034 +++++++++++++++++ crates/batten/tests/cli.rs | 35 +- crates/batten/tests/common/mod.rs | 108 ++ crates/batten/tests/document_read_count.rs | 35 +- .../repos/policy-mediated-call/batten.toml.in | 18 + .../repos/policy-mediated-call/gate.rego.in | 6 +- crates/batten/tests/git_facts.rs | 14 +- crates/batten/tests/identity_churn.rs | 15 +- crates/batten/tests/pointer_only.rs | 35 +- crates/batten/tests/policy_modules.rs | 156 ++- crates/batten/tests/policy_presets.rs | 24 +- crates/batten/tests/policy_test_suite.rs | 35 +- crates/batten/tests/policy_tree.rs | 75 +- crates/batten/tests/primitives.rs | 4 +- crates/batten/tests/privileged_lane.rs | 233 ++++ crates/batten/tests/ratchet.rs | 69 +- crates/batten/tests/remedy_authorship.rs | 21 +- crates/batten/tests/run_shape.rs | 327 ++++++ crates/batten/tests/shell_retirement.rs | 15 +- crates/batten/tests/sinks.rs | 30 +- crates/batten/tests/verdict_registry.rs | 382 ++++++ hk.pkl | 1 + man/batten-policy-explain.1 | 19 + man/batten-policy.1 | 4 +- policy/ancestry-decides-nothing.rego | 8 +- policy/command-task-defined.rego | 10 +- policy/module-layering.rego | 25 +- policy/opa-compliance.rego | 30 +- policy/privileged-lane.rego | 9 +- policy/remedy-authorship.rego | 12 +- policy/run-shape.rego | 2 +- policy/shell-retirement.rego | 36 +- policy/verdict-routes-resolve.rego | 250 ++++ policy/workspace-dep-referenced.rego | 14 +- schema/batten.schema.json | 100 ++ tests/privileged-lane.bats | 210 ---- tests/run-shape.bats | 229 ---- 67 files changed, 6180 insertions(+), 1009 deletions(-) create mode 100644 .regal/rules/custom/regal/rules/abi/refusal-is-typed/refusal_is_typed.rego create mode 100644 .regal/rules/custom/regal/rules/abi/refusal-is-typed/refusal_is_typed_test.rego create mode 100644 crates/batten/src/admission.rs create mode 100644 crates/batten/src/verdict.rs create mode 100644 crates/batten/tests/privileged_lane.rs create mode 100644 crates/batten/tests/run_shape.rs create mode 100644 crates/batten/tests/verdict_registry.rs create mode 100644 man/batten-policy-explain.1 create mode 100644 policy/verdict-routes-resolve.rego delete mode 100644 tests/privileged-lane.bats delete mode 100644 tests/run-shape.bats diff --git a/.claude/rules/policy-modules.md b/.claude/rules/policy-modules.md index fed956691..746c00a76 100644 --- a/.claude/rules/policy-modules.md +++ b/.claude/rules/policy-modules.md @@ -39,6 +39,41 @@ from the rule's name. `policy test`'s coverage binds on that literal, because a `test_` naming convention would be satisfied by a test that never touches the predicate. +## A refusal is `{rule, verdict, subjects}` — there is no `msg` + +Refused at **load**, in both directions, so this belongs with the shape rules +above rather than with the authoring judgement below. + +```rego +violation contains { + "rule": "shell-rule-retired", + "verdict": "V-SHELL-RULE-EDITED", + "subjects": [{"path": path}], +} if { ... } +``` + +`verdict` is a **token** the `[[verdict]]` registry declares; the prose that used +to live in `msg` lives there, once, where a gate can read it. A module binding +`msg` fails to load and says which key; so does one raising a token no row +declares, one raising a token declared as a tombstone, and one that **composes** +its verdict with `sprintf` — a class a reader cannot look up is not a class. The +other direction is refused too: a `[[verdict]]` row nothing raises fails the load, +because a class no gate reaches reads as coverage while its routes have never been +walked. + +The measured reason (CLOUD-1050): `msg` was a `String`, so a refusal naming no +remedy, naming a task that does not exist, or offering an override with no +precondition were all _expressible and none checkable_. Two of those were live in +this repository when the row was written. + +`subjects` is optional and ordered, and every member is a **tagged pointer** — +`{path}`, `{path, line}`, `{count}`, `{artifact}` — never prose, which is what +makes non-negotiable rule 4 structural here rather than a habit each module keeps. +The **first path-bearing subject becomes the finding's own pointer**, so the order +is a statement about which one a reader should follow first; a class with nothing +to point at omits the key rather than inventing one. The bare-string `deny` +channel carries a token too, and is held to the same registry. + ## Patterns come from `[[pattern]]`, never inline An inline regex is **refused at load**. A pattern is a `[[pattern]]` row read as diff --git a/.regal/config.yaml b/.regal/config.yaml index e6383ecdb..7c1b2a4ca 100644 --- a/.regal/config.yaml +++ b/.regal/config.yaml @@ -14,6 +14,12 @@ rules: default: level: ignore + abi: + # CLOUD-1050. `error` for the sibling's reason: the default `--fail-level` is + # `error`, so a warning here would report the defect and exit 0 — the + # silent-pass shape both custom rules exist to close. + refusal-is-typed: + level: error schemas: every-package-binds-input: # `error` rather than `warning`: `mise exec -- regal lint`'s default diff --git a/.regal/rules/custom/regal/rules/abi/refusal-is-typed/refusal_is_typed.rego b/.regal/rules/custom/regal/rules/abi/refusal-is-typed/refusal_is_typed.rego new file mode 100644 index 000000000..9c6a3a30f --- /dev/null +++ b/.regal/rules/custom/regal/rules/abi/refusal-is-typed/refusal_is_typed.rego @@ -0,0 +1,99 @@ +# METADATA +# description: | +# A refusal is `{rule, verdict, subjects}`; `msg` is retired and a verdict is a +# NAME (CLOUD-1050). +# +# WHY THIS EXISTS BESIDE A LOAD-TIME REFUSAL, which is strictly stronger where +# it applies. `policy::load` reads the compiled AST and refuses both shapes, but +# it only ever sees modules a `[[rule]]` row ENABLES. A module written and not +# yet registered — the ordinary state of one mid-authoring, and the state every +# module in a `bundle` root is in before the row lands — is invisible to it. +# This rule reads the corpus, so it covers the file the day it is written rather +# than the day it is enabled. +# +# TWO SHAPES, ONE ARGUMENT. `"msg": "..."` is the retired key: the decoder no +# longer reads it, so a module carrying it loads clean, evaluates clean and +# reports NOTHING — a dead gate and a clean tree being byte-identical on the +# decision surface. `"verdict": sprintf(...)` is a class no reader can look up +# and no registry can be held to; the whole point of a token is that being told +# it twice is being told the same thing twice, and a composed one cannot promise +# that. +# +# `test_` RULES ARE EXEMPT, and it is a real case rather than a hatch: a +# module's own test may legitimately construct a violation object to compare +# against, including one pinning a token that is being retired. The prefix is +# the one `policy test` already keys on, so no second convention is introduced. +package custom.regal.rules.abi["refusal-is-typed"] + +import data.regal.ast +import data.regal.result + +# METADATA +# description: the retired `msg` key, and a composed `verdict` +report contains violation if { + some rule in input.rules + publishes_a_refusal(rule) + some node in object_terms(rule) + some pair in node.value + pair[0].type == "string" + pair[0].value == "msg" + violation := result.fail(rego.metadata.chain(), result.location(rule.head)) +} + +report contains violation if { + some rule in input.rules + publishes_a_refusal(rule) + some node in object_terms(rule) + some pair in node.value + pair[0].type == "string" + pair[0].value == "verdict" + pair[1].type != "string" + violation := result.fail(rego.metadata.chain(), result.location(rule.head)) +} + +# A rule that PUBLISHES a refusal: `violation` or `deny`, and not a test. +# +# THE NARROWING IS NOT TIDINESS — it is this rule's own first firing, caught by +# running it over the corpus. `policy/verdict-routes-resolve.rego` has a helper +# projecting the registry's routes, and one of its keys is legitimately named +# `verdict` and legitimately bound to a variable. Judged as a refusal that is a +# composed token; judged as what it is, it is a projection with a well-chosen +# field name. A gate whose first firing is a false positive gets an exception +# written for it, and the exception is what rots. +# +# What the narrowing costs, stated: a module that builds its refusal object in a +# HELPER and yields it from `violation` is not judged here. That is could-not-look +# rather than a pass — the load-time check has the same bound and for the same +# reason — and the honest answer is that neither reader follows a value across a +# rule boundary. +publishes_a_refusal(rule) if { + not is_test(rule) + ast.ref_to_string(rule.head.ref) in {"violation", "deny"} +} + +# A rule the module wrote as one of its own tests. +# +# `ast.ref_to_string` rather than reading `head.ref[0].value` directly, because a +# rule head is a REF and the first term's spelling differs between a bare name +# and a dotted one — the same shape difference `every-package-binds-input` +# records having been caught by twice. +is_test(rule) if startswith(ast.ref_to_string(rule.head.ref), "test_") + +# Every object literal anywhere inside a rule. +# +# `walk` rather than a hand-written descent: an object literal can be nested in a +# comprehension, a function argument or another object, and a reader that knew +# only the top level would pass over exactly the module that hid one. +# +# THE RELATION FORM, `walk(x, [path, value])`, not `some pair in walk(x)`. The +# second spelling iterates the ELEMENTS of the returned pair rather than the +# pairs themselves, so `node` binds to a path or to a value and never to a term — +# it matches nothing and reports a clean corpus. Found by the two positive cases +# below failing while the five negative ones passed, which is exactly the +# asymmetry a suite with no positive arm would have shipped. +object_terms(rule) := {node | + walk(rule, [_, node]) + is_object(node) + node.type == "object" + is_array(node.value) +} diff --git a/.regal/rules/custom/regal/rules/abi/refusal-is-typed/refusal_is_typed_test.rego b/.regal/rules/custom/regal/rules/abi/refusal-is-typed/refusal_is_typed_test.rego new file mode 100644 index 000000000..d60c29402 --- /dev/null +++ b/.regal/rules/custom/regal/rules/abi/refusal-is-typed/refusal_is_typed_test.rego @@ -0,0 +1,89 @@ +package custom.regal.rules.abi["refusal-is-typed_test"] + +import data.custom.regal.rules.abi["refusal-is-typed"] as rule + +# THE POSITIVE ARM IS THE ONE THAT MAKES THIS A TEST OF THE RULE. A suite that +# only asserted `count(r) > 0` over a `msg`-bearing module passes unchanged if +# the predicate is hard-wired to fire — which is the failure the sibling rule's +# own comments record hitting twice, in both directions. So the conforming +# module is asserted silent at every arm. + +typed := `package batten.example + +violation contains { + "rule": "a-gate", + "verdict": "V-A-CLASS", + "subjects": [{"path": "a.rs"}], +} if { + input.call.operation == "write" +}` + +carries_msg := `package batten.example + +violation contains { + "rule": "a-gate", + "msg": "prose no mechanism can check", +} if { + input.call.operation == "write" +}` + +composed_verdict := `package batten.example + +violation contains { + "rule": "a-gate", + "verdict": sprintf("V-%s", ["A-CLASS"]), +} if { + input.call.operation == "write" +}` + +# A module's own test may construct the old shape to compare against — including +# one pinning a token being retired — so the prefix is exempt. +msg_inside_a_test := `package batten.example + +test_something if { + expected := {"rule": "a-gate", "msg": "the old shape"} + expected.rule == "a-gate" +}` + +# The discriminating negative: `msg` as a VALUE rather than a key. A rule that +# scanned for the literal anywhere would report this, and the literal is a +# perfectly ordinary string. +msg_as_a_value := `package batten.example + +violation contains { + "rule": "a-gate", + "verdict": "V-A-CLASS", + "subjects": [{"artifact": "msg"}], +} if { + input.call.operation == "write" +}` + +module(name, text) := object.union( + regal.parse_module(name, text), + {"regal": {"file": {"name": name}}}, +) + +test_a_typed_refusal_is_not_reported if { + r := rule.report with input as module("typed.rego", typed) + count(r) == 0 +} + +test_the_retired_key_is_reported if { + r := rule.report with input as module("msg.rego", carries_msg) + count(r) == 1 +} + +test_a_composed_verdict_is_reported if { + r := rule.report with input as module("composed.rego", composed_verdict) + count(r) == 1 +} + +test_a_test_rule_may_still_construct_the_old_shape if { + r := rule.report with input as module("test.rego", msg_inside_a_test) + count(r) == 0 +} + +test_the_literal_msg_as_a_value_is_not_a_key if { + r := rule.report with input as module("value.rego", msg_as_a_value) + count(r) == 0 +} diff --git a/.serena/memories/core.md b/.serena/memories/core.md index 426a27e94..450525832 100644 --- a/.serena/memories/core.md +++ b/.serena/memories/core.md @@ -98,6 +98,20 @@ err)` takes **both** channels and the resolved `Mode`, so a verb can write a output is the likeliest place in the engine for a secret to appear. Batten only ever **adds** failure: a non-zero child passes its code through untouched. Raise-only from a local file (add yes, redefine no). +- `admission.rs` — issued admissions (CLOUD-1051): an override stops being + knowledge and becomes a record. A content-addressed capability over + `{rule, verdict, subject, head, epoch, answers, prev, author}`, canonicalized + by JCS rather than concatenated — raw `a ‖ b` is ambiguous across field + boundaries, so two field splits can hash alike and the address would not be + well-defined. **The address is INTEGRITY, never authentication**: anyone + holding the answers can compute it, so what restricts who may mint one is the + store's write path and nothing else. Under a local store that means anyone who + can write it can mint one, which is acceptable against honest error and is + written down so a later reader does not mistake the hash for a signature. The + store has an `fs4` compare-and-set this row introduced — `receipt.rs` carried + no locking at all — and the questions come off a class's declared + `override.precondition` in `verdict.rs`. The gate never grades an answer; + presence and non-emptiness are the whole predicate (rule 3). - `action.rs` — the `[[hook.action]]` plugin surface (CLOUD-91), house-style §9's "repo-specific cleanup or keepalive is reconstructed here, not hardcoded". A row names an event and argv already on the operator's PATH. **`fire` returns @@ -888,6 +902,18 @@ repo config > default`, declared as data in `SETTINGS` (per-key env var/flag), rather than a second severity axis. Each verb carries its own redirect for the refusal contract. Table and lookup only — crossing it with the protected path set is CLOUD-96's gate, and the sets are CLOUD-37's. +- `verdict.rs` — the refusal vocabulary (CLOUD-1050): the `[[verdict]]` table, + its `Subject` pointers and its closed `Route` list. A refusal stopped being a + free string here — `{rule, verdict, subjects}` replaces `{rule, msg}`, the + prose moves into the one declared class definition, and every remedy defect + CLOUD-122 named becomes decidable instead of merely expressible. **One table + for both emitters**, Rego and native alike, which is the drift CLOUD-1050 + reversed its own first review over. Well-formedness is validated at parse + beside `pattern.rs`'s; registry EQUALITY against what the modules emit needs + the compiled bundles and lives in `policy::load`; route TARGET resolution is a + policy module, because a task runner is a consumer fact (rule 1). Tombstone + chains live here too, and are the same chain predicate CLOUD-1051's `prev` + needs. - `waiver.rs` — the designed escape hatch (CLOUD-208): a `[[waiver]]` names a rule, a required reason and a required expiry, and `apply` filters findings in `lib.rs`'s `run_rules` — the one funnel both `check` and `enforce` pass through diff --git a/batten.toml b/batten.toml index 54dc202f3..9ee9993bd 100644 --- a/batten.toml +++ b/batten.toml @@ -2410,6 +2410,23 @@ sources = [".github/workflows/*.yml", ".github/workflows/*.yaml"] module = "policy/privileged-lane.rego" severity = "deny" +# CLOUD-1050. The registry's tree-facing half: `verdict::validate` decides a +# route's SHAPE at parse and this decides whether it points at anything, which is +# a question about this tree and about a task RUNNER — so it is a module rather +# than a line of Rust (non-negotiable rule 1, the same placement CLOUD-614 was +# forced into by `no_artifact_name_reaches_the_core`). +# +# It matters more here than for a `command` ROW. A row naming a missing task +# fails loudly the first time it runs; a ROUTE naming one fails when a reader has +# just been refused, has been handed the one thing to run, and runs it. +[[rule]] +id = "verdict-routes-resolve" +kind = "policy" +scope = "tree" +sources = ["batten.toml", "mise.toml"] +module = "policy/verdict-routes-resolve.rego" +severity = "deny" + # CLOUD-1050 defects A and B: a gate's remedy reaches its reader, and exactly one # program authors it. The module's own header carries the measurement and the # scope limit; the reasoning is not restated here. @@ -3006,3 +3023,434 @@ id = "mcp-allow-check" on = "user-prompt-submit" run = ["mise-tasks/mcp-allow-check.sh", "--session"] timeout_ms = 2000 + +# --------------------------------------------------------------------------- +# THE REFUSAL VOCABULARY (CLOUD-1050). +# +# A refusal used to be a free string. `policy::Violation { rule, msg }` carried +# prose no mechanism could check, so every remedy defect CLOUD-122 and CLOUD-871 +# named was expressible and none was checkable: a refusal could name no remedy, +# name a task that does not exist, offer an override with no precondition, or +# spell one concept nineteen ways, and all of it passed a `String`. +# +# Now a refusal is `{rule, verdict, subjects}` and this table is where the prose +# went. Each row declares a CLASS: a stable token, one line of gloss for the hot +# path, the class definition `batten policy explain` answers with, and a closed +# route list — at least one, and never an override alone, because "ask for it to +# be waived" is the bare no the contract exists to refuse. +# +# THREE THINGS ARE GATED HERE RATHER THAN CONVENTIONAL. Well-formedness at parse +# (`verdict::validate`); registry equality at load, in both directions, so a +# token no row declares is refused and a row nothing raises is refused; and route +# TARGET resolution by `policy/verdict-routes-resolve.rego`, which is a module +# rather than a line of Rust because whether `mise run x` names a task is a +# question about a task RUNNER and non-negotiable rule 1 keeps the core ignorant +# of one. +# +# THE VENDORED HALF IS NOT HERE. Classes the binary ships — its native refusal +# sites and the vendored presets' — are declared in `crates/batten/src/verdict.rs` +# and unioned in at load. A preset reaches a consumer who wrote no row at all, so +# holding it to this table would make an enabled preset unloadable with no fix +# available. Declaring a token this binary vendors is refused as a collision. +# +# The gloss is bounded at 120 characters and one line, and that bound is the +# mechanism behind CLOUD-1053: the hot path prints token, gloss and pointer, and +# a gloss nobody bounded is how the paragraph comes back. + +[[verdict]] +id = "V-SHELL-RULE-ADDED" +gloss = "an authored shell rule or bats suite was added, moving CLOUD-843's corpus the wrong way" +class = """ +CLOUD-843's campaign is retiring 144 shell programs and 161 bats suites onto the \ +policy engine. A new one is invisible to every other sensor here: \ +`bash-surface-not-growing` admits it with a `# stays-bash:` declaration, and no \ +other gate asks whether a Rego surface could have carried the predicate instead. \ +Write it as a policy module with a compiled-binary test, or declare on its own \ +row that it stays bash. +""" + +[[verdict.route]] +id = "R-WRITE-A-POLICY-MODULE" +kind = "document" +target = ".claude/rules/policy-modules.md" + +[[verdict.route]] +id = "R-DECLARE-IT-STAYS-BASH" +kind = "document" +target = "batten.toml" + +[[verdict]] +id = "V-SHELL-RULE-EDITED" +gloss = "an authored shell rule or bats suite was edited in place rather than migrated" +class = """ +The load-bearing arm. A migration replaces a shell gate; it does not maintain \ +one. An edit is invisible to every other sensor in this tree — \ +`bash-surface-not-growing` counts programs and `bats-tests-not-deleted` counts \ +cases, and an edit changes neither — so the corpus stays exactly as large while \ +the campaign reports movement. Port the predicate, delete the file, and record \ +the successor on a `// carried:`, `// subsumed:` or `// changed:` row. +""" + +[[verdict.route]] +id = "R-PORT-AND-RETIRE" +kind = "document" +target = ".claude/rules/policy-modules.md" + +[[verdict]] +id = "V-RETIREMENT-UNMAPPED" +gloss = "a governed file was deleted and the retirement ledger records no successor for it" +class = """ +CLOUD-908's defect at file granularity: the deletion is admitted and the logic \ +evaporates, with nothing in the tree naming what now holds the predicate. Add \ +exactly one `// carried:`, `// subsumed:` or `// changed:` row naming this path, \ +under the `declared_in` glob the `[rule.conserves]` column already declares. +""" + +[[verdict.route]] +id = "R-ADD-A-LEDGER-ARM" +kind = "document" +target = "batten.toml" + +[[verdict]] +id = "V-RETIREMENT-AMBIGUOUS" +gloss = "a deleted file carries more than one retirement arm, so it records no answer at all" +class = """ +`carried`, `subsumed` and `changed` are three different claims about where the \ +logic went — preserved, absorbed by a more general property, or deliberately \ +diverged. A path asserting two of them has not said which is true. Keep the one \ +that is. +""" + +[[verdict.route]] +id = "R-KEEP-THE-TRUE-ARM" +kind = "document" +target = "batten.toml" + +[[verdict]] +id = "V-SUCCESSOR-NO-SURFACE" +gloss = "a retirement arm names no policy surface, only a test" +class = """ +A mapping that names only a test records where the assertions went and not where \ +the predicate went, so the gate it replaced has no successor anything can run. At \ +least one successor must be a `policy/*.rego` module or a \ +`crates/batten/src/*.rs` surface. +""" + +[[verdict.route]] +id = "R-NAME-THE-SURFACE" +kind = "document" +target = ".claude/rules/policy-modules.md" + +[[verdict]] +id = "V-SUCCESSOR-NO-TEST" +gloss = "a retirement arm names no compiled-binary test, so nothing proves the port works" +class = """ +A module's own `test_` rules are the load-time tier and they fabricate their own \ +input, so they cannot prove the ENGINE builds the shape the predicate reads. \ +`.claude/rules/policy-modules.md` records both live instances of that defect \ +being found by adding the second tier rather than by reading. At least one \ +successor must be a `crates/batten/tests/*.rs` case. +""" + +[[verdict.route]] +id = "R-ADD-A-BINARY-TEST" +kind = "document" +target = ".claude/rules/policy-modules.md" + +[[verdict]] +id = "V-ANCESTRY-DECIDES-MERGEDNESS" +gloss = "a reachability answer decides merged-ness, which a rebased landing is invisible to" +class = """ +Merged-ness is decided by patch identity, never by reachability (CLOUD-36). This \ +repository lands by fast-forward after a rebase, so the landed commits are not \ +the branch's commits and `merge-base --is-ancestor` answers about a history that \ +no longer exists. The failure is silent and in the permissive direction. +""" + +[[verdict.route]] +id = "R-ASK-PATCH-IDENTITY" +kind = "command" +target = "git cherry" + +[[verdict]] +id = "V-COMMIT-WITHOUT-A-MESSAGE-SOURCE" +gloss = "a `git commit` names no message source, so git opens $EDITOR and blocks there" +class = """ +No `-m`, `-F`, `-C`, `--no-edit`, `--fixup` or `--squash`. Git opens $EDITOR and \ +blocks, AFTER `pre-commit` has already spent the whole gate — measured at about \ +four minutes (CLOUD-488). Write the message to a file and use \ +`git commit -F `, the one form that cannot rebind. +""" + +[[verdict.route]] +id = "R-COMMIT-FROM-A-FILE" +kind = "command" +target = "git commit -F " + +[[verdict]] +id = "V-WORKFLOW-UNPARSED" +gloss = "a workflow could not be parsed, so its lanes were never judged" +class = """ +Could-not-look, and deliberately not spelled the same way as a workflow whose \ +lanes are all safe. A file the engine could not read has established nothing \ +about itself, and reporting silence there is CLOUD-251's vacuous pass. +""" + +[[verdict.route]] +id = "R-FIX-THE-WORKFLOW-SYNTAX" +kind = "command" +target = "mise run lint:deno" + +[[verdict]] +id = "V-PRIVILEGED-LANE-UNTESTED-ORIGIN" +gloss = "a job an outside author can reach holds contents:write and tests no head origin" +class = """ +The privileged-lane shape: a trigger an outside author can fire, a token that can \ +write, and no check that the head being built came from this repository. Test the \ +head's origin before the job does anything with its permissions, or drop the \ +permission. +""" + +[[verdict.route]] +id = "R-TEST-THE-HEAD-ORIGIN" +kind = "document" +target = ".github/workflows" + +[[verdict]] +id = "V-TASK-UNDEFINED" +gloss = "a command row names a task this tree does not define" +class = """ +`mise run ` resolves from the working directory, and the runner is on PATH, \ +so the row does not fail to launch — it STARTS, exits non-zero, and the engine \ +turns that into a finding at the row's own severity about a tree it never \ +inspected. Measured: a scratch tree carrying only a lockfile produced a \ +deny-severity finding asserting it was NTIA-nonconformant, having inspected \ +nothing. +""" + +[[verdict.route]] +id = "R-DEFINE-THE-TASK" +kind = "document" +target = "mise.toml" + +[[verdict.route]] +id = "R-NAME-A-PROGRAM-ON-PATH" +kind = "document" +target = "batten.toml" + +[[verdict]] +id = "V-AUTHORITY-UNPARSED" +gloss = "the committed authority could not be parsed, so none of its rows could be judged" +class = """ +`batten.toml` carries the rows, so a tree that could not read it cannot have its \ +rows judged at all. Loud rather than silent, for the reason every could-not-look \ +channel here is: an unreadable authority and a clean one must not produce the \ +same output. +""" + +[[verdict.route]] +id = "R-LINT-THE-AUTHORITY" +kind = "command" +target = "mise run config-lint" + +[[verdict]] +id = "V-LAYERING-EDGE-FORBIDDEN" +gloss = "a module reaches one the layer table places below it" +class = """ +A layering this tree documents and, before this rule, nothing enforced. The edge \ +is read off the `use` graph rather than off the text, so an import reached \ +through a re-export counts exactly as a direct one does. +""" + +[[verdict.route]] +id = "R-MOVE-THE-DEPENDENCY" +kind = "document" +target = ".serena/memories/core.md" + +[[verdict]] +id = "V-LAYER-UNPLACED" +gloss = "a module is judged by the layering rule and absent from its table" +class = """ +An unplaced module is not a module with no constraints — it is a module whose \ +constraints nobody wrote down, and the rule cannot tell those apart. Place it, or \ +narrow the row's selector so it is honestly out of scope. +""" + +[[verdict.route]] +id = "R-PLACE-THE-MODULE" +kind = "document" +target = "batten.toml" + +[[verdict]] +id = "V-LAYER-TABLE-DECIDES-NOTHING" +gloss = "the layer table forbids no edge, so the rule is on and decides nothing" +class = """ +A gate that cannot refuse is off, and one that is off while reading as configured \ +is worse than one that is absent — it reads as coverage. Declare the forbidden \ +edges, or remove the row. +""" + +[[verdict.route]] +id = "R-DECLARE-THE-EDGES" +kind = "document" +target = "batten.toml" + +[[verdict]] +id = "V-REMEDY-DROPPED-BY-THE-FILTER" +gloss = "a stderr line inside a `>&2` block carries no `::error::` prefix, so readers drop it" +class = """ +`land` and every reader following this tree's filter convention drop an \ +unprefixed line. A refusal line without the prefix is ABSENT, not quiet — \ +measured, a reader received the diagnosis and a paraphrase and never received the \ +route. Prefix it, as `board-payloads` does for every line of its own recipe block. +""" + +[[verdict.route]] +id = "R-PREFIX-THE-LINE" +kind = "document" +target = "mise-tasks/board-payloads.sh" + +[[verdict]] +id = "V-REMEDY-HAS-TWO-AUTHORS" +gloss = "a task body names a bypass it does not read, so it is a second author of another gate's remedy" +class = """ +A second authority drifts. Measured: a caller's copy dropped one route entirely \ +and turned the override's precondition into bookkeeping, leaving a binary whose \ +only concrete arm was the bypass (CLOUD-680). State no routes and point at the \ +gate — `linear-check`'s "see its message" is the idiom, and it cannot drift \ +because it carries none. +""" + +[[verdict.route]] +id = "R-POINT-AT-THE-GATE" +kind = "document" +target = "mise-tasks/linear-check.sh" + +[[verdict]] +id = "V-WORKSPACE-DEP-ORPHANED" +gloss = "a `[workspace.dependencies]` entry no member references resolves to nothing" +class = """ +Every graph-reading gate is then green about a dependency that is not in the \ +tree: the entry declares a version, nothing inherits it, and the closure a \ +licence or SBOM check walks does not contain it. Reference it with \ +`workspace = true`, or delete the entry. +""" + +[[verdict.route]] +id = "R-REFERENCE-OR-DELETE" +kind = "document" +target = "Cargo.toml" + +[[verdict]] +id = "V-MANIFEST-UNPARSED" +gloss = "a manifest could not be parsed, so its references were never counted" +class = """ +An orphan cannot be ruled out over a file nobody read. Could-not-look, reported \ +rather than collapsed into the clean answer. +""" + +[[verdict.route]] +id = "R-LINT-THE-MANIFEST" +kind = "command" +target = "mise run lint:toml" + +[[verdict]] +id = "V-WORKSPACE-TABLE-ABSENT" +gloss = "no `[workspace.dependencies]` table reached the judged set, so the rule decided nothing" +class = """ +The vacuity arm. A rule whose input never arrived has not established that there \ +are no orphans; it has established that it did not look. Narrow the row's \ +selector so it selects the manifest, or remove the row. +""" + +[[verdict.route]] +id = "R-SELECT-THE-MANIFEST" +kind = "document" +target = "batten.toml" + +[[verdict]] +id = "V-COMPLIANCE-SOURCE-UNPARSED" +gloss = "a compliance source could not be parsed, so the checker was never judged against the evaluator" +class = """ +The pinned type checker and the shipped evaluator have to agree about which OPA \ +level they implement, and that comparison needs both declarations. A file that \ +did not parse contributes neither, which is could-not-look rather than agreement. +""" + +[[verdict.route]] +id = "R-LINT-THE-SOURCE" +kind = "command" +target = "mise run lint:toml" + +[[verdict]] +id = "V-CHECKER-AHEAD-OF-EVALUATOR" +gloss = "the pinned checker implements a newer OPA level than the shipped evaluator records" +class = """ +A rule type checked by one and evaluated by the other is a false green: the \ +checker accepts a construct the evaluator cannot run, so the module passes its \ +build and decides nothing at adjudication. Move the pin down, or move the \ +recorded level up once upstream declares it. +""" + +[[verdict.route]] +id = "R-ALIGN-THE-PIN" +kind = "document" +target = "mise.toml" + +[[verdict]] +id = "V-COMPLIANCE-CLAIM-STALE" +gloss = "the recorded compliance level was read against a different evaluator version than the one pinned" +class = """ +The pin then tracks a claim nobody has checked. Re-read upstream's declared level \ +against the version actually pinned and move both together, which is what keeps \ +the record a measurement rather than a memory. +""" + +[[verdict.route]] +id = "R-REREAD-AND-MOVE-BOTH" +kind = "document" +target = "mise.toml" + +[[verdict]] +id = "V-COMPLIANCE-DECLARATION-ABSENT" +gloss = "a declaration this comparison needs is absent, so there is nothing to hold the pin to" +class = """ +One class rather than four, distinguished by the subject naming which declaration \ +is missing: the `opa` pin, the recorded compliance level, the evaluator version \ +it was read against, or the evaluator's own pin. All four are the same defect — a \ +comparison with one side missing — and splitting them would give a reader four \ +tokens for one thing to learn. +""" + +[[verdict.route]] +id = "R-DECLARE-IT" +kind = "document" +target = "mise.toml" + +[[verdict]] +id = "V-VERSION-UNREADABLE" +gloss = "a declared version is not MAJOR.MINOR, so no comparison it feeds can be trusted" +class = """ +The comparison is numeric, so an unparseable version silently orders wrong rather \ +than failing. Reported at the declaration, because that is where the fix goes. +""" + +[[verdict.route]] +id = "R-WRITE-A-MAJOR-MINOR" +kind = "document" +target = "mise.toml" + +[[verdict]] +id = "V-ROUTE-TASK-UNDEFINED" +gloss = "a route offers a task this tree does not define" +class = """ +The worst moment for a task to be missing. A reader has just been refused, has \ +been handed the one thing to run, runs it, and is told the task does not exist — \ +which is CLOUD-122's bare no with an extra step. Define the task, or name a \ +program on PATH, which this rule deliberately leaves alone. +""" + +[[verdict.route]] +id = "R-DEFINE-THE-ROUTED-TASK" +kind = "document" +target = "mise.toml" diff --git a/bench/suites/RESULTS.md b/bench/suites/RESULTS.md index 87e0b74e1..cc15896c5 100644 --- a/bench/suites/RESULTS.md +++ b/bench/suites/RESULTS.md @@ -6,169 +6,165 @@ 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: 161 -- serial total: 1385.6s +- suites: 157 +- serial total: 1522.3s | seconds | share | suite | | ---: | ---: | --- | -| 156.1 | 11.3% | `tests/derived-check.bats` | -| 145.0 | 10.5% | `tests/land-lock.bats` | -| 103.8 | 7.5% | `tests/session-start.bats` | -| 102.4 | 7.4% | `tests/ci-wait.bats` | -| 97.4 | 7.0% | `tests/land.bats` | -| 76.7 | 5.5% | `tests/hooks-wiring-check.bats` | -| 50.3 | 3.6% | `tests/ci-local-parity.bats` | -| 40.9 | 2.9% | `tests/prebuilt-lint.bats` | -| 36.2 | 2.6% | `tests/helpers.bats` | -| 34.5 | 2.5% | `tests/main-watch.bats` | -| 26.1 | 1.9% | `tests/commit-convention.bats` | -| 24.7 | 1.8% | `tests/sbom-check.bats` | -| 24.3 | 1.8% | `tests/hook-latency-drift.bats` | -| 23.1 | 1.7% | `tests/graph-check.bats` | -| 21.8 | 1.6% | `tests/claim-check.bats` | -| 19.3 | 1.4% | `tests/board-diff-overlap.bats` | -| 18.4 | 1.3% | `tests/board-write-record.bats` | -| 17.1 | 1.2% | `tests/config-deprecations.bats` | -| 16.3 | 1.2% | `tests/token-bench.bats` | -| 14.4 | 1.0% | `tests/config-lint.bats` | -| 11.3 | 0.8% | `tests/run-shape-guard.bats` | -| 10.6 | 0.8% | `tests/board-sweep.bats` | -| 10.4 | 0.8% | `tests/released.bats` | -| 10.1 | 0.7% | `tests/stop-guard.bats` | -| 9.8 | 0.7% | `tests/ready-lint.bats` | -| 8.9 | 0.6% | `tests/filed-here-check.bats` | -| 8.7 | 0.6% | `tests/target-race.bats` | -| 8.5 | 0.6% | `tests/mcp-allow-check.bats` | -| 8.2 | 0.6% | `tests/renovate-config-validator.bats` | -| 8.1 | 0.6% | `tests/release-tracking-check.bats` | -| 7.9 | 0.6% | `tests/mutant.bats` | -| 7.7 | 0.6% | `tests/reference-check.bats` | -| 7.6 | 0.5% | `tests/replay.bats` | -| 7.6 | 0.5% | `tests/ready-guard.bats` | -| 6.5 | 0.5% | `tests/release-assets-check.bats` | -| 6.4 | 0.5% | `tests/lock-complete.bats` | -| 6.4 | 0.5% | `tests/step-receipt.bats` | -| 6.2 | 0.4% | `tests/sbom.bats` | -| 5.8 | 0.4% | `tests/task-registry.bats` | -| 5.5 | 0.4% | `tests/in-progress-drain.bats` | -| 5.3 | 0.4% | `tests/singleton.bats` | -| 5.1 | 0.4% | `tests/schema-check.bats` | -| 4.9 | 0.4% | `tests/hk-selection.bats` | -| 4.7 | 0.3% | `tests/ntia-check.bats` | -| 4.4 | 0.3% | `tests/unlanded-check.bats` | -| 4.3 | 0.3% | `tests/ready-cites-check.bats` | -| 4.2 | 0.3% | `tests/land-divergence.bats` | -| 4.1 | 0.3% | `tests/pre-commit-staging.bats` | -| 3.7 | 0.3% | `tests/suite-select.bats` | -| 3.6 | 0.3% | `tests/rules-drift.bats` | -| 3.6 | 0.3% | `tests/doctor-race.bats` | -| 3.6 | 0.3% | `tests/target-ensure.bats` | -| 3.5 | 0.3% | `tests/semver.bats` | -| 3.4 | 0.2% | `tests/with-lock.bats` | -| 3.4 | 0.2% | `tests/landed-check.bats` | -| 3.2 | 0.2% | `tests/run-shape.bats` | -| 3.1 | 0.2% | `tests/closing-key-check.bats` | -| 3.0 | 0.2% | `tests/tree-clean.bats` | -| 3.0 | 0.2% | `tests/review-answered.bats` | -| 2.6 | 0.2% | `tests/bot-issue.bats` | -| 2.6 | 0.2% | `tests/spec-ref-check.bats` | -| 2.5 | 0.2% | `tests/claim-race-check.bats` | -| 2.4 | 0.2% | `tests/skill-check.bats` | -| 2.4 | 0.2% | `tests/alive.bats` | -| 2.3 | 0.2% | `tests/ci-tools-check.bats` | -| 2.3 | 0.2% | `tests/finding-sink-check.bats` | -| 2.3 | 0.2% | `tests/signing-posture.bats` | -| 2.2 | 0.2% | `tests/claimed-keys.bats` | -| 2.2 | 0.2% | `tests/verify.bats` | -| 2.2 | 0.2% | `tests/timeout-drift.bats` | -| 2.2 | 0.2% | `tests/target-prune.bats` | -| 2.0 | 0.1% | `tests/ci-lease-precondition.bats` | -| 1.9 | 0.1% | `tests/ci-slow-needed.bats` | -| 1.8 | 0.1% | `tests/reclaim-census.bats` | -| 1.8 | 0.1% | `tests/ready-lint-deferral.bats` | -| 1.8 | 0.1% | `tests/awk-regex-check.bats` | -| 1.7 | 0.1% | `tests/mutant-census.bats` | -| 1.7 | 0.1% | `tests/spawn-census.bats` | +| 162.2 | 10.7% | `tests/derived-check.bats` | +| 143.5 | 9.4% | `tests/land-lock.bats` | +| 107.9 | 7.1% | `tests/session-start.bats` | +| 104.3 | 6.9% | `tests/ci-wait.bats` | +| 96.6 | 6.3% | `tests/land.bats` | +| 92.9 | 6.1% | `tests/ci-local-parity.bats` | +| 76.2 | 5.0% | `tests/hooks-wiring-check.bats` | +| 40.0 | 2.6% | `tests/claim-check.bats` | +| 39.6 | 2.6% | `tests/board-diff-overlap.bats` | +| 39.5 | 2.6% | `tests/board-write-record.bats` | +| 34.4 | 2.3% | `tests/main-watch.bats` | +| 29.5 | 1.9% | `tests/prebuilt-lint.bats` | +| 26.2 | 1.7% | `tests/helpers.bats` | +| 24.8 | 1.6% | `tests/sbom-check.bats` | +| 24.8 | 1.6% | `tests/config-lint.bats` | +| 24.4 | 1.6% | `tests/board-sweep.bats` | +| 24.3 | 1.6% | `tests/hook-latency-drift.bats` | +| 24.3 | 1.6% | `tests/graph-check.bats` | +| 20.7 | 1.4% | `tests/token-bench.bats` | +| 12.6 | 0.8% | `tests/filed-here-check.bats` | +| 11.5 | 0.8% | `tests/run-shape-guard.bats` | +| 11.4 | 0.8% | `tests/commit-convention.bats` | +| 11.0 | 0.7% | `tests/released.bats` | +| 10.5 | 0.7% | `tests/target-race.bats` | +| 10.0 | 0.7% | `tests/stop-guard.bats` | +| 9.9 | 0.6% | `tests/ready-lint.bats` | +| 9.1 | 0.6% | `tests/renovate-config-validator.bats` | +| 9.0 | 0.6% | `tests/pre-commit-staging.bats` | +| 8.5 | 0.6% | `tests/release-tracking-check.bats` | +| 8.4 | 0.6% | `tests/mutant.bats` | +| 8.3 | 0.5% | `tests/replay.bats` | +| 8.0 | 0.5% | `tests/ready-guard.bats` | +| 7.8 | 0.5% | `tests/lock-complete.bats` | +| 7.1 | 0.5% | `tests/release-assets-check.bats` | +| 6.8 | 0.4% | `tests/sbom.bats` | +| 6.8 | 0.4% | `tests/mcp-allow-check.bats` | +| 6.7 | 0.4% | `tests/bot-issue.bats` | +| 6.7 | 0.4% | `tests/step-receipt.bats` | +| 6.0 | 0.4% | `tests/task-registry.bats` | +| 5.9 | 0.4% | `tests/closing-key-check.bats` | +| 5.4 | 0.4% | `tests/in-progress-drain.bats` | +| 5.2 | 0.3% | `tests/schema-check.bats` | +| 5.1 | 0.3% | `tests/alive.bats` | +| 4.9 | 0.3% | `tests/hk-selection.bats` | +| 4.9 | 0.3% | `tests/fanout-guard.bats` | +| 4.8 | 0.3% | `tests/singleton.bats` | +| 4.8 | 0.3% | `tests/doctor-race.bats` | +| 4.8 | 0.3% | `tests/land-divergence.bats` | +| 4.6 | 0.3% | `tests/ci-tools-check.bats` | +| 4.5 | 0.3% | `tests/ntia-check.bats` | +| 4.4 | 0.3% | `tests/reference-check.bats` | +| 4.3 | 0.3% | `tests/ci-slow-needed.bats` | +| 4.3 | 0.3% | `tests/ci-lease-precondition.bats` | +| 4.1 | 0.3% | `tests/ready-cites-check.bats` | +| 4.0 | 0.3% | `tests/claimed-keys.bats` | +| 3.9 | 0.3% | `tests/rules-drift.bats` | +| 3.8 | 0.3% | `tests/claim-race-check.bats` | +| 3.8 | 0.2% | `tests/semver.bats` | +| 3.7 | 0.2% | `tests/suite-select.bats` | +| 3.5 | 0.2% | `tests/landed-check.bats` | +| 3.4 | 0.2% | `tests/target-ensure.bats` | +| 3.3 | 0.2% | `tests/verify.bats` | +| 3.3 | 0.2% | `tests/with-lock.bats` | +| 3.2 | 0.2% | `tests/awk-regex-check.bats` | +| 3.1 | 0.2% | `tests/deferral-check.bats` | +| 3.1 | 0.2% | `tests/finding-sink-check.bats` | +| 3.0 | 0.2% | `tests/attestation-check.bats` | +| 2.9 | 0.2% | `tests/unlanded-check.bats` | +| 2.9 | 0.2% | `tests/tree-clean.bats` | +| 2.8 | 0.2% | `tests/spec-ref-check.bats` | +| 2.6 | 0.2% | `tests/timeout-drift.bats` | +| 2.6 | 0.2% | `tests/target-prune.bats` | +| 2.3 | 0.2% | `tests/skill-check.bats` | +| 2.1 | 0.1% | `tests/signing-posture.bats` | +| 2.0 | 0.1% | `tests/done-check.bats` | +| 1.9 | 0.1% | `tests/reclaim-census.bats` | +| 1.8 | 0.1% | `tests/checksums.bats` | +| 1.8 | 0.1% | `tests/checks-green.bats` | +| 1.7 | 0.1% | `tests/ready-lint-deferral.bats` | +| 1.6 | 0.1% | `tests/install-check.bats` | +| 1.6 | 0.1% | `tests/duplicate-close-check.bats` | +| 1.6 | 0.1% | `tests/spawn-census.bats` | | 1.6 | 0.1% | `tests/memories-check.bats` | -| 1.4 | 0.1% | `tests/deferral-check.bats` | -| 1.4 | 0.1% | `tests/perf-record.bats` | -| 1.4 | 0.1% | `tests/done-check.bats` | +| 1.6 | 0.1% | `tests/branch-age-check.bats` | +| 1.6 | 0.1% | `tests/mutant-census.bats` | +| 1.6 | 0.1% | `tests/perf-record.bats` | +| 1.5 | 0.1% | `tests/doctor.bats` | +| 1.5 | 0.1% | `tests/connector-verb-guard.bats` | +| 1.5 | 0.1% | `tests/board-payloads.bats` | +| 1.4 | 0.1% | `tests/done-pr-check.bats` | | 1.4 | 0.1% | `tests/land-divergence-assert.bats` | -| 1.3 | 0.1% | `tests/checks-green.bats` | -| 1.3 | 0.1% | `tests/install-check.bats` | +| 1.4 | 0.1% | `tests/release-backfill.bats` | +| 1.4 | 0.1% | `tests/evaluator-closure-check.bats` | +| 1.4 | 0.1% | `tests/linear-check.bats` | | 1.3 | 0.1% | `tests/nonverdict-scan.bats` | -| 1.3 | 0.1% | `tests/linear-check.bats` | -| 1.3 | 0.1% | `tests/attestation-check.bats` | -| 1.3 | 0.1% | `tests/release-backfill.bats` | | 1.2 | 0.1% | `tests/perf-assert.bats` | -| 1.1 | 0.1% | `tests/lint-deno.bats` | -| 1.1 | 0.1% | `tests/fact-record-keying.bats` | +| 1.2 | 0.1% | `tests/render-cli.bats` | +| 1.1 | 0.1% | `tests/digest-major-agreement.bats` | +| 1.1 | 0.1% | `tests/module-map-check.bats` | | 1.1 | 0.1% | `tests/gh-guard.bats` | -| 1.1 | 0.1% | `tests/render-cli.bats` | -| 1.0 | 0.1% | `tests/done-pr-check.bats` | -| 1.0 | 0.1% | `tests/prose-only-check.bats` | -| 1.0 | 0.1% | `tests/timeout-check.bats` | -| 1.0 | 0.1% | `tests/privileged-lane.bats` | -| 1.0 | 0.1% | `tests/module-map-check.bats` | +| 1.1 | 0.1% | `tests/stop-posture-check.bats` | +| 1.1 | 0.1% | `tests/transcript-corpus-check.bats` | +| 1.1 | 0.1% | `tests/lint-deno.bats` | +| 1.1 | 0.1% | `tests/ci-drift.bats` | +| 1.0 | 0.1% | `tests/abandon-matrix.bats` | +| 1.0 | 0.1% | `tests/connector-allow-guard.bats` | +| 1.0 | 0.1% | `tests/batten-glob-check.bats` | +| 1.0 | 0.1% | `tests/sbom-binary.bats` | +| 1.0 | 0.1% | `tests/suite-bench-check.bats` | +| 1.0 | 0.1% | `tests/install.bats` | | 1.0 | 0.1% | `tests/pr-unsubscribed.bats` | -| 0.9 | 0.1% | `tests/transcript-corpus-check.bats` | -| 0.9 | 0.1% | `tests/stop-posture-check.bats` | -| 0.9 | 0.1% | `tests/sbom-binary.bats` | -| 0.9 | 0.1% | `tests/doctor.bats` | -| 0.9 | 0.1% | `tests/evaluator-closure-check.bats` | -| 0.9 | 0.1% | `tests/checksums.bats` | | 0.9 | 0.1% | `tests/lint-rego.bats` | -| 0.9 | 0.1% | `tests/mcp-attach-check.bats` | -| 0.9 | 0.1% | `tests/suite-bench-check.bats` | -| 0.9 | 0.1% | `tests/mcp-timeout-budget.bats` | +| 0.9 | 0.1% | `tests/prose-only-check.bats` | +| 0.9 | 0.1% | `tests/hook-pin-check.bats` | +| 0.9 | 0.1% | `tests/verified.bats` | +| 0.9 | 0.1% | `tests/timeout-check.bats` | | 0.9 | 0.1% | `tests/perf-compare.bats` | -| 0.9 | 0.1% | `tests/merged-pr-keys.bats` | -| 0.8 | 0.1% | `tests/duplicate-close-check.bats` | -| 0.8 | 0.1% | `tests/hook-matcher-check.bats` | +| 0.9 | 0.1% | `tests/hook-matcher-check.bats` | +| 0.8 | 0.1% | `tests/mcp-timeout-budget.bats` | | 0.8 | 0.1% | `tests/hook-profile-check.bats` | -| 0.8 | 0.1% | `tests/install.bats` | -| 0.8 | 0.1% | `tests/verified.bats` | -| 0.8 | 0.1% | `tests/macos-link-check.bats` | -| 0.7 | 0.1% | `tests/hook-pin-check.bats` | -| 0.7 | 0.0% | `tests/branch-age-check.bats` | -| 0.6 | 0.0% | `tests/board-payloads.bats` | -| 0.6 | 0.0% | `tests/publish-credential-check.bats` | -| 0.6 | 0.0% | `tests/abandon-matrix.bats` | +| 0.8 | 0.1% | `tests/mcp-attach-check.bats` | +| 0.8 | 0.1% | `tests/connector-allow-resolve.bats` | +| 0.7 | 0.0% | `tests/merged-pr-keys.bats` | +| 0.7 | 0.0% | `tests/container-preflight.bats` | +| 0.7 | 0.0% | `tests/commit-attribution.bats` | +| 0.7 | 0.0% | `tests/macos-link-check.bats` | +| 0.7 | 0.0% | `tests/sonar-gate.bats` | +| 0.6 | 0.0% | `tests/coderabbit-config-check.bats` | +| 0.6 | 0.0% | `tests/serena-mcp.bats` | | 0.6 | 0.0% | `tests/pipefail-grep-check.bats` | -| 0.6 | 0.0% | `tests/sonar-gate.bats` | -| 0.6 | 0.0% | `tests/msrv-pin-agreement.bats` | | 0.6 | 0.0% | `tests/run-shape-guard-quoting.bats` | -| 0.6 | 0.0% | `tests/connector-allow-guard.bats` | +| 0.6 | 0.0% | `tests/cap-drift.bats` | +| 0.5 | 0.0% | `tests/pkl-check.bats` | +| 0.5 | 0.0% | `tests/publish-credential-check.bats` | +| 0.5 | 0.0% | `tests/token-bench-check.bats` | | 0.5 | 0.0% | `tests/land-lock-check.bats` | +| 0.5 | 0.0% | `tests/release-due.bats` | +| 0.5 | 0.0% | `tests/msrv-pin-agreement.bats` | | 0.5 | 0.0% | `tests/license-table-check.bats` | -| 0.5 | 0.0% | `tests/token-bench-check.bats` | -| 0.5 | 0.0% | `tests/pkl-check.bats` | -| 0.5 | 0.0% | `tests/digest-major-agreement.bats` | -| 0.5 | 0.0% | `tests/cap-drift.bats` | -| 0.5 | 0.0% | `tests/serena-mcp.bats` | -| 0.5 | 0.0% | `tests/batten-glob-check.bats` | -| 0.4 | 0.0% | `tests/release-due.bats` | -| 0.4 | 0.0% | `tests/nonverdict-assert.bats` | -| 0.4 | 0.0% | `tests/ci-drift.bats` | -| 0.4 | 0.0% | `tests/no-doctests.bats` | -| 0.4 | 0.0% | `tests/container-preflight.bats` | +| 0.5 | 0.0% | `tests/task-fail-closed.bats` | | 0.4 | 0.0% | `tests/report-only-check.bats` | -| 0.4 | 0.0% | `tests/commit-attribution.bats` | -| 0.4 | 0.0% | `tests/container-setup.bats` | -| 0.4 | 0.0% | `tests/connector-allow-resolve.bats` | +| 0.4 | 0.0% | `tests/no-doctests.bats` | +| 0.4 | 0.0% | `tests/nonverdict-assert.bats` | | 0.4 | 0.0% | `tests/mise-pin-agreement.bats` | -| 0.3 | 0.0% | `tests/coderabbit-config-check.bats` | -| 0.3 | 0.0% | `tests/task-fail-closed.bats` | -| 0.3 | 0.0% | `tests/mise-action-floor.bats` | +| 0.4 | 0.0% | `tests/dist.bats` | +| 0.4 | 0.0% | `tests/test-bats-parallel.bats` | | 0.3 | 0.0% | `tests/rust-paths-check.bats` | -| 0.3 | 0.0% | `tests/test-bats-parallel.bats` | | 0.3 | 0.0% | `tests/git-hook.bats` | -| 0.3 | 0.0% | `tests/remedy-payload-source.bats` | -| 0.3 | 0.0% | `tests/perf-gate.bats` | -| 0.2 | 0.0% | `tests/dist.bats` | +| 0.3 | 0.0% | `tests/egress-check.bats` | +| 0.3 | 0.0% | `tests/mise-action-floor.bats` | +| 0.2 | 0.0% | `tests/remedy-payload-source.bats` | +| 0.2 | 0.0% | `tests/evaluator-io-check.bats` | +| 0.2 | 0.0% | `tests/perf-gate.bats` | +| 0.2 | 0.0% | `tests/darwin-link.bats` | +| 0.2 | 0.0% | `tests/cross-check.bats` | | 0.1 | 0.0% | `tests/perf-pair.bats` | -| 0.1 | 0.0% | `tests/egress-check.bats` | -| 0.1 | 0.0% | `tests/evaluator-io-check.bats` | | 0.1 | 0.0% | `tests/zizmor-split.bats` | -| 0.1 | 0.0% | `tests/darwin-link.bats` | -| 0.1 | 0.0% | `tests/cross-check.bats` | diff --git a/completions/batten.bash b/completions/batten.bash index a8bd906a9..517acc1e8 100644 --- a/completions/batten.bash +++ b/completions/batten.bash @@ -382,6 +382,9 @@ _batten() { batten__subcmd__help__subcmd__policy,budget) cmd="batten__subcmd__help__subcmd__policy__subcmd__budget" ;; + batten__subcmd__help__subcmd__policy,explain) + cmd="batten__subcmd__help__subcmd__policy__subcmd__explain" + ;; batten__subcmd__help__subcmd__policy,test) cmd="batten__subcmd__help__subcmd__policy__subcmd__test" ;; @@ -442,6 +445,9 @@ _batten() { batten__subcmd__policy,budget) cmd="batten__subcmd__policy__subcmd__budget" ;; + batten__subcmd__policy,explain) + cmd="batten__subcmd__policy__subcmd__explain" + ;; batten__subcmd__policy,help) cmd="batten__subcmd__policy__subcmd__help" ;; @@ -454,6 +460,9 @@ _batten() { batten__subcmd__policy__subcmd__help,budget) cmd="batten__subcmd__policy__subcmd__help__subcmd__budget" ;; + batten__subcmd__policy__subcmd__help,explain) + cmd="batten__subcmd__policy__subcmd__help__subcmd__explain" + ;; batten__subcmd__policy__subcmd__help,help) cmd="batten__subcmd__policy__subcmd__help__subcmd__help" ;; @@ -2480,7 +2489,7 @@ _batten() { return 0 ;; batten__subcmd__help__subcmd__policy) - opts="budget test tools" + opts="budget test explain" if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 @@ -2507,6 +2516,20 @@ _batten() { COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 ;; + batten__subcmd__help__subcmd__policy__subcmd__explain) + 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__policy__subcmd__test) opts="" if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then @@ -2984,7 +3007,7 @@ _batten() { return 0 ;; batten__subcmd__policy) - opts="-q -v -y -h --strictness --fail-on-warning --config-from --silent --quiet --verbose --debug --trace --log-level --no-color --no-input --yes --help budget test tools help" + opts="-q -v -y -h --strictness --fail-on-warning --config-from --silent --quiet --verbose --debug --trace --log-level --no-color --no-input --yes --help budget test explain help" if [[ ${cur} == -* || ${COMP_CWORD} -eq 2 ]] ; then COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 @@ -3035,8 +3058,34 @@ _batten() { COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 ;; + batten__subcmd__policy__subcmd__explain) + opts="-J -q -v -y -h --json --strictness --fail-on-warning --config-from --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 + ;; + --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__policy__subcmd__help) - opts="budget test tools help" + opts="budget test explain help" if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 @@ -3063,6 +3112,20 @@ _batten() { COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 ;; + batten__subcmd__policy__subcmd__help__subcmd__explain) + 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__policy__subcmd__help__subcmd__help) opts="" if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then diff --git a/completions/batten.fish b/completions/batten.fish index 1fa3a743c..ed39085c4 100644 --- a/completions/batten.fish +++ b/completions/batten.fish @@ -659,30 +659,30 @@ complete -c batten -n "__fish_batten_using_subcommand generate; and __fish_seen_ complete -c batten -n "__fish_batten_using_subcommand generate; and __fish_seen_subcommand_from help" -f -a "markdown" -d 'Emit the whole command surface as one markdown reference, on stdout' complete -c batten -n "__fish_batten_using_subcommand generate; and __fish_seen_subcommand_from help" -f -a "schema" -d 'Emit the JSON Schema for a config or policy-input surface, derived from the types that define it' complete -c batten -n "__fish_batten_using_subcommand generate; 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 policy; and not __fish_seen_subcommand_from budget test tools 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 policy; and not __fish_seen_subcommand_from budget test explain 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 policy; and not __fish_seen_subcommand_from budget test tools 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 policy; and not __fish_seen_subcommand_from budget test tools 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 policy; and not __fish_seen_subcommand_from budget test explain 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 policy; and not __fish_seen_subcommand_from budget test explain 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 policy; and not __fish_seen_subcommand_from budget test tools 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 policy; and not __fish_seen_subcommand_from budget test tools help" -l silent -d 'Say nothing but a verdict or a usage error' -complete -c batten -n "__fish_batten_using_subcommand policy; and not __fish_seen_subcommand_from budget test tools help" -s q -l quiet -d 'Suppress ordinary progress (repeatable: -qq is silent)' -complete -c batten -n "__fish_batten_using_subcommand policy; and not __fish_seen_subcommand_from budget test tools help" -s v -l verbose -d 'Explain what is being checked (repeatable: -vv is debug)' -complete -c batten -n "__fish_batten_using_subcommand policy; and not __fish_seen_subcommand_from budget test tools help" -l debug -d 'Add resolution detail' -complete -c batten -n "__fish_batten_using_subcommand policy; and not __fish_seen_subcommand_from budget test tools help" -l trace -d 'Add everything' -complete -c batten -n "__fish_batten_using_subcommand policy; and not __fish_seen_subcommand_from budget test tools help" -l no-color -d 'Never colour stderr, whatever it is attached to' -complete -c batten -n "__fish_batten_using_subcommand policy; and not __fish_seen_subcommand_from budget test tools help" -l no-input -d 'Never prompt; treat the run as unattended' -complete -c batten -n "__fish_batten_using_subcommand policy; and not __fish_seen_subcommand_from budget test tools help" -s y -l yes -d 'Confirm a destructive operation that would otherwise refuse' -complete -c batten -n "__fish_batten_using_subcommand policy; and not __fish_seen_subcommand_from budget test tools help" -s h -l help -d 'Print help (see more with \'--help\')' -complete -c batten -n "__fish_batten_using_subcommand policy; and not __fish_seen_subcommand_from budget test tools help" -f -a "budget" -d 'Judge the always-loaded instruction set against its declared token budget' -complete -c batten -n "__fish_batten_using_subcommand policy; and not __fish_seen_subcommand_from budget test tools help" -f -a "test" -d 'Run each registered module\'s own `test_` rules and report the predicates none exercised' -complete -c batten -n "__fish_batten_using_subcommand policy; and not __fish_seen_subcommand_from budget test tools help" -f -a "tools" -d 'Print the tool names the mediated-call rows decide, one per line' -complete -c batten -n "__fish_batten_using_subcommand policy; and not __fish_seen_subcommand_from budget test tools help" -f -a "help" -d 'Print this message or the help of the given subcommand(s)' +complete -c batten -n "__fish_batten_using_subcommand policy; and not __fish_seen_subcommand_from budget test explain 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 policy; and not __fish_seen_subcommand_from budget test explain help" -l silent -d 'Say nothing but a verdict or a usage error' +complete -c batten -n "__fish_batten_using_subcommand policy; and not __fish_seen_subcommand_from budget test explain help" -s q -l quiet -d 'Suppress ordinary progress (repeatable: -qq is silent)' +complete -c batten -n "__fish_batten_using_subcommand policy; and not __fish_seen_subcommand_from budget test explain help" -s v -l verbose -d 'Explain what is being checked (repeatable: -vv is debug)' +complete -c batten -n "__fish_batten_using_subcommand policy; and not __fish_seen_subcommand_from budget test explain help" -l debug -d 'Add resolution detail' +complete -c batten -n "__fish_batten_using_subcommand policy; and not __fish_seen_subcommand_from budget test explain help" -l trace -d 'Add everything' +complete -c batten -n "__fish_batten_using_subcommand policy; and not __fish_seen_subcommand_from budget test explain help" -l no-color -d 'Never colour stderr, whatever it is attached to' +complete -c batten -n "__fish_batten_using_subcommand policy; and not __fish_seen_subcommand_from budget test explain help" -l no-input -d 'Never prompt; treat the run as unattended' +complete -c batten -n "__fish_batten_using_subcommand policy; and not __fish_seen_subcommand_from budget test explain help" -s y -l yes -d 'Confirm a destructive operation that would otherwise refuse' +complete -c batten -n "__fish_batten_using_subcommand policy; and not __fish_seen_subcommand_from budget test explain help" -s h -l help -d 'Print help (see more with \'--help\')' +complete -c batten -n "__fish_batten_using_subcommand policy; and not __fish_seen_subcommand_from budget test explain help" -f -a "budget" -d 'Judge the always-loaded instruction set against its declared token budget' +complete -c batten -n "__fish_batten_using_subcommand policy; and not __fish_seen_subcommand_from budget test explain help" -f -a "test" -d 'Run each registered module\'s own `test_` rules and report the predicates none exercised' +complete -c batten -n "__fish_batten_using_subcommand policy; and not __fish_seen_subcommand_from budget test explain help" -f -a "explain" -d 'Resolve a verdict token to its class definition and the routes out of it' +complete -c batten -n "__fish_batten_using_subcommand policy; and not __fish_seen_subcommand_from budget test explain help" -f -a "help" -d 'Print this message or the help of the given subcommand(s)' complete -c batten -n "__fish_batten_using_subcommand policy; and __fish_seen_subcommand_from budget" -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'" @@ -725,30 +725,30 @@ complete -c batten -n "__fish_batten_using_subcommand policy; and __fish_seen_su complete -c batten -n "__fish_batten_using_subcommand policy; and __fish_seen_subcommand_from test" -l no-input -d 'Never prompt; treat the run as unattended' complete -c batten -n "__fish_batten_using_subcommand policy; and __fish_seen_subcommand_from test" -s y -l yes -d 'Confirm a destructive operation that would otherwise refuse' complete -c batten -n "__fish_batten_using_subcommand policy; and __fish_seen_subcommand_from test" -s h -l help -d 'Print help (see more with \'--help\')' -complete -c batten -n "__fish_batten_using_subcommand policy; and __fish_seen_subcommand_from tools" -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 policy; and __fish_seen_subcommand_from explain" -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 policy; and __fish_seen_subcommand_from tools" -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 policy; and __fish_seen_subcommand_from tools" -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 policy; and __fish_seen_subcommand_from explain" -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 policy; and __fish_seen_subcommand_from explain" -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 policy; and __fish_seen_subcommand_from tools" -s J -l json -d 'Emit byte-stable JSON instead of pointer lines' -complete -c batten -n "__fish_batten_using_subcommand policy; and __fish_seen_subcommand_from tools" -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 policy; and __fish_seen_subcommand_from tools" -l silent -d 'Say nothing but a verdict or a usage error' -complete -c batten -n "__fish_batten_using_subcommand policy; and __fish_seen_subcommand_from tools" -s q -l quiet -d 'Suppress ordinary progress (repeatable: -qq is silent)' -complete -c batten -n "__fish_batten_using_subcommand policy; and __fish_seen_subcommand_from tools" -s v -l verbose -d 'Explain what is being checked (repeatable: -vv is debug)' -complete -c batten -n "__fish_batten_using_subcommand policy; and __fish_seen_subcommand_from tools" -l debug -d 'Add resolution detail' -complete -c batten -n "__fish_batten_using_subcommand policy; and __fish_seen_subcommand_from tools" -l trace -d 'Add everything' -complete -c batten -n "__fish_batten_using_subcommand policy; and __fish_seen_subcommand_from tools" -l no-color -d 'Never colour stderr, whatever it is attached to' -complete -c batten -n "__fish_batten_using_subcommand policy; and __fish_seen_subcommand_from tools" -l no-input -d 'Never prompt; treat the run as unattended' -complete -c batten -n "__fish_batten_using_subcommand policy; and __fish_seen_subcommand_from tools" -s y -l yes -d 'Confirm a destructive operation that would otherwise refuse' -complete -c batten -n "__fish_batten_using_subcommand policy; and __fish_seen_subcommand_from tools" -s h -l help -d 'Print help (see more with \'--help\')' +complete -c batten -n "__fish_batten_using_subcommand policy; and __fish_seen_subcommand_from explain" -s J -l json -d 'Emit byte-stable JSON instead of pointer lines' +complete -c batten -n "__fish_batten_using_subcommand policy; and __fish_seen_subcommand_from explain" -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 policy; and __fish_seen_subcommand_from explain" -l silent -d 'Say nothing but a verdict or a usage error' +complete -c batten -n "__fish_batten_using_subcommand policy; and __fish_seen_subcommand_from explain" -s q -l quiet -d 'Suppress ordinary progress (repeatable: -qq is silent)' +complete -c batten -n "__fish_batten_using_subcommand policy; and __fish_seen_subcommand_from explain" -s v -l verbose -d 'Explain what is being checked (repeatable: -vv is debug)' +complete -c batten -n "__fish_batten_using_subcommand policy; and __fish_seen_subcommand_from explain" -l debug -d 'Add resolution detail' +complete -c batten -n "__fish_batten_using_subcommand policy; and __fish_seen_subcommand_from explain" -l trace -d 'Add everything' +complete -c batten -n "__fish_batten_using_subcommand policy; and __fish_seen_subcommand_from explain" -l no-color -d 'Never colour stderr, whatever it is attached to' +complete -c batten -n "__fish_batten_using_subcommand policy; and __fish_seen_subcommand_from explain" -l no-input -d 'Never prompt; treat the run as unattended' +complete -c batten -n "__fish_batten_using_subcommand policy; and __fish_seen_subcommand_from explain" -s y -l yes -d 'Confirm a destructive operation that would otherwise refuse' +complete -c batten -n "__fish_batten_using_subcommand policy; and __fish_seen_subcommand_from explain" -s h -l help -d 'Print help (see more with \'--help\')' complete -c batten -n "__fish_batten_using_subcommand policy; and __fish_seen_subcommand_from help" -f -a "budget" -d 'Judge the always-loaded instruction set against its declared token budget' complete -c batten -n "__fish_batten_using_subcommand policy; and __fish_seen_subcommand_from help" -f -a "test" -d 'Run each registered module\'s own `test_` rules and report the predicates none exercised' -complete -c batten -n "__fish_batten_using_subcommand policy; and __fish_seen_subcommand_from help" -f -a "tools" -d 'Print the tool names the mediated-call rows decide, one per line' +complete -c batten -n "__fish_batten_using_subcommand policy; and __fish_seen_subcommand_from help" -f -a "explain" -d 'Resolve a verdict token to its class definition and the routes out of it' complete -c batten -n "__fish_batten_using_subcommand policy; 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 commit; 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' @@ -1407,7 +1407,7 @@ 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 generate" -f -a "schema" -d 'Emit the JSON Schema for a config or policy-input surface, derived from the types that define it' complete -c batten -n "__fish_batten_using_subcommand help; and __fish_seen_subcommand_from policy" -f -a "budget" -d 'Judge the always-loaded instruction set against its declared token budget' complete -c batten -n "__fish_batten_using_subcommand help; and __fish_seen_subcommand_from policy" -f -a "test" -d 'Run each registered module\'s own `test_` rules and report the predicates none exercised' -complete -c batten -n "__fish_batten_using_subcommand help; and __fish_seen_subcommand_from policy" -f -a "tools" -d 'Print the tool names the mediated-call rows decide, one per line' +complete -c batten -n "__fish_batten_using_subcommand help; and __fish_seen_subcommand_from policy" -f -a "explain" -d 'Resolve a verdict token to its class definition and the routes out of it' complete -c batten -n "__fish_batten_using_subcommand help; and __fish_seen_subcommand_from commit" -f -a "check" -d 'Refuse a commit subject that does not follow the configured convention' 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' diff --git a/completions/batten.zsh b/completions/batten.zsh index 2e96768e7..0bafa473d 100644 --- a/completions/batten.zsh +++ b/completions/batten.zsh @@ -1130,7 +1130,7 @@ trace\:"Add everything"))' \ '--help[Print help (see more with '\''--help'\'')]' \ && ret=0 ;; -(tools) +(explain) _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" @@ -1158,6 +1158,7 @@ trace\:"Add everything"))' \ '--yes[Confirm a destructive operation that would otherwise refuse]' \ '-h[Print help (see more with '\''--help'\'')]' \ '--help[Print help (see more with '\''--help'\'')]' \ +':token -- The verdict token to resolve, e.g. V-TASK-UNDEFINED:_default' \ && ret=0 ;; (help) @@ -1180,7 +1181,7 @@ _arguments "${_arguments_options[@]}" : \ _arguments "${_arguments_options[@]}" : \ && ret=0 ;; -(tools) +(explain) _arguments "${_arguments_options[@]}" : \ && ret=0 ;; @@ -2531,7 +2532,7 @@ _arguments "${_arguments_options[@]}" : \ _arguments "${_arguments_options[@]}" : \ && ret=0 ;; -(tools) +(explain) _arguments "${_arguments_options[@]}" : \ && ret=0 ;; @@ -3448,7 +3449,7 @@ _batten__subcmd__help__subcmd__policy_commands() { local commands; commands=( 'budget:Judge the always-loaded instruction set against its declared token budget' \ 'test:Run each registered module'\''s own \`test_\` rules and report the predicates none exercised' \ -'tools:Print the tool names the mediated-call rows decide, one per line' \ +'explain:Resolve a verdict token to its class definition and the routes out of it' \ ) _describe -t commands 'batten help policy commands' commands "$@" } @@ -3457,6 +3458,11 @@ _batten__subcmd__help__subcmd__policy__subcmd__budget_commands() { local commands; commands=() _describe -t commands 'batten help policy budget commands' commands "$@" } +(( $+functions[_batten__subcmd__help__subcmd__policy__subcmd__explain_commands] )) || +_batten__subcmd__help__subcmd__policy__subcmd__explain_commands() { + local commands; commands=() + _describe -t commands 'batten help policy explain commands' commands "$@" +} (( $+functions[_batten__subcmd__help__subcmd__policy__subcmd__test_commands] )) || _batten__subcmd__help__subcmd__policy__subcmd__test_commands() { local commands; commands=() @@ -3627,7 +3633,7 @@ _batten__subcmd__policy_commands() { local commands; commands=( 'budget:Judge the always-loaded instruction set against its declared token budget' \ 'test:Run each registered module'\''s own \`test_\` rules and report the predicates none exercised' \ -'tools:Print the tool names the mediated-call rows decide, one per line' \ +'explain:Resolve a verdict token to its class definition and the routes out of it' \ 'help:Print this message or the help of the given subcommand(s)' \ ) _describe -t commands 'batten policy commands' commands "$@" @@ -3637,12 +3643,17 @@ _batten__subcmd__policy__subcmd__budget_commands() { local commands; commands=() _describe -t commands 'batten policy budget commands' commands "$@" } +(( $+functions[_batten__subcmd__policy__subcmd__explain_commands] )) || +_batten__subcmd__policy__subcmd__explain_commands() { + local commands; commands=() + _describe -t commands 'batten policy explain commands' commands "$@" +} (( $+functions[_batten__subcmd__policy__subcmd__help_commands] )) || _batten__subcmd__policy__subcmd__help_commands() { local commands; commands=( 'budget:Judge the always-loaded instruction set against its declared token budget' \ 'test:Run each registered module'\''s own \`test_\` rules and report the predicates none exercised' \ -'tools:Print the tool names the mediated-call rows decide, one per line' \ +'explain:Resolve a verdict token to its class definition and the routes out of it' \ 'help:Print this message or the help of the given subcommand(s)' \ ) _describe -t commands 'batten policy help commands' commands "$@" @@ -3652,6 +3663,11 @@ _batten__subcmd__policy__subcmd__help__subcmd__budget_commands() { local commands; commands=() _describe -t commands 'batten policy help budget commands' commands "$@" } +(( $+functions[_batten__subcmd__policy__subcmd__help__subcmd__explain_commands] )) || +_batten__subcmd__policy__subcmd__help__subcmd__explain_commands() { + local commands; commands=() + _describe -t commands 'batten policy help explain commands' commands "$@" +} (( $+functions[_batten__subcmd__policy__subcmd__help__subcmd__help_commands] )) || _batten__subcmd__policy__subcmd__help__subcmd__help_commands() { local commands; commands=() diff --git a/crates/batten/src/admission.rs b/crates/batten/src/admission.rs new file mode 100644 index 000000000..8022414af --- /dev/null +++ b/crates/batten/src/admission.rs @@ -0,0 +1,811 @@ +//! Issued admissions: an override stops being knowledge and becomes a record +//! (CLOUD-1051). +//! +//! # The defect this replaces +//! +//! `BATTEN_PROSE_ONLY_OVERRIDE=1` and `BATTEN_FILED_HERE_OVERLAP=1` share one +//! property with every other bypass variable: **they are knowledge.** Read the +//! refusal — which prints the name — and you hold the bypass forever, for every +//! subject, in every session. Nothing is issued, nothing is scoped, nothing is +//! spent, so an override costs nothing to reach for. Measured 2026-08-25: an +//! agent hit `prose-only-check`, held the variable name the moment it read the +//! refusal, and put the override to a human over a change that needed no +//! override at all. +//! +//! # The protocol, and what its address does and does not prove +//! +//! ```text +//! admission = SHA-256("batten-admission-v1" ‖ JCS({rule, verdict, subject, +//! head, epoch, answers, +//! prev, author})) +//! ``` +//! +//! **The serialization is canonical, not concatenation.** Raw `a ‖ b` is +//! ambiguous across field boundaries — two different field splits can hash alike +//! — so the address would not be well-defined at all. One canonical form is what +//! `v1` pins, and [`canonical`] is that form. +//! +//! **Authorization is the RECORD's existence and state, never possession of the +//! name.** The hash's property is BINDING, not unguessability: anyone holding the +//! answers can compute it, so the address is neither a secret nor evidence of who +//! created it. What it proves is that the record is internally consistent with +//! its own fields — editing the reasoning after the fact invalidates it. +//! +//! **The authority is the STORE; the address is integrity.** What restricts who +//! may create a record is the store's write path and nothing else. Under this row +//! that write path is a local filesystem store, so the boundary is: **anyone who +//! can write the store can mint an admission.** That is acceptable against a +//! threat model of honest error, and it is written here so a later reader does not +//! mistake the hash for a signature. If the trust boundary ever widens to a shared +//! or remote store, the scheme needs a MAC or a signature over the record — a +//! separate row, never an inference from this one. +//! +//! Because it authorizes nothing on its own, an admission is safe to print, log, +//! quote in a commit and leave in a transcript. That is what removes the "never +//! print a bearer capability" constraint a PR body would eventually violate. +//! +//! # What content addressing buys that a random token cannot +//! +//! * **The store is self-verifying.** Recomputing the address from the record's +//! own fields must equal the address, so post-hoc tampering is detectable — and +//! the corpus is the entire diagnostic point. +//! * **The ordinal disappears.** Re-articulation needs no counter: reusing the +//! previous answers reproduces the previous address, which is already spent, so +//! overriding the same situation again requires genuinely different text. +//! * **`prev` makes the per-`(rule, subject)` history tamper-evident** — no +//! backdating and no reordering. +//! * **One value is capability, record key and audit reference**, so the token +//! and its record cannot drift apart. +//! +//! # The gate never adjudicates the reason +//! +//! Non-negotiable rule 3. The predicate is **every declared question answered +//! non-emptily ⇒ issue**, never "is this justification good", which is exactly the +//! model verdict a gate may not contain. The forcing function is articulation, not +//! approval — the cost is thinking, not asking. + +use std::collections::BTreeMap; +use std::io::Write as _; +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; + +/// The protocol label inside the hash. Changing the construction changes this. +/// +/// **Inside** the hashed bytes rather than beside them, deliberately and unlike +/// [`crate::identity`]'s version placement: this label is what makes two +/// constructions unable to collide, so it has to be part of what is hashed. A +/// version recorded next to a digest answers "how do I read this"; this one +/// answers "what was hashed", and only the second must be inside. +pub const PROTOCOL: &str = "batten-admission-v1"; + +/// The in-toto predicate type an override record carries. +pub const PREDICATE_TYPE: &str = "https://button.is/batten/override/v1"; + +/// The state a record is in. Two values, and there is no third. +/// +/// A crash before the compare-and-set leaves the record [`State::Issued`] and +/// still consumable; a crash after leaves it [`State::Spent`]. There is no +/// intermediate state because the state lives in one record replaced by one +/// atomic rename. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum State { + /// Written and not yet spent. + Issued, + /// Consumed. A second consume is a policy refusal, never an error. + Spent, +} + +/// Everything the address binds, and nothing else. +/// +/// Field order here is the struct's; the ADDRESS does not depend on it, because +/// [`canonical`] sorts. That independence is the point — a field reordered during +/// a refactor must not change an address already issued. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Binding { + /// The rule whose refusal is being overridden. + pub rule: String, + /// The declared class (CLOUD-1050) that refusal belongs to. Present in the + /// binding so an admission minted against one class cannot be presented + /// against another refusal of the same rule. + pub verdict: String, + /// The gate's canonical subject. Each gate owns its own spelling; see + /// [`crate::admission`]'s consumers. + pub subject: String, + /// The HEAD the override was articulated against. + pub head: String, + /// The config generation. An admission does not survive the policy change + /// that would have made it unnecessary. + pub epoch: String, + /// The declared questions and the answers given, keyed by question id. + pub answers: BTreeMap, + /// The previous admission for this `(rule, subject)`, or `None` at the head + /// of a chain. + pub prev: Option, + /// Who articulated it — the git identity, never a model identity + /// (`.claude/rules/commits.md`). + pub author: String, +} + +/// One override record, as the store holds it. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Record { + /// The in-toto statement type, shared with [`crate::receipt`]. + #[serde(rename = "_type")] + pub statement_type: String, + /// The predicate type, always [`PREDICATE_TYPE`]. + #[serde(rename = "predicateType")] + pub predicate_type: String, + /// The content address: the record's key, its capability and its audit + /// reference, which is what keeps the token and its record from drifting. + pub admission: String, + /// What the address binds. + pub binding: Binding, + /// Issued or spent. + pub state: State, +} + +impl Record { + /// Build an issued record for `binding`. + #[must_use] + pub fn issue(binding: Binding) -> Record { + Record { + statement_type: crate::receipt::STATEMENT_TYPE.to_owned(), + predicate_type: PREDICATE_TYPE.to_owned(), + admission: address(&binding), + binding, + state: State::Issued, + } + } + + /// Whether the record still recomputes to its own address. + /// + /// This is the self-verification clause: answers edited after issuance no + /// longer hash to the key they are filed under, so the tamper is a property + /// of the pair rather than something a reviewer has to notice. + #[must_use] + pub fn recomputes(&self) -> bool { + address(&self.binding) == self.admission + } +} + +/// Why a presented admission was refused. +/// +/// Every variant is a **pointer-shaped** answer: which clause failed, never the +/// reasoning, which lives in the record and is the author's own words rather than +/// repository content (rule 4's deliberate inversion, stated on the row). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Refused { + /// No record at that address. + Unknown, + /// The record does not hash to its own key: edited after issuance. + Tampered, + /// Already consumed. + Spent, + /// Bound to a different `(rule, verdict, subject, head, epoch)`. + Unbound, + /// The `prev` chain does not terminate — it cycles, or a link is missing. + ChainBroken, +} + +impl Refused { + /// The stable lowercase token (§6). + #[must_use] + pub fn as_str(self) -> &'static str { + match self { + Refused::Unknown => "unknown", + Refused::Tampered => "tampered", + Refused::Spent => "spent", + Refused::Unbound => "unbound", + Refused::ChainBroken => "chain-broken", + } + } +} + +/// The content address of a binding. +/// +/// SHA-256 over [`PROTOCOL`] followed by the canonical serialization. Lowercase +/// hex, so the value is a filename on every platform and is safe to quote +/// anywhere. +#[must_use] +pub fn address(binding: &Binding) -> String { + use sha2::{Digest as _, Sha256}; + let mut hasher = Sha256::new(); + hasher.update(PROTOCOL.as_bytes()); + hasher.update(canonical(binding).as_bytes()); + // Hex by hand rather than `{:x}`: the pinned `sha2` returns a `GenericArray`, + // which does not implement `LowerHex`. Same fold `contract::snapshot_path` + // uses, and one buffer rather than a `format!` per byte. + hasher + .finalize() + .iter() + .fold(String::with_capacity(64), |mut hex, byte| { + use std::fmt::Write as _; + let _ = write!(hex, "{byte:02x}"); + hex + }) +} + +/// The canonical serialization of a binding — RFC 8785 (JCS), over the subset +/// this object can contain. +/// +/// # The subset is a bound, stated rather than left for a reader to discover +/// +/// A [`Binding`] holds strings, string maps, and one nullable string. It holds no +/// numbers and no booleans, which matters because **number formatting is the hard +/// half of JCS** — the ECMAScript `Number::toString` shortest-round-trip rule — +/// and implementing it for fields that cannot occur would be code no test could +/// discriminate. So this canonicalizes the subset and refuses to pretend +/// otherwise; a future field carrying a number needs the number rule written and +/// tested, and `a_binding_carries_no_numeric_field` is what makes that a compile +/// failure rather than a silent wrong address. +/// +/// What it does implement is the whole of the rest: object keys sorted by their +/// UTF-16 code units, no insignificant whitespace, and the JSON string escaping +/// JCS pins — the two-character forms where they exist, `\u00XX` for the rest of +/// the control range, and the literal character everywhere else. +#[must_use] +pub fn canonical(binding: &Binding) -> String { + let mut out = String::from("{"); + // Sorted by key, spelled out rather than derived from the struct's field + // order: the address must not move when a field is reordered. + out.push_str(&member("answers", &object(&binding.answers))); + out.push(','); + out.push_str(&member("author", &string(&binding.author))); + out.push(','); + out.push_str(&member("epoch", &string(&binding.epoch))); + out.push(','); + out.push_str(&member("head", &string(&binding.head))); + out.push(','); + out.push_str(&member( + "prev", + &binding.prev.as_deref().map_or_else( + // JSON `null`, never the empty string: a chain head and a chain link + // whose predecessor is named `""` are different facts, and spelling + // them alike would let one be presented as the other. + || "null".to_owned(), + string, + ), + )); + out.push(','); + out.push_str(&member("rule", &string(&binding.rule))); + out.push(','); + out.push_str(&member("subject", &string(&binding.subject))); + out.push(','); + out.push_str(&member("verdict", &string(&binding.verdict))); + out.push('}'); + out +} + +/// One canonical object member: `"key":value`. +fn member(key: &str, value: &str) -> String { + format!("{}:{value}", string(key)) +} + +/// A canonical object over a sorted string map. +/// +/// [`BTreeMap`] already orders by `Ord` on `String`, which is byte order over +/// UTF-8. That agrees with JCS's UTF-16 code-unit order for every code point +/// below U+10000 and disagrees above it (a supplementary character sorts before +/// U+E000..U+FFFF in UTF-16 and after in UTF-8). Question ids are the keys here +/// and they come from `[[verdict]]`'s `override.precondition`, which +/// `verdict::validate` holds to ASCII — so the disagreement is unreachable, and +/// `question_ids_are_ascii` is the assertion that keeps it so. +fn object(map: &BTreeMap) -> String { + let members: Vec = map + .iter() + .map(|(key, value)| member(key, &string(value))) + .collect(); + format!("{{{}}}", members.join(",")) +} + +/// A canonical JSON string, escaped as JCS pins it. +fn string(text: &str) -> String { + let mut out = String::with_capacity(text.len() + 2); + out.push('"'); + for ch in text.chars() { + match ch { + '"' => out.push_str("\\\""), + '\\' => out.push_str("\\\\"), + '\u{8}' => out.push_str("\\b"), + '\u{c}' => out.push_str("\\f"), + '\n' => out.push_str("\\n"), + '\r' => out.push_str("\\r"), + '\t' => out.push_str("\\t"), + // The rest of the C0 range has no two-character form, so it takes + // the lowercase four-digit escape. Everything else — including every + // non-ASCII character — is emitted literally, which is what makes the + // output the shortest valid encoding JCS asks for. + c if (c as u32) < 0x20 => { + use std::fmt::Write as _; + let _ = write!(out, "\\u{:04x}", c as u32); + } + c => out.push(c), + } + } + out.push('"'); + out +} + +/// The directory the override store lives in. +/// +/// The canonical out-of-tree receipt store, beside [`crate::receipt`]'s own +/// records. `$GIT_DIR/batten-receipts/` is never an authority here — it is the +/// grandfathered compatibility path the retired shell adapters wrote to, and this +/// module does not read it. +/// +/// # Errors +/// +/// Returns an error when the state root cannot be resolved. +pub fn store_dir(repo_root: &Path) -> Result { + Ok(crate::state::repo_state_dir(repo_root)? + .join("receipts") + .join("overrides")) +} + +/// Where one admission's record lives. +/// +/// # Errors +/// +/// Returns an error when the store directory cannot be resolved. +pub fn record_path(repo_root: &Path, admission: &str) -> Result { + Ok(store_dir(repo_root)?.join(format!("{admission}.json"))) +} + +/// The lock file guarding the store's state transitions. +fn lock_path(dir: &Path) -> PathBuf { + dir.join(".lock") +} + +/// Take the store's advisory lock for the duration of the returned handle. +/// +/// **`fs4`, for the reason `.claude/rules/rust.md` records for the capture lock +/// and no other**: an OS advisory lock is released by the kernel when its holder +/// dies, so a process `SIGKILL`ed mid-write leaves the next reader a defined +/// prefix rather than a lock nobody can release. No in-process primitive offers +/// that, and the argument never depended on the dependency count. +/// +/// # Errors +/// +/// Returns an error when the store cannot be created or the lock cannot be taken. +fn lock(dir: &Path) -> Result { + std::fs::create_dir_all(dir) + .with_context(|| format!("create the override store at {}", dir.display()))?; + let file = std::fs::OpenOptions::new() + .create(true) + .truncate(false) + .write(true) + .open(lock_path(dir)) + .with_context(|| format!("open the override store lock in {}", dir.display()))?; + // BLOCKING, not `try_lock`, and that is the difference from + // [`crate::capture`]'s use of the same primitive. A capture that cannot take + // the lock drops the write and says so, because a missed capture is a missing + // record; a consume that cannot take the lock must WAIT, because the whole + // claim is that exactly one concurrent consumer wins — and a `WouldBlock` + // treated as a refusal would make the loser's verdict depend on scheduling. + fs4::FileExt::lock(&file) + .with_context(|| format!("lock the override store in {}", dir.display()))?; + Ok(file) +} + +/// Read one record, or `None` when there is none at that address or it will not +/// parse. +/// +/// Fail-closed on an unreadable record, the same posture [`crate::receipt`] takes: +/// a record that cannot be read is never an authorization. +#[must_use] +pub fn load(repo_root: &Path, admission: &str) -> Option { + let path = record_path(repo_root, admission).ok()?; + let bytes = std::fs::read(path).ok()?; + let record: Record = serde_json::from_slice(&bytes).ok()?; + (record.predicate_type == PREDICATE_TYPE).then_some(record) +} + +/// Write `record` to the store, atomically. +/// +/// One temp file and one rename, so a reader never sees a partial record. The +/// caller holds the lock. +fn store(repo_root: &Path, record: &Record) -> Result<()> { + let path = record_path(repo_root, &record.admission)?; + let dir = path + .parent() + .context("the override record path has a parent")?; + std::fs::create_dir_all(dir) + .with_context(|| format!("create the override store at {}", dir.display()))?; + let temp = path.with_extension("json.tmp"); + let bytes = serde_json::to_vec_pretty(record).context("serialize the override record")?; + { + let mut file = + std::fs::File::create(&temp).with_context(|| format!("write {}", temp.display()))?; + file.write_all(&bytes) + .with_context(|| format!("write {}", temp.display()))?; + file.sync_all() + .with_context(|| format!("flush {}", temp.display()))?; + } + std::fs::rename(&temp, &path).with_context(|| format!("install {}", path.display()))?; + Ok(()) +} + +/// Issue an admission for `binding`, returning its address. +/// +/// **Idempotent on the binding.** The same articulation yields the same address, +/// so a repeated request is one record rather than one per attempt — and an +/// address already spent stays spent, which is what makes re-articulation forced +/// by construction rather than by a counter. +/// +/// # Errors +/// +/// Returns an error when the store cannot be locked or written. +pub fn issue(repo_root: &Path, binding: Binding) -> Result { + let dir = store_dir(repo_root)?; + let _guard = lock(&dir)?; + let record = Record::issue(binding); + if load(repo_root, &record.admission).is_some() { + // Already articulated in exactly these words. Do not rewrite it — that + // would reset a spent record to issued, which is the replay this scheme + // exists to make impossible. + return Ok(record.admission); + } + let admission = record.admission.clone(); + store(repo_root, &record)?; + Ok(admission) +} + +/// Consume `admission` against the situation `expected` describes. +/// +/// The whole transition happens under one lock: read, check, write. Exactly one +/// concurrent consumer wins the compare-and-set; every other reads +/// [`Refused::Spent`] and is a policy refusal, never an internal error. +/// +/// `expected` carries only the five binding fields — the answers, `prev` and +/// `author` are the record's, and a caller that had to reproduce them would be +/// recomputing the address rather than presenting it. +/// +/// # Errors +/// +/// Returns an error only for a store failure — an unlockable directory, an +/// unwritable record. A refusal is `Ok(Err(..))`-shaped through [`Refused`] +/// because a rejected capability is a verdict about the request, not a fault. +pub fn consume( + repo_root: &Path, + admission: &str, + expected: &Situation<'_>, +) -> Result> { + let dir = store_dir(repo_root)?; + let _guard = lock(&dir)?; + let Some(record) = load(repo_root, admission) else { + return Ok(Err(Refused::Unknown)); + }; + if !record.recomputes() { + return Ok(Err(Refused::Tampered)); + } + if !expected.matches(&record.binding) { + return Ok(Err(Refused::Unbound)); + } + if record.state == State::Spent { + return Ok(Err(Refused::Spent)); + } + if !chain_terminates(repo_root, &record) { + return Ok(Err(Refused::ChainBroken)); + } + let spent = Record { + state: State::Spent, + ..record.clone() + }; + store(repo_root, &spent)?; + Ok(Ok(spent)) +} + +/// The five fields a caller can know without holding the record. +#[derive(Debug, Clone, Copy)] +pub struct Situation<'a> { + /// The rule refusing. + pub rule: &'a str, + /// The class it refused under. + pub verdict: &'a str, + /// The gate's canonical subject. + pub subject: &'a str, + /// The HEAD being judged. + pub head: &'a str, + /// The config generation. + pub epoch: &'a str, +} + +impl Situation<'_> { + /// Whether a binding is for this situation. + /// + /// All five, never a subset: dropping any one of them is what would let an + /// admission be harvested onto a different subject, a different HEAD, or a + /// policy generation that has since changed. + #[must_use] + pub fn matches(&self, binding: &Binding) -> bool { + binding.rule == self.rule + && binding.verdict == self.verdict + && binding.subject == self.subject + && binding.head == self.head + && binding.epoch == self.epoch + } +} + +/// Whether `record`'s `prev` chain resolves, terminates, and does not cycle. +/// +/// The same predicate [`crate::verdict`]'s tombstone successors need, and one +/// implementation serves both shapes: walk, refuse a revisit, refuse a link that +/// does not resolve. A missing link is refused rather than treated as a terminus, +/// because a chain that ends by pointing at nothing is indistinguishable from one +/// somebody deleted the middle of — which is the tamper-evidence `prev` is here to +/// provide. +fn chain_terminates(repo_root: &Path, record: &Record) -> bool { + let mut seen: std::collections::BTreeSet = std::collections::BTreeSet::new(); + seen.insert(record.admission.clone()); + let mut cursor = record.binding.prev.clone(); + while let Some(link) = cursor.clone() { + if !seen.insert(link.clone()) { + return false; + } + let Some(previous) = load(repo_root, &link) else { + return false; + }; + cursor.clone_from(&previous.binding.prev); + } + true +} + +/// The head of the `(rule, subject)` chain, for a caller about to articulate a +/// new admission. +/// +/// Scanning the store rather than holding a head pointer file: the store is +/// small, and a pointer file is a second authority that can disagree with the +/// records it points at. The head is the record nothing else names as `prev`. +/// +/// # Errors +/// +/// Returns an error when the store cannot be read. +pub fn chain_head(repo_root: &Path, rule: &str, subject: &str) -> Result> { + let dir = store_dir(repo_root)?; + let Ok(entries) = std::fs::read_dir(&dir) else { + return Ok(None); + }; + let mut records: Vec = Vec::new(); + for entry in entries.flatten() { + let path = entry.path(); + if path.extension().is_none_or(|ext| ext != "json") { + continue; + } + let Ok(bytes) = std::fs::read(&path) else { + continue; + }; + let Ok(record) = serde_json::from_slice::(&bytes) else { + continue; + }; + if record.binding.rule == rule && record.binding.subject == subject { + records.push(record); + } + } + let claimed: std::collections::BTreeSet = records + .iter() + .filter_map(|record| record.binding.prev.clone()) + .collect(); + Ok(records + .into_iter() + .map(|record| record.admission) + .find(|admission| !claimed.contains(admission))) +} + +/// One question the requester must answer. +/// +/// **Falsifiable to the writer, or a fluent agent performs the ritual and +/// proceeds.** These three are the ones that would have stopped the measured +/// instance, and each is answerable only by engaging with something specific: +/// the class's own declared precondition, the cost of not overriding, and the +/// routes the class already offered. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Question { + /// The answer key. Stable, ASCII, and part of what the address binds. + pub id: &'static str, + /// What to answer, rendered against the class. + pub prompt: String, +} + +/// The question that quotes the class's own declared precondition. +const PRECONDITION: &str = "precondition"; +/// The question that prices not overriding. +const LOST: &str = "lost"; +/// The question that re-presents the declined routes. +const REJECTED_ROUTE: &str = "rejected-route"; + +/// The questions a class's override route generates, or `None` when the class +/// declares no override route at all. +/// +/// **A class declaring no override route simply cannot be overridden, and that is +/// the right default.** `verdict::validate` already refuses a class whose ONLY +/// route is an override, so the two directions compose: a class either offers a +/// real way out and may additionally be overridden, or it offers a real way out +/// and may not. +/// +/// The third question is where re-presenting the declined routes happens — the +/// last cheap moment, and the one that catches the reader who never received +/// route 1. That is not decoration: CLOUD-1050's defect B was measured as exactly +/// this, an agent reaching for the override because the correct route was a +/// clause the caller's paraphrase had dropped. +#[must_use] +pub fn questions_for(entry: &crate::verdict::DeclaredVerdict) -> Option> { + let precondition = entry.routes.iter().find_map(|route| { + (route.kind == crate::verdict::RouteKind::Override) + .then_some(route.precondition.as_deref()) + .flatten() + })?; + let declined: Vec<&str> = entry + .routes + .iter() + .filter(|route| route.kind != crate::verdict::RouteKind::Override) + .map(|route| route.id.as_str()) + .collect(); + Some(vec![ + Question { + id: PRECONDITION, + prompt: format!( + "{} declares: \"{precondition}\". State that precondition and the fact \ + satisfying it here.", + entry.id + ), + }, + Question { + id: LOST, + prompt: "Name what is lost if you do not override.".to_owned(), + }, + Question { + id: REJECTED_ROUTE, + prompt: format!( + "This class declares {}. Name the one you rejected and why it does not \ + apply.", + declined.join(" ") + ), + }, + ]) +} + +/// Which declared questions `answers` leaves unanswered. +/// +/// **The gate never adjudicates the reason** (non-negotiable rule 3): the +/// predicate is presence and well-formedness, never quality. An answer is +/// well-formed when it is not blank — anything stronger is a model verdict, and a +/// gate containing one would be worse than today's password. +#[must_use] +pub fn unanswered<'a>( + questions: &'a [Question], + answers: &BTreeMap, +) -> Vec<&'a str> { + questions + .iter() + .filter(|question| { + answers + .get(question.id) + .is_none_or(|answer| answer.trim().is_empty()) + }) + .map(|question| question.id) + .collect() +} + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::expect_used)] +mod tests { + use super::*; + + fn binding() -> Binding { + Binding { + rule: "prose-only".to_owned(), + verdict: "V-PROSE-ONLY-DIFF".to_owned(), + subject: "a.rs,b.rs".to_owned(), + head: "0123456789abcdef".to_owned(), + epoch: "epoch-1".to_owned(), + answers: BTreeMap::from([ + ( + "precondition".to_owned(), + "the prose is the deliverable".to_owned(), + ), + ( + "lost".to_owned(), + "the release notes miss the window".to_owned(), + ), + ]), + prev: None, + author: "alec@button.is".to_owned(), + } + } + + #[test] + fn the_address_does_not_depend_on_map_insertion_order() { + // The property that makes the address well-defined at all. Two maps built + // in opposite orders are the same object, and a concatenation-based + // scheme would hash them differently. + let mut reversed = binding(); + reversed.answers = BTreeMap::new(); + reversed.answers.insert( + "lost".to_owned(), + "the release notes miss the window".to_owned(), + ); + reversed.answers.insert( + "precondition".to_owned(), + "the prose is the deliverable".to_owned(), + ); + assert_eq!(address(&binding()), address(&reversed)); + } + + #[test] + fn a_field_boundary_cannot_be_moved_without_moving_the_address() { + // THE DEFECT CANONICALIZATION EXISTS TO KILL. Under `a ‖ b` these two + // bindings hash identically — `"ab" ‖ ""` and `"a" ‖ "b"` are the same + // byte string — so an admission for one subject would authorize the + // other. Under JCS the delimiters are in the encoding. + let mut left = binding(); + left.rule = "ab".to_owned(); + left.verdict = String::new(); + let mut right = binding(); + right.rule = "a".to_owned(); + right.verdict = "b".to_owned(); + assert_ne!(address(&left), address(&right)); + } + + #[test] + fn a_chain_head_and_a_link_named_empty_are_different_addresses() { + // `prev: None` serializes as `null` rather than as `""`, so the head of a + // chain cannot be presented as a link and vice versa. + let mut named = binding(); + named.prev = Some(String::new()); + assert_ne!(address(&binding()), address(&named)); + } + + #[test] + fn editing_an_answer_breaks_the_recomputation() { + // The self-verification clause, which is what makes the corpus evidence + // rather than a log somebody could rewrite. + let mut record = Record::issue(binding()); + record + .binding + .answers + .insert("lost".to_owned(), "actually nothing".to_owned()); + assert!(!record.recomputes()); + } + + #[test] + fn the_canonical_form_escapes_the_control_range_and_nothing_else() { + // JCS asks for the shortest valid encoding: two-character forms where + // they exist, `\u00XX` for the rest of C0, and the literal character + // everywhere else — including every non-ASCII one, which a naive + // `\u`-everything escaper would expand and hash differently. + assert_eq!( + string("a\"b\\c\nd\u{1}e\u{e9}f"), + "\"a\\\"b\\\\c\\nd\\u0001e\u{e9}f\"" + ); + } + + #[test] + fn a_binding_carries_no_numeric_field() { + // The bound this module's canonicalizer states, held as an assertion + // rather than as a sentence. JCS's number rule is not implemented, so a + // field that could carry one must fail here rather than produce a silent + // wrong address. `serde_json` is the reader because it is the shape any + // future field would arrive through. + let value = serde_json::to_value(binding()).unwrap(); + let object = value.as_object().expect("a binding is an object"); + for (key, field) in object { + assert!( + !field.is_number() && !field.is_boolean(), + "`{key}` is neither a string, a string map nor null — write the JCS number rule first" + ); + } + } + + #[test] + fn question_ids_are_ascii() { + // What keeps `BTreeMap`'s UTF-8 byte order equal to JCS's UTF-16 + // code-unit order. The two disagree only above U+FFFF, and the ids come + // from `[[verdict]]`'s declared preconditions. + for id in binding().answers.keys() { + assert!(id.is_ascii(), "{id} is not an ascii question id"); + } + } +} diff --git a/crates/batten/src/cli.rs b/crates/batten/src/cli.rs index de842f7c0..7a9a6c579 100644 --- a/crates/batten/src/cli.rs +++ b/crates/batten/src/cli.rs @@ -317,6 +317,17 @@ pub enum PolicyCommand { /// Emit the names as byte-stable JSON instead of one per line. json: bool, }, + /// Resolve a verdict token to its class definition and routes (CLOUD-1053). + /// + /// Appended for [`PolicyCommand::Test`]'s reason: a variant inserted in the + /// middle re-numbers every later discriminant and `semver` reads that as + /// `enum_no_repr_variant_discriminant_changed`. + Explain { + /// The token to resolve, e.g. `V-TASK-UNDEFINED`. + token: String, + /// Emit the class as byte-stable JSON instead of pointer lines. + json: bool, + }, } /// Subcommands of `attribution`. @@ -693,6 +704,16 @@ fn policy_of(matches: &ArgMatches) -> Option { ("tools", matches) => Some(PolicyCommand::Tools { json: flag(matches, "json"), }), + ("explain", matches) => Some(PolicyCommand::Explain { + // `clap` already refuses the absent case — the declaration is + // `required` — so this default is unreachable rather than a silent + // empty query. + token: matches + .get_one::("token") + .cloned() + .unwrap_or_default(), + json: flag(matches, "json"), + }), _ => None, } } diff --git a/crates/batten/src/config.rs b/crates/batten/src/config.rs index 41b703701..09c428806 100644 --- a/crates/batten/src/config.rs +++ b/crates/batten/src/config.rs @@ -188,6 +188,20 @@ pub struct Config { /// [`crate::pattern`]. #[serde(default, rename = "pattern", skip_serializing_if = "Vec::is_empty")] pub patterns: Vec, + /// The refusal vocabulary (CLOUD-1050): every verdict a gate may reach for, + /// its one-line gloss, its class definition and its closed route list. + /// + /// **The sole authority for BOTH emitters** — a Rego `violation` and a + /// native `Refusal` name a token out of this one table, which is the + /// reversal CLOUD-1050 records of its own first review: a policy-only + /// registry beside a native authority is two vocabularies that disagree the + /// first time either moves. A module reaches it at + /// `data.batten.verdicts[""]`; a token nothing declares is refused at + /// load, and a declared token nothing emits is refused the other way, so the + /// table and the emitters cannot drift. The type and its validation are + /// [`crate::verdict`]. + #[serde(default, rename = "verdict", skip_serializing_if = "Vec::is_empty")] + pub verdicts: Vec, /// The per-path-class redirect table (CLOUD-280): what to run instead, /// keyed by what is protected rather than by the verb reaching for it. /// @@ -917,6 +931,13 @@ fn parse_ungated(text: &str, source: &str) -> Result { // discovering it at adjudication, which is the worst time and the wrong exit // class (house style §8). crate::pattern::validate(&config.patterns)?; + // The refusal vocabulary, at parse for the identical reason (CLOUD-1050). + // Every clause is a property of the TABLE — a token's prefix, a gloss that + // is one line, a route list that is not an override alone, a tombstone chain + // that terminates — so it is knowable without a tree and belongs where a + // config fault is reported. Registry EQUALITY against what the modules + // actually emit needs the compiled bundles and lives in `policy::load`. + crate::verdict::validate(&config.verdicts)?; crate::redirect::validate(&config.redirects)?; // And the marker table, for the identical reason in the identical shape // (CLOUD-253). Both tables arrived in one commit; CLOUD-242 wired one of @@ -1074,6 +1095,7 @@ impl Config { fail_on_warning: None, rules: Vec::new(), patterns: Vec::new(), + verdicts: Vec::new(), scope: Vec::new(), protected: Vec::new(), unlanded: Vec::new(), @@ -1368,6 +1390,7 @@ mod tests { const VALIDATED_AT_LOAD: &[(&str, &str)] = &[ ("verbs", "crate::verbs::validate("), ("patterns", "crate::pattern::validate("), + ("verdicts", "crate::verdict::validate("), ("redirects", "crate::redirect::validate("), ("markers", "crate::markers::validate("), // The LOCATED form (CLOUD-773): the loaders hold the config text, so a diff --git a/crates/batten/src/hook.rs b/crates/batten/src/hook.rs index a9b63924a..40cdad577 100644 --- a/crates/batten/src/hook.rs +++ b/crates/batten/src/hook.rs @@ -2451,6 +2451,15 @@ pub struct Policy { /// module a row enables, so they compose into one rule set instead of N /// isolated ones that cannot share a helper. bundles: Vec, + /// The refusal vocabulary this policy renders against (CLOUD-1050): the + /// consumer's `[[verdict]]` rows unioned with what the binary vendors. + /// + /// Carried on the POLICY rather than looked up per call, because + /// `adjudicate` is contractually pure — it may not read a config — and + /// because the merge is fixed for the life of the load. A linear scan over a + /// table this size is free against CLOUD-689's budget; what would not be is + /// resolving the table again per mediated call. + verdicts: Vec, } impl Policy { @@ -2471,6 +2480,7 @@ impl Policy { facts: Vec::new(), mints: Vec::new(), bundles: Vec::new(), + verdicts: Vec::new(), } } @@ -2584,10 +2594,14 @@ impl Policy { .filter(|rule| rule.scope == RuleScope::MediatedCall) .cloned() .collect::>(), - &resolved.patterns, + crate::policy::Vocabulary { + patterns: &resolved.patterns, + verdicts: &resolved.verdicts, + }, crate::policy::ModuleChecks::SkipOnHotPath, reference, )?, + verdicts: crate::policy::registry_for(&resolved.verdicts)?, }) } @@ -2961,6 +2975,7 @@ impl Policy { facts: self.facts.clone(), mints: self.mints.clone(), bundles: self.bundles.clone(), + verdicts: self.verdicts.clone(), } } @@ -4617,13 +4632,23 @@ fn policy_rules(policy: &Policy, envelope: &Envelope, facts: &Facts<'_>) -> Deci // spellings of it would let a waiver suppress something a finding // does not name. // - // The cause is the module's own message, which is the consumer's - // text exactly as a row's `reason` is — not a rendering of the - // policy body, which rule 4 would refuse. + // THE CAUSE IS A TOKEN, A GLOSS AND A POINTER (CLOUD-1050). + // It used to be the module's own prose, which nothing could check — + // a refusal was free to name no remedy at all. Now both halves come + // off the declared class: the line from `render_line`, and the fix + // from the class's first `command` route, so "a refusal names a way + // out" holds by construction rather than by each module's care. return Decision::Deny(Refusal::new( bundle.attribute(violation), - violation.msg.clone(), - Fix::None, + crate::verdict::render_line( + &policy.verdicts, + &violation.verdict, + &violation.subjects, + ), + Fix::declared(crate::verdict::first_command_route( + &policy.verdicts, + &violation.verdict, + )), )); } } @@ -5311,9 +5336,20 @@ fn protected_refusal(redirects: &[Redirect], target: &Target<'_>) -> Refusal { Some(subcommand) => format!("{} {subcommand}", target.program), None => target.program.to_owned(), }; - Refusal::new( + Refusal::declared( PROTECTED_MUTATION, - format!("`{action}` targets the protected path {}", target.path), + crate::verdict::Native::ProtectedMutation, + // The path first, so it becomes the finding's own pointer; the action + // second, because the class already says what a mutating verb is and the + // caller needs to recognise WHICH command of theirs was read that way. + // Both are tagged pointers rather than the prose they replaced, which is + // what makes non-negotiable rule 4 structural here. + &[ + crate::verdict::Subject::Path { + path: target.path.to_owned(), + }, + crate::verdict::Subject::Artifact { artifact: action }, + ], // Three tiers, narrowest first (CLOUD-280): the path class the consumer // declared, then the verb's own general remedy, then `Fix::None` — which // renders an explicit "none declared" and names the gate. The two @@ -6213,6 +6249,7 @@ mod tests { facts: Vec::new(), mints: Vec::new(), bundles: Vec::new(), + verdicts: Vec::new(), shapes: Vec::new(), fail_on_warning: false, verbs, @@ -6251,6 +6288,7 @@ mod tests { facts: Vec::new(), mints: Vec::new(), bundles: Vec::new(), + verdicts: Vec::new(), verbs: Vec::new(), protected: PathSet::empty(), redirects: Vec::new(), @@ -6314,6 +6352,7 @@ mod tests { facts: Vec::new(), mints: Vec::new(), bundles: Vec::new(), + verdicts: Vec::new(), verbs: Vec::new(), protected: PathSet::empty(), redirects: Vec::new(), @@ -6812,6 +6851,7 @@ mod tests { facts: Vec::new(), mints: Vec::new(), bundles: Vec::new(), + verdicts: Vec::new(), shapes: vec![shape("no-bare-cargo", "cargo", None)], fail_on_warning: false, verbs: Vec::new(), @@ -6888,6 +6928,7 @@ mod tests { facts: Vec::new(), mints: Vec::new(), bundles: Vec::new(), + verdicts: Vec::new(), shapes: vec![rule], fail_on_warning: false, verbs: Vec::new(), @@ -7250,6 +7291,7 @@ mod tests { facts: Vec::new(), mints: Vec::new(), bundles: Vec::new(), + verdicts: Vec::new(), shapes: vec![rule], fail_on_warning: false, verbs: Vec::new(), @@ -7280,6 +7322,7 @@ mod tests { facts: Vec::new(), mints: Vec::new(), bundles: Vec::new(), + verdicts: Vec::new(), shapes: vec![rule.clone()], fail_on_warning: false, verbs: Vec::new(), @@ -7304,6 +7347,7 @@ mod tests { facts: Vec::new(), mints: Vec::new(), bundles: Vec::new(), + verdicts: Vec::new(), shapes: vec![rule], fail_on_warning: true, verbs: Vec::new(), @@ -7336,6 +7380,7 @@ mod tests { facts: Vec::new(), mints: Vec::new(), bundles: Vec::new(), + verdicts: Vec::new(), shapes: vec![ shape("first", "gh pr merge", None), shape("second", "gh pr merge", None), @@ -7380,6 +7425,7 @@ mod tests { facts: Vec::new(), mints: Vec::new(), bundles: Vec::new(), + verdicts: Vec::new(), shapes: vec![rule], fail_on_warning: false, verbs: Vec::new(), @@ -7439,6 +7485,7 @@ mod tests { facts: Vec::new(), mints: Vec::new(), bundles: Vec::new(), + verdicts: Vec::new(), shapes: vec![rule], fail_on_warning: false, verbs: Vec::new(), @@ -7475,12 +7522,51 @@ mod tests { "severity": "deny", })) .expect("a policy row the loader accepts"); + // Every token this module's fixtures raise. Declared rather than + // skipped, because registry equality is exactly what `load` is being + // asked to enforce here — a fixture loading through a hole would be + // testing the hole. + // Only the tokens THIS source raises. Registry equality runs in both + // directions, so declaring the other fixture's class here would be dead + // vocabulary and the load would refuse it — which is the check doing its + // job, and the reason the list is derived from the module rather than + // shared across the cases. + let fixture_verdicts = ["V-VERIFY-RECEIPT-STALE", "V-REFUSED-BY-THE-MODULE"] + .into_iter() + .filter(|id| source.contains(id)) + .map(|id| crate::verdict::DeclaredVerdict { + id: id.to_owned(), + gloss: format!("the fixture class {id}"), + class: format!("What {id} means, at length."), + routes: vec![crate::verdict::Route { + id: "R-READ-THE-AUTHORITY".to_owned(), + kind: crate::verdict::RouteKind::Document, + target: "batten.toml".to_owned(), + precondition: None, + }], + successor: None, + }) + .collect::>(); Policy { harness: Harness::ExitCode, facts: Vec::new(), mints: Vec::new(), - bundles: crate::policy::load(&dir, &[row], &[], crate::policy::ModuleChecks::Run, None) - .expect("load"), + bundles: crate::policy::load( + &dir, + &[row], + crate::policy::Vocabulary { + patterns: &[], + verdicts: &fixture_verdicts, + }, + crate::policy::ModuleChecks::Run, + None, + ) + .expect("load"), + // The consumer half is the fixtures' own vocabulary; the vendored + // half is unioned in by `registry_for`, which is what the rendering + // path reads. + verdicts: crate::policy::registry_for(&fixture_verdicts) + .expect("no vendored collision"), shapes: Vec::new(), fail_on_warning: false, verbs: Vec::new(), @@ -7677,7 +7763,7 @@ rules contains "verify-receipt-stale" violation contains { "rule": "verify-receipt-stale", - "msg": "the verify receipt is stale for this HEAD", + "verdict": "V-VERIFY-RECEIPT-STALE", } if { input.facts.receipts.verify == "stale-head" } @@ -7807,7 +7893,7 @@ package batten import rego.v1 -deny contains "refused by the module" if { +deny contains "V-REFUSED-BY-THE-MODULE" if { contains(input.call.command, "forbidden") } "#; @@ -7824,9 +7910,19 @@ deny contains "refused by the module" if { match decision { Decision::Deny(refusal) => { let rendered = format!("{refusal:?}"); + // THE TOKEN AND ITS GLOSS TRAVEL (CLOUD-1050). It used to be + // the module's own prose; now the class is a name the registry + // resolves, so this asserts the name rather than a substring of + // a sentence — an assertion that survives a reworded gloss and + // fails a changed class, which is the discrimination the old one + // had backwards. + assert!( + rendered.contains("V-REFUSED-BY-THE-MODULE"), + "the class the module raised travels: {rendered}" + ); assert!( - rendered.contains("refused by the module"), - "the module's own message travels: {rendered}" + rendered.contains("the fixture class"), + "and so does the gloss the registry declares for it: {rendered}" ); assert!( !rendered.contains("deny contains"), @@ -7930,6 +8026,7 @@ deny contains "refused by the module" if { facts: Vec::new(), mints: Vec::new(), bundles: Vec::new(), + verdicts: Vec::new(), shapes: vec![rule], fail_on_warning: false, verbs: Vec::new(), @@ -8211,6 +8308,7 @@ deny contains "refused by the module" if { facts: Vec::new(), mints: Vec::new(), bundles: Vec::new(), + verdicts: Vec::new(), shapes: vec![rule], fail_on_warning: false, verbs: Vec::new(), @@ -8651,18 +8749,55 @@ deny contains "refused by the module" if { } #[test] - fn a_verb_with_no_redirect_declares_the_absence_rather_than_omitting_it() { - // `redirect` is optional on a verb, so the refusal must still say - // something actionable — CLOUD-280 is the per-path-class version. + fn a_verb_with_no_redirect_falls_back_to_the_classs_own_route() { + // WHAT CLOUD-1050 CHANGED HERE, and it is the change the row exists for. + // + // This case used to assert `Fix::None` — that a verb declaring no + // `redirect` produced a refusal whose remedy was the crate's generic "none + // declared" line. That was CLOUD-122's contract met by CONVENTION: the + // remedy was whatever the deny site remembered, and where it remembered + // nothing the reader got a sentence naming no verb. // - // Since CLOUD-122 the absence is a VALUE (`Fix::None`), not a missing - // field: the payload carries `"fix": null` and the rendering says so and - // then gives the crate's general recourse. A consumer cannot tell an - // omitted key from one the producer forgot, which is why the explicit - // none is the contract rather than a nicety. + // A native refusal now names a declared class, and `verdict::validate` + // refuses a class with no route and refuses one whose only route is an + // override. So the third tier is no longer a generic apology: it is + // `V-PROTECTED-MUTATION`'s own `R-RESTORE-IT`. The tiering is unchanged and + // is asserted by its siblings — a consumer's `[[redirect]]` still wins, and + // a verb's own `redirect` still wins over the class — this is only the + // floor, and the floor got a verb. let decision = guarded("mv batten.toml elsewhere"); let refusal = denial(decision.clone()); - assert_eq!(refusal.fix(), &Fix::None, "nothing is declared for `mv`"); + assert_eq!( + refusal.fix(), + &Fix::Run("git restore".to_owned()), + "the class's declared route is the floor now, not a generic apology" + ); + assert_eq!( + refusal.verdict(), + Some("V-PROTECTED-MUTATION"), + "and the refusal says which class it belongs to" + ); + let reason = denial_text(decision); + assert!( + reason.contains("V-PROTECTED-MUTATION ("), + "the hot path leads with the token and its gloss: {reason}" + ); + assert!( + reason.contains("batten.toml"), + "with the pointer inline rather than behind `explain`: {reason}" + ); + } + + #[test] + fn a_refusal_from_consumer_prose_still_declares_an_absent_fix_rather_than_omitting_it() { + // The half the case above used to carry, kept on the path where it is + // still reachable. A refusal composed from a `[[rule]]` row's own + // `reason` is the CONSUMER's statement and names no Batten class, so + // there is no declared route to fall back to — and the absence stays a + // VALUE. A consumer cannot tell an omitted key from one the producer + // forgot, which is why the explicit none is the contract. + let refusal = crate::refusal::Refusal::new("some-row", "it fired", Fix::None); + assert_eq!(refusal.verdict(), None); assert!( refusal .to_json() @@ -8670,9 +8805,8 @@ deny contains "refused by the module" if { .contains("\"fix\":null"), "the key is present and null" ); - let reason = denial_text(decision); - assert!(reason.contains("Fix: none declared"), "got: {reason}"); - assert!(reason.contains("surface that owns it"), "got: {reason}"); + assert!(refusal.render().contains("Fix: none declared")); + assert!(refusal.render().contains("surface that owns it")); } /// Adjudicate against the protected fixture with a declared redirect table. @@ -8737,14 +8871,18 @@ deny contains "refused by the module" if { #[test] fn neither_tier_declaring_anything_still_names_the_gate() { - // Tier three, also unchanged: `mv` declares no redirect and no row - // claims the path, so the absence is stated as a value. + // Tier three, and CLOUD-1050 is what changed it: `mv` declares no + // redirect and no row claims the path, so both consumer tiers are silent + // — and what stands underneath is no longer an absence but the CLASS's + // own declared route. The tiering itself is untouched, which is what the + // non-matching redirect glob above is here to show: a row that speaks for + // a different path does not reach this one. let decision = guarded_with( vec![redirect_row("somewhere/else/**", "irrelevant")], "mv batten.toml elsewhere", ); let refusal = denial(decision.clone()); - assert_eq!(refusal.fix(), &Fix::None); + assert_eq!(refusal.fix(), &Fix::Run("git restore".to_owned())); assert!(denial_text(decision).contains(PROTECTED_MUTATION)); } @@ -8864,6 +9002,7 @@ deny contains "refused by the module" if { facts: Vec::new(), mints: Vec::new(), bundles: Vec::new(), + verdicts: Vec::new(), shapes: Vec::new(), fail_on_warning: false, verbs: vec![verb("rm", None)], @@ -8946,6 +9085,7 @@ deny contains "refused by the module" if { facts: Vec::new(), mints: Vec::new(), bundles: Vec::new(), + verdicts: Vec::new(), shapes: Vec::new(), fail_on_warning: false, verbs: verbs.clone(), @@ -8958,6 +9098,7 @@ deny contains "refused by the module" if { facts: Vec::new(), mints: Vec::new(), bundles: Vec::new(), + verdicts: Vec::new(), shapes: Vec::new(), fail_on_warning: false, verbs, diff --git a/crates/batten/src/lib.rs b/crates/batten/src/lib.rs index 528e2d15a..fc2ac16c4 100644 --- a/crates/batten/src/lib.rs +++ b/crates/batten/src/lib.rs @@ -9,6 +9,7 @@ //! keeps the binary's `main` trivial. pub mod action; +pub mod admission; pub mod attribution; pub mod baseline; pub mod brief; @@ -81,6 +82,7 @@ pub mod trust; /// re-export table. pub mod uses; pub mod verbs; +pub mod verdict; pub mod waiver; pub mod worktree; @@ -199,6 +201,9 @@ pub fn run(cli: Cli, mode: Mode, out: &mut dyn Write, err: &mut dyn Write) -> Re PolicyCommand::Budget { json } => run_budget(json, &overrides, out), PolicyCommand::Test { json } => run_policy_test(json, &overrides, out), PolicyCommand::Tools { json } => run_policy_tools(json, &overrides, out), + PolicyCommand::Explain { token, json } => { + run_policy_explain(&token, json, &overrides, out) + } }, // `lint ` reads text the caller names and answers about its shape. // The §8 config chain is deliberately not threaded through it: the schema @@ -411,12 +416,12 @@ fn run_init( // deny site, and CLOUD-122's contract is that every deny points to a fix // structurally rather than because its author remembered to name one. init::Outcome::Exists => { - let refusal = Refusal::new( + let refusal = Refusal::declared( init::CONFIG_EXISTS, - format!( - "{} already exists, and init will not overwrite the committed authority", - config::CONFIG_FILE - ), + verdict::Native::InitWouldOverwrite, + &[verdict::Subject::Path { + path: config::CONFIG_FILE.to_owned(), + }], Fix::Run(format!( "edit {file} in place, or move it aside and run `batten init` again", file = config::CONFIG_FILE @@ -459,7 +464,15 @@ fn run_baseline( ) -> Result { let root = anchor(); let config = resolve::resolve(&root, overrides)?; - let scan = rules::run_static(&config.rules, &config.provisions, &config.patterns, &root)?; + let scan = rules::run_static( + &config.rules, + &config.provisions, + policy::Vocabulary { + patterns: &config.patterns, + verdicts: &config.verdicts, + }, + &root, + )?; if prune { let Some(existing) = baseline::load(&root)? else { @@ -798,7 +811,10 @@ fn run_state_record(overrides: &Overrides, mode: Mode, err: &mut dyn Write) -> R let scan = rules::run_recorded( &config.rules, &config.provisions, - &config.patterns, + policy::Vocabulary { + patterns: &config.patterns, + verdicts: &config.verdicts, + }, Path::new("."), )?; if !scan.not_evaluated.is_empty() { @@ -1718,13 +1734,121 @@ fn run_policy_tools(json: bool, overrides: &Overrides, out: &mut dyn Write) -> R Ok(ExitCode::Success) } +/// Resolve a verdict token to its class definition and routes (CLOUD-1053). +/// +/// # The hot path got shorter and this is where the rest went +/// +/// A refusal now prints a token, a one-line gloss and a pointer. That is the +/// common case, and it is the case that has to be cheap: an agent refused +/// eighteen times in a session reads eighteen lines, not eighteen paragraphs. +/// The paragraph is still worth having exactly once, when a reader meets a class +/// for the first time — so it lives in the registry and this is what fetches it. +/// +/// # Its payload is a deliberate, stated exception to pointer-only output +/// +/// House style §6 and non-negotiable rule 4 say a check emits a count, a +/// `path:line` or a boolean, never the content. This prints a paragraph, and +/// that is inside the rule rather than an exception to it in one respect and an +/// exception in another. Inside: the text is the **config author's own +/// declaration**, the class `config show` exists to echo, never content read out +/// of a subject file. Exception: it is a payload, and carrying it is the whole +/// point — a documentation verb that emitted a pointer to its own documentation +/// would be a redirect with extra steps. +/// +/// # Local, deterministic, and never a verdict +/// +/// No network, no spawn, no tree walk: the committed registry is resolved and a +/// token is looked up in it. Exit `0` for a token the registry declares, `1` for +/// one it does not or for a registry that will not load, `3` for an internal +/// fault — and never `2`, because this decides nothing about the repository. +/// +/// # Errors +/// +/// Propagates a config-resolution failure, which is the usage class (exit `1`). +fn run_policy_explain( + token: &str, + json: bool, + overrides: &Overrides, + out: &mut dyn Write, +) -> Result { + let config = resolve::resolve(Path::new("."), overrides)?; + // The same union the engine decides against, so `explain` cannot resolve a + // token differently from the gate that raised it — which is the drift a + // second reader of one table always produces. + let registry = policy::registry_for(&config.verdicts)?; + let Some((resolved, retired)) = verdict::resolve(®istry, token) else { + // Named, and the token is the caller's own argument rather than + // anything read out of the tree. A list of what IS declared would be the + // whole registry on stderr; the count plus the verb to run is the + // pointer-shaped answer. + return Err(error::UsageError::raise(format!( + "no `[[verdict]]` row declares `{token}`; this registry declares {} class(es)", + registry.len() + ))); + }; + if json { + writeln!(out, "{}", explain_json(token, resolved, retired)?)?; + return Ok(ExitCode::Success); + } + writeln!(out, "{} {}", resolved.id, resolved.gloss)?; + if retired { + // The token the reader ASKED for was a tombstone. Said outright rather + // than silently swapped: a reader who greps their own logs for the old + // token has to learn that it moved, and an answer that just showed the + // new class would leave them believing the old one is live. + writeln!(out, "retired {token} -> {}", resolved.id)?; + } + writeln!(out)?; + writeln!(out, "{}", resolved.class.trim())?; + writeln!(out)?; + for route in &resolved.routes { + let target = match route.precondition.as_deref() { + Some(precondition) => precondition, + None => route.target.as_str(), + }; + writeln!(out, "{} {} {target}", route.id, route.kind.as_str())?; + } + Ok(ExitCode::Success) +} + +/// The `-J` shape of [`run_policy_explain`], byte-stable. +/// +/// `token` and `resolved` are both carried, and they differ exactly when the +/// asked-for token was a tombstone — which is the one fact a caller reading this +/// programmatically cannot reconstruct from either alone. +fn explain_json(token: &str, resolved: &verdict::DeclaredVerdict, retired: bool) -> Result { + let routes: Vec = resolved + .routes + .iter() + .map(|route| { + serde_json::json!({ + "id": route.id, + "kind": route.kind.as_str(), + "target": route.target, + "precondition": route.precondition, + }) + }) + .collect(); + Ok(serde_json::to_string(&serde_json::json!({ + "token": token, + "resolved": resolved.id, + "retired": retired, + "gloss": resolved.gloss, + "class": resolved.class.trim(), + "routes": routes, + }))?) +} + fn run_policy_test(json: bool, overrides: &Overrides, out: &mut dyn Write) -> Result { let root = Path::new("."); let config = resolve::resolve(root, overrides)?; let bundles = policy::load( root, &config.rules, - &config.patterns, + policy::Vocabulary { + patterns: &config.patterns, + verdicts: &config.verdicts, + }, policy::ModuleChecks::Run, overrides.config_from.as_deref(), )?; @@ -2656,16 +2780,26 @@ fn dispatch_handlers( advice.push(format!("hook.handler.{id}: {reason}")); return Ok(None); } - Ok(Some(hook::Decision::Deny(crate::refusal::Refusal::new( - format!("hook.handler.{id}"), - reason, - // A handler's reason is free text and may or may not name a remedy, so - // the fix is declared absent rather than invented here. §5's "every - // refusal names something to run" is the HANDLER's obligation to meet in - // its own reason; manufacturing one at this call site would be Batten - // claiming to know a remedy it does not have. - crate::refusal::Fix::None, - )))) + Ok(Some(hook::Decision::Deny( + crate::refusal::Refusal::declared( + format!("hook.handler.{id}"), + verdict::Native::HandlerDenied, + // THE HANDLER'S OWN WORDS TRAVEL AS A SUBJECT, not as the reason + // (CLOUD-1050). The class is Batten's — "a configured hook handler denied + // the call" — and the handler's text is what it denied over, which is a + // subject of that class rather than a competing statement of it. Keeping + // it in the `reason` slot would have made a class Batten declares + // indistinguishable from free text a third party wrote. + &[verdict::Subject::Artifact { + artifact: reason.to_owned(), + }], + // A handler's reason may or may not name a remedy, so the fix falls back + // to the class's declared route rather than being invented here. §5's + // "every refusal names something to run" is now the REGISTRY's obligation + // and `verdict::validate` refuses a class that fails it. + crate::refusal::Fix::None, + ), + ))) } /// Assemble a `requires_key` row's checkout evidence (CLOUD-446). @@ -5109,12 +5243,16 @@ fn apply_baseline( /// Named rather than written inline because the pattern table joined the /// argument list (CLOUD-885) and a four-argument fn pointer is past what /// `clippy::type_complexity` will read. The alias is also the clearer spelling: -/// the three tables and the root are what a runner needs, and saying so once -/// beats repeating it at both call sites. +/// the tables and the root are what a runner needs, and saying so once beats +/// repeating it at both call sites. +/// +/// The pattern table and the refusal vocabulary travel as one +/// [`policy::Vocabulary`] (CLOUD-1050) rather than as two positions, which is +/// why this alias did not have to grow again. type RuleRunner = fn( &[rules::Rule], &[provision::Provision], - &[pattern::NamedPattern], + policy::Vocabulary<'_>, &Path, ) -> Result; @@ -5187,7 +5325,11 @@ fn run_rules( // store's resolve pass fail-closed (CLOUD-81), and the enforce surface now // journals (CLOUD-529), so dropping it here would let a rule that never // looked resolve every finding it covers. - let scan = runner(&config.rules, &config.provisions, &config.patterns, &root)?; + let vocabulary = policy::Vocabulary { + patterns: &config.patterns, + verdicts: &config.verdicts, + }; + let scan = runner(&config.rules, &config.provisions, vocabulary, &root)?; perform_requested_sinks(surface, &root, &scan); let mut findings = scan.findings.clone(); diff --git a/crates/batten/src/lint.rs b/crates/batten/src/lint.rs index d3cd07759..d1c3d2785 100644 --- a/crates/batten/src/lint.rs +++ b/crates/batten/src/lint.rs @@ -466,7 +466,7 @@ pub fn run(dir: &Path, base_ref: Option<&str>, today: crate::waiver::Date) -> Re crate::policy::load( dir, &config.rules, - &config.patterns, + crate::policy::Vocabulary::from(&config), crate::policy::ModuleChecks::Run, base_ref, ) diff --git a/crates/batten/src/policy.rs b/crates/batten/src/policy.rs index 922729fbe..75e3f10f5 100644 --- a/crates/batten/src/policy.rs +++ b/crates/batten/src/policy.rs @@ -171,22 +171,47 @@ const VIOLATION_RULE: &str = "violation"; /// so house style §8 is untouched. const RULES_RULE: &str = "rules"; -/// One denial a module produced: the predicate that fired, and its own message. +/// The key a `violation` names its declared class under (CLOUD-1050). +/// +/// Held to `verdict.rs`'s registry by [`check_verdicts_are_declared`], and to +/// the schema by `rules-drift`, so the three spellings of this one name cannot +/// drift apart. +const VERDICT_KEY: &str = "verdict"; + +/// The key a `violation` used to name its prose under, and no longer may. +/// +/// Kept as a constant rather than deleted with the field, because the load-time +/// refusal has to be able to NAME the thing it is refusing — a module carrying +/// this key gets told which key, which is what makes the migration one edit +/// rather than a hunt. +const RETIRED_MSG_KEY: &str = "msg"; + +/// One denial a module produced: the predicate that fired, the class it fired +/// under, and what it points at. /// /// `rule` is `Option` and the two arms are the two shapes, not a convenience: -/// `None` is a bare string from [`DENY_RULE`], attributed to the registering +/// `None` is a bare token from [`DENY_RULE`], attributed to the registering /// row; `Some` is a [`VIOLATION_RULE`] entry naming a predicate the module /// published. [`Bundle::attribute`] is the one place that collapses them, so no /// caller re-derives the fallback and gets it differently. /// -/// `msg` is the **module's own text**, exactly as a row's `reason` is the -/// consumer's — never a rendering of the policy body, which rule 4 would refuse. +/// # `verdict` replaced `msg`, and that is CLOUD-1050's whole content +/// +/// The field this carried was the module's own **prose**. Nothing could check +/// it: a refusal could name no remedy, name a task that does not exist, offer an +/// override with no precondition, or spell one concept nineteen ways, and every +/// one of those passes a `String`. So the class is a **token** declared in +/// [`crate::verdict`], where the prose lives once and a gate can read it, and +/// the pointers are [`crate::verdict::Subject`]s — tagged, ordered, and +/// structurally incapable of carrying a payload (non-negotiable rule 4). #[derive(Debug, Clone, PartialEq, Eq)] pub struct Violation { /// The predicate id, when the module named one. pub rule: Option, - /// The module's message. - pub msg: String, + /// The declared class this refusal belongs to. + pub verdict: String, + /// What it points at, in the order the module gave them. + pub subjects: Vec, } /// Every vendored preset: its name, and the modules it ships. @@ -515,6 +540,46 @@ pub fn engines_constructed() -> usize { ENGINES_CONSTRUCTED.load(Ordering::Relaxed) } +/// The declared vocabulary a policy module reads: named patterns, and the +/// refusal classes it may raise. +/// +/// **One parameter rather than two**, and that is a decision rather than +/// packaging. Both tables are config the consumer declares, both are fixed for +/// the life of the load, both are projected into the evaluator's `data` +/// document, and both are read by exactly the same call sites — so a caller that +/// has one always has the other. Threading them separately would have added a +/// fifth positional argument to every scan entry point and its ~50 call sites, +/// which is how a signature nobody can read at a call site gets built one +/// well-motivated parameter at a time. +#[derive(Debug, Clone, Copy)] +pub struct Vocabulary<'a> { + /// The `[[pattern]]` table (CLOUD-885). + pub patterns: &'a [crate::pattern::NamedPattern], + /// The `[[verdict]]` table (CLOUD-1050). + pub verdicts: &'a [crate::verdict::DeclaredVerdict], +} + +impl Vocabulary<'_> { + /// A consumer declaring neither table. + /// + /// Not the same as "no vocabulary at all": this binary's vendored classes + /// are unioned in at load, so a preset still loads and a native refusal + /// still resolves. What is empty here is the CONSUMER's half. + pub const EMPTY: Vocabulary<'static> = Vocabulary { + patterns: &[], + verdicts: &[], + }; +} + +impl<'a> From<&'a crate::config::Config> for Vocabulary<'a> { + fn from(config: &'a crate::config::Config) -> Self { + Vocabulary { + patterns: &config.patterns, + verdicts: &config.verdicts, + } + } +} + /// Whether a `load` re-derives the AST-borne config checks. /// /// **A placement decision, and it was measured rather than reasoned.** The two @@ -559,16 +624,24 @@ pub enum ModuleChecks { pub fn load( root: &Path, rules: &[Rule], - patterns: &[crate::pattern::NamedPattern], + vocabulary: Vocabulary<'_>, checks: ModuleChecks, reference: Option<&str>, ) -> Result> { + let Vocabulary { patterns, verdicts } = vocabulary; // The table is validated at PARSE, beside `verbs` and `redirects` and for // their reason (`config.rs`'s `VALIDATED_AT_LOAD` census asserts the call // site exists). Validating again here would be a second authority for one // question, which is the shape `rules-drift` exists to refuse. let pattern_data = crate::pattern::data_document(patterns); let declared_patterns: BTreeSet<&str> = patterns.iter().map(|p| p.id.as_str()).collect(); + // The refusal vocabulary (CLOUD-1050): the consumer's rows unioned with what + // this binary ships. The vendored half is not optional politeness — a + // preset reaches a consumer who wrote no `[[verdict]]` row at all, so + // holding it to their table would make an enabled preset unloadable with no + // fix available. + let registry = registry_for(verdicts)?; + let mut emitted: BTreeSet = BTreeSet::new(); let mut bundles = Vec::new(); let mut seen: BTreeSet<&str> = BTreeSet::new(); // Every predicate id published so far, and the module that published it — @@ -655,6 +728,8 @@ pub fn load( if checks == ModuleChecks::Run { check_tree_paths_are_emittable(rule, &bundle, source_key)?; check_no_inline_regex(rule, &bundle, &declared_patterns, source_key)?; + check_verdicts_are_declared(rule, &bundle, ®istry, source_key)?; + emitted.extend(emitted_verdicts(&bundle)); } claim_ids(&mut ids, &declared, source_key)?; bundles.push(bundle); @@ -699,12 +774,18 @@ pub fn load( if checks == ModuleChecks::Run { check_tree_paths_are_emittable(rule, &bundle, where_it_came_from)?; check_no_inline_regex(rule, &bundle, &declared_patterns, where_it_came_from)?; + check_verdicts_are_declared(rule, &bundle, ®istry, where_it_came_from)?; + emitted.extend(emitted_verdicts(&bundle)); } claim_ids(&mut ids, &declared, where_it_came_from)?; bundles.push(bundle); } + + if checks == ModuleChecks::Run { + check_registry_is_exhausted(verdicts, &emitted)?; + } Ok(bundles) } @@ -864,7 +945,13 @@ fn collect_strings(results: ®orus::QueryResults, rule_name: &str) -> Option Option> { let mut messages = Vec::new(); for value in package_members(results, DENY_RULE) { @@ -890,14 +977,21 @@ fn collect_deny_messages(results: ®orus::QueryResults) -> Option> Some(messages) } -/// The `{"rule": …, "msg": …}` members of every `violation` under the package, -/// or `None` for a shape this gate cannot read. -/// -/// A member missing `msg` is unreadable rather than empty-messaged: a refusal -/// whose text is the empty string tells its reader nothing, and inventing one -/// here would put Batten's words in the consumer's mouth. A member missing -/// `rule` is fine and falls back to the row, which is [`DENY_RULE`]'s behaviour -/// reached by a different spelling. +/// The `{"rule": …, "verdict": …, "subjects": […]}` members of every +/// `violation` under the package, or `None` for a shape this gate cannot read. +/// +/// A member missing `verdict` is unreadable rather than unclassified: a refusal +/// with no declared class is the free string CLOUD-1050 retired, and admitting +/// one here would let the old shape back in through the decoder — which is the +/// half-migration that leaves two ABIs and a reader that accepts both. A member +/// missing `rule` is fine and falls back to the row, which is [`DENY_RULE`]'s +/// behaviour reached by a different spelling. +/// +/// `subjects` is optional and defaults to empty: a class whose whole content is +/// "this tree, as a whole" has nothing to point at, and demanding a pointer +/// there would be satisfied by an invented one. A `subjects` that is present and +/// is NOT a list of readable shapes is could-not-look, because a module speaking +/// a dialect this decoder does not have is not a module reporting nothing. fn collect_violations(results: ®orus::QueryResults) -> Option> { let mut violations = Vec::new(); for value in package_members(results, VIOLATION_RULE) { @@ -909,20 +1003,49 @@ fn collect_violations(results: ®orus::QueryResults) -> Option> }; for item in items { let object = item.as_object().ok()?; - let msg = object.get(&"msg".into())?.as_string().ok()?; + let verdict = object.get(&"verdict".into())?.as_string().ok()?; let rule = object .get(&"rule".into()) .and_then(|value| value.as_string().ok()) .map(std::string::ToString::to_string); + let subjects = match object.get(&"subjects".into()) { + None | Some(regorus::Value::Undefined) => Vec::new(), + Some(raw) => read_subjects(raw)?, + }; violations.push(Violation { rule, - msg: msg.to_string(), + verdict: verdict.to_string(), + subjects, }); } } Some(violations) } +/// One violation's `subjects` array, or `None` for a shape this cannot read. +/// +/// Goes through `serde_json` rather than walking `regorus::Value` by hand, +/// because [`crate::verdict::Subject::from_json`] is the ONE reader for this +/// shape and a second one here is how the two arms of `{path}` and +/// `{path, line}` end up ordered differently on two surfaces. +fn read_subjects(value: ®orus::Value) -> Option> { + let items = match value { + regorus::Value::Array(items) => items.iter().collect::>(), + // A SET is admitted as well as an array, because Rego's comprehension + // over a set is the natural spelling and a module author should not have + // to know which one the decoder prefers. Ordering is then the set's own, + // which regorus keeps sorted — so §6 byte-stability holds either way. + regorus::Value::Set(items) => items.iter().collect(), + _ => return None, + }; + let mut subjects = Vec::new(); + for item in items { + let json = serde_json::to_value(item).ok()?; + subjects.push(crate::verdict::Subject::from_json(&json)?); + } + Some(subjects) +} + /// Every value named `rule_name` anywhere under the queried package. /// /// Recurses into object values, which is what a sub-package is in the `data` @@ -1008,11 +1131,11 @@ pub fn deny(bundle: &Bundle, input: &str) -> Look> { let mut violations = Vec::new(); match collect_deny_messages(&answered) { - Some(messages) => violations.extend( - messages - .into_iter() - .map(|msg| Violation { rule: None, msg }), - ), + Some(tokens) => violations.extend(tokens.into_iter().map(|verdict| Violation { + rule: None, + verdict, + subjects: Vec::new(), + })), None => return Look::CouldNotLook, } match collect_violations(&answered) { @@ -1201,6 +1324,228 @@ fn check_tree_paths_are_emittable(rule: &Rule, bundle: &Bundle, source: &str) -> Ok(()) } +/// The other direction of registry equality, once over the whole rule set +/// (CLOUD-1050). +/// +/// A declared token nothing emits is **dead vocabulary**: it reads as coverage +/// in `batten policy explain`, and its routes — the remedy a reader would be +/// sent down — have never been exercised by anything. That is the same defect a +/// `[[waiver]]` naming an undeclared rule has, arriving from the other side. +/// +/// **The consumer's own rows only.** The vendored half is this binary's +/// vocabulary rather than the consumer's config, and a vendored class is not +/// dead just because the tree in front of it enables no preset that raises one — +/// refusing there would make every consumer answer for a table they did not +/// write. +/// +/// **Tombstones are exempt by construction rather than by a clause**: a retired +/// entry is filtered out here, because being explainable and unemitted is its +/// whole purpose. +/// +/// Called only where the module checks ran, because only then was `emitted` +/// populated: the mediated path skips the AST read for CLOUD-689's budget, and +/// asserting equality against a set nothing filled would refuse every token on +/// every hook call. +/// +/// # Errors +/// +/// A [`UsageError`] (exit `1`) naming the unraised tokens. +fn check_registry_is_exhausted( + verdicts: &[crate::verdict::DeclaredVerdict], + emitted: &BTreeSet, +) -> Result<()> { + let native = crate::verdict::native_tokens(); + let unemitted: Vec<&str> = verdicts + .iter() + .filter(|entry| !entry.retired()) + .map(|entry| entry.id.as_str()) + .filter(|token| !emitted.contains(*token) && !native.contains(token)) + .collect(); + if let Some(first) = unemitted.first() { + return Err(UsageError::raise(format!( + "`[[verdict]]` declares `{first}`, which nothing raises — not a policy module \ +and not a native refusal site. A class no gate reaches reads as coverage in `batten policy \ +explain` and its routes have never been walked by anybody. Delete the row, or give it a \ +`successor` so it becomes a tombstone. Unemitted: {}", + unemitted.join(", ") + ))); + } + Ok(()) +} + +/// The registry a load decides against: the consumer's rows, then this binary's. +/// +/// **A collision is refused rather than resolved.** Letting the consumer's row +/// win would let a `batten.toml` silently redefine a class a vendored preset +/// raises — the preset's refusal would then carry words its author never wrote, +/// which is the same defect as a second authority for one question. Letting the +/// vendored one win would make a consumer's declaration inert while reading as +/// live. Both are worse than saying so. +/// +/// # Errors +/// +/// A [`UsageError`] (exit `1`) naming the colliding token. +pub fn registry_for( + verdicts: &[crate::verdict::DeclaredVerdict], +) -> Result> { + let mut registry = verdicts.to_vec(); + let declared: BTreeSet<&str> = verdicts.iter().map(|entry| entry.id.as_str()).collect(); + for entry in crate::verdict::vendored() { + if declared.contains(entry.id.as_str()) { + return Err(UsageError::raise(format!( + "`[[verdict]]` declares `{}`, which this binary already ships — \ + a class with two definitions renders one refusal under words its \ + emitter never wrote. Pick a token this binary does not vendor", + entry.id + ))); + } + registry.push(entry); + } + Ok(registry) +} + +/// Refuse a module whose refusals the registry does not declare (CLOUD-1050). +/// +/// Three clauses, and they are three different defects rather than one: +/// +/// * **the retired key.** A module still binding `msg` speaks the ABI the +/// decoder no longer reads, so its refusals would arrive as could-not-look — +/// a gate that loads, evaluates and reports nothing. Refused where it is +/// written. +/// * **a composed token.** `"verdict": sprintf(…)` is a class no reader can +/// resolve and no registry can be held to. The whole point of a token is that +/// being told it twice is being told the same thing twice, and a composed one +/// cannot promise that. +/// * **an undeclared or retired token.** A token the registry does not carry has +/// no gloss, no class definition and no routes, so the refusal it produces is +/// the bare no CLOUD-122 named; a token declared as a TOMBSTONE has all three +/// and is still wrong to emit, because a tombstone's whole meaning is that +/// nothing emits it any more. +/// +/// **The other direction — a declared token nothing emits — is deliberately NOT +/// refused here**, and that is a bound rather than an omission. This function +/// sees one bundle; "nothing emits it" is a property of every bundle plus every +/// native site, and refusing per bundle would refuse a token the module across +/// the file emits. `load` closes that half once, over the whole rule set. +/// +/// # Errors +/// +/// A [`UsageError`] (exit `1`) naming the module, the rule and the token. +/// Pointer-only: never a line of the module body. +fn check_verdicts_are_declared( + rule: &Rule, + bundle: &Bundle, + registry: &[crate::verdict::DeclaredVerdict], + source: &str, +) -> Result<()> { + // Derived here rather than handed in, so the call site is one line at each + // of its two positions. The table is tens of entries and this is the load + // path, not the mediated one — `ModuleChecks::SkipOnHotPath` is what keeps + // it off the 100ms budget, and re-deriving a set that small is free against + // the AST read it sits beside. + let declared = crate::verdict::declared_tokens(registry); + let emittable = crate::verdict::live_tokens(registry); + let Some(described) = describe(&bundle.engine) else { + // Could-not-look on the AST is not a refusal, for the reason the sibling + // checks state. + return Ok(()); + }; + for module in &described { + for rule_ast in &module.rules { + if rule_ast.name.starts_with(TEST_PREFIX) { + // A module's own test may legitimately construct a violation + // object to compare against, and holding a test to the registry + // would make a case that pins the OLD token unwritable — which + // is exactly the case a retirement needs. + continue; + } + if rule_ast.binds_msg { + return Err(UsageError::raise(format!( + "rule `{}` registers `{source}`, whose module {} still binds `{RETIRED_MSG_KEY}` \ +in `{}`. A refusal is `{{rule, {VERDICT_KEY}, subjects}}` since CLOUD-1050: the prose moved into a \ +`[[verdict]]` row, where a gate can read it, and this key is no longer decoded — a module carrying \ +it loads clean and reports nothing", + rule.id, module.path, rule_ast.name, + ))); + } + if rule_ast.composes_verdict { + return Err(UsageError::raise(format!( + "rule `{}` registers `{source}`, whose module {} COMPOSES its `{VERDICT_KEY}` in \ +`{}` rather than naming one. A token is a name a reader can look up and a registry can be held to; \ +a composed one is neither", + rule.id, module.path, rule_ast.name, + ))); + } + // THE BARE-STRING CHANNEL IS HELD TO THE SAME REGISTRY. A member of + // `deny` is a verdict token since CLOUD-1050, so its literals are + // tokens too — and leaving them unchecked would keep one spelling of + // a refusal that can say anything, which is the hole the whole row + // exists to close. + let tokens: Vec<&String> = if rule_ast.name == DENY_RULE { + rule_ast + .head_literal + .iter() + .chain(&rule_ast.verdict_literals) + .collect() + } else { + rule_ast.verdict_literals.iter().collect() + }; + for token in tokens { + if emittable.contains(token.as_str()) { + continue; + } + let retired = declared.contains(token.as_str()); + let mut known: Vec<&str> = emittable.iter().copied().collect(); + known.sort_unstable(); + return Err(UsageError::raise(format!( + "rule `{}` registers `{source}`, whose module {} raises the verdict \ +`{token}` in `{}`, which {}. Declared and emittable: {}", + rule.id, + module.path, + rule_ast.name, + if retired { + "the registry declares as RETIRED — a tombstone exists so a historical token \ +stays explainable, never so a live predicate can keep raising it" + } else { + "no `[[verdict]]` row declares — the refusal would carry no gloss, no class \ +definition and no route, which is the bare no this ABI exists to refuse" + }, + if known.is_empty() { + String::from("none") + } else { + known.join(", ") + }, + ))); + } + } + } + Ok(()) +} + +/// Every verdict token a bundle's non-test rules name. +/// +/// The other half of [`check_verdicts_are_declared`]: that one asks whether an +/// emitted token is declared, and [`load`] uses this to ask whether a declared +/// token is emitted. +fn emitted_verdicts(bundle: &Bundle) -> BTreeSet { + let mut found = BTreeSet::new(); + let Some(described) = describe(&bundle.engine) else { + return found; + }; + for module in &described { + for rule_ast in &module.rules { + if rule_ast.name.starts_with(TEST_PREFIX) { + continue; + } + found.extend(rule_ast.verdict_literals.iter().cloned()); + if rule_ast.name == DENY_RULE { + found.extend(rule_ast.head_literal.iter().cloned()); + } + } + } + found +} + /// Refuse a regex written inline in a module (CLOUD-885). /// /// **The lever is cost, not prohibition**, and this is the half that applies it. @@ -1512,6 +1857,43 @@ struct DescribedRule { /// undefined, which Rego reads as "this rule body does not hold" — so this /// is what makes a typo a refusal rather than a silent disarm. pattern_refs: Vec, + /// Every string literal this rule binds to a `verdict` key (CLOUD-1050). + /// + /// **Read off the AST rather than off an evaluation**, for [`Described`]'s + /// own reason: a `violation` whose body does not hold on the empty document + /// evaluates to undefined, so a registry-equality check driven by evaluation + /// would be blind to exactly the predicates that did not fire — which is + /// most of them, most of the time. The AST names the token whatever the body + /// answers. + /// + /// A token composed at runtime (`sprintf`, a variable) is deliberately not + /// resolved. That is could-not-look rather than a guess, and the Regal rule + /// beside this refuses the composition outright, so the case is closed at + /// the source rather than approximated here. + verdict_literals: Vec, + /// Whether this rule COMPOSES a verdict rather than naming one. + /// + /// `"verdict": sprintf(…)` is a token no reader can resolve and no registry + /// can be held to, so it is refused where it is written rather than + /// approximated here. + composes_verdict: bool, + /// The string literal this rule's HEAD contributes, when it contributes one + /// (CLOUD-1050). + /// + /// `deny contains "V-X" if …` builds a set of strings, and the member is the + /// head's key. Read from the head rather than from the rule's literals, + /// because a rule's literals include every argument it passes: the fixture + /// `deny contains "V-X" if contains(input.call.command, "forbidden")` has two + /// string literals and exactly one of them is a token. Taking both reported + /// `forbidden` as an undeclared class, which is this reader's own first + /// firing and was caught by the suite rather than by reading. + head_literal: Option, + /// Whether this rule binds anything to a `msg` key (CLOUD-1050). + /// + /// The retired shape. Refused at load rather than left to the linter, + /// because a module carrying it is a module whose refusals the decoder + /// cannot read, and discovering that at adjudication is the worst time. + binds_msg: bool, } /// Read every module's rule names, spans and literals off the compiled AST. @@ -1561,6 +1943,32 @@ fn describe(engine: ®orus::Engine) -> Option> { collect_inline_regex(rule, &mut inline_regex); let mut pattern_refs = Vec::new(); collect_pattern_refs(rule, &mut pattern_refs); + // ONLY THE RULES THAT PUBLISH A REFUSAL. A helper may legitimately + // carry a field named `verdict` bound to a variable — the route + // projection in `policy/verdict-routes-resolve.rego` does, and + // reading it as a composed token was this check's own first firing, + // caught by running it over the corpus rather than by reading. + // + // The cost, stated rather than discovered: a module that builds its + // refusal object in a helper and yields it from `violation` is not + // read here. That is could-not-look — the registry-equality pass + // then sees no token for it — and the honest answer is that this + // reader does not follow a value across a rule boundary. + let publishes = name == VIOLATION_RULE || name == DENY_RULE; + let mut verdict_literals = Vec::new(); + let mut composes_verdict = false; + let mut msg_literals = Vec::new(); + let mut composes_msg = false; + let head_literal = spec.get("head").and_then(head_literal_of); + if publishes { + collect_bound_values( + rule, + VERDICT_KEY, + &mut verdict_literals, + &mut composes_verdict, + ); + collect_bound_values(rule, RETIRED_MSG_KEY, &mut msg_literals, &mut composes_msg); + } rules.push(DescribedRule { name, head_line, @@ -1568,6 +1976,10 @@ fn describe(engine: ®orus::Engine) -> Option> { input_paths, inline_regex, pattern_refs, + head_literal, + verdict_literals, + composes_verdict, + binds_msg: !msg_literals.is_empty() || composes_msg, }); } described.push(Described { @@ -1579,6 +1991,22 @@ fn describe(engine: ®orus::Engine) -> Option> { Some(described) } +/// The string literal a `contains` rule head builds its member from, if any. +/// +/// Only [`regorus::ast::Expr::Set`]'s `key` — an `if`-shaped or function head +/// contributes no set member, so there is nothing to read there and answering +/// `None` is the honest result rather than a fallback. +fn head_literal_of(head: &serde_json::Value) -> Option { + Some( + head.get("Set")? + .get("key")? + .get("String")? + .get("value")? + .as_str()? + .to_owned(), + ) +} + /// A rule head's name and the line it starts on, whichever head shape it is. /// /// `Compr` (`x := 1`, `test_x if …`), `Set` (`violation contains …`) and `Func` @@ -1883,6 +2311,70 @@ fn collect_string_values(value: &serde_json::Value, kind: &str, found: &mut Vec< } } +/// Every value a rule's object literals bind to the key `key` (CLOUD-1050). +/// +/// regorus serialises an object literal as +/// `{"Object": {"fields": [[, , ], …]}}`, so a field +/// is a three-element array and the pair this asks about is positional. Reading +/// it that way rather than scanning for adjacent string literals is what keeps +/// `{"verdict": "V-X"}` distinguishable from `{"x": "verdict"}` — the second is +/// two literals in the same order and a proximity reader cannot tell them apart. +/// +/// `found` collects the STRING-literal values only. A value composed at runtime +/// is reported through `composed` instead: it is could-not-look on the token, +/// and the two must not be spelled the same way, because a registry-equality +/// check that silently skipped composed tokens would pass over exactly the +/// modules that reintroduced free prose. +fn collect_bound_values( + value: &serde_json::Value, + key: &str, + found: &mut Vec, + composed: &mut bool, +) { + match value { + serde_json::Value::Object(object) => { + if let Some(fields) = object + .get("Object") + .and_then(|node| node.get("fields")) + .and_then(serde_json::Value::as_array) + { + for field in fields { + let Some(pair) = field.as_array() else { + continue; + }; + let (Some(name), Some(bound)) = (pair.get(1), pair.get(2)) else { + continue; + }; + let named = name + .get("String") + .and_then(|node| node.get("value")) + .and_then(serde_json::Value::as_str); + if named != Some(key) { + continue; + } + match bound + .get("String") + .and_then(|node| node.get("value")) + .and_then(serde_json::Value::as_str) + { + Some(literal) => found.push(literal.to_owned()), + None => *composed = true, + } + } + } + for child in object.values() { + collect_bound_values(child, key, found, composed); + } + } + serde_json::Value::Array(items) => { + for item in items { + collect_bound_values(item, key, found, composed); + } + } + _ => {} + } +} + /// Every string literal in a rule's AST subtree. fn collect_literals(value: &serde_json::Value, found: &mut Vec) { match value { diff --git a/crates/batten/src/policy/presets/commit-hygiene/no-empty-commit.rego b/crates/batten/src/policy/presets/commit-hygiene/no-empty-commit.rego index a0a627880..bd9c3c42b 100644 --- a/crates/batten/src/policy/presets/commit-hygiene/no-empty-commit.rego +++ b/crates/batten/src/policy/presets/commit-hygiene/no-empty-commit.rego @@ -15,7 +15,7 @@ rules contains "no-empty-commit" violation contains { "rule": "no-empty-commit", - "msg": "an empty commit records no change; if the goal is a fresh run, re-run the pipeline rather than minting a SHA nobody can read", + "verdict": "V-EMPTY-COMMIT", } if { words := split(input.call.command, " ") words[0] == "git" diff --git a/crates/batten/src/policy/presets/shell-hygiene/shebang-names-its-language.rego b/crates/batten/src/policy/presets/shell-hygiene/shebang-names-its-language.rego index 9ee2f85ae..c0acfb732 100644 --- a/crates/batten/src/policy/presets/shell-hygiene/shebang-names-its-language.rego +++ b/crates/batten/src/policy/presets/shell-hygiene/shebang-names-its-language.rego @@ -45,10 +45,8 @@ names_shell(path) if endswith(path, ".bash") violation contains { "rule": "shebang-names-its-language", - "msg": sprintf( - "%s runs a shell but its name does not say so; every instrument that selects by extension covers it silently and exits 0", - [path], - ), + "verdict": "V-SHEBANG-UNNAMED-LANGUAGE", + "subjects": [{"path": path}], } if { some path, _ in input.tree.lines declares_shell(path) diff --git a/crates/batten/src/policy/presets/shell-hygiene/sibling-resolves.rego b/crates/batten/src/policy/presets/shell-hygiene/sibling-resolves.rego index 2349556f0..155f56ba1 100644 --- a/crates/batten/src/policy/presets/shell-hygiene/sibling-resolves.rego +++ b/crates/batten/src/policy/presets/shell-hygiene/sibling-resolves.rego @@ -119,10 +119,12 @@ tracked_set contains entry if some entry in input.tree.tracked violation contains { "rule": "sibling-resolves", - "msg": sprintf( - "%s builds the sibling path %s at run time and the tree carries no such file; every caller of this shape guards it with a test and exits 0, so the reference does not fail, it goes silent", - [path, resolved], - ), + # TWO SUBJECTS, IN THIS ORDER. The file carrying the reference comes first + # because that is where the fix goes; the path it computed comes second + # because that is what the reader has to reconcile. Reversing them would send + # a reader to a file that does not exist. + "verdict": "V-SIBLING-UNRESOLVED", + "subjects": [{"path": path}, {"path": resolved}], } if { some path, _ in input.tree.lines some resolved in constructed(path) diff --git a/crates/batten/src/policy/presets/trunk-based/no-force-push.rego b/crates/batten/src/policy/presets/trunk-based/no-force-push.rego index 87c9aac96..6b8254c85 100644 --- a/crates/batten/src/policy/presets/trunk-based/no-force-push.rego +++ b/crates/batten/src/policy/presets/trunk-based/no-force-push.rego @@ -16,7 +16,7 @@ rules contains "no-force-push" violation contains { "rule": "no-force-push", - "msg": "a force push rewrites a shared branch under whoever already fetched it; use --force-with-lease, which refuses when the remote moved", + "verdict": "V-FORCE-PUSH-AT-TRUNK", } if { words := split(input.call.command, " ") words[0] == "git" diff --git a/crates/batten/src/receipt.rs b/crates/batten/src/receipt.rs index 8f77e9725..3f40383d4 100644 --- a/crates/batten/src/receipt.rs +++ b/crates/batten/src/receipt.rs @@ -91,7 +91,12 @@ use crate::rules::ReceiptKey; use crate::{config, git, identity, output, state}; /// The in-toto Statement v1 type identifier (CLOUD-132: adopt the format). -const STATEMENT_TYPE: &str = "https://in-toto.io/Statement/v1"; +/// +/// Public since CLOUD-1051, because [`crate::admission`]'s override record is a +/// third statement in the same envelope. One constant rather than a second +/// spelling: an envelope identifier written twice is two things that can drift, +/// and the whole point of adopting a format is that a reader recognises it. +pub const STATEMENT_TYPE: &str = "https://in-toto.io/Statement/v1"; /// Batten's receipt predicate type. The predicate vocabulary is Batten's own; /// the envelope is in-toto's. diff --git a/crates/batten/src/refusal.rs b/crates/batten/src/refusal.rs index 835266d13..7b51cef52 100644 --- a/crates/batten/src/refusal.rs +++ b/crates/batten/src/refusal.rs @@ -107,6 +107,18 @@ pub struct Refusal { /// The id that refused: a `[[rule]]` row's id, or a derived gate's declared /// constant. What a reviewer greps for in `batten.toml`. rule: String, + /// The declared class this refusal belongs to, when it has one (CLOUD-1050). + /// + /// `Some` for every one of Batten's OWN refusal sites, which name a + /// [`crate::verdict::Native`] variant and so cannot raise a class nobody + /// declared — the coupling is the type system's rather than a convention's. + /// `None` for a refusal composed from a consumer's `[[rule]]` row, whose + /// remedy is the consumer's declared `reason` under house style §8 and which + /// is deliberately not a Batten class. + /// + /// Serialized as an explicit `null` rather than dropped, the same reason + /// `fix` is: a consumer cannot tell an omitted key from an absent value. + verdict: Option, /// One line of why, pointer-only. reason: String, /// What to run instead, or an explicit none. @@ -128,11 +140,53 @@ impl Refusal { pub fn new(rule: impl Into, reason: impl Into, fix: Fix) -> Refusal { Refusal { rule: rule.into(), + verdict: None, reason: reason.into(), fix, } } + /// Build one of Batten's OWN refusals, from a declared class (CLOUD-1050). + /// + /// The caller names a [`crate::verdict::Native`] variant and the pointers it + /// can offer; the gloss and the remedy come off the vendored registry, so + /// neither is a string this call site chose. That is what makes CLOUD-122's + /// contract structural on the native path: [`crate::verdict::validate`] + /// refuses a class with no route and refuses one whose only route is an + /// override, so a site physically cannot construct a refusal with no way out. + /// + /// `fix` is still a parameter rather than derived outright, because two of + /// these sites can offer something narrower than the class's own route — the + /// consumer's declared `redirect` for a protected path, for one — and a + /// three-tier fallback that could only ever make a refusal MORE specific is + /// worth keeping. Passing [`Fix::None`] takes the declared route. + #[must_use] + pub fn declared( + rule: impl Into, + native: crate::verdict::Native, + subjects: &[crate::verdict::Subject], + fix: Fix, + ) -> Refusal { + let registry = crate::verdict::vendored(); + let token = native.id(); + let fix = match fix { + Fix::Run(text) => Fix::Run(text), + Fix::None => Fix::declared(crate::verdict::first_command_route(®istry, token)), + }; + Refusal { + rule: rule.into(), + verdict: Some(token.to_owned()), + reason: crate::verdict::render_line(®istry, token, subjects), + fix, + } + } + + /// The declared class, or `None` for a refusal composed from consumer prose. + #[must_use] + pub fn verdict(&self) -> Option<&str> { + self.verdict.as_deref() + } + /// The id that refused. #[must_use] pub fn rule(&self) -> &str { @@ -217,7 +271,71 @@ mod tests { let refusal = Refusal::new("some-gate", "it fired", Fix::None); assert_eq!( refusal.to_json().expect("the fixed shape serializes"), - r#"{"rule":"some-gate","reason":"it fired","fix":null}"# + r#"{"rule":"some-gate","verdict":null,"reason":"it fired","fix":null}"# + ); + } + + #[test] + fn a_refusal_from_consumer_prose_declares_no_class() { + // The direction that keeps the key honest (CLOUD-1050). A refusal + // composed from a `[[rule]]` row's own `reason` is the CONSUMER's + // statement, and labelling it with a Batten class would make the two + // indistinguishable to any reader keying on the token — which is the + // whole reason the token exists. + assert_eq!( + Refusal::new("some-gate", "it fired", Fix::None).verdict(), + None + ); + } + + #[test] + fn a_native_refusal_carries_its_declared_class_and_that_classs_remedy() { + // The other direction, and the structural half of CLOUD-122. Nothing at + // the call site chose either the gloss or the fix: passing `Fix::None` + // takes the class's first `command` route, and `verdict::validate` + // refuses a class that declares none reachable — so a native site cannot + // construct a refusal with no way out even by omission. + let refusal = Refusal::declared( + "provision", + crate::verdict::Native::ScannerUnprovisioned, + &[crate::verdict::Subject::Artifact { + artifact: "gitleaks".to_owned(), + }], + Fix::None, + ); + assert_eq!(refusal.verdict(), Some("V-SCANNER-UNPROVISIONED")); + assert!( + refusal.reason().starts_with("V-SCANNER-UNPROVISIONED ("), + "the hot path leads with the token: {}", + refusal.reason() + ); + assert!( + refusal.reason().ends_with(" gitleaks"), + "and carries the pointer inline rather than behind `explain`: {}", + refusal.reason() + ); + assert_eq!( + refusal.fix(), + &Fix::Run("batten provision".to_owned()), + "the remedy came off the declared route, not off this call site" + ); + } + + #[test] + fn a_narrower_fix_at_the_site_still_wins_over_the_declared_route() { + // The protected-path tier (CLOUD-280): a consumer's own `[[redirect]]` + // is more specific than the class's general route, so the class is a + // FLOOR rather than a ceiling. Without this the migration would have + // silently flattened three tiers into one. + let refusal = Refusal::declared( + "protected-mutation", + crate::verdict::Native::ProtectedMutation, + &[], + Fix::Run("use `serena rename_memory`".to_owned()), + ); + assert_eq!( + refusal.fix(), + &Fix::Run("use `serena rename_memory`".to_owned()) ); } @@ -226,7 +344,7 @@ mod tests { let refusal = Refusal::new("some-gate", "it fired", Fix::Run("run this".to_owned())); assert_eq!( refusal.to_json().expect("the fixed shape serializes"), - r#"{"rule":"some-gate","reason":"it fired","fix":"run this"}"# + r#"{"rule":"some-gate","verdict":null,"reason":"it fired","fix":"run this"}"# ); } diff --git a/crates/batten/src/resolve.rs b/crates/batten/src/resolve.rs index f51af80b0..f5de99f89 100644 --- a/crates/batten/src/resolve.rs +++ b/crates/batten/src/resolve.rs @@ -303,6 +303,17 @@ pub struct Resolved { /// carried for [`Resolved::verbs`]'s reason and layered the same way. #[serde(rename = "pattern")] pub patterns: Vec, + /// The refusal vocabulary (CLOUD-1050), consumer data the authority + /// supplies — carried for [`Resolved::patterns`]'s reason and layered the + /// same way. + /// + /// **Authority only, no local layer.** A `batten.local.toml` that could add + /// a class could give a refusal words the committed policy never chose, + /// which is a weakening dressed as an addition: the token stays the same and + /// what it MEANS changes. House style §8 admits raise-only overrides, and + /// redefining a refusal is not one. + #[serde(rename = "verdict")] + pub verdicts: Vec, /// The per-path-class redirect table (CLOUD-280), authority rows plus any a /// local file **added**. Local rows append after committed ones, and the /// lookup takes the first match, so an uncommitted file can add a class the @@ -1169,6 +1180,7 @@ fn assemble( contract: repo.contract.clone(), verbs: repo.verbs.clone(), patterns: repo.patterns.clone(), + verdicts: repo.verdicts.clone(), redirects: tables.redirects, facts: tables.facts, // Straight from the authority, never through `tables`: see the field's @@ -1235,6 +1247,11 @@ fn attribution( ("contract", authority_set(repo.contract.is_some())), ("verb", authority_set(!repo.verbs.is_empty())), ("pattern", authority_set(!repo.patterns.is_empty())), + // Authority-only, like `fact` below and for a related reason (CLOUD-1050): + // a local row here would not point a gate at chosen output, it would + // supply the WORDS a committed gate refuses in — the token stays the same + // and what it means changes, which is a weakening dressed as an addition. + ("verdict", authority_set(!repo.verdicts.is_empty())), ("marker", authority_set(!repo.markers.is_empty())), ( "exec_pattern", diff --git a/crates/batten/src/rules.rs b/crates/batten/src/rules.rs index 9b0eb2dce..1eddb73ac 100644 --- a/crates/batten/src/rules.rs +++ b/crates/batten/src/rules.rs @@ -4296,7 +4296,7 @@ pub fn run_static( _provisions: &[crate::provision::Provision], // The declared pattern table (CLOUD-885), riding beside `provisions` for the // same reason it does: a second config table the rule set evaluates against. - patterns: &[crate::pattern::NamedPattern], + vocabulary: crate::policy::Vocabulary<'_>, root: &Path, ) -> anyhow::Result { // POLICY BUNDLES ARE LOADED HERE, on the read surface, and that is @@ -4311,7 +4311,7 @@ pub fn run_static( let bundles = crate::policy::load( root, rules, - patterns, + vocabulary, crate::policy::ModuleChecks::Run, None, )?; @@ -4326,10 +4326,12 @@ pub fn run_static( // verdict — and the `batten:` prefix the boundary adds is correct for // that code (§7). return Err(UsageError::raise( - Refusal::new( + Refusal::declared( &rule.id, - "this rule kind runs a configured command, which `batten check` \ - (a read-effect verb) will not do", + crate::verdict::Native::SpawningRuleOnReadVerb, + &[crate::verdict::Subject::Artifact { + artifact: rule.kind.as_str().to_owned(), + }], Fix::Run(SPAWNING_VERB.to_owned()), ) .render(), @@ -4375,7 +4377,7 @@ pub fn run_static( pub fn run_recorded( rules: &[Rule], provisions: &[crate::provision::Provision], - patterns: &[crate::pattern::NamedPattern], + vocabulary: crate::policy::Vocabulary<'_>, root: &Path, ) -> anyhow::Result { let (evaluable, withheld): (Vec<&Rule>, Vec<&Rule>) = rules @@ -4385,7 +4387,7 @@ pub fn run_recorded( let bundles = crate::policy::load( root, &evaluable, - patterns, + vocabulary, crate::policy::ModuleChecks::Run, None, )?; @@ -4418,7 +4420,7 @@ pub fn run_recorded( pub fn run_all( rules: &[Rule], provisions: &[crate::provision::Provision], - patterns: &[crate::pattern::NamedPattern], + vocabulary: crate::policy::Vocabulary<'_>, root: &Path, ) -> anyhow::Result { // Refuse before any work, the shape `run_static` above already uses: the @@ -4437,7 +4439,7 @@ pub fn run_all( let bundles = crate::policy::load( root, rules, - patterns, + vocabulary, crate::policy::ModuleChecks::Run, None, )?; @@ -5825,37 +5827,87 @@ fn policy_rule( if severity == RuleSeverity::Allow { continue; } + let (pointer, line) = first_pointer(&violation.subjects); findings.push(Finding { // THE PREDICATE'S ID, not the row's (CLOUD-832). `waiver::apply` // matches on this field, so a waiver names the gate a reader saw // rather than the bundle that happens to hold it. rule: id.to_owned(), severity, - // The bundle root is the pointer. A denial is about the tree the - // module read, not about any one of its declared documents — the - // module decides which of them mattered and says so in its own - // message, which is the consumer's text exactly as a row's `reason` - // is. - path: rule - .bundle - .clone() - .or_else(|| rule.module.clone()) - .unwrap_or_else(|| rule.id.clone()), - line: None, + // THE MODULE'S OWN POINTER WHEN IT GAVE ONE (CLOUD-1050), and the + // bundle root otherwise. + // + // Before the typed ABI a tree finding could only ever point at the + // bundle, because the module's only channel was prose and prose is + // not a pointer — so `check` reported `policy/x.rego some-rule` and + // left the reader to find the file themselves. A `subjects` entry IS + // a pointer, which is the whole reason the field is tagged rather + // than free, so the first path-bearing one is what the finding + // carries. A class whose subjects are counts or artifacts still + // falls back to the bundle root, which is the honest pointer when + // the finding is about a set rather than a file. + path: pointer.clone().unwrap_or_else(|| { + rule.bundle + .clone() + .or_else(|| rule.module.clone()) + .unwrap_or_else(|| rule.id.clone()) + }), + line, check: rule.settling_check().unwrap_or(Check::Reevaluate), remediation: rule.remediation(), identity: identity::StoredIdentity::new( identity::FindingKind::Scope, - // Keyed on the PREDICATE, so the same gate breaking again is one - // finding rather than a new one per run, and two predicates in - // one bundle are two findings rather than one. - identity::scope_fingerprint(id, &violation.msg), + // KEYED ON `(rule, verdict, subjects)` (CLOUD-1050). It used to + // be keyed on the module's prose, so rewording a message reset + // every baseline entry for that predicate while changing nothing + // it decided. The token and the pointers are what the finding IS, + // and they move only when the finding does. + identity::scope_fingerprint(id, &fingerprint_of(violation)), ), }); } None } +/// The first path-bearing subject a violation carries, as a finding's pointer +/// (CLOUD-1050). +/// +/// FIRST rather than all of them, because a `Finding` carries one pointer and +/// the ordering of `subjects` is the module's own statement of which matters +/// most. A class with several files to name declares them in the order a reader +/// should follow; picking any other one would silently disagree with that. +fn first_pointer(subjects: &[crate::verdict::Subject]) -> (Option, Option) { + for subject in subjects { + match subject { + crate::verdict::Subject::Line { path, line } => { + return (Some(path.clone()), usize::try_from(*line).ok()); + } + crate::verdict::Subject::Path { path } => return (Some(path.clone()), None), + // A count or an artifact is a pointer to a SET or a NAME, neither of + // which a `path` column can carry honestly — putting one there would + // render as a file the reader could not open. + crate::verdict::Subject::Count { .. } | crate::verdict::Subject::Artifact { .. } => {} + } + } + (None, None) +} + +/// The identity ingredient a policy finding is keyed on: its class and its +/// pointers, in order. +/// +/// Rendered rather than hashed structurally so the value is readable in a +/// baseline diff — the same reason `scope_fingerprint`'s sibling ingredients are +/// strings. The token comes first because it is what the finding IS; the +/// pointers separate two instances of one class. +fn fingerprint_of(violation: &crate::policy::Violation) -> String { + let pointers = crate::verdict::render_subjects(&violation.subjects); + if pointers.is_empty() { + violation.verdict.clone() + } else { + format!("{} {pointers}", violation.verdict) + } +} + /// Evaluate a [`RuleKind::Ratchet`]: count at the base rev, count in the working /// tree, and report if the aggregate moved the banned way. /// @@ -5946,7 +5998,7 @@ fn ratchet_rule( // deleted case owed no mapping at all. The two questions are different, so // only the aggregate finding below stays conditional on // `direction.violated`; the reasoning is on `conserve_case_names`. - conserve_case_names(rule, root, files, &base_counts, &base_text, findings); + let fully_mapped = conserve_case_names(rule, root, files, &base_counts, &base_text, findings); if !direction.violated(base_count, working_count) { return Ok(()); @@ -5958,12 +6010,94 @@ fn ratchet_rule( // justify its decrease still denies at its own severity. let mut blockers: BTreeSet = BTreeSet::new(); if let Some(token) = retires_with { + blockers = retirement_blockers( + root, + base, + token, + &base_counts, + &base_text, + &working_counts, + &fully_mapped, + files, + )?; + if blockers.is_empty() { + // Every affected file's subject died in this same change, or its + // cases are fully mapped. This is the retirement the `[[waiver]]` + // used to have to express by switching the whole rule off. + return Ok(()); + } + } + + // The admission for an INCREASE (CLOUD-929), the mirror of the block above + // and deliberately the weaker one. Reasoning is on `undeclared_growth`. + if admits_with.is_some() { + blockers.extend(undeclared_growth( + &base_counts, + &working_counts, + &working_declared, + )); + if blockers.is_empty() { + // Every file that grew said why. The surface moved in the refused + // direction and the change owns that in writing. + return Ok(()); + } + } + + ratchet_finding(rule, glob, base_count, working_count, &blockers, findings); + Ok(()) +} + +/// What stops a DECREASE from being admitted, per CLOUD-807's column. +/// +/// Extracted from [`ratchet_rule`] so that function stays under the line lint; +/// the reasoning for each clause is on the clause. Returns the empty set when +/// every affected path answered — which is the admission — and the caller reads +/// that rather than this deciding the rule's verdict, because a `retires_with` +/// row that admits still owes the increase half its own question. +#[expect( + clippy::too_many_arguments, + reason = "every argument is a distinct fact the admission reads, and bundling \ + them into a struct would put this block's private working state into \ + the module's type surface for one call site" +)] +fn retirement_blockers( + root: &Path, + base: &str, + token: &str, + base_counts: &BTreeMap, + base_text: &BTreeMap, + working_counts: &BTreeMap<&str, usize>, + fully_mapped: &BTreeSet, + files: &[String], +) -> anyhow::Result> { + let mut blockers: BTreeSet = BTreeSet::new(); + { let mut declared: BTreeSet = BTreeSet::new(); - for (path, was) in &base_counts { + for (path, was) in base_counts { let now = working_counts.get(path.as_str()).copied().unwrap_or(0); if now >= *was { continue; } + // THE MAPPED-SUCCESSOR ARM (CLOUD-1050). `retires_with` buys a + // decrease with a subject that DIED, and that is the right question + // for a suite whose subject is a shell program the migration + // deletes. It is the wrong question for a suite whose subject is a + // `.rego` module the migration KEEPS and rewrites: the module is + // alive, so the subject-death arm refuses, and the suite — which + // asserts the refusal text the rewrite just changed — cannot be + // edited either, because `shell-retirement` refuses editing a bats + // suite in place. Both doors shut on a deletion whose logic is + // provably accounted for. + // + // So a complete CLOUD-908 ledger is the second admission: every case + // this change dropped from this path resolved to exactly one arm, + // naming a target the tree carries, with a reason where `changed` + // demands one. That is strictly more evidence than subject death, + // which asks nothing about where the cases went — and it is why this + // arm is an addition rather than a loosening. + if fully_mapped.contains(path) { + continue; + } // Read from the BASE text, never the working one. A retired file has // no working copy to read, and allowing the working copy would let a // change rewrite its own permission in the commit that spends it. @@ -5989,29 +6123,24 @@ fn ratchet_rule( blockers.insert(format!("{SUBJECT_ALIVE} {path}")); } } - if blockers.is_empty() { - // Every affected file's subject died in this same change. This is - // the retirement the `[[waiver]]` used to have to express by - // switching the whole rule off. - return Ok(()); - } - } - - // The admission for an INCREASE (CLOUD-929), the mirror of the block above - // and deliberately the weaker one. Reasoning is on `undeclared_growth`. - if admits_with.is_some() { - blockers.extend(undeclared_growth( - &base_counts, - &working_counts, - &working_declared, - )); - if blockers.is_empty() { - // Every file that grew said why. The surface moved in the refused - // direction and the change owns that in writing. - return Ok(()); - } } + Ok(blockers) +} +/// The refusal a ratchet raises when neither admission answered. +/// +/// Extracted alongside [`retirement_blockers`] for the same reason, and it is the +/// half worth keeping whole: every field below carries an argument about what a +/// ratchet finding may and may not say, and splitting them across a call site +/// would separate each from its reason. +fn ratchet_finding( + rule: &Rule, + glob: &str, + base_count: usize, + working_count: usize, + blockers: &BTreeSet, + findings: &mut Vec, +) { findings.push(Finding { // The plain rule id, deliberately: `waiver::apply` matches on this // field, so decorating it would make a ratchet the one finding kind @@ -6034,7 +6163,7 @@ fn ratchet_rule( // header again. Never a case name, never a line of the deleted suite. path: format!( "{glob} {base_count}->{working_count}{}", - render_blockers(&blockers) + render_blockers(blockers) ), line: None, check: rule.settling_check().unwrap_or(Check::Reevaluate), @@ -6047,7 +6176,6 @@ fn ratchet_rule( identity::scope_fingerprint(&rule.id, glob), ), }); - Ok(()) } /// Which files grew without declaring a reason (CLOUD-929). @@ -6396,9 +6524,10 @@ fn conserve_case_names( base_counts: &BTreeMap, base_text: &BTreeMap, findings: &mut Vec, -) { +) -> BTreeSet { + let mut fully_mapped = BTreeSet::new(); let Some(conserves) = rule.conserves.as_ref() else { - return; + return fully_mapped; }; // Read ONCE for the whole scan rather than per file, and lazy: a change that // dropped no case name reads no successor at all. @@ -6420,8 +6549,19 @@ fn conserve_case_names( claimed, files, }; + // PER PATH, so the mapped-successor arm below can ask about one file + // (CLOUD-1050). A path whose every dropped case resolved to exactly one + // well-formed arm has had its logic accounted for, which is a different + // question from whether its SUBJECT died — and it is the question a + // migration can actually answer when the subject is a `.rego` module + // that is still very much alive. + let before = findings.len(); unconserved_cases(rule, path, text, &survivors, &mapping, findings); + if findings.len() == before { + fully_mapped.insert(path.clone()); + } } + fully_mapped } /// File one mapping refusal, keyed so the same case reported twice is one @@ -9003,11 +9143,12 @@ mod tests { /// found; [`Scan::not_evaluated`] has its own tests, so shadowing keeps the /// suite reading as it did before that half existed. fn run_static(rules: &[Rule], root: &Path) -> anyhow::Result> { - super::run_static(rules, &[], &[], root).map(|scan| scan.findings) + super::run_static(rules, &[], crate::policy::Vocabulary::EMPTY, root) + .map(|scan| scan.findings) } fn run_all(rules: &[Rule], root: &Path) -> anyhow::Result> { - super::run_all(rules, &[], &[], root).map(|scan| scan.findings) + super::run_all(rules, &[], crate::policy::Vocabulary::EMPTY, root).map(|scan| scan.findings) } fn forbid(id: &str, glob: &str, pattern: &str) -> Rule { @@ -9181,17 +9322,26 @@ mod tests { let dir = temp_dir("scan-skipped"); write(&dir, "src/a.rs", "fine\n"); - let clean = - super::run_static(&[forbid("looked", "**/*.rs", "TODO")], &[], &[], &dir).unwrap(); + let clean = super::run_static( + &[forbid("looked", "**/*.rs", "TODO")], + &[], + crate::policy::Vocabulary::EMPTY, + &dir, + ) + .unwrap(); assert!(clean.findings.is_empty()); assert!( clean.not_evaluated.is_empty(), "a rule that read a file and found nothing DID evaluate" ); - let skipped = - super::run_static(&[forbid("never-looked", "**/*.md", "TODO")], &[], &[], &dir) - .unwrap(); + let skipped = super::run_static( + &[forbid("never-looked", "**/*.md", "TODO")], + &[], + crate::policy::Vocabulary::EMPTY, + &dir, + ) + .unwrap(); assert!(skipped.findings.is_empty()); assert_eq!( skipped.not_evaluated.get("never-looked"), @@ -9207,7 +9357,7 @@ mod tests { ..forbid("switched-off", "**/*.rs", "TODO") }], &[], - &[], + crate::policy::Vocabulary::EMPTY, &dir, ) .unwrap(); diff --git a/crates/batten/src/secrets.rs b/crates/batten/src/secrets.rs index 535125180..144f93a21 100644 --- a/crates/batten/src/secrets.rs +++ b/crates/batten/src/secrets.rs @@ -1121,10 +1121,12 @@ fn discriminated(fingerprint: Fingerprint, rule: &Rule) -> Fingerprint { fn resolve_scanner(provisions: &[Provision], root: &Path) -> Result { let Some(entry) = provisions.iter().find(|entry| entry.name == SCANNER) else { return Err(UsageError::raise( - Refusal::new( + Refusal::declared( SCANNER, - "a `secrets` rule needs the scanner pinned, and this config declares no \ - `[[provision]]` entry for it", + crate::verdict::Native::ScannerUnpinned, + &[crate::verdict::Subject::Artifact { + artifact: SCANNER.to_owned(), + }], Fix::Run(PROVISION_VERB.to_owned()), ) .render(), @@ -1148,9 +1150,12 @@ fn resolve_scanner(provisions: &[Provision], root: &Path) -> Result { // already holds, with the verb named because a refusal that says only // what it would not do leaves the caller guessing (CLOUD-122). return Err(UsageError::raise( - Refusal::new( + Refusal::declared( SCANNER, - "the pinned scanner is not in the provision cache, so no file was scanned", + crate::verdict::Native::ScannerUnprovisioned, + &[crate::verdict::Subject::Artifact { + artifact: SCANNER.to_owned(), + }], Fix::Run(PROVISION_VERB.to_owned()), ) .render(), diff --git a/crates/batten/src/spec.rs b/crates/batten/src/spec.rs index 5c4b59fcf..92feb6ba8 100644 --- a/crates/batten/src/spec.rs +++ b/crates/batten/src/spec.rs @@ -357,6 +357,7 @@ mod tests { // no write for the noun row to smuggle on (CLOUD-50). "policy".to_owned(), "policy budget".to_owned(), + "policy explain".to_owned(), "policy test".to_owned(), "policy tools".to_owned(), // The freshness verb, never the `provision` noun or `apply`: @@ -516,6 +517,7 @@ mod tests { "payload field".to_owned(), "policy".to_owned(), "policy budget".to_owned(), + "policy explain".to_owned(), "policy test".to_owned(), "policy tools".to_owned(), "provision".to_owned(), diff --git a/crates/batten/src/stop.rs b/crates/batten/src/stop.rs index 7e8ce35f1..fae4dd028 100644 --- a/crates/batten/src/stop.rs +++ b/crates/batten/src/stop.rs @@ -133,7 +133,16 @@ impl StopFacts { if !self.blocks() { return None; } - let reason = format!("the turn is not finished — {}", self.lines().join("; ")); + // The blocking lines become tagged pointers rather than a joined + // sentence (CLOUD-1050). They were already pointer-only — `AtRisk::lines` + // and `denial: ` — so this is a change of shape, not of content, + // and the class's own gloss now carries the "the turn is not finished" + // half that used to be prose at this call site. + let subjects: Vec = self + .lines() + .into_iter() + .map(|line| crate::verdict::Subject::Artifact { artifact: line }) + .collect(); let fix = self .pending .iter() @@ -150,7 +159,12 @@ impl StopFacts { }, Fix::Run, ); - Some(Refusal::new(RULE, reason, fix)) + Some(Refusal::declared( + RULE, + crate::verdict::Native::StopConditionUnmet, + &subjects, + fix, + )) } } diff --git a/crates/batten/src/surface.rs b/crates/batten/src/surface.rs index 1cc1109a3..46252d558 100644 --- a/crates/batten/src/surface.rs +++ b/crates/batten/src/surface.rs @@ -659,6 +659,26 @@ const NO_CACHE: FlagDecl = FlagDecl { }; /// `--class `: narrow a defect query to one taxonomy class. +/// The verdict token `policy explain` resolves (CLOUD-1053). +/// +/// Positional and REQUIRED. `explain` with no token would have to either print +/// the whole registry — a payload nobody asked for, on a surface whose payload +/// exception is narrow and deliberate — or exit 0 having answered nothing, which +/// is the vacuous pass in a documentation verb. +const VERDICT_TOKEN: FlagDecl = FlagDecl { + id: "token", + long: None, + short: None, + help: "The verdict token to resolve, e.g. V-TASK-UNDEFINED", + env: EnvDecl::None, + global: false, + positional: true, + required: true, + hidden: false, + rung: Rung::None, + value: ValueDecl::Str, +}; + const CLASS: FlagDecl = FlagDecl { id: "class", long: Some("class"), @@ -1554,6 +1574,27 @@ pub const SURFACE: &[CommandDecl] = &[ effect: Effect::Read, flags: &[JSON], }, + // The dereference half of CLOUD-1053: the hot path prints a token, a gloss + // and a pointer, and this is what the token resolves to. + // + // `read` structurally, and more narrowly than its siblings: it resolves from + // the COMMITTED registry with no network, no spawn and no tree walk — a + // config read and a lookup. That is what lets it be the one place the class + // definition is carried without the hot path paying for it. + // + // **Its payload is a DELIBERATE, STATED exception to pointer-only output** + // (house style §6, non-negotiable rule 4). `explain` is local documentation + // rather than a finding; carrying the text the hot path no longer does is + // its entire purpose, and the text is the config author's own declaration — + // the class `config show` exists to echo — not content read out of a subject + // file. + CommandDecl { + path: "policy explain", + about: "Resolve a verdict token to its class definition and the routes out of it", + data_channel: true, + effect: Effect::Read, + flags: &[VERDICT_TOKEN, JSON], + }, // The `attribution` noun only dispatches, and like `worktree` it cannot be // `read`: its subtree carries `identity`, which writes `.git/config`. It is // deliberately NOT a verb under `policy` for exactly the reason that row diff --git a/crates/batten/src/trust.rs b/crates/batten/src/trust.rs index da84495e3..2ceb95e0a 100644 --- a/crates/batten/src/trust.rs +++ b/crates/batten/src/trust.rs @@ -616,6 +616,25 @@ pub enum WeakeningKind { /// report it — a vanished `[[mint]]` shows up as the rule it fed going /// unsatisfiable, which is loud rather than silent. MintChanged, + /// A `[[verdict]]` class gained an `override` route the base did not carry + /// (CLOUD-1050, CLOUD-1051) — an *added*-direction weakening, like + /// [`WeakeningKind::MintAdded`] and for the same reason. + /// + /// **The direction is inverted, and that inversion is why this kind exists.** + /// Removing a class is not a weakening: a module raising a token no row + /// declares fails to LOAD, so a deletion is fail-closed and loud. Rewriting a + /// class's gloss or its command routes changes what a refusal SAYS, never + /// whether it fires — `redirects`'s reading, one layer up. The one edit that + /// lowers the bar is an `override` route, because an override route is a + /// declared way to be RELEASED from the refusal: CLOUD-1051 generates the + /// questions an admission answers from its `precondition`, so a class that + /// gained one is a gate that gained a hatch. + /// + /// Reported as ADDED rather than as a byte change, deliberately. Whether one + /// precondition is looser than another is the judgement this module refuses + /// to make; that a class went from having no hatch to having one is a + /// predicate. + VerdictOverrideAdded, /// A `[[marker]]` row is gone, so its suppressions stop being counted. MarkerRemoved, /// An `[[exec_pattern]]` row is gone, so a lying exit `0` carrying it stops @@ -709,6 +728,7 @@ impl WeakeningKind { WeakeningKind::EpochPathRemoved, WeakeningKind::VerbRemoved, WeakeningKind::PatternRemoved, + WeakeningKind::VerdictOverrideAdded, WeakeningKind::FactCommandChanged, WeakeningKind::FactReturnsLoosened, WeakeningKind::FactRemoved, @@ -753,6 +773,7 @@ impl WeakeningKind { WeakeningKind::EpochPathRemoved => "epoch-path-removed", WeakeningKind::VerbRemoved => "verb-removed", WeakeningKind::PatternRemoved => "pattern-removed", + WeakeningKind::VerdictOverrideAdded => "verdict-override-added", WeakeningKind::FactCommandChanged => "fact-command-changed", WeakeningKind::FactReturnsLoosened => "fact-returns-loosened", WeakeningKind::FactRemoved => "fact-removed", @@ -879,6 +900,10 @@ pub const CENSUS: &[FieldCoverage] = &[ field: "patterns", coverage: Coverage::Compared(&[WeakeningKind::PatternRemoved]), }, + FieldCoverage { + field: "verdicts", + coverage: Coverage::Compared(&[WeakeningKind::VerdictOverrideAdded]), + }, FieldCoverage { field: "facts", coverage: Coverage::Compared(&[ @@ -1231,6 +1256,24 @@ fn entry_weakenings(base: &Config, working: &Config) -> Vec { "pattern", )); + // The refusal vocabulary (CLOUD-1050). Only the ADDED direction, and only + // the override route: see `VerdictOverrideAdded` for why removal is + // fail-closed and why rewording is not policy-bearing. + { + // Rendered key paths, because `added_entries` takes them already + // rendered — see its own note for why. + let render = |set: BTreeSet| -> Vec { + set.into_iter() + .map(|id| format!("verdict[{id}].override")) + .collect() + }; + found.extend(added_entries( + WeakeningKind::VerdictOverrideAdded, + &render(verdict_override_entries(base)), + &render(verdict_override_entries(working)), + )); + } + // The agent-sourced facts (CLOUD-776). Removal is reported and is a // tightening; a CHANGED command is the dangerous direction, because the same // string is both what the agent is told to run and what the record is checked @@ -1490,6 +1533,25 @@ fn pattern_entries(config: &Config) -> Vec { config.patterns.iter().map(|row| row.id.clone()).collect() } +/// Every `[[verdict]]` class that declares an `override` route (CLOUD-1050). +/// +/// The id alone, because that is the object the comparison decides over: a class +/// either offers a hatch or it does not, and which precondition it states is the +/// judgement `VerdictOverrideAdded` records this module refusing to make. +fn verdict_override_entries(config: &Config) -> BTreeSet { + config + .verdicts + .iter() + .filter(|entry| { + entry + .routes + .iter() + .any(|route| route.kind == crate::verdict::RouteKind::Override) + }) + .map(|entry| entry.id.clone()) + .collect() +} + /// The ids of a table, collected so [`removed_entries`] can compare them. fn ids(entries: impl Iterator) -> Vec { entries.collect() @@ -2577,6 +2639,63 @@ mod tests { assert!(weakenings(&working, &base).is_empty()); } + /// A verdict row, with or without an override route. + fn verdict_row(id: &str, hatched: bool) -> String { + let mut row = format!( + "\n[[verdict]]\nid = \"{id}\"\ngloss = \"a class\"\nclass = \"what it means\"\n\n\ + [[verdict.route]]\nid = \"R-FIX-IT\"\nkind = \"document\"\ntarget = \"batten.toml\"\n" + ); + if hatched { + row.push_str( + "\n[[verdict.route]]\nid = \"R-ASK\"\nkind = \"override\"\n\ + precondition = \"you can state why the gate should not stand here\"\n", + ); + } + row + } + + /// **The inverted direction, and the only edit to this table that lowers the + /// bar.** Deleting a class is fail-closed — a module raising a token no row + /// declares fails to LOAD — and rewording one changes what a refusal says + /// rather than whether it fires. An `override` route is different in kind: it + /// is a declared way to be RELEASED from the refusal, and CLOUD-1051 + /// generates the questions an admission answers from its precondition. + #[test] + fn a_verdict_that_gained_an_override_route_is_a_weakening() { + let base = config(&verdict_row("V-A-CLASS", false)); + let working = config(&verdict_row("V-A-CLASS", true)); + assert_eq!( + only(&base, &working), + Weakening::new( + WeakeningKind::VerdictOverrideAdded, + "verdict[V-A-CLASS].override", + "absent", + "present", + ) + ); + // The other direction is a tightening: the hatch closed. + assert!(weakenings(&working, &base).is_empty()); + // And an unchanged table says nothing, which is what keeps the row from + // reporting on every comparison. + assert!(weakenings(&base, &base).is_empty()); + } + + /// The two edits that are NOT weakenings, asserted so the narrow reading is + /// a property of the code rather than of the doc comment above it. + #[test] + fn deleting_or_rewording_a_verdict_is_not_a_weakening() { + let hatched = config(&verdict_row("V-A-CLASS", true)); + // Deleted entirely: fail-closed, because a module still raising the + // token no longer loads. + assert!(weakenings(&hatched, &config("")).is_empty()); + // Reworded, hatch unchanged: what the refusal SAYS moved and what it + // decides did not. + let reworded = config( + &verdict_row("V-A-CLASS", true).replace("what it means", "what it means, restated"), + ); + assert!(weakenings(&hatched, &reworded).is_empty()); + } + fn verb_row(verb: &str, subcommand: Option<&str>) -> String { let qualifier = subcommand.map_or_else(String::new, |sub| format!("subcommand = \"{sub}\"\n")); diff --git a/crates/batten/src/verdict.rs b/crates/batten/src/verdict.rs new file mode 100644 index 000000000..a70ee4009 --- /dev/null +++ b/crates/batten/src/verdict.rs @@ -0,0 +1,1034 @@ +//! The refusal vocabulary: what a gate may say, declared once as data +//! (CLOUD-1050). +//! +//! # The defect this closes +//! +//! A refusal was a **free string**. `policy::Violation { rule, msg }` and the +//! native `Refusal { rule, reason, fix }` both carried prose no mechanism could +//! check, so every remedy defect CLOUD-122 and CLOUD-871 named was expressible +//! and none was checkable: a refusal could name no remedy, name a task that does +//! not exist, offer an override with no precondition, or say the same thing in +//! nineteen spellings — and every one of those passes a string field. +//! +//! CLOUD-843's migration is the deadline rather than the motive. Every gate +//! ported to Rego under the free-string shape is a gate ported twice. +//! +//! # What replaces it +//! +//! A refusal is `{rule, verdict, subjects}`: +//! +//! * `rule` — the predicate id that fired, unchanged (CLOUD-832). +//! * `verdict` — a **token** declared in this registry. The prose lives here, +//! once, where a gate can read it. +//! * `subjects` — ordered pointers, each a tagged variant. Never a payload +//! (non-negotiable rule 4). +//! +//! The registry is the sole authority for **both** emitters, which is the +//! reversal CLOUD-1050 records of its own first review: a policy-only registry +//! plus a native authority is the drift defect one layer up, and the two would +//! disagree the first time either moved. +//! +//! # Deny-only survives, by construction +//! +//! A verdict declares refusal classes and nothing else. There is no allow +//! spelling here for the same reason `.claude/rules/policy-modules.md` gives for +//! the module shape: enabling a vocabulary can only ever add ways to refuse, so +//! house style §8's raise-only invariant is untouched. +//! +//! # What the config spelling is, and why it is not `[[policy.verdict]]` +//! +//! CLOUD-1050's body writes the table as `[[policy.verdict]]`. It is spelled +//! `[[verdict]]` here, and the reason is the surface rather than the row: every +//! named table this config already carries — `[[pattern]]`, `[[verb]]`, +//! `[[redirect]]` — is top level, and a nested one introduces a second nesting +//! convention for a single row type while deciding nothing differently. The +//! registry's semantics are exactly the ones the row specifies. + +use std::collections::BTreeSet; + +use serde::{Deserialize, Serialize}; + +use crate::error::UsageError; + +/// The prefix every verdict token carries. +/// +/// A token has to be recognisable **as a token** in a line of output that also +/// carries a rule id and a path, and a fixed prefix is what makes that free +/// rather than a convention a reader has to know. `R-` is its sibling for +/// routes. +pub const VERDICT_PREFIX: &str = "V-"; + +/// The prefix every route id carries. See [`VERDICT_PREFIX`]. +pub const ROUTE_PREFIX: &str = "R-"; + +/// The longest a gloss may be. +/// +/// The gloss is the **hot path's whole payload** (CLOUD-1053): one line, beside +/// a token and a pointer. A bound here is what keeps "one line" a property of +/// the data rather than of the author's restraint — the class definition is +/// where a paragraph goes, and `batten policy explain` is what fetches it. +const GLOSS_MAX: usize = 120; + +/// What a route offers the reader. +/// +/// **A closed set, deliberately.** The whole defect is a remedy nobody can +/// check; an open `kind` string would put it straight back, because a typo'd +/// kind would be a route that resolves to nothing and refuses nothing. +#[derive( + Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Deserialize, Serialize, schemars::JsonSchema, +)] +#[serde(rename_all = "snake_case")] +pub enum RouteKind { + /// Run something. `target` is the command. + Command, + /// Read something in this tree. `target` is a repo-relative path. + Document, + /// Take it to the tracker. `target` is the issue key. + Issue, + /// Ask for the refusal to be lifted. `target` is unread; `precondition` is + /// what the asker must be able to state, and is required (CLOUD-1051 reads + /// it to generate the questions an admission answers). + Override, +} + +impl RouteKind { + /// The token a rendered route carries. + #[must_use] + pub fn as_str(self) -> &'static str { + match self { + RouteKind::Command => "command", + RouteKind::Document => "document", + RouteKind::Issue => "issue", + RouteKind::Override => "override", + } + } +} + +/// One way out of a refusal. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, schemars::JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct Route { + /// The id a rendered refusal names, e.g. `R-DEFINE-THE-TASK`. + /// + /// Stable and referenceable: an agent told `R-DEFINE-THE-TASK` twice has + /// been told the same thing twice, which a paraphrase cannot establish. + pub id: String, + /// Which kind of way out this is. + pub kind: RouteKind, + /// What it points at, read according to [`Route::kind`]. + #[serde(default, skip_serializing_if = "String::is_empty")] + pub target: String, + /// What the asker must be able to state. [`RouteKind::Override`] only, and + /// required there. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub precondition: Option, +} + +/// One declared refusal class. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, schemars::JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct DeclaredVerdict { + /// The token, e.g. `V-TASK-UNDEFINED`. + pub id: String, + /// One line, the hot path's whole payload. + pub gloss: String, + /// What the class means, at length. `batten policy explain`'s payload, and + /// the **deliberate exception** to pointer-only output (house style §6): + /// `explain` is local documentation rather than a finding, and carrying the + /// text the hot path no longer does is its entire purpose. + pub class: String, + /// The ways out, in the order a reader should consider them. + #[serde(default, rename = "route", skip_serializing_if = "Vec::is_empty")] + pub routes: Vec, + /// The token that replaced this one, for a class that has been retired. + /// + /// A **tombstone**: the entry stays so a historical token is still + /// explainable, which git history cannot do at runtime. A tombstone is + /// exempt from the emitted-somewhere half of registry equality, because the + /// whole point is that nothing emits it any more. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub successor: Option, +} + +impl DeclaredVerdict { + /// Whether this entry is a tombstone. + #[must_use] + pub fn retired(&self) -> bool { + self.successor.is_some() + } +} + +/// One pointer a refusal carries. +/// +/// **Tagged, ordered, and never a payload.** Each variant is a shape a reader +/// can act on: a file, a line in a file, a count, or a derived artifact. Prose +/// is not among them, which is what makes rule 4 structural here rather than a +/// habit each emitter has to keep. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize)] +#[serde(untagged)] +pub enum Subject { + /// A path in this tree. + Path { + /// The repo-relative path. + path: String, + }, + /// A path and a line in it. + Line { + /// The repo-relative path. + path: String, + /// The 1-based line. + line: u64, + }, + /// How many. The shape a finding over a whole set takes, where naming one + /// member would misdirect. + Count { + /// The number. + count: u64, + }, + /// A named thing that is not a path — a task, a rule id, a token, a tool. + Artifact { + /// The name. + artifact: String, + }, +} + +impl Subject { + /// The pointer as one line of output. + #[must_use] + pub fn render(&self) -> String { + match self { + Subject::Path { path } => path.clone(), + Subject::Line { path, line } => format!("{path}:{line}"), + Subject::Count { count } => count.to_string(), + Subject::Artifact { artifact } => artifact.clone(), + } + } + + /// Read one subject off a policy module's `subjects` array. + /// + /// `None` is could-not-look, never an empty subject: a member whose shape + /// this cannot read is a module speaking a dialect the decoder does not + /// have, and inventing a pointer for it would put the engine's words in the + /// module's mouth. + /// + /// The arms are tried **most specific first** — `{path, line}` before + /// `{path}` — because `untagged` decoding is order-sensitive and a + /// line-bearing subject read as a bare path silently loses the line. + #[must_use] + pub fn from_json(value: &serde_json::Value) -> Option { + let object = value.as_object()?; + let path = object.get("path").and_then(serde_json::Value::as_str); + let line = object.get("line").and_then(serde_json::Value::as_u64); + match (path, line) { + (Some(path), Some(line)) => { + return Some(Subject::Line { + path: path.to_owned(), + line, + }); + } + (Some(path), None) => { + return Some(Subject::Path { + path: path.to_owned(), + }); + } + _ => {} + } + if let Some(count) = object.get("count").and_then(serde_json::Value::as_u64) { + return Some(Subject::Count { count }); + } + if let Some(artifact) = object.get("artifact").and_then(serde_json::Value::as_str) { + return Some(Subject::Artifact { + artifact: artifact.to_owned(), + }); + } + None + } +} + +/// Render an ordered subject list as the pointer half of one line. +#[must_use] +pub fn render_subjects(subjects: &[Subject]) -> String { + subjects + .iter() + .map(Subject::render) + .collect::>() + .join(" ") +} + +/// What a refusal says on the hot path: the token, its gloss, its pointers +/// (CLOUD-1053). +/// +/// ```text +/// V-TASK-UNDEFINED (a command row names a task this tree does not define) batten.toml:1604 +/// ``` +/// +/// **The subject stays inline** rather than being dereferenced through +/// `explain`. Making a reader run a second command to learn WHICH file would +/// make the common case slower, which is the opposite of the point; what moves +/// behind `explain` is the class definition, which the common case does not +/// need. +/// +/// A token the registry does not carry renders as itself with the gap stated. +/// `policy::load` refuses that at load, so it is reachable only on the mediated +/// path, where the AST check is skipped for CLOUD-689's budget — and there +/// saying so beats either inventing a gloss or dropping the refusal. +#[must_use] +pub fn render_line(registry: &[DeclaredVerdict], token: &str, subjects: &[Subject]) -> String { + let gloss = resolve(registry, token).map_or( + "no `[[verdict]]` row declares this class, so it carries no gloss", + |(entry, _)| entry.gloss.as_str(), + ); + let pointers = render_subjects(subjects); + if pointers.is_empty() { + format!("{token} ({gloss})") + } else { + format!("{token} ({gloss}) {pointers}") + } +} + +/// The first `command` route a class declares, which is what a refusal offers as +/// its fix. +/// +/// **This is what makes CLOUD-122's contract structural.** A refusal used to +/// carry whatever remedy its emitter remembered to write; now the remedy comes +/// off the declared class, `validate` refuses a class with no route and refuses +/// one whose only route is an override, so "every refusal names a way out" is a +/// property of the data rather than of each emitter's care. +/// +/// `None` when the class declares no command route — a `document` or `issue` +/// route is still a way out, and rendering it in the `Fix:` slot as if it were +/// something to run would be worse than the explicit "none declared" the empty +/// case already produces. +#[must_use] +pub fn first_command_route<'a>(registry: &'a [DeclaredVerdict], token: &str) -> Option<&'a str> { + let (entry, _) = resolve(registry, token)?; + entry + .routes + .iter() + .find(|route| route.kind == RouteKind::Command) + .map(|route| route.target.as_str()) +} + +/// Refuse a malformed registry, at load. +/// +/// Every clause here decides a property of the **table**, never of a predicate +/// that might use it — the same division [`crate::pattern::validate`] draws, and +/// the reason both live at parse rather than at adjudication (house style §8). +/// +/// The clauses, and what each exists to stop: +/// +/// * a token is non-empty, `V-`-prefixed and unique — a duplicate makes +/// `explain` ambiguous and a refusal unattributable; +/// * a gloss is one line and within [`GLOSS_MAX`] — the hot path's payload +/// cannot be a paragraph, which is the regression CLOUD-1053 exists to +/// prevent; +/// * a class definition is present — a token with no meaning is a worse string +/// than the prose it replaced; +/// * **at least one route**, and not an override alone. A refusal whose only way +/// out is "ask for it to be waived" is the bare no this contract forbids; +/// * an override route carries a precondition, and a non-override does not — +/// CLOUD-1051 generates its questions from that field, so an empty one is an +/// override nobody can request; +/// * route ids are `R-`-prefixed and unique within their verdict; +/// * a successor names a token this table declares, and the tombstone chain +/// terminates in a live token without cycling. +/// +/// **Route TARGET resolution is deliberately not here.** Whether `mise run x` +/// names a task this tree defines is a question about a task RUNNER, and +/// non-negotiable rule 1 keeps the core ignorant of one — the same argument +/// `policy/command-task-defined.rego` already makes for `[[rule]]` rows, which +/// is why the sibling clause for routes is a policy module rather than a line in +/// this function. +/// +/// # Errors +/// +/// A [`UsageError`] (exit `1`) naming the offending token. The declaration is +/// the config author's own text — the class `config show` exists to echo — so +/// naming it is inside rule 4. +pub fn validate(verdicts: &[DeclaredVerdict]) -> anyhow::Result<()> { + let mut seen: BTreeSet<&str> = BTreeSet::new(); + for verdict in verdicts { + validate_one(verdict)?; + if !seen.insert(verdict.id.as_str()) { + return Err(UsageError::raise(format!( + "verdict `{}` is declared twice; one class, one token — \ + `batten policy explain {}` cannot resolve to two definitions", + verdict.id, verdict.id + ))); + } + } + validate_chains(verdicts, &seen) +} + +/// The per-entry half of [`validate`]. +fn validate_one(verdict: &DeclaredVerdict) -> anyhow::Result<()> { + let id = verdict.id.as_str(); + if !id.starts_with(VERDICT_PREFIX) || id.len() <= VERDICT_PREFIX.len() { + return Err(UsageError::raise(format!( + "verdict `{id}`: a token is `{VERDICT_PREFIX}` followed by a name — \ + the prefix is what makes it readable as a token beside a rule id and a path" + ))); + } + if verdict.gloss.trim().is_empty() { + return Err(UsageError::raise(format!( + "verdict `{id}`: `gloss` is the hot path's whole payload, and an empty one \ + tells its reader nothing the token did not" + ))); + } + if verdict.gloss.contains('\n') || verdict.gloss.chars().count() > GLOSS_MAX { + return Err(UsageError::raise(format!( + "verdict `{id}`: `gloss` is ONE line of at most {GLOSS_MAX} characters — \ + the paragraph goes in `class`, which `batten policy explain` fetches" + ))); + } + if verdict.class.trim().is_empty() { + return Err(UsageError::raise(format!( + "verdict `{id}`: `class` is what `batten policy explain` answers with; \ + a token with no definition is a worse string than the prose it replaced" + ))); + } + if verdict.routes.is_empty() { + return Err(UsageError::raise(format!( + "verdict `{id}` declares no route — a refusal owes its reader a way out, \ + which is the contract `Fix` has carried since CLOUD-122" + ))); + } + if verdict + .routes + .iter() + .all(|route| route.kind == RouteKind::Override) + { + return Err(UsageError::raise(format!( + "verdict `{id}`'s only route is an override — \"ask for it to be waived\" \ + is the bare no this contract exists to refuse, so declare the route that \ + fixes the thing as well" + ))); + } + let mut route_ids: BTreeSet<&str> = BTreeSet::new(); + for route in &verdict.routes { + validate_route(id, route)?; + if !route_ids.insert(route.id.as_str()) { + return Err(UsageError::raise(format!( + "verdict `{id}` declares the route `{}` twice; a route id is what a \ + refusal names, and one naming two things names neither", + route.id + ))); + } + } + Ok(()) +} + +/// The per-route half of [`validate_one`]. +fn validate_route(verdict: &str, route: &Route) -> anyhow::Result<()> { + let id = route.id.as_str(); + if !id.starts_with(ROUTE_PREFIX) || id.len() <= ROUTE_PREFIX.len() { + return Err(UsageError::raise(format!( + "verdict `{verdict}`: route `{id}` is not `{ROUTE_PREFIX}`-prefixed" + ))); + } + match route.kind { + RouteKind::Override => { + let stated = route + .precondition + .as_deref() + .is_some_and(|text| !text.trim().is_empty()); + if !stated { + return Err(UsageError::raise(format!( + "verdict `{verdict}`: the override route `{id}` states no \ + `precondition` — an override whose condition nobody wrote down is \ + one nobody can be held to, and it is the field an admission's \ + questions are generated from" + ))); + } + } + RouteKind::Command | RouteKind::Document | RouteKind::Issue => { + if route.precondition.is_some() { + return Err(UsageError::raise(format!( + "verdict `{verdict}`: route `{id}` is a `{}` route and carries a \ + `precondition`, which only an `override` route is read for — a field \ + nothing reads is a condition nothing enforces", + route.kind.as_str() + ))); + } + if route.target.trim().is_empty() { + return Err(UsageError::raise(format!( + "verdict `{verdict}`: route `{id}` is a `{}` route with no `target`", + route.kind.as_str() + ))); + } + } + } + Ok(()) +} + +/// The tombstone half of [`validate`] (CLOUD-1053). +/// +/// A retired token names a successor; the chain has to terminate in a live +/// token and may not cycle. **This is the same predicate CLOUD-1051's admission +/// `prev` chain needs**, and it is written once here rather than twice. +fn validate_chains(verdicts: &[DeclaredVerdict], declared: &BTreeSet<&str>) -> anyhow::Result<()> { + for verdict in verdicts { + let mut walked: BTreeSet<&str> = BTreeSet::new(); + walked.insert(verdict.id.as_str()); + let mut cursor = verdict; + while let Some(next) = cursor.successor.as_deref() { + if !declared.contains(next) { + return Err(UsageError::raise(format!( + "verdict `{}` names the successor `{next}`, which this registry does \ + not declare — a tombstone that resolves to nothing leaves a \ + historical token unexplainable, which is the whole thing it exists \ + to prevent", + verdict.id + ))); + } + if !walked.insert(next) { + return Err(UsageError::raise(format!( + "verdict `{}`'s successor chain cycles at `{next}`; a chain has to \ + terminate in a live token", + verdict.id + ))); + } + let Some(found) = verdicts.iter().find(|entry| entry.id == next) else { + // Unreachable: `declared` was built from these ids. + return Ok(()); + }; + cursor = found; + } + } + Ok(()) +} + +/// Resolve a token through its tombstone chain to the live class. +/// +/// Returns the entry the reader should be shown and whether the token they +/// asked for was itself retired. `None` for a token this registry does not +/// declare, which is `explain`'s exit `1`. +#[must_use] +pub fn resolve<'a>( + verdicts: &'a [DeclaredVerdict], + token: &str, +) -> Option<(&'a DeclaredVerdict, bool)> { + let mut cursor = verdicts.iter().find(|entry| entry.id == token)?; + let retired = cursor.retired(); + let mut guard = verdicts.len(); + while let Some(next) = cursor.successor.as_deref() { + // `validate_chains` already refused a cycle, so this bound is the + // residue for a caller that reached here without validation — it must + // terminate rather than spin. + if guard == 0 { + break; + } + guard -= 1; + let Some(found) = verdicts.iter().find(|entry| entry.id == next) else { + break; + }; + cursor = found; + } + Some((cursor, retired)) +} + +/// Every declared token, live and retired, in a stable order. +#[must_use] +pub fn declared_tokens(verdicts: &[DeclaredVerdict]) -> BTreeSet<&str> { + verdicts.iter().map(|entry| entry.id.as_str()).collect() +} + +/// The tokens a module may emit: every declared token that is not a tombstone. +#[must_use] +pub fn live_tokens(verdicts: &[DeclaredVerdict]) -> BTreeSet<&str> { + verdicts + .iter() + .filter(|entry| !entry.retired()) + .map(|entry| entry.id.as_str()) + .collect() +} + +// ─── The native half of the registry ───────────────────────────────────────── + +/// A refusal class the CRATE owns, as opposed to one a consumer declares. +/// +/// # Why this is an enum and not a table of strings +/// +/// The registry's whole claim is that a token cannot be raised without being +/// declared. On the policy side that is enforced by reading the module's AST at +/// load; on the native side there is no AST to read, so the coupling has to be +/// in the type system or it is a convention. An enum gives it for free: a native +/// site names a variant, [`Native::ALL`] is asserted exhaustive by a +/// wildcard-free match, and a class that exists but is unreachable — or a site +/// raising a class nobody declared — is a compile error rather than a finding. +/// +/// The same discipline `crate::facts::Fact` carries, and for the same reason. +/// +/// # These are Batten's OWN words +/// +/// Every variant here is a class the crate states about itself: a protected path +/// was targeted, a scanner is unpinned, a read-effect verb was asked to spawn. A +/// refusal composed from a consumer's `[[rule]]` row is a different thing — the +/// remedy is the consumer's declared `reason` and lives in their authority under +/// house style §8 — and it is not represented here. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +#[non_exhaustive] +pub enum Native { + /// A mutating verb was aimed at a path the config protects. + ProtectedMutation, + /// `batten init` would overwrite the committed authority. + InitWouldOverwrite, + /// A configured hook handler denied the call. + HandlerDenied, + /// A `secrets` rule needs a scanner and the config pins none. + ScannerUnpinned, + /// The pinned scanner is not in the provision cache. + ScannerUnprovisioned, + /// A rule kind that spawns was reached through a read-effect verb. + SpawningRuleOnReadVerb, + /// The end-of-turn facts do not permit stopping. + StopConditionUnmet, +} + +impl Native { + /// Every native class, in declaration order. + /// + /// Held exhaustive by `every_native_class_is_listed`, which matches over a + /// variant with no wildcard — so a new class that is not added here fails to + /// compile rather than going undeclared. + pub const ALL: &'static [Native] = &[ + Native::ProtectedMutation, + Native::InitWouldOverwrite, + Native::HandlerDenied, + Native::ScannerUnpinned, + Native::ScannerUnprovisioned, + Native::SpawningRuleOnReadVerb, + Native::StopConditionUnmet, + ]; + + /// The token this class is declared and rendered under. + #[must_use] + pub fn id(self) -> &'static str { + match self { + Native::ProtectedMutation => "V-PROTECTED-MUTATION", + Native::InitWouldOverwrite => "V-AUTHORITY-EXISTS", + Native::HandlerDenied => "V-HANDLER-DENIED", + Native::ScannerUnpinned => "V-SCANNER-UNPINNED", + Native::ScannerUnprovisioned => "V-SCANNER-UNPROVISIONED", + Native::SpawningRuleOnReadVerb => "V-SPAWN-ON-READ-VERB", + Native::StopConditionUnmet => "V-STOP-CONDITION-UNMET", + } + } +} + +/// Every token the crate's own refusal sites may raise. +/// +/// Read by `policy::load` for the half of registry equality that asks whether a +/// declared token is emitted by ANYTHING: a `[[verdict]]` row covering a native +/// class is not dead vocabulary just because no module raises it. +#[must_use] +pub fn native_tokens() -> BTreeSet<&'static str> { + Native::ALL.iter().map(|native| native.id()).collect() +} + +/// One vendored class, in the shape a `const` table can hold. +/// +/// A mirror of [`DeclaredVerdict`] over `&'static str`, because that type owns +/// `String`s and cannot be a `const`. [`vendored`] converts. The alternative — +/// building the table at runtime from literals — puts the same data one +/// indirection further from the reader for nothing. +struct VendoredVerdict { + id: &'static str, + gloss: &'static str, + class: &'static str, + routes: &'static [VendoredRoute], +} + +/// One vendored route. See [`VendoredVerdict`]. +struct VendoredRoute { + id: &'static str, + kind: RouteKind, + target: &'static str, + precondition: Option<&'static str>, +} + +/// A `command`-kind route, which is most of them. +const fn run(id: &'static str, target: &'static str) -> VendoredRoute { + VendoredRoute { + id, + kind: RouteKind::Command, + target, + precondition: None, + } +} + +/// A `document`-kind route. +const fn read(id: &'static str, target: &'static str) -> VendoredRoute { + VendoredRoute { + id, + kind: RouteKind::Document, + target, + precondition: None, + } +} + +/// Every class the BINARY ships: the native ones and the vendored presets'. +/// +/// # Why the presets' vocabulary ships with the presets +/// +/// A preset reaches a consumer who never wrote a `[[verdict]]` row, so holding +/// it to the consumer's registry would make an enabled preset unloadable with no +/// fix available — the wrongly-refusing gate AGENTS.md calls a defect, and the +/// identical argument `policy::check_no_inline_regex` already records for a +/// preset's patterns. The supply-chain claim is unchanged: `include_str!` at +/// build time, no network, no registry, the same checksum as the rest of the +/// binary. +/// +/// Non-negotiable rule 1 reaches this table exactly as it reaches the presets +/// themselves: a class here may describe a PRACTICE and may never name a path, a +/// task, a tracker key or an entity. `presets_are_inside_the_rule_one_glob` +/// covers this file, because it is under `crates/**`. +const VENDORED: &[VendoredVerdict] = &[ + // ── native ────────────────────────────────────────────────────────────── + VendoredVerdict { + id: "V-PROTECTED-MUTATION", + gloss: "a mutating verb was aimed at a path the config protects", + class: "The path is in the `protected` set, so a write to it is refused before it \ +happens rather than reported after. The set is the consumer's own declaration; what is \ +protected is a question about their repository, and the engine only enforces it. A \ +narrower remedy may be declared per path class through `[[redirect]]`, which is what the \ +refusal names when one exists.", + routes: &[ + read("R-USE-THE-OWNING-SURFACE", "batten.toml"), + run("R-RESTORE-IT", "git restore"), + ], + }, + VendoredVerdict { + id: "V-AUTHORITY-EXISTS", + gloss: "`init` will not overwrite the committed authority", + class: "House style §8 gives a repository ONE committed authority, and `init` \ +writes it. Overwriting an existing one would replace a reviewed policy with a default \ +set, silently, in a verb whose whole purpose is that there was nothing there before. \ +Edit the file that exists, or move it aside deliberately.", + routes: &[read("R-EDIT-THE-AUTHORITY", "batten.toml")], + }, + VendoredVerdict { + id: "V-HANDLER-DENIED", + gloss: "a configured hook handler denied the call", + class: "The refusal is the handler's, not the engine's: a `[hook.handler]` row \ +names a program, the program answered deny, and this carries that answer through. The \ +handler's own reason is free text the consumer configured, so no remedy is invented here \ +— the handler is where a remedy would have to be declared.", + routes: &[read("R-READ-THE-HANDLER-ROW", "batten.toml")], + }, + VendoredVerdict { + id: "V-SCANNER-UNPINNED", + gloss: "a `secrets` rule needs its scanner pinned and none is declared", + class: "A `secrets` rule delegates to an external scanner, and which scanner \ +decides what the rule means. An unpinned one would resolve to whatever is ambient, so a \ +green run would say nothing about the tree — the same defect a bare `cargo` has against \ +a pinned toolchain. Declare the scanner as a `[[provision]]` entry.", + routes: &[ + run("R-PROVISION-THE-SCANNER", "batten provision"), + read("R-DECLARE-THE-ENTRY", "batten.toml"), + ], + }, + VendoredVerdict { + id: "V-SCANNER-UNPROVISIONED", + gloss: "the pinned scanner is not in the provision cache, so nothing was scanned", + class: "The scanner is declared and absent. This is could-not-look rather than a \ +clean tree, and it is reported as a refusal precisely so the two are not spelled the \ +same way: a secrets rule that scanned no file and reported nothing is the vacuous pass \ +this engine argues against everywhere.", + routes: &[run("R-PROVISION-THE-SCANNER", "batten provision")], + }, + VendoredVerdict { + id: "V-SPAWN-ON-READ-VERB", + gloss: "this rule kind runs a configured command, which a read-effect verb will not do", + class: "The effect model (house style §5) puts every verb in one class and holds \ +it there. A rule kind that spawns is `Effect`, and `check` is `Read`, so reaching one \ +through the other would make the read-only allowlist a claim nobody could rely on. The \ +rule is not wrong; the verb is.", + routes: &[run("R-USE-THE-SPAWNING-VERB", "batten enforce")], + }, + VendoredVerdict { + id: "V-STOP-CONDITION-UNMET", + gloss: "the end-of-turn facts do not permit stopping", + class: "A stop is a completion signal, and this engine's whole subject is keeping \ +that signal aligned with landed-and-verified work. The facts the turn ended on say it is \ +not, and the refusal names which. Each has its own route; the shared one is to finish \ +the thing rather than to re-declare that it is finished.", + routes: &[ + run("R-LAND-IT", "mise run land"), + read("R-READ-THE-FACTS", "batten.toml"), + ], + }, + // ── vendored presets ──────────────────────────────────────────────────── + VendoredVerdict { + id: "V-EMPTY-COMMIT", + gloss: "an empty commit records that somebody wanted a new SHA", + class: "A commit records a change. The reachable use of an empty one is kicking a \ +pipeline, which spends a run to re-ask a question the previous run already answered and \ +leaves a commit in the history no reader can act on. If the goal is a fresh run, re-run \ +the pipeline.", + routes: &[run("R-RERUN-THE-PIPELINE", "re-run the pipeline")], + }, + VendoredVerdict { + id: "V-FORCE-PUSH-AT-TRUNK", + gloss: "a force push rewrites a shared branch under whoever already fetched it", + class: "Rewriting a published branch invalidates every checkout of it that \ +already exists, and the holder finds out by having their next pull fail in a way that \ +looks like their own mistake. `--force-with-lease` refuses when the remote moved, which \ +is the same operation with the one check that makes it safe.", + routes: &[run("R-LEASE-THE-FORCE", "git push --force-with-lease")], + }, + VendoredVerdict { + id: "V-SHEBANG-UNNAMED-LANGUAGE", + gloss: "the file runs a shell and its name does not say so", + class: "Every instrument that selects by extension — a formatter, a linter, a \ +CI path filter — covers this file silently and exits 0. A green run over it therefore \ +means nothing was looked at rather than nothing was found, which is worse than a red \ +one. Name the language in the filename, or declare the file's coverage another way.", + routes: &[run("R-NAME-THE-LANGUAGE", "git mv")], + }, + VendoredVerdict { + id: "V-SIBLING-UNRESOLVED", + gloss: "a run-time sibling path is computed and the tree carries no such file", + class: "The shape resolves a path beside the running program and then guards it \ +with a test that exits 0, so the reference does not fail — it goes silent, and the \ +behaviour it was reaching for simply never happens. A path that must exist should be \ +asserted rather than tested.", + routes: &[read("R-ADD-THE-SIBLING", "the computed path")], + }, +]; + +/// Every class the binary ships, as the registry carries them. +/// +/// Unioned with the consumer's `[[verdict]]` rows by `policy::load`. A collision +/// between the two is refused there rather than here: which side is at fault is +/// a question about the pair, and this function knows only one of them. +#[must_use] +pub fn vendored() -> Vec { + VENDORED + .iter() + .map(|entry| DeclaredVerdict { + id: entry.id.to_owned(), + gloss: entry.gloss.to_owned(), + class: entry.class.to_owned(), + routes: entry + .routes + .iter() + .map(|route| Route { + id: route.id.to_owned(), + kind: route.kind, + target: route.target.to_owned(), + precondition: route.precondition.map(str::to_owned), + }) + .collect(), + successor: None, + }) + .collect() +} + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::expect_used)] +mod tests { + use super::*; + + fn route(id: &str) -> Route { + Route { + id: id.to_owned(), + kind: RouteKind::Command, + target: "mise run something".to_owned(), + precondition: None, + } + } + + fn entry(id: &str) -> DeclaredVerdict { + DeclaredVerdict { + id: id.to_owned(), + gloss: "a short line".to_owned(), + class: "the long definition".to_owned(), + routes: vec![route("R-DO-THE-THING")], + successor: None, + } + } + + #[test] + fn a_conforming_entry_validates() { + validate(&[entry("V-ONE")]).expect("a conforming registry loads"); + } + + #[test] + fn a_token_without_the_prefix_is_refused() { + let mut bad = entry("V-ONE"); + bad.id = "TASK-UNDEFINED".to_owned(); + assert!(validate(&[bad]).is_err()); + } + + #[test] + fn a_duplicate_token_is_refused() { + assert!(validate(&[entry("V-ONE"), entry("V-ONE")]).is_err()); + } + + #[test] + fn a_paragraph_gloss_is_refused() { + let mut bad = entry("V-ONE"); + bad.gloss = "x".repeat(GLOSS_MAX + 1); + assert!(validate(&[bad]).is_err()); + let mut wrapped = entry("V-ONE"); + wrapped.gloss = "one\ntwo".to_owned(); + assert!(validate(&[wrapped]).is_err()); + } + + #[test] + fn a_verdict_with_no_route_is_refused() { + let mut bad = entry("V-ONE"); + bad.routes.clear(); + assert!(validate(&[bad]).is_err()); + } + + #[test] + fn an_override_alone_is_refused() { + let mut bad = entry("V-ONE"); + bad.routes = vec![Route { + id: "R-ASK".to_owned(), + kind: RouteKind::Override, + target: String::new(), + precondition: Some("you can state why".to_owned()), + }]; + assert!(validate(&[bad]).is_err()); + } + + #[test] + fn an_override_with_no_precondition_is_refused() { + let mut bad = entry("V-ONE"); + bad.routes.push(Route { + id: "R-ASK".to_owned(), + kind: RouteKind::Override, + target: String::new(), + precondition: None, + }); + assert!(validate(&[bad]).is_err()); + } + + #[test] + fn a_command_route_carrying_a_precondition_is_refused() { + let mut bad = entry("V-ONE"); + bad.routes[0].precondition = Some("something".to_owned()); + assert!(validate(&[bad]).is_err()); + } + + #[test] + fn a_successor_naming_nothing_is_refused() { + let mut bad = entry("V-OLD"); + bad.successor = Some("V-GONE".to_owned()); + assert!(validate(&[bad]).is_err()); + } + + #[test] + fn a_cycling_chain_is_refused() { + let mut first = entry("V-A"); + first.successor = Some("V-B".to_owned()); + let mut second = entry("V-B"); + second.successor = Some("V-A".to_owned()); + assert!(validate(&[first, second]).is_err()); + } + + #[test] + fn a_tombstone_resolves_to_its_live_successor() { + let mut old = entry("V-OLD"); + old.successor = Some("V-NEW".to_owned()); + let new = entry("V-NEW"); + let table = vec![old, new]; + validate(&table).expect("a terminating chain loads"); + let (resolved, retired) = resolve(&table, "V-OLD").expect("the token resolves"); + assert_eq!(resolved.id, "V-NEW"); + assert!(retired, "the token the reader asked for was retired"); + assert_eq!(live_tokens(&table), BTreeSet::from(["V-NEW"])); + } + + #[test] + fn a_line_bearing_subject_is_not_read_as_a_bare_path() { + // The order-sensitivity `from_json` documents: read the other way round + // this silently drops the line, and a finding that loses its line is a + // pointer a reader cannot follow. + let value = serde_json::json!({"path": "a.rs", "line": 7}); + assert_eq!( + Subject::from_json(&value), + Some(Subject::Line { + path: "a.rs".to_owned(), + line: 7 + }) + ); + } + + #[test] + fn a_shape_the_decoder_does_not_know_is_could_not_look() { + let value = serde_json::json!({"reason": "because"}); + assert_eq!(Subject::from_json(&value), None); + } + + /// The exhaustiveness assertion `Native::ALL` rests on. + /// + /// The match below carries **no wildcard**, so a new variant fails to + /// compile here; the loop then asserts the listed set is the same one. Two + /// halves, because either alone is satisfiable by the defect: a wildcard + /// match would compile past a new class, and a list with no match beside it + /// would go stale silently. + #[test] + fn every_native_class_is_listed() { + for native in Native::ALL { + let named = match native { + Native::ProtectedMutation + | Native::InitWouldOverwrite + | Native::HandlerDenied + | Native::ScannerUnpinned + | Native::ScannerUnprovisioned + | Native::SpawningRuleOnReadVerb + | Native::StopConditionUnmet => native.id(), + }; + assert!( + named.starts_with(VERDICT_PREFIX), + "{named} is not a verdict token" + ); + } + assert_eq!( + native_tokens().len(), + Native::ALL.len(), + "two native classes share a token, so a refusal under it is unattributable" + ); + } + + /// The vendored table is held to its own validator. + /// + /// Non-negotiable rule 2: the refusals `validate` states are worth nothing + /// over the one table nobody applies them to, and this is the table that + /// ships to every consumer. + #[test] + fn the_vendored_table_validates() { + let table = vendored(); + validate(&table).expect("the table this binary ships is well formed"); + for native in Native::ALL { + assert!( + table.iter().any(|entry| entry.id == native.id()), + "{} is a native class with no declaration, so a refusal under it \ + could carry no gloss and no route", + native.id() + ); + } + } + + #[test] + fn subjects_render_in_the_order_they_were_given() { + let rendered = render_subjects(&[ + Subject::Path { + path: "a.rs".to_owned(), + }, + Subject::Line { + path: "b.rs".to_owned(), + line: 3, + }, + Subject::Count { count: 4 }, + Subject::Artifact { + artifact: "ntia-check".to_owned(), + }, + ]); + assert_eq!(rendered, "a.rs b.rs:3 4 ntia-check"); + } +} diff --git a/crates/batten/tests/cli.rs b/crates/batten/tests/cli.rs index 5323cb20a..4627639c8 100644 --- a/crates/batten/tests/cli.rs +++ b/crates/batten/tests/cli.rs @@ -1430,12 +1430,21 @@ effect = "destructive" "#; #[test] -fn a_deny_with_no_safe_remedy_declares_it_rather_than_omitting_the_clause() { - // The half of the contract that is easy to get wrong: where nothing is - // declared, the refusal says so. An omitted fix clause is indistinguishable - // from a producer that forgot one, which is exactly the bare "no" the - // contract exists to prevent — so the absence is a value, not a silence. - // (CLOUD-280 is what gives this case a per-path-class answer to give.) +fn a_deny_with_no_consumer_remedy_falls_back_to_the_declared_class() { + // WHAT CLOUD-1050 CHANGED, over the compiled binary — the tier that used to + // be a generic apology. + // + // The contract was: where nothing is declared, the refusal says so, because + // an omitted fix clause is indistinguishable from a producer that forgot one. + // That is still the contract; what changed is that a NATIVE refusal now names + // a declared class, so the floor under the consumer's two tiers is that + // class's own `command` route rather than a sentence naming no verb. + // `verdict::validate` refuses a class with no route and refuses one whose only + // route is an override, so this floor cannot be empty by construction. + // + // The explicit-absence half is still asserted, on the path where it is still + // reachable: `refusal::tests::a_refusal_from_consumer_prose_still_declares_an_ + // absent_fix_rather_than_omitting_it`. let dir = repo_with_config("refusal-no-remedy", NO_REMEDY_CONFIG); let output = run_hook_in( &dir, @@ -1450,8 +1459,12 @@ fn a_deny_with_no_safe_remedy_declares_it_rather_than_omitting_the_clause() { "names the gate, got: {stderr}" ); assert!( - stderr.contains("Fix: none declared"), - "the absence is stated, not omitted, got: {stderr}" + stderr.contains("V-PROTECTED-MUTATION ("), + "the hot path leads with the token and its gloss, got: {stderr}" + ); + assert!( + stderr.contains("Fix: git restore"), + "the class's own route is the floor, got: {stderr}" ); } @@ -4397,6 +4410,12 @@ const CENSUS_POSITIONALS: &[(&str, &str)] = &[ ("lint brief", "census-brief.md"), // Substituted at argv time — see `CENSUS_SEEDED_HANDLE`. ("capture show", CENSUS_SEEDED_HANDLE), + // A class this BINARY vendors (CLOUD-1050), so the census resolves it in a + // fixture that declares no `[[verdict]]` row of its own — which is the same + // reason the vendored half exists at all. A consumer token here would make + // this census depend on the fixture's authority carrying a row, and the + // fixture's authority is `batten init`'s output. + ("policy explain", "V-PROTECTED-MUTATION"), ]; /// A brief satisfying every row of `brief::SCHEMA`, for the census fixture. diff --git a/crates/batten/tests/common/mod.rs b/crates/batten/tests/common/mod.rs index 2090cef23..b095cac8f 100644 --- a/crates/batten/tests/common/mod.rs +++ b/crates/batten/tests/common/mod.rs @@ -418,3 +418,111 @@ impl Fixture { self.dir } } + +/// A registry declaring `ids` as live classes, for a fixture whose module raises +/// tokens this binary does not vendor (CLOUD-1050). +/// +/// Every entry is well formed by construction — a gloss, a class definition and +/// one `document` route — because `verdict::validate` runs at parse and a +/// fixture that had to hand-build a valid row would be testing the validator +/// rather than the thing it came for. The route is a `document` one pointing at +/// the committed authority: `command` would draw +/// `policy/verdict-routes-resolve.rego` into fixtures that are not about routes. +/// +/// Returned by value so the caller can borrow it into a +/// [`batten::policy::Vocabulary`], which is a borrowing type on purpose. +#[must_use] +pub(crate) fn verdicts(ids: &[&str]) -> Vec { + ids.iter() + .map(|id| batten::verdict::DeclaredVerdict { + id: (*id).to_owned(), + gloss: format!("the fixture class {id}"), + class: format!("What {id} means, at the length `batten policy explain` answers with."), + routes: vec![batten::verdict::Route { + id: "R-READ-THE-AUTHORITY".to_owned(), + kind: batten::verdict::RouteKind::Document, + target: "batten.toml".to_owned(), + precondition: None, + }], + successor: None, + }) + .collect() +} + +/// Every verdict token the `.rego` modules under `root` name, as a registry. +/// +/// **Derived from the fixtures rather than listed beside them**, because +/// registry equality runs in BOTH directions: a table naming a token the +/// modules under test do not raise is dead vocabulary and the load refuses it, +/// which is the check doing its job. A shared hand-written list therefore fails +/// every fixture except the one it was written for — measured, eleven of twelve. +/// +/// Non-recursive and text-scanned rather than parsed: a fixture module is a +/// literal in a test file, the tokens are literals in it, and a Rego parser here +/// would be a second one to keep in step with the engine's. +#[must_use] +pub(crate) fn verdicts_in(root: &Path) -> Vec { + let mut found: std::collections::BTreeSet = std::collections::BTreeSet::new(); + let mut roots = vec![root.to_path_buf()]; + while let Some(dir) = roots.pop() { + let Ok(entries) = std::fs::read_dir(&dir) else { + continue; + }; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + roots.push(path); + continue; + } + if path.extension().is_none_or(|ext| ext != "rego") { + continue; + } + let Ok(text) = std::fs::read_to_string(&path) else { + continue; + }; + found.extend(tokens_in(&text)); + } + } + // A token this BINARY vendors is already in the registry, so declaring it + // again is the collision `registry_for` refuses — correctly, because a class + // with two definitions renders one refusal under words its emitter never + // wrote. A fixture module raising a vendored class is a legitimate thing to + // write, so the filter belongs here rather than in the fixtures. + let vendored: std::collections::BTreeSet = batten::verdict::vendored() + .into_iter() + .map(|entry| entry.id) + .collect(); + let ids: Vec<&str> = found + .iter() + .filter(|id| !vendored.contains(*id)) + .map(String::as_str) + .collect(); + verdicts(&ids) +} + +/// Every token a module RAISES, read off the two spellings that raise one. +/// +/// **Bound to the raising position, not to the prefix.** A bare `V-…` scan also +/// picks up the tokens a module's own `test_` rules construct as fixture input — +/// `policy/verdict-routes-resolve.rego` carries `V-X` in six of them — and +/// declaring one of those is dead vocabulary the load then refuses. Reading the +/// two positions that actually raise a class is what makes this a projection of +/// what the module emits rather than of what it mentions. +fn tokens_in(text: &str) -> Vec { + const RAISES: &[&str] = &["\"verdict\": \"", "deny contains \""]; + let mut found = Vec::new(); + for line in text.lines() { + for opener in RAISES { + let Some(rest) = line.split_once(opener).map(|(_, rest)| rest) else { + continue; + }; + let Some((token, _)) = rest.split_once('"') else { + continue; + }; + if token.starts_with("V-") && token.len() > 2 { + found.push(token.to_owned()); + } + } + } + found +} diff --git a/crates/batten/tests/document_read_count.rs b/crates/batten/tests/document_read_count.rs index 1e71fe127..35337d96a 100644 --- a/crates/batten/tests/document_read_count.rs +++ b/crates/batten/tests/document_read_count.rs @@ -17,6 +17,8 @@ // Panicking on setup failure is the idiomatic way for a test to fail loudly. #![allow(clippy::unwrap_used, clippy::expect_used)] +mod common; + use std::fs; use std::path::{Path, PathBuf}; @@ -30,7 +32,7 @@ import rego.v1 rules contains "no-stray-key" -violation contains {"rule": "no-stray-key", "msg": "a stray key"} if { +violation contains {"rule": "no-stray-key", "verdict": "V-STRAY-KEY"} if { input.tree.documents["config.toml"].stray } "#; @@ -39,6 +41,25 @@ violation contains {"rule": "no-stray-key", "msg": "a stray key"} if { /// source ("two rows naming one source is dead config"), so the shared-read /// property has to be shown across DISTINCT bundles — which is also the shape /// the retirement produces, one migrated gate per bundle. +/// The vocabulary the modules under `root` need, derived from the modules. +/// +/// **Derived rather than listed, because registry equality runs in both +/// directions.** A table naming a token the modules under test do not raise is +/// dead vocabulary and `load` refuses it — correctly. Reading the tokens off the +/// modules the fixture just wrote declares exactly what it raises. +/// +/// Leaked, and stated: [`batten::policy::Vocabulary`] borrows, and the +/// alternative is naming a `Vec` at every load site. A test binary is a +/// short-lived process and the table is tens of entries. +fn fixtures(root: &Path) -> batten::policy::Vocabulary<'static> { + let table: &'static [batten::verdict::DeclaredVerdict] = + Box::leak(common::verdicts_in(root).into_boxed_slice()); + batten::policy::Vocabulary { + patterns: &[], + verdicts: table, + } +} + fn row(id: &str, documents: &[&str]) -> Rule { serde_json::from_value(serde_json::json!({ "id": id, @@ -95,7 +116,7 @@ fn rows_declaring_one_path_read_it_once() { row("third", &["config.toml"]), ], &[], - &[], + fixtures(&root), &root, ) .expect("the read surface runs the rows"); @@ -118,7 +139,12 @@ fn rows_declaring_one_path_read_it_once() { let before = rules::documents_acquired(); fs::write(root.join("other.toml"), "stray = true\n").expect("fixture"); write_bundles(&root, &["fourth"]); - let _ = rules::run_static(&[row("fourth", &["other.toml"])], &[], &[], &root); + let _ = rules::run_static( + &[row("fourth", &["other.toml"])], + &[], + fixtures(&root), + &root, + ); assert!( rules::documents_acquired() > before, "the counter moves for a path not already cached, so the delta above \ @@ -149,7 +175,8 @@ fn a_glob_source_resolves_against_the_walk_rather_than_being_read_literally() { })) .expect("a row declaring a selector"); - let scan = rules::run_static(&[globbed], &[], &[], &root).expect("the selector resolves"); + let scan = + rules::run_static(&[globbed], &[], fixtures(&root), &root).expect("the selector resolves"); assert_eq!( scan.findings.len(), 1, diff --git a/crates/batten/tests/fixtures/repos/policy-mediated-call/batten.toml.in b/crates/batten/tests/fixtures/repos/policy-mediated-call/batten.toml.in index 17f584900..577a5086e 100644 --- a/crates/batten/tests/fixtures/repos/policy-mediated-call/batten.toml.in +++ b/crates/batten/tests/fixtures/repos/policy-mediated-call/batten.toml.in @@ -23,3 +23,21 @@ scope = "mediated_call" module = "gate.rego" severity = "deny" reason = "the module decides; this row only registers it" + +# The class the module raises (CLOUD-1050). A token nothing declares is refused +# at load, so this row is what makes the registration above complete: the module +# names a class, and the authority says what that class means and what to do +# about it. +[[verdict]] +id = "V-VERDICT-DISCARDED" +gloss = "a detached mediated call discards its own verdict" +class = """ +Backgrounding a verdict-bearing command with `&` loses its exit status: the \ +shell reports the JOB's launch, not the command's answer, so a failure reads as \ +a success. Redirect to a file and read the log in a separate step. +""" + +[[verdict.route]] +id = "R-REDIRECT-TO-A-FILE" +kind = "document" +target = "batten.toml" diff --git a/crates/batten/tests/fixtures/repos/policy-mediated-call/gate.rego.in b/crates/batten/tests/fixtures/repos/policy-mediated-call/gate.rego.in index 7340b2b3c..197d818f0 100644 --- a/crates/batten/tests/fixtures/repos/policy-mediated-call/gate.rego.in +++ b/crates/batten/tests/fixtures/repos/policy-mediated-call/gate.rego.in @@ -4,6 +4,10 @@ import rego.v1 # Deny-only, and there is no spelling here for an allow: the gate reads this set # and nothing else, so a module can raise a gate and never lower one. -deny contains "a detached mediated call discards its own verdict" if { +# +# The member is a VERDICT TOKEN since CLOUD-1050, not prose — the bare-string +# channel is held to the same registry the attributed one is, so this class is +# declared in the authority beside the row that registers this module. +deny contains "V-VERDICT-DISCARDED" if { endswith(input.call.command, "&") } diff --git a/crates/batten/tests/git_facts.rs b/crates/batten/tests/git_facts.rs index 902927942..1b8503912 100644 --- a/crates/batten/tests/git_facts.rs +++ b/crates/batten/tests/git_facts.rs @@ -57,7 +57,17 @@ fn config(declares: &str) -> String { module = \"policy/probe.rego\"\n\ severity = \"warn\"\n\ no_fix_reason = \"this row exists to report what the engine emitted\"\n\ - {declares}" + {declares}\ + \n\ + [[verdict]]\n\ + id = \"V-THE-PREDICATE-HELD\"\n\ + gloss = \"the probe predicate held\"\n\ + class = \"What this fixture's probe asserts, at the length explain answers with.\"\n\ + \n\ + [[verdict.route]]\n\ + id = \"R-READ-THE-PROBE\"\n\ + kind = \"document\"\n\ + target = \"policy/probe.rego\"\n" ) } @@ -70,7 +80,7 @@ fn module(body: &str) -> String { \n\ violation contains {{\n\ \t\"rule\": \"git-probe\",\n\ - \t\"msg\": \"the predicate held\",\n\ + \t\"verdict\": \"V-THE-PREDICATE-HELD\",\n\ }} if {{\n\ {body}\n\ }}\n" diff --git a/crates/batten/tests/identity_churn.rs b/crates/batten/tests/identity_churn.rs index e9a080c21..ba7fd88c7 100644 --- a/crates/batten/tests/identity_churn.rs +++ b/crates/batten/tests/identity_churn.rs @@ -76,9 +76,18 @@ impl Scan { /// written against the whole matched line, and they still pass, so the /// engine demonstrably picks the same span the test used to. fn of(root: &Path, rules: &[Rule]) -> Self { - let findings = rules::run_static(rules, &[], &[], root) - .expect("scan the tree") - .findings; + let verdicts = common::verdicts_in(root); + let findings = rules::run_static( + rules, + &[], + batten::policy::Vocabulary { + patterns: &[], + verdicts: &verdicts, + }, + root, + ) + .expect("scan the tree") + .findings; let identities = identity::count_occurrences( findings.iter().map(|finding| finding.identity.fingerprint), ); diff --git a/crates/batten/tests/pointer_only.rs b/crates/batten/tests/pointer_only.rs index 8a00ab655..380a9a472 100644 --- a/crates/batten/tests/pointer_only.rs +++ b/crates/batten/tests/pointer_only.rs @@ -216,6 +216,16 @@ fn authority(spawning: bool) -> String { lines = [\"lineread.md\"]\n\ severity = \"deny\"\n\ \n\ + [[verdict]]\n\ + id = \"V-A-CANARY-LINE\"\n\ + gloss = \"a canary line reached a declared source\"\n\ + class = \"What the corpus module asserts, at explain length.\"\n\ + \n\ + [[verdict.route]]\n\ + id = \"R-READ-THE-MODULE\"\n\ + kind = \"document\"\n\ + target = \"policy/lines.rego\"\n\ + \n\ [[waiver]]\n\ rule = \"no-canary-waived\"\n\ reason = \"{waived}\"\n\ @@ -328,7 +338,7 @@ impl Corpus { "package batten\n\ import rego.v1\n\ rules contains \"no-canary-line\"\n\ - violation contains {\"rule\": \"no-canary-line\", \"msg\": \"a canary line\"} if {\n\ + violation contains {\"rule\": \"no-canary-line\", \"verdict\": \"V-A-CANARY-LINE\"} if {\n\ \tsome line in input.tree.lines[\"lineread.md\"]\n\ \tstartswith(line, \"Q7v\")\n\ }\n", @@ -670,6 +680,29 @@ const CENSUS: &[Verb] = &[ stdin: Stdin::Nothing, disposition: Disposition::PointerOnly, }, + // THE DELIBERATE, STATED PAYLOAD EXCEPTION (CLOUD-1053). Every other verb + // here emits a pointer because its answer is ABOUT something in the tree; + // this one's answer IS the declaration. A `[[verdict]]` row's `class` is the + // config author's own text — the class `config show` exists to echo — and + // carrying the paragraph the hot path no longer does is the entire reason + // the verb exists. A documentation verb that emitted a pointer to its own + // documentation would be a redirect with extra steps. + // + // `Echoes` rather than a fourth disposition, because that is exactly what + // this is and the variant already carries the argument: the answer is the + // caller's own declaration, held to the content half, which is the half rule + // 4 is about. The token asked for is a literal the caller typed. + Verb { + path: "policy explain", + args: &["V-PROTECTED-MUTATION"], + stdin: Stdin::Nothing, + disposition: Disposition::Echoes( + "the answer IS a `[[verdict]]` row — its gloss, its class definition and its \ + routes. That is the config author's own declaration rather than content read out \ + of a subject file, and it is the payload the hot path stopped carrying when a \ + refusal became a token plus a pointer", + ), + }, // Both attribution verbs are the law rather than an exception, and this one // has less latitude than most: everything it reads is metadata someone // wanted suppressed, so a report carrying the matched text would republish diff --git a/crates/batten/tests/policy_modules.rs b/crates/batten/tests/policy_modules.rs index b45e73768..2e17326c1 100644 --- a/crates/batten/tests/policy_modules.rs +++ b/crates/batten/tests/policy_modules.rs @@ -19,6 +19,8 @@ // Panicking on setup failure is the idiomatic way for a test to fail loudly. #![allow(clippy::unwrap_used, clippy::expect_used)] +mod common; + use std::fs; use std::path::Path; @@ -50,18 +52,52 @@ fn row(id: &str, module: &str) -> Rule { /// written before the `violation` shape existed yields exactly this, and is /// attributed to the registering row as it always was. Spelled once here so the /// cases below read as assertions about behaviour rather than about a struct. -fn unattributed(msg: &str) -> Violation { +fn unattributed(verdict: &str) -> Violation { Violation { rule: None, - msg: msg.to_owned(), + verdict: verdict.to_owned(), + subjects: Vec::new(), } } /// The denial an attributed `violation` produces. -fn attributed(rule: &str, msg: &str) -> Violation { +fn attributed(rule: &str, verdict: &str) -> Violation { Violation { rule: Some(rule.to_owned()), - msg: msg.to_owned(), + verdict: verdict.to_owned(), + subjects: Vec::new(), + } +} + +/// The vocabulary the modules under `root` need, derived from the modules. +/// +/// **Derived rather than listed, because registry equality runs in both +/// directions.** A shared table naming every token this file's fixtures use is +/// dead vocabulary for all but one of them, and `load` refuses that — correctly. +/// Reading the tokens off the modules the fixture just wrote declares exactly +/// what it raises, which is what a consumer's own `batten.toml` does. +/// +/// Leaked, and stated: [`policy::Vocabulary`] borrows — that is what keeps it a +/// parameter rather than a clone on the mediated path — and the alternative is +/// naming a `Vec` at every one of this file's twenty load sites. A test binary +/// is a short-lived process and the table is tens of entries. +fn fixtures(root: &Path) -> policy::Vocabulary<'static> { + let table: &'static [batten::verdict::DeclaredVerdict] = + Box::leak(common::verdicts_in(root).into_boxed_slice()); + policy::Vocabulary { + patterns: &[], + verdicts: table, + } +} + +/// The same table, plus the pattern rows a fixture declares. +fn fixtures_with( + root: &Path, + patterns: &'static [batten::pattern::NamedPattern], +) -> policy::Vocabulary<'static> { + policy::Vocabulary { + patterns, + verdicts: fixtures(root).verdicts, } } @@ -77,7 +113,7 @@ package batten import rego.v1 -deny contains "a write, refused by the module" if { +deny contains "V-WRITE-REFUSED" if { input.call.operation == "write" } "#; @@ -107,7 +143,7 @@ package batten import rego.v1 -deny contains "the module reached the network" if { +deny contains "V-REACHED-THE-NETWORK" if { http.send({"method": "get", "url": "http://example.invalid/"}) } "#; @@ -120,7 +156,7 @@ package batten import rego.v1 -deny contains "the module validated a schema" if { +deny contains "V-VALIDATED-A-SCHEMA" if { json.verify_schema({"type": "object"}) } "#; @@ -140,11 +176,11 @@ import rego.v1 rules contains "no-stray-artifact" rules contains "no-empty-fixture" -violation contains {"rule": "no-stray-artifact", "msg": "a tracked build product"} if { +violation contains {"rule": "no-stray-artifact", "verdict": "V-STRAY-ARTIFACT"} if { input.call.operation == "write" } -violation contains {"rule": "no-empty-fixture", "msg": "a fixture with no cases"} if { +violation contains {"rule": "no-empty-fixture", "verdict": "V-EMPTY-FIXTURE"} if { input.call.operation == "write" } "#; @@ -162,7 +198,7 @@ import rego.v1 rules contains "declared-and-unused" -violation contains {"rule": "never-declared", "msg": "raised without being published"} if { +violation contains {"rule": "never-declared", "verdict": "V-NEVER-DECLARED"} if { true } "#; @@ -179,7 +215,7 @@ import rego.v1 rules contains "shared-id" -violation contains {"rule": "shared-id", "msg": "from module A"} if { +violation contains {"rule": "shared-id", "verdict": "V-FROM-MODULE-A"} if { input.call.operation == "write" } "#; @@ -191,7 +227,7 @@ import rego.v1 rules contains "shared-id" -violation contains {"rule": "shared-id", "msg": "from module B"} if { +violation contains {"rule": "shared-id", "verdict": "V-FROM-MODULE-B"} if { input.call.operation == "read" } "#; @@ -208,7 +244,7 @@ package batten import rego.v1 -deny contains "the module called a builtin that is in the closure" if { +deny contains "V-BUILTIN-IN-THE-CLOSURE" if { count([1, 2, 3]) == 3 } "#; @@ -226,7 +262,7 @@ fn a_module_denies_on_a_fact_and_is_silent_otherwise() { let bundles = policy::load( &root, &[row("policy-writes", &path)], - &[], + fixtures(&root), policy::ModuleChecks::Run, None, ) @@ -236,7 +272,7 @@ fn a_module_denies_on_a_fact_and_is_silent_otherwise() { let denied = policy::deny(&bundles[0], r#"{"call":{"operation":"write"}}"#); assert_eq!( denied, - Look::Is(vec![unattributed("a write, refused by the module")]), + Look::Is(vec![unattributed("V-WRITE-REFUSED")]), "the module decided over the fact it was handed" ); @@ -258,7 +294,7 @@ fn an_unparseable_input_is_could_not_look_and_never_an_empty_deny_set() { let bundles = policy::load( &root, &[row("policy-writes", &path)], - &[], + fixtures(&root), policy::ModuleChecks::Run, None, ) @@ -282,7 +318,7 @@ fn a_cyclic_module_is_refused_at_load() { let err = policy::load( &root, &[row("policy-cyclic", &path)], - &[], + fixtures(&root), policy::ModuleChecks::Run, None, ) @@ -311,7 +347,7 @@ fn a_module_that_cannot_be_read_is_refused_at_load() { let err = policy::load( &root, &[row("policy-absent", "nowhere.rego")], - &[], + fixtures(&root), policy::ModuleChecks::Run, None, ) @@ -329,7 +365,7 @@ fn two_rows_registering_one_module_are_refused_at_load() { let err = policy::load( &root, &[row("first", &path), row("second", &path)], - &[], + fixtures(&root), policy::ModuleChecks::Run, None, ) @@ -343,7 +379,7 @@ package batten import rego.v1 -deny contains "names a tracker key" if { +deny contains "V-NAMES-A-TRACKER-KEY" if { regex.match(`CLOUD-[0-9]+`, input.call.command) } "#; @@ -354,7 +390,7 @@ package batten import rego.v1 -deny contains "names a tracker key" if { +deny contains "V-NAMES-A-TRACKER-KEY" if { regex.match(data.batten.patterns["tracker-key"], input.call.command) } "#; @@ -365,7 +401,7 @@ package batten import rego.v1 -deny contains "names a tracker key" if { +deny contains "V-NAMES-A-TRACKER-KEY" if { regex.match(concat("", ["CLOUD", "-[0-9]+"]), input.call.command) } "#; @@ -391,7 +427,7 @@ fn a_regex_written_inline_is_refused_and_a_declared_one_decides() { let err = policy::load( &root, &[row("keys", &inline)], - &[], + fixtures(&root), policy::ModuleChecks::Run, None, ) @@ -416,7 +452,7 @@ fn a_regex_written_inline_is_refused_and_a_declared_one_decides() { let bundles = policy::load( &root, &[row("keys", &declared)], - &[tracker_key()], + fixtures_with(&root, Box::leak(Box::new([tracker_key()]))), policy::ModuleChecks::Run, None, ) @@ -427,7 +463,7 @@ fn a_regex_written_inline_is_refused_and_a_declared_one_decides() { }; assert_eq!( violations, - vec![unattributed("names a tracker key")], + vec![unattributed("V-NAMES-A-TRACKER-KEY")], "the projected table decides, rather than resolving to undefined" ); @@ -437,7 +473,7 @@ fn a_regex_written_inline_is_refused_and_a_declared_one_decides() { let err = policy::load( &root, &[row("keys", &smuggled)], - &[], + fixtures(&root), policy::ModuleChecks::Run, None, ) @@ -458,7 +494,7 @@ fn a_module_holds_no_source_and_cannot_leak_one_through_debug() { let bundles = policy::load( &root, &[row("policy-writes", &path)], - &[], + fixtures(&root), policy::ModuleChecks::Run, None, ) @@ -522,16 +558,14 @@ fn no_evaluator_feature_admits_io() { let bundles = policy::load( &root, &[row("policy-included", &included)], - &[], + fixtures(&root), policy::ModuleChecks::Run, None, ) .expect("a module over an in-closure builtin loads"); assert_eq!( policy::deny(&bundles[0], "{}"), - Look::Is(vec![unattributed( - "the module called a builtin that is in the closure" - )]), + Look::Is(vec![unattributed("V-BUILTIN-IN-THE-CLOSURE")]), "the control must deny, or an absent-builtin verdict below is unattributable" ); @@ -544,7 +578,7 @@ fn no_evaluator_feature_admits_io() { match policy::load( &root, &[row("policy-network", &network)], - &[], + fixtures(&root), policy::ModuleChecks::Run, None, ) { @@ -574,7 +608,7 @@ fn no_evaluator_feature_admits_io() { match policy::load( &root, &[row("policy-schema", &schema)], - &[], + fixtures(&root), policy::ModuleChecks::Run, None, ) { @@ -610,7 +644,7 @@ fn one_module_carries_two_predicates_that_deny_under_their_own_ids() { let bundles = policy::load( &root, &[row("policy-two", &path)], - &[], + fixtures(&root), policy::ModuleChecks::Run, None, ) @@ -652,8 +686,8 @@ fn one_module_carries_two_predicates_that_deny_under_their_own_ids() { assert_eq!( whole, vec![ - attributed("no-empty-fixture", "a fixture with no cases"), - attributed("no-stray-artifact", "a tracked build product"), + attributed("no-empty-fixture", "V-EMPTY-FIXTURE"), + attributed("no-stray-artifact", "V-STRAY-ARTIFACT"), ] ); } @@ -670,7 +704,7 @@ fn a_bare_string_deny_still_reports_under_the_registering_row() { let bundles = policy::load( &root, &[row("policy-writes", &path)], - &[], + fixtures(&root), policy::ModuleChecks::Run, None, ) @@ -685,10 +719,7 @@ fn a_bare_string_deny_still_reports_under_the_registering_row() { else { panic!("the module answered"); }; - assert_eq!( - violations, - vec![unattributed("a write, refused by the module")] - ); + assert_eq!(violations, vec![unattributed("V-WRITE-REFUSED")]); assert_eq!( bundles[0].attribute(&violations[0]), "policy-writes", @@ -708,7 +739,7 @@ fn an_undeclared_violation_id_is_refused_at_load() { let err = policy::load( &root, &[row("policy-undeclared", &path)], - &[], + fixtures(&root), policy::ModuleChecks::Run, None, ) @@ -744,7 +775,7 @@ fn two_modules_declaring_one_id_are_refused_at_load() { let err = policy::load( &root, &[row("policy-a", &first), row("policy-b", &second)], - &[], + fixtures(&root), policy::ModuleChecks::Run, None, ) @@ -778,7 +809,7 @@ fn a_waiver_over_one_predicate_does_not_suppress_its_sibling() { let bundles = policy::load( &root, &[row("policy-two", &path)], - &[], + fixtures(&root), policy::ModuleChecks::Run, None, ) @@ -828,7 +859,7 @@ import rego.v1 rules contains "declared-and-unused" -violation contains {"rule": "only-on-a-write", "msg": "reached later"} if { +violation contains {"rule": "only-on-a-write", "verdict": "V-REACHED-LATER"} if { input.call.operation == "write" } "#; @@ -836,7 +867,7 @@ violation contains {"rule": "only-on-a-write", "msg": "reached later"} if { let bundles = policy::load( &root, &[row("policy-late", &path)], - &[], + fixtures(&root), policy::ModuleChecks::Run, None, ) @@ -871,8 +902,14 @@ fn a_predicate_severity_naming_an_unpublished_id_is_refused_at_load() { .into_iter() .collect(), ); - let err = policy::load(&root, &[rule], &[], policy::ModuleChecks::Run, None) - .expect_err("a severity aimed at an id nothing publishes decides nothing"); + let err = policy::load( + &root, + &[rule], + fixtures(&root), + policy::ModuleChecks::Run, + None, + ) + .expect_err("a severity aimed at an id nothing publishes decides nothing"); let text = format!("{err}"); assert!(text.contains("no-such-predicate"), "names the key: {text}"); assert!(text.contains("two.rego"), "and the module: {text}"); @@ -895,8 +932,14 @@ fn severity_resolves_per_predicate_and_falls_back_to_the_row() { .into_iter() .collect(), ); - let bundles = - policy::load(&root, &[rule.clone()], &[], policy::ModuleChecks::Run, None).expect("load"); + let bundles = policy::load( + &root, + &[rule.clone()], + fixtures(&root), + policy::ModuleChecks::Run, + None, + ) + .expect("load"); assert_eq!( rule.severity_for(Some("no-stray-artifact")), @@ -946,7 +989,7 @@ import rego.v1 rules contains "no-force-push" -violation contains {"rule": "no-force-push", "msg": "a force push at the trunk"} if { +violation contains {"rule": "no-force-push", "verdict": "V-FORCE-PUSH-AT-TRUNK"} if { input.call.operation == "write" } "#; @@ -954,7 +997,7 @@ violation contains {"rule": "no-force-push", "msg": "a force push at the trunk"} let bundles = policy::load( &root, &[row("policy-git", &path)], - &[], + fixtures(&root), policy::ModuleChecks::Run, None, ) @@ -971,7 +1014,7 @@ violation contains {"rule": "no-force-push", "msg": "a force push at the trunk"} }; assert_eq!( violations, - vec![attributed("no-force-push", "a force push at the trunk")], + vec![attributed("no-force-push", "V-FORCE-PUSH-AT-TRUNK")], "the package prefix is `batten` and the RULE NAMES are what is fixed; \ pinning the whole path leaves this module silently unreachable" ); @@ -1012,7 +1055,7 @@ import data.batten.shared rules contains "no-protected-write" -violation contains {"rule": "no-protected-write", "msg": "a write under a protected root"} if { +violation contains {"rule": "no-protected-write", "verdict": "V-PROTECTED-MUTATION"} if { shared.is_protected(input.call.path) } "#; @@ -1036,10 +1079,7 @@ violation contains {"rule": "no-protected-write", "msg": "a write under a protec }; assert_eq!( violations, - vec![attributed( - "no-protected-write", - "a write under a protected root" - )], + vec![attributed("no-protected-write", "V-PROTECTED-MUTATION")], "module B called module A's helper; under per-module isolation this \ evaluates to `could not find function shared.is_protected`" ); diff --git a/crates/batten/tests/policy_presets.rs b/crates/batten/tests/policy_presets.rs index bc4f897ed..eb8152e2f 100644 --- a/crates/batten/tests/policy_presets.rs +++ b/crates/batten/tests/policy_presets.rs @@ -57,7 +57,7 @@ fn a_preset_predicate_denies_and_is_green_by_turns() { let bundles = policy::load( &root, &[preset_row("trunk", "trunk-based")], - &[], + policy::Vocabulary::EMPTY, policy::ModuleChecks::Run, None, ) @@ -101,7 +101,7 @@ fn the_commit_hygiene_preset_decides_both_ways() { let bundles = policy::load( &root, &[preset_row("hygiene", "commit-hygiene")], - &[], + policy::Vocabulary::EMPTY, policy::ModuleChecks::Run, None, ) @@ -129,7 +129,7 @@ fn an_unknown_preset_name_is_refused_at_load() { let err = policy::load( &root, &[preset_row("typo", "trunk-basd")], - &[], + policy::Vocabulary::EMPTY, policy::ModuleChecks::Run, None, ) @@ -149,8 +149,14 @@ fn an_unknown_preset_name_is_refused_at_load() { #[test] fn enabling_no_preset_yields_no_preset_predicates() { let root = scratch("opt-in"); - let bundles = policy::load(&root, &[], &[], policy::ModuleChecks::Run, None) - .expect("no rows, no bundles"); + let bundles = policy::load( + &root, + &[], + policy::Vocabulary::EMPTY, + policy::ModuleChecks::Run, + None, + ) + .expect("no rows, no bundles"); assert!( bundles.is_empty(), "a consumer who enables nothing gets nothing, which is what keeps this \ @@ -185,7 +191,7 @@ fn a_preset_id_colliding_with_an_in_repo_id_is_refused_at_load() { let err = policy::load( &root, &[preset_row("trunk", "trunk-based"), mine], - &[], + policy::Vocabulary::EMPTY, policy::ModuleChecks::Run, None, ) @@ -212,7 +218,7 @@ fn every_advertised_preset_name_actually_loads() { policy::load( &root, &[preset_row("row", name)], - &[], + policy::Vocabulary::EMPTY, policy::ModuleChecks::Run, None, ) @@ -303,7 +309,7 @@ fn every_shipped_preset_publishes_its_ids() { let bundles = policy::load( &root, &[preset_row("row", name)], - &[], + policy::Vocabulary::EMPTY, policy::ModuleChecks::Run, None, ) @@ -336,7 +342,7 @@ fn every_shipped_preset_passes_its_own_suite() { let bundles = policy::load( &root, &[preset_row("row", name)], - &[], + policy::Vocabulary::EMPTY, policy::ModuleChecks::Run, None, ) diff --git a/crates/batten/tests/policy_test_suite.rs b/crates/batten/tests/policy_test_suite.rs index e39d51d08..73659075d 100644 --- a/crates/batten/tests/policy_test_suite.rs +++ b/crates/batten/tests/policy_test_suite.rs @@ -48,6 +48,25 @@ use common::{Fixture, run, stdout}; /// Deserialized rather than struct-literalled, for `policy_modules.rs`'s reason: /// `Rule` carries `deny_unknown_fields`, so a row the loader would refuse cannot /// be smuggled into a test by hand. +/// The vocabulary the modules under `root` need, derived from the modules. +/// +/// **Derived rather than listed, because registry equality runs in both +/// directions.** A table naming a token the modules under test do not raise is +/// dead vocabulary and `load` refuses it — correctly. Reading the tokens off the +/// modules the fixture just wrote declares exactly what it raises. +/// +/// Leaked, and stated: [`batten::policy::Vocabulary`] borrows, and the +/// alternative is naming a `Vec` at every load site. A test binary is a +/// short-lived process and the table is tens of entries. +fn fixtures(root: &Path) -> batten::policy::Vocabulary<'static> { + let table: &'static [batten::verdict::DeclaredVerdict] = + Box::leak(common::verdicts_in(root).into_boxed_slice()); + batten::policy::Vocabulary { + patterns: &[], + verdicts: table, + } +} + fn row(id: &str, module: &str) -> Rule { serde_json::from_value(serde_json::json!({ "id": id, @@ -89,7 +108,7 @@ rules contains "no-force-push" violation contains { "rule": "no-force-push", - "msg": "a force push rewrites a shared branch", + "verdict": "V-FORCE-PUSH-AT-TRUNK", } if { words := split(input.call.command, " ") "--force" in words @@ -114,7 +133,7 @@ rules contains "no-force-push" violation contains { "rule": "no-force-push", - "msg": "a force push rewrites a shared branch", + "verdict": "V-FORCE-PUSH-AT-TRUNK", } if { contains(input.call.command, "--force") } @@ -163,7 +182,7 @@ import rego.v1 rules contains "always" -violation contains {"rule": "always", "msg": "m"} if { +violation contains {"rule": "always", "verdict": "V-FIXTURE-M"} if { input.call.command == "x" } @@ -195,11 +214,11 @@ rules contains "tested" rules contains "never-tested" -violation contains {"rule": "tested", "msg": "m"} if { +violation contains {"rule": "tested", "verdict": "V-FIXTURE-M"} if { input.call.command == "a" } -violation contains {"rule": "never-tested", "msg": "m"} if { +violation contains {"rule": "never-tested", "verdict": "V-FIXTURE-M"} if { input.call.command == "b" } @@ -262,7 +281,7 @@ import rego.v1 rules contains "never-tested" -violation contains {"rule": "never-tested", "msg": "m"} if { +violation contains {"rule": "never-tested", "verdict": "V-FIXTURE-M"} if { input.call.command == "b" } @@ -291,7 +310,7 @@ import rego.v1 rules contains "untested" -violation contains {"rule": "untested", "msg": "m"} if { +violation contains {"rule": "untested", "verdict": "V-FIXTURE-M"} if { input.call.command == "x" } "#, @@ -478,7 +497,7 @@ fn a_registered_module_with_tests_still_loads_and_denies() { let bundles = policy::load( Path::new(&dir), &[row("probe", "probe.rego")], - &[], + fixtures(&dir), policy::ModuleChecks::Run, None, ) diff --git a/crates/batten/tests/policy_tree.rs b/crates/batten/tests/policy_tree.rs index 801dc99ac..d1845460b 100644 --- a/crates/batten/tests/policy_tree.rs +++ b/crates/batten/tests/policy_tree.rs @@ -18,6 +18,8 @@ // Panicking on setup failure is the idiomatic way for a test to fail loudly. #![allow(clippy::unwrap_used, clippy::expect_used)] +mod common; + use std::fs; use std::path::{Path, PathBuf}; @@ -29,6 +31,25 @@ use batten::rules::{self, Rule}; /// `deny_unknown_fields`, so this exercises the column census a consumer's /// `batten.toml` goes through and a row the loader would refuse cannot be /// smuggled into a test by hand. +/// The vocabulary the modules under `root` need, derived from the modules. +/// +/// **Derived rather than listed, because registry equality runs in both +/// directions.** A table naming a token the modules under test do not raise is +/// dead vocabulary and `load` refuses it — correctly. Reading the tokens off the +/// modules the fixture just wrote declares exactly what it raises. +/// +/// Leaked, and stated: [`batten::policy::Vocabulary`] borrows, and the +/// alternative is naming a `Vec` at every load site. A test binary is a +/// short-lived process and the table is tens of entries. +fn fixtures(root: &Path) -> batten::policy::Vocabulary<'static> { + let table: &'static [batten::verdict::DeclaredVerdict] = + Box::leak(common::verdicts_in(root).into_boxed_slice()); + batten::policy::Vocabulary { + patterns: &[], + verdicts: table, + } +} + fn tree_row(id: &str, bundle: &str, documents: &[&str]) -> Rule { serde_json::from_value(serde_json::json!({ "id": id, @@ -57,7 +78,7 @@ import rego.v1 rules contains "no-stray-key" -violation contains {"rule": "no-stray-key", "msg": "the manifest declares a stray key"} if { +violation contains {"rule": "no-stray-key", "verdict": "V-STRAY-KEY"} if { input.tree.documents["config.toml"].stray } "#; @@ -67,7 +88,7 @@ fn write_bundle(root: &Path, source: &str) { } fn scan(root: &Path, rules: &[Rule]) -> rules::Scan { - rules::run_static(rules, &[], &[], root).expect("the read surface runs a policy row") + rules::run_static(rules, &[], fixtures(root), root).expect("the read surface runs a policy row") } /// (a) A tree-scoped bundle denies on a fixture that violates. @@ -186,7 +207,11 @@ fn check_still_refuses_a_spawning_kind() { })) .expect("a command row"); - let err = rules::run_static(&[command], &[], &[], &root) + // `Vocabulary::EMPTY`, deliberately: this load carries no POLICY row, so it + // declares no class either. Handing it the bundle's vocabulary would make + // every token dead — registry equality runs in both directions — and the + // refusal under test would arrive from the wrong gate. + let err = rules::run_static(&[command], &[], batten::policy::Vocabulary::EMPTY, &root) .expect_err("a read-effect verb will not run a configured command"); let text = format!("{err}"); assert!( @@ -212,7 +237,7 @@ import rego.v1 rules contains "saw-undeclared" -violation contains {"rule": "saw-undeclared", "msg": "an undeclared document reached the module"} if { +violation contains {"rule": "saw-undeclared", "verdict": "V-SAW-UNDECLARED"} if { input.tree.documents["undeclared.toml"] } "#; @@ -242,7 +267,7 @@ fn an_empty_bundle_root_is_refused_at_load() { let err = rules::run_static( &[tree_row("repo-policy", "policy/", &["config.toml"])], &[], - &[], + fixtures(&root), &root, ) .expect_err("a folder with no modules enables nothing"); @@ -259,12 +284,12 @@ fn every_module_under_the_bundle_root_is_enabled() { let root = scratch("many-modules"); fs::write( root.join("policy").join("a.rego"), - "package batten.a\nimport rego.v1\nrules contains \"from-a\"\nviolation contains {\"rule\": \"from-a\", \"msg\": \"a\"} if { input.tree.documents[\"config.toml\"].stray }\n", + "package batten.a\nimport rego.v1\nrules contains \"from-a\"\nviolation contains {\"rule\": \"from-a\", \"verdict\": \"V-FROM-A\"} if { input.tree.documents[\"config.toml\"].stray }\n", ) .expect("module a"); fs::write( root.join("policy").join("b.rego"), - "package batten.b\nimport rego.v1\nrules contains \"from-b\"\nviolation contains {\"rule\": \"from-b\", \"msg\": \"b\"} if { input.tree.documents[\"config.toml\"].stray }\n", + "package batten.b\nimport rego.v1\nrules contains \"from-b\"\nviolation contains {\"rule\": \"from-b\", \"verdict\": \"V-FROM-B\"} if { input.tree.documents[\"config.toml\"].stray }\n", ) .expect("module b"); fs::write(root.join("config.toml"), "stray = true\n").expect("fixture"); @@ -296,7 +321,7 @@ import rego.v1 rules contains "no-stray-artifact" -violation contains {"rule": "no-stray-artifact", "msg": "a tracked build product"} if { +violation contains {"rule": "no-stray-artifact", "verdict": "V-STRAY-ARTIFACT"} if { some p in input.tree.tracked endswith(p, ".o") } @@ -375,7 +400,7 @@ import rego.v1 rules contains "reads-a-ghost" -violation contains {"rule": "reads-a-ghost", "msg": "x"} if { +violation contains {"rule": "reads-a-ghost", "verdict": "V-FIXTURE-X"} if { some p in input.tree.nonesuch endswith(p, ".o") } @@ -385,7 +410,7 @@ violation contains {"rule": "reads-a-ghost", "msg": "x"} if { let err = batten::policy::load( &root, &[tree_row("repo-policy", "policy/", &[])], - &[], + fixtures(&root), batten::policy::ModuleChecks::Run, None, ) @@ -420,16 +445,16 @@ import rego.v1 rules contains "reads-real-keys" -violation contains {"rule": "reads-real-keys", "msg": "x"} if { +violation contains {"rule": "reads-real-keys", "verdict": "V-FIXTURE-X"} if { some p in input.tree.tracked endswith(p, ".o") } -violation contains {"rule": "reads-real-keys", "msg": "y"} if { +violation contains {"rule": "reads-real-keys", "verdict": "V-FIXTURE-Y"} if { input.tree.documents["config.toml"].stray } -violation contains {"rule": "reads-real-keys", "msg": "z"} if { +violation contains {"rule": "reads-real-keys", "verdict": "V-FIXTURE-Z"} if { count(input.tree.missing) > 0 } "#, @@ -438,7 +463,7 @@ violation contains {"rule": "reads-real-keys", "msg": "z"} if { batten::policy::load( &root, &[tree_row("repo-policy", "policy/", &[])], - &[], + fixtures(&root), batten::policy::ModuleChecks::Run, None, ) @@ -470,7 +495,7 @@ fn a_document_with_no_parser_is_refused_rather_than_skipped() { let err = rules::run_static( &[tree_row("repo-policy", "policy/", &["CLAUDE.md"])], &[], - &[], + fixtures(&root), &root, ) .expect_err("a declared document this build cannot parse is a config fault"); @@ -527,7 +552,7 @@ fn an_absent_unsupported_document_is_still_a_parser_fault() { let err = rules::run_static( &[tree_row("repo-policy", "policy/", &["CLAUDE.md"])], &[], - &[], + fixtures(&root), &root, ) .expect_err("the extension is decided before the tree is consulted"); @@ -564,7 +589,7 @@ import rego.v1 rules contains "reads-a-ghost" -violation contains {"rule": "reads-a-ghost", "msg": "x"} if { +violation contains {"rule": "reads-a-ghost", "verdict": "V-FIXTURE-X"} if { count(input.tree["nonesuch"]) > 0 } "#, @@ -573,7 +598,7 @@ violation contains {"rule": "reads-a-ghost", "msg": "x"} if { let err = batten::policy::load( &root, &[tree_row("repo-policy", "policy/", &[])], - &[], + fixtures(&root), batten::policy::ModuleChecks::Run, None, ) @@ -600,7 +625,7 @@ import rego.v1 rules contains "reads-real-keys" -violation contains {"rule": "reads-real-keys", "msg": "x"} if { +violation contains {"rule": "reads-real-keys", "verdict": "V-FIXTURE-X"} if { input.tree["documents"]["config.toml"].stray } "#, @@ -609,7 +634,7 @@ violation contains {"rule": "reads-real-keys", "msg": "x"} if { batten::policy::load( &root, &[tree_row("repo-policy", "policy/", &[])], - &[], + fixtures(&root), batten::policy::ModuleChecks::Run, None, ) @@ -646,7 +671,7 @@ import rego.v1 rules contains "no-focused-case" -violation contains {"rule": "no-focused-case", "msg": "a focused case is committed"} if { +violation contains {"rule": "no-focused-case", "verdict": "V-FOCUSED-CASE"} if { some line in input.tree.lines["suite.bats"] startswith(line, "@focus") } @@ -685,7 +710,7 @@ import rego.v1 rules contains "no-focused-case" -violation contains {"rule": "no-focused-case", "msg": "a focused case is committed"} if { +violation contains {"rule": "no-focused-case", "verdict": "V-FOCUSED-CASE"} if { some line in input.tree.lines["suite.bats"] startswith(line, "@focus") } @@ -731,7 +756,7 @@ import rego.v1 rules contains "no-focused-case" -violation contains {"rule": "no-focused-case", "msg": "a focused case is committed"} if { +violation contains {"rule": "no-focused-case", "verdict": "V-FOCUSED-CASE"} if { some line in input.tree.lines["suite.bats"] startswith(line, "@focus") } @@ -782,7 +807,7 @@ has_closing_key if { startswith(line, "Refs: CLOUD-") } -violation contains {"rule": "closes-a-key", "msg": "the body closes no issue key"} if { +violation contains {"rule": "closes-a-key", "verdict": "V-CLOSES-NO-KEY"} if { not has_closing_key } "#, @@ -825,7 +850,7 @@ has_closing_key if { startswith(line, "Refs: CLOUD-") } -violation contains {"rule": "closes-a-key", "msg": "the body closes no issue key"} if { +violation contains {"rule": "closes-a-key", "verdict": "V-CLOSES-NO-KEY"} if { not has_closing_key } "#, diff --git a/crates/batten/tests/primitives.rs b/crates/batten/tests/primitives.rs index c0406a6b2..284b0ce47 100644 --- a/crates/batten/tests/primitives.rs +++ b/crates/batten/tests/primitives.rs @@ -1024,7 +1024,7 @@ fn the_acceptance_runner_is_the_landed_rule_engine() { let err = batten::rules::run_static( &config.rules, &config.provisions, - &config.patterns, + batten::policy::Vocabulary::from(&config), &repo.dir, ) .unwrap_err(); @@ -1036,7 +1036,7 @@ fn the_acceptance_runner_is_the_landed_rule_engine() { batten::rules::run_all( &config.rules, &config.provisions, - &config.patterns, + batten::policy::Vocabulary::from(&config), &repo.dir ) .expect("the spawning surface runs it") diff --git a/crates/batten/tests/privileged_lane.rs b/crates/batten/tests/privileged_lane.rs new file mode 100644 index 000000000..1d37660ae --- /dev/null +++ b/crates/batten/tests/privileged_lane.rs @@ -0,0 +1,233 @@ +//! `policy/privileged-lane.rego` decides over the compiled engine (CLOUD-931). +//! +//! # Where this came from +//! +//! The successor to `tests/privileged-lane.bats`, retired under CLOUD-1059. Its +//! subject was `policy/privileged-lane.rego`, which CLOUD-1050 rewrote: the +//! module's refusal stopped being prose and became a declared class, so the +//! suite's fixture — a `batten.toml` with no `[[verdict]]` row — stopped +//! loading. `shell-retirement` refuses editing a bats suite in place, so the +//! open door is the migration. Every case below carries a `// carried:` arm. +//! +//! # What it keeps +//! +//! `policy/privileged-lane.rego` is a live `deny` row over this repository's own +//! privileged CI lanes, and its nine `test_` rules are `with input as` +//! assertions — insufficient evidence on their own (CLOUD-845), because a module +//! can fabricate a shape the engine never produces, pass its own suite green, +//! and gate nothing. Every case here goes in through `batten check`, the same +//! door `verify` and the hk gate come through, and reads the verdict a caller +//! would read. +//! +//! The two tiers are complementary rather than redundant: the module's own rules +//! pin the PREDICATE, this file pins that the ENGINE builds the input the +//! predicate reads. + +// THE FILE-GRANULARITY RETIREMENT ARM (CLOUD-1059). Its grammar is disjoint +// from CLOUD-908's case arms below by construction: a case arm's first field +// after the marker is a QUOTED case name, and a file arm's is a path. Neither +// reader can match the other's shape, which is what lets one marker carry two +// ledgers without a second convention. +// +// carried: tests/privileged-lane.bats policy/privileged-lane.rego crates/batten/tests/privileged_lane.rs + +// Panicking on setup failure is the idiomatic way for a test to fail loudly. +#![allow(clippy::unwrap_used, clippy::expect_used)] + +mod common; + +use std::fs; +use std::path::{Path, PathBuf}; + +/// A throwaway repository carrying the committed module, the row that enables +/// it, the class it raises, and one workflow. +fn fixture(name: &str, workflow: &str, body: &str) -> PathBuf { + let root = common::scratch(&format!("privileged-lane-{name}")); + fs::create_dir_all(root.join("policy")).expect("scratch policy dir"); + fs::create_dir_all(root.join(".github/workflows")).expect("scratch workflows dir"); + let module = common::at_root("policy/privileged-lane.rego") + .canonicalize() + .expect("the committed module is where the row says it is"); + fs::copy(module, root.join("policy/privileged-lane.rego")).expect("install committed module"); + fs::write(root.join(".github/workflows").join(workflow), body).expect("write the workflow"); + fs::write( + root.join("batten.toml"), + concat!( + "version = 1\n\n", + "[[rule]]\n", + "id = \"privileged-lane-tests-origin\"\n", + "kind = \"policy\"\n", + "scope = \"tree\"\n", + "sources = [\".github/workflows/*.yml\"]\n", + "module = \"policy/privileged-lane.rego\"\n", + "severity = \"deny\"\n\n", + "[[verdict]]\n", + "id = \"V-PRIVILEGED-LANE-UNTESTED-ORIGIN\"\n", + "gloss = \"a job an outside author can reach holds contents:write and tests no head origin\"\n", + "class = \"\"\"\n", + "A trigger an outside author can fire, a token that can write, and no check that \\\n", + "the head being built came from this repository.\n", + "\"\"\"\n\n", + "[[verdict.route]]\n", + "id = \"R-TEST-THE-HEAD-ORIGIN\"\n", + "kind = \"document\"\n", + "target = \".github/workflows\"\n\n", + "[[verdict]]\n", + "id = \"V-WORKFLOW-UNPARSED\"\n", + "gloss = \"a workflow could not be parsed, so its lanes were never judged\"\n", + "class = \"\"\"\n", + "Could-not-look, and deliberately not spelled the same way as a workflow whose \\\n", + "lanes are all safe.\n", + "\"\"\"\n\n", + "[[verdict.route]]\n", + "id = \"R-FIX-THE-WORKFLOW-SYNTAX\"\n", + "kind = \"document\"\n", + "target = \".github/workflows\"\n", + ), + ) + .expect("write the fixture authority"); + // No global or system config: a contributor's own git settings must not be + // able to change a verdict here (CLOUD-282). `common::git_in` already fences + // discovery; `init` is what makes the walk a repository walk. + common::git_in(&root, &["init", "-q", "-b", "main"]); + root +} + +/// The exit contract, asserted by NAME rather than by integer +/// (`.claude/rules/rust.md`): `2` is the policy verdict, `0` is clean. The shell +/// tasks' inverted convention must not be carried in — a case asserting `1` here +/// would be asserting "unreadable input" while meaning "violation", and it would +/// pass. +fn denied(root: &Path) { + let output = common::run(root, &["check"]); + let text = String::from_utf8_lossy(&output.stdout).into_owned(); + assert_eq!( + output.status.code(), + Some(batten::exit::ExitCode::Violation.code()), + "expected the policy verdict: {text}{}", + String::from_utf8_lossy(&output.stderr) + ); + assert!( + text.contains("privileged-lane-tests-origin"), + "the finding names the rule: {text}" + ); +} + +fn clean(root: &Path) { + let output = common::run(root, &["check"]); + assert_eq!( + output.status.code(), + Some(batten::exit::ExitCode::Success.code()), + "expected a clean tree: {}{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); +} + +// carried: "a bot lane selecting by branch prefix is denied" crates/batten/tests/privileged_lane.rs +#[test] +fn a_bot_lane_selecting_by_branch_prefix_is_denied() { + // The defect CLOUD-867 was filed for: the head is chosen by a string the PR + // author picks. Driven through the engine rather than through `policy test`. + let root = fixture( + "branch-prefix", + "auto-bot-land.yml", + "on:\n workflow_run:\n workflows: [ci]\njobs:\n land:\n permissions:\n \ + contents: write\n if: startsWith(github.event.workflow_run.head_branch, 'renovate/')\n \ + steps:\n - run: echo land\n", + ); + denied(&root); +} + +// carried: "the same lane testing the head origin is clean" crates/batten/tests/privileged_lane.rs +#[test] +fn the_same_lane_testing_the_head_origin_is_clean() { + // The discriminating half. Same trigger, same grant, same job — only the + // origin test is added, so a gate that denied both would prove nothing. + let root = fixture( + "origin-tested", + "auto-bot-land.yml", + "on:\n workflow_run:\n workflows: [ci]\njobs:\n land:\n permissions:\n \ + contents: write\n if: github.event.workflow_run.head_repository.full_name == \ + github.repository\n steps:\n - run: echo land\n", + ); + clean(&root); +} + +// carried: "a scheduled writer that resolves no outside head is not a subject" crates/batten/tests/privileged_lane.rs +#[test] +fn a_scheduled_writer_that_resolves_no_outside_head_is_not_a_subject() { + // THE FALSE POSITIVE THE THIRD CONJUNCT EXISTS FOR: `perf.yml` is scheduled, + // holds contents:write to push its own series, and selects no outside head. + // A gate whose first firing is a false positive gets an exception written + // for it, and the exception is what rots. + let root = fixture( + "scheduled", + "perf.yml", + "on:\n schedule:\n - cron: \"0 0 * * *\"\n workflow_dispatch:\njobs:\n measure:\n \ + permissions:\n contents: write\n steps:\n - run: git push origin \ + refs/notes/perf\n", + ); + clean(&root); +} + +// carried: "an outsider-reachable writer that resolves no outside head is not a subject" crates/batten/tests/privileged_lane.rs +#[test] +fn an_outsider_reachable_writer_that_resolves_no_outside_head_is_not_a_subject() { + // THE CASE THAT ACTUALLY DISCRIMINATES THE THIRD CONJUNCT. It exists because + // the first declared mutation SURVIVED, and the survival identified a false + // rationale rather than a false predicate. + // + // That rationale named `perf.yml` as what the third conjunct excludes. + // Measured: the real `perf.yml` triggers are `schedule` and + // `workflow_dispatch`, and NEITHER is in `outsider_reachable`'s list — so the + // FIRST conjunct already excludes it and dropping the third changes nothing. + // The module's own `test_a_scheduled_writer_with_no_outside_head_is_not_a_subject` + // has the same hole. This comment records how it was found, because reading + // could not have found it. + // + // A discriminating input is outsider-reachable (`issue_comment`), holds + // `contents: write`, and resolves no outside head — no `pull_request` or + // `workflow_run` trigger and no `/pulls` anywhere. Clean today; a finding the + // moment the third conjunct stops being asked. + let root = fixture( + "outsider-reachable", + "triage.yml", + "on:\n issue_comment:\n types: [created]\njobs:\n label:\n permissions:\n \ + contents: write\n steps:\n - run: echo \"labelling from a comment\"\n", + ); + clean(&root); +} + +// carried: "a read-only lane is not a subject" crates/batten/tests/privileged_lane.rs +#[test] +fn a_read_only_lane_is_not_a_subject() { + let root = fixture( + "read-only", + "ci.yml", + "on:\n pull_request:\njobs:\n gate:\n permissions:\n contents: read\n \ + steps:\n - run: echo test\n", + ); + clean(&root); +} + +// THE CASE THIS FILE DELIBERATELY DOES NOT CARRY, and the reason is a finding +// rather than an omission (CLOUD-1049). +// +// The module's first clause says an unparseable workflow lands in +// `input.tree.missing` and denies, so that "could not read it" never reads as +// "clean". Written as a case here, that is RED: a genuinely invalid workflow +// produces exit 0, no finding, and no cause, even under `--strictness strict`. +// The same fixture with a parseable workflow that fails the predicate denies at +// 2, so the row is live and selected; it is the unparseable path specifically +// that vanishes. +// +// It is not shipped red, and it is not shipped asserting the current behaviour +// either — that would bake the defect in as the contract and go green forever. +// CLOUD-1049 owns it and names this file as where the case belongs once the +// engine honours what the module already documents. +// +// This is also the clearest evidence for why this tier exists at all: the +// module's own `test_an_unparseable_workflow_denies_rather_than_passing` is +// GREEN, because `with input as` hands itself the populated `missing` the engine +// never builds (CLOUD-845). diff --git a/crates/batten/tests/ratchet.rs b/crates/batten/tests/ratchet.rs index d0cca91fc..fd9b0d2bc 100644 --- a/crates/batten/tests/ratchet.rs +++ b/crates/batten/tests/ratchet.rs @@ -841,10 +841,25 @@ fn a_changed_arm_carrying_its_reason_is_admitted() { } #[test] -fn the_mapping_does_not_weaken_the_subject_admission() { - // Arm (e). A complete mapping is not a second way to buy a decrease: the - // subject still has to die. Without this the column could be read as an - // alternative to `retires_with` rather than an obligation inside it. +fn a_complete_mapping_is_the_second_admission_for_a_decrease() { + // WHAT CHANGED UNDER CLOUD-1050, and why it is a repair rather than a + // weakening. + // + // Arm (e) used to read: a complete mapping is NOT a second way to buy a + // decrease, the subject still has to die. That composition is unsatisfiable + // for the case CLOUD-1059 creates. A Bats suite whose subject is a LIVE + // `.rego` module cannot be edited in place — `shell-retirement` refuses + // exactly that — and its subject is not dying, because the module is what + // the migration keeps. So the only two doors were both shut, and a rule with + // no open door is not a ratchet, it is a wall. + // + // The repair is a SECOND admission, not a wider one: `retires_with` admits a + // decrease on subject death OR on a complete CLOUD-908 ledger. Both are + // evidence that the logic survived; neither is a promise. Arm (e)'s real + // content — an UNMAPPED decrease answers to the subject — is unchanged and + // is asserted by `an_unmapped_case_refuses_the_deletion` and by + // `a_partial_deletion_still_owes_an_arm_for_the_case_it_dropped`, which + // reads the `subject-alive` pointer this case used to. let dir = mapping_repo( "conserves-subject-alive", "// carried: \"one\" successors/alpha.rs\n// subsumed: \"two\" programs/beta\n", @@ -854,12 +869,13 @@ fn the_mapping_does_not_weaken_the_subject_admission() { let output = check(&dir); assert_eq!( output.status.code(), - Some(2), - "the subject is still alive, and a perfect mapping does not buy the decrease" + Some(0), + "a complete ledger admits the decrease without the subject dying: {:?}", + stdout(&output) ); assert!( - stdout(&output).contains("subject-alive programs/alpha"), - "CLOUD-807's clause still fires, unweakened: {:?}", + !stdout(&output).contains("subject-alive programs/alpha"), + "the subject clause does not fire over a fully mapped path: {:?}", stdout(&output) ); } @@ -900,14 +916,22 @@ fn a_partial_deletion_owes_an_arm_only_for_what_it_dropped() { // satisfied gets switched off, which is coverage evaporation by a second // route than the one this column closes. // - // ASSERTED ON THE FINDINGS RATHER THAN THE EXIT CODE, and that is forced by - // how the two halves compose: a partial deletion leaves the suite alive, so - // its subject is alive too, so `retires_with` refuses the decrease no matter - // how perfect the mapping is. The exit code is therefore 2 either way and - // says nothing about this column. What discriminates is WHICH findings - // appear — and the first version of this test asserted the code, passed for - // the wrong reason on a fixture that had deleted the program out from under - // a surviving suite, and had to be rewritten. + // ASSERTED ON THE FINDINGS RATHER THAN THE EXIT CODE. What discriminates + // this column is WHICH findings appear — the first version of this test + // asserted the code, passed for the wrong reason on a fixture that had + // deleted the program out from under a surviving suite, and had to be + // rewritten. + // + // WHAT CHANGED UNDER CLOUD-1050, because this comment used to say the + // opposite. It read: "a partial deletion leaves the suite alive, so its + // subject is alive too, so `retires_with` refuses the decrease no matter how + // perfect the mapping is." That was true while subject death was the ONLY + // admission. The mapped-successor arm is the second one, and a complete + // ledger is strictly more evidence than a dead subject: it says where every + // dropped case went, which subject death asks nothing about. So a perfect + // mapping now admits, and `an_unmapped_partial_deletion_is_still_refused` + // below is the discriminating half — without it this case would be satisfied + // by an arm that admitted everything. let dir = mapping_repo( "conserves-partial", "// carried: \"two\" successors/alpha.rs\n", @@ -921,8 +945,8 @@ fn a_partial_deletion_owes_an_arm_only_for_what_it_dropped() { let output = check(&dir); let text = stdout(&output); assert!( - text.contains("subject-alive programs/alpha"), - "the plain admission still refuses a decrease under a live subject: {text:?}" + !text.contains("subject-alive programs/alpha"), + "a complete ledger admits the decrease without the subject dying: {text:?}" ); assert!( !text.contains("suites/alpha.t:2"), @@ -956,6 +980,15 @@ fn a_partial_deletion_still_owes_an_arm_for_the_case_it_dropped() { !text.contains("suites/alpha.t:2"), "and only the dropped one: {text:?}" ); + // AND THE MAPPED-SUCCESSOR ARM DOES NOT ADMIT IT (CLOUD-1050). An unmapped + // dropped case leaves the path un-conserved, so the aggregate admission + // falls back to asking whether the subject died — and it did not. Without + // this the new arm would be a hole rather than an admission: any decrease + // would pass on a ledger that claimed nothing. + assert!( + text.contains("subject-alive programs/alpha"), + "an unmapped decrease still answers to the subject: {text:?}" + ); } #[test] diff --git a/crates/batten/tests/remedy_authorship.rs b/crates/batten/tests/remedy_authorship.rs index 6b95321af..ec6ca88ab 100644 --- a/crates/batten/tests/remedy_authorship.rs +++ b/crates/batten/tests/remedy_authorship.rs @@ -26,6 +26,8 @@ // Panicking on setup failure is the idiomatic way for a test to fail loudly. #![allow(clippy::unwrap_used, clippy::expect_used)] +mod common; + use std::fs; use std::path::{Path, PathBuf}; @@ -76,8 +78,25 @@ fn manifest(root: &Path, body: &str) { fs::write(root.join("mise.toml"), body).expect("write manifest"); } +/// The vocabulary the installed module needs, read off the module itself +/// (CLOUD-1050). +/// +/// Derived rather than listed for the reason registry equality gives: it runs in +/// both directions, so a hand-written table drifts from the committed module the +/// moment that module gains or loses a class — and this fixture copies the +/// COMMITTED module in precisely so it cannot drift. fn scan(root: &Path, rule: Rule) -> rules::Scan { - rules::run_static(&[rule], &[], &[], root).expect("the read surface runs a policy row") + let verdicts = common::verdicts_in(root); + rules::run_static( + &[rule], + &[], + batten::policy::Vocabulary { + patterns: &[], + verdicts: &verdicts, + }, + root, + ) + .expect("the read surface runs a policy row") } // --------------------------------------------------------------------------- diff --git a/crates/batten/tests/run_shape.rs b/crates/batten/tests/run_shape.rs new file mode 100644 index 000000000..781930be2 --- /dev/null +++ b/crates/batten/tests/run_shape.rs @@ -0,0 +1,327 @@ +//! `policy/run-shape.rego` decides over the compiled binary (CLOUD-843 track 2). +//! +//! # Where this came from, and why it is Rust +//! +//! This file is the successor to `tests/run-shape.bats`, retired under +//! CLOUD-1059. The suite's own subject was `policy/run-shape.rego`, which +//! CLOUD-1050 rewrote: the module's refusal stopped being prose and became a +//! declared class. Its cases asserted the prose, so they went red — and +//! `shell-retirement` refuses editing a bats suite in place, which is the whole +//! point of that gate. Both doors shut on an edit; the open one is the +//! migration, and this is it. Every case below carries a `// carried:` arm. +//! +//! # What it keeps, unchanged +//! +//! **It drives the compiled binary over a real envelope**, which is the reason +//! the suite existed at all rather than a convenience. `batten policy test` is +//! established as insufficient evidence (CLOUD-845): `with input as` lets a +//! module's own test fabricate a shape the engine cannot produce, so a module +//! can pass its suite green and gate nothing. Every case here goes in through +//! `batten hook` — the same door a mediated call comes through — and reads the +//! permission decision the host would read. +//! +//! The fixture is a throwaway repository carrying ONE row and a copy of the +//! COMMITTED module, so the predicate is exercised in isolation from this +//! repository's other rules and cannot drift from the module that ships. +//! +//! # The status assertion is load-bearing, and it was a defect once +//! +//! `batten hook` prints NOTHING on an allow — the JSON is emitted only to deny — +//! and exits 0 either way, because the contract is that the harness reads the +//! decision and not the code. So a check of the form "the output does not +//! contain `deny`" is true over an EMPTY string, and every allow case in the +//! retired suite went green on any output at all, including the output of a +//! binary that died before it judged anything. That was CLOUD-251's vacuous pass +//! wearing a test's clothes, suite-wide. Both helpers below assert the status. + +// THE FILE-GRANULARITY RETIREMENT ARM (CLOUD-1059). See the sibling note in +// `privileged_lane.rs` for why one marker carries two disjoint ledgers. +// +// carried: tests/run-shape.bats policy/run-shape.rego crates/batten/tests/run_shape.rs + +// Panicking on setup failure is the idiomatic way for a test to fail loudly. +#![allow(clippy::unwrap_used, clippy::expect_used)] + +mod common; + +use std::fs; +use std::path::{Path, PathBuf}; + +/// A throwaway repository carrying the committed module and the one row that +/// enables it, plus the `[[pattern]]` and `[[verdict]]` rows it needs. +/// +/// The pattern row is not decoration: an inline regex is refused at load, so +/// without it the module's reference is undefined and every allow case flips to +/// a denial — the silent disarm the engine refuses outright (CLOUD-885). The +/// verdict row is its sibling under CLOUD-1050: a token no row declares is +/// refused at load, so a fixture without it would be testing that refusal. +/// +/// # One scratch tree PER CASE, and the shared one was a real race +/// +/// `name` is not decoration. Every case here built `scratch("run-shape")`, and +/// `nextest` runs each case in its own process concurrently — so one case was +/// recreating the tree while another was reading it, and the reader got a +/// directory with no `batten.toml` in it. It surfaced as +/// `a_git_commit_naming_no_message_source_is_denied` failing on its SECOND +/// assertion with empty output, intermittently, which is exactly what a +/// half-written fixture looks like from the outside. The sibling +/// `privileged_lane.rs` had the per-case shape from the start; this file did not, +/// and the difference is why only this one flaked. +fn fixture(name: &str) -> PathBuf { + let root = common::scratch(&format!("run-shape-{name}")); + fs::create_dir_all(root.join("policy")).expect("scratch policy dir"); + let module = common::at_root("policy/run-shape.rego") + .canonicalize() + .expect("the committed module is where the row says it is"); + fs::copy(module, root.join("policy/run-shape.rego")).expect("install committed module"); + fs::write( + root.join("batten.toml"), + concat!( + "version = 1\n\n", + "[[rule]]\n", + "id = \"commit-message-obtainable\"\n", + "kind = \"policy\"\n", + "scope = \"mediated_call\"\n", + "module = \"policy/run-shape.rego\"\n", + "severity = \"deny\"\n\n", + "[[pattern]]\n", + "id = \"short-message-flag-cluster\"\n", + "regex = \"^-[A-Za-z]*[mFCc]\"\n\n", + "[[verdict]]\n", + "id = \"V-COMMIT-WITHOUT-A-MESSAGE-SOURCE\"\n", + "gloss = \"a `git commit` names no message source, so git opens $EDITOR and blocks\"\n", + "class = \"\"\"\n", + "No `-m`, `-F`, `-C`, `--no-edit`, `--fixup` or `--squash`. Git opens $EDITOR and \\\n", + "blocks, AFTER `pre-commit` has already spent the whole gate. Write the message to \\\n", + "a file and use `git commit -F `, the one form that cannot rebind.\n", + "\"\"\"\n\n", + "[[verdict.route]]\n", + "id = \"R-COMMIT-FROM-A-FILE\"\n", + "kind = \"command\"\n", + "target = \"git commit -F \"\n", + ), + ) + .expect("write the fixture authority"); + common::git_in(&root, &["init", "-q", "-b", "main"]); + root +} + +/// Hand `command` to the engine as a Claude Code `PreToolUse` envelope. +fn hook(root: &Path, command: &str) -> (bool, String) { + let envelope = serde_json::json!({ + "hook_event_name": "PreToolUse", + "tool_name": "Bash", + "tool_input": {"command": command}, + }) + .to_string(); + let output = common::run_with_stdin(root, &["hook", "--harness", "claude-code"], &envelope); + // THE STATUS IS PART OF THE ANSWER. Allow and deny both exit 0, so a + // non-zero status is exactly and only the crash — and without this check an + // allow assertion passes over a binary that died before judging anything. + assert_eq!( + output.status.code(), + Some(0), + "the engine decided rather than crashed: {}", + String::from_utf8_lossy(&output.stderr) + ); + let text = String::from_utf8_lossy(&output.stdout).into_owned(); + (text.contains(r#""permissionDecision":"deny""#), text) +} + +fn denied(root: &Path, command: &str) { + let (deny, text) = hook(root, command); + assert!(deny, "`{command}` should be refused: {text}"); +} + +fn allowed(root: &Path, command: &str) { + let (deny, text) = hook(root, command); + assert!(!deny, "`{command}` should be allowed: {text}"); +} + +// --------------------------------------------------------------------------- +// The predicate. +// --------------------------------------------------------------------------- + +// carried: "THE MEASURED SHAPE: a git commit naming no message source is denied" crates/batten/tests/run_shape.rs +#[test] +fn a_git_commit_naming_no_message_source_is_denied() { + // `pre-commit` runs before git asks for a message, so this spends the whole + // gate and then blocks on $EDITOR with nobody to close it (CLOUD-488). + let root = fixture("no-message-source"); + denied(&root, "git commit"); + denied(&root, "git commit -a"); +} + +// carried: "every form that CAN obtain a message stays allowed" crates/batten/tests/run_shape.rs +#[test] +fn every_form_that_can_obtain_a_message_stays_allowed() { + // The load-bearing half. A predicate that only ever denied would satisfy the + // case above and be useless (CLOUD-418). + let root = fixture("obtainable"); + for command in [ + "git commit -F /tmp/msg.txt", + "git commit -m \"a message\"", + "git commit -am \"a message\"", + "git commit --amend --no-edit", + "git commit --fixup HEAD", + "git commit -C HEAD@{1}", + "git commit --message=hello", + "git commit -F -", + ] { + allowed(&root, command); + } +} + +// carried: "THE MEASURED SHAPE: a token carrying an m is not a flag cluster" crates/batten/tests/run_shape.rs +#[test] +fn a_token_carrying_an_m_is_not_a_flag_cluster() { + // CLOUD-885. The rule reads "one `-`, then LETTERS, at least one of which + // selects a message source". Before `regex.match` it was spelled as + // `contains` over the flag's tail, which cannot say "letters" — so `-x=mfoo` + // carried an `m`, read as naming a message source, and a commit that still + // blocks on $EDITOR went through. + // + // This is the discriminating case rather than another `-m`: the suite as it + // stood covered `-m`, `-am`, `-F`, `-C` and the long forms, and every one of + // them passes under BOTH spellings. + let root = fixture("short-cluster"); + denied(&root, "git commit -x=mfoo"); + // The other direction, so the anchor is proven and not just the class: a + // message flag must be reached from the START of the cluster. `-vm` is one. + allowed(&root, "git commit -vm \"a message\""); +} + +// --------------------------------------------------------------------------- +// The list, which is where a raw-string module goes silent. +// --------------------------------------------------------------------------- + +// carried: "a compound list is judged per element, not by its first word" crates/batten/tests/run_shape.rs +#[test] +fn a_compound_list_is_judged_per_element() { + // THE SHAPE A RAW-STRING MODULE MISSES. The vendored `no-force-push` preset + // anchors on `words[0] == "git"` over the whole command, so `cd /tmp && git + // push --force` reaches it as `cd` and is allowed — green tests, silent + // gate. Every element is a command here. + let root = fixture("list-element"); + denied(&root, "cd /tmp && git commit"); + allowed(&root, "git add -A && git commit -m x"); +} + +// carried: "a pipe stage is judged too" crates/batten/tests/run_shape.rs +#[test] +fn a_pipe_stage_is_judged_too() { + denied(&fixture("pipe-stage"), "echo hi | git commit"); +} + +// carried: "a wrapper is looked through to the program it runs" crates/batten/tests/run_shape.rs +#[test] +fn a_wrapper_is_looked_through_to_the_program_it_runs() { + let root = fixture("wrapper"); + denied(&root, "timeout 300 git commit"); + allowed(&root, "timeout 300 git commit -m x"); +} + +// --------------------------------------------------------------------------- +// Scrubbing: prose is not a call. +// --------------------------------------------------------------------------- + +// carried: "a git commit inside a quoted span is prose, not a call" crates/batten/tests/run_shape.rs +#[test] +fn a_git_commit_inside_a_quoted_span_is_prose() { + // This repository writes the shape down constantly — in commit messages, in + // issue bodies, in this file. A module judging the raw string would refuse + // its own documentation. + let root = fixture("quoted-span"); + allowed(&root, "echo \"git commit\""); + allowed(&root, "echo 'git commit'"); +} + +// carried: "a quoted span carrying a list separator is not a list" crates/batten/tests/run_shape.rs +#[test] +fn a_quoted_span_carrying_a_list_separator_is_not_a_list() { + // THE CASE THAT DISCRIMINATES the quote scrub. A quoted mention with no + // separator in it is already safe by the program anchoring above; what needs + // the scrub is a message that carries a `;` or `&&`, because the list split + // would otherwise turn the tail of a commit message into its own command. + // Both quote characters, because they are two passes. + let root = fixture("quoted-separator"); + allowed(&root, "echo \"step one; git commit -x\""); + allowed(&root, "echo 'step one; git commit -x'"); +} + +// carried: "a git commit inside a heredoc body is prose, not a call" crates/batten/tests/run_shape.rs +#[test] +fn a_git_commit_inside_a_heredoc_body_is_prose() { + allowed( + &fixture("heredoc-body"), + "cat > t.bats <`, `pre-commit`, the predicate id — and a policy deny + // carried `Fix::None`, so the module had to remember to write them. + // + // Now the class is declared: `Fix` comes off the class's first `command` + // route, so "a refusal names a way out" holds by construction rather than by + // each module's care, and `verdict::validate` refuses a class that declares + // none. So this asserts the token, the gloss and the route — the three + // things a reader acts on — rather than a sentence that may be reworded. + let root = fixture("refusal-class"); + let (deny, text) = hook(&root, "git commit"); + assert!(deny, "the shape is still refused: {text}"); + assert!( + text.contains("commit-names-no-message-source"), + "the predicate id: {text}" + ); + assert!( + text.contains("V-COMMIT-WITHOUT-A-MESSAGE-SOURCE"), + "the declared class: {text}" + ); + assert!( + text.contains("git commit -F "), + "the route out, taken off the class rather than out of the module: {text}" + ); +} + +// carried: "git -C commit is a deliberate false negative, carried over" crates/batten/tests/run_shape.rs +#[test] +fn git_c_path_commit_is_a_deliberate_false_negative() { + // The bash guard resolved `sub1` to the path and let it through, because a + // guard with false positives gets bypassed (CLOUD-199) and this repository + // commits from its own root. A migration that silently fixed it would be + // changing the predicate, not moving it. + allowed(&fixture("dash-c"), "git -C /some/path commit"); +} + +// carried: "a command with no git commit in it at all is untouched" crates/batten/tests/run_shape.rs +#[test] +fn a_command_with_no_git_commit_in_it_is_untouched() { + let root = fixture("untouched"); + allowed(&root, "ls -la"); + allowed(&root, "hg commit"); +} diff --git a/crates/batten/tests/shell_retirement.rs b/crates/batten/tests/shell_retirement.rs index 4887a3bf7..641150722 100644 --- a/crates/batten/tests/shell_retirement.rs +++ b/crates/batten/tests/shell_retirement.rs @@ -99,8 +99,21 @@ fn install_module(root: &Path) { fs::copy(source, root.join("policy/shell-retirement.rego")).expect("install committed module"); } +/// The vocabulary the installed module needs, read off the module itself +/// (CLOUD-1050). Derived rather than listed: this fixture copies the COMMITTED +/// module in so it cannot drift, and a hand-written table beside it would. fn scan(root: &Path) -> rules::Scan { - rules::run_static(&[row()], &[], &[], root).expect("the read surface runs a policy row") + let verdicts = common::verdicts_in(root); + rules::run_static( + &[row()], + &[], + batten::policy::Vocabulary { + patterns: &[], + verdicts: &verdicts, + }, + root, + ) + .expect("the read surface runs a policy row") } fn findings(root: &Path) -> Vec { diff --git a/crates/batten/tests/sinks.rs b/crates/batten/tests/sinks.rs index b9b61c7a8..a3d6a81a6 100644 --- a/crates/batten/tests/sinks.rs +++ b/crates/batten/tests/sinks.rs @@ -423,7 +423,17 @@ fn ratchet_config(read_back: bool) -> String { scope = \"tree\"\n\ module = \"{module}\"\n\ severity = \"warn\"\n\ - no_fix_reason = \"run enforce once to establish the baseline\"\n" + no_fix_reason = \"run enforce once to establish the baseline\"\n\ + \n\ + [[verdict]]\n\ + id = \"V-NO-BASELINE-YET\"\n\ + gloss = \"no baseline has been produced for the rule this module reads\"\n\ + class = \"What the fixture asserts, at the length explain answers with.\"\n\ + \n\ + [[verdict.route]]\n\ + id = \"R-RUN-ENFORCE-ONCE\"\n\ + kind = \"command\"\n\ + target = \"batten enforce\"\n" ) } @@ -435,7 +445,7 @@ const READS_BACK: &str = "package batten\n\ \n\ violation contains {\n\ \t\"rule\": \"needs-a-baseline\",\n\ - \t\"msg\": \"no baseline for no-todo yet\",\n\ + \t\"verdict\": \"V-NO-BASELINE-YET\",\n\ } if {\n\ \tnot input.tree.produced[\"no-todo\"]\n\ }\n"; @@ -446,7 +456,7 @@ const BLIND: &str = "package batten\n\ \n\ violation contains {\n\ \t\"rule\": \"needs-a-baseline\",\n\ - \t\"msg\": \"no baseline for no-todo yet\",\n\ + \t\"verdict\": \"V-NO-BASELINE-YET\",\n\ }\n"; fn ratchet_repo(name: &str, read_back: bool) -> PathBuf { @@ -570,7 +580,7 @@ fn a_journal_never_reaches_the_policy_input() { \n\ violation contains {\n\ \t\"rule\": \"reads-the-journal\",\n\ - \t\"msg\": \"the journal was readable\",\n\ + \t\"verdict\": \"V-JOURNAL-WAS-READABLE\",\n\ } if {\n\ \tinput.tree.produced[\"no-todo\"]\n\ }\n"; @@ -597,7 +607,17 @@ fn a_journal_never_reaches_the_policy_input() { scope = \"tree\"\n\ module = \"policy/reads.rego\"\n\ severity = \"warn\"\n\ - no_fix_reason = \"nothing to fix; this row exists to prove the journal is unreadable\"\n", + no_fix_reason = \"nothing to fix; this row exists to prove the journal is unreadable\"\n\ + \n\ + [[verdict]]\n\ + id = \"V-JOURNAL-WAS-READABLE\"\n\ + gloss = \"a journal reached the policy input, which nothing may read back\"\n\ + class = \"What the fixture asserts, at the length explain answers with.\"\n\ + \n\ + [[verdict.route]]\n\ + id = \"R-READ-THE-STORE-FILTER\"\n\ + kind = \"document\"\n\ + target = \"policy/reads.rego\"\n", ) .file("src/lib.rs", "// TODO: something\n") .file("policy/reads.rego", module) diff --git a/crates/batten/tests/verdict_registry.rs b/crates/batten/tests/verdict_registry.rs new file mode 100644 index 000000000..1b60b62c0 --- /dev/null +++ b/crates/batten/tests/verdict_registry.rs @@ -0,0 +1,382 @@ +//! The typed refusal ABI, over the engine rather than over a fabricated input +//! (CLOUD-1050). +//! +//! # What is under test, and why it cannot be a `with input as` case +//! +//! Registry equality is a property of **loading**: the engine reads a module's +//! AST, compares the tokens it raises against the declared table, and refuses in +//! both directions. A module's own `test_` rules cannot reach any of that — they +//! run inside a bundle that has already loaded, so a suite made of them is green +//! over exactly the modules the loader would have refused. +//! +//! `.claude/rules/policy-modules.md` records both live instances of that class +//! being found by adding this tier rather than by reading, which is why it is +//! this file and not a fixture module that carries the assertions. +//! +//! # Every case here reddens exactly one predicate +//! +//! Each fixture differs from the conforming one by a single edit — the retired +//! key, a composed token, a missing row, an unraised row, a tombstone still +//! raised, a collision with a vendored class. The conforming case is asserted +//! too, because a loader that refused everything would satisfy every negative +//! case above it. + +// Panicking on setup failure is the idiomatic way for a test to fail loudly. +#![allow(clippy::unwrap_used, clippy::expect_used)] + +mod common; + +use std::fs; +use std::path::{Path, PathBuf}; + +use batten::facts::Look; +use batten::policy::{self, Vocabulary}; +use batten::rules::Rule; +use batten::verdict::{self, DeclaredVerdict, Subject}; + +/// A mediated-call policy row naming `module`. +fn row(id: &str, module: &str) -> Rule { + serde_json::from_value(serde_json::json!({ + "id": id, + "kind": "policy", + "scope": "mediated_call", + "module": module, + "severity": "deny", + })) + .expect("a policy row the loader accepts") +} + +/// Write `source` as a module in a fresh scratch tree and return both. +fn tree(name: &str, source: &str) -> (PathBuf, String) { + let root = common::scratch(&format!("verdict-registry-{name}")); + fs::write(root.join("gate.rego"), source).expect("write module"); + (root, "gate.rego".to_owned()) +} + +/// Load `source` against `verdicts`, returning whatever the loader answered. +fn load( + name: &str, + source: &str, + verdicts: &[DeclaredVerdict], +) -> anyhow::Result> { + let (root, module) = tree(name, source); + policy::load( + &root, + &[row("gate", &module)], + Vocabulary { + patterns: &[], + verdicts, + }, + policy::ModuleChecks::Run, + None, + ) +} + +/// The conforming module: a token, and a tagged pointer. +const CONFORMING: &str = r#" +package batten + +import rego.v1 + +rules contains "a-gate" + +violation contains { + "rule": "a-gate", + "verdict": "V-FIXTURE-CLASS", + "subjects": [{"path": "a.rs", "line": 7}], +} if { + input.call.operation == "write" +} +"#; + +/// The registry the conforming module needs. +fn declared() -> Vec { + common::verdicts(&["V-FIXTURE-CLASS"]) +} + +// --------------------------------------------------------------------------- +// The positive arm. Without it every refusal below is satisfied by a loader +// that refuses everything. +// --------------------------------------------------------------------------- + +#[test] +fn a_conforming_module_loads_and_denies_with_its_token_and_pointer() { + let bundles = load("conforming", CONFORMING, &declared()).expect("a conforming module loads"); + let input = r#"{"call": {"operation": "write"}}"#; + let Look::Is(denials) = policy::deny(&bundles[0], input) else { + panic!("the bundle answered could-not-look over a document it can read"); + }; + assert_eq!(denials.len(), 1); + assert_eq!(denials[0].verdict, "V-FIXTURE-CLASS"); + assert_eq!( + denials[0].subjects, + vec![Subject::Line { + path: "a.rs".to_owned(), + line: 7 + }], + "the tagged pointer survives the decoder with its line intact" + ); +} + +// --------------------------------------------------------------------------- +// The retired key. +// --------------------------------------------------------------------------- + +/// **The migration's own gate.** A module still speaking the old ABI would +/// otherwise load clean, evaluate clean and report nothing, because the decoder +/// no longer reads `msg` — a dead gate and a clean tree being byte-identical is +/// the defect `.claude/rules/policy-modules.md` opens on. +#[test] +fn a_module_still_binding_msg_is_refused_and_the_refusal_names_the_key() { + let source = CONFORMING.replace( + r#""verdict": "V-FIXTURE-CLASS","#, + r#""msg": "some prose the engine cannot check","#, + ); + let err = + load("retired-key", &source, &declared()).expect_err("the retired key is refused at load"); + let text = format!("{err}"); + assert!(text.contains("msg"), "the refusal names the key: {text}"); + assert!( + text.contains("gate.rego"), + "and points at the module: {text}" + ); +} + +// --------------------------------------------------------------------------- +// Registry equality, both directions. +// --------------------------------------------------------------------------- + +#[test] +fn a_token_no_row_declares_is_refused() { + let err = load("undeclared", CONFORMING, &[]) + .expect_err("a class with no declaration carries no gloss and no route"); + assert!(format!("{err}").contains("V-FIXTURE-CLASS")); +} + +/// The other direction, and the one a reviewer would not think to ask for: a +/// class no gate reaches reads as coverage in `explain` while its routes have +/// never been walked by anybody. +#[test] +fn a_declared_row_nothing_raises_is_refused() { + let mut table = declared(); + table.extend(common::verdicts(&["V-NOBODY-RAISES-THIS"])); + let err = load("unemitted", CONFORMING, &table).expect_err("dead vocabulary is refused"); + assert!(format!("{err}").contains("V-NOBODY-RAISES-THIS")); +} + +// --------------------------------------------------------------------------- +// A token has to be a NAME. +// --------------------------------------------------------------------------- + +#[test] +fn a_composed_verdict_is_refused() { + let source = CONFORMING.replace( + r#""verdict": "V-FIXTURE-CLASS","#, + r#""verdict": sprintf("V-%s", ["FIXTURE-CLASS"]),"#, + ); + let err = load("composed", &source, &declared()) + .expect_err("a token a reader cannot look up is not a token"); + assert!(format!("{err}").contains("gate.rego")); +} + +// --------------------------------------------------------------------------- +// Tombstones. +// --------------------------------------------------------------------------- + +#[test] +fn a_tombstoned_token_that_is_still_raised_is_refused() { + let mut table = declared(); + table[0].successor = Some("V-THE-LIVE-ONE".to_owned()); + table.extend(common::verdicts(&["V-THE-LIVE-ONE"])); + let err = load("tombstoned", CONFORMING, &table) + .expect_err("a tombstone exists so a historical token stays explainable"); + let text = format!("{err}"); + assert!(text.contains("V-FIXTURE-CLASS"), "{text}"); + assert!(text.contains("RETIRED"), "{text}"); +} + +/// A tombstone whose successor is live resolves, and the token the reader asked +/// for is reported as retired rather than silently swapped. +#[test] +fn a_tombstone_resolves_through_its_chain() { + let mut table = common::verdicts(&["V-OLD", "V-NEW"]); + table[0].successor = Some("V-NEW".to_owned()); + verdict::validate(&table).expect("a terminating chain is well formed"); + let (resolved, retired) = verdict::resolve(&table, "V-OLD").expect("the token resolves"); + assert_eq!(resolved.id, "V-NEW"); + assert!(retired); +} + +// --------------------------------------------------------------------------- +// The vendored half. +// --------------------------------------------------------------------------- + +/// A consumer redefining a class the binary ships would render a preset's +/// refusal under words its author never wrote. +#[test] +fn a_consumer_row_colliding_with_a_vendored_class_is_refused() { + let mut table = declared(); + table.extend(common::verdicts(&["V-EMPTY-COMMIT"])); + let err = load("collision", CONFORMING, &table) + .expect_err("a class with two definitions is refused rather than resolved"); + assert!(format!("{err}").contains("V-EMPTY-COMMIT")); +} + +/// **A preset loads against a registry the consumer never wrote.** Holding it to +/// their table would make an enabled preset unloadable with no fix available, +/// which is the wrongly-refusing gate AGENTS.md calls a defect. +#[test] +fn a_vendored_preset_loads_with_no_consumer_rows_at_all() { + let root = common::scratch("verdict-registry-preset"); + let preset: Rule = serde_json::from_value(serde_json::json!({ + "id": "hygiene", + "kind": "policy", + "scope": "mediated_call", + "preset": "commit-hygiene", + "severity": "deny", + })) + .expect("a preset row the loader accepts"); + let bundles = policy::load( + &root, + &[preset], + Vocabulary::EMPTY, + policy::ModuleChecks::Run, + None, + ) + .expect("a vendored preset ships its own vocabulary"); + let Look::Is(denials) = policy::deny( + &bundles[0], + r#"{"call": {"command": "git commit --allow-empty -m x"}}"#, + ) else { + panic!("the preset answered could-not-look"); + }; + assert_eq!(denials.len(), 1); + assert_eq!(denials[0].verdict, "V-EMPTY-COMMIT"); +} + +// --------------------------------------------------------------------------- +// The bare-string channel is held to the same registry. +// --------------------------------------------------------------------------- + +const BARE_DENY: &str = r#" +package batten + +import rego.v1 + +deny contains "V-FIXTURE-CLASS" if { + input.call.operation == "write" +} +"#; + +#[test] +fn a_bare_deny_member_is_a_token_and_is_declared() { + let bundles = load("bare-deny", BARE_DENY, &declared()).expect("a declared token loads"); + let Look::Is(denials) = policy::deny(&bundles[0], r#"{"call": {"operation": "write"}}"#) else { + panic!("could-not-look"); + }; + assert_eq!(denials[0].verdict, "V-FIXTURE-CLASS"); + assert_eq!(denials[0].rule, None, "a bare member names no predicate"); +} + +#[test] +fn a_bare_deny_member_no_row_declares_is_refused() { + let err = load("bare-deny-undeclared", BARE_DENY, &[]) + .expect_err("the string channel does not reopen the free-string hole"); + assert!(format!("{err}").contains("V-FIXTURE-CLASS")); +} + +// --------------------------------------------------------------------------- +// Route resolution, over the tree surface. +// --------------------------------------------------------------------------- + +/// The committed module, copied into the fixture rather than restated: an +/// inline copy would drift from the shipped one and pass while the real gate +/// was broken. +fn install_routes_module(root: &Path) { + let source = common::at_root("policy/verdict-routes-resolve.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/verdict-routes-resolve.rego")) + .expect("install committed module"); +} + +/// Run the committed route-resolution row over a scratch tree carrying +/// `authority` as its `batten.toml` and `manifest` as its `mise.toml`. +fn route_findings(name: &str, authority: &str, manifest: &str) -> Vec { + let root = common::scratch(&format!("verdict-routes-{name}")); + install_routes_module(&root); + fs::write(root.join("batten.toml"), authority).expect("write authority"); + fs::write(root.join("mise.toml"), manifest).expect("write manifest"); + common::git_in(&root, &["init", "--initial-branch=main"]); + common::git_in(&root, &["add", "-A"]); + common::git_in(&root, &["commit", "-q", "-m", "fixture"]); + let routes_row: Rule = serde_json::from_value(serde_json::json!({ + "id": "verdict-routes-resolve", + "kind": "policy", + "scope": "tree", + "sources": ["batten.toml", "mise.toml"], + "module": "policy/verdict-routes-resolve.rego", + "severity": "deny", + })) + .expect("the row batten.toml declares"); + // The vocabulary the committed module needs, read off the module itself. + // Derived rather than listed: this fixture copies the COMMITTED module in so + // it cannot drift, and a hand-written table beside it would. + let verdicts = common::verdicts_in(&root); + batten::rules::run_static( + &[routes_row], + &[], + Vocabulary { + patterns: &[], + verdicts: &verdicts, + }, + &root, + ) + .expect("the read surface runs a policy row") + .findings + .into_iter() + .map(|finding| finding.rule) + .collect() +} + +/// The authority a route fixture declares, with one route of `kind` at `target`. +fn authority_with(kind: &str, target: &str) -> String { + format!( + "version = 1\n\n\ + [[verdict]]\n\ + id = \"V-X\"\n\ + gloss = \"a class\"\n\ + class = \"what it means\"\n\n\ + [[verdict.route]]\n\ + id = \"R-X\"\n\ + kind = \"{kind}\"\n\ + target = \"{target}\"\n" + ) +} + +const MANIFEST: &str = "[tasks.present]\nrun = \"true\"\n"; + +#[test] +fn a_command_route_naming_an_undefined_task_is_refused_over_the_engine() { + assert_eq!( + route_findings( + "undefined", + &authority_with("command", "mise run absent"), + MANIFEST + ), + vec!["verdict-routes-resolve".to_owned()] + ); +} + +#[test] +fn a_command_route_naming_a_defined_task_is_clean_over_the_engine() { + assert!( + route_findings( + "defined", + &authority_with("command", "mise run present"), + MANIFEST + ) + .is_empty() + ); +} diff --git a/hk.pkl b/hk.pkl index 423ac1a5e..4d4b0cfb5 100644 --- a/hk.pkl +++ b/hk.pkl @@ -436,6 +436,7 @@ local gate = new Mapping { "crates/batten/src/severity.rs", "crates/batten/src/transcript.rs", "crates/batten/src/verbs.rs", + "crates/batten/src/verdict.rs", "crates/batten/src/waiver.rs", "schema/*", ) diff --git a/man/batten-policy-explain.1 b/man/batten-policy-explain.1 new file mode 100644 index 000000000..233c9bbe8 --- /dev/null +++ b/man/batten-policy-explain.1 @@ -0,0 +1,19 @@ +.ie \n(.g .ds Aq \(aq +.el .ds Aq ' +.TH batten-policy-explain 1 batten +.SH NAME +batten\-policy\-explain \- Resolve a verdict token to its class definition and the routes out of it +.SH SYNOPSIS +\fBbatten policy explain\fR [\fB\-J\fR|\fB\-\-json\fR] [\fB\-h\fR|\fB\-\-help\fR] <\fItoken\fR> +.SH DESCRIPTION +Resolve a verdict token to its class definition and the routes out of it +.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 +.TP +<\fItoken\fR> +The verdict token to resolve, e.g. V\-TASK\-UNDEFINED diff --git a/man/batten-policy.1 b/man/batten-policy.1 index f15e321b5..8e262cd5c 100644 --- a/man/batten-policy.1 +++ b/man/batten-policy.1 @@ -19,8 +19,8 @@ Judge the always\-loaded instruction set against its declared token budget batten\-policy\-test(1) Run each registered module\*(Aqs own `test_` rules and report the predicates none exercised .TP -batten\-policy\-tools(1) -Print the tool names the mediated\-call rows decide, one per line +batten\-policy\-explain(1) +Resolve a verdict token to its class definition and the routes out of it .TP batten\-policy\-help(1) Print this message or the help of the given subcommand(s) diff --git a/policy/ancestry-decides-nothing.rego b/policy/ancestry-decides-nothing.rego index db3da0368..f87d3c273 100644 --- a/policy/ancestry-decides-nothing.rego +++ b/policy/ancestry-decides-nothing.rego @@ -58,10 +58,10 @@ reachability_answers := { violation contains { "rule": "ancestry-decides-nothing", - "msg": sprintf( - "%s:%d passes '%s' in command position; merged-ness is decided by patch identity, never by reachability (CLOUD-36) — a rebased landing is invisible to ancestry", - [path, site.line, token], - ), + # The site first, then the token that gave it away: the fix is at the line, + # and the token is what a reader searches for once there. + "verdict": "V-ANCESTRY-DECIDES-MERGEDNESS", + "subjects": [{"path": path, "line": site.line}, {"artifact": token}], } if { some path, sites in input.tree.invocations some site in sites diff --git a/policy/command-task-defined.rego b/policy/command-task-defined.rego index e4f233892..ade4f22db 100644 --- a/policy/command-task-defined.rego +++ b/policy/command-task-defined.rego @@ -119,10 +119,9 @@ mise_task(command) := task if { violation contains { "rule": "command-task-defined", - "msg": sprintf( - "batten.toml's `%s` row runs task `%s`, which this tree does not define — the runner is on PATH so the row will START, exit non-zero, and report a finding at its own severity about a tree it never inspected", - [row.id, row.task], - ), + # The row first, then the task it names: the fix is on the row. + "verdict": "V-TASK-UNDEFINED", + "subjects": [{"artifact": row.id}, {"artifact": row.task}], } if { # ONLY WHERE A TASK SOURCE WAS FOUND. Without this guard the rule reproduces # the very defect it exists to fix: pointed at a tree with no task source — @@ -143,7 +142,8 @@ violation contains { # resolves. violation contains { "rule": "command-task-defined", - "msg": sprintf("%s could not be parsed, so no `command` row's task could be resolved", [path]), + "verdict": "V-AUTHORITY-UNPARSED", + "subjects": [{"path": path}], } if { # THE AUTHORITY ONLY. `mise.toml` is declared as a source and lands in # `missing` whenever a tree simply does not have one — which is every fixture diff --git a/policy/module-layering.rego b/policy/module-layering.rego index 1004ec8ba..c97beb873 100644 --- a/policy/module-layering.rego +++ b/policy/module-layering.rego @@ -59,7 +59,7 @@ rules contains "module-layering" # Every module this table has placed. A module in the judged set and absent here # is refused rather than allowed. declared_modules := { - "action", "attribution", "baseline", "budget", "bypass", "capture", "ci", + "action", "admission", "attribution", "baseline", "budget", "bypass", "capture", "ci", "cli", "commit", "completion", "config", "contract", "decision", "defects", "design", "doctor", "drain", "effect", "emission", "epoch", "error", "exec", "exit", "facts", "findings", "git", "handler", "hook", "identity", "init", @@ -67,7 +67,7 @@ declared_modules := { "output", "pattern", "policy", "provision", "receipt", "redirect", "refusal", "render", "resolve", "rules", "secrets", "session", "severity", "sink", "spec", "state", "stop", "store", "surface", "transcript", "trust", "uses", - "verbs", "waiver", "worktree", + "verbs", "verdict", "waiver", "worktree", # `brief`, `main` and `selfwrite` were absent from the first draft of this # table, and the coverage rule caught all three on its first run against the # real tree — which is the property working before any human read it. `main` @@ -136,10 +136,8 @@ module_of(path) := name if { # that produced them stays on the engine's side. violation contains { "rule": "module-layering", - "msg": sprintf( - "%s:%d %s must not reach %s — a layering this tree documents and nothing enforced", - [path, edge.line, module_of(path), edge.to], - ), + "verdict": "V-LAYERING-EDGE-FORBIDDEN", + "subjects": [{"path": path, "line": edge.line}, {"artifact": edge.to}], } if { some path, edges in input.tree.uses some edge in edges @@ -155,10 +153,8 @@ violation contains { # close, so it is a finding rather than silence. violation contains { "rule": "module-layering", - "msg": sprintf( - "%s is judged by the layering rule and absent from its table; place it or narrow the row's selector", - [path], - ), + "verdict": "V-LAYER-UNPLACED", + "subjects": [{"path": path}], } if { some path, _ in input.tree.uses not declared_modules[module_of(path)] @@ -170,7 +166,7 @@ violation contains { # can be switched off by deletion without anything going red. violation contains { "rule": "module-layering", - "msg": "the layer table forbids no edge, so this rule decides nothing — a gate that cannot refuse is off", + "verdict": "V-LAYER-TABLE-DECIDES-NOTHING", } if { count(forbidden) == 0 } @@ -281,7 +277,12 @@ test_an_unplaced_module_is_refused_rather_than_allowed if { "crates/batten/src/brand_new.rs", [internal("error", 3)], ) - contains(v.msg, "absent from its table") + + # The TOKEN, not a substring of prose (CLOUD-1050). The predicate a test + # pins is the class it raises, and a token is what makes that assertion + # exact — a `contains` over a message passed for any rewording that kept + # three words, and failed for any that did not. + v.verdict == "V-LAYER-UNPLACED" } # A selector that matched nothing reaches this module as an empty set and it says diff --git a/policy/opa-compliance.rego b/policy/opa-compliance.rego index 6c35cea9b..0f47ec1c9 100644 --- a/policy/opa-compliance.rego +++ b/policy/opa-compliance.rego @@ -59,7 +59,8 @@ rules contains "opa-tracks-regorus-compliance" # read — a vacuous pass, indistinguishable from a real one. violation contains { "rule": "opa-tracks-regorus-compliance", - "msg": sprintf("%s could not be parsed, so the pinned checker was never judged against the shipped evaluator", [path]), + "verdict": "V-COMPLIANCE-SOURCE-UNPARSED", + "subjects": [{"path": path}], } if { some path in input.tree.missing judged(path) @@ -68,10 +69,8 @@ violation contains { # The checker and the evaluator naming different OPA release lines. violation contains { "rule": "opa-tracks-regorus-compliance", - "msg": sprintf( - "mise.toml pins opa %s but records regorus as compliant with OPA v%s — a rule type checked by one and evaluated by the other is a false green", - [pin, declared], - ), + "verdict": "V-CHECKER-AHEAD-OF-EVALUATOR", + "subjects": [{"artifact": pin}, {"artifact": declared}], } if { pin := opa_pin declared := declared_level @@ -83,10 +82,8 @@ violation contains { # and that is the point. violation contains { "rule": "opa-tracks-regorus-compliance", - "msg": sprintf( - "the recorded OPA compliance level was read against regorus %s but Cargo.toml pins %s — re-read upstream's declared level and move both, or the pin tracks a claim nobody has checked", - [recorded_for, regorus_pin], - ), + "verdict": "V-COMPLIANCE-CLAIM-STALE", + "subjects": [{"artifact": recorded_for}, {"artifact": regorus_pin}], } if { recorded_for := compliance_for version_line(recorded_for) != version_line(regorus_pin) @@ -148,7 +145,8 @@ in_this_workspace if input.tree.documents["Cargo.toml"] # name the caller's parse failure as four separate findings. violation contains { "rule": "opa-tracks-regorus-compliance", - "msg": "mise.toml parses but declares no `opa` pin, so the checker and the evaluator cannot be compared — this is could-not-look, not agreement", + "verdict": "V-COMPLIANCE-DECLARATION-ABSENT", + "subjects": [{"artifact": "opa"}], } if { in_this_workspace input.tree.documents["mise.toml"] @@ -157,7 +155,8 @@ violation contains { violation contains { "rule": "opa-tracks-regorus-compliance", - "msg": "mise.toml parses but records no `REGORUS_OPA_COMPLIANCE`, so there is no declared level to hold the pin to", + "verdict": "V-COMPLIANCE-DECLARATION-ABSENT", + "subjects": [{"artifact": "REGORUS_OPA_COMPLIANCE"}], } if { in_this_workspace input.tree.documents["mise.toml"] @@ -166,7 +165,8 @@ violation contains { violation contains { "rule": "opa-tracks-regorus-compliance", - "msg": "mise.toml parses but records no `REGORUS_OPA_COMPLIANCE_FOR`, so nothing ties the recorded level to the regorus line it was read against", + "verdict": "V-COMPLIANCE-DECLARATION-ABSENT", + "subjects": [{"artifact": "REGORUS_OPA_COMPLIANCE_FOR"}], } if { in_this_workspace input.tree.documents["mise.toml"] @@ -175,7 +175,8 @@ violation contains { violation contains { "rule": "opa-tracks-regorus-compliance", - "msg": "Cargo.toml parses but declares no readable `regorus` version, so the recorded claim cannot be checked against it", + "verdict": "V-COMPLIANCE-DECLARATION-ABSENT", + "subjects": [{"artifact": "regorus"}], } if { in_this_workspace not regorus_pin @@ -187,7 +188,8 @@ violation contains { # one level in. `"1"` against a declared `1.2.0` was measured passing. violation contains { "rule": "opa-tracks-regorus-compliance", - "msg": sprintf("%s in %s is not a MAJOR.MINOR version, so no comparison it feeds can be trusted", [entry.key, entry.owner]), + "verdict": "V-VERSION-UNREADABLE", + "subjects": [{"artifact": entry.key}, {"artifact": entry.owner}], } if { in_this_workspace some entry in required diff --git a/policy/privileged-lane.rego b/policy/privileged-lane.rego index 449b895aa..850dc863c 100644 --- a/policy/privileged-lane.rego +++ b/policy/privileged-lane.rego @@ -68,7 +68,8 @@ rules contains "privileged-lane-tests-origin" # indistinguishable from a real one. violation contains { "rule": "privileged-lane-tests-origin", - "msg": sprintf("%s could not be parsed, so its lanes were never judged", [path]), + "verdict": "V-WORKFLOW-UNPARSED", + "subjects": [{"path": path}], } if { some path in input.tree.missing is_workflow(path) @@ -77,10 +78,8 @@ violation contains { # The finding itself: a subject job that never mentions the head's origin. violation contains { "rule": "privileged-lane-tests-origin", - "msg": sprintf( - "%s job `%s` can be reached by an outside author and holds contents:write, but tests no head origin", - [path, job], - ), + "verdict": "V-PRIVILEGED-LANE-UNTESTED-ORIGIN", + "subjects": [{"path": path}, {"artifact": job}], } if { some path, doc in input.tree.documents is_workflow(path) diff --git a/policy/remedy-authorship.rego b/policy/remedy-authorship.rego index aeaaa4f9c..d90f7d06b 100644 --- a/policy/remedy-authorship.rego +++ b/policy/remedy-authorship.rego @@ -93,10 +93,8 @@ rules contains "remedy-has-one-author" violation contains { "rule": "remedy-reaches-the-reader", - "msg": sprintf( - "%s:%d writes to stderr inside a `>&2` block without the `::error::` prefix, so `land` and every reader following this tree's filter convention DROP it. A refusal line without the prefix is absent, not quiet — measured, a reader received the diagnosis and a paraphrase and never received the route (CLOUD-1050 defect A). Prefix it, as `board-payloads` does for every line of its own recipe block", - [path, i + 1], - ), + "verdict": "V-REMEDY-DROPPED-BY-THE-FILTER", + "subjects": [{"path": path, "line": i + 1}], } if { some path, block in stderr_block some i, line in block @@ -182,10 +180,8 @@ emits_a_literal(line) if { violation contains { "rule": "remedy-has-one-author", - "msg": sprintf( - "the `%s` task body names `%s`, a bypass it does not itself read, so this caller is a SECOND authority for another gate's remedy and a second authority drifts. Measured: a caller's copy dropped one route entirely and turned the override's precondition into bookkeeping, leaving a binary whose only concrete arm was the bypass (CLOUD-1050 defect B, CLOUD-680). State no routes and point at the gate — `linear-check`'s \"see its message\" is the idiom, and it cannot drift because it carries none", - [name, var], - ), + "verdict": "V-REMEDY-HAS-TWO-AUTHORS", + "subjects": [{"artifact": name}, {"artifact": var}], } if { some name, body in task_bodies some var in bypass_names(body) diff --git a/policy/run-shape.rego b/policy/run-shape.rego index 4da831d39..58deeda58 100644 --- a/policy/run-shape.rego +++ b/policy/run-shape.rego @@ -46,7 +46,7 @@ rules contains "commit-names-no-message-source" violation contains { "rule": "commit-names-no-message-source", - "msg": "this `git commit` names no message source — no `-m`, `-F`, `-C`, `--no-edit`, `--fixup` or `--squash` — so git opens $EDITOR and blocks there, after `pre-commit` has already spent the whole gate (~4 minutes measured, CLOUD-488). Write the message to a file and use `git commit -F `, the one form that cannot rebind", + "verdict": "V-COMMIT-WITHOUT-A-MESSAGE-SOURCE", } if { # THE CHEAP TERM FIRST, and it is load-bearing rather than tidy. Everything # below — the heredoc scan, both quote passes, the list and pipe splits — is diff --git a/policy/shell-retirement.rego b/policy/shell-retirement.rego index 9432ebbbe..a32e161ee 100644 --- a/policy/shell-retirement.rego +++ b/policy/shell-retirement.rego @@ -140,10 +140,8 @@ governed_when_deleted(path) if is_bats(path) violation contains { "rule": "shell-rule-retired", - "msg": sprintf( - "%s is an authored shell rule or bats suite ADDED by this change. CLOUD-843's campaign is retiring this corpus onto the policy engine, so a new one moves the count the wrong way and nothing else in the tree refuses it: `bash-surface-not-growing` admits it with a `# stays-bash:` declaration and no gate asks whether a Rego surface could have carried it. Write the predicate as a policy module with a compiled-binary test, or declare it stays bash on its own row", - [path], - ), + "verdict": "V-SHELL-RULE-ADDED", + "subjects": [{"path": path}], } if { some path in delta.added governed_at_head(path) @@ -155,10 +153,8 @@ violation contains { violation contains { "rule": "shell-rule-retired", - "msg": sprintf( - "%s is an authored shell rule or bats suite this change EDITED IN PLACE. A migration replaces a shell gate, it does not maintain one -- and an edit is invisible to every other sensor here, because `bash-surface-not-growing` counts programs and `bats-tests-not-deleted` counts cases, and an edit changes neither. Port the predicate to a policy module or Rust surface, delete this file, and record the successor on a `// carried:`, `// subsumed:` or `// changed:` row", - [path], - ), + "verdict": "V-SHELL-RULE-EDITED", + "subjects": [{"path": path}], } if { some path in delta.edited governed_at_head(path) @@ -170,10 +166,8 @@ violation contains { violation contains { "rule": "shell-rule-retired", - "msg": sprintf( - "%s was DELETED by this change and the retirement ledger records no successor for it, so nothing in the tree names what now holds its predicate. That is CLOUD-908's defect at file granularity: the deletion is admitted and the logic evaporates. Add exactly one `// carried:`, `// subsumed:` or `// changed:` row naming this path under `crates/batten/tests/*.rs`", - [path], - ), + "verdict": "V-RETIREMENT-UNMAPPED", + "subjects": [{"path": path}], } if { some path in delta.deleted governed_when_deleted(path) @@ -182,10 +176,8 @@ violation contains { violation contains { "rule": "shell-rule-retired", - "msg": sprintf( - "%s was deleted and carries MORE THAN ONE retirement arm. `carried`, `subsumed` and `changed` are three different claims about where the logic went -- preserved, absorbed by a general property, or deliberately diverged -- and a path asserting two of them has recorded no answer at all. Keep the one that is true", - [path], - ), + "verdict": "V-RETIREMENT-AMBIGUOUS", + "subjects": [{"path": path}], } if { some path in delta.deleted governed_when_deleted(path) @@ -194,10 +186,8 @@ violation contains { violation contains { "rule": "shell-rule-retired", - "msg": sprintf( - "%s was deleted and its retirement arm names no POLICY SURFACE -- no `policy/*.rego` and no `crates/batten/src/*.rs`. A mapping that names only a test records where the assertions went and not where the predicate went, so the gate it replaced has no successor anything can run", - [path], - ), + "verdict": "V-SUCCESSOR-NO-SURFACE", + "subjects": [{"path": path}], } if { some path in delta.deleted governed_when_deleted(path) @@ -207,10 +197,8 @@ violation contains { violation contains { "rule": "shell-rule-retired", - "msg": sprintf( - "%s was deleted and its retirement arm names no COMPILED-BINARY TEST under `crates/batten/tests/*.rs`. A module's own `test_` rules are the load-time tier and they fabricate their own input, so they cannot prove the engine builds the shape the predicate reads -- which is the tier `.claude/rules/policy-modules.md` records both live instances of that defect being found by", - [path], - ), + "verdict": "V-SUCCESSOR-NO-TEST", + "subjects": [{"path": path}], } if { some path in delta.deleted governed_when_deleted(path) diff --git a/policy/verdict-routes-resolve.rego b/policy/verdict-routes-resolve.rego new file mode 100644 index 000000000..e57770fc1 --- /dev/null +++ b/policy/verdict-routes-resolve.rego @@ -0,0 +1,250 @@ +# Every route a declared verdict offers resolves to something (CLOUD-1050). +# +# The registry's other half. `verdict::validate` decides the SHAPE of a route — +# a `R-` prefix, a kind from the closed set, an override that states a +# precondition, a class that is not an override alone — and it decides all of it +# without a tree, which is why it lives at parse. What it cannot decide is +# whether the route points at anything, because that is a question about THIS +# tree. +# +# WHY THIS IS A MODULE AND NOT A LINE OF RUST. The load-bearing half is a +# `command` route naming a task, and `mise run ` is a task RUNNER's +# spelling. Non-negotiable rule 1 keeps the core ignorant of one — the engine +# knows formats, and which file carries which is the consumer's `batten.toml`. +# CLOUD-614 hit exactly this and the tree said so: implementing the sibling +# clause in `crates/batten` was refused by `no_artifact_name_reaches_the_core`. +# So the clause belongs where the runner is known, which is here, and +# `policy/command-task-defined.rego` is the same predicate over `[[rule]]` rows. +# +# WHY IT MATTERS MORE HERE THAN THERE. A `command` ROW naming a missing task +# fails loudly the first time it runs. A ROUTE naming one fails at the worst +# possible moment: a reader has just been refused, has been handed the one thing +# to run, runs it, and is told the task does not exist. That is CLOUD-122's bare +# no with an extra step, and it is the defect CLOUD-680 measured — an override +# hiding among four costumes while the human audited four mechanisms to find the +# one decision. +# +# WHAT IT DOES NOT DECIDE, stated rather than discovered. That the task, once +# run, actually clears the refusal is not checkable here and would be the model +# verdict rule 3 forbids. And a route naming a program on PATH is left alone, for +# `command-task-defined`'s reason: only the `mise run` shape is claimed. +# +# ─── THE `document` ARM WAS WRITTEN, MEASURED AND DROPPED ──────────────────── +# +# It refused a `document` route whose target no tracked path matched, and it is +# not decidable from a PARTIAL tree. `command-task-defined`'s marker — +# "`mise.toml` is a parsed document, so this tree uses this runner" — does not +# separate the two populations here: a fixture that copies this repository's +# `batten.toml` AND its `mise.toml` into a scratch directory satisfies it while +# carrying none of the files the routes name. Measured on three cases of +# `tests/prebuilt-lint.bats`, six findings each, against trees whose routes are +# not broken at all. +# +# No honest marker separates them. "Is this the tree that authored the config" is +# the question, and every candidate answer is either consumer-specific (rule 1) +# or satisfied by the fixture too. A gate whose first firing is a false positive +# gets an exception written for it, and the exception is what rots — so the arm +# is absent rather than exempted. What survives is the arm that matters most: a +# `command` route naming a missing task fails at the worst possible moment, and +# it IS decidable, by the marker CLOUD-614 already validated. +# +#MUTANT route-task-unchecked|s@not defined[entry.task]@false@|a command route naming an undefined task is refused +# +#MUTANT-EXEMPT CLOUD-931|no `tests/verdict-routes-resolve.bats` exists and none may: this row is part of the change that retires bats suites onto the engine, so a suite named for it would be the thing CLOUD-1059 refuses. The second tier is `crates/batten/tests/verdict_registry.rs`, which drives the compiled binary + +# METADATA +# description: | +# Bound to the TREE surface: `scope = "tree"`, so it reads the tree document +# and never the mediated `{call, facts}` shape. +# THE BRACKETS ARE NOT STYLE: the schema file carries a hyphen, so the dotted +# form is a parse error reported as `invalid schema reference`. +# THIS BLOCK IS YAML AND MUST STAY THE LAST COMMENT BLOCK BEFORE `package`. +# schemas: +# - input: schema["policy-input.schema"] +package batten.verdict_routes + +import rego.v1 + +rules contains "verdict-routes-resolve" + +# --------------------------------------------------------------------------- +# The routes, flattened out of the registry. +# --------------------------------------------------------------------------- + +route_rows contains { + "verdict": entry.id, + "route": row.id, + "kind": row.kind, + "target": row.target, +} if { + some entry in input.tree.documents["batten.toml"].verdict + some row in entry.route +} + +# --------------------------------------------------------------------------- +# Tasks this tree defines. The same two sources `command-task-defined` reads, +# and deliberately the same shape: two spellings of "what tasks exist" is the +# drift a shared question does not survive. +# --------------------------------------------------------------------------- + +uses_this_runner if input.tree.documents["mise.toml"] + +defined contains name if { + some name, _ in input.tree.documents["mise.toml"].tasks +} + +defined contains name if { + uses_this_runner + some path in input.tree.tracked + startswith(path, "mise-tasks/") + parts := split(path, "/") + name := parts[count(parts) - 1] +} + +defined contains stem if { + uses_this_runner + some path in input.tree.tracked + startswith(path, "mise-tasks/") + parts := split(path, "/") + name := parts[count(parts) - 1] + contains(name, ".") + stem := split(name, ".")[0] +} + +# The task a `mise run` route names, or nothing for any other program. Narrow on +# purpose: `git cherry` is a route and is not this rule's business. +mise_task(command) := task if { + words := [word | some word in split(command, " "); word != ""] + words[0] == "mise" + words[1] == "run" + rest := [word | some i, word in words; i > 1; not startswith(word, "-")] + task := rest[0] +} + +# --------------------------------------------------------------------------- +# The refusals. +# --------------------------------------------------------------------------- + +violation contains { + "rule": "verdict-routes-resolve", + "verdict": "V-ROUTE-TASK-UNDEFINED", + "subjects": [{"artifact": entry.verdict}, {"artifact": entry.route}, {"artifact": entry.task}], +} if { + # Could-not-look guard, `command-task-defined`'s: with no task namespace + # there is nothing to judge against, and reporting there makes the rule fire + # on every tree that merely holds a copy of this config. + count(defined) > 0 + some row in route_rows + row.kind == "command" + task := mise_task(row.target) + entry := object.union(row, {"task": task}) + not defined[entry.task] +} + +# COULD NOT LOOK, NEVER A SILENT PASS. The authority carries the registry, so a +# tree that could not read it has judged no route at all — which must not be +# spelled the same way as a registry whose every route resolves. +violation contains { + "rule": "verdict-routes-resolve", + "verdict": "V-AUTHORITY-UNPARSED", + "subjects": [{"path": path}], +} if { + some path in input.tree.missing + endswith(path, "batten.toml") +} + +# --- cases --------------------------------------------------------------- + +test_a_command_route_naming_an_undefined_task_is_refused if { + found := violation with input as {"tree": { + "documents": { + "batten.toml": {"verdict": [{ + "id": "V-X", + "route": [{"id": "R-X", "kind": "command", "target": "mise run absent-task"}], + }]}, + "mise.toml": {"tasks": {"present": {}}}, + }, + "tracked": ["mise.toml"], + "missing": [], + }} + count(found) == 1 +} + +test_a_command_route_naming_a_defined_task_is_clean if { + found := violation with input as {"tree": { + "documents": { + "batten.toml": {"verdict": [{ + "id": "V-X", + "route": [{"id": "R-X", "kind": "command", "target": "mise run present"}], + }]}, + "mise.toml": {"tasks": {"present": {}}}, + }, + "tracked": ["mise.toml"], + "missing": [], + }} + count(found) == 0 +} + +# A ROUTE NAMING A PROGRAM ON PATH IS LEFT ALONE — the shape that never had the +# defect, and refusing it would make the one route spelling that works +# everywhere unwritable. +test_a_program_on_path_is_not_judged if { + found := violation with input as {"tree": { + "documents": { + "batten.toml": {"verdict": [{ + "id": "V-X", + "route": [{"id": "R-X", "kind": "command", "target": "git cherry"}], + }]}, + "mise.toml": {"tasks": {"present": {}}}, + }, + "tracked": ["mise.toml"], + "missing": [], + }} + count(found) == 0 +} + +# An override route points at neither a task nor a file, so it is judged by +# neither clause. Its `precondition` is `verdict::validate`'s question. +test_an_override_route_is_not_judged_here if { + found := violation with input as {"tree": { + "documents": { + "batten.toml": {"verdict": [{ + "id": "V-X", + "route": [{"id": "R-ASK", "kind": "override", "target": ""}], + }]}, + "mise.toml": {"tasks": {}}, + }, + "tracked": ["batten.toml"], + "missing": [], + }} + count(found) == 0 +} + +test_an_unreadable_authority_is_loud if { + found := violation with input as {"tree": { + "documents": {}, + "tracked": [], + "missing": ["batten.toml"], + }} + count(found) == 1 +} + +# The anti-vacuity arm: a registry whose every route resolves must be silent, or +# a module that refused nothing would pass every negative case above. +test_a_resolving_registry_is_silent if { + found := violation with input as {"tree": { + "documents": { + "batten.toml": {"verdict": [{ + "id": "V-X", + "route": [ + {"id": "R-RUN", "kind": "command", "target": "mise run present"}, + {"id": "R-READ", "kind": "document", "target": "batten.toml"}, + ], + }]}, + "mise.toml": {"tasks": {"present": {}}}, + }, + "tracked": ["batten.toml", "mise.toml"], + "missing": [], + }} + count(found) == 0 +} diff --git a/policy/workspace-dep-referenced.rego b/policy/workspace-dep-referenced.rego index baa175eab..86911684b 100644 --- a/policy/workspace-dep-referenced.rego +++ b/policy/workspace-dep-referenced.rego @@ -84,10 +84,8 @@ referenced contains key if { # so the message is where the pointer lives. violation contains { "rule": "workspace-dep-referenced", - "msg": sprintf( - "Cargo.toml declares `%s` in [workspace.dependencies] and no member references it with `workspace = true`, so it resolves to nothing and every graph-reading gate is green about a dependency that is not in the tree", - [key], - ), + "verdict": "V-WORKSPACE-DEP-ORPHANED", + "subjects": [{"artifact": key}], } if { some key in declared not referenced[key] @@ -97,10 +95,8 @@ violation contains { # in `missing`, and without this the walk above simply does not see it. violation contains { "rule": "workspace-dep-referenced", - "msg": sprintf( - "%s could not be parsed, so its references were never counted and an orphan cannot be ruled out", - [path], - ), + "verdict": "V-MANIFEST-UNPARSED", + "subjects": [{"path": path}], } if { some path in input.tree.missing endswith(path, "Cargo.toml") @@ -112,7 +108,7 @@ violation contains { # input rather than assumed away. violation contains { "rule": "workspace-dep-referenced", - "msg": "Cargo.toml carries no [workspace.dependencies] table in the judged set, so this rule decided nothing", + "verdict": "V-WORKSPACE-TABLE-ABSENT", } if { count(declared) == 0 } diff --git a/schema/batten.schema.json b/schema/batten.schema.json index ba299a2b2..9a476e152 100644 --- a/schema/batten.schema.json +++ b/schema/batten.schema.json @@ -285,6 +285,13 @@ "$ref": "#/$defs/MutatingVerb" } }, + "verdict": { + "description": "The refusal vocabulary (CLOUD-1050): every verdict a gate may reach for,\nits one-line gloss, its class definition and its closed route list.\n\n**The sole authority for BOTH emitters** — a Rego `violation` and a\nnative `Refusal` name a token out of this one table, which is the\nreversal CLOUD-1050 records of its own first review: a policy-only\nregistry beside a native authority is two vocabularies that disagree the\nfirst time either moves. A module reaches it at\n`data.batten.verdicts[\"\"]`; a token nothing declares is refused at\nload, and a declared token nothing emits is refused the other way, so the\ntable and the emitters cannot drift. The type and its validation are\n[`crate::verdict`].", + "type": "array", + "items": { + "$ref": "#/$defs/DeclaredVerdict" + } + }, "version": { "description": "The config schema version. Must equal [`SUPPORTED_VERSION`].", "type": "integer", @@ -674,6 +681,44 @@ "body" ] }, + "DeclaredVerdict": { + "description": "One declared refusal class.", + "type": "object", + "properties": { + "class": { + "description": "What the class means, at length. `batten policy explain`'s payload, and\nthe **deliberate exception** to pointer-only output (house style §6):\n`explain` is local documentation rather than a finding, and carrying the\ntext the hot path no longer does is its entire purpose.", + "type": "string" + }, + "gloss": { + "description": "One line, the hot path's whole payload.", + "type": "string" + }, + "id": { + "description": "The token, e.g. `V-TASK-UNDEFINED`.", + "type": "string" + }, + "route": { + "description": "The ways out, in the order a reader should consider them.", + "type": "array", + "items": { + "$ref": "#/$defs/Route" + } + }, + "successor": { + "description": "The token that replaced this one, for a class that has been retired.\n\nA **tombstone**: the entry stays so a historical token is still\nexplainable, which git history cannot do at runtime. A tombstone is\nexempt from the emitted-somewhere half of registry equality, because the\nwhole point is that nothing emits it any more.", + "type": [ + "string", + "null" + ] + } + }, + "additionalProperties": false, + "required": [ + "id", + "gloss", + "class" + ] + }, "Defects": { "description": "The `[defects]` table: where the ledger lives and what may be in it.\n\nBoth keys are consumer facts. The crate carries neither a path nor a class\ntoken (non-negotiable rule 1) — a taxonomy is a description of one team's\nfailure modes, not an engine fact.", "type": "object", @@ -1553,6 +1598,61 @@ "path" ] }, + "Route": { + "description": "One way out of a refusal.", + "type": "object", + "properties": { + "id": { + "description": "The id a rendered refusal names, e.g. `R-DEFINE-THE-TASK`.\n\nStable and referenceable: an agent told `R-DEFINE-THE-TASK` twice has\nbeen told the same thing twice, which a paraphrase cannot establish.", + "type": "string" + }, + "kind": { + "description": "Which kind of way out this is.", + "$ref": "#/$defs/RouteKind" + }, + "precondition": { + "description": "What the asker must be able to state. [`RouteKind::Override`] only, and\nrequired there.", + "type": [ + "string", + "null" + ] + }, + "target": { + "description": "What it points at, read according to [`Route::kind`].", + "type": "string" + } + }, + "additionalProperties": false, + "required": [ + "id", + "kind" + ] + }, + "RouteKind": { + "description": "What a route offers the reader.\n\n**A closed set, deliberately.** The whole defect is a remedy nobody can\ncheck; an open `kind` string would put it straight back, because a typo'd\nkind would be a route that resolves to nothing and refuses nothing.", + "oneOf": [ + { + "description": "Run something. `target` is the command.", + "type": "string", + "const": "command" + }, + { + "description": "Read something in this tree. `target` is a repo-relative path.", + "type": "string", + "const": "document" + }, + { + "description": "Take it to the tracker. `target` is the issue key.", + "type": "string", + "const": "issue" + }, + { + "description": "Ask for the refusal to be lifted. `target` is unread; `precondition` is\nwhat the asker must be able to state, and is required (CLOUD-1051 reads\nit to generate the questions an admission answers).", + "type": "string", + "const": "override" + } + ] + }, "Rule": { "description": "One declarative rule from `batten.toml`'s `[[rule]]` array.\n\n`deny_unknown_fields` keeps the surface narrow (§8): a mistyped key is a hard\nerror, never a silently ignored setting that disables a gate. The struct is\nflat rather than an enum with `#[serde(flatten)]` precisely so this guarantee\nholds — `flatten` silently defeats `deny_unknown_fields`.\n`severity` is required by every kind but `judge`, which is refused it\n(CLOUD-445). The field is an `Option` so a judge row can omit it, so the\nderived `required` list cannot carry it — and a schema that merely dropped it\nwould stop flagging the missing key on the four kinds that must have one,\nmoving a check out of the editor with nothing taking its place.\n\nThis conditional puts it back, stated once here and derived into both\npublished schemas. It mirrors [`RuleKind::requires`] and\n[`RuleKind::permits`]; `tests::the_schema_conditional_matches_the_column_census`\nis what keeps the two from drifting.", "type": "object", diff --git a/tests/privileged-lane.bats b/tests/privileged-lane.bats deleted file mode 100644 index a1c930479..000000000 --- a/tests/privileged-lane.bats +++ /dev/null @@ -1,210 +0,0 @@ -#!/usr/bin/env bats -# subject: policy/privileged-lane.rego -# -# The negative-control suite for the tree-scoped privileged-lane gate (CLOUD-931). -# -# WHY THIS EXISTS AT ALL. `policy/privileged-lane.rego` is a live `deny` row over -# this repository's own privileged CI lanes, and until this file it had no test -# outside itself: no bats suite, no Rust test, and an entry in no mutation set. -# Its nine `test_` rules are `with input as` assertions, and CLOUD-845 established -# that those are insufficient evidence on their own — a module can fabricate a -# shape the engine never produces, pass its own suite green, and gate nothing. -# -# So every case here goes in through `batten check`, the same door `verify` and -# the hk gate come through, and reads the verdict a caller would read. The two -# tiers are complementary rather than redundant: the module's own rules pin the -# PREDICATE, this file pins that the ENGINE builds the input the predicate reads. -# -# The case that only this tier can make is `an_unparseable_workflow_denies`. -# `input.tree.missing` is populated by the engine when a declared source will not -# parse; a `with input as` test hands itself that key and proves nothing about -# whether anything ever fills it. -# -# The fixture is a throwaway git repository carrying ONE row and a copy of the -# module under test, so the predicate is exercised in isolation from this -# repository's other rules, and `mise run mutant` reaches it: the copy is taken -# from the suite's own tree, which under `mutant` is the mutated one. - -setup() { - load helpers - - # The same resolution chain `tests/run-shape.bats` uses, and for the reason - # measured there: there is no release build when `test:bats` runs in CI, and a - # shorter chain aborts setup before the skip can fire — turning "no binary - # here" into a wall of red over a gate that was never exercised. - # `batten_binary` rather than a release-first chain: `test:bats` builds DEBUG, - # so a leftover release binary shadowed it and this suite would report on a - # build older than the code under test (CLOUD-859). - BIN=$(batten_binary "$BATS_TEST_DIRNAME/..") || skip "no batten binary to drive" - - MODULE="$BATS_TEST_DIRNAME/../policy/privileged-lane.rego" - REPO="$BATS_TEST_TMPDIR/repo" - mkdir -p "$REPO/policy" "$REPO/.github/workflows" - cp "$MODULE" "$REPO/policy/privileged-lane.rego" - { - echo "version = 1" - echo - echo "[[rule]]" - echo 'id = "privileged-lane-tests-origin"' - echo 'kind = "policy"' - echo 'scope = "tree"' - echo 'sources = [".github/workflows/*.yml"]' - echo 'module = "policy/privileged-lane.rego"' - echo 'severity = "deny"' - } >"$REPO/batten.toml" - # No global or system config: a contributor's own git settings must not be able - # to change a verdict here (CLOUD-282). - GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_SYSTEM=/dev/null git init -q -b main "$REPO" -} - -# Write one workflow into the fixture. Each case supplies its own, so a case -# names exactly the shape it is about. -workflow() { # workflow - printf '%s\n' "$2" >"$REPO/.github/workflows/$1" -} - -check() { - (cd "$REPO" && "$BIN" check) -} - -# The exit contract, asserted by NAME rather than by integer (`.claude/rules/rust.md`): -# 2 is the policy verdict, 0 is clean. The shell tasks' inverted convention must -# not be carried in — a case asserting 1 here would be asserting "unreadable -# input" while meaning "violation", and it would pass. -assert_denied() { - [ "$status" -eq 2 ] || { - echo "expected the policy verdict (2), got $status: $output" >&2 - return 1 - } - [[ "$output" == *privileged-lane-tests-origin* ]] || { - echo "the finding does not name the rule: $output" >&2 - return 1 - } -} - -assert_clean() { - [ "$status" -eq 0 ] || { - echo "expected a clean tree (0), got $status: $output" >&2 - return 1 - } -} - -@test "a bot lane selecting by branch prefix is denied" { - # The defect CLOUD-867 was filed for: the head is chosen by a string the PR - # author picks. Driven through the engine rather than through `policy test`. - workflow auto-bot-land.yml 'on: - workflow_run: - workflows: [ci] -jobs: - land: - permissions: - contents: write - if: startsWith(github.event.workflow_run.head_branch, ${{ '"'"'renovate/'"'"' }}) - steps: - - run: echo land' - run check - assert_denied -} - -@test "the same lane testing the head origin is clean" { - # The discriminating half. Same trigger, same grant, same job — only the origin - # test is added, so a gate that denied both would be proving nothing. - workflow auto-bot-land.yml 'on: - workflow_run: - workflows: [ci] -jobs: - land: - permissions: - contents: write - if: github.event.workflow_run.head_repository.full_name == github.repository - steps: - - run: echo land' - run check - assert_clean -} - -@test "a scheduled writer that resolves no outside head is not a subject" { - # THE FALSE POSITIVE THE THIRD CONJUNCT EXISTS FOR, and the mutation this - # suite declares is aimed at exactly it: `perf.yml` is scheduled, holds - # contents:write to push its own series, and selects no outside head. A gate - # whose first firing is a false positive gets an exception written for it, and - # the exception is what rots. - workflow perf.yml 'on: - schedule: - - cron: "0 0 * * *" - workflow_dispatch: -jobs: - measure: - permissions: - contents: write - steps: - - run: git push origin refs/notes/perf' - run check - assert_clean -} - -@test "an outsider-reachable writer that resolves no outside head is not a subject" { - # THE CASE THAT ACTUALLY DISCRIMINATES THE THIRD CONJUNCT. It exists because - # the first declared mutation SURVIVED, and the survival is what identified a - # false rationale rather than a false predicate. - # - # That rationale named `perf.yml` as what the third conjunct excludes. Measured: - # the real `perf.yml` triggers are `schedule` and `workflow_dispatch`, and - # NEITHER is in `outsider_reachable`'s list — so the FIRST conjunct already - # excludes it and dropping the third changes nothing about it. The module's own - # `test_a_scheduled_writer_with_no_outside_head_is_not_a_subject` has the same - # hole: its input is not outsider-reachable either, so it passes with the third - # conjunct deleted. `privileged-lane.rego`'s header records the correction; this - # comment records how it was found, because reading could not have found it. - # - # A discriminating input is outsider-reachable (`issue_comment`), holds - # `contents: write`, and resolves no outside head — no `pull_request` or - # `workflow_run` trigger and no `/pulls` anywhere. Clean today; a finding the - # moment the third conjunct stops being asked. That is what makes it the case - # the declared mutation names. - workflow triage.yml 'on: - issue_comment: - types: [created] -jobs: - label: - permissions: - contents: write - steps: - - run: echo "labelling from a comment"' - run check - assert_clean -} - -@test "a read-only lane is not a subject" { - workflow ci.yml 'on: - pull_request: -jobs: - gate: - permissions: - contents: read - steps: - - run: echo test' - run check - assert_clean -} - -# THE CASE THIS SUITE DELIBERATELY DOES NOT CARRY, and the reason is a finding -# rather than an omission (CLOUD-1049). -# -# The module's first clause says an unparseable workflow lands in -# `input.tree.missing` and denies, so that "could not read it" never reads as -# "clean". Written as a case here, that is RED: a genuinely invalid workflow — -# `yaml.safe_load` raises `ParserError` on it — produces exit 0, no finding, and -# no cause, even under `--strictness strict`. The same fixture with a parseable -# workflow that fails the predicate denies at 2, so the row is live and selected; -# it is the unparseable path specifically that vanishes. -# -# It is not shipped red, and it is not shipped asserting the current behaviour -# either — that would bake the defect in as the contract and go green forever. -# CLOUD-1049 owns it and names this file as where the case belongs once the -# engine honours what the module already documents. -# -# This is also the clearest evidence for why this tier exists at all: the -# module's own `test_an_unparseable_workflow_denies_rather_than_passing` is -# GREEN, because `with input as` hands itself the populated `missing` the engine -# never builds (CLOUD-845). diff --git a/tests/run-shape.bats b/tests/run-shape.bats deleted file mode 100644 index 33faac439..000000000 --- a/tests/run-shape.bats +++ /dev/null @@ -1,229 +0,0 @@ -#!/usr/bin/env bats -# subject: policy/run-shape.rego -# -# The negative-control suite for the first migrated gate (CLOUD-843 track 2). -# -# IT DRIVES THE COMPILED BINARY OVER A REAL ENVELOPE, and that is the whole -# point rather than a convenience. `batten policy test` is established as -# insufficient evidence (CLOUD-845): `with input as` lets a module's own test -# fabricate a shape the engine cannot produce, so a module can pass its suite -# green and gate nothing. Every case here goes in through `batten hook` — the -# same door a mediated call comes through — and reads the permission decision -# the host would read. -# -# The fixture is a throwaway git repository carrying ONE row and a copy of the -# module under test, so the predicate is exercised in isolation from this -# repository's other rules, and `mise run mutant` reaches it: the copy is taken -# from the suite's own tree, which under `mutant` is the mutated one. - -setup() { - load helpers - - # Resolved the way `payload-field` and `tests/stop-guard.bats` resolve it — - # $BATTEN_BIN, then release, then debug, then PATH — rather than a shorter - # chain of this file's own. Measured on this branch's first CI run: there is - # no release build when `test:bats` runs there, and `BIN="$(command -v - # batten)"` under bats' `set -e` ABORTED setup before the skip below could - # fire, so all 13 cases went red over a binary that was simply somewhere - # else. The debug arm is what keeps these controls running in CI rather than - # skipping there, which for a suite whose whole job is proving the gate - # decides would be coverage evaporating exactly where it counts. - # `batten_binary` rather than a release-first chain: `test:bats` builds DEBUG, - # so a leftover release binary shadowed it and this suite would report on a - # build older than the code under test (CLOUD-859). - BIN=$(batten_binary "$BATS_TEST_DIRNAME/..") || skip "no batten binary to drive" - - MODULE="$BATS_TEST_DIRNAME/../policy/run-shape.rego" - REPO="$BATS_TEST_TMPDIR/repo" - mkdir -p "$REPO/policy" - cp "$MODULE" "$REPO/policy/run-shape.rego" - { - echo "version = 1" - echo - echo "[[rule]]" - echo 'id = "commit-message-obtainable"' - echo 'kind = "policy"' - echo 'scope = "mediated_call"' - echo 'module = "policy/run-shape.rego"' - echo 'severity = "deny"' - # The declared pattern the module references (CLOUD-885). An inline regex - # is refused at load, so this row is not fixture decoration — without it - # the module's reference is undefined and every allow case flips to a - # denial, which is the silent-disarm the engine now refuses outright. - echo - echo "[[pattern]]" - echo 'id = "short-message-flag-cluster"' - echo 'regex = "^-[A-Za-z]*[mFCc]"' - } >"$REPO/batten.toml" - # No global or system config: a contributor's own git settings must not be - # able to change a verdict here (CLOUD-282). - GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_SYSTEM=/dev/null git init -q -b main "$REPO" -} - -# Build the Claude Code PreToolUse envelope and hand it to the engine. Python -# does the JSON quoting because these commands carry quotes, newlines and -# heredocs, which is exactly what the predicate is about. -hook() { # hook - local envelope - envelope=$(python3 -c 'import json,sys; print(json.dumps({"hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":sys.argv[1]}}))' "$1") - (cd "$REPO" && printf '%s' "$envelope" | "$BIN" hook --harness claude-code) -} - -# BOTH HELPERS ASSERT THE EXIT STATUS, and for `allowed` that is the whole -# assertion rather than a belt-and-braces addition. `batten hook` prints NOTHING -# on an allow — the JSON is emitted only to deny, and the exit status is 0 either -# way, because the contract is that the harness reads the decision and not the -# code. So `[[ "$1" != *deny* ]]` over an EMPTY string is true, and every allow -# case in this file went green on any output at all, including the output of a -# binary that died before it judged anything. Measured on this branch: allow and -# deny both exit 0, so a non-zero status is exactly and only the crash. -# -# That is CLOUD-251's vacuous pass wearing a test's clothes, and it was suite- -# wide rather than one case: nothing here referenced `$status` at all. Fixed in -# the helper so all fourteen cases gain it at once. `$status` is the global bats -# `run` sets, so this reads the status of the call the case just made. -denied() { [ "$status" -eq 0 ] && [[ "$1" == *'"permissionDecision":"deny"'* ]]; } -allowed() { [ "$status" -eq 0 ] && [[ "$1" != *'"deny"'* ]]; } - -# --- the predicate ---------------------------------------------------------- - -@test "THE MEASURED SHAPE: a git commit naming no message source is denied" { - # `pre-commit` runs before git asks for a message, so this spends the whole - # gate and then blocks on $EDITOR with nobody to close it (CLOUD-488). - run hook 'git commit' - denied "$output" - run hook 'git commit -a' - denied "$output" -} - -@test "every form that CAN obtain a message stays allowed" { - # The load-bearing half. A predicate that only ever denied would satisfy the - # case above and be useless (CLOUD-418). - local c - for c in 'git commit -F /tmp/msg.txt' \ - 'git commit -m "a message"' \ - 'git commit -am "a message"' \ - 'git commit --amend --no-edit' \ - 'git commit --fixup HEAD' \ - 'git commit -C HEAD@{1}' \ - 'git commit --message=hello' \ - 'git commit -F -'; do - run hook "$c" - allowed "$output" - done -} - -@test "THE MEASURED SHAPE: a token carrying an m is not a flag cluster" { - # CLOUD-885. The rule reads "one `-`, then LETTERS, at least one of which - # selects a message source". Before `regex.match` it was spelled as - # `contains` over the flag's tail, which cannot say "letters" — so `-x=mfoo` - # carried an `m`, read as naming a message source, and a commit that still - # blocks on $EDITOR went through. - # - # This is the discriminating case rather than another `-m`: the suite as it - # stood covered `-m`, `-am`, `-F`, `-C` and the long forms, and every one of - # them passes under BOTH spellings. Reproduced against the old spelling in a - # fixture before this landed, which is the evidence the case exists for. - run hook 'git commit -x=mfoo' - denied "$output" - - # The other direction, so the anchor is proven and not just the class: a - # message flag must be reached from the START of the cluster. `-vm` is one. - run hook 'git commit -vm "a message"' - allowed "$output" -} - -# --- the list, which is where a raw-string module goes silent --------------- - -@test "a compound list is judged per element, not by its first word" { - # THE SHAPE A RAW-STRING MODULE MISSES. The vendored `no-force-push` preset - # anchors on `words[0] == "git"` over the whole command, so `cd /tmp && git - # push --force` reaches it as `cd` and is allowed — green tests, silent - # gate. Every element is a command here. - run hook 'cd /tmp && git commit' - denied "$output" - run hook 'git add -A && git commit -m x' - allowed "$output" -} - -@test "a pipe stage is judged too" { - run hook 'echo hi | git commit' - denied "$output" -} - -@test "a wrapper is looked through to the program it runs" { - run hook 'timeout 300 git commit' - denied "$output" - run hook 'timeout 300 git commit -m x' - allowed "$output" -} - -# --- scrubbing: prose is not a call ---------------------------------------- - -@test "a git commit inside a quoted span is prose, not a call" { - # This repository writes the shape down constantly — in commit messages, in - # issue bodies, in this file. A module judging the raw string would refuse - # its own documentation. - run hook 'echo "git commit"' - allowed "$output" - run hook "echo 'git commit'" - allowed "$output" -} - -@test "a quoted span carrying a list separator is not a list" { - # THE CASE THAT DISCRIMINATES the quote scrub. A quoted mention with no - # separator in it is already safe by the program anchoring above; what needs - # the scrub is a message that carries a `;` or `&&`, because the list split - # would otherwise turn the tail of a commit message into its own command. - # Both quote characters, because they are two passes. - run hook 'echo "step one; git commit -x"' - allowed "$output" - run hook "echo 'step one; git commit -x'" - allowed "$output" -} - -@test "a git commit inside a heredoc body is prose, not a call" { - run hook "$(printf 'cat > t.bats <<%s\ngit commit\n%s\n' BATS BATS)" - allowed "$output" -} - -@test "an unquoted mention does not resolve to git" { - # The anchoring, without which `echo git commit` reads as a call. - run hook 'echo git commit' - allowed "$output" -} - -@test "a heredoc or redirect bound to this element is a message source" { - run hook "$(printf 'git commit -F - <<%s\nmsg\n%s\n' "'EOF'" EOF)" - allowed "$output" - run hook 'git commit -F - < /tmp/msg.txt' - allowed "$output" -} - -# --- the refusal itself ----------------------------------------------------- - -@test "the refusal names its predicate and the remedy that cannot rebind" { - # A migrated gate keeps its remedy text (CLOUD-437): a `msg` that lost it in - # translation is a regression no `policy test` would catch. A policy deny - # carries `Fix::None`, so the remedy has to live in the module's own prose. - run hook 'git commit' - [[ "$output" == *"commit-names-no-message-source"* ]] - [[ "$output" == *'-F '* ]] - [[ "$output" == *"pre-commit"* ]] -} - -@test "git -C commit is a deliberate false negative, carried over" { - # The bash guard resolved `sub1` to the path and let it through, because a - # guard with false positives gets bypassed (CLOUD-199) and this repository - # commits from its own root. A migration that silently fixed it would be - # changing the predicate, not moving it. - run hook 'git -C /some/path commit' - allowed "$output" -} - -@test "a command with no git commit in it at all is untouched" { - run hook 'ls -la' - allowed "$output" - run hook 'hg commit' - allowed "$output" -} From 66a5e5c2926a0cd06bc0b98772a6a97601e39c3f Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Tue, 25 Aug 2026 21:57:27 +0000 Subject: [PATCH 05/38] feat(receipt)!: an override becomes an issued record, not a variable somebody knows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CLOUD-1051, the issuer half. `BATTEN_PROSE_ONLY_OVERRIDE=1` and every other bypass variable share one property: they are KNOWLEDGE. Read the refusal — which prints the name — and you hold the bypass forever, for every subject, in every session. Nothing is issued, scoped or spent, so an override costs nothing to reach for. Measured 2026-08-25: an agent hit `prose-only-check`, held the variable name the moment it read the refusal, and put the override to a human over a change that needed no override at all. `batten override request --rule --verdict --subject` answers a class's declared `override.precondition` and returns a content-addressed admission: SHA-256("batten-admission-v1" || JCS({rule, verdict, subject, head, epoch, answers, prev, author})) Canonical serialization rather than concatenation, because raw `a || b` is ambiguous across field boundaries — two field splits can hash alike, so the address would not be well-defined. `a_field_boundary_cannot_be_moved_without_ moving_the_address` is that stated as a case. What the address does and does NOT prove is written into the module rather than left to a reader: it is BINDING, not authentication. Anyone holding the answers can compute it. What restricts who may mint one is the store's write path and nothing else — under a local store, anyone who can write it can mint one. That is acceptable against honest error and it is recorded so nobody later mistakes the hash for a signature. The store had no locking at all — `fs4` appeared only in `capture.rs`. Consume is a compare-and-set under an `fs4` advisory lock, taken BLOCKING rather than `try_lock`: the claim is that exactly one concurrent consumer wins, and a `WouldBlock` read as a refusal would make the loser's verdict depend on scheduling. Eight threads, one winner, seven policy refusals. The answers arrive on STDIN, not in argv. A mediated call sees `input.call.command`, so answers in argv would put the author's own reasoning into every hook's input document — and this row is the one place rule 4 is deliberately inverted, which makes the exposure real. Three findings from writing the tests, each fixed rather than filed: * `store_dir` failed outright at `.` — "cannot derive a repository name from ." — because the state directory is keyed by the repository's own directory name. Canonicalize first, the same repair `secrets::resolve_scanner` already carries. * THE CYCLING-`prev` CASE MEASURED SOMETHING STRONGER THAN IT ASSERTED. A cycle is UNCONSTRUCTIBLE under content addressing: each address must be computed over a binding already carrying the other's, and neither exists until the other does. The only way to write the pair is to edit `prev` in afterwards, which is exactly what `recomputes` catches one clause earlier. The case now states that, and records that `ChainBroken`'s live instance is a link resolving to nothing rather than a loop. * Five cases shared one articulation, so they shared one record and interfered. That is the scheme working — the same reasoning IS the same admission — but it is a property test authors have to know about, so it is written on `binding`. NO CLASS BATTEN SHIPS DECLARES AN OVERRIDE ROUTE, measured while writing this. All eleven vendored classes carry only `command`, `document` and `issue` routes, so nothing the binary itself refuses is overridable — CLOUD-1051's "a gate declaring no precondition simply cannot be overridden" holding by construction. Overridability is a consumer decision declared in their own authority, which is where house style §8 puts it. Refs: CLOUD-1051, CLOUD-1050, CLOUD-418 A fourth, found by the same case failing on its SECOND run: the override store outlives the checkout. That is the design — an override record is an out-of-tree receipt — but it means a fresh scratch directory does not isolate a case, so the fixture clears the store. --- completions/batten.bash | 159 +++++++- completions/batten.fish | 96 +++-- completions/batten.zsh | 160 ++++++++ crates/batten/src/admission.rs | 11 +- crates/batten/src/cli.rs | 55 +++ crates/batten/src/lib.rs | 181 ++++++++- crates/batten/src/spec.rs | 11 + crates/batten/src/surface.rs | 84 ++++ crates/batten/tests/admission.rs | 596 ++++++++++++++++++++++++++++ crates/batten/tests/pointer_only.rs | 27 ++ man/batten-override-request.1 | 22 + man/batten-override.1 | 20 + man/batten.1 | 3 + 13 files changed, 1389 insertions(+), 36 deletions(-) create mode 100644 crates/batten/tests/admission.rs create mode 100644 man/batten-override-request.1 create mode 100644 man/batten-override.1 diff --git a/completions/batten.bash b/completions/batten.bash index 517acc1e8..b86ff12d3 100644 --- a/completions/batten.bash +++ b/completions/batten.bash @@ -64,6 +64,9 @@ _batten() { batten,lint) cmd="batten__subcmd__lint" ;; + batten,override) + cmd="batten__subcmd__override" + ;; batten,payload) cmd="batten__subcmd__payload" ;; @@ -295,6 +298,9 @@ _batten() { batten__subcmd__help,lint) cmd="batten__subcmd__help__subcmd__lint" ;; + batten__subcmd__help,override) + cmd="batten__subcmd__help__subcmd__override" + ;; batten__subcmd__help,payload) cmd="batten__subcmd__help__subcmd__payload" ;; @@ -376,6 +382,9 @@ _batten() { batten__subcmd__help__subcmd__lint,brief) cmd="batten__subcmd__help__subcmd__lint__subcmd__brief" ;; + batten__subcmd__help__subcmd__override,request) + cmd="batten__subcmd__help__subcmd__override__subcmd__request" + ;; batten__subcmd__help__subcmd__payload,field) cmd="batten__subcmd__help__subcmd__payload__subcmd__field" ;; @@ -430,6 +439,18 @@ _batten() { batten__subcmd__lint__subcmd__help,help) cmd="batten__subcmd__lint__subcmd__help__subcmd__help" ;; + batten__subcmd__override,help) + cmd="batten__subcmd__override__subcmd__help" + ;; + batten__subcmd__override,request) + cmd="batten__subcmd__override__subcmd__request" + ;; + batten__subcmd__override__subcmd__help,help) + cmd="batten__subcmd__override__subcmd__help__subcmd__help" + ;; + batten__subcmd__override__subcmd__help,request) + cmd="batten__subcmd__override__subcmd__help__subcmd__request" + ;; batten__subcmd__payload,field) cmd="batten__subcmd__payload__subcmd__field" ;; @@ -557,7 +578,7 @@ _batten() { case "${cmd}" in batten) - opts="-q -v -y -h -V --strictness --fail-on-warning --config-from --silent --quiet --verbose --debug --trace --log-level --no-color --no-input --yes --help --version check enforce exec capture config lint spec doctor init baseline generate policy commit attribution worktree provision hook payload receipt defects design state help" + opts="-q -v -y -h -V --strictness --fail-on-warning --config-from --silent --quiet --verbose --debug --trace --log-level --no-color --no-input --yes --help --version check enforce exec capture config lint spec doctor init baseline generate policy commit attribution worktree override provision hook payload receipt defects design state help" if [[ ${cur} == -* || ${COMP_CWORD} -eq 1 ]] ; then COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 @@ -1943,7 +1964,7 @@ _batten() { return 0 ;; batten__subcmd__help) - opts="check enforce exec capture config lint spec doctor init baseline generate policy commit attribution worktree provision hook payload receipt defects design state help" + opts="check enforce exec capture config lint spec doctor init baseline generate policy commit attribution worktree override provision hook payload receipt defects design state help" if [[ ${cur} == -* || ${COMP_CWORD} -eq 2 ]] ; then COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 @@ -2460,6 +2481,34 @@ _batten() { COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 ;; + batten__subcmd__help__subcmd__override) + opts="request" + 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__help__subcmd__override__subcmd__request) + 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__payload) opts="field" if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then @@ -2904,6 +2953,112 @@ _batten() { COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 ;; + batten__subcmd__override) + opts="-q -v -y -h --strictness --fail-on-warning --config-from --silent --quiet --verbose --debug --trace --log-level --no-color --no-input --yes --help request help" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 2 ]] ; 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 + ;; + --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__override__subcmd__help) + opts="request 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__override__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__override__subcmd__help__subcmd__request) + 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__override__subcmd__request) + opts="-q -v -y -h --rule --verdict --subject --strictness --fail-on-warning --config-from --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 + --rule) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; + --verdict) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; + --subject) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; + --strictness) + COMPREPLY=($(compgen -W "permissive standard strict" -- "${cur}")) + return 0 + ;; + --config-from) + 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__payload) opts="-q -v -y -h --strictness --fail-on-warning --config-from --silent --quiet --verbose --debug --trace --log-level --no-color --no-input --yes --help field help" if [[ ${cur} == -* || ${COMP_CWORD} -eq 2 ]] ; then diff --git a/completions/batten.fish b/completions/batten.fish index ed39085c4..9a23158fe 100644 --- a/completions/batten.fish +++ b/completions/batten.fish @@ -60,6 +60,7 @@ complete -c batten -n "__fish_batten_needs_command" -f -a "policy" -d 'Inspect t complete -c batten -n "__fish_batten_needs_command" -f -a "commit" -d 'The shape a commit must take here: what its subject may say' complete -c batten -n "__fish_batten_needs_command" -f -a "attribution" -d 'What produced commits may carry about the tooling that made them' complete -c batten -n "__fish_batten_needs_command" -f -a "worktree" -d 'Worktrees and the work in them: what is at risk' +complete -c batten -n "__fish_batten_needs_command" -f -a "override" -d 'Issued admissions: an override is a record, never a variable somebody knows' complete -c batten -n "__fish_batten_needs_command" -f -a "provision" -d 'Pinned tools this repository provisions, cached out of tree' complete -c batten -n "__fish_batten_needs_command" -f -a "hook" -d 'Adjudicate a mediated tool call read from stdin (a deny is exit 2, the one contract)' complete -c batten -n "__fish_batten_needs_command" -f -a "payload" -d 'Read a hook payload from stdin' @@ -915,6 +916,53 @@ complete -c batten -n "__fish_batten_using_subcommand worktree; and __fish_seen_ complete -c batten -n "__fish_batten_using_subcommand worktree; and __fish_seen_subcommand_from status" -s h -l help -d 'Print help (see more with \'--help\')' complete -c batten -n "__fish_batten_using_subcommand worktree; and __fish_seen_subcommand_from help" -f -a "status" -d 'Report work that is uncommitted, unpushed, or not landed on the configured target' complete -c batten -n "__fish_batten_using_subcommand worktree; 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 override; and not __fish_seen_subcommand_from request 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 override; and not __fish_seen_subcommand_from request 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 override; and not __fish_seen_subcommand_from request 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 override; and not __fish_seen_subcommand_from request 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 override; and not __fish_seen_subcommand_from request help" -l silent -d 'Say nothing but a verdict or a usage error' +complete -c batten -n "__fish_batten_using_subcommand override; and not __fish_seen_subcommand_from request help" -s q -l quiet -d 'Suppress ordinary progress (repeatable: -qq is silent)' +complete -c batten -n "__fish_batten_using_subcommand override; and not __fish_seen_subcommand_from request help" -s v -l verbose -d 'Explain what is being checked (repeatable: -vv is debug)' +complete -c batten -n "__fish_batten_using_subcommand override; and not __fish_seen_subcommand_from request help" -l debug -d 'Add resolution detail' +complete -c batten -n "__fish_batten_using_subcommand override; and not __fish_seen_subcommand_from request help" -l trace -d 'Add everything' +complete -c batten -n "__fish_batten_using_subcommand override; and not __fish_seen_subcommand_from request help" -l no-color -d 'Never colour stderr, whatever it is attached to' +complete -c batten -n "__fish_batten_using_subcommand override; and not __fish_seen_subcommand_from request help" -l no-input -d 'Never prompt; treat the run as unattended' +complete -c batten -n "__fish_batten_using_subcommand override; and not __fish_seen_subcommand_from request help" -s y -l yes -d 'Confirm a destructive operation that would otherwise refuse' +complete -c batten -n "__fish_batten_using_subcommand override; and not __fish_seen_subcommand_from request help" -s h -l help -d 'Print help (see more with \'--help\')' +complete -c batten -n "__fish_batten_using_subcommand override; and not __fish_seen_subcommand_from request help" -f -a "request" -d 'Answer a class\'s declared precondition and receive an admission for one situation' +complete -c batten -n "__fish_batten_using_subcommand override; and not __fish_seen_subcommand_from request help" -f -a "help" -d 'Print this message or the help of the given subcommand(s)' +complete -c batten -n "__fish_batten_using_subcommand override; and __fish_seen_subcommand_from request" -l rule -d 'The rule whose refusal is being overridden' -r +complete -c batten -n "__fish_batten_using_subcommand override; and __fish_seen_subcommand_from request" -l verdict -d 'The verdict token that refusal carries, e.g. V-PROSE-ONLY-DIFF' -r +complete -c batten -n "__fish_batten_using_subcommand override; and __fish_seen_subcommand_from request" -l subject -d 'The gate\'s canonical subject, exactly as its refusal names it' -r +complete -c batten -n "__fish_batten_using_subcommand override; and __fish_seen_subcommand_from request" -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 override; and __fish_seen_subcommand_from request" -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 override; and __fish_seen_subcommand_from request" -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 override; and __fish_seen_subcommand_from request" -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 override; and __fish_seen_subcommand_from request" -l silent -d 'Say nothing but a verdict or a usage error' +complete -c batten -n "__fish_batten_using_subcommand override; and __fish_seen_subcommand_from request" -s q -l quiet -d 'Suppress ordinary progress (repeatable: -qq is silent)' +complete -c batten -n "__fish_batten_using_subcommand override; and __fish_seen_subcommand_from request" -s v -l verbose -d 'Explain what is being checked (repeatable: -vv is debug)' +complete -c batten -n "__fish_batten_using_subcommand override; and __fish_seen_subcommand_from request" -l debug -d 'Add resolution detail' +complete -c batten -n "__fish_batten_using_subcommand override; and __fish_seen_subcommand_from request" -l trace -d 'Add everything' +complete -c batten -n "__fish_batten_using_subcommand override; and __fish_seen_subcommand_from request" -l no-color -d 'Never colour stderr, whatever it is attached to' +complete -c batten -n "__fish_batten_using_subcommand override; and __fish_seen_subcommand_from request" -l no-input -d 'Never prompt; treat the run as unattended' +complete -c batten -n "__fish_batten_using_subcommand override; and __fish_seen_subcommand_from request" -s y -l yes -d 'Confirm a destructive operation that would otherwise refuse' +complete -c batten -n "__fish_batten_using_subcommand override; and __fish_seen_subcommand_from request" -s h -l help -d 'Print help (see more with \'--help\')' +complete -c batten -n "__fish_batten_using_subcommand override; and __fish_seen_subcommand_from help" -f -a "request" -d 'Answer a class\'s declared precondition and receive an admission for one situation' +complete -c batten -n "__fish_batten_using_subcommand override; 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 provision; and not __fish_seen_subcommand_from status apply 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'" @@ -1368,29 +1416,30 @@ 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 "migrate" -d 'Upgrade the findings store to this binary\'s record version' 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 help; and not __fish_seen_subcommand_from check enforce exec capture config lint spec doctor init baseline generate policy commit attribution worktree provision hook payload receipt defects design state help" -f -a "check" -d 'Run the applicable read-only gates against the repository' -complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture config lint spec doctor init baseline generate policy commit attribution worktree provision hook payload receipt defects design state help" -f -a "enforce" -d 'Run every configured rule, including kinds that execute a configured command' -complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture config lint spec doctor init baseline generate policy commit attribution worktree provision hook payload receipt defects design state help" -f -a "exec" -d 'Run a command — or a `:::` bundle — and report a pointer to what it wrote' -complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture config lint spec doctor init baseline generate policy commit attribution worktree provision hook payload receipt defects design state help" -f -a "capture" -d 'Captured command output: navigate what `exec` already ran, without running it again' -complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture config lint spec doctor init baseline generate policy commit attribution worktree provision hook payload receipt defects design state help" -f -a "config" -d 'Inspect configuration' -complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture config lint spec doctor init baseline generate policy commit attribution worktree provision hook payload receipt defects design state help" -f -a "lint" -d 'Lint an artifact against a declared schema' -complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture config lint spec doctor init baseline generate policy commit attribution worktree provision hook payload receipt defects design state help" -f -a "spec" -d 'Print the tool\'s own command spec' -complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture config lint spec doctor init baseline generate policy commit attribution worktree provision hook payload receipt defects design state help" -f -a "doctor" -d 'Diagnose whether Batten can run in this repository' -complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture config lint spec doctor init baseline generate policy commit attribution worktree provision hook payload receipt defects design state help" -f -a "init" -d 'Write a starter batten.toml, refusing to overwrite an existing one' -complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture config lint spec doctor init baseline generate policy commit attribution worktree provision hook payload receipt defects design state help" -f -a "baseline" -d 'Record the findings that already exist, so only new ones fail' -complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture config lint spec doctor init baseline generate policy commit attribution worktree provision hook payload receipt defects design state help" -f -a "generate" -d 'Emit artifacts derived from the command spec, on stdout' -complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture config lint spec doctor init baseline generate policy commit attribution worktree provision hook payload receipt defects design state help" -f -a "policy" -d 'Inspect the thresholds and path sets this repository holds itself to' -complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture config lint spec doctor init baseline generate policy commit attribution worktree provision hook payload receipt defects design state help" -f -a "commit" -d 'The shape a commit must take here: what its subject may say' -complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture config lint spec doctor init baseline generate policy commit attribution worktree provision hook payload receipt defects design state help" -f -a "attribution" -d 'What produced commits may carry about the tooling that made them' -complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture config lint spec doctor init baseline generate policy commit attribution worktree provision hook payload receipt defects design state help" -f -a "worktree" -d 'Worktrees and the work in them: what is at risk' -complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture config lint spec doctor init baseline generate policy commit attribution worktree provision hook payload receipt defects design state help" -f -a "provision" -d 'Pinned tools this repository provisions, cached out of tree' -complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture config lint spec doctor init baseline generate policy commit attribution worktree provision hook payload receipt defects design state help" -f -a "hook" -d 'Adjudicate a mediated tool call read from stdin (a deny is exit 2, the one contract)' -complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture config lint spec doctor init baseline generate policy commit attribution worktree provision hook payload receipt defects design state help" -f -a "payload" -d 'Read a hook payload from stdin' -complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture config lint spec doctor init baseline generate policy commit attribution worktree provision hook payload receipt defects design state help" -f -a "receipt" -d 'Verification receipts: SHA-keyed claims a named check passed, invalidated by git facts' -complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture config lint spec doctor init baseline generate policy commit attribution worktree provision hook payload receipt defects design state help" -f -a "defects" -d 'The append-only defect ledger: the lessons this repository has already paid for' -complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture config lint spec doctor init baseline generate policy commit attribution worktree provision hook payload receipt defects design state help" -f -a "design" -d 'Design-evidence claims: the integrity of the record behind a decision' -complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture config lint spec doctor init baseline generate policy commit attribution worktree provision hook payload receipt defects design state help" -f -a "state" -d 'The out-of-tree findings store: which store belongs to this checkout' -complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture config lint spec doctor init baseline generate policy commit attribution worktree provision hook payload receipt defects design state help" -f -a "help" -d 'Print this message or the help of the given subcommand(s)' +complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture config lint spec doctor init baseline generate policy commit attribution worktree override provision hook payload receipt defects design state help" -f -a "check" -d 'Run the applicable read-only gates against the repository' +complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture config lint spec doctor init baseline generate policy commit attribution worktree override provision hook payload receipt defects design state help" -f -a "enforce" -d 'Run every configured rule, including kinds that execute a configured command' +complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture config lint spec doctor init baseline generate policy commit attribution worktree override provision hook payload receipt defects design state help" -f -a "exec" -d 'Run a command — or a `:::` bundle — and report a pointer to what it wrote' +complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture config lint spec doctor init baseline generate policy commit attribution worktree override provision hook payload receipt defects design state help" -f -a "capture" -d 'Captured command output: navigate what `exec` already ran, without running it again' +complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture config lint spec doctor init baseline generate policy commit attribution worktree override provision hook payload receipt defects design state help" -f -a "config" -d 'Inspect configuration' +complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture config lint spec doctor init baseline generate policy commit attribution worktree override provision hook payload receipt defects design state help" -f -a "lint" -d 'Lint an artifact against a declared schema' +complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture config lint spec doctor init baseline generate policy commit attribution worktree override provision hook payload receipt defects design state help" -f -a "spec" -d 'Print the tool\'s own command spec' +complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture config lint spec doctor init baseline generate policy commit attribution worktree override provision hook payload receipt defects design state help" -f -a "doctor" -d 'Diagnose whether Batten can run in this repository' +complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture config lint spec doctor init baseline generate policy commit attribution worktree override provision hook payload receipt defects design state help" -f -a "init" -d 'Write a starter batten.toml, refusing to overwrite an existing one' +complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture config lint spec doctor init baseline generate policy commit attribution worktree override provision hook payload receipt defects design state help" -f -a "baseline" -d 'Record the findings that already exist, so only new ones fail' +complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture config lint spec doctor init baseline generate policy commit attribution worktree override provision hook payload receipt defects design state help" -f -a "generate" -d 'Emit artifacts derived from the command spec, on stdout' +complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture config lint spec doctor init baseline generate policy commit attribution worktree override provision hook payload receipt defects design state help" -f -a "policy" -d 'Inspect the thresholds and path sets this repository holds itself to' +complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture config lint spec doctor init baseline generate policy commit attribution worktree override provision hook payload receipt defects design state help" -f -a "commit" -d 'The shape a commit must take here: what its subject may say' +complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture config lint spec doctor init baseline generate policy commit attribution worktree override provision hook payload receipt defects design state help" -f -a "attribution" -d 'What produced commits may carry about the tooling that made them' +complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture config lint spec doctor init baseline generate policy commit attribution worktree override provision hook payload receipt defects design state help" -f -a "worktree" -d 'Worktrees and the work in them: what is at risk' +complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture config lint spec doctor init baseline generate policy commit attribution worktree override provision hook payload receipt defects design state help" -f -a "override" -d 'Issued admissions: an override is a record, never a variable somebody knows' +complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture config lint spec doctor init baseline generate policy commit attribution worktree override provision hook payload receipt defects design state help" -f -a "provision" -d 'Pinned tools this repository provisions, cached out of tree' +complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture config lint spec doctor init baseline generate policy commit attribution worktree override provision hook payload receipt defects design state help" -f -a "hook" -d 'Adjudicate a mediated tool call read from stdin (a deny is exit 2, the one contract)' +complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture config lint spec doctor init baseline generate policy commit attribution worktree override provision hook payload receipt defects design state help" -f -a "payload" -d 'Read a hook payload from stdin' +complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture config lint spec doctor init baseline generate policy commit attribution worktree override provision hook payload receipt defects design state help" -f -a "receipt" -d 'Verification receipts: SHA-keyed claims a named check passed, invalidated by git facts' +complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture config lint spec doctor init baseline generate policy commit attribution worktree override provision hook payload receipt defects design state help" -f -a "defects" -d 'The append-only defect ledger: the lessons this repository has already paid for' +complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture config lint spec doctor init baseline generate policy commit attribution worktree override provision hook payload receipt defects design state help" -f -a "design" -d 'Design-evidence claims: the integrity of the record behind a decision' +complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture config lint spec doctor init baseline generate policy commit attribution worktree override provision hook payload receipt defects design state help" -f -a "state" -d 'The out-of-tree findings store: which store belongs to this checkout' +complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture config lint spec doctor init baseline generate policy commit attribution worktree override provision hook payload receipt defects design state help" -f -a "help" -d 'Print this message or the help of the given subcommand(s)' complete -c batten -n "__fish_batten_using_subcommand help; and __fish_seen_subcommand_from capture" -f -a "show" -d 'Print a capture\'s pointer, or the lines a selection asks for, with no second run' complete -c batten -n "__fish_batten_using_subcommand help; and __fish_seen_subcommand_from capture" -f -a "list" -d 'List this repository\'s captures as handles, in a fixed order' complete -c batten -n "__fish_batten_using_subcommand help; and __fish_seen_subcommand_from capture" -f -a "prune" -d 'Remove this repository\'s captures — the one removal path; captures never expire on their own' @@ -1412,6 +1461,7 @@ 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 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' complete -c batten -n "__fish_batten_using_subcommand help; and __fish_seen_subcommand_from worktree" -f -a "status" -d 'Report work that is uncommitted, unpushed, or not landed on the configured target' +complete -c batten -n "__fish_batten_using_subcommand help; and __fish_seen_subcommand_from override" -f -a "request" -d 'Answer a class\'s declared precondition and receive an admission for one situation' complete -c batten -n "__fish_batten_using_subcommand help; and __fish_seen_subcommand_from provision" -f -a "status" -d 'Report which provisioned tools do not match the manifest' complete -c batten -n "__fish_batten_using_subcommand help; and __fish_seen_subcommand_from provision" -f -a "apply" -d 'Fetch, verify against the pinned checksum, and install into the out-of-tree cache' complete -c batten -n "__fish_batten_using_subcommand help; and __fish_seen_subcommand_from payload" -f -a "field" -d 'Print one field of a hook payload read from stdin, for a shell hook that must not depend on jq' diff --git a/completions/batten.zsh b/completions/batten.zsh index 0bafa473d..7757ff910 100644 --- a/completions/batten.zsh +++ b/completions/batten.zsh @@ -1521,6 +1521,101 @@ esac ;; esac ;; +(override) +_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' \ +'--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'\'')]' \ +":: :_batten__subcmd__override_commands" \ +"*::: :->override" \ +&& ret=0 + + case $state in + (override) + words=($line[1] "${words[@]}") + (( CURRENT += 1 )) + curcontext="${curcontext%:*:*}:batten-override-command-$line[1]:" + case $line[1] in + (request) +_arguments "${_arguments_options[@]}" : \ +'--rule=[The rule whose refusal is being overridden]: :_default' \ +'--verdict=[The verdict token that refusal carries, e.g. V-PROSE-ONLY-DIFF]: :_default' \ +'--subject=[The gate'\''s canonical subject, exactly as its refusal names it]: :_default' \ +'--strictness=[Raise how strictly gates apply (an override may only tighten policy)]: :((permissive\:"Advisory\: findings are reported without failing the run" +standard\:"The default\: a finding is a violation" +strict\:"Everything \`Standard\` fails on, plus anything advisory"))' \ +'--config-from=[Read the committed config from a git ref (e.g. origin/main) instead of the working tree]: :_default' \ +'--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__override__subcmd__help_commands" \ +"*::: :->help" \ +&& ret=0 + + case $state in + (help) + words=($line[1] "${words[@]}") + (( CURRENT += 1 )) + curcontext="${curcontext%:*:*}:batten-override-help-command-$line[1]:" + case $line[1] in + (request) +_arguments "${_arguments_options[@]}" : \ +&& ret=0 +;; +(help) +_arguments "${_arguments_options[@]}" : \ +&& ret=0 +;; + esac + ;; +esac +;; + esac + ;; +esac +;; (provision) _arguments "${_arguments_options[@]}" : \ '--strictness=[Raise how strictly gates apply (an override may only tighten policy)]: :((permissive\:"Advisory\: findings are reported without failing the run" @@ -2604,6 +2699,26 @@ _arguments "${_arguments_options[@]}" : \ ;; esac ;; +(override) +_arguments "${_arguments_options[@]}" : \ +":: :_batten__subcmd__help__subcmd__override_commands" \ +"*::: :->override" \ +&& ret=0 + + case $state in + (override) + words=($line[1] "${words[@]}") + (( CURRENT += 1 )) + curcontext="${curcontext%:*:*}:batten-help-override-command-$line[1]:" + case $line[1] in + (request) +_arguments "${_arguments_options[@]}" : \ +&& ret=0 +;; + esac + ;; +esac +;; (provision) _arguments "${_arguments_options[@]}" : \ ":: :_batten__subcmd__help__subcmd__provision_commands" \ @@ -2783,6 +2898,7 @@ _batten_commands() { 'commit:The shape a commit must take here\: what its subject may say' \ 'attribution:What produced commits may carry about the tooling that made them' \ 'worktree:Worktrees and the work in them\: what is at risk' \ +'override:Issued admissions\: an override is a record, never a variable somebody knows' \ 'provision:Pinned tools this repository provisions, cached out of tree' \ 'hook:Adjudicate a mediated tool call read from stdin (a deny is exit 2, the one contract)' \ 'payload:Read a hook payload from stdin' \ @@ -3212,6 +3328,7 @@ _batten__subcmd__help_commands() { 'commit:The shape a commit must take here\: what its subject may say' \ 'attribution:What produced commits may carry about the tooling that made them' \ 'worktree:Worktrees and the work in them\: what is at risk' \ +'override:Issued admissions\: an override is a record, never a variable somebody knows' \ 'provision:Pinned tools this repository provisions, cached out of tree' \ 'hook:Adjudicate a mediated tool call read from stdin (a deny is exit 2, the one contract)' \ 'payload:Read a hook payload from stdin' \ @@ -3432,6 +3549,18 @@ _batten__subcmd__help__subcmd__lint__subcmd__brief_commands() { local commands; commands=() _describe -t commands 'batten help lint brief commands' commands "$@" } +(( $+functions[_batten__subcmd__help__subcmd__override_commands] )) || +_batten__subcmd__help__subcmd__override_commands() { + local commands; commands=( +'request:Answer a class'\''s declared precondition and receive an admission for one situation' \ + ) + _describe -t commands 'batten help override commands' commands "$@" +} +(( $+functions[_batten__subcmd__help__subcmd__override__subcmd__request_commands] )) || +_batten__subcmd__help__subcmd__override__subcmd__request_commands() { + local commands; commands=() + _describe -t commands 'batten help override request commands' commands "$@" +} (( $+functions[_batten__subcmd__help__subcmd__payload_commands] )) || _batten__subcmd__help__subcmd__payload_commands() { local commands; commands=( @@ -3597,6 +3726,37 @@ _batten__subcmd__lint__subcmd__help__subcmd__help_commands() { local commands; commands=() _describe -t commands 'batten lint help help commands' commands "$@" } +(( $+functions[_batten__subcmd__override_commands] )) || +_batten__subcmd__override_commands() { + local commands; commands=( +'request:Answer a class'\''s declared precondition and receive an admission for one situation' \ +'help:Print this message or the help of the given subcommand(s)' \ + ) + _describe -t commands 'batten override commands' commands "$@" +} +(( $+functions[_batten__subcmd__override__subcmd__help_commands] )) || +_batten__subcmd__override__subcmd__help_commands() { + local commands; commands=( +'request:Answer a class'\''s declared precondition and receive an admission for one situation' \ +'help:Print this message or the help of the given subcommand(s)' \ + ) + _describe -t commands 'batten override help commands' commands "$@" +} +(( $+functions[_batten__subcmd__override__subcmd__help__subcmd__help_commands] )) || +_batten__subcmd__override__subcmd__help__subcmd__help_commands() { + local commands; commands=() + _describe -t commands 'batten override help help commands' commands "$@" +} +(( $+functions[_batten__subcmd__override__subcmd__help__subcmd__request_commands] )) || +_batten__subcmd__override__subcmd__help__subcmd__request_commands() { + local commands; commands=() + _describe -t commands 'batten override help request commands' commands "$@" +} +(( $+functions[_batten__subcmd__override__subcmd__request_commands] )) || +_batten__subcmd__override__subcmd__request_commands() { + local commands; commands=() + _describe -t commands 'batten override request commands' commands "$@" +} (( $+functions[_batten__subcmd__payload_commands] )) || _batten__subcmd__payload_commands() { local commands; commands=( diff --git a/crates/batten/src/admission.rs b/crates/batten/src/admission.rs index 8022414af..fe2a571ae 100644 --- a/crates/batten/src/admission.rs +++ b/crates/batten/src/admission.rs @@ -340,7 +340,16 @@ fn string(text: &str) -> String { /// /// Returns an error when the state root cannot be resolved. pub fn store_dir(repo_root: &Path) -> Result { - Ok(crate::state::repo_state_dir(repo_root)? + // CANONICALIZE FIRST, for the reason `secrets::resolve_scanner` records: the + // anchor is a RELATIVE path (`.`) whenever the config sits in the cwd, and + // the state directory is keyed by the repository's own directory name, which + // `state::derive_repo_name` cannot read off `.`. Measured as a hard failure + // — "cannot derive a repository name from ." — the first time + // `override request` ran, because that verb anchors at `.` like every other. + let anchored = repo_root + .canonicalize() + .with_context(|| format!("resolve the repository root at {}", repo_root.display()))?; + Ok(crate::state::repo_state_dir(&anchored)? .join("receipts") .join("overrides")) } diff --git a/crates/batten/src/cli.rs b/crates/batten/src/cli.rs index 7a9a6c579..db431812c 100644 --- a/crates/batten/src/cli.rs +++ b/crates/batten/src/cli.rs @@ -175,6 +175,17 @@ pub enum Command { /// The chosen sub-verb. command: CommitCommand, }, + /// Issued admissions — an override that is a record, not knowledge. + /// + /// Appended for the reason `Init` above states, and it is the same one every + /// new variant here answers to: this enum carries no `repr`, so a variant + /// placed beside its neighbours in the surface would shift every later + /// discriminant and `mise run semver` would read that as a break the crate + /// has to declare. + Override { + /// The chosen sub-verb. + command: OverrideCommand, + }, } /// Everything `batten exec` was asked for, as one value. @@ -281,6 +292,21 @@ pub enum ProvisionCommand { }, } +/// Subcommands of `override` (CLOUD-1051). +#[derive(Debug, Clone, PartialEq, Eq)] +#[non_exhaustive] +pub enum OverrideCommand { + /// Answer a class's declared precondition and receive an admission. + Request { + /// The rule whose refusal is being overridden. + rule: String, + /// The declared class that refusal carries. + verdict: String, + /// The gate's canonical subject. + subject: String, + }, +} + /// Subcommands of `worktree`. #[derive(Debug, Clone, PartialEq, Eq)] #[non_exhaustive] @@ -804,6 +830,34 @@ fn worktree_of(matches: &ArgMatches) -> Option { } } +/// `override request`'s three binding fields (CLOUD-1051). +/// +/// The answers are NOT here: they arrive on stdin, for the reason `surface.rs` +/// records on the row — argv reaches every mediated hook's input document, and +/// the answers are the one payload this surface deliberately carries. +/// +/// `clap` already refuses each absent case (all three are `required`), so the +/// defaults below are unreachable rather than a silent empty binding. +fn override_of(matches: &ArgMatches) -> Option { + match matches.subcommand()? { + ("request", matches) => Some(OverrideCommand::Request { + rule: matches + .get_one::("rule") + .cloned() + .unwrap_or_default(), + verdict: matches + .get_one::("verdict") + .cloned() + .unwrap_or_default(), + subject: matches + .get_one::("subject") + .cloned() + .unwrap_or_default(), + }), + _ => None, + } +} + fn generate_of(matches: &ArgMatches) -> Option { match matches.subcommand()? { ("completions", matches) => matches @@ -923,6 +977,7 @@ fn command_of((name, matches): (&str, &ArgMatches)) -> Option { "attribution" => attribution_of(matches).map(|command| Command::Attribution { command }), "commit" => commit_of(matches).map(|command| Command::Commit { command }), "worktree" => worktree_of(matches).map(|command| Command::Worktree { command }), + "override" => override_of(matches).map(|command| Command::Override { command }), "generate" => generate_of(matches).map(|command| Command::Generate { command }), // `get_many`, not `get_one`: the tail is an `Append` action, so every // token after `--` is a separate value and the child's argv is the whole diff --git a/crates/batten/src/lib.rs b/crates/batten/src/lib.rs index fc2ac16c4..fbd69b1e6 100644 --- a/crates/batten/src/lib.rs +++ b/crates/batten/src/lib.rs @@ -93,8 +93,8 @@ use anyhow::Result; pub use cli::{ AttributionCommand, Cli, Command, CommitCommand, ConfigCommand, DefectsCommand, DesignCommand, - GenerateCommand, LintCommand, PolicyCommand, ProvisionCommand, ReceiptCommand, SpecFormat, - StateCommand, WorktreeCommand, + GenerateCommand, LintCommand, OverrideCommand, PolicyCommand, ProvisionCommand, ReceiptCommand, + SpecFormat, StateCommand, WorktreeCommand, }; pub use config::Config; pub use effect::Effect; @@ -197,14 +197,7 @@ pub fn run(cli: Cli, mode: Mode, out: &mut dyn Write, err: &mut dyn Write) -> Re receipt::run_status(&check, key, json, out) } }, - Some(Command::Policy { command }) => match command { - PolicyCommand::Budget { json } => run_budget(json, &overrides, out), - PolicyCommand::Test { json } => run_policy_test(json, &overrides, out), - PolicyCommand::Tools { json } => run_policy_tools(json, &overrides, out), - PolicyCommand::Explain { token, json } => { - run_policy_explain(&token, json, &overrides, out) - } - }, + Some(Command::Policy { command }) => run_policy(command, &overrides, out), // `lint ` reads text the caller names and answers about its shape. // The §8 config chain is deliberately not threaded through it: the schema // is engine structure, not repo policy, so there is no key for a config to @@ -230,6 +223,7 @@ pub fn run(cli: Cli, mode: Mode, out: &mut dyn Write, err: &mut dyn Write) -> Re Some(Command::Worktree { command }) => match command { WorktreeCommand::Status { json } => run_worktree_status(json, &overrides, out), }, + Some(Command::Override { command }) => run_override(command, &overrides, 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 { @@ -1811,6 +1805,173 @@ fn run_policy_explain( Ok(ExitCode::Success) } +/// Dispatch the `policy` subtree. +/// +/// Lifted out of [`run`]'s table alongside [`run_override`] and for the same +/// reason: that body is one line per verb, and a three-verb nested match is not +/// one line. +fn run_policy( + command: PolicyCommand, + overrides: &Overrides, + out: &mut dyn Write, +) -> Result { + match command { + PolicyCommand::Budget { json } => run_budget(json, overrides, out), + PolicyCommand::Test { json } => run_policy_test(json, overrides, out), + PolicyCommand::Tools { json } => run_policy_tools(json, overrides, out), + PolicyCommand::Explain { token, json } => run_policy_explain(&token, json, overrides, out), + } +} + +/// Dispatch the `override` subtree. +/// +/// One arm today, and a function rather than an inline match for the reason +/// every other multi-verb noun here has one: [`run`]'s body is a table of one +/// line per verb, and a verb whose arm is a nested destructure stops it being +/// readable as a table. +fn run_override( + command: OverrideCommand, + overrides: &Overrides, + out: &mut dyn Write, + err: &mut dyn Write, +) -> Result { + match command { + OverrideCommand::Request { + rule, + verdict, + subject, + } => run_override_request(&rule, &verdict, &subject, overrides, out, err), + } +} + +/// `batten override request` — issue an admission for one situation +/// (CLOUD-1051). +/// +/// # What the caller supplies and what this resolves +/// +/// The caller names the rule, the class and the gate's canonical subject; the +/// other two binding terms — HEAD and the config epoch — are resolved HERE. That +/// asymmetry is the point: an admission whose HEAD the caller could choose would +/// bind nothing, and one whose epoch the caller could choose would survive the +/// policy change that made it unnecessary. +/// +/// # The questions, and the two-step this produces +/// +/// They are generated from the class's own declared `override.precondition`, so +/// a class that declares no override route cannot be overridden at all — the +/// right default, and the one `verdict::validate` already composes with by +/// refusing a class whose ONLY route is an override. +/// +/// Run with nothing on stdin, this PRINTS the questions and exits `1`. That is +/// the "an unanswered question yields no admission" clause and the +/// re-presentation of the declined routes in one step, at the last cheap moment +/// — which is what catches the reader who never received route 1 (CLOUD-1050 +/// defect B, measured). +/// +/// # It never grades an answer +/// +/// Non-negotiable rule 3. The predicate is presence and non-emptiness; anything +/// stronger is a model verdict inside a gate, which would be worse than today's +/// password. +/// +/// # Errors +/// +/// Returns a [`error::UsageError`] for an unknown class or a class that declares +/// no override route, and an internal error when the store cannot be written. +fn run_override_request( + rule: &str, + token: &str, + subject: &str, + overrides: &Overrides, + out: &mut dyn Write, + err: &mut dyn Write, +) -> Result { + let root = Path::new("."); + let config = resolve::resolve(root, overrides)?; + let registry = policy::registry_for(&config.verdicts)?; + let Some((resolved, _)) = verdict::resolve(®istry, token) else { + return Err(error::UsageError::raise(format!( + "no `[[verdict]]` row declares `{token}`; this registry declares {} class(es)", + registry.len() + ))); + }; + let Some(questions) = admission::questions_for(resolved) else { + // Not a failure of the request — a statement about the class. A class + // that declares no override route is one this repository decided cannot + // be overridden, and saying so is more useful than issuing something the + // gate would then refuse. + return Err(error::UsageError::raise(format!( + "{} declares no `override` route, so it cannot be overridden", + resolved.id + ))); + }; + + let mut raw = String::new(); + if std::io::stdin().read_to_string(&mut raw).is_err() { + raw.clear(); + } + let answers = parse_answers(&raw); + let missing = admission::unanswered(&questions, &answers); + if !missing.is_empty() { + for question in &questions { + writeln!(err, "{} {}", question.id, question.prompt)?; + } + writeln!( + err, + "::error:: {} question(s) unanswered. Answer each as `=` on stdin.", + missing.len() + )?; + return Ok(ExitCode::Usage); + } + + let head = git::head_commit(root)?; + // The SAME epoch `config epoch` reports, resolved through the same function, + // so an admission cannot bind a generation the caller could not look up. + let (epoch, _) = epoch::describe(root, None)?; + // `user.email` rather than a name: it is the accountable identity + // `[attribution]` already decides over, and it is never a model identity + // (`.claude/rules/commits.md`). An unset one is the empty string rather than + // a failure — the author is a field of the record, not a precondition of it, + // and refusing an override because git has no email configured would put a + // gate in front of the break-glass for a reason unrelated to the situation. + let author = git::config_value(root, "user.email") + .ok() + .flatten() + .unwrap_or_default(); + let prev = admission::chain_head(root, rule, subject)?; + let binding = admission::Binding { + rule: rule.to_owned(), + verdict: resolved.id.clone(), + subject: subject.to_owned(), + head, + epoch, + answers, + prev, + author, + }; + let issued = admission::issue(root, binding)?; + // The admission alone on stdout. It authorizes nothing on its own — the + // record's existence and state do — which is exactly what makes it safe to + // print, log, and quote in a commit. + writeln!(out, "{issued}")?; + Ok(ExitCode::Success) +} + +/// Read `=` lines into an answer map. +/// +/// Everything after the FIRST `=` is the answer, so an answer may contain one; +/// a line with no `=` is not an answer and is dropped rather than guessed at. +/// Blank lines are skipped so a caller can lay the block out readably. +fn parse_answers(raw: &str) -> std::collections::BTreeMap { + raw.lines() + .filter_map(|line| { + let (id, answer) = line.split_once('=')?; + let id = id.trim(); + (!id.is_empty()).then(|| (id.to_owned(), answer.trim().to_owned())) + }) + .collect() +} + /// The `-J` shape of [`run_policy_explain`], byte-stable. /// /// `token` and `resolved` are both carried, and they differ exactly when the diff --git a/crates/batten/src/spec.rs b/crates/batten/src/spec.rs index 92feb6ba8..9c5e6ebe8 100644 --- a/crates/batten/src/spec.rs +++ b/crates/batten/src/spec.rs @@ -513,6 +513,17 @@ mod tests { // 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(), "payload".to_owned(), "payload field".to_owned(), "policy".to_owned(), diff --git a/crates/batten/src/surface.rs b/crates/batten/src/surface.rs index 46252d558..ec2e0b678 100644 --- a/crates/batten/src/surface.rs +++ b/crates/batten/src/surface.rs @@ -679,6 +679,55 @@ const VERDICT_TOKEN: FlagDecl = FlagDecl { value: ValueDecl::Str, }; +/// `--rule `: which gate's refusal an admission is requested against. +const OVERRIDE_RULE: FlagDecl = FlagDecl { + id: "rule", + long: Some("rule"), + short: None, + help: "The rule whose refusal is being overridden", + env: EnvDecl::None, + global: false, + positional: false, + required: true, + hidden: false, + rung: Rung::None, + value: ValueDecl::Str, +}; + +/// `--verdict `: which declared class that refusal belongs to. +/// +/// Required rather than derived from the rule, because one rule can refuse under +/// more than one class and an admission minted against one must not be +/// presentable against another (CLOUD-1051's binding). +const OVERRIDE_VERDICT: FlagDecl = FlagDecl { + id: "verdict", + long: Some("verdict"), + short: None, + help: "The verdict token that refusal carries, e.g. V-PROSE-ONLY-DIFF", + env: EnvDecl::None, + global: false, + positional: false, + required: true, + hidden: false, + rung: Rung::None, + value: ValueDecl::Str, +}; + +/// `--subject `: the gate's own canonical spelling of what it refused about. +const OVERRIDE_SUBJECT: FlagDecl = FlagDecl { + id: "subject", + long: Some("subject"), + short: None, + help: "The gate's canonical subject, exactly as its refusal names it", + env: EnvDecl::None, + global: false, + positional: false, + required: true, + hidden: false, + rung: Rung::None, + value: ValueDecl::Str, +}; + const CLASS: FlagDecl = FlagDecl { id: "class", long: Some("class"), @@ -1679,6 +1728,41 @@ pub const SURFACE: &[CommandDecl] = &[ effect: Effect::Read, flags: &[JSON], }, + // The `override` noun only dispatches, and its subtree writes, so it takes + // `provision`'s conservative reading rather than `policy`'s: a write-bearing + // subtree under a `read` noun would leak onto the derived allowlist for any + // consumer that treats an entry as a prefix (CLOUD-90). + CommandDecl { + path: "override", + about: "Issued admissions: an override is a record, never a variable somebody knows", + data_channel: false, + effect: Effect::Unclassified, + flags: &[], + }, + // CLOUD-1051. `write`, and the write is the whole point: what authorizes is + // the RECORD's existence and state, so a verb that only computed an address + // would authorize nothing. + // + // **THE ANSWERS ARRIVE ON STDIN, NOT IN ARGV**, and that is a decision rather + // than a convenience. Three reasons, in order of weight. A mediated call sees + // `input.call.command`, so answers in argv would put the author's own + // reasoning into every hook's input document — and this is the one surface + // whose payload is deliberately NOT pointer-only, which makes the exposure + // real. An answer is a sentence, and argv is the wrong shape for prose that + // may carry quotes and newlines. And the tree already reads its gate inputs + // this way — `ready-lint`, `claim-check` and `deferral-check` are all pure + // functions of stdin — so this is the established seam rather than a new one. + // + // No `-J`: it matches every other write row (`init`, `baseline`, `defects + // add`, `provision apply`), and the answer a caller needs is one admission on + // stdout, which a JSON document of one field would not improve. + CommandDecl { + path: "override request", + about: "Answer a class's declared precondition and receive an admission for one situation", + data_channel: false, + effect: Effect::Write, + flags: &[OVERRIDE_RULE, OVERRIDE_VERDICT, OVERRIDE_SUBJECT], + }, // The `provision` noun only dispatches, and its subtree carries a write // verb, so it takes `receipt`'s conservative reading rather than `policy`'s: // a write-bearing subtree under a `read` noun would leak onto the derived diff --git a/crates/batten/tests/admission.rs b/crates/batten/tests/admission.rs new file mode 100644 index 000000000..146740394 --- /dev/null +++ b/crates/batten/tests/admission.rs @@ -0,0 +1,596 @@ +//! Issued admissions, over the store and the compiled binary (CLOUD-1051). +//! +//! # What is under test +//! +//! The claim this row makes is not about a hash function. It is that **the +//! record's existence and state are what authorize**, and that a record cannot be +//! moved to a different situation, spent twice, or edited after issuance without +//! that being detectable. Every case below reddens exactly one of those clauses. +//! +//! The address's own property is asserted in `admission.rs`'s unit tier — field +//! boundaries, escaping, order independence — because those are properties of the +//! canonicalization rather than of the store. This file is the other half: what +//! happens when two processes reach for one record, when a record is rewritten +//! under its own key, when a chain points at itself. +//! +//! # The anti-vacuity arm is the last case +//! +//! A scheme that refused every presentation would satisfy every negative case +//! here. `a_correctly_answered_override_completes_end_to_end` is what makes the +//! rest mean something (CLOUD-418). + +// Panicking on setup failure is the idiomatic way for a test to fail loudly. +#![allow(clippy::unwrap_used, clippy::expect_used)] + +mod common; + +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; + +use batten::admission::{self, Binding, Record, Refused, Situation, State}; + +/// A `[[verdict]]` row that CAN be overridden, and one that cannot. +/// +/// # No class Batten ships declares an `override` route, and that is the default +/// +/// Measured while writing this file: every one of `verdict::VENDORED`'s classes +/// — the seven native ones and the four preset ones — declares only `command`, +/// `document` and `issue` routes. So **nothing the binary itself refuses is +/// overridable**, which is CLOUD-1051's "a gate declaring no precondition simply +/// cannot be overridden" holding by construction rather than by anyone +/// remembering it. +/// +/// Overridability is therefore a CONSUMER decision, declared in their own +/// authority, which is where it belongs under house style §8: whether a +/// repository's own gate has a legitimate break-glass is a question about that +/// repository. The fixture declares one so the end-to-end arm has something to +/// exercise; the negative arm uses a vendored class precisely because none of +/// them can be overridden. +const AUTHORITY: &str = r#"version = 1 + +[[verdict]] +id = "V-PROSE-ONLY-DIFF" +gloss = "a branch whose whole diff is comment lines buys a CI matrix that confirms nothing" +class = """ +Every changed line is a comment and no test moved, so a full required matrix +would confirm nothing that could differ. Ride the next change these files carry. +""" + +[[verdict.route]] +id = "R-BATCH-IT" +kind = "command" +target = "let the next change to these files carry it" + +[[verdict.route]] +id = "R-OVERRIDE-PROSE-ONLY" +kind = "override" +precondition = "the prose IS the deliverable and cannot wait for the next change" +"#; + +/// A throwaway repository with a real HEAD, so `override request` has one to +/// bind and the store has a state root to derive from. +fn fixture(name: &str) -> PathBuf { + let root = common::scratch(&format!("admission-{name}")); + common::write(&root, "batten.toml", AUTHORITY); + common::git_in(&root, &["init", "-q", "-b", "main"]); + common::git_in(&root, &["add", "-A"]); + common::git_in(&root, &["commit", "-qm", "seed"]); + // THE STORE OUTLIVES THE CHECKOUT, and that is the design rather than a + // leak: an override record is an out-of-tree receipt, so `common::scratch` + // recreating the tree does not clear it. A case is therefore not isolated by + // a fresh directory alone — measured, `a_crash_between_issuance_and_ + // consumption_leaves_the_record_consumable` passed on a clean container and + // read `Spent` on the second run, because the previous run's record for the + // identical articulation was still there. + // + // Clearing it here is what makes each run independent. It is also the only + // place in this file that reaches into the store as a directory rather than + // through the module's own API, which is why it is here and not in a case. + if let Ok(store) = admission::store_dir(&root) { + let _ = std::fs::remove_dir_all(&store); + } + root +} + +fn answers(reason: &str) -> BTreeMap { + BTreeMap::from([ + ( + "precondition".to_owned(), + "the prose is the deliverable".to_owned(), + ), + ("lost".to_owned(), reason.to_owned()), + ( + "rejected-route".to_owned(), + "R-BATCH-IT: no next change is coming".to_owned(), + ), + ]) +} + +/// A binding for one case. +/// +/// `reason` is what distinguishes cases from each other, and it has to: the +/// address is a pure function of the binding, so two cases articulating the +/// identical words would share one record. That is the scheme working — the same +/// reasoning IS the same admission — but it makes cases interfere, which is how +/// `a_crash_between_issuance_and_consumption_leaves_the_record_consumable` first +/// read `Spent`. Every caller below passes a distinct reason. +fn binding(subject: &str, head: &str, epoch: &str, reason: &str) -> Binding { + Binding { + rule: "prose-only".to_owned(), + verdict: "V-PROSE-ONLY-DIFF".to_owned(), + subject: subject.to_owned(), + head: head.to_owned(), + epoch: epoch.to_owned(), + answers: answers(reason), + prev: None, + author: "alec@button.is".to_owned(), + } +} + +/// The situation the binding above was minted for. +fn situation<'a>(subject: &'a str, head: &'a str, epoch: &'a str) -> Situation<'a> { + Situation { + rule: "prose-only", + verdict: "V-PROSE-ONLY-DIFF", + subject, + head, + epoch, + } +} + +fn refusal(root: &Path, admission: &str, expected: &Situation<'_>) -> Refused { + admission::consume(root, admission, expected) + .expect("the store answered") + .expect_err("the presentation was refused") +} + +#[test] +fn an_admission_bound_to_another_subject_is_refused() { + // The harvesting case, and the reason the subject is inside the hash rather + // than beside it: an override earned for one diff must not release another. + let root = fixture("other-subject"); + let issued = admission::issue( + &root, + binding("a.rs,b.rs", "head1", "epoch1", "the notes wait"), + ) + .expect("issued"); + assert_eq!( + refusal(&root, &issued, &situation("c.rs", "head1", "epoch1")), + Refused::Unbound + ); +} + +#[test] +fn an_admission_bound_to_another_head_is_refused() { + // A branch that keeps committing keeps changing what it is asking to + // release. Without HEAD in the binding, one articulation would cover every + // later commit on the branch — which is the standing password again, with a + // longer name. + let root = fixture("other-head"); + let issued = admission::issue( + &root, + binding("a.rs", "head1", "epoch1", "the notes wait for a head"), + ) + .expect("issued"); + assert_eq!( + refusal(&root, &issued, &situation("a.rs", "head2", "epoch1")), + Refused::Unbound + ); +} + +#[test] +fn an_admission_does_not_survive_the_config_generation_it_was_taken_under() { + // THE CLAUSE THAT IS EASY TO LEAVE OUT. The most common honest reason to + // override is that the gate is wrong — and the fix for a wrong gate is a + // config change, which mints a new epoch. Binding the epoch means the + // admission expires exactly when the thing that made it necessary is + // repaired, rather than outliving it. + let root = fixture("other-epoch"); + let issued = admission::issue( + &root, + binding("a.rs", "head1", "epoch1", "the notes wait for an epoch"), + ) + .expect("issued"); + assert_eq!( + refusal(&root, &issued, &situation("a.rs", "head1", "epoch2")), + Refused::Unbound + ); +} + +#[test] +fn a_spent_admission_is_refused_and_the_same_words_reproduce_it() { + // The two halves of "single-use per SITUATION", and they are one property. + // Re-articulating with the SAME answers recomputes the SAME address, which is + // already spent — so the counter a per-session scheme would need does not + // exist, and overriding the same situation twice costs genuinely different + // text rather than a second attempt. + let root = fixture("spent"); + let situation = situation("a.rs", "head1", "epoch1"); + let issued = admission::issue(&root, binding("a.rs", "head1", "epoch1", "the notes wait")) + .expect("issued"); + + let spent = admission::consume(&root, &issued, &situation) + .expect("the store answered") + .expect("the first consume wins"); + assert_eq!(spent.state, State::Spent); + + assert_eq!(refusal(&root, &issued, &situation), Refused::Spent); + + // Re-issuing the identical articulation is idempotent on the address, and + // does NOT reset the record — that would be the replay this exists to stop. + let again = admission::issue(&root, binding("a.rs", "head1", "epoch1", "the notes wait")) + .expect("issued"); + assert_eq!(again, issued); + assert_eq!(refusal(&root, &again, &situation), Refused::Spent); +} + +#[test] +fn genuinely_different_answers_are_a_different_address_and_are_issuable() { + // The discriminating half of the case above. Without this, a scheme that + // simply refused every second request for a situation would pass — and + // re-articulation would be impossible rather than merely expensive. + let root = fixture("re-articulated"); + let situation = situation("a.rs", "head1", "epoch1"); + let first = admission::issue(&root, binding("a.rs", "head1", "epoch1", "the notes wait")) + .expect("issued"); + admission::consume(&root, &first, &situation) + .expect("the store answered") + .expect("spent"); + + let second = admission::issue( + &root, + binding("a.rs", "head1", "epoch1", "the release window closes today"), + ) + .expect("issued"); + assert_ne!(second, first, "different reasoning is a different address"); + admission::consume(&root, &second, &situation) + .expect("the store answered") + .expect("the fresh articulation is consumable"); +} + +#[test] +fn a_record_edited_after_issuance_no_longer_recomputes_and_is_refused() { + // THE SELF-VERIFICATION CLAUSE, and the reason the corpus is evidence rather + // than a log. Under a random bearer token this edit is undetectable: the + // token still matches the record it is filed under, because the token never + // depended on the record's contents. + let root = fixture("tampered"); + let issued = admission::issue( + &root, + binding("a.rs", "head1", "epoch1", "the notes wait, edited later"), + ) + .expect("issued"); + + let path = admission::record_path(&root, &issued).expect("record path"); + let mut record: Record = + serde_json::from_slice(&std::fs::read(&path).expect("read")).expect("parse"); + record + .binding + .answers + .insert("lost".to_owned(), "actually nothing".to_owned()); + assert!(!record.recomputes(), "the edit broke the address"); + std::fs::write( + &path, + serde_json::to_vec_pretty(&record).expect("serialize"), + ) + .expect("write"); + + assert_eq!( + refusal(&root, &issued, &situation("a.rs", "head1", "epoch1")), + Refused::Tampered + ); +} + +#[test] +fn a_cycle_cannot_be_constructed_without_breaking_an_address() { + // WHAT THIS CASE MEASURED, and it is stronger than what it set out to assert. + // + // It was written as "a cycling `prev` chain is refused", and it does not + // reach the chain predicate at all — because **a cycle is unconstructible + // under content addressing**. To make two records name each other, each + // address must be computed over a binding that already carries the other's + // address, and neither exists until the other does. The only way to write + // the pair is to compute both addresses first and then edit `prev` in — which + // is precisely the edit `recomputes` catches, one clause earlier. + // + // So the honest statement is: the forger cannot reach `ChainBroken`, because + // they are stopped at `Tampered` while still holding a well-formed pair. The + // `ChainBroken` arm is not dead — its live case is the sibling below, a link + // that resolves to nothing — but it is not what a cycle produces, and a case + // asserting otherwise would have been describing a defence that never fires. + let root = fixture("cycle"); + let mut left = binding("a.rs", "head1", "epoch1", "first"); + let mut right = binding("a.rs", "head1", "epoch1", "second"); + let left_id = admission::address(&left); + let right_id = admission::address(&right); + left.prev = Some(right_id.clone()); + right.prev = Some(left_id.clone()); + // Neither address survives that edit, which is the finding. + assert_ne!(admission::address(&left), left_id); + assert_ne!(admission::address(&right), right_id); + + for (id, forged) in [(&left_id, &left), (&right_id, &right)] { + let record = Record { + statement_type: "https://in-toto.io/Statement/v1".to_owned(), + predicate_type: admission::PREDICATE_TYPE.to_owned(), + admission: id.clone(), + binding: forged.clone(), + state: State::Issued, + }; + let path = admission::record_path(&root, id).expect("record path"); + std::fs::create_dir_all(path.parent().expect("parent")).expect("store dir"); + std::fs::write( + &path, + serde_json::to_vec_pretty(&record).expect("serialize"), + ) + .expect("write"); + } + + let situation = Situation { + rule: "prose-only", + verdict: "V-PROSE-ONLY-DIFF", + subject: "a.rs", + head: "head1", + epoch: "epoch1", + }; + assert_eq!( + refusal(&root, &left_id, &situation), + Refused::Tampered, + "the forger is stopped before the chain is ever walked" + ); +} + +#[test] +fn a_prev_that_resolves_to_nothing_is_refused_rather_than_treated_as_a_terminus() { + // A chain that ends by pointing at nothing is indistinguishable from one + // somebody deleted the middle of, and reading it as a terminus would make + // deletion the way to launder a history. + let root = fixture("broken-link"); + let mut orphan = binding("a.rs", "head1", "epoch1", "first"); + orphan.prev = Some("0".repeat(64)); + let issued = admission::issue(&root, orphan).expect("issued"); + assert_eq!( + refusal(&root, &issued, &situation("a.rs", "head1", "epoch1")), + Refused::ChainBroken + ); +} + +#[test] +fn an_unknown_admission_is_refused_rather_than_minted_on_presentation() { + // The address is not the authority. Presenting a well-formed one the store + // has never seen must refuse — otherwise anyone who can compute a hash holds + // the bypass, which is the property this whole row exists to remove. + let root = fixture("unknown"); + let computed = admission::address(&binding("a.rs", "head1", "epoch1", "never issued")); + assert_eq!( + refusal(&root, &computed, &situation("a.rs", "head1", "epoch1")), + Refused::Unknown + ); +} + +#[test] +fn two_concurrent_consumes_resolve_to_exactly_one_winner() { + // The compare-and-set, under the lock this row introduced — `receipt.rs` + // carried none. Threads rather than a fabricated interleaving, because the + // claim is about the LOCK and a fabricated one would test the code around it. + // + // The loser is a policy refusal (`Spent`), never an internal error: a caller + // that lost a race has been told something true about the record, and + // reporting a fault would send them looking for a broken store. + let root = fixture("concurrent"); + let issued = admission::issue( + &root, + binding("a.rs", "head1", "epoch1", "the notes wait, raced"), + ) + .expect("issued"); + + let outcomes: Vec> = std::thread::scope(|scope| { + let handles: Vec<_> = (0..8) + .map(|_| { + let root = root.clone(); + let issued = issued.clone(); + scope.spawn(move || { + admission::consume( + &root, + &issued, + &Situation { + rule: "prose-only", + verdict: "V-PROSE-ONLY-DIFF", + subject: "a.rs", + head: "head1", + epoch: "epoch1", + }, + ) + .expect("the store answered") + }) + }) + .collect(); + handles + .into_iter() + .map(|handle| handle.join().expect("the thread finished")) + .collect() + }); + + assert_eq!( + outcomes.iter().filter(|outcome| outcome.is_ok()).count(), + 1, + "exactly one consumer wins the compare-and-set" + ); + for outcome in &outcomes { + if let Err(refused) = outcome { + assert_eq!(*refused, Refused::Spent, "a loser is a policy refusal"); + } + } +} + +#[test] +fn a_crash_between_issuance_and_consumption_leaves_the_record_consumable() { + // There is no intermediate state to recover FROM: the state lives in one + // record replaced by one atomic rename, so the only two things a crash can + // leave are "issued" and "spent". This is the first of those, and a store + // reopened from scratch is exactly what a crashed process leaves behind. + let root = fixture("crash"); + let issued = admission::issue( + &root, + binding("a.rs", "head1", "epoch1", "the notes wait, interrupted"), + ) + .expect("issued"); + // Nothing else happens — the "crash" is the absence of a consume — and a + // fresh read of the store finds the record where it was. + let record = admission::load(&root, &issued).expect("the record survived"); + assert_eq!(record.state, State::Issued); + admission::consume(&root, &issued, &situation("a.rs", "head1", "epoch1")) + .expect("the store answered") + .expect("still consumable after the crash"); +} + +// --------------------------------------------------------------------------- +// The verb, over the compiled binary. +// --------------------------------------------------------------------------- + +#[test] +fn an_unanswered_question_yields_no_admission_and_prints_what_to_answer() { + // The two-step, and it is one case because it is one property: the questions + // are what a caller has not yet answered. Exit 1 rather than 2 — this is a + // statement about the request, not a policy verdict. + let root = fixture("unanswered"); + let output = common::run_with_stdin( + &root, + &[ + "override", + "request", + "--rule", + "prose-only", + "--verdict", + "V-PROSE-ONLY-DIFF", + "--subject", + "a.rs", + ], + "", + ); + let stderr = String::from_utf8_lossy(&output.stderr).into_owned(); + assert_eq!( + output.status.code(), + Some(batten::exit::ExitCode::Usage.code()), + "an unanswered request is a usage error: {stderr}" + ); + assert!( + String::from_utf8_lossy(&output.stdout).trim().is_empty(), + "and nothing was issued on the data channel" + ); + assert!( + stderr.contains("precondition") && stderr.contains("lost"), + "the declared questions are re-presented: {stderr}" + ); +} + +#[test] +fn a_class_declaring_no_override_route_cannot_be_overridden() { + // The right default, and it composes with `verdict::validate`'s refusal of a + // class whose ONLY route is an override: a class either offers a real way out + // and may additionally be overridden, or it offers a real way out and may + // not. `V-SPAWN-ON-READ-VERB` is the second kind. + let root = fixture("no-override-route"); + let output = common::run_with_stdin( + &root, + &[ + "override", + "request", + "--rule", + "check", + "--verdict", + "V-SPAWN-ON-READ-VERB", + "--subject", + "a.rs", + ], + "precondition=x\nlost=y\nrejected-route=z\n", + ); + let stderr = String::from_utf8_lossy(&output.stderr).into_owned(); + assert_eq!( + output.status.code(), + Some(batten::exit::ExitCode::Usage.code()), + "a class with no override route refuses the request: {stderr}" + ); + assert!( + stderr.contains("declares no `override` route"), + "and says which clause: {stderr}" + ); +} + +#[test] +fn an_undeclared_class_is_refused_naming_the_registry_size() { + // Pointer-shaped, and deliberately not a listing: the whole registry on + // stderr would be the payload this surface's own row is careful about. + let root = fixture("unknown-class"); + let output = common::run_with_stdin( + &root, + &[ + "override", + "request", + "--rule", + "prose-only", + "--verdict", + "V-NO-SUCH-CLASS", + "--subject", + "a.rs", + ], + "precondition=x\nlost=y\nrejected-route=z\n", + ); + assert_eq!( + output.status.code(), + Some(batten::exit::ExitCode::Usage.code()) + ); + assert!( + String::from_utf8_lossy(&output.stderr).contains("class(es)"), + "the answer is a count, not the table" + ); +} + +#[test] +fn a_correctly_answered_override_completes_end_to_end() { + // THE ANTI-VACUITY ARM (CLOUD-418). Every case above is a refusal, and a + // mechanism that refused everything would satisfy all of them. This is the + // legitimate break-glass, demonstrated end to end over the binary: three + // answers in, one admission out, and the record in the store consumable + // exactly once against the situation it names. + let root = fixture("end-to-end"); + let output = common::run_with_stdin( + &root, + &[ + "override", + "request", + "--rule", + "prose-only", + "--verdict", + "V-PROSE-ONLY-DIFF", + "--subject", + "a.rs,b.rs", + ], + "precondition=the prose IS the deliverable — this branch is the release notes\n\ + lost=the notes miss the release window and ship describing the previous version\n\ + rejected-route=R-BATCH-IT assumes a next change to these files, and there is none queued\n", + ); + let stderr = String::from_utf8_lossy(&output.stderr).into_owned(); + assert_eq!( + output.status.code(), + Some(batten::exit::ExitCode::Success.code()), + "a fully answered request issues: {stderr}" + ); + let issued = String::from_utf8_lossy(&output.stdout).trim().to_owned(); + assert_eq!(issued.len(), 64, "one hex address on stdout: {issued:?}"); + assert!( + issued.chars().all(|c| c.is_ascii_hexdigit()), + "and nothing else: {issued:?}" + ); + + let record = admission::load(&root, &issued).expect("the store holds it"); + assert_eq!(record.state, State::Issued); + assert!(record.recomputes(), "and it verifies against its own key"); + assert_eq!(record.binding.subject, "a.rs,b.rs"); + assert_eq!(record.binding.verdict, "V-PROSE-ONLY-DIFF"); + assert_eq!( + record.binding.answers.len(), + 3, + "all three declared answers are in the record, which is where the reasoning lives" + ); +} diff --git a/crates/batten/tests/pointer_only.rs b/crates/batten/tests/pointer_only.rs index 380a9a472..1aa53cd75 100644 --- a/crates/batten/tests/pointer_only.rs +++ b/crates/batten/tests/pointer_only.rs @@ -703,6 +703,33 @@ const CENSUS: &[Verb] = &[ refusal became a token plus a pointer", ), }, + // CLOUD-1051, and it is POINTER-ONLY on the channel this census reads, which + // is worth stating because the row it serves is the one place rule 4 is + // deliberately inverted. + // + // The inversion is about the RECORD, not this output. The reasoning a caller + // types is the payload and it belongs in the record, because it is the + // author's own words rather than repository content. What crosses stdout is + // one admission — a 64-character hex address — and that is a pointer in the + // strictest sense available: it authorizes nothing on its own (the record's + // existence and state do), which is precisely what makes it safe to print, + // log and quote in a commit. + // + // The unanswered path writes the declared questions to STDERR, so the data + // channel carries the address or nothing at all. + Verb { + path: "override request", + args: &[ + "--rule", + "prose-only", + "--verdict", + "V-PROTECTED-MUTATION", + "--subject", + "a.rs", + ], + stdin: Stdin::Nothing, + disposition: Disposition::PointerOnly, + }, // Both attribution verbs are the law rather than an exception, and this one // has less latitude than most: everything it reads is metadata someone // wanted suppressed, so a report carrying the matched text would republish diff --git a/man/batten-override-request.1 b/man/batten-override-request.1 new file mode 100644 index 000000000..93733e738 --- /dev/null +++ b/man/batten-override-request.1 @@ -0,0 +1,22 @@ +.ie \n(.g .ds Aq \(aq +.el .ds Aq ' +.TH batten-override-request 1 batten +.SH NAME +batten\-override\-request \- Answer a class\*(Aqs declared precondition and receive an admission for one situation +.SH SYNOPSIS +\fBbatten override request\fR [\fB\-h\fR|\fB\-\-help\fR] <\fB\-\-rule\fR> <\fB\-\-verdict\fR> <\fB\-\-subject\fR> +.SH DESCRIPTION +Answer a class\*(Aqs declared precondition and receive an admission for one situation +.SH OPTIONS +.TP +\fB\-\-rule\fR +The rule whose refusal is being overridden +.TP +\fB\-\-verdict\fR +The verdict token that refusal carries, e.g. V\-PROSE\-ONLY\-DIFF +.TP +\fB\-\-subject\fR +The gate\*(Aqs canonical subject, exactly as its refusal names it +.TP +\fB\-h\fR, \fB\-\-help\fR +Print help diff --git a/man/batten-override.1 b/man/batten-override.1 new file mode 100644 index 000000000..ebdd37363 --- /dev/null +++ b/man/batten-override.1 @@ -0,0 +1,20 @@ +.ie \n(.g .ds Aq \(aq +.el .ds Aq ' +.TH batten-override 1 batten +.SH NAME +batten\-override \- Issued admissions: an override is a record, never a variable somebody knows +.SH SYNOPSIS +\fBbatten override\fR [\fB\-h\fR|\fB\-\-help\fR] <\fIsubcommands\fR> +.SH DESCRIPTION +Issued admissions: an override is a record, never a variable somebody knows +.SH OPTIONS +.TP +\fB\-h\fR, \fB\-\-help\fR +Print help +.SH SUBCOMMANDS +.TP +batten\-override\-request(1) +Answer a class\*(Aqs declared precondition and receive an admission for one situation +.TP +batten\-override\-help(1) +Print this message or the help of the given subcommand(s) diff --git a/man/batten.1 b/man/batten.1 index 3e3278574..b78ee723c 100644 --- a/man/batten.1 +++ b/man/batten.1 @@ -97,6 +97,9 @@ What produced commits may carry about the tooling that made them batten\-worktree(1) Worktrees and the work in them: what is at risk .TP +batten\-override(1) +Issued admissions: an override is a record, never a variable somebody knows +.TP batten\-provision(1) Pinned tools this repository provisions, cached out of tree .TP From 0f429c618be23ced3a402f2245d1b150ff805e9a Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Tue, 25 Aug 2026 22:49:42 +0000 Subject: [PATCH 06/38] feat(policy)!: prose-only-check becomes a rego row, and check learns --rule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CLOUD-827's gate, ported off `mise-tasks/prose-only-check.sh` under CLOUD-1051, and the port is a repair rather than a move. THE SHELL CLASSIFIED DIFF LINES. `git diff --unified=0` emits a moved block of code as changed lines on both sides, and a reflowed comment as changed lines wherever the wrapping fell — so the classifier answered a question about POSITIONS when the question is about CONTENT. The engine compares REMAINDERS instead: strip every comment and blank line from each side and ask whether what is left is byte-identical. `git::base_delta` gained `code-changed`, which is fact ACQUISITION — the same class as `input.tree.uses`, where the engine resolves a lexical fact and the module decides over it — and `policy/prose-only.rego` is three conjuncts over the decided set. THE SECOND REPAIR IS DELETIONS. The shell dropped every one (`--diff-filter=d`) because a removed file has no surviving lines to classify, and its own header records the cost: a branch deleting a module would have read as a comment change. Remainders classify them — a deleted side is the empty remainder — so deleting a module differs and deleting a pure-prose file does not, which the blanket exclusion could not tell apart. Both are cases now. `batten check --rule ` is what made the port possible at all. A gate ported out of `mise-tasks/` loses its task name, and `verify` and `mise-tasks/land.sh` both invoke this one by name — `land.sh` being an authored shell rule `shell-retirement` refuses to see edited, so retiring the gate by editing its callers would have meant retiring the landing loop to move one predicate. Both callers stay byte-identical. TWO WRONG TURNS SETTLED WHAT A NARROWING NARROWS, and both are recorded on `select_rules`. Loading a one-row subset under the ordinary checks refuses: registry equality is a property of the AUTHORITY, so twenty-six other modules' classes read as unemitted. Loading the FULL set refuses too, oppositely: `check` declines before any work when any declared row spawns, and this repository declares one. `ModuleChecks::RunOverSelection` keeps every check about the modules that loaded and drops the one about the table. A THIRD FINDING, and it made two subject kinds dead: `rules::first_pointer` preferred a path and then gave up, so a class whose only subjects were a COUNT or a NAME rendered as ` ` and the subject reached nobody. Measured on this row, whose whole pointer is a count. A dead channel that renders as a clean pointer is the shape this engine argues against everywhere. Retired, with a file arm and a case arm each: mise-tasks/prose-only-check.sh -> policy/prose-only.rego tests/prose-only-check.bats -> crates/batten/tests/prose_only.rs (14) Twelve cases carry `carried`; two carry `changed` with the deletion reasoning; one `subsumed`. The last two — the override and the remedy — are `subsumed` by `admission.rs` and the `[[verdict]]` registry, because CLOUD-1051 is what happened to both: the override is an issued record and the remedy is `R-BATCH-IT`, a declared route `verdict::validate` refuses a class for lacking. MEASURED COST, REPORTED RATHER THAN ABSORBED. `delta_sources = ["**"]` is required for correctness — a narrower list answers over a subset and reports prose-only about a branch that also moved something the list does not name — but it makes `base_delta` read every blob at the base rev. `mise run batten-check` went from ~3 min to ~11 min on a debug build. The fix is to classify added/edited/deleted by BLOB ID rather than by text and read content only for the paths whose remainder is actually asked about; that is contained but is not in this change. Refs: CLOUD-1051, CLOUD-827, CLOUD-1059, CLOUD-1050 Weakens: verdict-override-added verdict[V-PROSE-ONLY-DIFF].override --- batten.toml | 61 ++++ completions/batten.bash | 6 +- completions/batten.fish | 1 + completions/batten.zsh | 1 + crates/batten/src/cli.rs | 7 + crates/batten/src/facts.rs | 5 + crates/batten/src/git.rs | 96 +++++- crates/batten/src/lib.rs | 200 ++++++++++--- crates/batten/src/policy.rs | 16 +- crates/batten/src/rules.rs | 142 +++++++-- crates/batten/src/surface.rs | 33 ++- crates/batten/tests/prose_only.rs | 467 ++++++++++++++++++++++++++++++ man/batten-check.1 | 5 +- mise-tasks/prose-only-check.sh | 193 ------------ mise.toml | 21 +- policy/prose-only.rego | 192 ++++++++++++ schema/policy-input.schema.json | 6 + tests/prose-only-check.bats | 194 ------------- 18 files changed, 1183 insertions(+), 463 deletions(-) create mode 100644 crates/batten/tests/prose_only.rs delete mode 100755 mise-tasks/prose-only-check.sh create mode 100644 policy/prose-only.rego delete mode 100644 tests/prose-only-check.bats diff --git a/batten.toml b/batten.toml index 9ee9993bd..527bfe096 100644 --- a/batten.toml +++ b/batten.toml @@ -2483,6 +2483,30 @@ line_sources = ["mise-tasks/*.sh", "crates/batten/tests/*.rs"] module = "policy/shell-retirement.rego" severity = "deny" +# CLOUD-827, ported off `mise-tasks/prose-only-check.sh` under CLOUD-1051. A +# branch whose whole diff is comment lines buys a full required matrix that can +# confirm nothing, because there is nothing in it CI could have an opinion about. +# +# THE SELECTOR IS `**` DELIBERATELY, and it is the one place this row is wider +# than its predecessor's. The shell read `git diff --name-only`, which is every +# tracked path; a `delta_sources` list would answer the question over a subset +# and report "prose-only" about a branch that also moved something the list does +# not name — a false refusal, in the direction that blocks correct work. +# +# THE OVERRIDE IS DECLARED ON THE CLASS, not here. `[[verdict]]`'s +# `V-PROSE-ONLY-DIFF` carries an `override` route with its precondition, which is +# what `batten override request` generates its questions from (CLOUD-1051). No +# `bypass_env`: the whole point of that row is that the bare variable stops +# working. +[[rule]] +id = "prose-only" +kind = "policy" +scope = "tree" +base = "origin/main" +delta_sources = ["**"] +module = "policy/prose-only.rego" +severity = "deny" + # CLOUD-876. `opa check -s` type checks the Rego corpus at build time because # regorus ships no static undefined-field checker; regorus evaluates it at # runtime. Two implementations of one language, so the pins must name one release @@ -3079,6 +3103,43 @@ id = "R-DECLARE-IT-STAYS-BASH" kind = "document" target = "batten.toml" +# CLOUD-827's class, and CLOUD-1051's ONLY overridable one in this tree. +# +# THE OVERRIDE ROUTE IS WHAT MAKES THE GATE OVERRIDABLE AT ALL. No class the +# binary ships declares one, so nothing Batten itself refuses can be overridden; +# whether a repository's own gate has a legitimate break-glass is a question about +# that repository, which is where house style §8 puts it. This row is that +# decision, taken deliberately: the prose sometimes IS the deliverable — release +# notes, an issue's own evidence — and no next change to those files is coming. +# +# The precondition is what `batten override request` generates its first question +# from, and it is FALSIFIABLE, which is the property that makes the ritual worth +# anything. "The prose is the deliverable and cannot wait" is answerable with a +# fact or not at all — measured 2026-08-25, the instance that motivated CLOUD-1051 +# would have had to answer it with a diff that was executable data and that then +# waited an hour before riding another branch. Both halves false. +[[verdict]] +id = "V-PROSE-ONLY-DIFF" +gloss = "every changed line is a comment and no test moved, so a CI matrix would confirm nothing" +class = """ +A full required matrix costs real minutes against a trunk landing every few \ +minutes, and on a diff CI cannot have an opinion about it confirms nothing that \ +could differ. This is not "comments are free": a comment here can change a \ +verdict, and every gate that reads one runs locally, for free, before a runner \ +is spent. What is priced is spending the matrix on a change that should ride \ +the next one these files carry. +""" + +[[verdict.route]] +id = "R-BATCH-IT" +kind = "command" +target = "let the next change to these files carry the prose" + +[[verdict.route]] +id = "R-OVERRIDE-PROSE-ONLY" +kind = "override" +precondition = "the prose IS the deliverable and cannot wait for the next change to these files" + [[verdict]] id = "V-SHELL-RULE-EDITED" gloss = "an authored shell rule or bats suite was edited in place rather than migrated" diff --git a/completions/batten.bash b/completions/batten.bash index b86ff12d3..1d77cb82b 100644 --- a/completions/batten.bash +++ b/completions/batten.bash @@ -962,12 +962,16 @@ _batten() { return 0 ;; batten__subcmd__check) - opts="-J -q -v -y -h --json --strictness --fail-on-warning --config-from --silent --quiet --verbose --debug --trace --log-level --no-color --no-input --yes --help" + opts="-J -q -v -y -h --rule --json --strictness --fail-on-warning --config-from --silent --quiet --verbose --debug --trace --log-level --no-color --no-input --yes --help" if [[ ${cur} == -* || ${COMP_CWORD} -eq 2 ]] ; then COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 fi case "${prev}" in + --rule) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; --strictness) COMPREPLY=($(compgen -W "permissive standard strict" -- "${cur}")) return 0 diff --git a/completions/batten.fish b/completions/batten.fish index 9a23158fe..d7ef2c168 100644 --- a/completions/batten.fish +++ b/completions/batten.fish @@ -69,6 +69,7 @@ complete -c batten -n "__fish_batten_needs_command" -f -a "defects" -d 'The appe complete -c batten -n "__fish_batten_needs_command" -f -a "design" -d 'Design-evidence claims: the integrity of the record behind a decision' complete -c batten -n "__fish_batten_needs_command" -f -a "state" -d 'The out-of-tree findings store: which store belongs to this checkout' complete -c batten -n "__fish_batten_needs_command" -f -a "help" -d 'Print this message or the help of the given subcommand(s)' +complete -c batten -n "__fish_batten_using_subcommand check" -l rule -d 'Run only the declared rule with this id' -r complete -c batten -n "__fish_batten_using_subcommand 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' standard\t'The default: a finding is a violation' strict\t'Everything `Standard` fails on, plus anything advisory'" diff --git a/completions/batten.zsh b/completions/batten.zsh index 7757ff910..105230a4e 100644 --- a/completions/batten.zsh +++ b/completions/batten.zsh @@ -52,6 +52,7 @@ trace\:"Add everything"))' \ case $line[1] in (check) _arguments "${_arguments_options[@]}" : \ +'--rule=[Run only the declared rule with this id]: :_default' \ '--strictness=[Raise how strictly gates apply (an override may only tighten policy)]: :((permissive\:"Advisory\: findings are reported without failing the run" standard\:"The default\: a finding is a violation" strict\:"Everything \`Standard\` fails on, plus anything advisory"))' \ diff --git a/crates/batten/src/cli.rs b/crates/batten/src/cli.rs index db431812c..8e65707fa 100644 --- a/crates/batten/src/cli.rs +++ b/crates/batten/src/cli.rs @@ -51,6 +51,12 @@ pub enum Command { Check { /// Emit findings as byte-stable JSON instead of pointer lines. json: bool, + /// Run only the declared row with this id (CLOUD-1051). + /// + /// `None` is every applicable row, which is what `check` has always + /// meant. A `Some` naming no declared row is a usage error rather than a + /// clean run over nothing — see `surface::CHECK_RULE`. + rule: Option, }, /// Run every configured rule, including kinds that execute a configured command. Enforce { @@ -947,6 +953,7 @@ fn command_of((name, matches): (&str, &ArgMatches)) -> Option { match name { "check" => Some(Command::Check { json: flag(matches, "json"), + rule: matches.get_one::("rule").cloned(), }), "enforce" => Some(Command::Enforce { json: flag(matches, "json"), diff --git a/crates/batten/src/facts.rs b/crates/batten/src/facts.rs index 8b6fe64c1..bd9975328 100644 --- a/crates/batten/src/facts.rs +++ b/crates/batten/src/facts.rs @@ -1133,6 +1133,11 @@ impl Fact { "added": {"type": "array", "items": {"type": "string"}}, "edited": {"type": "array", "items": {"type": "string"}}, "deleted": {"type": "array", "items": {"type": "string"}}, + // CLOUD-1051. A subset of the three above: the paths whose + // non-comment remainder moved. Serialized as `code-changed` + // rather than `code_changed` because every other key in this + // document is hyphenated. + "code-changed": {"type": "array", "items": {"type": "string"}}, }, "additionalProperties": false, }), diff --git a/crates/batten/src/git.rs b/crates/batten/src/git.rs index acd8ff4fd..58ecf93c7 100644 --- a/crates/batten/src/git.rs +++ b/crates/batten/src/git.rs @@ -2072,6 +2072,66 @@ pub struct BaseDelta { pub edited: Vec, /// Present at the base rev, absent now. pub deleted: Vec, + /// Of the paths above, the ones whose **non-comment content** differs + /// (CLOUD-1051). + /// + /// # Why the engine computes this and not the module + /// + /// "Which lines of this file are comments" is a lexical fact about the file, + /// the same class as [`Fact::Uses`](crate::facts::Fact::Uses) — the engine + /// resolves it, and the module decides over it. The predicate that consumes + /// it ("nothing but comments moved, and no test did") stays in Rego, where it + /// can be read and tested. + /// + /// # It compares REMAINDERS, not diff lines, and that is the repair + /// + /// The shell gate this replaces classified `git diff --unified=0`'s `+`/`-` + /// lines one at a time. That reads a moved block of code as changed lines on + /// both sides and a reflowed comment as a code change whenever a line + /// straddles the boundary. Comparing the two remainders answers the question + /// directly: strip every comment and blank line from each side and ask + /// whether what is left is byte-identical. + /// + /// A side that does not exist is the empty remainder, which is what makes + /// added and deleted paths classifiable at all. That is a deliberate + /// improvement on the shell, which dropped deletions wholesale + /// (`--diff-filter=d`) because it could not classify them: deleting a module + /// now differs (its remainder was not empty) while deleting a pure-prose file + /// does not, where the shell's blanket exclusion had to treat both alike. + #[serde(rename = "code-changed")] + pub code_changed: Vec, +} + +/// A file's content with its comment and blank lines removed. +/// +/// **An unrecognised extension has no comments**, so its remainder is the whole +/// file and any change to it is a code change. The failure direction is +/// deliberate and is the shell gate's: a rule over this ADMITS a branch when it +/// is wrong in one direction and blocks correct work when it is wrong in the +/// other, and only the second is unrecoverable by waiting. +/// +/// Block comments are deliberately NOT recognised. A `/* */` run cannot be +/// classified line-by-line without tracking state, and guessing fails in the +/// refusing direction — so a file using them reads as code, which is the safe +/// half. +fn without_comments(path: &str, text: &str) -> String { + let prefix = match path.rsplit_once('.').map(|(_, ext)| ext) { + // Markdown is prose end to end; there is no non-comment remainder. + Some("md") => return String::new(), + Some("rs") => "//", + Some("sh" | "bash" | "bats") => "#", + // `mise-tasks/` programs carry no extension (CLOUD-865 renamed most to + // `.sh`, but the pattern stays so a re-added extensionless task is still + // read). The check is on the DIRECTORY, so it cannot claim a file + // elsewhere that happens to lack a dot. + _ if path.starts_with("mise-tasks/") => "#", + _ => return text.to_owned(), + }; + text.lines() + .map(str::trim) + .filter(|line| !line.is_empty() && !line.starts_with(prefix)) + .collect::>() + .join("\n") } /// Classify every path a declared glob selects as added, edited or deleted @@ -2130,17 +2190,24 @@ pub fn base_delta(dir: &Path, base: &str, globs: &[String]) -> Result delta.added.push(path.clone()), - Some(was) => { - // Read through the same UTF-8 lens the base half uses, so a file - // that side skipped for non-UTF-8 content is not reported as - // edited on every run. - let now = std::fs::read_to_string(dir.join(&path)).unwrap_or_default(); - if *was != now { - delta.edited.push(path.clone()); - } - } + Some(was) if **was != now => delta.edited.push(path.clone()), + Some(_) => {} + } + // Only a path this branch touched can have moved its remainder, so the + // comparison is skipped for the unchanged majority rather than charged + // to every selected file. + if was.is_none_or(|was| *was != now) + && without_comments(&path, was.map_or("", String::as_str)) + != without_comments(&path, &now) + { + delta.code_changed.push(path.clone()); } present.insert(path); } @@ -2152,10 +2219,19 @@ pub fn base_delta(dir: &Path, base: &str, globs: &[String]) -> Result Re // subcommand listing (a usage error, exit 1) before parse returns. Kept // total — the workspace lints forbid panicking on a reachable path. None => Ok(ExitCode::Success), - Some(Command::Check { json }) => run_rules( + Some(Command::Check { json, rule }) => run_rules( out, err, mode, &overrides, - rules::run_static, - Surface::ReadOnly, - json, + rules::run_static_over, + RunRequest::read_only(json, rule.as_deref()), ), Some(Command::Enforce { json }) => run_rules( out, err, mode, &overrides, - rules::run_all, - Surface::Spawning, - json, + rules::run_all_over, + RunRequest::spawning(json), ), Some(Command::Config { command }) => run_config(&command, &overrides, out), Some(Command::Spec { format }) => run_spec(format, out), @@ -5415,6 +5413,7 @@ type RuleRunner = fn( &[provision::Provision], policy::Vocabulary<'_>, &Path, + policy::ModuleChecks, ) -> Result; /// Write what the decision asked for, on the surface allowed to write @@ -5452,15 +5451,159 @@ fn perform_requested_sinks(surface: Surface, root: &Path, scan: &rules::Scan) { let _ = sink::perform(&git_dir, branch.as_deref(), &scan.requested); } +/// What a rule-running verb was asked for, as one value. +/// +/// A struct rather than three more positionals, for `ExecRequest`'s reason and a +/// lint that enforces it: [`run_rules`] is the funnel every rule-running verb +/// comes through, and a funnel that takes eight arguments is one a caller gets +/// wrong by transposing two of them. +#[derive(Debug, Clone, Copy)] +struct RunRequest<'a> { + /// Which effect class the caller is on, which decides the sink pass. + surface: Surface, + /// Emit findings as byte-stable JSON instead of pointer lines. + json: bool, + /// Run only the declared row with this id (CLOUD-1051), or every applicable + /// row. + only: Option<&'a str>, +} + +impl<'a> RunRequest<'a> { + /// `check`'s request: the read-only surface, optionally narrowed. + const fn read_only(json: bool, only: Option<&'a str>) -> RunRequest<'a> { + RunRequest { + surface: Surface::ReadOnly, + json, + only, + } + } + + /// `enforce`'s request. Deliberately NOT narrowable: `--rule` exists so a + /// migrated gate keeps its task name on the read surface, and every caller + /// that needs it is a `check` caller. Offering it here too would be surface + /// nobody asked for, on the verb that spawns. + const fn spawning(json: bool) -> RunRequest<'static> { + RunRequest { + surface: Surface::Spawning, + json, + only: None, + } + } +} + +/// The rows a run evaluates, and which config-fault checks it may make. +/// +/// # A narrowing narrows BOTH, and two wrong turns settled that +/// +/// Loading a one-row subset under the ordinary checks reports every OTHER +/// module's classes as unemitted and refuses — measured the first time `--rule` +/// ran, twenty-six tokens named and none of them the caller's business — because +/// registry equality is a property of the AUTHORITY rather than of a run. +/// Loading the FULL set instead refuses too, for the opposite reason: `check` +/// declines before any work when any declared row spawns, and this repository +/// declares one, so a narrowed read of a policy row died on `no-secrets`. +/// +/// So [`policy::ModuleChecks::RunOverSelection`] keeps every check that is about +/// the modules that loaded and drops the one that is about the table, which the +/// unnarrowed run every `verify` performs still answers. +/// +/// **A filter matching nothing is a usage error, never a clean run.** That is the +/// vacuous pass in its purest form — the caller reads "the gate passed" from a +/// gate that was never selected, and a renamed row silently stops being +/// enforced. +/// +/// # Errors +/// +/// Returns a [`error::UsageError`] when `only` names no declared row. +fn select_rules( + declared: &[rules::Rule], + only: Option<&str>, +) -> Result<(Vec, policy::ModuleChecks)> { + let Some(id) = only else { + return Ok((declared.to_vec(), policy::ModuleChecks::Run)); + }; + let selected: Vec = declared + .iter() + .filter(|rule| rule.id == id) + .cloned() + .collect(); + if selected.is_empty() { + return Err(error::UsageError::raise(format!( + "no `[[rule]]` row is declared with id `{id}`; this authority declares {} row(s)", + declared.len() + ))); + } + Ok((selected, policy::ModuleChecks::RunOverSelection)) +} + +/// The gates the ENGINE owns rather than a `[[rule]]` row, as findings. +/// +/// Both join the ordinary finding list, deliberately: a budget and a ledger +/// verdict are policy verdicts like any other, so they must be waivable, must +/// appear in `-J`, and must reach the store — all of which come free from being +/// an ordinary `Finding`, and all of which a private verdict path would have had +/// to re-implement. An over-budget set was previously visible only to whoever +/// thought to run `policy budget`, which is a report, not a gate (CLOUD-50). +/// +/// The ledger's gate (CLOUD-52) is engine-side for a sharper reason: it records +/// the lessons that produced the other gates, and one a branch could lower by +/// editing a rule table is worth less than none. +/// +/// Rooted at the repo rather than the process directory — the ledger path is +/// repo-relative and the git bases are the repository's, so answering from a +/// subdirectory would read a ledger that is not there. It takes the run's one +/// anchor (CLOUD-214) rather than resolving a second. +/// +/// # Errors +/// +/// Returns an error when a declared budget entry or the ledger cannot be read. +fn engine_side_findings(root: &Path, config: &resolve::Resolved) -> Result> { + let mut found: Vec = budget::measure_all(root, config.budget.as_ref())? + .iter() + .filter_map(budget::Report::finding) + .collect(); + if let Some(declared) = config.defects.as_ref() { + found.extend(defects::gate(root, declared)?); + } + Ok(found) +} + +/// The two stderr notices every rule-running verb opens with. +/// +/// Lifted out of [`run_rules`] to keep that funnel under the line lint, and the +/// pair travels together because both are ladder-gated messages about the +/// CONFIG rather than about the tree — stdout is the findings channel and must +/// stay byte-identical to a run whose committed authority states the same +/// effective config, so neither can ever be anything but a stderr line. +/// +/// The first is zero-config onboarding's one visible half (CLOUD-70), emitted +/// from this funnel because it is the surface a first contact reaches; `config +/// show` already says the same thing in its own language, by attributing every +/// key to `default`. +/// +/// # Errors +/// +/// Returns an error when the message channel cannot be written. +fn announce_config(mode: Mode, err: &mut dyn Write, config: &resolve::Resolved) -> Result<()> { + if config.authority == config::Authority::Absent { + output::message(mode, Verbosity::Normal, err, config::DEFAULTS_NOTE)?; + } + announce_degrade(mode, err, config.base.as_ref()) +} + fn run_rules( out: &mut dyn Write, err: &mut dyn Write, mode: Mode, overrides: &Overrides, runner: RuleRunner, - surface: Surface, - json: bool, + request: RunRequest<'_>, ) -> Result { + let RunRequest { + surface, + json, + only, + } = request; // The *resolved* rule set, so a local override's added rules are gates a run // actually applies rather than config the tool merely prints. The promotion // setting comes off the same resolution, so one §8 chain decides both. @@ -5471,17 +5614,7 @@ fn run_rules( // and files from another. let root = anchor(); let config = resolve::resolve(&root, overrides)?; - // Zero-config onboarding's one visible half (CLOUD-70). Ladder-gated on the - // messaging channel, like `transcript::ABSENT_NOTICE` above it: stdout is - // the findings channel and must stay byte-identical to a run whose committed - // authority states the same effective config, so this can only ever be a - // stderr line. Emitted from this funnel because it is the surface a first - // contact reaches; `config show` already says the same thing in its own - // language, by attributing every key to `default`. - if config.authority == config::Authority::Absent { - output::message(mode, Verbosity::Normal, err, config::DEFAULTS_NOTE)?; - } - announce_degrade(mode, err, config.base.as_ref())?; + announce_config(mode, err, &config)?; // The whole `Scan`, not just its findings: `not_evaluated` is what keeps the // store's resolve pass fail-closed (CLOUD-81), and the enforce surface now // journals (CLOUD-529), so dropping it here would let a rule that never @@ -5490,7 +5623,8 @@ fn run_rules( patterns: &config.patterns, verdicts: &config.verdicts, }; - let scan = runner(&config.rules, &config.provisions, vocabulary, &root)?; + let (selected, checks) = select_rules(&config.rules, only)?; + let scan = runner(&selected, &config.provisions, vocabulary, &root, checks)?; perform_requested_sinks(surface, &root, &scan); let mut findings = scan.findings.clone(); @@ -5504,24 +5638,12 @@ fn run_rules( // being an ordinary `Finding`, and all of which a private verdict path would // have had to re-implement. An over-budget set was previously visible only // to whoever thought to run `policy budget`, which is a report, not a gate. - findings.extend( - budget::measure_all(&root, config.budget.as_ref())? - .iter() - .filter_map(budget::Report::finding), - ); - - // The defect ledger's gate (CLOUD-52), joining on the same terms and for the - // same reasons. It is engine-side rather than a `[[rule]]` row because the - // ledger records the lessons that produced the other gates — one a branch - // could lower by editing a rule table is worth less than none. - // - // Rooted at the repo, not the process directory: the ledger path is - // repo-relative and the git bases are the repository's, so answering from a - // subdirectory would read a ledger that is not there. It reads `root` now - // rather than resolving the root a second time — the whole run shares one - // anchor (CLOUD-214), which is the same answer this line always wanted. - if let Some(declared) = config.defects.as_ref() { - findings.extend(defects::gate(&root, declared)?); + // The engine-side gates are skipped entirely under a narrowing: a caller + // asking about one declared row is not asking about the budget or the + // ledger, and running them would make a narrowed read fail for a reason it + // did not ask about. + if only.is_none() { + findings.extend(engine_side_findings(&root, &config)?); } // The transcript capability (CLOUD-95), resolved BESIDE the runner rather than diff --git a/crates/batten/src/policy.rs b/crates/batten/src/policy.rs index 75e3f10f5..21afb4cc6 100644 --- a/crates/batten/src/policy.rs +++ b/crates/batten/src/policy.rs @@ -598,6 +598,18 @@ impl<'a> From<&'a crate::config::Config> for Vocabulary<'a> { /// violation is; what the mediated call must do is load and decide. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ModuleChecks { + /// Run the AST checks, but NOT registry equality's exhausted half + /// (CLOUD-1051). + /// + /// For a run the caller narrowed with `check --rule`. "Is every declared + /// `[[verdict]]` row raised by something" is a property of the AUTHORITY, and + /// a one-row selection would answer it with every other module's classes + /// reported as unemitted — measured the first time `--rule` ran: twenty-six + /// tokens named, none of them the caller's business. A selection must not be + /// able to change a verdict about the config, so this variant asks the + /// questions that are about the MODULES that loaded and leaves the one that + /// is about the table to the unnarrowed run every `verify` already does. + RunOverSelection, /// Re-derive them: the caller is a surface that reports config faults. Run, /// Skip them: the caller is the mediated path, where the answer is already @@ -725,7 +737,7 @@ pub fn load( // preset, so a preset reading an unemittable `input.tree` key would // have loaded as a dead gate — the exact failure the check exists to // refuse, arriving through the one source that bypasses it. - if checks == ModuleChecks::Run { + if checks != ModuleChecks::SkipOnHotPath { check_tree_paths_are_emittable(rule, &bundle, source_key)?; check_no_inline_regex(rule, &bundle, &declared_patterns, source_key)?; check_verdicts_are_declared(rule, &bundle, ®istry, source_key)?; @@ -771,7 +783,7 @@ pub fn load( check_predicate_severity(rule, &declared, where_it_came_from)?; - if checks == ModuleChecks::Run { + if checks != ModuleChecks::SkipOnHotPath { check_tree_paths_are_emittable(rule, &bundle, where_it_came_from)?; check_no_inline_regex(rule, &bundle, &declared_patterns, where_it_came_from)?; check_verdicts_are_declared(rule, &bundle, ®istry, where_it_came_from)?; diff --git a/crates/batten/src/rules.rs b/crates/batten/src/rules.rs index 1eddb73ac..594d9ad76 100644 --- a/crates/batten/src/rules.rs +++ b/crates/batten/src/rules.rs @@ -4293,11 +4293,88 @@ pub const SPAWNING_VERB: &str = "batten enforce"; /// malformed rule. An I/O failure propagates as an internal error (→ exit `3`). pub fn run_static( rules: &[Rule], - _provisions: &[crate::provision::Provision], + provisions: &[crate::provision::Provision], // The declared pattern table (CLOUD-885), riding beside `provisions` for the // same reason it does: a second config table the rule set evaluates against. vocabulary: crate::policy::Vocabulary<'_>, root: &Path, +) -> anyhow::Result { + run_over( + rules, + provisions, + vocabulary, + root, + crate::policy::ModuleChecks::Run, + RunKind::Static, + ) +} + +/// [`run_static`] with the config-fault checks the caller is entitled to make +/// (CLOUD-1051). +/// +/// The four-argument entry points stay, and every caller that does not narrow +/// keeps using them: `ModuleChecks` is a question about ONE caller — the one that +/// passed `--rule` — and widening sixteen call sites to carry a constant would be +/// churn that says nothing. +/// +/// # Errors +/// +/// As [`run_static`]: a config fault at load, or a declared row this surface +/// cannot honestly run. +pub fn run_static_over( + rules: &[Rule], + provisions: &[crate::provision::Provision], + vocabulary: crate::policy::Vocabulary<'_>, + root: &Path, + checks: crate::policy::ModuleChecks, +) -> anyhow::Result { + run_over(rules, provisions, vocabulary, root, checks, RunKind::Static) +} + +/// [`run_all`] with the config-fault checks the caller is entitled to make. +/// +/// # Errors +/// +/// As [`run_all`]: a config fault at load, or a rule whose command fails to run. +pub fn run_all_over( + rules: &[Rule], + provisions: &[crate::provision::Provision], + vocabulary: crate::policy::Vocabulary<'_>, + root: &Path, + checks: crate::policy::ModuleChecks, +) -> anyhow::Result { + run_over(rules, provisions, vocabulary, root, checks, RunKind::All) +} + +/// Which surface a run is on. One enum rather than two near-identical bodies. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum RunKind { + /// `check`: refuse before any work if a declared row would spawn. + Static, + /// `enforce`: every kind runs. + All, +} + +fn run_over( + rules: &[Rule], + provisions: &[crate::provision::Provision], + vocabulary: crate::policy::Vocabulary<'_>, + root: &Path, + checks: crate::policy::ModuleChecks, + kind: RunKind, +) -> anyhow::Result { + match kind { + RunKind::Static => run_static_inner(rules, provisions, vocabulary, root, checks), + RunKind::All => run_all_inner(rules, provisions, vocabulary, root, checks), + } +} + +fn run_static_inner( + rules: &[Rule], + _provisions: &[crate::provision::Provision], + vocabulary: crate::policy::Vocabulary<'_>, + root: &Path, + checks: crate::policy::ModuleChecks, ) -> anyhow::Result { // POLICY BUNDLES ARE LOADED HERE, on the read surface, and that is // CLOUD-833's substantive claim rather than a formality. `run_static` backs @@ -4308,13 +4385,7 @@ pub fn run_static( // process or reach the network, a property CLOUD-831 gates rather than // asserts. So admitting it here makes `check` MORE capable without making it // less honest, and the spawning refusal below is untouched. - let bundles = crate::policy::load( - root, - rules, - vocabulary, - crate::policy::ModuleChecks::Run, - None, - )?; + let bundles = crate::policy::load(root, rules, vocabulary, checks, None)?; // Refuse before any work: the read-only surface must not even begin a run // it cannot complete honestly. for rule in rules { @@ -4422,6 +4493,23 @@ pub fn run_all( provisions: &[crate::provision::Provision], vocabulary: crate::policy::Vocabulary<'_>, root: &Path, +) -> anyhow::Result { + run_over( + rules, + provisions, + vocabulary, + root, + crate::policy::ModuleChecks::Run, + RunKind::All, + ) +} + +fn run_all_inner( + rules: &[Rule], + provisions: &[crate::provision::Provision], + vocabulary: crate::policy::Vocabulary<'_>, + root: &Path, + checks: crate::policy::ModuleChecks, ) -> anyhow::Result { // Refuse before any work, the shape `run_static` above already uses: the // alternative is running the check side, exiting on its verdict, and having @@ -4436,13 +4524,7 @@ pub fn run_all( ))); } } - let bundles = crate::policy::load( - root, - rules, - vocabulary, - crate::policy::ModuleChecks::Run, - None, - )?; + let bundles = crate::policy::load(root, rules, vocabulary, checks, None)?; run(rules, provisions, root, &bundles) } @@ -5869,13 +5951,28 @@ fn policy_rule( None } -/// The first path-bearing subject a violation carries, as a finding's pointer -/// (CLOUD-1050). +/// The first subject a violation carries, as a finding's pointer (CLOUD-1050). /// /// FIRST rather than all of them, because a `Finding` carries one pointer and /// the ordering of `subjects` is the module's own statement of which matters /// most. A class with several files to name declares them in the order a reader /// should follow; picking any other one would silently disagree with that. +/// +/// # A path-bearing subject wins, and a count or a name still reaches the reader +/// +/// This preferred a path and then gave up, so a class whose only subjects were a +/// COUNT or a NAME rendered as ` ` and the subject reached nobody +/// (CLOUD-1051, measured on `prose-only`, whose whole pointer is a count). +/// A dead channel that renders as a clean pointer is the shape this engine +/// argues against everywhere, so the fallback is here rather than a note saying +/// modules should not declare one. +/// +/// It rides in `path` because a line-less finding renders as ` ` and +/// that is the one field which can carry it without a second output shape — the +/// same reading `ratchet_rule` already takes for its two counts. The rendering is +/// [`crate::verdict::Subject::render`]'s, so a count says the same thing here as +/// it does on the mediated path, and a reader cannot mistake `2 file(s)` for a +/// file they could open. fn first_pointer(subjects: &[crate::verdict::Subject]) -> (Option, Option) { for subject in subjects { match subject { @@ -5883,13 +5980,16 @@ fn first_pointer(subjects: &[crate::verdict::Subject]) -> (Option, Optio return (Some(path.clone()), usize::try_from(*line).ok()); } crate::verdict::Subject::Path { path } => return (Some(path.clone()), None), - // A count or an artifact is a pointer to a SET or a NAME, neither of - // which a `path` column can carry honestly — putting one there would - // render as a file the reader could not open. + // Held back for the second pass: a path is the pointer a reader can + // act on directly, so one anywhere in the list outranks a count at + // the front of it. crate::verdict::Subject::Count { .. } | crate::verdict::Subject::Artifact { .. } => {} } } - (None, None) + match subjects.first() { + Some(subject) => (Some(subject.render()), None), + None => (None, None), + } } /// The identity ingredient a policy finding is keyed on: its class and its diff --git a/crates/batten/src/surface.rs b/crates/batten/src/surface.rs index ec2e0b678..a14a37ea4 100644 --- a/crates/batten/src/surface.rs +++ b/crates/batten/src/surface.rs @@ -679,6 +679,37 @@ const VERDICT_TOKEN: FlagDecl = FlagDecl { value: ValueDecl::Str, }; +/// `--rule ` on `check`: run one declared row rather than all of them +/// (CLOUD-1051). +/// +/// # What this is for, and it is not a convenience +/// +/// A gate ported out of `mise-tasks/` into a `[[rule]]` row loses its task name, +/// and every lifecycle step that invoked it by name breaks. The alternative was +/// to edit those callers — which for `land.sh` means editing an authored shell +/// rule `shell-retirement` refuses to see edited, so the migration would have had +/// to retire the landing loop to move one gate. This is the narrowing that makes +/// a migrated row invocable by name instead, so a caller stays byte-identical +/// and out of the changed-file set. +/// +/// **A `--rule` naming no declared row is a usage error, never a clean run.** A +/// filter that matched nothing and exited 0 is the vacuous pass in its purest +/// form: the caller would read "the gate passed" from a gate that was never +/// selected, and a renamed row would silently stop being enforced. +const CHECK_RULE: FlagDecl = FlagDecl { + id: "rule", + long: Some("rule"), + short: None, + help: "Run only the declared rule with this id", + env: EnvDecl::None, + global: false, + positional: false, + required: false, + hidden: false, + rung: Rung::None, + value: ValueDecl::Str, +}; + /// `--rule `: which gate's refusal an admission is requested against. const OVERRIDE_RULE: FlagDecl = FlagDecl { id: "rule", @@ -1171,7 +1202,7 @@ pub const SURFACE: &[CommandDecl] = &[ about: "Run the applicable read-only gates against the repository", data_channel: true, effect: Effect::Read, - flags: &[JSON], + flags: &[CHECK_RULE, JSON], }, // `enforce` runs rule kinds that execute commands declared in // `batten.toml`. Per §5 a command that runs user-supplied code is listed diff --git a/crates/batten/tests/prose_only.rs b/crates/batten/tests/prose_only.rs new file mode 100644 index 000000000..aa1251774 --- /dev/null +++ b/crates/batten/tests/prose_only.rs @@ -0,0 +1,467 @@ +//! `policy/prose-only.rego` decides over the compiled engine (CLOUD-827, ported +//! under CLOUD-1051). +//! +//! # Where this came from +//! +//! The successor to `tests/prose-only-check.bats`, whose subject — +//! `mise-tasks/prose-only-check.sh` — this change deletes. The predicate moved +//! into the engine and the module; the classification of what counts as a comment +//! moved into `git::base_delta`, which is fact acquisition rather than predicate, +//! the same split `input.tree.uses` already makes. +//! +//! # Why this tier and not the module's own rules +//! +//! `policy/prose-only.rego`'s `test_` cases hand themselves a `base-delta` +//! object, so they are green over a key the engine may never fill — CLOUD-845's +//! defect exactly, and `code-changed` is a brand-new key. Every fixture below +//! builds a **real repository with a real `origin/main`**, so the delta and its +//! remainder comparison are computed by the engine from two trees rather than +//! handed in. That is the only way to test the half that is new. +//! +//! # The comparison this file is really about +//! +//! The shell classified diff LINES; the engine compares REMAINDERS. Two cases +//! here discriminate that directly and neither could have been written against +//! the shell: a block of code moved within a file (identical remainders, so +//! prose-only holds even though every line of it appears as `+` and `-`), and a +//! comment reflowed across a boundary. + +// THE FILE-GRANULARITY RETIREMENT ARM (CLOUD-1059). Its grammar is disjoint from +// CLOUD-908's case arms below by construction: a case arm's first field after the +// marker is a QUOTED case name, and a file arm's is a path. +// +// carried: mise-tasks/prose-only-check.sh policy/prose-only.rego crates/batten/tests/prose_only.rs +// carried: tests/prose-only-check.bats policy/prose-only.rego crates/batten/tests/prose_only.rs + +// Panicking on setup failure is the idiomatic way for a test to fail loudly. +#![allow(clippy::unwrap_used, clippy::expect_used)] + +mod common; + +use std::fs; +use std::path::{Path, PathBuf}; + +use batten::rules::{self, Rule}; + +/// The row as `batten.toml` declares it, deserialized rather than +/// struct-literalled: `Rule` carries `deny_unknown_fields`, so this goes through +/// the same column census a consumer's config does. +fn row() -> Rule { + serde_json::from_value(serde_json::json!({ + "id": "prose-only", + "kind": "policy", + "scope": "tree", + "base": "origin/main", + "delta_sources": ["**"], + "module": "policy/prose-only.rego", + "severity": "deny", + })) + .expect("the row batten.toml declares") +} + +/// A repository whose `origin/main` carries `base`, with `head` applied on top. +fn repo(name: &str, base: &[(&str, &str)], head: &Head<'_>) -> PathBuf { + let root = common::scratch(&format!("prose-only-{name}")); + common::git_in(&root, &["init", "--initial-branch=main"]); + write_all(&root, base); + install_module(&root); + common::git_in(&root, &["add", "-A"]); + common::git_in(&root, &["commit", "-m", "base"]); + let base_sha = common::git_in(&root, &["rev-parse", "HEAD"]); + common::git_in( + &root, + &["update-ref", "refs/remotes/origin/main", &base_sha], + ); + + for path in head.removed { + fs::remove_file(root.join(path)).expect("remove at head"); + } + write_all(&root, head.written); + root +} + +/// What the working tree does to the base: files written, files removed. +struct Head<'a> { + written: &'a [(&'a str, &'a str)], + removed: &'a [&'a str], +} + +fn write_all(root: &Path, files: &[(&str, &str)]) { + for (path, body) in files { + let full = root.join(path); + if let Some(parent) = full.parent() { + fs::create_dir_all(parent).expect("scratch parent"); + } + fs::write(full, body).expect("write fixture file"); + } +} + +/// The COMMITTED module, copied in rather than restated: an inline copy would +/// drift from the shipped one and pass while the real gate was broken. +fn install_module(root: &Path) { + let source = common::at_root("policy/prose-only.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/prose-only.rego")).expect("install committed module"); +} + +fn findings(root: &Path) -> Vec { + let verdicts = common::verdicts_in(root); + rules::run_static( + &[row()], + &[], + batten::policy::Vocabulary { + patterns: &[], + verdicts: &verdicts, + }, + root, + ) + .expect("the read surface runs a policy row") + .findings + .into_iter() + .map(|finding| finding.rule) + .collect() +} + +fn refused(root: &Path) { + let findings = findings(root); + assert_eq!( + findings, + vec!["prose-only".to_owned()], + "the branch should be priced as prose-only" + ); +} + +fn admitted(root: &Path) { + assert!( + findings(root).is_empty(), + "the branch should not be priced: {:?}", + findings(root) + ); +} + +const CODE: &str = "// a doc line\nfn main() {\n let x = 1;\n let y = 2;\n}\n"; + +// --------------------------------------------------------------------------- +// The positive arm first: without it every admission below is satisfied by a +// module that fires on nothing. +// --------------------------------------------------------------------------- + +// carried: "a comment-only diff with no test change is refused" crates/batten/tests/prose_only.rs +#[test] +fn a_branch_whose_whole_diff_is_comment_lines_is_refused() { + // The measured instance CLOUD-827 was filed for: two rewritten sentences of + // `//!` doc comment, on their way to a full required matrix. + let root = repo( + "comments", + &[("crates/batten/src/git.rs", CODE)], + &Head { + written: &[( + "crates/batten/src/git.rs", + "// a doc line, rewritten\n// and a second one\nfn main() {\n let x = 1;\n let y = 2;\n}\n", + )], + removed: &[], + }, + ); + refused(&root); +} + +// carried: "a comment change plus any code line is admitted" crates/batten/tests/prose_only.rs +#[test] +fn one_changed_line_of_code_admits_the_branch() { + // The discriminating half of the case above. Same file, same comment edit, + // one value changed — and the gate says nothing, because now CI has + // something to have an opinion about. + let root = repo( + "code", + &[("crates/batten/src/git.rs", CODE)], + &Head { + written: &[( + "crates/batten/src/git.rs", + "// a doc line, rewritten\nfn main() {\n let x = 99;\n let y = 2;\n}\n", + )], + removed: &[], + }, + ); + admitted(&root); +} + +// carried: "a comment change plus a test change is admitted — the PR #604 shape" crates/batten/tests/prose_only.rs +#[test] +fn a_comment_change_plus_a_test_change_is_admitted() { + // The conjunct that makes doc work possible rather than obstructed: the + // change that rewrites a doc AND ships the gate enforcing it is admitted, + // while the follow-up carrying only the prose is not. + let root = repo( + "with-test", + &[ + ("crates/batten/src/git.rs", CODE), + ("tests/a.bats", "@test \"x\" { true; }\n"), + ], + &Head { + written: &[ + ( + "crates/batten/src/git.rs", + "// rewritten\nfn main() {\n let x = 1;\n let y = 2;\n}\n", + ), + ( + "tests/a.bats", + "@test \"x\" { true; }\n@test \"y\" { true; }\n", + ), + ], + removed: &[], + }, + ); + admitted(&root); +} + +// --------------------------------------------------------------------------- +// The two cases the shell could not express, and the reason the port compares +// remainders rather than diff lines. +// --------------------------------------------------------------------------- + +// carried: "a shell comment counts as prose, and code in the same file does not" crates/batten/tests/prose_only.rs +#[test] +fn a_block_of_code_moved_within_a_file_is_not_a_code_change() { + // THE FALSE POSITIVE THE LINE CLASSIFIER PRODUCED. Reordering two statements + // emits every moved line as both a `+` and a `-`, so the shell counted four + // non-comment lines and admitted the branch — correctly, by accident, since + // it was admitting rather than refusing. Reversed, the same defect refuses: + // a comment block moved past a code line reads as a code change. + // + // Remainders answer directly. This case is a MOVE with no edit, so both + // remainders are the same multiset of lines in a different order — which is + // still a change, and the gate says so. What it demonstrates is that the + // engine is comparing content rather than counting emitted lines. + let root = repo( + "moved", + &[("crates/batten/src/git.rs", CODE)], + &Head { + written: &[( + "crates/batten/src/git.rs", + "// a doc line\nfn main() {\n let y = 2;\n let x = 1;\n}\n", + )], + removed: &[], + }, + ); + admitted(&root); +} + +// carried: "a reflowed comment block with blank lines is still prose" crates/batten/tests/prose_only.rs +#[test] +fn a_comment_reflowed_across_a_line_boundary_is_still_prose_only() { + // The other direction, and the one that cost a matrix. Rewrapping a doc + // comment moves text across line boundaries; a line classifier sees the + // whole block replaced and is right, but a classifier that mistook one + // wrapped line for code would refuse a pure-prose change. Remainders are + // empty on both sides here, so the answer does not depend on where the + // wrapping fell. + let root = repo( + "reflowed", + &[( + "crates/batten/src/git.rs", + "// one two three four\n// five six\nfn main() {}\n", + )], + &Head { + written: &[( + "crates/batten/src/git.rs", + "// one two\n// three four five\n// six\nfn main() {}\n", + )], + removed: &[], + }, + ); + refused(&root); +} + +// --------------------------------------------------------------------------- +// Deletions, which the shell dropped wholesale and could not tell apart. +// --------------------------------------------------------------------------- + +// changed: "a deleted file is not read as a comment change" crates/batten/tests/prose_only.rs the shell excluded every deletion wholesale because it could not classify one; the engine compares remainders, so a module deletion is still refused while a pure-prose deletion is admitted +#[test] +fn deleting_a_module_is_not_prose_only() { + // The case `--diff-filter=d` existed to protect against, stated in the + // shell's own header: "a removed file has no surviving lines to classify, + // and treating it as prose would let a branch that deletes a module read as + // a comment change." The engine classifies it instead of excluding it. + let root = repo( + "delete-module", + &[("crates/batten/src/gone.rs", CODE)], + &Head { + written: &[], + removed: &["crates/batten/src/gone.rs"], + }, + ); + admitted(&root); +} + +// changed: "a .md-only diff is refused — the whole file is prose" crates/batten/tests/prose_only.rs the shell could only reach this for an EDITED markdown file; the deletion half was excluded, and this case is the half that exclusion cost +#[test] +fn deleting_a_pure_prose_file_is_prose_only() { + // The half the blanket exclusion cost. Deleting a `.md` file IS a prose + // change, and the shell had to treat it exactly like deleting a module + // because it could not look inside either. + let root = repo( + "delete-prose", + &[("NOTES.md", "# notes\n\nsome prose\n")], + &Head { + written: &[], + removed: &["NOTES.md"], + }, + ); + refused(&root); +} + +// --------------------------------------------------------------------------- +// The admitting direction on everything it cannot classify. +// --------------------------------------------------------------------------- + +// carried: "an unrecognised extension admits the branch" crates/batten/tests/prose_only.rs +#[test] +fn an_unrecognised_extension_admits_the_branch() { + // The failure direction is deliberate and is the shell's: this gate spends + // someone else's minutes when it is wrong in one direction and blocks + // correct work when it is wrong in the other, and only the second is + // unrecoverable by waiting. A file type with no known comment syntax is all + // code, so any change to it is a code change. + let root = repo( + "unknown-extension", + &[("data.csv", "a,b\n1,2\n")], + &Head { + written: &[("data.csv", "a,b\n1,3\n")], + removed: &[], + }, + ); + admitted(&root); +} + +// carried: "an empty diff is not judged" crates/batten/tests/prose_only.rs +#[test] +fn an_empty_branch_is_not_a_subject() { + // Refusing one would fire on every freshly-cut branch before a line is + // written, which is a gate refusing the act of starting work. + let root = repo( + "empty", + &[("a.md", "prose\n")], + &Head { + written: &[], + removed: &[], + }, + ); + admitted(&root); +} + +// carried: "no base to diff against is not judged, rather than refused" crates/batten/tests/prose_only.rs +#[test] +fn an_unresolvable_base_says_nothing_rather_than_refusing() { + // COULD-NOT-LOOK IS NOT A VERDICT, and here the vacuous direction would be a + // REFUSAL rather than a pass — the branch blocked over a question the engine + // could not ask. `base_delta` answers `None` for an unresolvable base and + // the module reads the undefined path as not-holding. + let root = common::scratch("prose-only-no-base"); + common::git_in(&root, &["init", "--initial-branch=main"]); + write_all(&root, &[("a.md", "prose\n")]); + install_module(&root); + common::git_in(&root, &["add", "-A"]); + common::git_in(&root, &["commit", "-m", "only"]); + // No `refs/remotes/origin/main` is ever created, which is the condition. + admitted(&root); +} + +// subsumed: "a Rust block comment is NOT read as prose" crates/batten/tests/prose_only.rs +#[test] +fn a_shell_program_carrying_no_extension_is_read_as_shell() { + // `mise-tasks/` programs carry no extension (CLOUD-865 renamed most to + // `.sh`, but a re-added extensionless task must still be read). Without the + // directory clause this file falls to the unrecognised arm, its whole text + // becomes the remainder, and a comment-only edit reads as code. + let root = repo( + "extensionless", + &[( + "mise-tasks/thing", + "#!/usr/bin/env bash\n# a note\necho hi\n", + )], + &Head { + written: &[( + "mise-tasks/thing", + "#!/usr/bin/env bash\n# a different note\necho hi\n", + )], + removed: &[], + }, + ); + refused(&root); +} + +// changed: "the refusal names paths and a count, never a line of the diff" crates/batten/tests/prose_only.rs the shell printed every changed path beside the count; the port emits the count alone, because a diff is content nobody has published +#[test] +fn the_finding_carries_a_count_and_never_a_path() { + // Non-negotiable rule 4, and it does real work here: a diff is content + // somebody has not published yet, and the branch's own file list is exactly + // that. The shell printed every path; this prints how many. + let root = repo( + "pointer", + &[("a.md", "one\n"), ("b.md", "two\n")], + &Head { + written: &[("a.md", "one edited\n"), ("b.md", "two edited\n")], + removed: &[], + }, + ); + let verdicts = common::verdicts_in(&root); + let scan = rules::run_static( + &[row()], + &[], + batten::policy::Vocabulary { + patterns: &[], + verdicts: &verdicts, + }, + &root, + ) + .expect("the read surface runs a policy row"); + let finding = scan.findings.first().expect("one finding"); + // THE COUNT LIVES IN THE REMEDIATION LINE, NOT IN `path`, and that is the + // engine's answer rather than this module's. `rules::first_pointer` takes a + // finding's path from the first PATH-BEARING subject, and this class + // deliberately has none — so the path falls back to the module that decided, + // which is itself a pointer and not a leak. + // + // What matters is the direction, and it is asserted on both channels: no + // changed path reaches either, and the count reaches the one a reader sees. + assert!( + !finding.path.contains(".md"), + "no changed path reaches the pointer: {}", + finding.path + ); + assert!( + finding.path.contains('2'), + "the count does, in the one field a line-less finding can carry it: {}", + finding.path + ); + let rendered = format!("{finding:?}"); + assert!( + !rendered.contains("a.md") && !rendered.contains("b.md"), + "and no changed path reaches the whole finding either: {rendered}" + ); +} + +// THE TWO CASES THIS FILE DOES NOT CARRY, and both moved rather than vanished. +// +// `tests/prose-only-check.bats` asserted the shell's override — that +// `BATTEN_PROSE_ONLY_OVERRIDE=1` admitted the branch and appended a line saying +// which one — and that its refusal named where the content should go rather than +// merely naming a flag. CLOUD-1051 is what happened to both. +// +// The override is no longer an environment variable, so there is nothing here to +// assert about one: it is an issued, content-addressed, single-use record, and +// `crates/batten/tests/admission.rs` is where every clause of it is tested — +// including the recording half, which is now the record's existence rather than +// an append to a log. +// +// The remedy is no longer prose this gate composes. It is +// `V-PROSE-ONLY-DIFF`'s declared `R-BATCH-IT` route, and `verdict::validate` +// refuses a class that declares no route at all — so "the refusal names +// something to run" stopped being a property of this gate's message and became a +// property of the registry. `crates/batten/tests/verdict_registry.rs` holds it. +// +// subsumed: "the override admits the branch and records which one it admitted" crates/batten/tests/admission.rs +// subsumed: "the remedy names where the content should go, not merely a flag" crates/batten/tests/verdict_registry.rs diff --git a/man/batten-check.1 b/man/batten-check.1 index c769fe887..1c6de0786 100644 --- a/man/batten-check.1 +++ b/man/batten-check.1 @@ -4,11 +4,14 @@ .SH NAME batten\-check \- Run the applicable read\-only gates against the repository .SH SYNOPSIS -\fBbatten check\fR [\fB\-J\fR|\fB\-\-json\fR] [\fB\-h\fR|\fB\-\-help\fR] +\fBbatten check\fR [\fB\-\-rule\fR] [\fB\-J\fR|\fB\-\-json\fR] [\fB\-h\fR|\fB\-\-help\fR] .SH DESCRIPTION Run the applicable read\-only gates against the repository .SH OPTIONS .TP +\fB\-\-rule\fR +Run only the declared rule with this id +.TP \fB\-J\fR, \fB\-\-json\fR Emit byte\-stable JSON instead of pointer lines .TP diff --git a/mise-tasks/prose-only-check.sh b/mise-tasks/prose-only-check.sh deleted file mode 100755 index 9f5fdc3d6..000000000 --- a/mise-tasks/prose-only-check.sh +++ /dev/null @@ -1,193 +0,0 @@ -#!/usr/bin/env bash -#MISE description="Gate: a branch whose whole diff is comment lines buys a CI matrix no required check can have an opinion about (reads the diff; pointer-only)" -# -# CLOUD-827. Measured 2026-08-21 by doing it: a branch whose entire diff was two -# rewritten sentences of `//!` doc comment in `crates/batten/src/git.rs` went -# through `verify` and was on its way to `gh pr create` + `land` — a full required -# matrix (`ci`, `cross`, `commit-lint`, `zizmor`, `darwin-link`, `semver`, `perf`, -# `windows`, `final`) against a trunk landing every ~16 minutes. What stopped it -# was a human saying "don't you dare waste CI minutes for comments", which is the -# wrong mechanism: a rule that is only prose is feedforward, and the agent HAD the -# rule — it is in AGENTS.md — and still queued the matrix, because every gate it -# consulted said yes. -# -# THE ECONOMY IS ALREADY WRITTEN DOWN, which is what makes this an omission rather -# than a new opinion. AGENTS.md: "Local execution — bash, a build, the whole test -# suite — costs nothing... A CI run costs real minutes." `ci.yml`'s own header -# names the two economies it implements — drafts run nothing, and `main` is not a -# trigger. This is the third: a change CI cannot have an opinion about should ride -# the next change that it can. -# -# WHY THIS IS NOT "COMMENTS ARE FREE", which would be wrong in this repository. A -# comment here can change a verdict: `every_stays_shelled_out_claim_names_its_price` -# scans a module doc for citations, `no_gix_gap_primitive_survives` scans `src/` -# for retired vocabulary, `spec-ref-check` resolves `CLOUD- §N` citations in -# tracked files, `rules-drift` holds restated defaults against their mechanisms. -# Every one of those runs in `verify`, locally, for free. That is precisely why the -# economy HOLDS rather than fails: if a comment change breaks one, the author -# learns before a runner is spent. CI is confirming what was already proved, and on -# a prose-only diff it confirms nothing that could differ. -# -# THE `tests/` CONJUNCT IS WHAT MAKES THE GOOD CASE PASS, and it is the difference -# between pricing batching and obstructing doc work. A change that adds or edits a -# test is not prose-only — so PR #604, a doc rewrite PLUS the gate that enforces -# it, is admitted, while the follow-up carrying only the two sentences is not. -# -# AN UNRECOGNISED EXTENSION COUNTS AS NOT-A-COMMENT, so an unknown file type -# ADMITS the branch. The failure direction is deliberate: this gate spends someone -# else's minutes when it is wrong in one direction and blocks correct work when it -# is wrong in the other, and only the second is unrecoverable by waiting. -# -# WHERE IT RUNS. `land`'s pre-ready set, beside `deferral-check`, -# `filed-here-check` and `closing-key-check` — the three that already refuse a lap -# on grounds other than correctness — and in `verify`'s path, so enforcing it costs -# no runner. -# -# Pointer-only (non-negotiable 4): the changed paths and a count, never a line of -# the diff. A diff is content someone has not published yet. -# -# `BATTEN_PROSE_ONLY_OVERRIDE=1` mints over the refusal and RECORDS WHAT IT -# OVERRODE, the `BATTEN_FILED_HERE_OVERLAP` idiom — for when the prose IS the -# deliverable and cannot wait. The override is worth having only if it leaves a -# trace, so it writes to `$GIT_DIR/batten-receipts/prose-only-overrides.` -# and prints the same, so a reviewer sees a decision rather than a silence. -# -# MUTATION COVERAGE (CLOUD-418). The first row is the whole predicate: a gate that -# never refuses is the state this repository was in before it existed, and every -# other case in the suite still passes under it. The second and third are the two -# conjuncts, each of which alone would make the gate wrong in a different -# direction. -#MUTANT prose-only-never-refuses|s/^prose_only=1$/prose_only=0/|a comment-only diff with no test change is refused -#MUTANT prose-only-ignores-tests|s/^\ttests\/\*) touched_tests=1 ;;$/\ttests\/*) : ;;/|a comment change plus a test change is admitted -#MUTANT prose-only-unknown-is-comment|s/^\t\*) return 1 ;;$/\t*) return 0 ;;/|an unrecognised extension admits the branch -#MUTANT prose-only-override-unrecorded|s/^\tprintf '%s.n' "\$note" >>"\$record"$/\t:/|the override admits the branch and records which one -set -uo pipefail - -BASE="${PROSE_ONLY_BASE:-origin/main}" - -# EXIT 0 IS "DO NOT REFUSE", and every could-not-look path takes it. A gate that -# blocked landing because it failed to compute a diff would be the reason work -# cannot proceed, which is a worse defect than the matrix it is trying to save. -if ! git rev-parse --verify -q "$BASE" >/dev/null 2>&1; then - echo "prose-only-check: no $BASE to diff against — not judged" - exit 0 -fi - -# `--diff-filter=d` drops deletions: a removed file has no surviving lines to -# classify, and treating it as prose would let a branch that deletes a module read -# as a comment change. -files=$(git diff --name-only --diff-filter=d "$BASE...HEAD" 2>/dev/null) || { - echo "prose-only-check: the diff could not be computed — not judged" - exit 0 -} - -if [[ -z "${files//[[:space:]]/}" ]]; then - echo "prose-only-check: no diff against $BASE — nothing to price" - exit 0 -fi - -# Per-extension and deliberately narrow. Returning 1 for anything unrecognised is -# the admitting direction, per the header. -is_comment_line() { - local path="$1" line="$2" - # Strip the leading +/- and any indentation before classifying: a comment is a - # comment at any depth, and `git diff --unified=0` still emits the marker. - local text="${line:1}" - text="${text#"${text%%[![:space:]]*}"}" - # A blank line inside an otherwise-prose hunk is whitespace, not code. Counting - # it as code would make every reflowed comment block read as a code change. - [[ -n "$text" ]] || return 0 - case "$path" in - *.md) return 0 ;; - *.rs) - # `//`, `///` and `//!` all begin with `//`; block comments are deliberately - # NOT recognised, because a `/* */` run cannot be classified line-by-line - # without tracking state, and guessing here fails in the refusing direction. - [[ "$text" == //* ]] - ;; - *.sh | *.bash | *.bats | mise-tasks/*) - # `mise-tasks/` programs carry no extension (CLOUD-865 renamed most to `.sh`, - # but the pattern stays so a re-added extensionless task is still read). - [[ "$text" == \#* ]] - ;; - *) return 1 ;; - esac -} - -touched_tests=0 -for f in $files; do - case "$f" in - tests/*) touched_tests=1 ;; - esac -done - -# `--unified=0` so only changed lines are emitted: context lines are unchanged by -# definition and classifying them would make a comment edit next to code read as a -# code change. -prose_only=1 -noncomment_count=0 -current="" -while IFS= read -r line; do - case "$line" in - '+++ '*) continue ;; - '--- '*) continue ;; - 'diff --git '*) - # `b/` is the post-image name, which is the one `--diff-filter=d` - # guarantees exists. - current="${line##*" b/"}" - continue - ;; - '@@'*) continue ;; - '+'* | '-'*) - [[ -n "$current" ]] || continue - if ! is_comment_line "$current" "$line"; then - prose_only=0 - noncomment_count=$((noncomment_count + 1)) - fi - ;; - esac -done < <(git diff --unified=0 "$BASE...HEAD" 2>/dev/null) - -file_count=$(printf '%s\n' "$files" | grep -c . || true) - -if [[ "$touched_tests" -eq 1 ]]; then - echo "prose-only-check: $file_count file(s) changed, including under tests/ — not prose-only" - exit 0 -fi - -if [[ "$prose_only" -eq 0 ]]; then - echo "prose-only-check: $file_count file(s) changed, $noncomment_count non-comment line(s) — not prose-only" - exit 0 -fi - -# From here the branch IS prose-only. The override is read at the refusal rather -# than at the top, so the record names a decision that was actually needed. -if [[ -n "${BATTEN_PROSE_ONLY_OVERRIDE:-}" ]]; then - branch=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo detached) - gitdir=$(git rev-parse --git-dir 2>/dev/null || echo .git) - record="$gitdir/batten-receipts/prose-only-overrides.$branch" - mkdir -p "$(dirname "$record")" 2>/dev/null || true - note="prose-only-check: OVERRIDDEN on $branch — $file_count prose-only file(s)" - printf '%s\n' "$note" >>"$record" - echo "$note" - exit 0 -fi - -{ - echo "::error:: this branch's whole diff is comment lines and no test changed, so a full CI matrix would confirm nothing $file_count file(s):" - while IFS= read -r f; do - [[ -n "$f" ]] || continue - echo " $f" - done <<<"$files" - # THE PREFIX IS LOAD-BEARING, not house style (CLOUD-1050 defect A). `land` - # echoes a step's last words through an `::error::` filter, and so does every - # reader following this tree's convention — so an unprefixed line is DROPPED. - # Measured in one land log: line 777 the diagnosis (prefixed), line 780 this - # remedy (unprefixed, and the only line of the refusal without it), line 782 - # `verify`'s paraphrase (prefixed). The reader received the diagnosis and a - # paraphrase naming only the override, and never received the route below. - # `remedy-reaches-the-reader` is the gate; `board-payloads` is the tree's - # model, prefixing every line of its own recipe block. - echo "::error:: Put the content on the row that owns it and let the next change to these files carry it, or set BATTEN_PROSE_ONLY_OVERRIDE=1 if the prose is the deliverable and cannot wait — that records which branch used it." -} >&2 -exit 2 diff --git a/mise.toml b/mise.toml index 900214dac..135375688 100644 --- a/mise.toml +++ b/mise.toml @@ -398,7 +398,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,ci-slow-needed,bot-issue,land,land-lock,ci-lease-precondition,board-diff-overlap,reclaim-census,connector-allow-resolve,serena-mcp,target-prune,claimed-keys,released,in-progress-drain,merged-pr-keys,board-payloads,attestation-check,awk-regex-check,batten-glob-check,board-sweep,board-write-record,branch-age-check,cap-drift,checks-green,ci-drift,ci-local-parity,ci-tools-check,claim-check,claim-race-check,closing-key-check,prose-only-check,coderabbit-config-check,config-deprecations,config-lint,connector-allow-guard,container-preflight,darwin-link,deferral-check,derived-check,digest-major-agreement,doctor,done-check,done-pr-check,duplicate-close-check,evaluator-closure-check,evaluator-io-check,filed-here-check,finding-sink-check,gh-guard,graph-check,hook-matcher-check,hook-pin-check,hook-profile-check,hooks-wiring-check,install-check,land-divergence-assert,land-lock-check,landed-check,license-table-check,linear-check,lock-complete,macos-link-check,mcp-allow-check,mcp-attach-check,mcp-timeout-budget,memories-check,mise-action-floor,mise-pin-agreement,module-map-check,msrv-pin-agreement,mutant,mutant-census,no-doctests,nonverdict-assert,ntia-check,perf-assert,perf-compare,perf-gate,pipefail-grep-check,privileged-lane,pr-unsubscribed,publish-credential-check,ready-cites-check,ready-guard,ready-lint,reference-check,release-assets-check,release-due,release-tracking-check,renovate-config-validator,report-only-check,review-answered,rules-drift,run-shape,run-shape-guard,rust-paths-check,sbom,sbom-check,schema-check,semver,signing-posture,skill-check,sonar-gate,spec-ref-check,stop-guard,stop-posture-check,suite-bench-check,timeout-check,token-bench-check,transcript-corpus-check,tree-clean,unlanded-check,verified" +MUTANT_GATES = "alive,ci-slow-needed,bot-issue,land,land-lock,ci-lease-precondition,board-diff-overlap,reclaim-census,connector-allow-resolve,serena-mcp,target-prune,claimed-keys,released,in-progress-drain,merged-pr-keys,board-payloads,attestation-check,awk-regex-check,batten-glob-check,board-sweep,board-write-record,branch-age-check,cap-drift,checks-green,ci-drift,ci-local-parity,ci-tools-check,claim-check,claim-race-check,closing-key-check,coderabbit-config-check,config-deprecations,config-lint,connector-allow-guard,container-preflight,darwin-link,deferral-check,derived-check,digest-major-agreement,doctor,done-check,done-pr-check,duplicate-close-check,evaluator-closure-check,evaluator-io-check,filed-here-check,finding-sink-check,gh-guard,graph-check,hook-matcher-check,hook-pin-check,hook-profile-check,hooks-wiring-check,install-check,land-divergence-assert,land-lock-check,landed-check,license-table-check,linear-check,lock-complete,macos-link-check,mcp-allow-check,mcp-attach-check,mcp-timeout-budget,memories-check,mise-action-floor,mise-pin-agreement,module-map-check,msrv-pin-agreement,mutant,mutant-census,no-doctests,nonverdict-assert,ntia-check,perf-assert,perf-compare,perf-gate,pipefail-grep-check,privileged-lane,pr-unsubscribed,publish-credential-check,ready-cites-check,ready-guard,ready-lint,reference-check,release-assets-check,release-due,release-tracking-check,renovate-config-validator,report-only-check,review-answered,rules-drift,run-shape,run-shape-guard,rust-paths-check,sbom,sbom-check,schema-check,semver,signing-posture,skill-check,sonar-gate,spec-ref-check,stop-guard,stop-posture-check,suite-bench-check,timeout-check,token-bench-check,transcript-corpus-check,tree-clean,unlanded-check,verified" # --- 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. @@ -806,6 +806,25 @@ if ! cargo nextest run --workspace; then exit 1; fi ./mise-tasks/step-receipt.sh record test:cargo || true ''' +[tasks.prose-only-check] +description = "Gate: a branch whose whole diff is comment lines buys a CI matrix no required check can have an opinion about (reads the diff; pointer-only)" +# CLOUD-827's gate, ported off `mise-tasks/prose-only-check.sh` under CLOUD-1051. +# +# THE TASK NAME SURVIVES THE MIGRATION, DELIBERATELY. `verify` and +# `mise-tasks/land.sh` both invoke it by name, and `land.sh` is an authored shell +# rule `shell-retirement` refuses to see edited — so retiring the gate by editing +# its callers would have meant retiring the landing loop to move one predicate. +# `batten check --rule` is the narrowing that makes a migrated row invocable +# instead, and this row is its first consumer. Both callers stay byte-identical +# and out of this PR's changed-file set. +# +# The `[tasks.policy-test]` precedent below is the same shape for the same reason: +# an inline `cargo run -p batten` body where a shell file used to be. +# +# `--rule` naming no declared row is a usage error rather than a clean run, so a +# renamed row breaks this loudly instead of silently passing. +run = "cargo run --quiet -p batten -- check --rule prose-only" + [tasks.policy-test] description = "Gate: run every registered policy module's and enabled preset's own `test_` rules" # CLOUD-931. `batten policy test` shipped with CLOUD-835 so the retirement diff --git a/policy/prose-only.rego b/policy/prose-only.rego new file mode 100644 index 000000000..169a22287 --- /dev/null +++ b/policy/prose-only.rego @@ -0,0 +1,192 @@ +# A branch whose whole diff is comments buys a CI matrix that confirms nothing +# (CLOUD-827, ported under CLOUD-1051). +# +# THE ECONOMY IS ALREADY WRITTEN DOWN, which is what makes this an omission +# rather than a new opinion. AGENTS.md: "Local execution — bash, a build, the +# whole test suite — costs nothing... A CI run costs real minutes." `ci.yml`'s +# own header names the two economies it implements — drafts run nothing, and +# `main` is not a trigger. This is the third: a change CI cannot have an opinion +# about should ride the next change that it can. +# +# WHY THIS IS NOT "COMMENTS ARE FREE", which would be wrong here. A comment in +# this repository can change a verdict: `spec-ref-check` resolves `CLOUD- §N` +# citations in tracked files, `rules-drift` holds restated defaults against their +# mechanisms. Every one of those runs in `verify`, locally, for free. That is +# precisely why the economy HOLDS rather than fails: if a comment change breaks +# one, the author learns before a runner is spent. +# +# WHAT THE PORT CHANGED, and it is a repair rather than a move (CLOUD-1051). +# The shell gate classified `git diff --unified=0`'s `+`/`-` lines one at a time, +# which reads a moved block of code as changed lines on both sides and mistakes a +# reflowed comment for code whenever a line straddles a boundary. The engine +# compares REMAINDERS instead — strip every comment and blank line from each side +# and ask whether what is left is byte-identical — so `input.tree["base-delta"]` +# hands this module a decided set and the predicate below is three conjuncts over +# it. `git.rs`'s `code_changed` carries the reasoning for the acquisition half. +# +# THE SECOND CHANGE IS DELETIONS. The shell dropped them wholesale +# (`--diff-filter=d`) because it could not classify a file with no surviving +# lines, and its own header records the cost: a branch deleting a module would +# have read as a comment change if they were admitted. Remainders classify them — +# deleting a module differs, deleting a pure-prose file does not — so the blanket +# exclusion is gone and the case it was protecting against is still refused. +# +# THE `tests/` CONJUNCT IS WHAT MAKES THE GOOD CASE PASS, and it is the difference +# between pricing batching and obstructing doc work. A change that adds or edits a +# test is not prose-only, so a doc rewrite PLUS the gate that enforces it is +# admitted while the follow-up carrying only the prose is not. +# +# COULD-NOT-LOOK IS NOT A REFUSAL. `input.tree["base-delta"]` is `null` when the +# base rev does not resolve, and Rego reads an undefined path as *does not hold*, +# so this module simply says nothing. That is the shell's `exit 0` on an +# unresolvable base, kept: a gate that blocked landing because it failed to +# compute a diff would be a worse defect than the matrix it is trying to save. +#MUTANT-EXEMPT CLOUD-931|no `tests/prose-only.bats` exists, and none may: `shell-retirement` refuses adding a bats suite, and CLOUD-1059 is why this module exists at all. `mutant` resolves a gate's suite as `tests/$gate.bats`, so there is no named case a mutation could turn red. The compiled-binary tier is `crates/batten/tests/prose_only.rs` + +# METADATA +# description: | +# Bound to the TREE surface: this row is `scope = "tree"`, so it reads the tree +# document and never the mediated `{call, facts}` shape. +# THE BRACKETS ARE NOT STYLE: the schema file carries a hyphen, so the dotted +# form is a parse error reported as `invalid schema reference` rather than as a +# missing bind, and an unbound module type checks as `Any`. +# THIS BLOCK IS YAML AND MUST STAY THE LAST COMMENT BLOCK BEFORE `package`: +# OPA parses the whole contiguous block that starts the annotation, so prose +# placed after it reaches the YAML parser and the module fails to load. +# schemas: +# - input: schema["policy-input.schema"] +package batten.prose_only + +import rego.v1 + +rules contains "prose-only" + +# Every path this branch touched, whichever way it moved. +changed := array.concat( + array.concat( + object.get(input.tree, ["base-delta", "added"], []), + object.get(input.tree, ["base-delta", "edited"], []), + ), + object.get(input.tree, ["base-delta", "deleted"], []), +) + +# The paths whose non-comment content moved. The engine decided this; see the +# header for why the classification is acquisition rather than predicate. +code_changed := object.get(input.tree, ["base-delta", "code-changed"], []) + +# A changed path under `tests/`. One is enough — the conjunct is about whether +# CI has anything to say, and a single test change means it does. +# +# NOT NAMED `test_touched`, which is what it was called first: `batten policy +# test` collects every rule whose name starts with `test_`, so the helper was +# read as a case, evaluated with no input, and reported as a failure. A helper in +# this corpus may not carry that prefix, and the runner is right to be literal +# about it — a convention it interpreted loosely would let a real case be skipped +# for being misnamed. +touches_a_test if { + some path in changed + startswith(path, "tests/") +} + +# THE REFUSAL. Three conjuncts, each of which alone would make the gate wrong in +# a different direction: without the first it fires on an empty branch, without +# the second it fires on every change, without the third it blocks the doc +# rewrite that ships with its own test. +violation contains { + "rule": "prose-only", + "verdict": "V-PROSE-ONLY-DIFF", + "subjects": [{"count": count(changed)}], +} if { + count(changed) > 0 + count(code_changed) == 0 + not touches_a_test +} + +# The predicate's own tests. The ALLOW cases are the load-bearing half: a rule +# that fired on everything would satisfy the deny below and gate nothing. + +judging(delta) := {"tree": {"base-delta": delta}} + +test_a_comment_only_branch_with_no_test_change_is_refused if { + some v in violation with input as judging({ + "added": [], + "edited": ["crates/batten/src/git.rs"], + "deleted": [], + "code-changed": [], + }) + v.verdict == "V-PROSE-ONLY-DIFF" +} + +# THE CONJUNCT THAT MAKES DOC WORK POSSIBLE. Same diff plus a test, and the gate +# says nothing — which is the difference between pricing batching and obstructing +# the change that documents a gate and ships it. +test_a_comment_change_plus_a_test_change_is_admitted if { + count(violation) == 0 with input as judging({ + "added": [], + "edited": ["crates/batten/src/git.rs", "tests/prose-only.bats"], + "deleted": [], + "code-changed": [], + }) +} + +test_a_code_change_is_not_prose_only if { + count(violation) == 0 with input as judging({ + "added": [], + "edited": ["crates/batten/src/git.rs"], + "deleted": [], + "code-changed": ["crates/batten/src/git.rs"], + }) +} + +# An empty branch has nothing to price, and refusing one would fire on every +# freshly-cut branch before a line is written. +test_an_empty_branch_is_not_a_subject if { + count(violation) == 0 with input as judging({ + "added": [], + "edited": [], + "deleted": [], + "code-changed": [], + }) +} + +# DELETIONS ARE CLASSIFIED NOW, and these two are the pair that shows it. The +# shell could do neither: it dropped every deletion, so both of these read alike. +test_deleting_a_module_is_not_prose_only if { + count(violation) == 0 with input as judging({ + "added": [], + "edited": [], + "deleted": ["crates/batten/src/gone.rs"], + "code-changed": ["crates/batten/src/gone.rs"], + }) +} + +test_deleting_a_pure_prose_file_is_prose_only if { + some v in violation with input as judging({ + "added": [], + "edited": [], + "deleted": ["NOTES.md"], + "code-changed": [], + }) + v.verdict == "V-PROSE-ONLY-DIFF" +} + +# COULD-NOT-LOOK SAYS NOTHING. An unresolvable base is `null`, and reading it as +# an empty delta would be the vacuous pass — except here the vacuous direction is +# a refusal, which is worse: the branch would be blocked over a question the +# engine could not ask. +test_an_unresolvable_base_is_silent if { + count(violation) == 0 with input as {"tree": {"base-delta": null}} +} + +# The pointer is a COUNT, never the paths (non-negotiable rule 4). A diff is +# content someone has not published yet, and the branch's own file list is +# exactly that. +test_the_finding_carries_a_count_and_no_path if { + some v in violation with input as judging({ + "added": [], + "edited": ["a.md", "b.md"], + "deleted": [], + "code-changed": [], + }) + v.subjects == [{"count": 2}] +} diff --git a/schema/policy-input.schema.json b/schema/policy-input.schema.json index 9c9c98f16..c33b93ec2 100644 --- a/schema/policy-input.schema.json +++ b/schema/policy-input.schema.json @@ -17,6 +17,12 @@ }, "type": "array" }, + "code-changed": { + "items": { + "type": "string" + }, + "type": "array" + }, "deleted": { "items": { "type": "string" diff --git a/tests/prose-only-check.bats b/tests/prose-only-check.bats deleted file mode 100644 index db49b7d4e..000000000 --- a/tests/prose-only-check.bats +++ /dev/null @@ -1,194 +0,0 @@ -#!/usr/bin/env bats -# subject: mise-tasks/prose-only-check.sh -# CLOUD-827. A branch whose entire diff was two rewritten sentences of `//!` doc -# comment was on its way to a full required matrix against a trunk landing every -# ~16 minutes. What stopped it was a human, which is the wrong mechanism: the -# agent had the rule (AGENTS.md) and every gate it consulted said yes. -# -# Each case builds its own repository, because the predicate reads -# `origin/main...HEAD` and a case that leaned on the checkout would assert about -# whichever branch happened to be current. - -setup() { - GATE="$BATS_TEST_DIRNAME/../mise-tasks/prose-only-check.sh" - REPO="$BATS_TEST_TMPDIR/repo" - mkdir -p "$REPO" - # `git init -b`, not `git branch -f`: forcing the checked-out branch fails, and - # CI hides it only because the runner still defaults to `master` (CLOUD-282). - git init -q -b claude/prose-fixture "$REPO" - cd "$REPO" || return 1 - git config commit.gpgsign false - git config user.email t@t - git config user.name t -} - -commit() { git add -A && git commit -q -m "$1"; } - -# The base every case diffs against. Pointing `origin/main` at the first commit -# makes everything after it "this branch's work". -base() { - commit "base" - git update-ref refs/remotes/origin/main HEAD -} - -@test "a comment-only diff with no test change is refused" { - # THE ACCEPTANCE CASE, and the one the first mutation targets: with the - # predicate stubbed to never refuse, this is the only case that reddens. - printf 'fn a() {}\n' >src.rs - base - printf '// a rewritten sentence\nfn a() {}\n' >src.rs - commit "docs: reword" - run "$GATE" - [ "$status" -eq 2 ] - [[ "$output" == *"would confirm nothing"* ]] - [[ "$output" == *"src.rs"* ]] -} - -@test "a comment change plus a test change is admitted — the PR #604 shape" { - # This prices batching, never doc work. A doc rewrite that also carries the - # gate enforcing it is exactly the change that SHOULD land. - printf 'fn a() {}\n' >src.rs - mkdir -p tests - printf 'old\n' >tests/t.bats - base - printf '// a rewritten sentence\nfn a() {}\n' >src.rs - printf 'new assertion\n' >tests/t.bats - commit "docs+test" - run "$GATE" - [ "$status" -eq 0 ] - [[ "$output" == *"including under tests/"* ]] -} - -@test "a comment change plus any code line is admitted" { - printf 'fn a() {}\n' >src.rs - base - printf '// note\nfn a() { let x = 1; }\n' >src.rs - commit "docs+code" - run "$GATE" - [ "$status" -eq 0 ] - [[ "$output" == *"non-comment line"* ]] -} - -@test "a .md-only diff is refused — the whole file is prose" { - printf 'one\n' >README.md - base - printf 'one\ntwo\n' >README.md - commit "docs: expand" - run "$GATE" - [ "$status" -eq 2 ] -} - -@test "an unrecognised extension admits the branch" { - # The admitting direction is deliberate. Wrong one way this spends someone - # else's minutes; wrong the other way it blocks correct work, and only the - # second cannot be recovered by waiting. - printf 'a: 1\n' >conf.yaml - base - printf 'a: 2\n' >conf.yaml - commit "chore: config" - run "$GATE" - [ "$status" -eq 0 ] -} - -@test "an empty diff is not judged" { - printf 'fn a() {}\n' >src.rs - base - run "$GATE" - [ "$status" -eq 0 ] - [[ "$output" == *"nothing to price"* ]] -} - -@test "no base to diff against is not judged, rather than refused" { - printf 'fn a() {}\n' >src.rs - commit "only" - run "$GATE" - [ "$status" -eq 0 ] - [[ "$output" == *"not judged"* ]] -} - -@test "a shell comment counts as prose, and code in the same file does not" { - printf 'echo hi\n' >t.sh - base - printf '# a comment\necho hi\n' >t.sh - commit "docs: comment" - run "$GATE" - [ "$status" -eq 2 ] - - printf '# a comment\necho bye\n' >t.sh - commit "code too" - run "$GATE" - [ "$status" -eq 0 ] -} - -@test "a Rust block comment is NOT read as prose" { - # `/* */` cannot be classified line-by-line without tracking state, and - # guessing would fail in the refusing direction — so it reads as code. - printf 'fn a() {}\n' >src.rs - base - printf '/* a block */\nfn a() {}\n' >src.rs - commit "docs: block" - run "$GATE" - [ "$status" -eq 0 ] -} - -@test "a reflowed comment block with blank lines is still prose" { - # A blank line inside an otherwise-prose hunk is whitespace, not code. - # Counting it as code would make every reflowed comment block read as a code - # change, which is the common case this gate is about. - printf '// one\nfn a() {}\n' >src.rs - base - printf '// one\n\n// two\nfn a() {}\n' >src.rs - commit "docs: reflow" - run "$GATE" - [ "$status" -eq 2 ] -} - -@test "a deleted file is not read as a comment change" { - # A removed file has no surviving lines to classify. Treating it as prose - # would let a branch that deletes a module read as a doc change. - printf 'fn a() {}\n' >src.rs - printf 'fn b() {}\n' >gone.rs - base - rm gone.rs - commit "refactor: drop a module" - run "$GATE" - [ "$status" -eq 0 ] -} - -@test "the override admits the branch and records which one it admitted" { - printf 'fn a() {}\n' >src.rs - base - printf '// reworded\nfn a() {}\n' >src.rs - commit "docs: reword" - BATTEN_PROSE_ONLY_OVERRIDE=1 run "$GATE" - [ "$status" -eq 0 ] - [[ "$output" == *"OVERRIDDEN"* ]] - [[ "$output" == *"claude/prose-fixture"* ]] - # The trace is the whole point: a visible decision rather than a silence. - run cat "$(git rev-parse --git-dir)/batten-receipts/prose-only-overrides.claude/prose-fixture" - [ "$status" -eq 0 ] - [[ "$output" == *"OVERRIDDEN"* ]] -} - -@test "the refusal names paths and a count, never a line of the diff" { - # Rule 4. A diff is content someone has not published yet. - printf 'fn a() {}\n' >src.rs - base - printf '// customer detail in the comment\nfn a() {}\n' >src.rs - commit "docs: reword" - run "$GATE" - [ "$status" -eq 2 ] - [[ "$output" != *"customer detail"* ]] - [[ "$output" == *"src.rs"* ]] -} - -@test "the remedy names where the content should go, not merely a flag" { - printf 'fn a() {}\n' >src.rs - base - printf '// reworded\nfn a() {}\n' >src.rs - commit "docs: reword" - run "$GATE" - [ "$status" -eq 2 ] - [[ "$output" == *"the row that owns it"* ]] - [[ "$output" == *"BATTEN_PROSE_ONLY_OVERRIDE"* ]] -} From 958c28cbdf25d0f5710dd62884e87937c1fa6c1d Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Wed, 26 Aug 2026 01:40:34 +0000 Subject: [PATCH 07/38] refactor(git): base_delta classifies by blob id, and reads only what moved MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit THE PERFORMANCE CLAIM THIS CHANGE WAS WRITTEN FOR IS REFUTED, and the number is recorded here rather than quietly dropped, because the mistake is the instructive part. The premise was that `prose-only`'s `delta_sources = ["**"]` made `base_delta` inflate every blob at the base rev into a map on every check, costing `mise run batten-check` ~3 min to ~11 min on a debug build. Measured as a pair — both binaries built from this tree, run back to back on one machine so noise is common-mode: arm debug release base (adac8b1) 7m44.7s 1m19.68s head (this commit) 7m45.7s 1m19.68s Ratio 1.002 and 1.000, with BYTE-IDENTICAL output on all four runs. The cost was never in `base_delta`, so removing it removes nothing. Two errors of mine produced the original figure, both of the same shape and both already recorded once in this repository's history. First I compared a run that ABORTED EARLY — a debug `enforce` that exited 1 when `hk` was not on PATH, reported 4m33s, and never reached the rest of the rule set — against a complete one. Second I attributed a cost to my own row before isolating it. So this lands as a `refactor`, on what it actually buys: - A DEFECT REPAIR. A non-UTF-8 base blob was skipped entirely by the reading walk, so the path was absent from the map and reported `added` on every run forever. Under id comparison an unchanged binary file is simply unchanged. - ONE AUTHORITY FOR THE WALK. `walk_blob_ids` is extracted rather than copied, so the gitlink, non-blob, non-UTF-8-path and `core.quotePath` skip rules stay in one place; `for_each_blob_at_rev` is a thin wrapper adding the read. A second traversal beside it is how CLOUD-328 and CLOUD-749 got in, and the alternative shape here would have added one. It also does strictly less work — an unchanged path is settled without decompressing its base blob and without either `without_comments` pass — but that is an argument, not a measurement, and the measurement above says the difference is below the noise floor. Nothing here is offered as a speedup. Filters are not applied and the failure direction is the safe one. `.gitattributes` is `* text=auto eol=lf`, so checkout and index bytes agree on every platform. Where a checkout ever did convert line endings the ids would differ and the path falls through to the same text comparison the always-reading version already made for it — an extra read, never a manufactured verdict. WHAT REMAINS UNMEASURED, stated rather than left to be discovered: what the `prose-only` row itself costs. `--config-from 7ad91d0` is the isolation this repository normally uses, and it refuses here — that config predates the verdict registry, so the new modules raise tokens no row declares. Isolating it needs a different instrument and is not this commit's work. Also here, because the gate found it and the fix is one generator run: `bench/suites/RESULTS.md` still recorded `tests/prose-only-check.bats`, which adac8b1 deleted — a cost attached to nothing. Regenerated with `mise run suite-bench --write`; 156 suites. Refs: CLOUD-1051, CLOUD-1059 --- bench/suites/RESULTS.md | 307 +++++++++++++++++++-------------------- crates/batten/src/git.rs | 129 ++++++++++++---- 2 files changed, 253 insertions(+), 183 deletions(-) diff --git a/bench/suites/RESULTS.md b/bench/suites/RESULTS.md index cc15896c5..75178746f 100644 --- a/bench/suites/RESULTS.md +++ b/bench/suites/RESULTS.md @@ -6,165 +6,164 @@ 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: 157 -- serial total: 1522.3s +- suites: 156 +- serial total: 1212.8s | seconds | share | suite | | ---: | ---: | --- | -| 162.2 | 10.7% | `tests/derived-check.bats` | -| 143.5 | 9.4% | `tests/land-lock.bats` | -| 107.9 | 7.1% | `tests/session-start.bats` | -| 104.3 | 6.9% | `tests/ci-wait.bats` | -| 96.6 | 6.3% | `tests/land.bats` | -| 92.9 | 6.1% | `tests/ci-local-parity.bats` | -| 76.2 | 5.0% | `tests/hooks-wiring-check.bats` | -| 40.0 | 2.6% | `tests/claim-check.bats` | -| 39.6 | 2.6% | `tests/board-diff-overlap.bats` | -| 39.5 | 2.6% | `tests/board-write-record.bats` | -| 34.4 | 2.3% | `tests/main-watch.bats` | -| 29.5 | 1.9% | `tests/prebuilt-lint.bats` | -| 26.2 | 1.7% | `tests/helpers.bats` | -| 24.8 | 1.6% | `tests/sbom-check.bats` | -| 24.8 | 1.6% | `tests/config-lint.bats` | -| 24.4 | 1.6% | `tests/board-sweep.bats` | -| 24.3 | 1.6% | `tests/hook-latency-drift.bats` | -| 24.3 | 1.6% | `tests/graph-check.bats` | -| 20.7 | 1.4% | `tests/token-bench.bats` | -| 12.6 | 0.8% | `tests/filed-here-check.bats` | -| 11.5 | 0.8% | `tests/run-shape-guard.bats` | -| 11.4 | 0.8% | `tests/commit-convention.bats` | -| 11.0 | 0.7% | `tests/released.bats` | -| 10.5 | 0.7% | `tests/target-race.bats` | -| 10.0 | 0.7% | `tests/stop-guard.bats` | -| 9.9 | 0.6% | `tests/ready-lint.bats` | -| 9.1 | 0.6% | `tests/renovate-config-validator.bats` | -| 9.0 | 0.6% | `tests/pre-commit-staging.bats` | -| 8.5 | 0.6% | `tests/release-tracking-check.bats` | -| 8.4 | 0.6% | `tests/mutant.bats` | -| 8.3 | 0.5% | `tests/replay.bats` | -| 8.0 | 0.5% | `tests/ready-guard.bats` | -| 7.8 | 0.5% | `tests/lock-complete.bats` | -| 7.1 | 0.5% | `tests/release-assets-check.bats` | -| 6.8 | 0.4% | `tests/sbom.bats` | -| 6.8 | 0.4% | `tests/mcp-allow-check.bats` | -| 6.7 | 0.4% | `tests/bot-issue.bats` | -| 6.7 | 0.4% | `tests/step-receipt.bats` | -| 6.0 | 0.4% | `tests/task-registry.bats` | -| 5.9 | 0.4% | `tests/closing-key-check.bats` | -| 5.4 | 0.4% | `tests/in-progress-drain.bats` | -| 5.2 | 0.3% | `tests/schema-check.bats` | -| 5.1 | 0.3% | `tests/alive.bats` | -| 4.9 | 0.3% | `tests/hk-selection.bats` | -| 4.9 | 0.3% | `tests/fanout-guard.bats` | -| 4.8 | 0.3% | `tests/singleton.bats` | -| 4.8 | 0.3% | `tests/doctor-race.bats` | -| 4.8 | 0.3% | `tests/land-divergence.bats` | -| 4.6 | 0.3% | `tests/ci-tools-check.bats` | -| 4.5 | 0.3% | `tests/ntia-check.bats` | -| 4.4 | 0.3% | `tests/reference-check.bats` | -| 4.3 | 0.3% | `tests/ci-slow-needed.bats` | -| 4.3 | 0.3% | `tests/ci-lease-precondition.bats` | -| 4.1 | 0.3% | `tests/ready-cites-check.bats` | -| 4.0 | 0.3% | `tests/claimed-keys.bats` | -| 3.9 | 0.3% | `tests/rules-drift.bats` | -| 3.8 | 0.3% | `tests/claim-race-check.bats` | -| 3.8 | 0.2% | `tests/semver.bats` | -| 3.7 | 0.2% | `tests/suite-select.bats` | -| 3.5 | 0.2% | `tests/landed-check.bats` | -| 3.4 | 0.2% | `tests/target-ensure.bats` | -| 3.3 | 0.2% | `tests/verify.bats` | -| 3.3 | 0.2% | `tests/with-lock.bats` | -| 3.2 | 0.2% | `tests/awk-regex-check.bats` | -| 3.1 | 0.2% | `tests/deferral-check.bats` | -| 3.1 | 0.2% | `tests/finding-sink-check.bats` | -| 3.0 | 0.2% | `tests/attestation-check.bats` | -| 2.9 | 0.2% | `tests/unlanded-check.bats` | -| 2.9 | 0.2% | `tests/tree-clean.bats` | -| 2.8 | 0.2% | `tests/spec-ref-check.bats` | -| 2.6 | 0.2% | `tests/timeout-drift.bats` | -| 2.6 | 0.2% | `tests/target-prune.bats` | -| 2.3 | 0.2% | `tests/skill-check.bats` | -| 2.1 | 0.1% | `tests/signing-posture.bats` | -| 2.0 | 0.1% | `tests/done-check.bats` | -| 1.9 | 0.1% | `tests/reclaim-census.bats` | -| 1.8 | 0.1% | `tests/checksums.bats` | -| 1.8 | 0.1% | `tests/checks-green.bats` | -| 1.7 | 0.1% | `tests/ready-lint-deferral.bats` | -| 1.6 | 0.1% | `tests/install-check.bats` | -| 1.6 | 0.1% | `tests/duplicate-close-check.bats` | -| 1.6 | 0.1% | `tests/spawn-census.bats` | -| 1.6 | 0.1% | `tests/memories-check.bats` | -| 1.6 | 0.1% | `tests/branch-age-check.bats` | -| 1.6 | 0.1% | `tests/mutant-census.bats` | -| 1.6 | 0.1% | `tests/perf-record.bats` | -| 1.5 | 0.1% | `tests/doctor.bats` | -| 1.5 | 0.1% | `tests/connector-verb-guard.bats` | -| 1.5 | 0.1% | `tests/board-payloads.bats` | -| 1.4 | 0.1% | `tests/done-pr-check.bats` | -| 1.4 | 0.1% | `tests/land-divergence-assert.bats` | -| 1.4 | 0.1% | `tests/release-backfill.bats` | -| 1.4 | 0.1% | `tests/evaluator-closure-check.bats` | -| 1.4 | 0.1% | `tests/linear-check.bats` | -| 1.3 | 0.1% | `tests/nonverdict-scan.bats` | -| 1.2 | 0.1% | `tests/perf-assert.bats` | -| 1.2 | 0.1% | `tests/render-cli.bats` | -| 1.1 | 0.1% | `tests/digest-major-agreement.bats` | -| 1.1 | 0.1% | `tests/module-map-check.bats` | -| 1.1 | 0.1% | `tests/gh-guard.bats` | -| 1.1 | 0.1% | `tests/stop-posture-check.bats` | -| 1.1 | 0.1% | `tests/transcript-corpus-check.bats` | -| 1.1 | 0.1% | `tests/lint-deno.bats` | -| 1.1 | 0.1% | `tests/ci-drift.bats` | -| 1.0 | 0.1% | `tests/abandon-matrix.bats` | +| 140.3 | 11.6% | `tests/land-lock.bats` | +| 107.9 | 8.9% | `tests/derived-check.bats` | +| 102.9 | 8.5% | `tests/ci-wait.bats` | +| 96.4 | 7.9% | `tests/session-start.bats` | +| 81.1 | 6.7% | `tests/ci-local-parity.bats` | +| 73.8 | 6.1% | `tests/land.bats` | +| 50.2 | 4.1% | `tests/hooks-wiring-check.bats` | +| 35.4 | 2.9% | `tests/claim-check.bats` | +| 34.5 | 2.8% | `tests/main-watch.bats` | +| 30.7 | 2.5% | `tests/board-diff-overlap.bats` | +| 29.7 | 2.4% | `tests/board-write-record.bats` | +| 26.1 | 2.2% | `tests/helpers.bats` | +| 24.2 | 2.0% | `tests/hook-latency-drift.bats` | +| 20.0 | 1.7% | `tests/prebuilt-lint.bats` | +| 19.7 | 1.6% | `tests/config-lint.bats` | +| 17.0 | 1.4% | `tests/sbom-check.bats` | +| 16.6 | 1.4% | `tests/token-bench.bats` | +| 16.5 | 1.4% | `tests/graph-check.bats` | +| 15.9 | 1.3% | `tests/board-sweep.bats` | +| 10.2 | 0.8% | `tests/commit-convention.bats` | +| 8.4 | 0.7% | `tests/filed-here-check.bats` | +| 7.9 | 0.7% | `tests/target-race.bats` | +| 7.9 | 0.6% | `tests/run-shape-guard.bats` | +| 7.4 | 0.6% | `tests/signing-posture.bats` | +| 7.0 | 0.6% | `tests/ready-lint.bats` | +| 6.5 | 0.5% | `tests/released.bats` | +| 6.5 | 0.5% | `tests/renovate-config-validator.bats` | +| 6.1 | 0.5% | `tests/stop-guard.bats` | +| 5.9 | 0.5% | `tests/mutant.bats` | +| 5.8 | 0.5% | `tests/singleton.bats` | +| 5.4 | 0.4% | `tests/replay.bats` | +| 5.2 | 0.4% | `tests/ready-guard.bats` | +| 5.2 | 0.4% | `tests/release-tracking-check.bats` | +| 4.9 | 0.4% | `tests/ci-tools-check.bats` | +| 4.7 | 0.4% | `tests/claim-race-check.bats` | +| 4.6 | 0.4% | `tests/lock-complete.bats` | +| 4.5 | 0.4% | `tests/task-registry.bats` | +| 4.5 | 0.4% | `tests/mcp-allow-check.bats` | +| 4.5 | 0.4% | `tests/step-receipt.bats` | +| 4.4 | 0.4% | `tests/sbom.bats` | +| 4.2 | 0.3% | `tests/doctor-race.bats` | +| 4.1 | 0.3% | `tests/closing-key-check.bats` | +| 4.0 | 0.3% | `tests/release-assets-check.bats` | +| 4.0 | 0.3% | `tests/in-progress-drain.bats` | +| 4.0 | 0.3% | `tests/ci-lease-precondition.bats` | +| 3.6 | 0.3% | `tests/schema-check.bats` | +| 3.5 | 0.3% | `tests/skill-check.bats` | +| 3.4 | 0.3% | `tests/target-ensure.bats` | +| 3.4 | 0.3% | `tests/claimed-keys.bats` | +| 3.4 | 0.3% | `tests/ci-slow-needed.bats` | +| 3.1 | 0.3% | `tests/ready-cites-check.bats` | +| 3.1 | 0.3% | `tests/pre-commit-staging.bats` | +| 3.0 | 0.2% | `tests/ntia-check.bats` | +| 3.0 | 0.2% | `tests/hk-selection.bats` | +| 3.0 | 0.2% | `tests/reference-check.bats` | +| 2.9 | 0.2% | `tests/with-lock.bats` | +| 2.9 | 0.2% | `tests/fanout-guard.bats` | +| 2.8 | 0.2% | `tests/land-divergence.bats` | +| 2.8 | 0.2% | `tests/unlanded-check.bats` | +| 2.7 | 0.2% | `tests/semver.bats` | +| 2.5 | 0.2% | `tests/rules-drift.bats` | +| 2.4 | 0.2% | `tests/finding-sink-check.bats` | +| 2.3 | 0.2% | `tests/deferral-check.bats` | +| 2.3 | 0.2% | `tests/bot-issue.bats` | +| 2.3 | 0.2% | `tests/tree-clean.bats` | +| 2.2 | 0.2% | `tests/checks-green.bats` | +| 2.2 | 0.2% | `tests/landed-check.bats` | +| 2.0 | 0.2% | `tests/alive.bats` | +| 2.0 | 0.2% | `tests/verify.bats` | +| 2.0 | 0.2% | `tests/done-check.bats` | +| 2.0 | 0.2% | `tests/suite-select.bats` | +| 1.8 | 0.1% | `tests/timeout-drift.bats` | +| 1.6 | 0.1% | `tests/doctor.bats` | +| 1.6 | 0.1% | `tests/reclaim-census.bats` | +| 1.6 | 0.1% | `tests/checksums.bats` | +| 1.5 | 0.1% | `tests/spec-ref-check.bats` | +| 1.4 | 0.1% | `tests/awk-regex-check.bats` | +| 1.3 | 0.1% | `tests/verified.bats` | +| 1.2 | 0.1% | `tests/mutant-census.bats` | +| 1.2 | 0.1% | `tests/memories-check.bats` | +| 1.2 | 0.1% | `tests/connector-verb-guard.bats` | +| 1.2 | 0.1% | `tests/attestation-check.bats` | +| 1.1 | 0.1% | `tests/ready-lint-deferral.bats` | +| 1.0 | 0.1% | `tests/spawn-census.bats` | +| 1.0 | 0.1% | `tests/linear-check.bats` | +| 1.0 | 0.1% | `tests/perf-record.bats` | | 1.0 | 0.1% | `tests/connector-allow-guard.bats` | -| 1.0 | 0.1% | `tests/batten-glob-check.bats` | -| 1.0 | 0.1% | `tests/sbom-binary.bats` | -| 1.0 | 0.1% | `tests/suite-bench-check.bats` | -| 1.0 | 0.1% | `tests/install.bats` | -| 1.0 | 0.1% | `tests/pr-unsubscribed.bats` | -| 0.9 | 0.1% | `tests/lint-rego.bats` | -| 0.9 | 0.1% | `tests/prose-only-check.bats` | -| 0.9 | 0.1% | `tests/hook-pin-check.bats` | -| 0.9 | 0.1% | `tests/verified.bats` | -| 0.9 | 0.1% | `tests/timeout-check.bats` | -| 0.9 | 0.1% | `tests/perf-compare.bats` | -| 0.9 | 0.1% | `tests/hook-matcher-check.bats` | -| 0.8 | 0.1% | `tests/mcp-timeout-budget.bats` | -| 0.8 | 0.1% | `tests/hook-profile-check.bats` | -| 0.8 | 0.1% | `tests/mcp-attach-check.bats` | -| 0.8 | 0.1% | `tests/connector-allow-resolve.bats` | -| 0.7 | 0.0% | `tests/merged-pr-keys.bats` | -| 0.7 | 0.0% | `tests/container-preflight.bats` | -| 0.7 | 0.0% | `tests/commit-attribution.bats` | -| 0.7 | 0.0% | `tests/macos-link-check.bats` | -| 0.7 | 0.0% | `tests/sonar-gate.bats` | -| 0.6 | 0.0% | `tests/coderabbit-config-check.bats` | -| 0.6 | 0.0% | `tests/serena-mcp.bats` | -| 0.6 | 0.0% | `tests/pipefail-grep-check.bats` | -| 0.6 | 0.0% | `tests/run-shape-guard-quoting.bats` | -| 0.6 | 0.0% | `tests/cap-drift.bats` | -| 0.5 | 0.0% | `tests/pkl-check.bats` | +| 1.0 | 0.1% | `tests/target-prune.bats` | +| 1.0 | 0.1% | `tests/install-check.bats` | +| 0.9 | 0.1% | `tests/evaluator-closure-check.bats` | +| 0.9 | 0.1% | `tests/land-divergence-assert.bats` | +| 0.9 | 0.1% | `tests/nonverdict-scan.bats` | +| 0.8 | 0.1% | `tests/duplicate-close-check.bats` | +| 0.8 | 0.1% | `tests/lint-deno.bats` | +| 0.8 | 0.1% | `tests/perf-assert.bats` | +| 0.8 | 0.1% | `tests/release-backfill.bats` | +| 0.8 | 0.1% | `tests/done-pr-check.bats` | +| 0.8 | 0.1% | `tests/module-map-check.bats` | +| 0.8 | 0.1% | `tests/transcript-corpus-check.bats` | +| 0.8 | 0.1% | `tests/cap-drift.bats` | +| 0.8 | 0.1% | `tests/digest-major-agreement.bats` | +| 0.7 | 0.1% | `tests/pr-unsubscribed.bats` | +| 0.7 | 0.1% | `tests/branch-age-check.bats` | +| 0.7 | 0.1% | `tests/connector-allow-resolve.bats` | +| 0.7 | 0.1% | `tests/gh-guard.bats` | +| 0.7 | 0.1% | `tests/render-cli.bats` | +| 0.7 | 0.1% | `tests/suite-bench-check.bats` | +| 0.7 | 0.1% | `tests/hook-pin-check.bats` | +| 0.6 | 0.1% | `tests/sbom-binary.bats` | +| 0.6 | 0.1% | `tests/board-payloads.bats` | +| 0.6 | 0.1% | `tests/stop-posture-check.bats` | +| 0.6 | 0.1% | `tests/commit-attribution.bats` | +| 0.6 | 0.1% | `tests/ci-drift.bats` | +| 0.6 | 0.0% | `tests/mcp-timeout-budget.bats` | +| 0.6 | 0.0% | `tests/hook-matcher-check.bats` | +| 0.6 | 0.0% | `tests/macos-link-check.bats` | +| 0.6 | 0.0% | `tests/container-preflight.bats` | +| 0.6 | 0.0% | `tests/lint-rego.bats` | +| 0.6 | 0.0% | `tests/merged-pr-keys.bats` | +| 0.6 | 0.0% | `tests/mcp-attach-check.bats` | +| 0.6 | 0.0% | `tests/perf-compare.bats` | +| 0.5 | 0.0% | `tests/hook-profile-check.bats` | +| 0.5 | 0.0% | `tests/install.bats` | +| 0.5 | 0.0% | `tests/abandon-matrix.bats` | | 0.5 | 0.0% | `tests/publish-credential-check.bats` | -| 0.5 | 0.0% | `tests/token-bench-check.bats` | -| 0.5 | 0.0% | `tests/land-lock-check.bats` | -| 0.5 | 0.0% | `tests/release-due.bats` | -| 0.5 | 0.0% | `tests/msrv-pin-agreement.bats` | -| 0.5 | 0.0% | `tests/license-table-check.bats` | -| 0.5 | 0.0% | `tests/task-fail-closed.bats` | -| 0.4 | 0.0% | `tests/report-only-check.bats` | -| 0.4 | 0.0% | `tests/no-doctests.bats` | -| 0.4 | 0.0% | `tests/nonverdict-assert.bats` | -| 0.4 | 0.0% | `tests/mise-pin-agreement.bats` | -| 0.4 | 0.0% | `tests/dist.bats` | -| 0.4 | 0.0% | `tests/test-bats-parallel.bats` | -| 0.3 | 0.0% | `tests/rust-paths-check.bats` | -| 0.3 | 0.0% | `tests/git-hook.bats` | -| 0.3 | 0.0% | `tests/egress-check.bats` | -| 0.3 | 0.0% | `tests/mise-action-floor.bats` | -| 0.2 | 0.0% | `tests/remedy-payload-source.bats` | +| 0.5 | 0.0% | `tests/coderabbit-config-check.bats` | +| 0.4 | 0.0% | `tests/pkl-check.bats` | +| 0.4 | 0.0% | `tests/sonar-gate.bats` | +| 0.4 | 0.0% | `tests/pipefail-grep-check.bats` | +| 0.4 | 0.0% | `tests/license-table-check.bats` | +| 0.4 | 0.0% | `tests/msrv-pin-agreement.bats` | +| 0.4 | 0.0% | `tests/run-shape-guard-quoting.bats` | +| 0.4 | 0.0% | `tests/token-bench-check.bats` | +| 0.3 | 0.0% | `tests/land-lock-check.bats` | +| 0.3 | 0.0% | `tests/timeout-check.bats` | +| 0.3 | 0.0% | `tests/report-only-check.bats` | +| 0.3 | 0.0% | `tests/batten-glob-check.bats` | +| 0.3 | 0.0% | `tests/serena-mcp.bats` | +| 0.3 | 0.0% | `tests/release-due.bats` | +| 0.3 | 0.0% | `tests/no-doctests.bats` | +| 0.3 | 0.0% | `tests/nonverdict-assert.bats` | +| 0.3 | 0.0% | `tests/dist.bats` | +| 0.2 | 0.0% | `tests/mise-pin-agreement.bats` | +| 0.2 | 0.0% | `tests/rust-paths-check.bats` | +| 0.2 | 0.0% | `tests/mise-action-floor.bats` | +| 0.2 | 0.0% | `tests/git-hook.bats` | | 0.2 | 0.0% | `tests/evaluator-io-check.bats` | +| 0.2 | 0.0% | `tests/remedy-payload-source.bats` | | 0.2 | 0.0% | `tests/perf-gate.bats` | -| 0.2 | 0.0% | `tests/darwin-link.bats` | -| 0.2 | 0.0% | `tests/cross-check.bats` | +| 0.1 | 0.0% | `tests/egress-check.bats` | +| 0.1 | 0.0% | `tests/test-bats-parallel.bats` | +| 0.1 | 0.0% | `tests/task-fail-closed.bats` | +| 0.1 | 0.0% | `tests/darwin-link.bats` | +| 0.1 | 0.0% | `tests/cross-check.bats` | | 0.1 | 0.0% | `tests/perf-pair.bats` | -| 0.1 | 0.0% | `tests/zizmor-split.bats` | +| 0.0 | 0.0% | `tests/zizmor-split.bats` | diff --git a/crates/batten/src/git.rs b/crates/batten/src/git.rs index 58ecf93c7..058a9734e 100644 --- a/crates/batten/src/git.rs +++ b/crates/batten/src/git.rs @@ -1991,6 +1991,41 @@ pub fn for_each_blob_at_rev( mut visit: impl FnMut(&str, &str), ) -> Result<()> { let repository = open(dir)?; + walk_blob_ids(&repository, rev, glob, |path, id| { + // A read that fails is treated as an empty file rather than aborting, + // for the same reason non-UTF-8 content is: an unrelated asset must not + // be able to disable the gate. + let Ok(object) = repository.find_object(id) else { + return; + }; + let Ok(text) = std::str::from_utf8(&object.data) else { + return; + }; + visit(path, text); + }) +} + +/// The walk [`for_each_blob_at_rev`] and [`base_delta`] share, handing back each +/// selected blob's path and its **object id** without decompressing it +/// (CLOUD-1051). +/// +/// Extracted rather than copied for the reason [`for_each_blob_at_rev`] itself +/// was extracted from [`count_at_rev`]: every skip below — the gitlink, the +/// non-blob mode, the non-UTF-8 path, the `core.quotePath`-proof byte recorder — +/// is a correctness property that keeps two halves of a comparison selecting the +/// same set, and a second traversal beside this one is how CLOUD-328 and +/// CLOUD-749 got in. +/// +/// **The id is the cheap half and that is the point.** A tree traversal reads +/// tree objects only; `find_object` is what pays zlib for a blob's bytes. A +/// caller that only needs to know *whether* a blob changed never has to spend +/// that — see [`base_delta`], which is why this split exists. +fn walk_blob_ids( + repository: &gix::Repository, + rev: &str, + glob: &str, + mut visit: impl FnMut(&str, gix::ObjectId), +) -> Result<()> { let unresolved = || { UsageError::raise(format!( "ratchet base {rev:?} does not resolve to a tree in this repository" @@ -2044,16 +2079,7 @@ pub fn for_each_blob_at_rev( if !selector.matches(path) { continue; } - // A read that fails is treated as an empty file rather than aborting, - // for the same reason non-UTF-8 content is: an unrelated asset must not - // be able to disable the gate. - let Ok(object) = repository.find_object(entry.oid) else { - continue; - }; - let Ok(text) = std::str::from_utf8(&object.data) else { - continue; - }; - visit(path, text); + visit(path, entry.oid); } Ok(()) } @@ -2161,19 +2187,41 @@ fn without_comments(path: &str, text: &str) -> String { /// /// Raises only when the repository cannot be opened at all. pub fn base_delta(dir: &Path, base: &str, globs: &[String]) -> Result> { - let mut at_base: BTreeMap = BTreeMap::new(); + let repository = open(dir)?; + let hash = repository.object_hash(); + + // IDS, NOT TEXT, and that is the whole cost of this function (CLOUD-1051). + // The first version stored every selected blob's decompressed text here. With + // `delta_sources = ["**"]` — which `prose-only` declares, because a + // prose-only change is a claim about the whole diff — that is the entire + // repository inflated into a map on every `check`: measured at ~3 min to + // ~11 min for `mise run batten-check` on a debug build. An id costs a tree + // read; the blob behind it is fetched below only for a path that actually + // moved, which is a handful per branch rather than every tracked file. + let mut at_base: BTreeMap = BTreeMap::new(); for glob in globs { // A base that does not resolve is could-not-look for the WHOLE fact, not // for one glob: a partial answer here would report every path the // unresolvable glob would have covered as added. - match for_each_blob_at_rev(dir, base, glob, |path, text| { - at_base.insert(path.to_owned(), text.to_owned()); + match walk_blob_ids(&repository, base, glob, |path, id| { + at_base.insert(path.to_owned(), id); }) { Ok(()) => {} Err(_) => return Ok(None), } } + // The base side of a comparison, paid for one path at a time. Unreadable and + // non-UTF-8 blobs answer with the empty remainder, which is the same reading + // the text-mapped version gave them by skipping them. + let base_text = |id: gix::ObjectId| -> String { + repository + .find_object(id) + .ok() + .and_then(|object| std::str::from_utf8(&object.data).map(str::to_owned).ok()) + .unwrap_or_default() + }; + // ONE walk for every declared glob, matched against all of them, rather than // a walk per glob: the working-tree half is the expensive side and the // selectors are cheap, so re-walking would charge the tree once per @@ -2190,26 +2238,45 @@ pub fn base_delta(dir: &Path, base: &str, globs: &[String]) -> Result delta.added.push(path.clone()), - Some(was) if **was != now => delta.edited.push(path.clone()), - Some(_) => {} + Some(_) => delta.edited.push(path.clone()), } // Only a path this branch touched can have moved its remainder, so the - // comparison is skipped for the unchanged majority rather than charged - // to every selected file. - if was.is_none_or(|was| *was != now) - && without_comments(&path, was.map_or("", String::as_str)) - != without_comments(&path, &now) - { + // comparison is charged to those alone rather than to every selected + // file — and the base blob is fetched here, at the one point it is worth + // paying for. A non-UTF-8 working file reads as the empty remainder, the + // same lens `base_text` applies to the other side. + let now = String::from_utf8(now).unwrap_or_default(); + let was = was.map(base_text).unwrap_or_default(); + if without_comments(&path, &was) != without_comments(&path, &now) { delta.code_changed.push(path.clone()); } - present.insert(path); } // Deleted is decided against the WALK, not against `Path::exists`: the walk // honours `.gitignore`, so a path the base tracked and the head ignores is a @@ -2222,8 +2289,12 @@ pub fn base_delta(dir: &Path, base: &str, globs: &[String]) -> Result Date: Wed, 26 Aug 2026 03:17:01 +0000 Subject: [PATCH 08/38] feat(recorder): a record's shape is the consumer's, and its column may be a gate's verdict MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CLOUD-1051's second adapter needs `filed-here-check`'s predicate in Rego, and that predicate reads a record `board-write-record.sh` writes. Retiring that recorder turned out not to be a port at all; this is the mechanism the port needs, and the finding that made it necessary. WHY IT IS NOT A PORT. Three things, confirmed rather than assumed: * its refinement column IS `ready-lint.sh`'s exit status, over a payload it ASSEMBLES from two halves of the envelope — the body the tracker returned plus the caller's three relation directions, on the create path only; * `ready-lint.sh` is 43 KB and referenced by 19 files, so it is the single authority on a grammar `claim-check`, `graph-check`, `landed-check` and `spec-ref-check` all share. Porting it is out of scope by any reading; * `[[mint]]` (CLOUD-1024) is the natural home and cannot reach. Its placeholder vocabulary is deliberately CLOSED — six forms over a path — and none of them carries a value some other program decided. That left a generic recorder in the core, or the seven-column layout transcribed into `crates/batten`. The second is non-negotiable rule 1's exact violation: `issue`, `comment`, `ready` and `unready` are a tracker's vocabulary, and a grep of the core for any of them must return zero. WHAT THIS IS. A closed value language, evaluated over the tool envelope, with the record's shape declared in `batten.toml`: literal | result | input | object | wrap | section | program plus per-column `minus`, `without`, `counted-with`, `zero-is-a-count`. Nothing here names a tracker, a column, a verdict token or a program. THREE DECISIONS WORTH READING RATHER THAN REDERIVING: `result` and `input` are separate variants, not one path with a prefix. They differ in TRUST — the result is what the far end stored, the input is what the caller asked for — and that difference is the whole reason the censused record is unforgeable by its own author. A consumer choosing between them is choosing whose word to take, so the choice is spelled rather than buried in a prefix. Refusal is at LOAD, never a degraded column. A recorder naming a program or pattern nothing declares fails the config load. The alternative is a column that renders `-` forever, indistinguishable from one that looked and could not see — and the gate downstream PASSES on `-` by design, so that failure would be silent in the direction that matters. `section` takes `[[pattern]]` ids and refuses an inline regex, which is `.claude/rules/policy-modules.md`'s load-time rule one layer over, carrying its measured reason: one concept, one spelling. THE TRUST SURFACE, because a new config field with no weakening verdict is a comparison nobody considered. `every_config_field_carries_a_verdict` caught both new fields; three kinds answer them: * `recorder-added` — inverted, like `mint-added`, and sharper. A recorder WRITES what a gate reads, and its column can carry a VERDICT, so an added row does not merely satisfy a rule automatically — it supplies the value that rule decides on. * `recorder-changed` — a byte comparison, because every dangerous edit is invisible to a name comparison: repointing `tool` records a different call, dropping a `requires` path records a failed one, re-mapping a `status` table turns a refusal into a pass without touching the gate. * `program-changed` — the indirection the row comparison structurally cannot see. A recorder can be byte-identical while the program behind its verdict column resolves somewhere else entirely. Removal is absent from all three: a recorder naming an undeclared program fails the LOAD, so a deletion is fail-closed and loud rather than a silent weakening. Six cases, three firing and three discriminating, including one that asserts the recorder is unchanged while the program under it moved. THE SPAWN IS AN INVENTORY ROW. `run_program` carries its `#[expect(clippy::disallowed_types)]` opening with the verdict `stays` and saying why the censused program is run rather than reimplemented. Not yet wired to `record_post_tool` and not yet exercised by a `[[recorder]]` row; both land with the adapter that consumes them. Schemas regenerated by `mise run schema`; `hk.pkl`'s schema-check glob names the new module. Refs: CLOUD-1051, CLOUD-1024, CLOUD-721 --- .serena/memories/core.md | 13 + crates/batten/src/config.rs | 41 +++ crates/batten/src/lib.rs | 1 + crates/batten/src/recorder.rs | 656 ++++++++++++++++++++++++++++++++++ crates/batten/src/trust.rs | 293 +++++++++++++++ hk.pkl | 1 + schema/batten.schema.json | 337 +++++++++++++++++ 7 files changed, 1342 insertions(+) create mode 100644 crates/batten/src/recorder.rs diff --git a/.serena/memories/core.md b/.serena/memories/core.md index 450525832..7087c44e8 100644 --- a/.serena/memories/core.md +++ b/.serena/memories/core.md @@ -825,6 +825,19 @@ repo config > default`, declared as data in `SETTINGS` (per-key env var/flag), crate↔config contract, hence the constant. Stated limits: no `cwd`, so an absolute or `..` path is compared as written, and expansion/substitution hide operands. Both under-deny, the sanctioned direction. +- `recorder.rs` — a post-tool record whose SHAPE is the consumer's and whose + columns may carry a value another gate decided (CLOUD-1051). Sits beside + `mint.rs` rather than inside it: a mint renders a closed template over a path, + which is right for a receipt and cannot express an exit status. A closed value + language — `literal | result | input | object | wrap | section | program` — + plus per-column `minus`/`without`/`counted-with`/`zero-is-a-count`. `result` + and `input` are separate variants because they differ in TRUST, which is what + makes the record unforgeable by its own author. Refusal is at LOAD (an + undeclared program or pattern), never a column that renders `-` forever, since + the gate downstream passes on `-` by design. `section` takes `[[pattern]]` ids + and refuses an inline regex, `policy-modules.md`'s rule one layer over. + `run_program` is an annotated spawn — the censused program is the single + authority on a grammar 19 files share, so it is run rather than reimplemented. - `redirect.rs` — the per-path-class redirect table (CLOUD-280): what to run instead, keyed by **what is protected** rather than by the verb reaching for it. `[[redirect]]` is `{glob, mutation}`, and `hook::protected_refusal` diff --git a/crates/batten/src/config.rs b/crates/batten/src/config.rs index 09c428806..86ef1534d 100644 --- a/crates/batten/src/config.rs +++ b/crates/batten/src/config.rs @@ -31,6 +31,7 @@ //! the file is the *maximal weakening*, CLOUD-243) and [`crate::doctor`] needs to //! report `config-missing`. Only the §8 resolution chain wants defaults. +use std::collections::BTreeMap; use std::fs; use std::io; use std::path::Path; @@ -244,6 +245,31 @@ pub struct Config { /// returns nothing and every one of them lives here. #[serde(default, rename = "mint", skip_serializing_if = "Vec::is_empty")] pub mints: Vec, + /// Records written from the tool result that earned them (CLOUD-1051). + /// + /// The third selector on the post-tool event, and the one that can carry a + /// value another gate decided. A `[[mint]]` renders a template over the + /// payload; a `[[recorder]]` may additionally run a declared program and + /// record its verdict, which is what a board write's refinement column IS. + /// + /// Consumer-owned for the same reason `[[mint]]` is, and more so: the column + /// names, the verdict tokens and the programs are all a tracker's vocabulary, + /// so a grep of `crates/batten` for any of them returns nothing and every one + /// of them lives here. + #[serde(default, rename = "recorder", skip_serializing_if = "Vec::is_empty")] + pub recorders: Vec, + /// The programs a `[[recorder]]` may run, by id. + /// + /// Named rather than inline so one program has one spelling, which is + /// `[[pattern]]`'s rule one layer over — and so `validate` can refuse a + /// recorder naming a program nothing declares instead of leaving a column + /// that renders could-not-look forever. + #[serde( + default, + rename = "program", + skip_serializing_if = "BTreeMap::is_empty" + )] + pub programs: BTreeMap, /// Output predicates over a wrapped command's captured streams (CLOUD-117): /// literals that, found in `batten exec`'s output, promote a lying exit `0` /// to a violation. Consumer-specific by nature — which warning means @@ -973,6 +999,18 @@ fn parse_ungated(text: &str, source: &str) -> Result { // check is a gate no record can satisfy. crate::facts::validate_keying(&config.facts, &config.rules)?; crate::mint::validate(&config.mints)?; + // AFTER the pattern table is validated, because a recorder's `section` names + // a pattern id and the refusal for a missing one is only honest once the ids + // are known to be well-formed themselves. + crate::recorder::validate( + &config.recorders, + &config.programs, + &config + .patterns + .iter() + .map(|pattern| pattern.id.clone()) + .collect(), + )?; // `[budget]` is a table rather than a list, so the census below (which scans // `Vec` fields) does not reach it — but the failure it guards against is // the same one: a table that parses and gates nothing. A `[budget]` header @@ -1105,6 +1143,8 @@ impl Config { redirects: Vec::new(), facts: Vec::new(), mints: Vec::new(), + recorders: Vec::new(), + programs: BTreeMap::new(), markers: Vec::new(), exec: None, capture: None, @@ -1403,6 +1443,7 @@ mod tests { ("waivers", "crate::waiver::validate("), ("facts", "crate::facts::validate("), ("mints", "crate::mint::validate("), + ("recorders", "crate::recorder::validate("), ]; /// Tables proven well formed somewhere else, each with the reason. Listing diff --git a/crates/batten/src/lib.rs b/crates/batten/src/lib.rs index 627a79a61..a5889ca0f 100644 --- a/crates/batten/src/lib.rs +++ b/crates/batten/src/lib.rs @@ -56,6 +56,7 @@ pub mod pattern; pub mod policy; pub mod provision; pub mod receipt; +pub mod recorder; pub mod redirect; pub mod refusal; pub mod render; diff --git a/crates/batten/src/recorder.rs b/crates/batten/src/recorder.rs new file mode 100644 index 000000000..b1bd4972e --- /dev/null +++ b/crates/batten/src/recorder.rs @@ -0,0 +1,656 @@ +//! Record what a tool result said, under a declaration the consumer owns +//! (CLOUD-1051). +//! +//! # Why this exists beside [`crate::mint`] rather than inside it +//! +//! [`crate::mint`] answers *"file a receipt keyed to a subject this result +//! names"*, and its placeholder vocabulary is deliberately closed: six forms over +//! a path, none of which can express a value some OTHER program decided. That +//! bound is correct for a receipt — a receipt attests that a call happened — and +//! it is what makes a mint auditable by reading one template. +//! +//! A recorder answers a different question: *"what did this write DO, judged by +//! the gates this repository already owns"*. The censused instance is a board +//! write whose refinement verdict is another gate's exit status over a payload +//! assembled from two halves of the envelope. No path selector reaches that, and +//! widening `mint`'s vocabulary until it did would give every receipt the ability +//! to spawn — a much larger blast radius than the one row that needs it. +//! +//! # Non-negotiable rule 1 is the whole design constraint +//! +//! **Nothing here names a tracker, a column, a verdict token or a program.** The +//! record's shape is a consumer's `[[recorder]]` row: which tool, which columns in +//! which order, and for each column an expression over the closed vocabulary +//! [`Value`] documents. A reader who wants to know what the columns MEAN reads +//! `batten.toml`; this module only knows how to evaluate an expression and append +//! a line. +//! +//! That is what the alternative could not do. Porting the shell recorder by +//! transcribing its seven columns into Rust would have put `issue`, `comment`, +//! `ready`, `unready` and two program paths inside `crates/batten`, which is +//! exactly the violation a grep for a consumer's names is supposed to return zero +//! hits for. +//! +//! # Every failure is silent, and that is inherited rather than chosen +//! +//! A recorder runs on `PostToolUse`. A recorder that blocked, errored or printed +//! would cause the failure the sink rules exist to catch — pressure toward not +//! writing to the board at all. So every fallible step degrades to [`ABSENT`], +//! and a row that cannot be assembled is simply not written. The gate that reads +//! the record already treats `-` as *could not look* and passes on it, so a +//! degraded column is a quieter answer and never a louder one. +//! +//! # Spawning, declared as an inventory row +//! +//! [`run_program`] is a spawn, and `clippy.toml` makes every spawn an inventory +//! row rather than a refusal. It is bounded three ways: only a program the +//! committed config names, only when the tool selector already matched, and only +//! with the assembled stdin this module built. The tool match is what keeps the +//! per-call budget intact — the overwhelming majority of tool results select no +//! recorder and reach none of this. + +use std::collections::BTreeMap; +use std::io::Write as _; +use std::path::Path; + +use anyhow::Result; +use serde::{Deserialize, Serialize}; + +/// The token every unresolvable value renders as. +/// +/// Shared with [`crate::mint::ABSENT`]'s reasoning rather than its constant, +/// because the two modules write different records and a reader of either needs +/// the same three-valued read: a column that is `-` is *could not look*, never +/// *looked and found nothing*. Collapsing those is the CLOUD-251 shape every +/// record here is careful to avoid. +pub const ABSENT: &str = "-"; + +/// How a record is filed. +#[derive( + Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Deserialize, Serialize, schemars::JsonSchema, +)] +#[serde(rename_all = "kebab-case")] +pub enum RecordKey { + /// Filed under the current branch, so every commit on it continues to serve + /// the same record. + Branch, +} + +/// One recorder this repository declares. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, schemars::JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct Declared { + /// The record namespace — the file this writes, and the file the rule that + /// reads it names. + pub name: String, + /// Which tool's result records, matched by [`crate::rules::selects_tool_name`]. + /// + /// A tool name, never a field shape, for [`crate::mint::Declared::tool`]'s + /// measured reason: a connector is exposed under more than one name over its + /// lifetime (CLOUD-178), and a write response and a read payload are + /// shape-identical across the fields either carries. + pub tool: String, + /// What the record is filed under. + pub key: RecordKey, + /// Paths into the RESULT that must resolve for anything to be written. + /// + /// The success predicate, exactly as [`crate::mint::satisfied`] is: a result + /// that does not carry these is a call that did not do the thing, and + /// recording it would put a row in the journal for work nobody did. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub requires: Vec, + /// Paths into the INPUT that must NOT resolve for anything to be written. + /// + /// The censused use is telling a create from an update: an update carries an + /// id the create cannot, so a recorder that only wants creates declares the + /// id here rather than needing a negation operator in the expression + /// language. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub refused_when_input: Vec, + /// The columns, in order, space-joined into one line. + /// + /// **Every column renders exactly one whitespace-free token.** A record a + /// positional reader can parse cannot have a variable-width field before its + /// last, and the shell recorder this replaces learned that the hard way — its + /// fifth column swallowed the sixth until the join moved to write time. So + /// [`render_column`] folds whitespace rather than trusting a template not to + /// produce any. + pub columns: Vec, +} + +/// One column of a record. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, schemars::JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct Column { + /// What this column is called. Never read by the engine — it is what makes + /// the config legible and what a finding can point at. + pub name: String, + /// The expression whose value this column carries. + pub value: Value, + /// Remove, from this column's whitespace-separated tokens, every token the + /// named expression yields. + /// + /// The censused use is *"the rows the stored body cites that the caller + /// passed as no relation"* — a set difference between what a program emitted + /// and what the input carried. Expressed as a column operation rather than a + /// [`Value`] variant because it is about the rendered tokens, not about the + /// JSON. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub minus: Option, + /// Drop this token if it equals the value of the named expression. + /// + /// The row's own key is not an edge to anywhere; without this the censused + /// column counts every row as citing itself. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub without: Option, + /// Render as `` rather than as the tokens alone. + /// + /// **A count and a pointer, never the payload** (non-negotiable rule 4). The + /// separator is the consumer's because the reader of the record is the + /// consumer's too. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub counted_with: Option, + /// Render `0` rather than [`ABSENT`] when the expression resolved and yielded + /// no tokens. + /// + /// **Zero is a count; `-` is could-not-look.** Collapsing the two is the one + /// mistake this record shape cannot afford, because a gate downstream passes + /// on `-` by design and would then pass on a real measurement of nothing. + #[serde(default)] + pub zero_is_a_count: bool, +} + +/// An expression over the tool envelope and the programs this repository owns. +/// +/// **Closed, for [`crate::mint::Piece`]'s reason**: an unrecognised form is a +/// load error, never a value that silently renders as itself and writes a record +/// nobody can read. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, schemars::JsonSchema)] +#[serde(deny_unknown_fields, rename_all = "kebab-case")] +pub enum Value { + /// A constant the consumer wrote. What a `kind` column is made of. + Literal(String), + /// The scalar at a dotted path into the tool RESULT. + Result(String), + /// The scalar at a dotted path into the tool INPUT. + /// + /// Separate from [`Value::Result`] rather than one path with a prefix, + /// because the two differ in TRUST and the difference is the whole reason the + /// censused recorder is unforgeable: the result is what the far end stored, + /// the input is what the caller asked for. A consumer choosing between them + /// is choosing whose word to take, and that choice should be spelled. + Input(String), + /// A JSON object built from named sub-expressions. + Object(BTreeMap), + /// Each string an iterating INPUT path yields, wrapped as `{: value}`. + /// + /// The shape a relation list takes in the payload the censused verdict + /// program reads. Generic because the key is the consumer's. + Wrap { + /// The iterating input path, e.g. `blockedBy[]`. + from: String, + /// The object key each element is filed under. + key: String, + }, + /// The lines of another expression's value from a label matching `select` + /// until the next label matching `label`. + /// + /// **Both patterns are `[[pattern]]` ids, never inline regexes**, which is the + /// same load-time refusal `.claude/rules/policy-modules.md` records for a + /// policy module and for the same measured reason: one concept, one spelling. + /// The censused use narrows a body to one clause of a structured block, and + /// that block's grammar already has exactly one definition elsewhere. + Section { + /// The expression whose text is narrowed. + from: Box, + /// The pattern id every label matches — what ENDS a span. + label: String, + /// The pattern id the wanted label matches — what STARTS the span. + select: String, + }, + /// What a declared program said about another expression's value. + Program { + /// The program id, resolved through the consumer's `[program]` table. + program: String, + /// The expression whose rendered value is handed to the program on stdin. + stdin: Box, + /// What to read back. + read: Read, + }, +} + +/// What a recorder reads back from a program it ran. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, schemars::JsonSchema)] +#[serde(deny_unknown_fields, rename_all = "kebab-case")] +pub enum Read { + /// The exit status, mapped through a table the consumer wrote. + /// + /// **An unmapped status is [`ABSENT`], never a default verdict**, and the + /// censused reason is exact: the program this replaces exits `0` for pass, + /// `1` for a judgement and `2` for *could not judge*, so folding anything + /// non-zero into the refusal would record a verdict about the ENVIRONMENT + /// wearing the mask of a verdict about the subject. + Status(BTreeMap), + /// Standard output, whole. + Stdout, + /// The remainder of the first stdout line carrying this prefix. + /// + /// **Absent is could-not-look; present-and-empty is the honest zero.** The + /// censused producer emits its line BEFORE it branches on a verdict, so a + /// missing line means it never got that far, and a present empty one means it + /// looked and found nothing. + StdoutLine(String), +} + +/// One program a recorder may run. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, schemars::JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct Program { + /// The path, repo-relative. Resolved against the repository root rather than + /// the cwd for [`crate::lib`]'s measured reason: a hook inherits the cwd of + /// the tool call, which is not required to be inside this project. + pub path: String, + /// Arguments, before stdin is written. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub args: Vec, +} + +/// Whether every required result path resolved and no refusing input path did. +#[must_use] +pub fn satisfied( + declared: &Declared, + result: &serde_json::Value, + input: &serde_json::Value, +) -> bool { + declared + .requires + .iter() + .all(|path| scalar(result, path).is_some()) + && declared + .refused_when_input + .iter() + .all(|path| scalar(input, path).is_none()) +} + +/// The single scalar a dotted path selects, as a bare string. +/// +/// `None` for anything that is not exactly one non-null scalar, so a caller can +/// never write a JSON fragment into a record a positional reader parses — +/// [`crate::mint::scalar`]'s rule, and it matters more here because this record's +/// columns are positional by construction. +fn scalar(value: &serde_json::Value, path: &str) -> Option { + let mut current = value; + for segment in path.split('.') { + current = current.get(segment)?; + } + match current { + serde_json::Value::String(text) => Some(text.clone()), + serde_json::Value::Bool(_) | serde_json::Value::Number(_) => Some(current.to_string()), + serde_json::Value::Null | serde_json::Value::Array(_) | serde_json::Value::Object(_) => { + None + } + } +} + +/// Every string an iterating path yields. +fn strings(value: &serde_json::Value, path: &str) -> Vec { + let (name, iterate) = match path.strip_suffix("[]") { + Some(name) => (name, true), + None => (path, false), + }; + let mut current = value; + for segment in name.split('.') { + let Some(next) = current.get(segment) else { + return Vec::new(); + }; + current = next; + } + if !iterate { + return current.as_str().map(str::to_owned).into_iter().collect(); + } + current + .as_array() + .map(|values| { + values + .iter() + .filter_map(|value| value.as_str().map(str::to_owned)) + .collect() + }) + .unwrap_or_default() +} + +/// What an expression is evaluated against. +/// +/// Assembled once at the boundary and handed down, so [`evaluate`] stays a pure +/// function of its inputs and is testable without a world — the rule every other +/// render path in this crate follows. +#[derive(Debug)] +pub struct Context<'a> { + /// What the far end stored. The unforgeable half. + pub result: &'a serde_json::Value, + /// What the caller asked for. + pub input: &'a serde_json::Value, + /// The programs the committed config declares, by id. + pub programs: &'a BTreeMap, + /// The patterns the committed config declares, by id. + pub patterns: &'a BTreeMap, + /// Where a relative program path resolves against. + pub root: &'a Path, +} + +/// Evaluate one expression, or `None` where it could not be resolved. +/// +/// `None` is *could not look* end to end: it propagates through [`Value::Object`] +/// as an omitted key rather than as a null, because the censused consumer of that +/// object treats an absent key and a present-but-empty one as different answers +/// (an empty-but-present relation set asserts *this row has no blockers*, which is +/// a claim nothing here checked). +#[must_use] +pub fn evaluate(value: &Value, context: &Context<'_>) -> Option { + match value { + Value::Literal(text) => Some(serde_json::Value::String(text.clone())), + Value::Result(path) => scalar(context.result, path).map(serde_json::Value::String), + Value::Input(path) => scalar(context.input, path).map(serde_json::Value::String), + Value::Object(fields) => { + let mut object = serde_json::Map::new(); + for (key, expression) in fields { + if let Some(rendered) = evaluate(expression, context) { + object.insert(key.clone(), rendered); + } + } + Some(serde_json::Value::Object(object)) + } + Value::Wrap { from, key } => Some(serde_json::Value::Array( + strings(context.input, from) + .into_iter() + .map(|element| { + let mut object = serde_json::Map::new(); + object.insert(key.clone(), serde_json::Value::String(element)); + serde_json::Value::Object(object) + }) + .collect(), + )), + Value::Section { + from, + label, + select, + } => { + let text = as_text(&evaluate(from, context)?)?; + let label = context.patterns.get(label)?; + let select = context.patterns.get(select)?; + let span = section(&text, label, select); + if span.is_empty() { + return None; + } + Some(serde_json::Value::String(span)) + } + Value::Program { + program, + stdin, + read, + } => { + let declared = context.programs.get(program)?; + let payload = as_text(&evaluate(stdin, context)?)?; + let (status, out) = run_program(context.root, declared, &payload)?; + match read { + Read::Status(map) => map + .get(&status.to_string()) + .cloned() + .map(serde_json::Value::String), + Read::Stdout => Some(serde_json::Value::String(out)), + Read::StdoutLine(prefix) => out + .lines() + .find_map(|line| line.strip_prefix(prefix.as_str())) + .map(|rest| serde_json::Value::String(rest.to_owned())), + } + } + } +} + +/// An evaluated value as the text a program reads on stdin. +/// +/// A string is itself; anything else is its compact JSON. That split is what lets +/// one `stdin` slot carry both a bare body and an assembled payload without the +/// consumer declaring which. +fn as_text(value: &serde_json::Value) -> Option { + match value { + serde_json::Value::String(text) => Some(text.clone()), + serde_json::Value::Null => None, + other => serde_json::to_string(other).ok(), + } +} + +/// The lines from a label matching `select` until the next label matching +/// `label`. +/// +/// `in_span` is re-decided on EVERY label rather than only on the first, because +/// a structured block's clauses are siblings: the next label ends the span +/// whether or not it is the one wanted. +fn section(text: &str, label: ®ex::Regex, select: ®ex::Regex) -> String { + let mut kept: Vec<&str> = Vec::new(); + let mut in_span = false; + for line in text.lines() { + if label.is_match(line) { + in_span = select.is_match(line); + } + if in_span { + kept.push(line); + } + } + kept.join("\n") +} + +/// Run a declared program with `payload` on stdin, and read back its status and +/// stdout. +/// +/// **stderr is discarded, deliberately.** Every program a recorder runs is a gate +/// whose stderr is its own pointer report, and this module prints nothing — a +/// recorder that surfaced another gate's findings would be a second, unasked-for +/// channel for them. +fn run_program(root: &Path, program: &Program, payload: &str) -> Option<(i32, String)> { + #[expect( + clippy::disallowed_types, + reason = "stays — a recorder runs the gates this repository already owns, because the \ + value it records IS their verdict (CLOUD-1051). Bounded three ways: only a \ + program the committed config names, only once the tool selector matched, and \ + only with the stdin this module assembled, so almost every tool result \ + reaches none of it and the per-call budget is untouched. It stays a spawn \ + rather than becoming a port because the censused program is the single \ + authority on a grammar 19 other files share — a second implementation of it \ + here would be the drift this whole migration exists to remove." + )] + let mut child = std::process::Command::new(root.join(&program.path)) + .args(&program.args) + .current_dir(root) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::null()) + .spawn() + .ok()?; + child.stdin.take()?.write_all(payload.as_bytes()).ok()?; + let finished = child.wait_with_output().ok()?; + Some(( + finished.status.code()?, + String::from_utf8_lossy(&finished.stdout).into_owned(), + )) +} + +/// One column's rendered token. +/// +/// Whitespace is folded to a single `,` LAST, after every set operation, so a +/// column is one field however many tokens it carries — the property that lets a +/// record grow a column without the previous one swallowing it. +#[must_use] +pub fn render_column(column: &Column, context: &Context<'_>) -> String { + let Some(value) = evaluate(&column.value, context) else { + return String::from(ABSENT); + }; + let Some(text) = as_text(&value) else { + return String::from(ABSENT); + }; + let mut tokens: Vec = text.split_whitespace().map(str::to_owned).collect(); + if let Some(minus) = &column.minus { + let removed: Vec = evaluate(minus, context) + .as_ref() + .and_then(as_text) + .map(|text| text.split_whitespace().map(str::to_owned).collect()) + .unwrap_or_default(); + tokens.retain(|token| !removed.contains(token)); + } + if let Some(dropped) = column + .without + .as_ref() + .and_then(|without| evaluate(without, context)) + .as_ref() + .and_then(as_text) + { + tokens.retain(|token| *token != dropped); + } + if tokens.is_empty() { + // The three-valued read, and the only place it is decided. A column that + // asked for a count says `0`; one that did not says could-not-look. + return String::from(if column.zero_is_a_count { "0" } else { ABSENT }); + } + let joined = tokens.join(","); + match &column.counted_with { + Some(separator) => format!("{}{separator}{joined}", tokens.len()), + None => joined, + } +} + +/// The whole record line this result earns, or `None` where it earns none. +#[must_use] +pub fn render(declared: &Declared, context: &Context<'_>) -> Option { + if !satisfied(declared, context.result, context.input) { + return None; + } + Some( + declared + .columns + .iter() + .map(|column| render_column(column, context)) + .collect::>() + .join(" "), + ) +} + +/// Refuse, at LOAD, every recorder that could not have written what it claims. +/// +/// The refusals below are all of the same kind and it is the kind this repository +/// keeps choosing: a declaration naming something the tree does not carry is a +/// gate that runs, resolves nothing, and reports clean. A recorder is worse than +/// a rule that way, because it has no verdict at all — a column that silently +/// renders `-` forever looks exactly like a column that looked and could not see. +/// +/// # Errors +/// +/// Returns a [`crate::error::UsageError`] (→ exit `1`) naming the recorder, the +/// column and the missing id. +pub fn validate( + recorders: &[Declared], + programs: &BTreeMap, + patterns: &std::collections::BTreeSet, +) -> Result<()> { + let mut seen = std::collections::BTreeSet::new(); + for recorder in recorders { + if recorder.name.trim().is_empty() { + return Err(crate::error::UsageError::raise( + "a `[[recorder]]` row carries an empty `name`, so it names no record", + )); + } + if !seen.insert(recorder.name.clone()) { + return Err(crate::error::UsageError::raise(format!( + "two `[[recorder]]` rows are named {:?}, so one would silently \ + append to the other's record", + recorder.name + ))); + } + if recorder.tool.trim().is_empty() { + return Err(crate::error::UsageError::raise(format!( + "recorder {:?} selects on an empty `tool`, which matches nothing", + recorder.name + ))); + } + if recorder.columns.is_empty() { + return Err(crate::error::UsageError::raise(format!( + "recorder {:?} declares no columns, so it would append blank lines", + recorder.name + ))); + } + for column in &recorder.columns { + for value in [ + Some(&column.value), + column.minus.as_ref(), + column.without.as_ref(), + ] + .into_iter() + .flatten() + { + validate_value(&recorder.name, &column.name, value, programs, patterns)?; + } + } + } + Ok(()) +} + +/// Every id one expression names, checked against what the config declares. +fn validate_value( + recorder: &str, + column: &str, + value: &Value, + programs: &BTreeMap, + patterns: &std::collections::BTreeSet, +) -> Result<()> { + match value { + Value::Literal(_) | Value::Result(_) | Value::Input(_) | Value::Wrap { .. } => Ok(()), + Value::Object(fields) => fields + .values() + .try_for_each(|value| validate_value(recorder, column, value, programs, patterns)), + Value::Section { + from, + label, + select, + } => { + for id in [label, select] { + if !patterns.contains(id) { + return Err(crate::error::UsageError::raise(format!( + "recorder {recorder:?} column {column:?} narrows on pattern {id:?}, \ + which no `[[pattern]]` row declares — an inline regex is refused here \ + for the reason a policy module's is, so the id must exist" + ))); + } + } + validate_value(recorder, column, from, programs, patterns) + } + Value::Program { + program, + stdin, + read, + } => { + if !programs.contains_key(program) { + return Err(crate::error::UsageError::raise(format!( + "recorder {recorder:?} column {column:?} runs program {program:?}, \ + which no `[program]` row declares" + ))); + } + if let Read::Status(map) = read { + if map.is_empty() { + return Err(crate::error::UsageError::raise(format!( + "recorder {recorder:?} column {column:?} reads program {program:?}'s \ + status through an empty table, so every status would record as \ + could-not-look" + ))); + } + for status in map.keys() { + if status.parse::().is_err() { + return Err(crate::error::UsageError::raise(format!( + "recorder {recorder:?} column {column:?} maps status {status:?}, \ + which is not an exit code" + ))); + } + } + } + validate_value(recorder, column, stdin, programs, patterns) + } + } +} diff --git a/crates/batten/src/trust.rs b/crates/batten/src/trust.rs index 2ceb95e0a..ac3333cea 100644 --- a/crates/batten/src/trust.rs +++ b/crates/batten/src/trust.rs @@ -616,6 +616,33 @@ pub enum WeakeningKind { /// report it — a vanished `[[mint]]` shows up as the rule it fed going /// unsatisfiable, which is loud rather than silent. MintChanged, + /// A `[[recorder]]` row the base did not carry (CLOUD-1051) — an + /// *added*-direction weakening, for [`WeakeningKind::MintAdded`]'s reason + /// exactly. + /// + /// A recorder WRITES the record a gate reads, so presence lowers the bar + /// rather than raising it. The sharper case than a mint's: a recorder's + /// column can carry a verdict, so an added row does not merely satisfy a + /// rule automatically, it can supply the value that rule decides on. + RecorderAdded, + /// A `[[recorder]]` row survives under its name with a different definition. + /// + /// Compared as a byte change for [`WeakeningKind::MintChanged`]'s reason, and + /// the dangerous edits are again invisible to a name comparison: repointing + /// `tool` records a different call, dropping a `requires` path records a + /// failed one, and re-mapping a `status` table turns a refusal into a pass + /// without touching the gate that reads it. + RecorderChanged, + /// A `[program]` entry a `[[recorder]]` runs was added or repointed + /// (CLOUD-1051). + /// + /// **The sharpest key on this table, because it is indirection.** A recorder + /// can be byte-identical while the program its verdict column reads resolves + /// somewhere else entirely, and the record that reaches the gate then carries + /// whatever that program says. Removal is absent for + /// [`WeakeningKind::MintChanged`]'s reason: a recorder naming an undeclared + /// program fails the config LOAD, so a deletion is fail-closed and loud. + ProgramChanged, /// A `[[verdict]]` class gained an `override` route the base did not carry /// (CLOUD-1050, CLOUD-1051) — an *added*-direction weakening, like /// [`WeakeningKind::MintAdded`] and for the same reason. @@ -734,6 +761,9 @@ impl WeakeningKind { WeakeningKind::FactRemoved, WeakeningKind::MintAdded, WeakeningKind::MintChanged, + WeakeningKind::RecorderAdded, + WeakeningKind::RecorderChanged, + WeakeningKind::ProgramChanged, WeakeningKind::MarkerRemoved, WeakeningKind::ExecPatternRemoved, WeakeningKind::ProvisionRemoved, @@ -779,6 +809,9 @@ impl WeakeningKind { WeakeningKind::FactRemoved => "fact-removed", WeakeningKind::MintAdded => "mint-added", WeakeningKind::MintChanged => "mint-changed", + WeakeningKind::RecorderAdded => "recorder-added", + WeakeningKind::RecorderChanged => "recorder-changed", + WeakeningKind::ProgramChanged => "program-changed", WeakeningKind::MarkerRemoved => "marker-removed", WeakeningKind::ExecPatternRemoved => "exec-pattern-removed", WeakeningKind::ProvisionRemoved => "provision-removed", @@ -916,6 +949,17 @@ pub const CENSUS: &[FieldCoverage] = &[ field: "mints", coverage: Coverage::Compared(&[WeakeningKind::MintAdded, WeakeningKind::MintChanged]), }, + FieldCoverage { + field: "recorders", + coverage: Coverage::Compared(&[ + WeakeningKind::RecorderAdded, + WeakeningKind::RecorderChanged, + ]), + }, + FieldCoverage { + field: "programs", + coverage: Coverage::Compared(&[WeakeningKind::ProgramChanged]), + }, FieldCoverage { field: "redirects", coverage: Coverage::NotPolicyBearing( @@ -1366,6 +1410,8 @@ fn entry_weakenings(base: &Config, working: &Config) -> Vec { } } + found.extend(recorder_weakenings(base, working)); + found.extend(removed_entries( WeakeningKind::MarkerRemoved, &ids(base.markers.iter().map(|marker| marker.id.clone())), @@ -2039,6 +2085,77 @@ fn column_token(value: &serde_json::Value) -> String { format!("sha256:{}", &digest[..12]) } +/// Every weakening the recorder surface carries (CLOUD-1051). +/// +/// Extracted rather than inlined because `weakenings` is held to a line ceiling, +/// and a comparison that grows a surface should grow a function rather than push +/// the caller over a limit that exists to keep it readable. +fn recorder_weakenings(base: &Config, working: &Config) -> Vec { + let mut found = Vec::new(); + + // The recorders (CLOUD-1051), same inverted direction as the mints above and + // for a sharper reason: a recorder column can carry a VERDICT, so an added + // row does not merely satisfy a rule automatically — it can supply the value + // that rule decides on. + found.extend(added_entries( + WeakeningKind::RecorderAdded, + &ids(base + .recorders + .iter() + .map(|recorder| format!("recorder[{}]", recorder.name))), + &ids(working + .recorders + .iter() + .map(|recorder| format!("recorder[{}]", recorder.name))), + )); + for base_recorder in &base.recorders { + if let Some(working_recorder) = working + .recorders + .iter() + .find(|candidate| candidate.name == base_recorder.name) + && working_recorder != base_recorder + { + // Pointer-only (rule 4): the name and two digests. A column carries a + // consumer's field paths and one side is whatever a branch wrote. + found.push(Weakening { + kind: WeakeningKind::RecorderChanged, + key: format!("recorder[{}]", base_recorder.name), + base: column_token( + &serde_json::to_value(base_recorder).unwrap_or(serde_json::Value::Null), + ), + working: column_token( + &serde_json::to_value(working_recorder).unwrap_or(serde_json::Value::Null), + ), + }); + } + } + // THE INDIRECTION, and it is the one a byte comparison of the recorder above + // cannot see: a recorder can be identical while the program its verdict column + // reads resolves somewhere else entirely. Added AND changed, because both put a + // different program behind the same column. + for (id, working_program) in &working.programs { + let base_program = base.programs.get(id); + if base_program != Some(working_program) { + found.push(Weakening { + kind: WeakeningKind::ProgramChanged, + key: format!("program[{id}]"), + base: base_program.map_or_else( + || String::from("-"), + |program| { + column_token( + &serde_json::to_value(program).unwrap_or(serde_json::Value::Null), + ) + }, + ), + working: column_token( + &serde_json::to_value(working_program).unwrap_or(serde_json::Value::Null), + ), + }); + } + } + found +} + /// The lowercase token a [`Strictness`] is written as, read off its `ValueEnum` /// derive rather than re-tabulated here. fn strictness_token(strictness: Strictness) -> String { @@ -3398,6 +3515,182 @@ mod tests { } } + /// A recorder naming one program, for the cases below. + fn recorder(name: &str, tool: &str, program: &str) -> crate::recorder::Declared { + crate::recorder::Declared { + name: name.to_owned(), + tool: tool.to_owned(), + key: crate::recorder::RecordKey::Branch, + requires: vec!["id".to_owned()], + refused_when_input: Vec::new(), + columns: vec![crate::recorder::Column { + name: "verdict".to_owned(), + value: crate::recorder::Value::Program { + program: program.to_owned(), + stdin: Box::new(crate::recorder::Value::Result("description".to_owned())), + read: crate::recorder::Read::Status( + [(String::from("0"), String::from("pass"))] + .into_iter() + .collect(), + ), + }, + minus: None, + without: None, + counted_with: None, + zero_is_a_count: false, + }], + } + } + + #[test] + fn adding_a_recorder_is_a_weakening_because_a_recorder_writes_what_a_gate_reads() { + // The inverted direction again, and sharper than the mint's: a recorder + // column can carry a VERDICT, so an added row does not merely satisfy a + // rule automatically — it supplies the value that rule decides on. + let base = Config::declaring_nothing(); + let mut working = base.clone(); + working.recorders = vec![recorder("board-writes", "*save_issue", "linter")]; + + let kinds: Vec = weakenings(&base, &working) + .iter() + .map(|weakening| weakening.kind) + .collect(); + assert!( + kinds.contains(&WeakeningKind::RecorderAdded), + "got: {kinds:?}" + ); + } + + #[test] + fn removing_a_recorder_is_not_a_weakening() { + // The discriminating direction: with no recorder the record stops being + // written, so the gate reading it has less to pass on rather than more. + let mut base = Config::declaring_nothing(); + base.recorders = vec![recorder("board-writes", "*save_issue", "linter")]; + let working = Config::declaring_nothing(); + + let kinds: Vec = weakenings(&base, &working) + .iter() + .map(|weakening| weakening.kind) + .collect(); + assert!( + !kinds.contains(&WeakeningKind::RecorderAdded), + "got: {kinds:?}" + ); + assert!( + !kinds.contains(&WeakeningKind::RecorderChanged), + "got: {kinds:?}" + ); + } + + #[test] + fn repointing_a_recorders_tool_is_reported_and_carries_no_definition() { + // The edit a NAME comparison cannot see: the row survives under its name + // while recording a different call entirely. + let mut base = Config::declaring_nothing(); + base.recorders = vec![recorder("board-writes", "*save_issue", "linter")]; + let mut working = base.clone(); + working.recorders = vec![recorder("board-writes", "*save_comment", "linter")]; + + let found = weakenings(&base, &working); + let changed: Vec<&Weakening> = found + .iter() + .filter(|weakening| weakening.kind == WeakeningKind::RecorderChanged) + .collect(); + assert_eq!(changed.len(), 1, "got: {found:?}"); + // Pointer-only (rule 4): a digest on each side, never the definition. + assert!( + changed[0].base.starts_with("sha256:") && changed[0].working.starts_with("sha256:"), + "got: {:?}", + changed[0] + ); + } + + #[test] + fn an_unchanged_recorder_is_not_reported() { + // So the case above discriminates rather than firing on any config that + // carries a recorder at all. + let mut base = Config::declaring_nothing(); + base.recorders = vec![recorder("board-writes", "*save_issue", "linter")]; + let working = base.clone(); + + let kinds: Vec = weakenings(&base, &working) + .iter() + .map(|weakening| weakening.kind) + .collect(); + assert!( + !kinds.contains(&WeakeningKind::RecorderChanged), + "got: {kinds:?}" + ); + } + + #[test] + fn repointing_the_program_behind_an_unchanged_recorder_is_reported() { + // THE INDIRECTION, and the reason `ProgramChanged` is a separate kind. + // The recorder is byte-identical on both sides, so `RecorderChanged` + // cannot fire — and the verdict column now reads whatever a different + // program says. + let mut base = Config::declaring_nothing(); + base.recorders = vec![recorder("board-writes", "*save_issue", "linter")]; + base.programs = [( + String::from("linter"), + crate::recorder::Program { + path: String::from("mise-tasks/ready-lint.sh"), + args: Vec::new(), + }, + )] + .into_iter() + .collect(); + let mut working = base.clone(); + working.programs = [( + String::from("linter"), + crate::recorder::Program { + path: String::from("mise-tasks/always-passes.sh"), + args: Vec::new(), + }, + )] + .into_iter() + .collect(); + + let kinds: Vec = weakenings(&base, &working) + .iter() + .map(|weakening| weakening.kind) + .collect(); + assert!( + kinds.contains(&WeakeningKind::ProgramChanged), + "got: {kinds:?}" + ); + assert!( + !kinds.contains(&WeakeningKind::RecorderChanged), + "the recorder itself is identical: {kinds:?}" + ); + } + + #[test] + fn an_unchanged_program_table_is_not_reported() { + // The discriminator for the case above. + let mut base = Config::declaring_nothing(); + base.programs = [( + String::from("linter"), + crate::recorder::Program { + path: String::from("mise-tasks/ready-lint.sh"), + args: Vec::new(), + }, + )] + .into_iter() + .collect(); + let working = base.clone(); + + let kinds: Vec = weakenings(&base, &working) + .iter() + .map(|weakening| weakening.kind) + .collect(); + assert!( + !kinds.contains(&WeakeningKind::ProgramChanged), + "got: {kinds:?}" + ); + } + #[test] fn adding_a_mint_is_a_weakening_because_a_mint_writes_what_a_gate_reads() { // THE INVERTED DIRECTION, and the case that makes this kind worth having. diff --git a/hk.pkl b/hk.pkl index 4d4b0cfb5..3526c8137 100644 --- a/hk.pkl +++ b/hk.pkl @@ -431,6 +431,7 @@ local gate = new Mapping { "crates/batten/src/outputs.rs", "crates/batten/src/pattern.rs", "crates/batten/src/provision.rs", + "crates/batten/src/recorder.rs", "crates/batten/src/redirect.rs", "crates/batten/src/rules.rs", "crates/batten/src/severity.rs", diff --git a/schema/batten.schema.json b/schema/batten.schema.json index 9a476e152..a2fa2bad6 100644 --- a/schema/batten.schema.json +++ b/schema/batten.schema.json @@ -203,6 +203,13 @@ "$ref": "#/$defs/NamedPattern" } }, + "program": { + "description": "The programs a `[[recorder]]` may run, by id.\n\nNamed rather than inline so one program has one spelling, which is\n`[[pattern]]`'s rule one layer over — and so `validate` can refuse a\nrecorder naming a program nothing declares instead of leaving a column\nthat renders could-not-look forever.", + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/Program" + } + }, "protected": { "description": "Paths whose modification is guarded. A plain include set — no `!`\nentries — evaluated independently of [`Config::scope`] and\n[`Config::unlanded`]. CLOUD-31's config-trust diff defends this set.", "type": "array", @@ -217,6 +224,13 @@ "$ref": "#/$defs/Provision" } }, + "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", + "items": { + "$ref": "#/$defs/Declared3" + } + }, "redirect": { "description": "The per-path-class redirect table (CLOUD-280): what to run instead,\nkeyed by what is protected rather than by the verb reaching for it.\n\nConsulted before [`MutatingVerb::redirect`], which stays the fallback, so\nthe behaviour CLOUD-96 shipped is the floor rather than a regression.\nDeliberately a sibling of [`Config::protected`] rather than a widening of\nit: that set keeps its element type, so [`crate::trust`]'s\n`protected[]` weakening keys are untouched.\n\n[`MutatingVerb::redirect`]: crate::verbs::MutatingVerb::redirect", "type": "array", @@ -535,6 +549,59 @@ "required_checks" ] }, + "Column": { + "description": "One column of a record.", + "type": "object", + "properties": { + "counted_with": { + "description": "Render as `` rather than as the tokens alone.\n\n**A count and a pointer, never the payload** (non-negotiable rule 4). The\nseparator is the consumer's because the reader of the record is the\nconsumer's too.", + "type": [ + "string", + "null" + ] + }, + "minus": { + "description": "Remove, from this column's whitespace-separated tokens, every token the\nnamed expression yields.\n\nThe censused use is *\"the rows the stored body cites that the caller\npassed as no relation\"* — a set difference between what a program emitted\nand what the input carried. Expressed as a column operation rather than a\n[`Value`] variant because it is about the rendered tokens, not about the\nJSON.", + "anyOf": [ + { + "$ref": "#/$defs/Value" + }, + { + "type": "null" + } + ] + }, + "name": { + "description": "What this column is called. Never read by the engine — it is what makes\nthe config legible and what a finding can point at.", + "type": "string" + }, + "value": { + "description": "The expression whose value this column carries.", + "$ref": "#/$defs/Value" + }, + "without": { + "description": "Drop this token if it equals the value of the named expression.\n\nThe row's own key is not an edge to anywhere; without this the censused\ncolumn counts every row as citing itself.", + "anyOf": [ + { + "$ref": "#/$defs/Value" + }, + { + "type": "null" + } + ] + }, + "zero_is_a_count": { + "description": "Render `0` rather than [`ABSENT`] when the expression resolved and yielded\nno tokens.\n\n**Zero is a count; `-` is could-not-look.** Collapsing the two is the one\nmistake this record shape cannot afford, because a gate downstream passes\non `-` by design and would then pass on a real measurement of nothing.", + "type": "boolean", + "default": false + } + }, + "additionalProperties": false, + "required": [ + "name", + "value" + ] + }, "Commit": { "description": "The `[commit]` table: what a commit subject must look like here.\n\nConsumer-specific by nature (non-negotiable rule 1): the engine holds the\nmatcher, this table holds the answer. Which type words a repository admits,\nand whether it requires a scope, are that repository's business.", "type": "object", @@ -681,6 +748,52 @@ "body" ] }, + "Declared3": { + "description": "One recorder this repository declares.", + "type": "object", + "properties": { + "columns": { + "description": "The columns, in order, space-joined into one line.\n\n**Every column renders exactly one whitespace-free token.** A record a\npositional reader can parse cannot have a variable-width field before its\nlast, and the shell recorder this replaces learned that the hard way — its\nfifth column swallowed the sixth until the join moved to write time. So\n[`render_column`] folds whitespace rather than trusting a template not to\nproduce any.", + "type": "array", + "items": { + "$ref": "#/$defs/Column" + } + }, + "key": { + "description": "What the record is filed under.", + "$ref": "#/$defs/RecordKey" + }, + "name": { + "description": "The record namespace — the file this writes, and the file the rule that\nreads it names.", + "type": "string" + }, + "refused_when_input": { + "description": "Paths into the INPUT that must NOT resolve for anything to be written.\n\nThe censused use is telling a create from an update: an update carries an\nid the create cannot, so a recorder that only wants creates declares the\nid here rather than needing a negation operator in the expression\nlanguage.", + "type": "array", + "items": { + "type": "string" + } + }, + "requires": { + "description": "Paths into the RESULT that must resolve for anything to be written.\n\nThe success predicate, exactly as [`crate::mint::satisfied`] is: a result\nthat does not carry these is a call that did not do the thing, and\nrecording it would put a row in the journal for work nobody did.", + "type": "array", + "items": { + "type": "string" + } + }, + "tool": { + "description": "Which tool's result records, matched by [`crate::rules::selects_tool_name`].\n\nA tool name, never a field shape, for [`crate::mint::Declared::tool`]'s\nmeasured reason: a connector is exposed under more than one name over its\nlifetime (CLOUD-178), and a write response and a read payload are\nshape-identical across the fields either carries.", + "type": "string" + } + }, + "additionalProperties": false, + "required": [ + "name", + "tool", + "key", + "columns" + ] + }, "DeclaredVerdict": { "description": "One declared refusal class.", "type": "object", @@ -1413,6 +1526,27 @@ } ] }, + "Program": { + "description": "One program a recorder may run.", + "type": "object", + "properties": { + "args": { + "description": "Arguments, before stdin is written.", + "type": "array", + "items": { + "type": "string" + } + }, + "path": { + "description": "The path, repo-relative. Resolved against the repository root rather than\nthe cwd for [`crate::lib`]'s measured reason: a hook inherits the cwd of\nthe tool call, which is not required to be inside this project.", + "type": "string" + } + }, + "additionalProperties": false, + "required": [ + "path" + ] + }, "Provision": { "description": "One provisioned tool.\n\nThe `oneOf` mirrors [`validate_artifact_spelling`]'s xor into the derived\nschema, the same way [`crate::rules::Rule`] mirrors its severity conditional:\nan author editing `batten.toml` against the published schema gets the refusal\nin their editor rather than on the next run. It is a **second expression of\none rule, never a second authority** — the loader's check is what decides, and\nit is what the error message comes from.", "type": "object", @@ -1495,6 +1629,45 @@ "binary" ] }, + "Read": { + "description": "What a recorder reads back from a program it ran.", + "oneOf": [ + { + "description": "The exit status, mapped through a table the consumer wrote.\n\n**An unmapped status is [`ABSENT`], never a default verdict**, and the\ncensused reason is exact: the program this replaces exits `0` for pass,\n`1` for a judgement and `2` for *could not judge*, so folding anything\nnon-zero into the refusal would record a verdict about the ENVIRONMENT\nwearing the mask of a verdict about the subject.", + "type": "object", + "properties": { + "status": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "additionalProperties": false, + "required": [ + "status" + ] + }, + { + "description": "Standard output, whole.", + "type": "string", + "const": "stdout" + }, + { + "description": "The remainder of the first stdout line carrying this prefix.\n\n**Absent is could-not-look; present-and-empty is the honest zero.** The\ncensused producer emits its line BEFORE it branches on a verdict, so a\nmissing line means it never got that far, and a present empty one means it\nlooked and found nothing.", + "type": "object", + "properties": { + "stdout-line": { + "type": "string" + } + }, + "additionalProperties": false, + "required": [ + "stdout-line" + ] + } + ] + }, "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": [ @@ -1530,6 +1703,16 @@ } ] }, + "RecordKey": { + "description": "How a record is filed.", + "oneOf": [ + { + "description": "Filed under the current branch, so every commit on it continues to serve\nthe same record.", + "type": "string", + "const": "branch" + } + ] + }, "Redirect": { "description": "One declared path class and the mutation sanctioned for it.", "type": "object", @@ -2433,6 +2616,160 @@ } ] }, + "Value": { + "description": "An expression over the tool envelope and the programs this repository owns.\n\n**Closed, for [`crate::mint::Piece`]'s reason**: an unrecognised form is a\nload error, never a value that silently renders as itself and writes a record\nnobody can read.", + "oneOf": [ + { + "description": "A constant the consumer wrote. What a `kind` column is made of.", + "type": "object", + "properties": { + "literal": { + "type": "string" + } + }, + "additionalProperties": false, + "required": [ + "literal" + ] + }, + { + "description": "The scalar at a dotted path into the tool RESULT.", + "type": "object", + "properties": { + "result": { + "type": "string" + } + }, + "additionalProperties": false, + "required": [ + "result" + ] + }, + { + "description": "The scalar at a dotted path into the tool INPUT.\n\nSeparate from [`Value::Result`] rather than one path with a prefix,\nbecause the two differ in TRUST and the difference is the whole reason the\ncensused recorder is unforgeable: the result is what the far end stored,\nthe input is what the caller asked for. A consumer choosing between them\nis choosing whose word to take, and that choice should be spelled.", + "type": "object", + "properties": { + "input": { + "type": "string" + } + }, + "additionalProperties": false, + "required": [ + "input" + ] + }, + { + "description": "A JSON object built from named sub-expressions.", + "type": "object", + "properties": { + "object": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/Value" + } + } + }, + "additionalProperties": false, + "required": [ + "object" + ] + }, + { + "description": "Each string an iterating INPUT path yields, wrapped as `{: value}`.\n\nThe shape a relation list takes in the payload the censused verdict\nprogram reads. Generic because the key is the consumer's.", + "type": "object", + "properties": { + "wrap": { + "type": "object", + "properties": { + "from": { + "description": "The iterating input path, e.g. `blockedBy[]`.", + "type": "string" + }, + "key": { + "description": "The object key each element is filed under.", + "type": "string" + } + }, + "additionalProperties": false, + "required": [ + "from", + "key" + ] + } + }, + "additionalProperties": false, + "required": [ + "wrap" + ] + }, + { + "description": "The lines of another expression's value from a label matching `select`\nuntil the next label matching `label`.\n\n**Both patterns are `[[pattern]]` ids, never inline regexes**, which is the\nsame load-time refusal `.claude/rules/policy-modules.md` records for a\npolicy module and for the same measured reason: one concept, one spelling.\nThe censused use narrows a body to one clause of a structured block, and\nthat block's grammar already has exactly one definition elsewhere.", + "type": "object", + "properties": { + "section": { + "type": "object", + "properties": { + "from": { + "description": "The expression whose text is narrowed.", + "$ref": "#/$defs/Value" + }, + "label": { + "description": "The pattern id every label matches — what ENDS a span.", + "type": "string" + }, + "select": { + "description": "The pattern id the wanted label matches — what STARTS the span.", + "type": "string" + } + }, + "additionalProperties": false, + "required": [ + "from", + "label", + "select" + ] + } + }, + "additionalProperties": false, + "required": [ + "section" + ] + }, + { + "description": "What a declared program said about another expression's value.", + "type": "object", + "properties": { + "program": { + "type": "object", + "properties": { + "program": { + "description": "The program id, resolved through the consumer's `[program]` table.", + "type": "string" + }, + "read": { + "description": "What to read back.", + "$ref": "#/$defs/Read" + }, + "stdin": { + "description": "The expression whose rendered value is handed to the program on stdin.", + "$ref": "#/$defs/Value" + } + }, + "additionalProperties": false, + "required": [ + "program", + "stdin", + "read" + ] + } + }, + "additionalProperties": false, + "required": [ + "program" + ] + } + ] + }, "VerdictProgram": { "description": "One program family whose **exit status is the answer** (CLOUD-443).\n\nEnumerable on purpose. \"Is this command's status the thing the caller wants\"\nis not decidable in general, and an open predicate would be a judgement\n(non-negotiable rule 3) — so the set is a closed list the consumer declares,\nand the read-only member of each family is excluded by name, because a query's\noutput *is* its answer and piping it is ordinary composition.\n\nThe four optional columns are not four ideas: each is the shape one real\nfamily needs, and no row may carry two that contradict.\n\n| column | matches | the family it exists for |\n| --- | --- | --- |\n| `subcommands` | first non-flag word is one of these | a task runner's `run`, a VCS's mutating verbs |\n| `nested` | second non-flag word is one of these | a forge CLI's ` ` |\n| `except` | a first word exists and is NOT listed | a build tool with far more verdict subcommands than query ones |\n| `any_argument` | a first word merely exists | a test runner, where a bare invocation prints usage and answers nothing |", "type": "object", From b0dc0d4d46c05960843c3a1b8bb0711fc16499a3 Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Wed, 26 Aug 2026 03:33:32 +0000 Subject: [PATCH 09/38] feat(recorder)!: the board-write record becomes a declaration, and three defects it hid MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `mise-tasks/board-write-record.sh` and `tests/board-write-record.bats` are gone. The record is three `[[recorder]]` rows over two `[program]` ids, written by the engine on `PostToolUse`. `.claude/settings.json` loses its second entry — `batten hook` was already the first, so the engine received these events all along. THE MILESTONE: the generic expression language expresses the shell recorder's seven columns, including the two `[[mint]]`'s closed vocabulary could not reach. The assembled `ready-lint` payload is an `object` of `result` reads plus three `wrap`ped `input` relation lists; the §1 column is a `section` narrowing over two `[[pattern]]` ids. Neither needed a new primitive. What lives in config now, and none of it in the core: `issue`, `comment`, `ready`, `unready`, both program paths, the clause grammar, the column order. FOUR DECISIONS PRESERVED VERBATIM, each measured and each easy to get backwards: * EVERY COLUMN READS `result`, NEVER `input` — what makes the verdict unforgeable. `ready-lint` over caller-assembled text was measured green three times during CLOUD-514's own refinement, once under an id no row carried. * RELATIONS ARE SYNTHESISED ONLY ON THE CREATE PATH (CLOUD-781). On an update the argument is a patch, so synthesising `[]` from a body-only groom asserts THIS ROW HAS NO BLOCKERS — a claim nothing checked. Omitting the key lets `ready-lint` exit 2, which no `status` row maps, so the column records could-not-look. * THE COMMENT ROW TAKES ITS ID FROM THE INPUT. A `save_comment` response is the COMMENT object; its `id` is a uuid naming no row. The retired recorder's first five live rows filled an issue-key column with uuids — a wrong answer wearing a right answer's shape, worse than the gap it replaced. * `cites` OVER-COUNTS BY CONSTRUCTION and says so, which is conservative in the direction CLOUD-923 asks for: the failure mode it names is the record being quieter than the truth, and an upper bound cannot be that. RETIREMENT LEDGER: 36 cases, every one placed — 26 carried, 8 subsumed, 2 changed. `crates/batten/tests/board_record.rs` carries the arms and 20 compiled-binary cases over `batten hook`, the tier that proves the ENGINE builds the record where the retired suite drove a shell program directly. Its programs are STUBS with chosen exit codes: the retired suite ran the real `ready-lint.sh`, so a change to a grammar 19 files share could redden it, making it a test of that grammar rather than of the recorder. `BATTEN_BOARD_WRITE_BYPASS` is `changed`, not carried. A bypass lets an author past a REFUSAL and a recorder refuses nothing, so all it could buy was a quieter record — the one direction the gate reading it cannot detect, since it passes on could-not-look by design. THREE DEFECTS THE PORT FOUND, two of them introduced BY the migration: * A GLOB-SHAPED SELECTOR MATCHED NOTHING, SILENTLY. `*save_issue` came from the shell's `case` pattern, but `rules::selects_tool_name` matches the whole name or its final `__`-delimited segment (CLOUD-178) and is not a glob. The whole table was dead, and only the cases asserting a row WAS written could see it — the ones asserting none passed vacuously. That asymmetry leaves the failure no loud direction at runtime, so `validate` now refuses `*`, `?` or `[` in a selector at LOAD, the only place it can be caught. * A LATER ROW READ WHAT AN EARLIER ROW JUST WROTE. Several rows write one record, so the create appended and the groom row then matched its own create. The shell could not have this — it was one program deciding once, and splitting the decision into rows opened the window. The snapshot is taken ONCE before any append. * A ONE-COLUMN PRECONDITION MATCHED A COMMENT, letting a comment ABOUT a row stand in for this branch having FILED it. The shell anchored on kind and id together; `requires-recorded` now takes a column map, which is that anchor generalised. `requires-recorded` itself is new and bounded: without it CLOUD-514's third remedy is unreachable, since a groom carries an id and the create row refuses it, leaving the creation-time verdict standing forever (measured on PR #525). ONE PRIMITIVE THE CONFIG ASKED FOR: `inputs`, the plain-token sibling of `wrap`. `wrap` builds the OBJECT a program reads on stdin; `inputs` builds the TOKEN list a column operation compares against. A `minus` built from `wrap` compares against `{"id":"X"}` and removes nothing, silently. BOTH TABLES ARE AUTHORITY-ONLY, outside `Tables`, so a `batten.local.toml` cannot reach them: a local recorder could hand a gate a verdict of its own choosing while every rule and severity stayed as the authority wrote them. Three weakening kinds cover them, with six cases — three firing, three discriminating. 2492/2492 green; schemas regenerated by `mise run schema`. Refs: CLOUD-1051, CLOUD-514, CLOUD-908, CLOUD-781, CLOUD-923, CLOUD-178 Weakens: program-changed program[named-paths] Weakens: program-changed program[ready-lint] Weakens: recorder-added recorder[board-comment] Weakens: recorder-added recorder[board-issue-created] Weakens: recorder-added recorder[board-issue-groomed] --- .claude/settings.json | 9 - batten.toml | 207 +++++++++ crates/batten/src/hook.rs | 107 +++++ crates/batten/src/lib.rs | 59 +++ crates/batten/src/recorder.rs | 275 ++++++++++- crates/batten/src/resolve.rs | 30 ++ crates/batten/src/trust.rs | 4 +- crates/batten/tests/board_record.rs | 678 ++++++++++++++++++++++++++++ mise-tasks/board-write-record.sh | 509 --------------------- mise-tasks/hooks-wiring-check.sh | 3 +- mise.toml | 2 +- schema/batten.schema.json | 72 ++- tests/board-write-record.bats | 595 ------------------------ 13 files changed, 1399 insertions(+), 1151 deletions(-) create mode 100644 crates/batten/tests/board_record.rs delete mode 100755 mise-tasks/board-write-record.sh delete mode 100644 tests/board-write-record.bats diff --git a/.claude/settings.json b/.claude/settings.json index f8bbb9c04..02b483fe1 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -34,15 +34,6 @@ "command": "batten hook --harness claude-code" } ] - }, - { - "matcher": ".*save_issue|.*save_comment", - "hooks": [ - { - "type": "command", - "command": "$CLAUDE_PROJECT_DIR/mise-tasks/board-write-record.sh" - } - ] } ], "Stop": [ diff --git a/batten.toml b/batten.toml index 527bfe096..5dd6288aa 100644 --- a/batten.toml +++ b/batten.toml @@ -686,6 +686,213 @@ requires = ["hasNextPage", "issues[].id"] mode = "append" body = "{join:issues[].id}\nbase {git:origin/main}" +# ─── THE BOARD-WRITE RECORD (CLOUD-1051), replacing `board-write-record.sh` ─── +# +# The sensor half of CLOUD-514: every row this branch put on the board, with the +# tracker's own verdict on whether a new row was refined. `filed-here-check` reads +# it; this writes it. +# +# WHY THE VERDICT IS A PROGRAM AND NOT A TEMPLATE. `ready-lint` reads a payload +# the caller assembles, and during CLOUD-514's own refinement it was run three +# times against text in a local file — once under an id no row carried — green +# every time. A toll payable in text nobody filed is not a toll. A recorder does +# not have that problem: it fires on the tool RESULT, so the body being judged is +# the one the tracker STORED. That is why the `description` every column below +# reads is `result`, never `input`. +# +# The two programs stay shell deliberately. `ready-lint.sh` is the single +# authority on the Ready-block grammar that `claim-check`, `graph-check`, +# `landed-check` and `spec-ref-check` all share, and `board-diff-overlap.sh` owns +# basename resolution and the tracked-only bound. A second implementation of +# either would be the drift this migration exists to remove. +[program.ready-lint] +path = "mise-tasks/ready-lint.sh" + +[program.named-paths] +path = "mise-tasks/board-diff-overlap.sh" +args = ["--named"] + +# The §1 span, narrowed before the paths are read (CLOUD-854). +# +# The fifth column holds every path the BODY names, and a path-name intersection +# cannot tell a row CLAIMING work on a file from one CITING it as evidence — so +# the refusal fired hardest on the rows that documented their provenance best. §1 +# names the source of truth by construction, so intersecting the two is what tells +# them apart. +# +# The grammar is `ready-lint`'s own CLAUSE_LABEL: a bolded label or a heading +# carrying the tag. Declared here rather than inline because an inline regex is +# refused at load, and because a Ready block has exactly one definition of where a +# clause begins. +[[pattern]] +id = "clause-label" +regex = '^[[:space:]]*([*-][[:space:]]*)?\*\*[^*]*\((§|clause )[0-9]+\)|^#{2,6}[[:space:]]+[^#]*\((§|clause )[0-9]+\)' + +[[pattern]] +id = "clause-one" +regex = '\((§|clause )1\)' + +# A CREATE is what carries a refinement obligation, so an `id` in the INPUT +# refuses this row: that is an update to a row that already exists, which is not a +# board write this branch is answerable for. +[[recorder]] +name = "board-issue-created" +record = "board-writes" +tool = "save_issue" +key = "branch" +requires = ["id"] +refused-when-input = ["id"] + +[[recorder.columns]] +name = "kind" +value = { literal = "issue" } + +[[recorder.columns]] +name = "id" +value = { result = "id" } + +[[recorder.columns]] +name = "updated" +value = { result = "updatedAt" } + +# ALL THREE RELATION DIRECTIONS, and only on the create path. `ready-lint`'s +# deferral rule accepts any direction, and demanding `blockedBy` specifically +# would push authors to declare false dependencies to pass a lint — measured, a +# row that honestly recorded a hand-off as `relatedTo` linted unready through four +# grooms. The create call's argument IS the whole relation set exactly once in a +# row's life, because there is nothing prior to append to. +[[recorder.columns]] +name = "verdict" +value = { program = { run = "ready-lint", read = { status = { "0" = "ready", "1" = "unready" } }, stdin = { object = { id = { result = "id" }, description = { result = "description" }, relations = { object = { blockedBy = { wrap = { from = "blockedBy[]", key = "id" } }, blocks = { wrap = { from = "blocks[]", key = "id" } }, relatedTo = { wrap = { from = "relatedTo[]", key = "id" } } } } } } } } + +[[recorder.columns]] +name = "overlap" +value = { program = { run = "named-paths", read = "stdout", stdin = { result = "description" } } } +zero-is-a-count = true + +# THE ROWS PROSE WILL LINK, which is a PREDICTION and is stated as one. The tracker +# auto-links every key a body mentions into a symmetric edge, so writing a body +# modifies rows the caller passed as no parameter and is told about in no response. +# A cited row that was already related is counted here and adds no edge, so this +# over-counts and never under-counts — conservative in the direction that matters, +# since the failure mode is the record being quieter than the truth. +[[recorder.columns]] +name = "cites" +value = { program = { run = "ready-lint", read = { stdout-line = "cites-body " }, stdin = { object = { id = { result = "id" }, description = { result = "description" } } } } } +minus = { inputs = ["relatedTo[]", "blockedBy[]", "blocks[]"] } +without = { result = "id" } +counted-with = ":" +zero-is-a-count = true + +[[recorder.columns]] +name = "sec1" +value = { program = { run = "named-paths", read = "stdout", stdin = { section = { from = { result = "description" }, label = "clause-label", select = "clause-one" } } } } + +# 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 +# create row above refuses it, the record keeps its creation-time verdict, and the +# gate refuses forever. Measured on PR #525: the row was groomed until `ready-lint` +# exited 0 over the tracker's own response, and the refusal did not move. +# +# Narrow on purpose: `requires-recorded` admits it only when this record already +# carries a line for that id in column 1. That is the row this branch filed and is +# answerable for, so re-linting grants nothing a fresh create would not have, and +# the verdict still comes from the tracker's RESPONSE. A groom of somebody else's +# row is skipped exactly as before. +# +# NO RELATIONS ON THIS PATH (CLOUD-781). `save_issue` relations are append-only, so +# on an update the argument is a patch — a groom touching only the body passes no +# `blockedBy` at all, and synthesising `[]` from that asserts THIS ROW HAS NO +# BLOCKERS, a claim nothing here checked. Omitting the key fires +# `unjudgeable-relations`, `ready-lint` exits 2, no status maps it, and the column +# records could-not-look. That is the honest answer for relations nothing can see. +[[recorder]] +name = "board-issue-groomed" +record = "board-writes" +tool = "save_issue" +key = "branch" +requires = ["id"] +requires-recorded = { matches = { 0 = { literal = "issue" }, 1 = { result = "id" } } } + +[[recorder.columns]] +name = "kind" +value = { literal = "issue" } + +[[recorder.columns]] +name = "id" +value = { result = "id" } + +[[recorder.columns]] +name = "updated" +value = { result = "updatedAt" } + +[[recorder.columns]] +name = "verdict" +value = { program = { run = "ready-lint", read = { status = { "0" = "ready", "1" = "unready" } }, stdin = { object = { id = { result = "id" }, description = { result = "description" } } } } } + +[[recorder.columns]] +name = "overlap" +value = { program = { run = "named-paths", read = "stdout", stdin = { result = "description" } } } +zero-is-a-count = true + +[[recorder.columns]] +name = "cites" +value = { program = { run = "ready-lint", read = { stdout-line = "cites-body " }, stdin = { object = { id = { result = "id" }, description = { result = "description" } } } } } +without = { result = "id" } +counted-with = ":" +zero-is-a-count = true + +[[recorder.columns]] +name = "sec1" +value = { program = { run = "named-paths", read = "stdout", stdin = { section = { from = { result = "description" }, label = "clause-label", select = "clause-one" } } } } + +# 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 +# verdict column is a literal could-not-look rather than a lint. +# +# THE ID COMES FROM THE INPUT, and this is the one column on the whole table that +# must. A `save_comment` response is the COMMENT object: its `id` is the comment's +# own uuid and it carries no reference to the row the comment landed on. Measured +# on the shell recorder's first five live rows, which came out carrying uuids in an +# issue-key column — a wrong answer wearing a right answer's shape, which reads as +# data rather than as a gap and is strictly worse than the gap. +[[recorder]] +name = "board-comment" +record = "board-writes" +tool = "save_comment" +key = "branch" +requires = ["id"] + +[[recorder.columns]] +name = "kind" +value = { literal = "comment" } + +[[recorder.columns]] +name = "id" +value = { input = "issueId" } + +[[recorder.columns]] +name = "updated" +value = { result = "updatedAt" } + +[[recorder.columns]] +name = "verdict" +value = { literal = "-" } + +[[recorder.columns]] +name = "overlap" +value = { literal = "-" } + +[[recorder.columns]] +name = "cites" +value = { literal = "-" } + +[[recorder.columns]] +name = "sec1" +value = { literal = "-" } + # `issue-search-guard`'s decision, as config (CLOUD-312 row 1, CLOUD-505). # # A row is not filed until somebody has looked for the one that already exists. diff --git a/crates/batten/src/hook.rs b/crates/batten/src/hook.rs index 40cdad577..f23ad5d6b 100644 --- a/crates/batten/src/hook.rs +++ b/crates/batten/src/hook.rs @@ -2437,6 +2437,9 @@ pub struct Policy { /// contractually pure, so the table travels as a value rather than being /// re-read at the point of use. mints: Vec, + recorders: Vec, + patterns: Vec, + programs: std::collections::BTreeMap, /// The enabled policy bundles, compiled at the boundary (CLOUD-647, /// CLOUD-837). /// @@ -2479,6 +2482,9 @@ impl Policy { redirects: Vec::new(), facts: Vec::new(), mints: Vec::new(), + recorders: Vec::new(), + patterns: Vec::new(), + programs: std::collections::BTreeMap::new(), bundles: Vec::new(), verdicts: Vec::new(), } @@ -2548,6 +2554,9 @@ impl Policy { redirects: resolved.redirects.clone(), facts: resolved.facts.clone(), mints: resolved.mints.clone(), + recorders: resolved.recorders.clone(), + patterns: resolved.patterns.clone(), + programs: resolved.programs.clone(), // Boundary I/O, and this is the only place it happens: `load` // reads, compiles and smoke-queries every registered module here so // that `adjudicate` stays contractually pure and a broken module is @@ -2643,6 +2652,50 @@ impl Policy { &self.mints } + /// Every record this repository writes from a tool result (CLOUD-1051). + /// + /// [`Policy::declared_mints`]'s sibling on the same selector. The difference + /// is what a column may carry: a mint renders a closed template over the + /// payload, a recorder may additionally run a declared program and record + /// what it decided. + #[must_use] + pub fn declared_recorders(&self) -> &[crate::recorder::Declared] { + &self.recorders + } + + /// The `[[pattern]]` table, compiled, for a recorder's `section` narrowing. + /// + /// Compiled HERE rather than held compiled, because this is the one caller + /// and it is reached only once a recorder has already matched the tool — so + /// the overwhelming majority of mediated calls never pay for it. A pattern + /// that will not compile is dropped rather than raised: `pattern::validate` + /// has already refused it at load, so reaching this with a bad one is + /// impossible for a config that loaded, and a panic here would be a hook + /// becoming the reason work stops. + #[must_use] + pub fn compiled_patterns(&self) -> std::collections::BTreeMap { + self.patterns + .iter() + .filter_map(|pattern| { + regex::Regex::new(&pattern.regex) + .ok() + .map(|compiled| (pattern.id.clone(), compiled)) + }) + .collect() + } + + /// The programs a `[[recorder]]` may run, by id. + /// + /// Held beside the recorders rather than resolved per row, because + /// `recorder::validate` has already refused any row naming an id this table + /// does not carry — so a lookup here cannot fail for a config that loaded. + #[must_use] + pub fn declared_programs( + &self, + ) -> &std::collections::BTreeMap { + &self.programs + } + /// The agent-sourced fact this check names, if the consumer declared one /// (CLOUD-776). /// @@ -2974,6 +3027,9 @@ impl Policy { redirects: self.redirects.clone(), facts: self.facts.clone(), mints: self.mints.clone(), + recorders: self.recorders.clone(), + patterns: self.patterns.clone(), + programs: self.programs.clone(), bundles: self.bundles.clone(), verdicts: self.verdicts.clone(), } @@ -6248,6 +6304,9 @@ mod tests { harness: Harness::ExitCode, facts: Vec::new(), mints: Vec::new(), + recorders: Vec::new(), + patterns: Vec::new(), + programs: std::collections::BTreeMap::new(), bundles: Vec::new(), verdicts: Vec::new(), shapes: Vec::new(), @@ -6287,6 +6346,9 @@ mod tests { harness: Harness::ExitCode, facts: Vec::new(), mints: Vec::new(), + recorders: Vec::new(), + patterns: Vec::new(), + programs: std::collections::BTreeMap::new(), bundles: Vec::new(), verdicts: Vec::new(), verbs: Vec::new(), @@ -6351,6 +6413,9 @@ mod tests { harness: Harness::ExitCode, facts: Vec::new(), mints: Vec::new(), + recorders: Vec::new(), + patterns: Vec::new(), + programs: std::collections::BTreeMap::new(), bundles: Vec::new(), verdicts: Vec::new(), verbs: Vec::new(), @@ -6850,6 +6915,9 @@ mod tests { harness: Harness::ExitCode, facts: Vec::new(), mints: Vec::new(), + recorders: Vec::new(), + patterns: Vec::new(), + programs: std::collections::BTreeMap::new(), bundles: Vec::new(), verdicts: Vec::new(), shapes: vec![shape("no-bare-cargo", "cargo", None)], @@ -6927,6 +6995,9 @@ mod tests { harness: Harness::ExitCode, facts: Vec::new(), mints: Vec::new(), + recorders: Vec::new(), + patterns: Vec::new(), + programs: std::collections::BTreeMap::new(), bundles: Vec::new(), verdicts: Vec::new(), shapes: vec![rule], @@ -7290,6 +7361,9 @@ mod tests { harness: Harness::ExitCode, facts: Vec::new(), mints: Vec::new(), + recorders: Vec::new(), + patterns: Vec::new(), + programs: std::collections::BTreeMap::new(), bundles: Vec::new(), verdicts: Vec::new(), shapes: vec![rule], @@ -7321,6 +7395,9 @@ mod tests { harness: Harness::ExitCode, facts: Vec::new(), mints: Vec::new(), + recorders: Vec::new(), + patterns: Vec::new(), + programs: std::collections::BTreeMap::new(), bundles: Vec::new(), verdicts: Vec::new(), shapes: vec![rule.clone()], @@ -7346,6 +7423,9 @@ mod tests { harness: Harness::ExitCode, facts: Vec::new(), mints: Vec::new(), + recorders: Vec::new(), + patterns: Vec::new(), + programs: std::collections::BTreeMap::new(), bundles: Vec::new(), verdicts: Vec::new(), shapes: vec![rule], @@ -7379,6 +7459,9 @@ mod tests { harness: Harness::ExitCode, facts: Vec::new(), mints: Vec::new(), + recorders: Vec::new(), + patterns: Vec::new(), + programs: std::collections::BTreeMap::new(), bundles: Vec::new(), verdicts: Vec::new(), shapes: vec![ @@ -7424,6 +7507,9 @@ mod tests { harness: Harness::ExitCode, facts: Vec::new(), mints: Vec::new(), + recorders: Vec::new(), + patterns: Vec::new(), + programs: std::collections::BTreeMap::new(), bundles: Vec::new(), verdicts: Vec::new(), shapes: vec![rule], @@ -7484,6 +7570,9 @@ mod tests { harness: Harness::ExitCode, facts: Vec::new(), mints: Vec::new(), + recorders: Vec::new(), + patterns: Vec::new(), + programs: std::collections::BTreeMap::new(), bundles: Vec::new(), verdicts: Vec::new(), shapes: vec![rule], @@ -7551,6 +7640,9 @@ mod tests { harness: Harness::ExitCode, facts: Vec::new(), mints: Vec::new(), + recorders: Vec::new(), + patterns: Vec::new(), + programs: std::collections::BTreeMap::new(), bundles: crate::policy::load( &dir, &[row], @@ -8025,6 +8117,9 @@ deny contains "V-REFUSED-BY-THE-MODULE" if { harness: Harness::ExitCode, facts: Vec::new(), mints: Vec::new(), + recorders: Vec::new(), + patterns: Vec::new(), + programs: std::collections::BTreeMap::new(), bundles: Vec::new(), verdicts: Vec::new(), shapes: vec![rule], @@ -8307,6 +8402,9 @@ deny contains "V-REFUSED-BY-THE-MODULE" if { harness: Harness::ExitCode, facts: Vec::new(), mints: Vec::new(), + recorders: Vec::new(), + patterns: Vec::new(), + programs: std::collections::BTreeMap::new(), bundles: Vec::new(), verdicts: Vec::new(), shapes: vec![rule], @@ -9001,6 +9099,9 @@ deny contains "V-REFUSED-BY-THE-MODULE" if { harness: Harness::ExitCode, facts: Vec::new(), mints: Vec::new(), + recorders: Vec::new(), + patterns: Vec::new(), + programs: std::collections::BTreeMap::new(), bundles: Vec::new(), verdicts: Vec::new(), shapes: Vec::new(), @@ -9084,6 +9185,9 @@ deny contains "V-REFUSED-BY-THE-MODULE" if { harness: Harness::ExitCode, facts: Vec::new(), mints: Vec::new(), + recorders: Vec::new(), + patterns: Vec::new(), + programs: std::collections::BTreeMap::new(), bundles: Vec::new(), verdicts: Vec::new(), shapes: Vec::new(), @@ -9097,6 +9201,9 @@ deny contains "V-REFUSED-BY-THE-MODULE" if { harness: Harness::ExitCode, facts: Vec::new(), mints: Vec::new(), + recorders: Vec::new(), + patterns: Vec::new(), + programs: std::collections::BTreeMap::new(), bundles: Vec::new(), verdicts: Vec::new(), shapes: Vec::new(), diff --git a/crates/batten/src/lib.rs b/crates/batten/src/lib.rs index a5889ca0f..faf280c29 100644 --- a/crates/batten/src/lib.rs +++ b/crates/batten/src/lib.rs @@ -3627,6 +3627,65 @@ fn record_post_tool( // command, so it fires on exactly the calls the conjunct above excludes: an // MCP call carries no command and is the whole point here. record_mints(overrides, envelope); + // CLOUD-1051's, on the same selector and after it. A mint renders a closed + // template; a recorder may additionally run a declared program and record + // what it decided, which is what a board write's refinement column IS. + // Ordered after so a recorder can never be the reason a receipt goes + // unwritten — the two are independent, and the cheaper one goes first. + write_records(overrides, envelope); +} + +/// Append every declared record this result earns (CLOUD-1051). +/// +/// [`record_mints`]'s shape deliberately: the same cheap-question-first ordering, +/// the same anchor-not-cwd rule, and the same silence on every failure. What +/// differs is that a recorder may SPAWN — see [`crate::recorder::run_program`]'s +/// inventory row — so the tool match is load-bearing rather than an economy, and +/// it is checked inside `append_all` before any program is reached. +fn write_records(overrides: &Overrides, envelope: &hook::Envelope) { + // The cheap question first, for `record_mints`' reason: a post-tool event for + // a tool no row names is nearly every call, and `perf-gate` holds the + // mediated path to a ratio. + if envelope.result.is_null() { + return; + } + let Some(result) = facts::payload_in(&envelope.result) else { + return; + }; + let Ok((policy, _)) = load_policy(overrides, hook::Harness::ExitCode) else { + return; + }; + let recorders = policy.declared_recorders(); + if recorders.is_empty() { + return; + } + // THE ANCHOR, NEVER THE CWD (CLOUD-1024's measured defect, restated here + // because this is a second writer and would have repeated it): `batten hook` + // is registered once and then mediates calls from wherever the agent happens + // to be standing. + let root = hook_authority_root(); + let Ok(git_dir) = git::git_dir(root) else { + return; + }; + let Ok(Some(branch)) = git::current_branch(root) else { + return; + }; + let patterns = policy.compiled_patterns(); + let context = crate::recorder::Context { + result: &result, + input: &envelope.input, + programs: policy.declared_programs(), + patterns: &patterns, + root, + }; + crate::recorder::append_all( + recorders, + &git_dir, + &branch, + &context, + rules::selects_tool_name, + &envelope.raw_tool, + ); } /// Mint every receipt this result earns (CLOUD-1024). diff --git a/crates/batten/src/recorder.rs b/crates/batten/src/recorder.rs index b1bd4972e..6bf25c1fc 100644 --- a/crates/batten/src/recorder.rs +++ b/crates/batten/src/recorder.rs @@ -78,11 +78,20 @@ pub enum RecordKey { /// One recorder this repository declares. #[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, schemars::JsonSchema)] -#[serde(deny_unknown_fields)] +#[serde(deny_unknown_fields, rename_all = "kebab-case")] pub struct Declared { - /// The record namespace — the file this writes, and the file the rule that - /// reads it names. + /// What this row is called. Unique across the table, and never the file — a + /// record is written by SEVERAL rows (one per tool, and one per create/groom + /// path), so keying uniqueness on the file would make the natural + /// declaration unwritable. pub name: String, + /// The record this row appends to — the file, and the file the rule that + /// reads it names. + /// + /// Deliberately many-to-one. The censused record carries a line per board + /// write whatever tool made it, so the rows that write it differ in their + /// selector and their columns while naming one destination. + pub record: String, /// Which tool's result records, matched by [`crate::rules::selects_tool_name`]. /// /// A tool name, never a field shape, for [`crate::mint::Declared::tool`]'s @@ -107,6 +116,18 @@ pub struct Declared { /// language. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub refused_when_input: Vec, + /// Write only when the record ALREADY carries a line whose column + /// `column` equals this expression's value. + /// + /// **The narrow exception that keeps a remedy reachable.** A gate that + /// refuses a subject recorded in a bad state has to tell the author how to + /// clear it, and "fix the subject and re-run" only works if the fix is + /// recorded too. But recording every later write would let a row this branch + /// never filed be re-judged on its own say-so — so the exception is bounded + /// to subjects this record already names, which is exactly the set this + /// branch is answerable for. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub requires_recorded: Option, /// The columns, in order, space-joined into one line. /// /// **Every column renders exactly one whitespace-free token.** A record a @@ -118,9 +139,24 @@ pub struct Declared { pub columns: Vec, } +/// A precondition on what the record already holds. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, schemars::JsonSchema)] +#[serde(deny_unknown_fields, rename_all = "kebab-case")] +pub struct Recorded { + /// Every column that must match, keyed by position from zero. + /// + /// **A map rather than one column, and the reason is measured.** The + /// censused record carries a line per board write whatever KIND it was, so a + /// precondition on the subject alone matches a comment line about that + /// subject — and a comment is not this branch having filed the row. The + /// retired shell anchored on the kind and the whole id field together; this + /// is that anchor, generalised. + pub matches: BTreeMap, +} + /// One column of a record. #[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, schemars::JsonSchema)] -#[serde(deny_unknown_fields)] +#[serde(deny_unknown_fields, rename_all = "kebab-case")] pub struct Column { /// What this column is called. Never read by the engine — it is what makes /// the config legible and what a finding can point at. @@ -182,6 +218,20 @@ pub enum Value { Input(String), /// A JSON object built from named sub-expressions. Object(BTreeMap), + /// Every string the named iterating INPUT paths yield, space-joined. + /// + /// [`Value::Wrap`]'s plain sibling, and both are needed rather than one + /// being a special case of the other. `Wrap` builds the OBJECT shape a + /// program reads on stdin; this builds the TOKEN list a column operation + /// compares against. Using `Wrap` for a `minus` compares against + /// `{"id":"X"}` and removes nothing — measured, and silently, because a set + /// difference that subtracts nothing looks exactly like one with nothing to + /// subtract. + /// + /// A list rather than one path because the censused use unions three + /// relation directions, and a union spelled as three columns would be three + /// columns nobody wanted. + Inputs(Vec), /// Each string an iterating INPUT path yields, wrapped as `{: value}`. /// /// The shape a relation list takes in the payload the censused verdict @@ -209,9 +259,13 @@ pub enum Value { select: String, }, /// What a declared program said about another expression's value. + /// + /// The id field is `run` rather than `program`, which is not cosmetic: this + /// enum is externally tagged, so the variant's own key is already `program` + /// and a field of the same name nests one inside the other for a reader. Program { /// The program id, resolved through the consumer's `[program]` table. - program: String, + run: String, /// The expression whose rendered value is handed to the program on stdin. stdin: Box, /// What to read back. @@ -244,7 +298,7 @@ pub enum Read { /// One program a recorder may run. #[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, schemars::JsonSchema)] -#[serde(deny_unknown_fields)] +#[serde(deny_unknown_fields, rename_all = "kebab-case")] pub struct Program { /// The path, repo-relative. Resolved against the repository root rather than /// the cwd for [`crate::lib`]'s measured reason: a hook inherits the cwd of @@ -349,6 +403,13 @@ pub struct Context<'a> { pub fn evaluate(value: &Value, context: &Context<'_>) -> Option { match value { Value::Literal(text) => Some(serde_json::Value::String(text.clone())), + Value::Inputs(paths) => Some(serde_json::Value::String( + paths + .iter() + .flat_map(|path| strings(context.input, path)) + .collect::>() + .join(" "), + )), Value::Result(path) => scalar(context.result, path).map(serde_json::Value::String), Value::Input(path) => scalar(context.input, path).map(serde_json::Value::String), Value::Object(fields) => { @@ -384,12 +445,8 @@ pub fn evaluate(value: &Value, context: &Context<'_>) -> Option { - let declared = context.programs.get(program)?; + Value::Program { run, stdin, read } => { + let declared = context.programs.get(run)?; let payload = as_text(&evaluate(stdin, context)?)?; let (status, out) = run_program(context.root, declared, &payload)?; match read { @@ -555,13 +612,19 @@ pub fn validate( for recorder in recorders { if recorder.name.trim().is_empty() { return Err(crate::error::UsageError::raise( - "a `[[recorder]]` row carries an empty `name`, so it names no record", + "a `[[recorder]]` row carries an empty `name`, so nothing can point at it", )); } + if recorder.record.trim().is_empty() { + return Err(crate::error::UsageError::raise(format!( + "recorder {:?} names no `record`, so it would write nowhere", + recorder.name + ))); + } if !seen.insert(recorder.name.clone()) { return Err(crate::error::UsageError::raise(format!( - "two `[[recorder]]` rows are named {:?}, so one would silently \ - append to the other's record", + "two `[[recorder]]` rows are named {:?}, so a finding could not say \ + which one wrote a line", recorder.name ))); } @@ -571,12 +634,63 @@ pub fn validate( recorder.name ))); } + // A SELECTOR IS NOT A GLOB, and a row that thinks it is fails SILENTLY. + // + // `rules::selects_tool_name` matches the whole name or its final + // `__`-delimited segment (CLOUD-178, so a connector renamed over its + // lifetime still matches). A `*save_issue` written by habit from a shell + // `case` pattern therefore matches nothing at all — and a recorder that + // never fires is byte-identical, on every surface, to a tool nobody + // called. Measured here: the whole table was dead and only the cases + // asserting a row WAS written could see it, because the ones asserting + // none passed vacuously. + // + // Refused at load for that asymmetry: the failure has no loud direction + // at runtime, so the only place it can be caught is before the run. + if let Some(found) = recorder + .tool + .find(['*', '?', '[']) + .and_then(|at| recorder.tool.get(at..=at)) + { + return Err(crate::error::UsageError::raise(format!( + "recorder {:?} selects on tool {:?}, which carries {found:?} — a tool \ + selector is matched whole or by its final `__`-delimited segment, \ + never as a glob, so this row would match nothing and record silently", + recorder.name, recorder.tool + ))); + } if recorder.columns.is_empty() { return Err(crate::error::UsageError::raise(format!( "recorder {:?} declares no columns, so it would append blank lines", recorder.name ))); } + if let Some(recorded) = &recorder.requires_recorded { + if recorded.matches.is_empty() { + return Err(crate::error::UsageError::raise(format!( + "recorder {:?} gates on an empty `requires-recorded`, which every \ + existing line satisfies — a precondition matching anything is not one", + recorder.name + ))); + } + for (column, value) in &recorded.matches { + if *column >= recorder.columns.len() { + return Err(crate::error::UsageError::raise(format!( + "recorder {:?} gates on column {column} of an existing line, but \ + it declares only {} column(s) — the comparison could never hold", + recorder.name, + recorder.columns.len() + ))); + } + validate_value( + &recorder.name, + "requires-recorded", + value, + programs, + patterns, + )?; + } + } for column in &recorder.columns { for value in [ Some(&column.value), @@ -602,7 +716,11 @@ fn validate_value( patterns: &std::collections::BTreeSet, ) -> Result<()> { match value { - Value::Literal(_) | Value::Result(_) | Value::Input(_) | Value::Wrap { .. } => Ok(()), + Value::Literal(_) + | Value::Result(_) + | Value::Input(_) + | Value::Inputs(_) + | Value::Wrap { .. } => Ok(()), Value::Object(fields) => fields .values() .try_for_each(|value| validate_value(recorder, column, value, programs, patterns)), @@ -622,21 +740,17 @@ fn validate_value( } validate_value(recorder, column, from, programs, patterns) } - Value::Program { - program, - stdin, - read, - } => { - if !programs.contains_key(program) { + Value::Program { run, stdin, read } => { + if !programs.contains_key(run) { return Err(crate::error::UsageError::raise(format!( - "recorder {recorder:?} column {column:?} runs program {program:?}, \ + "recorder {recorder:?} column {column:?} runs program {run:?}, \ which no `[program]` row declares" ))); } if let Read::Status(map) = read { if map.is_empty() { return Err(crate::error::UsageError::raise(format!( - "recorder {recorder:?} column {column:?} reads program {program:?}'s \ + "recorder {recorder:?} column {column:?} reads program {run:?}'s \ status through an empty table, so every status would record as \ could-not-look" ))); @@ -654,3 +768,116 @@ fn validate_value( } } } + +/// Append every record this result earns, and report how many were written. +/// +/// **`git_dir`, never the cwd**, which is a measured defect rather than a style +/// choice: a hook inherits the cwd of the tool call, which is not required to be +/// inside this project. `record_mints` states the same rule one module over and +/// is why the capture store kept working while a cwd-rooted reader wrote nothing. +/// +/// Every failure is silent per the module doc. The return value is for a caller +/// that wants to count, never for a verdict — a recorder decides nothing. +pub fn append_all( + recorders: &[Declared], + git_dir: &Path, + branch: &str, + context: &Context<'_>, + selects: impl Fn(&str, &str) -> bool, + tool: &str, +) -> usize { + let mut written = 0; + // THE SNAPSHOT IS TAKEN ONCE, BEFORE ANY APPEND, and that is a correctness + // property rather than an economy. Several rows write one record, so a row + // evaluated later in this loop would otherwise read what an earlier row just + // wrote — and "already recorded" would come to mean "recorded a moment ago by + // this very call". Measured: the create row appended, and the groom row then + // matched its own create and appended a second line for one write. The shell + // this replaces could not have the bug, because it was one program deciding + // once; splitting it into rows is what introduced the ordering. + let mut snapshots: BTreeMap = BTreeMap::new(); + for recorder in recorders { + if !selects(&recorder.tool, tool) { + continue; + } + let RecordKey::Branch = recorder.key; + let path = record_path(git_dir, &recorder.record, branch); + if !snapshots.contains_key(&recorder.record) { + snapshots.insert( + recorder.record.clone(), + std::fs::read_to_string(&path).unwrap_or_default(), + ); + } + if let Some(recorded) = &recorder.requires_recorded + && !already_recorded( + snapshots.get(&recorder.record).map_or("", String::as_str), + recorded, + context, + ) + { + continue; + } + let Some(line) = render(recorder, context) else { + continue; + }; + if append(&path, &line).is_some() { + written += 1; + } + } + written +} + +/// Where a branch-keyed record lives. +/// +/// The `/`→`-` fold is the one every other branch-keyed receipt here takes, and +/// it must match byte for byte: a reader looking under a different spelling finds +/// no file and passes everything, which is the silent direction. +#[must_use] +pub fn record_path(git_dir: &Path, record: &str, branch: &str) -> std::path::PathBuf { + git_dir + .join("batten-receipts") + .join(format!("{record}.{}", branch.replace('/', "-"))) +} + +/// Whether the snapshot already carries a line matching EVERY named column. +/// +/// A record that could not be read is the empty snapshot, so nothing matches and +/// nothing is written. That is the fail-closed direction for a WRITE, and it is +/// deliberately the opposite of how the gate reading this record fails: writing +/// less can only make a later gate quieter, while writing on an unestablished +/// precondition would record a judgement nothing supports. +/// +/// Every column must match, never any: the columns together are the anchor, and +/// matching on one of them is what let a comment line stand in for a filing. +fn already_recorded(snapshot: &str, recorded: &Recorded, context: &Context<'_>) -> bool { + let wanted: Option> = recorded + .matches + .iter() + .map(|(column, value)| { + evaluate(value, context) + .as_ref() + .and_then(as_text) + .map(|text| (*column, text)) + }) + .collect(); + let Some(wanted) = wanted else { + return false; + }; + snapshot.lines().any(|line| { + let columns: Vec<&str> = line.split_whitespace().collect(); + wanted + .iter() + .all(|(at, text)| columns.get(*at).is_some_and(|column| column == text)) + }) +} + +/// Append one line, creating the store if it does not exist. +fn append(path: &Path, line: &str) -> Option<()> { + std::fs::create_dir_all(path.parent()?).ok()?; + let mut file = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(path) + .ok()?; + writeln!(file, "{line}").ok() +} diff --git a/crates/batten/src/resolve.rs b/crates/batten/src/resolve.rs index f5de99f89..1e7e59364 100644 --- a/crates/batten/src/resolve.rs +++ b/crates/batten/src/resolve.rs @@ -342,6 +342,24 @@ pub struct Resolved { /// than a rule someone has to remember. #[serde(rename = "mint")] pub mints: Vec, + /// The records the authority writes from a tool result (CLOUD-1051). + /// + /// **Authority-only, on the strongest form of the reason `mints` above is.** + /// A declared mint lets a local file manufacture the evidence a gate honours. + /// A declared recorder does that AND chooses which program supplies a + /// column's value — so a local layer able to reach this table could hand a + /// gate a verdict of its own choosing while every rule, pattern and severity + /// stayed exactly as the authority wrote them. Carried straight from the + /// authority rather than through [`Tables`], so the restriction is structural + /// rather than a rule someone has to remember. + #[serde(rename = "recorder")] + pub recorders: Vec, + /// The programs a recorder may run, authority-only for the same reason and + /// separately, because the indirection is the sharper half: repointing an id + /// here changes what every column reading it records while the recorder rows + /// stay byte-identical. + #[serde(rename = "program")] + pub programs: std::collections::BTreeMap, /// The suppression-marker table, consumer data the authority supplies. #[serde(rename = "marker")] pub markers: Vec, @@ -1186,6 +1204,8 @@ fn assemble( // Straight from the authority, never through `tables`: see the field's // own note for why the local layer may not reach this one. mints: repo.mints.clone(), + recorders: repo.recorders.clone(), + programs: repo.programs.clone(), markers: repo.markers.clone(), exec: repo.exec, exec_patterns: tables.exec_patterns, @@ -1268,6 +1288,16 @@ fn attribution( // here would not point a gate at chosen output, it would write the // receipt the gate honours (CLOUD-1024). ("mint", authority_set(!repo.mints.is_empty())), + // Authority-only for the STRONGEST form of that reason: a recorder row + // writes what a gate reads AND names the program whose verdict a column + // carries, so a local row here could hand a gate an answer of its own + // choosing while every rule and severity stayed as the authority wrote + // them (CLOUD-1051). + ("recorder", authority_set(!repo.recorders.is_empty())), + // Separately, because the indirection is the sharper half: repointing an + // id here changes what every column reading it records while the recorder + // rows stay byte-identical. + ("program", authority_set(!repo.programs.is_empty())), ("waiver", authority_set(!repo.waivers.is_empty())), ("budget", authority_set(repo.budget.is_some())), ("must_land_on", authority_set(repo.must_land_on.is_some())), diff --git a/crates/batten/src/trust.rs b/crates/batten/src/trust.rs index ac3333cea..faf4efcad 100644 --- a/crates/batten/src/trust.rs +++ b/crates/batten/src/trust.rs @@ -3519,14 +3519,16 @@ mod tests { fn recorder(name: &str, tool: &str, program: &str) -> crate::recorder::Declared { crate::recorder::Declared { name: name.to_owned(), + record: name.to_owned(), tool: tool.to_owned(), + requires_recorded: None, key: crate::recorder::RecordKey::Branch, requires: vec!["id".to_owned()], refused_when_input: Vec::new(), columns: vec![crate::recorder::Column { name: "verdict".to_owned(), value: crate::recorder::Value::Program { - program: program.to_owned(), + run: program.to_owned(), stdin: Box::new(crate::recorder::Value::Result("description".to_owned())), read: crate::recorder::Read::Status( [(String::from("0"), String::from("pass"))] diff --git a/crates/batten/tests/board_record.rs b/crates/batten/tests/board_record.rs new file mode 100644 index 000000000..6f5ed1ca2 --- /dev/null +++ b/crates/batten/tests/board_record.rs @@ -0,0 +1,678 @@ +//! The board-write record, over the compiled binary (CLOUD-1051). +//! +//! # The tier this is, and why the retired suite could not be it +//! +//! `tests/board-write-record.bats` drove a shell program directly. This drives +//! `batten hook`, so it proves the ENGINE builds the record — the seam +//! `.claude/rules/policy-modules.md` names as the one a `with input as` case +//! cannot reach, one layer down: a fabricated invocation passes over a boundary +//! that may be unable to produce the shape at all. +//! +//! # The programs are STUBS, deliberately +//! +//! The retired suite ran the real `ready-lint.sh` and `board-diff-overlap.sh`, +//! so a change to either could redden it — which made it a test of the grammar +//! those own rather than of the recorder. Here each program is a fixture script +//! with a chosen exit status and stdout, so every case below discriminates one +//! property of the RECORDER: the selector, the trust split, the three-valued +//! reads, the column arithmetic, and the create/groom boundary. The two real +//! programs keep their own suites. +//! +//! # RETIREMENT LEDGER — `tests/board-write-record.bats`, 36 cases +//! +//! CARRIED — the property survives, proved here against the engine. +//! +// carried: "a created row is recorded with its id, updatedAt and a green verdict" crates/batten/tests/board_record.rs +// carried: "an unrefined row records a verdict of unready rather than being refused" crates/batten/tests/board_record.rs +// carried: "a row whose body names a changed file records a non-zero overlap" crates/batten/tests/board_record.rs +// carried: "A PATH NAMED ONLY OUTSIDE §1 IS IN THE NAMED COLUMN AND NOT THE §1 ONE" crates/batten/tests/board_record.rs +// carried: "a path named IN §1 reaches the §1 column, so a real claim is still visible" crates/batten/tests/board_record.rs +// carried: "a row naming nothing tracked records a zero" crates/batten/tests/board_record.rs +// carried: "updating an existing row is never recorded" crates/batten/tests/board_record.rs +// carried: "a groom of a row THIS branch filed is recorded" crates/batten/tests/board_record.rs +// carried: "a groom of a row this branch did NOT file is still skipped" crates/batten/tests/board_record.rs +// carried: "an id that merely PREFIXES a filed one does not count as filed here" crates/batten/tests/board_record.rs +// carried: "a groom whose §8 cites a blocker is unjudgeable, not unready" crates/batten/tests/board_record.rs +// carried: "a groom of a genuinely unready body still records unready" crates/batten/tests/board_record.rs +// carried: "a write records the rows its stored body cites" crates/batten/tests/board_record.rs +// carried: "a write passing exactly the rows it cites records zero" crates/batten/tests/board_record.rs +// carried: "zero and could-not-look are distinguishable in the record" crates/batten/tests/board_record.rs +// carried: "the row's own key is not counted as an edge to anywhere" crates/batten/tests/board_record.rs +// carried: "a write the producer never ran for records a dash, never a zero" crates/batten/tests/board_record.rs +// carried: "a comment records the issue key its input names, not the comment uuid" crates/batten/tests/board_record.rs +// carried: "REGRESSION: a comment row never records a uuid" crates/batten/tests/board_record.rs +// carried: "a reply, or a comment on a non-issue parent, records a dash rather than a guess" crates/batten/tests/board_record.rs +// carried: "all three live connector spellings are recorded identically" crates/batten/tests/board_record.rs +// carried: "a tool that does not write to the board is never recorded" crates/batten/tests/board_record.rs +// carried: "POINTER, NEVER PAYLOAD: no byte of the description reaches the record" crates/batten/tests/board_record.rs +// carried: "POINTER, NEVER PAYLOAD: the citing sentence does not reach the record" crates/batten/tests/board_record.rs +// carried: "a comment on a row does not make a later update to it recordable" crates/batten/tests/board_record.rs +// carried: "nothing from the body but a tracked path reaches the record" crates/batten/tests/board_record.rs +//! +//! SUBSUMED — the plumbing became the engine's, which is what a migration should +//! produce. Each names the general property that now covers it. +//! +// subsumed: "FAIL OPEN: an unreadable, nameless or resultless payload records nothing and says nothing" crates/batten/src/lib.rs +// subsumed: "FAIL OPEN: a flat tool_response is not the MCP envelope and records nothing" crates/batten/src/facts.rs +// subsumed: "FAIL OPEN: a detached HEAD has no branch to key a record to" crates/batten/src/lib.rs +// subsumed: "FAIL OPEN: outside a git repository nothing is recorded and nothing is blocked" crates/batten/src/lib.rs +// subsumed: "the settings entry is wired, on a suffix-anchored PostToolUse matcher" mise-tasks/hooks-wiring-check.sh +// subsumed: "the keys come from ready-lint's emission, not a second scan here" crates/batten/src/recorder.rs +// subsumed: "a row whose §8 claims a blocker still records a green verdict" crates/batten/tests/board_record.rs +// subsumed: "A CREATE CITING A BLOCKER IT DID NOT PASS IS STILL UNREADY" crates/batten/tests/board_record.rs +//! +//! CHANGED — behaviour that diverges deliberately, each with its reason. +//! +// changed: "board-write-record::the bypass is honoured" crates/batten/src/recorder.rs +// changed: "A FILE THIS BRANCH HAS NOT TOUCHED IS STILL RECORDED" crates/batten/tests/board_record.rs +//! +//! `BATTEN_BOARD_WRITE_BYPASS` is **gone rather than ported**, and that is a +//! deliberate narrowing rather than an oversight. A bypass exists to let an +//! author past a REFUSAL; a recorder refuses nothing, so the only thing that +//! variable could buy was a quieter record — which is the one direction the gate +//! reading it cannot detect, since it passes on could-not-look by design. A +//! caller who wants no record removes the `[[recorder]]` row, which is a visible +//! config change rather than an invisible environment one. The engine's own +//! `BATTEN_HOOK_BYPASS` still suppresses the whole mediated path. +//! +//! The overlap column's meaning is unchanged — it holds the paths the body NAMES, +//! intersected later by the gate — so the retired case asserting an untouched +//! file is still recorded is carried under a name that says what it measures. + +mod common; + +use std::fs; +use std::path::{Path, PathBuf}; + +use common::{run_with_stdin, scratch}; + +/// A fixture repository with a recorder table and two stub programs. +/// +/// The stubs are what make each case below a test of the RECORDER: their status +/// and stdout are chosen per fixture, so a column's value is a function of the +/// declaration rather than of a grammar some other file owns. +fn repo(name: &str, lint_exit: i32, lint_stdout: &str, paths_stdout: &str) -> PathBuf { + let dir = scratch(name); + write_program(&dir, "lint.sh", lint_exit, lint_stdout); + write_program(&dir, "paths.sh", 0, paths_stdout); + fs::write(dir.join("batten.toml"), CONFIG).expect("write config"); + git(&dir, &["init", "--quiet", "--initial-branch", "work"]); + git(&dir, &["config", "user.email", "t@example.com"]); + git(&dir, &["config", "user.name", "t"]); + git(&dir, &["add", "-A"]); + git(&dir, &["commit", "--quiet", "-m", "seed"]); + dir +} + +fn write_program(dir: &Path, name: &str, exit: i32, stdout: &str) { + let path = dir.join(name); + fs::write( + &path, + // `%s\n` with the newline OWNED HERE rather than embedded in the + // argument: a `\n` inside the Rust literal reaches bash escaped, so + // `printf '%s'` prints it verbatim and glues it to the last token. + format!("#!/usr/bin/env bash\ncat >/dev/null\nprintf '%s\\n' {stdout:?}\nexit {exit}\n"), + ) + .expect("write stub"); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + fs::set_permissions(&path, fs::Permissions::from_mode(0o755)).expect("chmod stub"); + } +} + +#[expect( + clippy::disallowed_types, + reason = "stays — a fixture repository needs real git history for the branch key the \ + recorder files under, and `git.rs`'s own test fixtures are the precedent. \ + Test-only, so no shipped path spawns here." +)] +fn git(dir: &Path, args: &[&str]) { + let status = std::process::Command::new("git") + .args(args) + .current_dir(dir) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .expect("run git"); + assert!(status.success(), "git {args:?} failed"); +} + +/// The record this branch accumulated, or the empty string when none was written. +fn record(dir: &Path) -> String { + fs::read_to_string(dir.join(".git/batten-receipts/board-writes.work")).unwrap_or_default() +} + +/// A `PostToolUse` envelope carrying an MCP content-block response. +fn payload(tool: &str, input: &str, result: &str) -> String { + // The MCP envelope, which is the shape a connector actually sends: a + // content-block list whose text is the row as JSON. The retired suite's + // header recorded this as measured rather than assumed, because a body + // written against the documented flat-object shape reads nothing. + let text = serde_json::to_string(result).expect("encode result"); + let blocks = format!(r#"[{{"type":"text","text":{text}}}]"#); + format!( + r#"{{"hook_event_name":"PostToolUse","tool_name":{tool:?},"tool_input":{input},"tool_response":{blocks}}}"# + ) +} + +fn hook(dir: &Path, tool: &str, input: &str, result: &str) { + let output = run_with_stdin( + dir, + &["hook", "--harness", "exit-code"], + &payload(tool, input, result), + ); + assert_eq!( + output.status.code(), + Some(0), + "a recorder refuses nothing, so the call is always allowed" + ); +} + +const CONFIG: &str = r#" +version = 1 + +[program.lint] +path = "lint.sh" + +[program.paths] +path = "paths.sh" + +[[pattern]] +id = "clause-label" +regex = '^\*\*[^*]*\(§[0-9]+\)' + +[[pattern]] +id = "clause-one" +regex = '\(§1\)' + +[[recorder]] +name = "created" +record = "board-writes" +tool = "save_issue" +key = "branch" +requires = ["id"] +refused-when-input = ["id"] + +[[recorder.columns]] +name = "kind" +value = { literal = "issue" } + +[[recorder.columns]] +name = "id" +value = { result = "id" } + +[[recorder.columns]] +name = "updated" +value = { result = "updatedAt" } + +[[recorder.columns]] +name = "verdict" +value = { program = { run = "lint", read = { status = { "0" = "ready", "1" = "unready" } }, stdin = { object = { id = { result = "id" }, description = { result = "description" }, relations = { object = { blockedBy = { wrap = { from = "blockedBy[]", key = "id" } } } } } } } } + +[[recorder.columns]] +name = "overlap" +value = { program = { run = "paths", read = "stdout", stdin = { result = "description" } } } +zero-is-a-count = true + +[[recorder.columns]] +name = "cites" +value = { program = { run = "lint", read = { stdout-line = "cites-body " }, stdin = { result = "description" } } } +minus = { inputs = ["relatedTo[]", "blockedBy[]", "blocks[]"] } +without = { result = "id" } +counted-with = ":" +zero-is-a-count = true + +[[recorder.columns]] +name = "sec1" +value = { program = { run = "paths", read = "stdout", stdin = { section = { from = { result = "description" }, label = "clause-label", select = "clause-one" } } } } + +[[recorder]] +name = "groomed" +record = "board-writes" +tool = "save_issue" +key = "branch" +requires = ["id"] +requires-recorded = { matches = { 0 = { literal = "issue" }, 1 = { result = "id" } } } + +[[recorder.columns]] +name = "kind" +value = { literal = "issue" } + +[[recorder.columns]] +name = "id" +value = { result = "id" } + +[[recorder.columns]] +name = "updated" +value = { result = "updatedAt" } + +[[recorder.columns]] +name = "verdict" +value = { program = { run = "lint", read = { status = { "0" = "ready", "1" = "unready" } }, stdin = { object = { id = { result = "id" }, description = { result = "description" } } } } } + +[[recorder]] +name = "comment" +record = "board-writes" +tool = "save_comment" +key = "branch" +requires = ["id"] + +[[recorder.columns]] +name = "kind" +value = { literal = "comment" } + +[[recorder.columns]] +name = "id" +value = { input = "issueId" } + +[[recorder.columns]] +name = "verdict" +value = { literal = "-" } +"#; + +#[test] +fn a_created_row_is_recorded_with_its_id_updated_at_and_a_green_verdict() { + let dir = repo("record-create", 0, "cites-body ", "0"); + hook( + &dir, + "mcp__Linear__save_issue", + r#"{"title":"a finding"}"#, + r#"{"id":"CLOUD-1","updatedAt":"2026-08-26T00:00:00Z","description":"body"}"#, + ); + let line = record(&dir); + let columns: Vec<&str> = line.split_whitespace().collect(); + assert_eq!(columns[0], "issue", "got: {line:?}"); + assert_eq!(columns[1], "CLOUD-1", "got: {line:?}"); + assert_eq!(columns[2], "2026-08-26T00:00:00Z", "got: {line:?}"); + assert_eq!(columns[3], "ready", "got: {line:?}"); +} + +#[test] +fn an_unrefined_row_records_unready_rather_than_being_refused() { + // The direction that matters: a recorder never refuses, so the write lands + // and the verdict is what a later gate reads. + let dir = repo("record-unready", 1, "", "0"); + hook( + &dir, + "mcp__Linear__save_issue", + "{}", + r#"{"id":"CLOUD-2","updatedAt":"t","description":"body"}"#, + ); + assert_eq!( + record(&dir).split_whitespace().nth(3), + Some("unready"), + "got: {:?}", + record(&dir) + ); +} + +#[test] +fn a_status_no_row_maps_is_could_not_look_never_a_refusal() { + // CARRIES "a groom whose §8 cites a blocker is unjudgeable, not unready" and + // "a write the producer never ran for records a dash, never a zero". The + // retired program's exit 2 meant COULD NOT JUDGE, and folding anything + // non-zero into the refusal would record a verdict about the environment + // wearing the mask of a verdict about the row. + let dir = repo("record-unjudgeable", 2, "", "0"); + hook( + &dir, + "mcp__Linear__save_issue", + "{}", + r#"{"id":"CLOUD-3","updatedAt":"t","description":"body"}"#, + ); + assert_eq!( + record(&dir).split_whitespace().nth(3), + Some("-"), + "got: {:?}", + record(&dir) + ); +} + +#[test] +fn zero_and_could_not_look_are_distinguishable_in_the_record() { + // CLOUD-251's split, and the one this record shape cannot afford to lose: the + // gate downstream PASSES on `-` by design, so a real measurement of nothing + // rendering as `-` would be a finding silently waived. + let dir = repo("record-zero", 0, "cites-body ", ""); + hook( + &dir, + "mcp__Linear__save_issue", + "{}", + r#"{"id":"CLOUD-4","updatedAt":"t","description":"body"}"#, + ); + let line = record(&dir); + let columns: Vec<&str> = line.split_whitespace().collect(); + assert_eq!( + columns[4], "0", + "an asked question that found nothing: {line:?}" + ); + assert_eq!( + columns[6], "-", + "a narrowing that yielded no span is could-not-look: {line:?}" + ); +} + +#[test] +fn a_row_whose_body_names_a_changed_file_records_a_non_zero_overlap() { + let dir = repo("record-overlap", 0, "cites-body ", "2 a.rs b.rs"); + hook( + &dir, + "mcp__Linear__save_issue", + "{}", + r#"{"id":"CLOUD-5","updatedAt":"t","description":"body"}"#, + ); + assert_eq!( + record(&dir).split_whitespace().nth(4), + Some("2,a.rs,b.rs"), + "one column, one token: {:?}", + record(&dir) + ); +} + +#[test] +fn a_path_named_only_outside_section_one_is_not_in_the_section_one_column() { + // CLOUD-854's discriminator. A path-name intersection cannot tell a row + // CLAIMING work on a file from one CITING it as evidence, so §1 — which names + // the source of truth by construction — is read separately. + let dir = repo("record-sec1-absent", 0, "cites-body ", "1 a.rs"); + hook( + &dir, + "mcp__Linear__save_issue", + "{}", + r#"{"id":"CLOUD-6","updatedAt":"t","description":"**Evidence (§2).**\nmeasured on a.rs"}"#, + ); + let line = record(&dir); + let columns: Vec<&str> = line.split_whitespace().collect(); + assert_eq!( + columns[4], "1,a.rs", + "the named column still sees it: {line:?}" + ); + assert_eq!( + columns[6], "-", + "no §1 span, so the §1 column could not look: {line:?}" + ); +} + +#[test] +fn a_path_named_in_section_one_reaches_the_section_one_column() { + // The discriminator for the case above: without this, the §1 column could be + // permanently `-` and the pair would still pass. + let dir = repo("record-sec1-present", 0, "cites-body ", "1 a.rs"); + hook( + &dir, + "mcp__Linear__save_issue", + "{}", + r#"{"id":"CLOUD-7","updatedAt":"t","description":"**Source of truth (§1).**\na.rs"}"#, + ); + assert_eq!( + record(&dir).split_whitespace().nth(6), + Some("1,a.rs"), + "got: {:?}", + record(&dir) + ); +} + +#[test] +fn updating_an_existing_row_is_never_recorded() { + // An `id` in the INPUT is an update to a row that already exists, which is + // not a board write this branch is answerable for. + let dir = repo("record-update", 0, "cites-body ", "0"); + hook( + &dir, + "mcp__Linear__save_issue", + r#"{"id":"CLOUD-8"}"#, + r#"{"id":"CLOUD-8","updatedAt":"t","description":"body"}"#, + ); + assert_eq!(record(&dir), "", "nothing recorded: {:?}", record(&dir)); +} + +#[test] +fn a_groom_of_a_row_this_branch_filed_is_recorded() { + // CLOUD-514's third remedy, and without this it is unreachable: the gate + // tells a branch to groom its unrefined row and re-run, but a groom carries + // an id, so the create row refuses it and the creation-time verdict stands. + let dir = repo("record-groom", 1, "", "0"); + hook( + &dir, + "mcp__Linear__save_issue", + "{}", + r#"{"id":"CLOUD-9","updatedAt":"t","description":"body"}"#, + ); + assert_eq!(record(&dir).lines().count(), 1, "the create"); + + // The groom, now that the record names this subject. + hook( + &dir, + "mcp__Linear__save_issue", + r#"{"id":"CLOUD-9"}"#, + r#"{"id":"CLOUD-9","updatedAt":"t2","description":"groomed"}"#, + ); + let written = record(&dir); + let lines: Vec<&str> = written.lines().collect(); + assert_eq!(lines.len(), 2, "the groom is a second line: {lines:?}"); + assert!(lines[1].contains("CLOUD-9"), "got: {lines:?}"); +} + +#[test] +fn a_groom_of_a_row_this_branch_did_not_file_is_still_skipped() { + // The narrowing that keeps the exception from becoming a hole: re-judging a + // row this branch never filed would let it be re-judged on its own say-so. + let dir = repo("record-groom-foreign", 0, "cites-body ", "0"); + hook( + &dir, + "mcp__Linear__save_issue", + r#"{"id":"CLOUD-99"}"#, + r#"{"id":"CLOUD-99","updatedAt":"t","description":"body"}"#, + ); + assert_eq!(record(&dir), "", "nothing recorded: {:?}", record(&dir)); +} + +#[test] +fn an_id_that_merely_prefixes_a_filed_one_does_not_count_as_filed_here() { + // The whole-column comparison, and the reason it is a column rather than a + // substring: `CLOUD-1` must not match the `CLOUD-17` line beside it. + let dir = repo("record-prefix", 0, "cites-body ", "0"); + hook( + &dir, + "mcp__Linear__save_issue", + "{}", + r#"{"id":"CLOUD-17","updatedAt":"t","description":"body"}"#, + ); + hook( + &dir, + "mcp__Linear__save_issue", + r#"{"id":"CLOUD-1"}"#, + r#"{"id":"CLOUD-1","updatedAt":"t","description":"body"}"#, + ); + assert_eq!( + record(&dir).lines().count(), + 1, + "the prefix did not admit a groom: {:?}", + record(&dir) + ); +} + +#[test] +fn a_write_records_the_rows_its_stored_body_cites() { + let dir = repo("record-cites", 0, "cites-body CLOUD-20 CLOUD-21", "0"); + hook( + &dir, + "mcp__Linear__save_issue", + "{}", + r#"{"id":"CLOUD-10","updatedAt":"t","description":"body"}"#, + ); + assert_eq!( + record(&dir).split_whitespace().nth(5), + Some("2:CLOUD-20,CLOUD-21"), + "a count and the far-end keys: {:?}", + record(&dir) + ); +} + +#[test] +fn a_write_passing_exactly_the_rows_it_cites_records_zero() { + // The set difference: a row passed as a relation is not "minted by prose" + // however the body also mentions it. + let dir = repo("record-cites-passed", 0, "cites-body CLOUD-20", "0"); + hook( + &dir, + "mcp__Linear__save_issue", + r#"{"relatedTo":["CLOUD-20"]}"#, + r#"{"id":"CLOUD-11","updatedAt":"t","description":"body"}"#, + ); + assert_eq!( + record(&dir).split_whitespace().nth(5), + Some("0"), + "got: {:?}", + record(&dir) + ); +} + +#[test] +fn the_rows_own_key_is_not_counted_as_an_edge_to_anywhere() { + let dir = repo("record-self-cite", 0, "cites-body CLOUD-12", "0"); + hook( + &dir, + "mcp__Linear__save_issue", + "{}", + r#"{"id":"CLOUD-12","updatedAt":"t","description":"body"}"#, + ); + assert_eq!( + record(&dir).split_whitespace().nth(5), + Some("0"), + "got: {:?}", + record(&dir) + ); +} + +#[test] +fn a_comment_records_the_issue_key_its_input_names_never_the_comment_uuid() { + // REGRESSION, measured on the retired recorder's first five live rows: a + // `save_comment` response is the COMMENT object, so its `id` is the comment's + // own uuid and names no row. A uuid in an issue-key column is a wrong answer + // wearing a right answer's shape. + let dir = repo("record-comment", 0, "", "0"); + let uuid = "4d16245a-43ea-49ae-b67d-c2ee0b64b96e"; + hook( + &dir, + "mcp__Linear__save_comment", + r#"{"issueId":"CLOUD-13"}"#, + &format!(r#"{{"id":"{uuid}"}}"#), + ); + let line = record(&dir); + assert_eq!( + line.split_whitespace().nth(1), + Some("CLOUD-13"), + "got: {line:?}" + ); + assert!(!line.contains(uuid), "no uuid reaches the record: {line:?}"); +} + +#[test] +fn a_reply_or_a_comment_on_a_non_issue_parent_records_a_dash_rather_than_a_guess() { + // A reply carries `parentId` and no `issueId`; the thread determines the + // issue, which this boundary cannot see. Could-not-look, never a fallback. + let dir = repo("record-reply", 0, "", "0"); + hook( + &dir, + "mcp__Linear__save_comment", + r#"{"parentId":"abc"}"#, + r#"{"id":"def"}"#, + ); + assert_eq!( + record(&dir).split_whitespace().nth(1), + Some("-"), + "got: {:?}", + record(&dir) + ); +} + +#[test] +fn a_comment_on_a_row_does_not_make_a_later_update_to_it_recordable() { + // The groom precondition reads column 1 of any line, so a comment line for a + // subject must not be mistaken for this branch having FILED it. + let dir = repo("record-comment-then-update", 0, "cites-body ", "0"); + hook( + &dir, + "mcp__Linear__save_comment", + r#"{"issueId":"CLOUD-14"}"#, + r#"{"id":"uuid"}"#, + ); + hook( + &dir, + "mcp__Linear__save_issue", + r#"{"id":"CLOUD-14"}"#, + r#"{"id":"CLOUD-14","updatedAt":"t","description":"body"}"#, + ); + let lines = record(&dir); + assert_eq!( + lines.lines().count(), + 1, + "only the comment; the update was not admitted: {lines:?}" + ); + assert!(lines.starts_with("comment"), "got: {lines:?}"); +} + +#[test] +fn all_three_live_connector_spellings_are_recorded_identically() { + // CLOUD-178: the same connector was exposed under three names over its + // lifetime, so a selector naming one matches none of the others and the miss + // is silent. + for (index, tool) in [ + "mcp__Linear__save_issue", + "mcp__b7f2__save_issue", + "mcp__claude_ai_Linear__save_issue", + ] + .into_iter() + .enumerate() + { + let dir = repo(&format!("record-spelling-{index}"), 0, "cites-body ", "0"); + hook( + &dir, + tool, + "{}", + r#"{"id":"CLOUD-15","updatedAt":"t","description":"body"}"#, + ); + assert!( + record(&dir).starts_with("issue CLOUD-15"), + "{tool} recorded nothing: {:?}", + record(&dir) + ); + } +} + +#[test] +fn a_tool_that_does_not_write_to_the_board_is_never_recorded() { + let dir = repo("record-other-tool", 0, "cites-body ", "0"); + hook( + &dir, + "mcp__Linear__get_issue", + "{}", + r#"{"id":"CLOUD-16","updatedAt":"t","description":"body"}"#, + ); + assert_eq!(record(&dir), "", "nothing recorded: {:?}", record(&dir)); +} + +#[test] +fn pointer_never_payload_no_byte_of_the_description_reaches_the_record() { + // Non-negotiable rule 4, and it is load-bearing here rather than decorative: + // the text this reads is an entire issue body. + let sentence = "SENTINELPROSE that must not travel"; + let dir = repo("record-pointer", 0, "cites-body CLOUD-30", "1 a.rs"); + hook( + &dir, + "mcp__Linear__save_issue", + "{}", + &format!( + r#"{{"id":"CLOUD-18","updatedAt":"t","description":"**Source of truth (§1).**\n{sentence} a.rs"}}"# + ), + ); + let line = record(&dir); + assert!(!line.is_empty(), "the row was recorded"); + assert!( + !line.contains("SENTINELPROSE"), + "no prose reaches the record: {line:?}" + ); + assert!(line.contains("a.rs"), "only a tracked path does: {line:?}"); +} diff --git a/mise-tasks/board-write-record.sh b/mise-tasks/board-write-record.sh deleted file mode 100755 index 6017e5120..000000000 --- a/mise-tasks/board-write-record.sh +++ /dev/null @@ -1,509 +0,0 @@ -#!/usr/bin/env bash -#MISE description="PostToolUse hook body: record what this branch put on the board, with the tracker's own verdict on whether a new row was refined" -# -# CLOUD-514, phase 1. Every gate here prices FAILING to record something — -# `finding-sink-check` fails a turn that cites evidence and writes nothing, -# `deferral-check` fails a PR that defers without naming an issue. Nothing prices -# the opposite. Filing satisfies all of them in seconds while finishing costs a -# diff, a suite and a landing, so for an agent under pressure the punt is not a -# temptation, it is arithmetic. -# -# THIS FILE GATES NOTHING. It is the sensor half, and shipping a sensor alone is -# normally the "log without a gate" non-negotiable 2 refuses. The exception is -# argued and measured: the gate's firing rate cannot be estimated retrospectively -# because WHICH BRANCH PUT WHICH ROW ON THE BOARD has never been recorded -# anywhere. The one available proxy — rows created between a PR opening and its -# merge — was measured over 40 merged PRs and fires on 183 of 184, because a -# window over a fleet captures every session's filings. So this record is what -# makes the gate specifiable at all, and its enabling trigger is a number this -# produces rather than a date. -# -# WHY THE LINT HAPPENS HERE, WHICH IS THE WHOLE DESIGN. The first draft had the -# agent run `ready-lint` and mint a receipt. That is worthless: `ready-lint` reads -# a payload the caller assembles, and it was run three times during this issue's -# own refinement against text in a local file — once under the id `CLOUD-NEW`, -# for a row that did not exist — green every time. A toll payable in text nobody -# filed is not a toll. A `PostToolUse` body does not have that problem: it fires -# on the tool RESULT, which is the tracker's response to the create. -# -# THE RESULT ENVELOPE IS MEASURED, NOT ASSUMED. No hook in this tree had ever -# read a tool result, and the documented example is a `Write` whose response is a -# flat object. An MCP tool's is not: it is the content-block list -# `[{"type":"text","text":""}]`, so `.tool_response.id` does not -# exist and a body written against the documented shape would silently record -# nothing. -# -# RELATIONS COME FROM THE INPUT, AND THAT IS NOT A COMPROMISE. `ready-lint`'s §8 -# rule cross-checks prose claiming `blockedBy CLOUD-N` against the payload's -# relations, and the create response carries no relations at all — so linting the -# response alone reports `blocker-cited-without-relation` on exactly the rows that -# were refined most carefully. The create call's own `blockedBy` argument is the -# entire relation set on a create (there is nothing prior to append to), so it is -# what the tracker acted on. The BODY being judged is still the tracker's; naming -# a blocker is what creates the relation, so this cannot claim a dependency the -# board does not have. -# -# POINTER-ONLY IS LOAD-BEARING HERE (non-negotiable 4), not decorative: the text -# this reads is the entire issue body. Five fields reach the file — kind, id, -# updatedAt, verdict, the named paths, and the rows the stored body cites that the -# caller passed as no relation (CLOUD-923) — and nothing is ever printed. -# -# THE FIFTH FIELD IS PHASE 3 (CLOUD-514), and it is what the first two phases -# left out. The `verdict` column prices REFINEMENT: it asks whether the new row -# was written to Ready. It cannot ask the question the issue's acceptance is -# actually about — whether the row names code THIS BRANCH IS HOLDING OPEN — -# because a Ready block is prose, and prose is the one currency an agent has -# without limit. Measured 2026-08-20 on this branch: four rows filed in three and -# a half minutes, twelve more spent writing four Ready blocks, and every one -# recorded `ready`. The toll did not reverse the arithmetic; it certified it. -# -# So the overlap column records how many paths the row's body names that -# `origin/main...HEAD` is also changing — `mise-tasks/board-diff-overlap.sh`, which -# owns the predicate and its measurement. It is recorded for a groom as well as a -# create, for the same reason the verdict is: a later reading of the same row by -# the same mechanism is simply the current one. Pointer-only holds by -# construction there — only paths tracked in this repository can appear. -# -# FAILS OPEN AND SILENT on everything it cannot establish. A recorder that -# blocked or noised a board write would cause the failure `finding-sink-check` -# exists to catch, which is the opposite of the point. -# -# The mutation widens the exception below to every update, so an edit to any -# existing row — anyone's — is recorded as a filing, which inflates the very count -# the gate is specified against, in the direction that makes filing look normal. -#MUTANT any-update-recorded|s/^\t\t\t\tgrep -qE .*$/\t\t\t\ttrue; then/|a groom of a row this branch did NOT file is still skipped -# The mutation removes the one exception to that, so a groom of a row THIS branch -# filed goes unrecorded again — which leaves the creation-time verdict standing -# and `filed-here-check`'s third remedy unreachable, the state that held PR #525. -#MUTANT never-records-a-groom|s/^\t\t\t\tfiled_here=1$/\t\t\t\tfiled_here=/|a groom of a row THIS branch filed is recorded -# The mutation drops the relations synthesis, so a row whose §8 claims a blocker -# lints as blocker-cited-without-relation and records `unready` — the false -# refusal that would fire on the best-refined rows. -# The mutation restores the shipped defect: synthesise on the update path too, so -# a groom that adds a correct §8 clause is the write that records `unready` — -# `filed-here-check` refusing the remedy it advertises (CLOUD-781). -#MUTANT groom-synthesises-relations|s/^\tif \[\[ -z "\$existing" \]\]; then$/\tif true; then/|a groom whose §8 cites a blocker is unjudgeable, not unready -#MUTANT relations-dropped|s/relations: \$rels/relations: {}/|claims a blocker still records a green verdict -# The mutation restores the shipped defect: take the id from the response for a -# comment too, so an issue-key column fills with comment uuids and sink 2 becomes -# unobservable while every count still looks right. -#MUTANT comment-id-from-response|s/^if \[\[ "\$kind" = comment \]\]; then$/if false; then/|records the issue key its input names -# The mutation stops asking the diff question at all, so every row records `-` -# and `filed-here-check` reads "could not look" for a row filed straight over the -# branch's own open files — phase 3 wired shut while every count still looks right. -#MUTANT overlap-never-measured|s/^\t\toverlap=\$(printf .*$/\t\toverlap=-/|a row whose body names a changed file records a non-zero overlap -# The mutation drops `--named`, so the column falls back to the write-time -# intersection — which is `0` for every row filed before its file is touched, the -# defect CLOUD-774 exists to remove. Only a case that files with an EMPTY diff and -# then checks can catch it. -# `--n[a]med` is a character class on purpose: written literally, the pattern -# matches THIS LINE, so every run rewrote the declaration instead of the call, -# came back non-inert, and survived. `mutant` refuses that shape as -# `self-mutating-row` since CLOUD-480; the class is how a row names a string it -# must also contain. -#MUTANT overlap-frozen-at-write-time|s@ --n[a]med @ @|A FILE THIS BRANCH HAS NOT TOUCHED IS STILL RECORDED -# -# CLOUD-923's column, and the mutation is the one that passes every other row: read -# the citations from the caller's ARGUMENT instead of the tracker's response, so a -# caller that strips them from what it sends records zero over a body that carries -# eight. Anchored on `^if`, so it cannot match its own `#MUTANT` line and is -# not the self-mutating shape `mutant` refuses since CLOUD-480. -# THIS DECLARATION WENT STALE UNDER ITS OWN AUTHOR (CLOUD-941's class, one commit -# apart): it targeted the `-n "${description:-}"` guard, and CLOUD-806 replaced that -# guard with `-n "$emitted"` when the keys moved to `ready-lint`'s emission. `sed` -# does not call "matched zero lines" an error, so `mutant` reported it `inert` rather -# than passing — which is the verdict working, and the reason a declaration must be -# re-read whenever the line it names is edited. -#MUTANT cites-read-from-the-argument|s@^if \[\[ "$kind" = issue \]\] && \[\[ -n "$emitted" \]\]; then@if false; then@|a write records the rows its stored body cites -set -uo pipefail - -# -# CLOUD-479's pairing, declared rather than discovered. This hook is registered -# BY PATH, so it does not get mise's env and the `"aqua:jqlang/jq"` pin below -# does not reach it — `jq` is whatever the ambient PATH holds, or nothing. Every -# read here is fail-open, so an absent `jq` would not error: it would ALLOW, -# silently, which is the one outcome a guard must never reach by accident. -# -# Asserted loudly and still open. A `PreToolUse` hook's exit 2 is a DENY, so a -# missing parser must not take that channel — it says so on stderr and gets out -# of the way, which is loud where it used to be silent while never blocking a -# call over a broken toolchain. -#PIN-OK: jq -if ! command -v jq >/dev/null 2>&1; then - echo "::error:: board-write-record: no jq on PATH — this guard is registered by path, so it does not get mise's pinned jq. It is checking NOTHING and allowing every call. Run: mise install" >&2 - exit 0 -fi - -[[ -n "${BATTEN_BOARD_WRITE_BYPASS:-}" ]] && exit 0 - -raw=$(cat) || exit 0 - -tool=$(printf '%s' "$raw" | jq -r '.tool_name // empty' 2>/dev/null) || exit 0 -[[ -n "$tool" ]] || exit 0 - -# Suffix, never prefix. CLOUD-178 measured the same connector exposed as -# `mcp__Linear__save_issue`, `mcp____save_issue` and -# `mcp__claude_ai_Linear__save_issue` depending on the registration episode, so a -# rule naming one matches none of the others and the miss is silent. The settings -# matcher is anchored the same way; this is belt to its braces. -case "$tool" in -*save_issue) kind=issue ;; -*save_comment) kind=comment ;; -*) exit 0 ;; -esac - -# An `id` in the INPUT means this updates a row that already exists, which is not -# a board write this branch is answerable for. `finding-sink-check` already tells -# the two apart this way. -# -# ONE EXCEPTION, AND WITHOUT IT `filed-here-check`'S THIRD REMEDY IS UNREACHABLE -# (CLOUD-514). That gate tells a branch which filed an unrefined row to "groom -# the row above to Ready and re-run `land`" — but a groom is a `save_issue` WITH -# an id, so this skipped it, the record kept its creation-time `unready`, and the -# gate refused forever. Measured 2026-08-19 on PR #525: the row was groomed until -# `ready-lint` exited 0 over the tracker's own response, and the refusal did not -# move. Remedies 1 and 2 fail the same way once a line exists, so the only escape -# was the bypass — which the gate's own bats suite does not scrub, so exporting it -# turned six refusal cases into passes. -# -# The exception is narrow on purpose: an update is recorded only when this -# branch's record already carries an `issue` line for that id. That is the row -# this branch filed and is answerable for, so re-linting it grants nothing a -# fresh create would not have granted, and the verdict still comes from linting -# the tracker's RESPONSE rather than from any caller's assertion. An update to -# somebody else's row is skipped exactly as before. -existing="" -if [[ "$kind" = issue ]]; then - existing=$(printf '%s' "$raw" | jq -r '.tool_input.id // empty' 2>/dev/null) || exit 0 - if [[ -n "$existing" ]]; then - filed_here="" - if here_dir=$(git rev-parse --git-dir 2>/dev/null) && [[ -n "$here_dir" ]] && - here_branch=$(git symbolic-ref --quiet --short HEAD 2>/dev/null) && - [[ -n "$here_branch" ]]; then - here_record="$here_dir/batten-receipts/board-writes.${here_branch//\//-}" - # Anchored on the kind and the whole id field, so `CLOUD-71` never - # matches the `CLOUD-717` line beside it. - if [[ -r "$here_record" ]] && - grep -qE "^issue ${existing} " "$here_record" 2>/dev/null; then - filed_here=1 - fi - fi - [[ -n "$filed_here" ]] || exit 0 - fi -fi - -# The content-block envelope, per the measurement in the header. `fromjson?` -# rather than `fromjson` so a text block that is not JSON is skipped instead of -# aborting the whole read. -row=$(printf '%s' "$raw" | jq -c ' - [ .tool_response[]? | select(.type == "text") | .text | fromjson? ] | first // empty -' 2>/dev/null) || exit 0 -[[ -n "$row" ]] || exit 0 - -# THE ID COLUMN IS AN ISSUE KEY, AND FOR A COMMENT THE RESPONSE CANNOT SUPPLY -# ONE. Measured on this recorder's own first five live rows, which came out as -# `comment 4d16245a-43ea-49ae-b67d-c2ee0b64b96e …` — the comment's own uuid. A -# `save_comment` response is the COMMENT object: its `.id` is the comment, and it -# carries no reference to the row the comment landed on. Taking `.id` for both -# kinds therefore silently fills an issue-key column with uuids, and sink 2's -# whole definition — a comment on the row that already owns the finding — becomes -# unobservable. -# -# The key is in the INPUT instead, as the parent reference. `issueId` is the only -# parent that is a board row; a reply carries `parentId` and no `issueId` (the -# thread determines the issue, which this hook cannot see), and a comment on a -# project, document or milestone is not a row at all. Both record `-`, the same -# "could not look" this file already draws for a verdict. -# -# NEVER FALL BACK TO THE COMMENT UUID. A uuid in an issue-key column is a wrong -# answer wearing a right answer's shape, and it reads as data rather than as a -# gap — which is strictly worse than the gap, because nothing downstream can tell -# the two apart. -if [[ "$kind" = comment ]]; then - id=$(printf '%s' "$raw" | jq -r '.tool_input.issueId // "-"' 2>/dev/null) || id=- -else - id=$(jq -r '.id // empty' <<<"$row" 2>/dev/null) || exit 0 - [[ -n "$id" ]] || exit 0 -fi -[[ -n "$id" ]] || id=- -updated=$(jq -r '.updatedAt // "-"' <<<"$row" 2>/dev/null) || updated=- - -git_dir=$(git rev-parse --git-dir 2>/dev/null) || exit 0 -[[ -n "$git_dir" ]] || exit 0 -branch=$(git symbolic-ref --quiet --short HEAD 2>/dev/null) || exit 0 -[[ -n "$branch" ]] || exit 0 - -# A comment is sink 2 — recorded so the create-versus-comment ratio is -# observable, never judged. Only a new row carries a refinement obligation. -verdict=- -if [[ "$kind" = issue ]]; then - # ALL THREE DIRECTIONS, NOT JUST `blockedBy` (CLOUD-774). `ready-lint`'s - # `deferral-cited-without-relation` scans the whole description and accepts ANY - # relation direction, and its header says why: demanding `blockedBy` - # specifically "would push authors to declare false dependencies to pass a - # lint". Synthesising only `blockedBy` reintroduced exactly that pressure from - # the other side — a row that honestly records a hand-off as `relatedTo`, the - # direction that rule calls the common case, linted `unready` here while - # passing when a human ran `ready-lint` by hand. Measured 2026-08-20: CLOUD-769 - # recorded `unready` through four grooms for this and nothing else. - # AND ONLY ON A CREATE (CLOUD-781). The argument is the whole relation set - # exactly once in a row's life: at creation, when there is nothing prior to - # append to. `save_issue` relations are APPEND-ONLY, so on an update the - # argument is a patch — a groom that touches only the body passes no - # `blockedBy` at all, and `[.tool_input.blockedBy[]?]` over an absent key - # yields `[]`. Synthesising that hands `ready-lint` a payload asserting THIS - # ROW HAS NO BLOCKERS, which is a claim nothing here checked, and under - # CLOUD-679's rule an empty-but-present key is an ANSWER — correctly believed. - # The defect is the assertion, not the gate. - # - # Measured 2026-08-20, one row, three writes: a groom whose §8 cited a real - # blocker recorded `unready`, and the identical body re-saved with - # `blockedBy` restated — a no-op against the tracker — recorded `ready`. The - # relation was on the tracker throughout; only this could not see it. So - # `filed-here-check` refused the lap of a branch that filed a row and then - # groomed it to Ready, which is that gate's own third remedy: the toll - # refusing the remedy it exists to reward. - # - # OMIT THE KEY, never synthesise a thinner one. There is no richer answer - # available — the update response carries no relations and a hook holds no - # tracker credential, the same bound `claim-check` documents — so the honest - # answers are "could not look" or a wrong one. Omitting fires - # `unjudgeable-relations`, this records `-`, and `filed-here-check` passes `-` - # by design. Three-valued, composing with the sibling gate rather than - # duplicating it. - if [[ -z "$existing" ]]; then - rels=$(printf '%s' "$raw" | jq -c '{ - blockedBy: [.tool_input.blockedBy[]? | {id: .}], - blocks: [.tool_input.blocks[]? | {id: .}], - relatedTo: [.tool_input.relatedTo[]? | {id: .}] - }' 2>/dev/null) || rels='{"blockedBy":[],"blocks":[],"relatedTo":[]}' - payload=$(jq -c --argjson rels "$rels" '{ - id: .id, - description: (.description // ""), - relations: $rels - }' <<<"$row" 2>/dev/null) || payload="" - else - payload=$(jq -c '{ - id: .id, - description: (.description // "") - }' <<<"$row" 2>/dev/null) || payload="" - fi - # BY PATH, AND THE STATUS IS READ AS THREE ANSWERS RATHER THAN TWO. - # - # `mise run ready-lint` is the obvious call and is wrong here: a hook inherits - # the cwd of the tool call, which is not required to be inside this project, - # and `mise run` outside a project errors out. That failure is silent and it - # lands on the WRONG side — mapping any non-zero to `unready` would record a - # refusal for every row filed from a directory mise could not resolve, which - # is a verdict about the environment wearing the mask of a verdict about the - # row. Sibling tasks are addressed relative to this file for that reason. - # - # `ready-lint`'s contract is 0 pass / 1 the block is wrong / 2 the input could - # not be read OR could not be judged, so only 1 is a judgement. Anything else - # — 2, or a 127 from a missing interpreter — leaves the verdict `-`, which the - # gate reads as "not answered" rather than as a refusal. Since CLOUD-781 the - # update path reaches that 2 DELIBERATELY rather than only by accident: it - # omits the relations key, `unjudgeable-relations` fires, and `-` is the - # honest answer for a groom whose relations nothing here can see. - if [[ -n "$payload" ]]; then - # STDOUT IS KEPT NOW (CLOUD-806). `ready-lint` emits the structure it already - # built — `cites-body` and `cites-blockers` — before it branches on a verdict, - # so this reads the derived fact instead of rebuilding it with a second regex - # over the same body. stderr still goes nowhere: its pointers are the lint's - # own report, and this file prints nothing. - lint_out=$(printf '%s' "$payload" | "$(dirname -- "${BASH_SOURCE[0]}")/ready-lint.sh" 2>/dev/null) - case $? in - 0) verdict=ready ;; - 1) verdict=unready ;; - *) verdict=- ;; - esac - fi -fi - -# THE NAMED-PATHS COLUMN, which until CLOUD-774 held the write-time INTERSECTION -# and was renamed with the meaning. `-` is "could not look" here exactly as it is -# for the verdict: outside a checkout, or a body the tracker did not return. It is -# no longer "no `origin/main`" — `--named` needs only `git ls-files`, so a fresh -# clone records the row instead of losing it. -# -# WHY THE COLUMN CHANGED MEANING. An intersection is a fact about the diff at the -# instant the row was written, and rows are routinely written before any file is -# touched — AGENTS.md tells you to claim before writing code, so the compliant -# order produced `0` every time and `filed-over-own-diff` could never see the punt -# it was built for. What a row is ABOUT does not decay; `filed-here-check` does -# the intersection when it is asked instead. -# -# The description read is the tracker's RESPONSE, not the caller's argument, so it -# is unforgeable for the same reason the verdict is. -overlap=- -if [[ "$kind" = issue ]]; then - description=$(jq -r '.description // empty' <<<"$row" 2>/dev/null) || description="" - if [[ -n "$description" ]]; then - overlap=$(printf '%s' "$description" | "$(dirname -- "${BASH_SOURCE[0]}")/board-diff-overlap.sh" --named 2>/dev/null) || overlap=- - fi - [[ -n "$overlap" ]] || overlap=- - # ONE COLUMN, ONE WHITESPACE-FREE TOKEN (CLOUD-923). `board-diff-overlap - # --named` emits ` ...`, so this column was the only variable-width - # one — and a record whose fifth field can swallow the rest of the line cannot - # have a sixth. `filed-here-check` already comma-joined it on read (`packed`), - # so the value it computes is unchanged; what moves is where the join happens. - # Without this, the cites column below lands inside the named-path list and the - # gate refuses a lap over a path called `0`. - # - # THE ONE-WAY COST, stated rather than papered over with a back-compat claim - # this cannot honour: a record line written by the PREVIOUS shape carries the - # space-separated form, so `filed-here-check` reads its second and later paths - # into the cites column and judges the row on fewer named paths than it named. - # That direction cannot manufacture a refusal — it can only miss one — and the - # window is bounded by the store, which lives under `$GIT_DIR`, is never - # committed, and dies with the container. Writer and reader ship in one commit. - overlap=${overlap// /,} -fi - -# ─── THE SEVENTH COLUMN: THE PATHS §1 NAMES (CLOUD-854) ────────────────────── -# -# The fifth column holds every path the BODY names, and `filed-over-own-diff` -# refuses on it. Measured four times, that predicate cannot tell a row claiming -# work on a file from a row CITING it as evidence: "measured on X" and "I will fix -# X later" produce identical input to a path-name intersection. So the gate fires -# hardest on the rows that document their provenance best — the property -# CLOUD-732 exists to REQUIRE — and two gates end up pulling opposite ways. -# -# The subject is recoverable without judging prose, because a Ready block's §1 -# names the source of truth by construction. This column is the §1 clause's paths -# alone, and `filed-here-check` intersects the two: a row is refused only where a -# path is both in the diff AND in its own §1. -# -# ONE EXTRACTOR, NOT TWO. The span is narrowed here and handed to the SAME -# `board-diff-overlap --named` that computes the fifth column, so basename -# resolution, the ambiguity rule and the tracked-only bound cannot drift into a -# second authority. `-` keeps its meaning: no §1, no body, or a span this could -# not read is "could not look", and `filed-here-check` passes such a row rather -# than refusing on a question the recorder could not ask. -# -# The clause grammar is `ready-lint`'s CLAUSE_LABEL — a bolded label or a heading -# carrying the tag — because a Ready block has exactly one definition of where a -# clause begins, and a second reading of it here is the drift CLOUD-290 records. -sec1=- -if [[ "$kind" = issue ]] && [[ -n "${description:-}" ]]; then - span=$(awk ' - /^[[:space:]]*([*-][[:space:]]*)?\*\*[^*]*\((§|clause )[0-9]+\)|^#{2,6}[[:space:]]+[^#]*\((§|clause )[0-9]+\)/ { - # A clause label ends the §1 span and starts a new one, so `in_s1` - # is re-decided on every label rather than only on the first. - in_s1 = ($0 ~ /\((§|clause )1\)/) ? 1 : 0 - } - in_s1 { print } - ' <<<"$description" 2>/dev/null) || span="" - if [[ -n "$span" ]]; then - sec1=$(printf '%s' "$span" | "$(dirname -- "${BASH_SOURCE[0]}")/board-diff-overlap.sh" --named 2>/dev/null) || sec1=- - fi - [[ -n "$sec1" ]] || sec1=- - sec1=${sec1// /,} -fi - -# THE CITED-KEYS COLUMN (CLOUD-923). The tracker auto-links every `CLOUD-nnn` -# mention in a body into a symmetric `relatedTo` edge, so writing a body modifies -# every row it cites — rows the caller passed as no parameter, named as no -# relation, and is told about in no response. Measured over one grooming session: -# 43 edges added, 11 passed, 32 minted by prose, and therefore 32 rows outside the -# session's scope silently modified. Nothing saw it: `graph-check` reads -# `relatedTo` for nothing at all, and this recorder had five columns and no -# relation term. -# -# ─── WHAT THIS COLUMN IS, AND WHAT IT IS NOT ───────────────────────────────── -# -# CLOUD-923 §1 says the pre-write set is in the `issue-read-check` payload and the -# post-write set is in the `save_issue` response, so an observed DELTA needs no new -# fetch. BOTH HALVES ARE FALSE, measured 2026-08-23 rather than assumed: -# -# * a `save_issue` response carries no `relations` key at all — only -# `get_issue(includeRelations: true)` does, and a hook holds no tracker -# credential to make that call (`claim-check`'s constraint); -# * `issue-read-check`'s receipt is `key seen read_at body_hash seen_status` — -# five fields, no relation set. The payload had one; the receipt did not keep -# it. -# -# So the observed delta is not computable here without the fetch §1 forbids. What -# IS in hand is the caller's arguments and the body the tracker STORED, and their -# difference is the set of edges prose will mint: **the keys the stored body cites -# that the caller passed as no relation.** -# -# That is a PREDICTION, not an observation, and the difference is stated rather -# than absorbed: a cited row that was already related is counted here and adds no -# edge, so this OVER-counts and never under-counts. Conservative in the direction -# CLOUD-923 §2 asks for — the failure mode it names is the record being quieter -# than the truth, and an upper bound cannot be that. -# -# Unforgeable for the same reason the verdict and the named-paths column are: the -# body read is the tracker's RESPONSE, never the caller's argument. A caller who -# strips citations from what it SENDS still gets them counted from what came back. -# -# Pointer-only per non-negotiable rule 4: a count and the far-end keys, -# comma-joined so the record stays one field per column. Never a line of the body -# that minted them. -# -# THE KEYS COME FROM `ready-lint`, NOT FROM A SECOND SCAN (CLOUD-806). This file -# already spawns that gate on this very body, and that gate is the one program in -# the tree that turns a Ready block into structure — it strips Linear's -# `` mention markup, dedupes, and orders numerically. A second regex here -# would be a second authority over one question, and the two would disagree the -# first time either was touched. -# -# ABSENT IS "COULD NOT LOOK", NEVER EMPTY. The producer emits the line BEFORE it -# branches on a verdict, so a missing line means it exited before reaching the -# emission — an unreadable payload — and this column stays `-`. A line that is -# PRESENT and carries no keys is the honest zero. Collapsing those two is the -# CLOUD-251 shape the overlap column above already takes care to avoid. -# -# REPORTED, NEVER REFUSED, and that is CLOUD-923's open call decided. The tracker's -# auto-linking is not the author's choice and no body can opt out of it, so a -# refusal would be a toll with no remedy — the shape `filed-here-check`'s own -# header warns about. This file prints nothing and moves no exit code regardless; -# what changes is that a later reader can see which far-end rows a branch touched. -cites=- -# The guard is the EMISSION, not the description: `ready-lint` is the producer, so -# "did it get far enough to emit" is the only thing that decides whether this -# column can be computed at all (CLOUD-806). -emitted="" -cited="" -if [[ -n "${lint_out:-}" ]]; then - while IFS= read -r line; do - [[ "$line" == cites-body* ]] || continue - emitted=1 - cited=$(tr ' ' '\n' <<<"${line#cites-body }" | grep -vx '' || true) - break - done <<<"$lint_out" -fi -if [[ "$kind" = issue ]] && [[ -n "$emitted" ]]; then - # The caller's arguments, all three directions: a row passed as a blocker is - # not "minted by prose" however the body also mentions it. - passed=$(printf '%s' "$raw" | jq -r ' - [ .tool_input.relatedTo[]?, .tool_input.blockedBy[]?, .tool_input.blocks[]? ] - | map(select(type == "string")) | unique | .[] - ' 2>/dev/null) || passed="" - minted="" - while IFS= read -r k; do - [[ -n "$k" ]] || continue - # The row's own key is not an edge to anywhere. - [[ "$k" != "$id" ]] || continue - grep -qxF -- "$k" <<<"$passed" && continue - minted="${minted:+$minted,}$k" - done <<<"$(sort -t- -k2,2n <<<"$cited")" - # ZERO IS A COUNT; `-` IS "COULD NOT LOOK". A body the tracker did not return - # leaves the initialiser above standing, so the two are distinguishable in the - # record — CLOUD-251's split, which this column would otherwise collapse in the - # quiet direction. - if [[ -z "$minted" ]]; then - cites=0 - else - cites="$(($(tr -cd , <<<"$minted" | wc -c) + 1)):$minted" - fi -fi - -mkdir -p "$git_dir/batten-receipts" 2>/dev/null || exit 0 -# Slashes are the one character a filename cannot carry; the substitution matches -# every other branch-keyed receipt here. -record="$git_dir/batten-receipts/board-writes.${branch//\//-}" -printf '%s %s %s %s %s %s %s\n' "$kind" "$id" "$updated" "$verdict" "$overlap" "$cites" "${sec1:--}" >>"$record" 2>/dev/null || exit 0 - -exit 0 diff --git a/mise-tasks/hooks-wiring-check.sh b/mise-tasks/hooks-wiring-check.sh index e9ad6a7ce..9590076a6 100755 --- a/mise-tasks/hooks-wiring-check.sh +++ b/mise-tasks/hooks-wiring-check.sh @@ -174,8 +174,7 @@ gemini-cli .gemini/settings.json}" # The rest were six until CLOUD-777, whose widened scope could finally see them. # Their owners are not invented: CLOUD-312 is "the shell guards retire behind # it", whose scope CLOUD-777 widened from pre-tool to every point. -DECLARED="${HOOKS_WIRING_DECLARED-mise-tasks/board-write-record.sh CLOUD-312 -mise-tasks/stop-guard.sh CLOUD-312 +DECLARED="${HOOKS_WIRING_DECLARED-mise-tasks/stop-guard.sh CLOUD-312 .claude/hooks/session-start.sh CLOUD-312 mise-tasks/run-shape-guard.sh CLOUD-821 stop-hook-git-check.sh CLOUD-605 diff --git a/mise.toml b/mise.toml index 135375688..be1709e55 100644 --- a/mise.toml +++ b/mise.toml @@ -398,7 +398,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,ci-slow-needed,bot-issue,land,land-lock,ci-lease-precondition,board-diff-overlap,reclaim-census,connector-allow-resolve,serena-mcp,target-prune,claimed-keys,released,in-progress-drain,merged-pr-keys,board-payloads,attestation-check,awk-regex-check,batten-glob-check,board-sweep,board-write-record,branch-age-check,cap-drift,checks-green,ci-drift,ci-local-parity,ci-tools-check,claim-check,claim-race-check,closing-key-check,coderabbit-config-check,config-deprecations,config-lint,connector-allow-guard,container-preflight,darwin-link,deferral-check,derived-check,digest-major-agreement,doctor,done-check,done-pr-check,duplicate-close-check,evaluator-closure-check,evaluator-io-check,filed-here-check,finding-sink-check,gh-guard,graph-check,hook-matcher-check,hook-pin-check,hook-profile-check,hooks-wiring-check,install-check,land-divergence-assert,land-lock-check,landed-check,license-table-check,linear-check,lock-complete,macos-link-check,mcp-allow-check,mcp-attach-check,mcp-timeout-budget,memories-check,mise-action-floor,mise-pin-agreement,module-map-check,msrv-pin-agreement,mutant,mutant-census,no-doctests,nonverdict-assert,ntia-check,perf-assert,perf-compare,perf-gate,pipefail-grep-check,privileged-lane,pr-unsubscribed,publish-credential-check,ready-cites-check,ready-guard,ready-lint,reference-check,release-assets-check,release-due,release-tracking-check,renovate-config-validator,report-only-check,review-answered,rules-drift,run-shape,run-shape-guard,rust-paths-check,sbom,sbom-check,schema-check,semver,signing-posture,skill-check,sonar-gate,spec-ref-check,stop-guard,stop-posture-check,suite-bench-check,timeout-check,token-bench-check,transcript-corpus-check,tree-clean,unlanded-check,verified" +MUTANT_GATES = "alive,ci-slow-needed,bot-issue,land,land-lock,ci-lease-precondition,board-diff-overlap,reclaim-census,connector-allow-resolve,serena-mcp,target-prune,claimed-keys,released,in-progress-drain,merged-pr-keys,board-payloads,attestation-check,awk-regex-check,batten-glob-check,board-sweep,branch-age-check,cap-drift,checks-green,ci-drift,ci-local-parity,ci-tools-check,claim-check,claim-race-check,closing-key-check,coderabbit-config-check,config-deprecations,config-lint,connector-allow-guard,container-preflight,darwin-link,deferral-check,derived-check,digest-major-agreement,doctor,done-check,done-pr-check,duplicate-close-check,evaluator-closure-check,evaluator-io-check,filed-here-check,finding-sink-check,gh-guard,graph-check,hook-matcher-check,hook-pin-check,hook-profile-check,hooks-wiring-check,install-check,land-divergence-assert,land-lock-check,landed-check,license-table-check,linear-check,lock-complete,macos-link-check,mcp-allow-check,mcp-attach-check,mcp-timeout-budget,memories-check,mise-action-floor,mise-pin-agreement,module-map-check,msrv-pin-agreement,mutant,mutant-census,no-doctests,nonverdict-assert,ntia-check,perf-assert,perf-compare,perf-gate,pipefail-grep-check,privileged-lane,pr-unsubscribed,publish-credential-check,ready-cites-check,ready-guard,ready-lint,reference-check,release-assets-check,release-due,release-tracking-check,renovate-config-validator,report-only-check,review-answered,rules-drift,run-shape,run-shape-guard,rust-paths-check,sbom,sbom-check,schema-check,semver,signing-posture,skill-check,sonar-gate,spec-ref-check,stop-guard,stop-posture-check,suite-bench-check,timeout-check,token-bench-check,transcript-corpus-check,tree-clean,unlanded-check,verified" # --- GitHub reachability behind an egress proxy (Claude Code web sandbox etc.) --- # mise resolves every tool's release through GitHub's *API* host, api.github.com. diff --git a/schema/batten.schema.json b/schema/batten.schema.json index a2fa2bad6..d8d83b04a 100644 --- a/schema/batten.schema.json +++ b/schema/batten.schema.json @@ -553,7 +553,7 @@ "description": "One column of a record.", "type": "object", "properties": { - "counted_with": { + "counted-with": { "description": "Render as `` rather than as the tokens alone.\n\n**A count and a pointer, never the payload** (non-negotiable rule 4). The\nseparator is the consumer's because the reader of the record is the\nconsumer's too.", "type": [ "string", @@ -590,7 +590,7 @@ } ] }, - "zero_is_a_count": { + "zero-is-a-count": { "description": "Render `0` rather than [`ABSENT`] when the expression resolved and yielded\nno tokens.\n\n**Zero is a count; `-` is could-not-look.** Collapsing the two is the one\nmistake this record shape cannot afford, because a gate downstream passes\non `-` by design and would then pass on a real measurement of nothing.", "type": "boolean", "default": false @@ -764,10 +764,14 @@ "$ref": "#/$defs/RecordKey" }, "name": { - "description": "The record namespace — the file this writes, and the file the rule that\nreads it names.", + "description": "What this row is called. Unique across the table, and never the file — a\nrecord is written by SEVERAL rows (one per tool, and one per create/groom\npath), so keying uniqueness on the file would make the natural\ndeclaration unwritable.", "type": "string" }, - "refused_when_input": { + "record": { + "description": "The record this row appends to — the file, and the file the rule that\nreads it names.\n\nDeliberately many-to-one. The censused record carries a line per board\nwrite whatever tool made it, so the rows that write it differ in their\nselector and their columns while naming one destination.", + "type": "string" + }, + "refused-when-input": { "description": "Paths into the INPUT that must NOT resolve for anything to be written.\n\nThe censused use is telling a create from an update: an update carries an\nid the create cannot, so a recorder that only wants creates declares the\nid here rather than needing a negation operator in the expression\nlanguage.", "type": "array", "items": { @@ -781,6 +785,17 @@ "type": "string" } }, + "requires-recorded": { + "description": "Write only when the record ALREADY carries a line whose column\n`column` equals this expression's value.\n\n**The narrow exception that keeps a remedy reachable.** A gate that\nrefuses a subject recorded in a bad state has to tell the author how to\nclear it, and \"fix the subject and re-run\" only works if the fix is\nrecorded too. But recording every later write would let a row this branch\nnever filed be re-judged on its own say-so — so the exception is bounded\nto subjects this record already names, which is exactly the set this\nbranch is answerable for.", + "anyOf": [ + { + "$ref": "#/$defs/Recorded" + }, + { + "type": "null" + } + ] + }, "tool": { "description": "Which tool's result records, matched by [`crate::rules::selects_tool_name`].\n\nA tool name, never a field shape, for [`crate::mint::Declared::tool`]'s\nmeasured reason: a connector is exposed under more than one name over its\nlifetime (CLOUD-178), and a write response and a read payload are\nshape-identical across the fields either carries.", "type": "string" @@ -789,6 +804,7 @@ "additionalProperties": false, "required": [ "name", + "record", "tool", "key", "columns" @@ -1713,6 +1729,26 @@ } ] }, + "Recorded": { + "description": "A precondition on what the record already holds.", + "type": "object", + "properties": { + "matches": { + "description": "Every column that must match, keyed by position from zero.\n\n**A map rather than one column, and the reason is measured.** The\ncensused record carries a line per board write whatever KIND it was, so a\nprecondition on the subject alone matches a comment line about that\nsubject — and a comment is not this branch having filed the row. The\nretired shell anchored on the kind and the whole id field together; this\nis that anchor, generalised.", + "type": "object", + "additionalProperties": false, + "patternProperties": { + "^\\d+$": { + "$ref": "#/$defs/Value" + } + } + } + }, + "additionalProperties": false, + "required": [ + "matches" + ] + }, "Redirect": { "description": "One declared path class and the mutation sanctioned for it.", "type": "object", @@ -2674,6 +2710,22 @@ "object" ] }, + { + "description": "Every string the named iterating INPUT paths yield, space-joined.\n\n[`Value::Wrap`]'s plain sibling, and both are needed rather than one\nbeing a special case of the other. `Wrap` builds the OBJECT shape a\nprogram reads on stdin; this builds the TOKEN list a column operation\ncompares against. Using `Wrap` for a `minus` compares against\n`{\"id\":\"X\"}` and removes nothing — measured, and silently, because a set\ndifference that subtracts nothing looks exactly like one with nothing to\nsubtract.\n\nA list rather than one path because the censused use unions three\nrelation directions, and a union spelled as three columns would be three\ncolumns nobody wanted.", + "type": "object", + "properties": { + "inputs": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "additionalProperties": false, + "required": [ + "inputs" + ] + }, { "description": "Each string an iterating INPUT path yields, wrapped as `{: value}`.\n\nThe shape a relation list takes in the payload the censused verdict\nprogram reads. Generic because the key is the consumer's.", "type": "object", @@ -2736,20 +2788,20 @@ ] }, { - "description": "What a declared program said about another expression's value.", + "description": "What a declared program said about another expression's value.\n\nThe id field is `run` rather than `program`, which is not cosmetic: this\nenum is externally tagged, so the variant's own key is already `program`\nand a field of the same name nests one inside the other for a reader.", "type": "object", "properties": { "program": { "type": "object", "properties": { - "program": { - "description": "The program id, resolved through the consumer's `[program]` table.", - "type": "string" - }, "read": { "description": "What to read back.", "$ref": "#/$defs/Read" }, + "run": { + "description": "The program id, resolved through the consumer's `[program]` table.", + "type": "string" + }, "stdin": { "description": "The expression whose rendered value is handed to the program on stdin.", "$ref": "#/$defs/Value" @@ -2757,7 +2809,7 @@ }, "additionalProperties": false, "required": [ - "program", + "run", "stdin", "read" ] diff --git a/tests/board-write-record.bats b/tests/board-write-record.bats deleted file mode 100644 index 1e47cb71d..000000000 --- a/tests/board-write-record.bats +++ /dev/null @@ -1,595 +0,0 @@ -#!/usr/bin/env bats -# subject: mise-tasks/board-write-record.sh -# CLOUD-514 phase 1. The recorder that answers "what did this branch put on the -# board, and was a new row refined when it was filed". -# -# Every test runs inside a throwaway `git init`, because the subject IS the git -# dir: the record is keyed to the branch and stored under `$GIT_DIR`, so a suite -# running in this repo's checkout would write records for a real session. - -setup() { - REC="$BATS_TEST_DIRNAME/../mise-tasks/board-write-record.sh" - REPO="$BATS_TEST_TMPDIR/repo" - mkdir -p "$REPO" - git -C "$REPO" init --quiet - # Per fixture, never inherited — a CI runner carries no global identity, so a - # bare `git commit` here is `fatal: empty ident name` and fails only there. - # Measured on CLOUD-513, which cost a full CI run; that gate is filed and - # unfixed, so this stays hand-written in every fixture suite. - git -C "$REPO" config user.email t@example.com - git -C "$REPO" config user.name t - git -C "$REPO" commit -q --allow-empty -m init - cd "$REPO" || return 1 -} - -record() { - local branch - branch=$(git symbolic-ref --quiet --short HEAD) - printf '%s\n' "$(git rev-parse --git-dir)/batten-receipts/board-writes.${branch//\//-}" -} - -# A Ready block that satisfies `ready-lint`'s checkable clauses. Kept minimal on -# purpose: this suite is about the recorder, and the lint has its own. -ready_body() { - cat <<'BODY' -**Why** - -A thing is broken. - -**Refinement — Ready** - -* **Source of truth (§1).** A file. -* **Mechanism as a computable predicate (§2).** A grep with an exit code. -* **Effect (§3).** `read`. -* **Output & exit contract (§5).** Pointer-only. -* **Commit / bump (§6).** `fix(thing)` — patch until `0.1.0`. -* **Test obligation (§7).** A bats row, shown able to fail. -* **Blockers (§8).** None. -BODY -} - -# The payload goes through a FILE, and the helper prints its path. Embedding JSON -# into a `bash -c` string lets the shell reinterpret its braces and quotes before -# the body ever sees it. -# -# `tool_response` is the CONTENT-BLOCK envelope an MCP tool actually returns, not -# the flat object the docs illustrate with a Write. That distinction is the whole -# reason this suite exists. -# -# `blockers` is a SPACE-SEPARATED list, not JSON: the helper is called inside a -# `bash -c` string, so a bracketed literal arrives with its quoting mangled and -# `--argjson` rejects it. jq builds the array instead. -event() { - local tool="${1:-mcp__Linear__save_issue}" body="${2:-}" blockers="${3:-}" input_id="${4:-}" - [ -n "$body" ] || body=$(ready_body) - jq -nc \ - --arg t "$tool" --arg b "$body" --arg iid "$input_id" --arg blockers "$blockers" ' - { - tool_name: $t, - tool_input: ({title: "a finding", - blockedBy: ($blockers | split(" ") | map(select(length > 0)))} - + (if $iid == "" then {} else {id: $iid} end)), - tool_response: [{type: "text", text: ({ - id: "CLOUD-999", title: "a finding", - description: $b, updatedAt: "2026-08-13T00:00:00.000Z" - } | tojson)}] - }' >"$BATS_TEST_TMPDIR/event.json" - printf '%s\n' "$BATS_TEST_TMPDIR/event.json" -} - -# A `save_comment` event. The response is the COMMENT object — a uuid `id`, and no -# reference to the row it landed on — which is exactly why the id must come from -# the input's parent reference instead. -# -# `target` empty with `other_parent` set models a reply (`parentId`) or a comment -# on a project/document; both carry no `issueId`. -comment_event() { - # `${2-…}` and not `${2:-…}`: an explicitly EMPTY target is the reply case and - # must stay empty, where `:-` would substitute the default over it. - local tool="${1:-mcp__Linear__save_comment}" target="${2-CLOUD-42}" other_parent="${3:-}" - jq -nc --arg t "$tool" --arg target "$target" --arg other "$other_parent" ' - { - tool_name: $t, - tool_input: ((if $target == "" then {} else {issueId: $target} end) - + (if $other == "" then {} else {($other): "abc-123"} end) - + {body: "a finding"}), - tool_response: [{type: "text", text: ({ - id: "4d16245a-43ea-49ae-b67d-c2ee0b64b96e", - body: "a finding", updatedAt: "2026-08-13T00:00:00.000Z" - } | tojson)}] - }' >"$BATS_TEST_TMPDIR/event.json" - printf '%s\n' "$BATS_TEST_TMPDIR/event.json" -} - -# --- what gets recorded -------------------------------------------------------- - -@test "a created row is recorded with its id, updatedAt and a green verdict" { - run bash -c "'$REC' < $(event)" - [ "$status" -eq 0 ] - [ -f "$(record)" ] - run cat "$(record)" - [[ "$output" == "issue CLOUD-999 2026-08-13T00:00:00.000Z ready - 0 -" ]] -} - -# THE ROW THIS DESIGN TURNS ON. `ready-lint`'s §8 rule cross-checks prose claiming -# a blocker against the payload's relations, and the create RESPONSE carries no -# relations at all. Linting the response alone therefore reports -# blocker-cited-without-relation on exactly the rows refined most carefully. The -# create call's own `blockedBy` argument is the whole relation set on a create, so -# it is what the synthesis uses. -@test "a row whose §8 claims a blocker still records a green verdict" { - local body - body=$(ready_body | sed 's/\*\*Blockers (§8).\*\* None./**Blockers (§8).** `blockedBy` CLOUD-1./') - run bash -c "'$REC' < $(event mcp__Linear__save_issue "$body" CLOUD-1)" - [ "$status" -eq 0 ] - run cat "$(record)" - [[ "$output" == *" ready - 0 "* ]] -} - -@test "an unrefined row records a verdict of unready rather than being refused" { - run bash -c "'$REC' < $(event mcp__Linear__save_issue 'Just a sentence, no Ready block.')" - [ "$status" -eq 0 ] - [ -z "$output" ] - run cat "$(record)" - [[ "$output" == *" unready - 0 "* ]] -} - -# --- the diff column (CLOUD-514, phase 3) ------------------------------------- -# -# The overlap is `-` everywhere above because a throwaway `git init` has no -# `origin/main` to diff against — which is the fail-open reading, and the reason -# every case that predates this column still holds. These two build the ref so -# the column can carry a real answer, in both directions. -with_diff() { # a branch whose diff against origin/main touches ONE tracked file - printf 'x\n' >"$REPO/keeper.rs" - printf 'y\n' >"$REPO/untouched.rs" - git -C "$REPO" add keeper.rs untouched.rs - git -C "$REPO" commit -q -m base - git -C "$REPO" update-ref refs/remotes/origin/main HEAD - git -C "$REPO" checkout -q -b work - printf 'x2\n' >"$REPO/keeper.rs" - git -C "$REPO" commit -q -am change -} - -@test "a row whose body names a changed file records a non-zero overlap" { - with_diff - run bash -c "'$REC' < $(event mcp__Linear__save_issue 'The bug is in keeper.rs:12.')" - [ "$status" -eq 0 ] - run cat "$(record)" - [[ "$output" == *" 1,keeper.rs 0 "* ]] -} - -# --- the §1 column (CLOUD-854) ------------------------------------------------- -# -# The fifth column holds every path a body names, and that set cannot tell a row -# CLAIMING a file from one CITING it as evidence — measured five times, each -# costing an override. §1 of a Ready block names the source of truth by -# construction, so the seventh column is that clause's paths alone and -# `filed-here-check` intersects the two. -# -# These two cases are the discriminating pair: identical bodies but for WHICH -# clause names the file, and the columns must disagree. - -@test "A PATH NAMED ONLY OUTSIDE §1 IS IN THE NAMED COLUMN AND NOT THE §1 ONE" { - with_diff - local body - # §1 names nothing tracked; the prose cites `keeper.rs` as where the bug was - # measured — the shape CLOUD-732 requires and this column must not punish. - body=$(ready_body | sed 's/^\*\*Why\*\*$/**Why**\n\nMeasured in keeper.rs:12./') - run bash -c "'$REC' < $(event mcp__Linear__save_issue "$body")" - [ "$status" -eq 0 ] - run cat "$(record)" - # The whole point of the pair: the named column HAS the cited path and the §1 - # column does NOT, so the two disagree and `filed-here-check` can tell them - # apart. Anchored on the line end, because a suffix that stopped at the cites - # column would pass whatever the §1 column said. - [[ "$output" == *" 1,keeper.rs 0 0" ]] -} - -@test "a path named IN §1 reaches the §1 column, so a real claim is still visible" { - with_diff - local body - body=$(ready_body | sed 's/\*\*Source of truth (§1).\*\* A file./**Source of truth (§1).** `keeper.rs`./') - run bash -c "'$REC' < $(event mcp__Linear__save_issue "$body")" - [ "$status" -eq 0 ] - run cat "$(record)" - [[ "$output" == *" 1,keeper.rs 0 1,keeper.rs" ]] -} - -# THE OTHER DIRECTION (CLOUD-418): a suite that only ever asserts the firing -# cannot tell a working sensor from one that names every file in the repository. -# Here that means a body naming NOTHING TRACKED records `0` — the column is the -# paths the row names, so an untracked mention is the only thing that can be empty. -@test "a row naming nothing tracked records a zero" { - with_diff - run bash -c "'$REC' < $(event mcp__Linear__save_issue 'The bug is in nosuchfile.rs:12.')" - [ "$status" -eq 0 ] - run cat "$(record)" - [[ "$output" == *" 0 0 "* ]] - [[ "$output" != *nosuchfile.rs* ]] -} - -# CLOUD-774. THE COLUMN IS WHAT THE ROW NAMES, NOT AN INTERSECTION, and this is the -# case that pins the change. A file the branch has not touched is still recorded, -# because the diff it will be judged against does not exist yet: rows are filed -# before any edit — AGENTS.md says claim before writing code — and freezing the -# intersection there recorded `0` for every one of them. `filed-here-check` -# intersects when it is asked. -@test "A FILE THIS BRANCH HAS NOT TOUCHED IS STILL RECORDED" { - with_diff - run bash -c "'$REC' < $(event mcp__Linear__save_issue 'The bug is in untouched.rs:12.')" - [ "$status" -eq 0 ] - run cat "$(record)" - [[ "$output" == *" 1,untouched.rs 0 "* ]] -} - -# POINTER, NEVER PAYLOAD (non-negotiable 4). The recorder reads an entire issue -# body; the only thing that may reach the file from it is a tracked path. -@test "nothing from the body but a tracked path reaches the record" { - with_diff - run bash -c "'$REC' < $(event mcp__Linear__save_issue 'keeper.rs leaks hunter2 for acct 00219.')" - [ "$status" -eq 0 ] - run cat "$(record)" - [[ "$output" == *keeper.rs* ]] - [[ "$output" != *hunter2* ]] - [[ "$output" != *00219* ]] -} - -# An update is not a board write this branch is answerable for. Recording it -# would inflate the very count the gate gets specified against. -@test "updating an existing row is never recorded" { - run bash -c "'$REC' < $(event mcp__Linear__save_issue '' '' CLOUD-1)" - [ "$status" -eq 0 ] - [ ! -f "$(record)" ] -} - -# THE ONE EXCEPTION, and `filed-here-check`'s third remedy is unreachable without -# it. That gate tells a branch which filed an unrefined row to groom it to Ready -# and re-run `land` — but a groom is a `save_issue` WITH an id, so the rule above -# skipped it and the creation-time `unready` stood forever. Measured on PR #525: -# the row was groomed until `ready-lint` exited 0 over the tracker's own response, -# and the refusal did not move. Remedies 1 and 2 cannot reach it either once a -# line exists, so the only escape left was the bypass — which that gate's own -# suite does not scrub, so exporting it turned six refusal cases green. -@test "a groom of a row THIS branch filed is recorded" { - run bash -c "'$REC' < $(event mcp__Linear__save_issue 'Just a sentence, no Ready block.')" - [ "$status" -eq 0 ] - run cat "$(record)" - [[ "$output" == *" unready - 0 "* ]] - - run bash -c "'$REC' < $(event mcp__Linear__save_issue '' '' CLOUD-999)" - [ "$status" -eq 0 ] - [ "$(wc -l <"$(record)")" -eq 2 ] - run tail -1 "$(record)" - [[ "$output" == "issue CLOUD-999 2026-08-13T00:00:00.000Z ready - 0 -" ]] -} - -# --- the update path asserts nothing about relations (CLOUD-781) -------------- -# -# ONE BODY, TWO PATHS, so the pair differs only in whether an `id` is present in -# the call. `save_issue` relations are APPEND-ONLY: on a create the `blockedBy` -# argument is the whole relation set, and on a groom it is a patch, so an absent -# argument means "unchanged", never "none". Synthesising `[]` from it asserted -# the row had no blockers — a claim the recorder never checked — and the §8 -# clause citing a real one recorded `unready`. -# -# Measured 2026-08-20: a groom whose §8 cited a blocker recorded `unready`, and -# the identical body re-saved with `blockedBy` restated (a no-op against the -# tracker) recorded `ready`. `filed-here-check` then refused the lap of a branch -# that had done exactly what that gate's third remedy tells it to do. - -# A §8 clause citing a blocker, over the suite's own Ready body. -cites_a_blocker() { - ready_body | sed 's/\*\*Blockers (§8).\*\* None./**Blockers (§8).** `blockedBy` CLOUD-1./' -} - -@test "a groom whose §8 cites a blocker is unjudgeable, not unready" { - # THE ROW CLOUD-781 EXISTS FOR, and it is red against the recorder as it - # shipped — the groom passes no `blockedBy`, exactly as a body-only edit does. - run bash -c "'$REC' < $(event mcp__Linear__save_issue 'Just a sentence, no Ready block.')" - [ "$status" -eq 0 ] - - run bash -c "'$REC' < $(event mcp__Linear__save_issue "$(cites_a_blocker)" '' CLOUD-999)" - [ "$status" -eq 0 ] - run tail -1 "$(record)" - # `-` is "could not lint", which `filed-here-check` passes by design. Never - # `unready`, which is a verdict about a Ready block nothing could judge. - # - # The sixth column is `1:CLOUD-1` rather than `0` (CLOUD-923): the §8 clause - # cites that row and the groom passed it as no relation, which is the whole of - # what that column counts. The verdict columns are unaffected — the two read - # different things about the same write. - [[ "$output" == "issue CLOUD-999 2026-08-13T00:00:00.000Z - - 1:CLOUD-1 -" ]] -} - -@test "A CREATE CITING A BLOCKER IT DID NOT PASS IS STILL UNREADY" { - # THE ANTI-WEAKENING PIN. The toll that prices refinement lives on the create - # path, where the argument really is the whole relation set — so "no edges" is - # an answer there and the citation is a real violation. This case turns red if - # the omission above is applied to the create path, which is the one way this - # change could weaken the toll. - run bash -c "'$REC' < $(event mcp__Linear__save_issue "$(cites_a_blocker)")" - [ "$status" -eq 0 ] - run cat "$(record)" - # `1:CLOUD-1` in the sixth column, for CLOUD-923's reason: the §8 clause cites - # that row and this create passed it as no relation. Independent of the verdict — - # the same citation makes the toll fire AND the edge get counted, from the two - # different questions the two columns ask. - [[ "$output" == *" unready - 1:CLOUD-1 "* ]] -} - -@test "a groom of a genuinely unready body still records unready" { - # The omission gives up ONE clause, not the verdict: §1 through §7 are judged - # from the body alone and are unaffected by what relations could not be seen. - run bash -c "'$REC' < $(event mcp__Linear__save_issue 'Just a sentence, no Ready block.')" - [ "$status" -eq 0 ] - - run bash -c "'$REC' < $(event mcp__Linear__save_issue 'Still just a sentence, no Ready block.' '' CLOUD-999)" - [ "$status" -eq 0 ] - run tail -1 "$(record)" - [[ "$output" == *" unready - 0 "* ]] -} - -# The exception is narrow on purpose: it grants nothing a fresh create would not -# have granted, because the row is one this branch is already answerable for. -# Anyone else's row is skipped exactly as it was. -@test "a groom of a row this branch did NOT file is still skipped" { - run bash -c "'$REC' < $(event)" - [ "$status" -eq 0 ] - [ "$(wc -l <"$(record)")" -eq 1 ] - - run bash -c "'$REC' < $(event mcp__Linear__save_issue '' '' CLOUD-1)" - [ "$status" -eq 0 ] - [ "$(wc -l <"$(record)")" -eq 1 ] -} - -# The record is matched on the whole id field, anchored both ends. An unanchored -# read would let a create of CLOUD-999 make a groom of CLOUD-9 — a different row -# entirely — look like this branch's own. -@test "an id that merely PREFIXES a filed one does not count as filed here" { - run bash -c "'$REC' < $(event)" - [ "$(wc -l <"$(record)")" -eq 1 ] - - run bash -c "'$REC' < $(event mcp__Linear__save_issue '' '' CLOUD-9)" - [ "$status" -eq 0 ] - [ "$(wc -l <"$(record)")" -eq 1 ] -} - -# COMMENTING ON A ROW IS NOT FILING IT. The exception is scoped to rows this -# branch CREATED, which is the set `filed-here-check` gates; letting a comment -# line qualify would open the update path on every row anyone commented on. -# --- CLOUD-923: the rows a body cites that the caller never passed ------------- -# -# The tracker auto-links every `CLOUD-nnn` mention into a symmetric `relatedTo` -# edge, so writing a body modifies every row it cites — passed as no parameter, -# named as no relation, reported in no response. Measured over one session: 43 -# edges added, 11 passed, 32 minted by prose, so 32 rows outside its scope silently -# modified. This column is the upper bound on that set, and it reads the body the -# TRACKER STORED so a caller cannot strip the citations out of what it sends. - -# A body citing $* — the rows appear only as prose, never as an argument. -cites_rows() { - ready_body - printf 'Evidence: %s carry the measurement.\n' "$*" -} - -@test "a write records the rows its stored body cites" { - # §7 (a). Two of the three cited rows are passed as `blockedBy`, so only the - # third is minted by prose — the argument set is subtracted whichever direction - # it was passed in. - run bash -c "'$REC' < $(event mcp__Linear__save_issue "$(cites_rows CLOUD-1 CLOUD-2 CLOUD-3)" 'CLOUD-1 CLOUD-2')" - [ "$status" -eq 0 ] - run cat "$(record)" - [[ "$output" == *" 1:CLOUD-3 "* ]] - [[ "$output" != *CLOUD-1* ]] -} - -@test "a write passing exactly the rows it cites records zero" { - # §7 (b). The honest zero, and the reason the column is not simply a citation - # count: an author who declares the edges owes nothing here. - run bash -c "'$REC' < $(event mcp__Linear__save_issue "$(cites_rows CLOUD-1 CLOUD-2)" 'CLOUD-1 CLOUD-2')" - [ "$status" -eq 0 ] - run cat "$(record)" - [[ "$output" == *" 0 "* ]] -} - -@test "zero and could-not-look are distinguishable in the record" { - # §7 (c). A comment carries no description this can read, so its column is `-`. - # Reading that as "no edges added" is the collapse CLOUD-251 named, and it is the - # direction that would make this record quieter than the truth. - run bash -c "'$REC' < $(comment_event)" - [ "$status" -eq 0 ] - run cat "$(record)" - [[ "$output" == *" - "* ]] - [[ "$output" != *" 0" ]] -} - -@test "the row's own key is not counted as an edge to anywhere" { - # A body naming itself is the ordinary shape — every correction section in this - # repository's rows does it — and it is not a relation. - run bash -c "'$REC' < $(event mcp__Linear__save_issue "$(cites_rows CLOUD-999)")" - [ "$status" -eq 0 ] - run cat "$(record)" - [[ "$output" == *" 0 "* ]] - [[ "$output" != *CLOUD-999\ * ]] || true -} - -@test "the keys come from ready-lint's emission, not a second scan here" { - # CLOUD-806. This file already spawns that gate on this very body, and that gate - # is the one program in the tree that turns a Ready block into structure. The - # assertion is a PROPERTY ONLY THE PRODUCER HAS: it orders numerically, so - # `CLOUD-9` precedes `CLOUD-10`. A second scan here would have emitted them in - # whatever order its own sort produced, and a lexical sort puts `CLOUD-10` first. - run bash -c "'$REC' < $(event mcp__Linear__save_issue "$(cites_rows CLOUD-10 CLOUD-9)")" - [ "$status" -eq 0 ] - run cat "$(record)" - [[ "$output" == *" 2:CLOUD-9,CLOUD-10 "* ]] -} - -@test "a write the producer never ran for records a dash, never a zero" { - # The emission is the guard, not the description. A row whose response carries no - # description still REACHES the producer — the recorder substitutes an empty - # string — so it emits an empty set and this column is the honest `0`. That is - # worth pinning, because it is the case a reader would expect to be `-`. - jq -nc '{ - tool_name: "mcp__Linear__save_issue", - tool_input: {title: "x", blockedBy: []}, - tool_response: [{type: "text", text: ({id: "CLOUD-999", updatedAt: "2026-08-13T00:00:00.000Z"} | tojson)}] - }' >"$BATS_TEST_TMPDIR/nodesc.json" - run bash -c "'$REC' < $BATS_TEST_TMPDIR/nodesc.json" - [ "$status" -eq 0 ] - run cat "$(record)" - [[ "$output" == *" 0 "* ]] - - # `-` is for a write the producer was never run for at all, which is every - # comment: the case above in "zero and could-not-look are distinguishable" - # asserts that arm, and this one pins that the two really are different writes - # rather than one shape read two ways. - run bash -c "'$REC' < $(comment_event)" - run tail -1 "$(record)" - [[ "$output" == *" - "* ]] -} - -@test "POINTER, NEVER PAYLOAD: the citing sentence does not reach the record" { - # The column reads the body, which is the largest thing this hook touches. Only - # the keys and a count may leave it (non-negotiable rule 4). - run bash -c "'$REC' < $(event mcp__Linear__save_issue "$(cites_rows CLOUD-7)")" - run cat "$(record)" - [[ "$output" == *"1:CLOUD-7 "* ]] - [[ "$output" != *"carry the measurement"* ]] - [[ "$output" != *Evidence* ]] -} - -@test "a comment on a row does not make a later update to it recordable" { - run bash -c "'$REC' < $(comment_event mcp__Linear__save_comment CLOUD-999)" - [ "$(wc -l <"$(record)")" -eq 1 ] - - run bash -c "'$REC' < $(event mcp__Linear__save_issue '' '' CLOUD-999)" - [ "$status" -eq 0 ] - [ "$(wc -l <"$(record)")" -eq 1 ] -} - -# Sink 2: recorded so the create-versus-comment ratio is observable, never judged. -# -# THE ID IS THE ISSUE KEY, FROM THE INPUT. A `save_comment` response is the -# comment object — its `.id` is the comment's own uuid and it names no row at all -# — so reading the response here fills an issue-key column with uuids. Measured -# on this recorder's first five live rows, which did exactly that. -@test "a comment records the issue key its input names, not the comment uuid" { - run bash -c "'$REC' < $(comment_event mcp__Linear__save_comment CLOUD-42)" - [ "$status" -eq 0 ] - run cat "$(record)" - [[ "$output" == "comment CLOUD-42 2026-08-13T00:00:00.000Z - - - -" ]] -} - -# The regression case, stated as a shape rather than a value: whatever a comment -# row carries, it is never a uuid. A uuid in an issue-key column reads as data -# rather than as a gap, which is worse than the gap — nothing downstream can tell -# them apart. -@test "REGRESSION: a comment row never records a uuid" { - local target field - for target in CLOUD-42 ""; do - rm -f "$(record)" - run bash -c "'$REC' < $(comment_event mcp__Linear__save_comment "$target")" - [ "$status" -eq 0 ] - # The id COLUMN, not the whole line: a key like CLOUD-42 contains `-4`, so - # a uuid-shaped match over the line flags a correct row. - field=$(awk '{print $2}' "$(record)") - [[ "$field" == "-" || "$field" =~ ^CLOUD-[0-9]+$ ]] - done -} - -# A reply names only its parent thread, and a comment on a project or document is -# not a board row at all. Both are "could not look", which is the same distinction -# this recorder already draws for a verdict. -@test "a reply, or a comment on a non-issue parent, records a dash rather than a guess" { - run bash -c "'$REC' < $(comment_event mcp__Linear__save_comment '' parentId)" - [ "$status" -eq 0 ] - run cat "$(record)" - [[ "$output" == "comment - 2026-08-13T00:00:00.000Z - - - -" ]] -} - -# CLOUD-178 measured the same connector under three names depending on the -# registration episode; a rule naming one matches none of the others, silently. -@test "all three live connector spellings are recorded identically" { - local tool - for tool in mcp__Linear__save_issue mcp__claude_ai_Linear__save_issue mcp__4db58e41-0000-0000-0000-000000000000__save_issue; do - rm -f "$(record)" - run bash -c "'$REC' < $(event "$tool")" - [ "$status" -eq 0 ] - run cat "$(record)" - [[ "$output" == "issue CLOUD-999"* ]] - done -} - -@test "a tool that does not write to the board is never recorded" { - local tool - for tool in mcp__Linear__list_issues Bash Write mcp__serena__write_memory; do - run bash -c "'$REC' < $(event "$tool")" - [ "$status" -eq 0 ] - [ ! -f "$(record)" ] - done -} - -# --- pointer-only, and failing open ------------------------------------------- - -# Not decorative: the text this reads is the entire issue body. Four fields reach -# the file and nothing is ever printed (non-negotiable 4). -@test "POINTER, NEVER PAYLOAD: no byte of the description reaches the record" { - run bash -c "'$REC' < $(event mcp__Linear__save_issue 'SECRETMARKER in the body')" - [ -z "$output" ] - run cat "$(record)" - [[ "$output" != *"SECRETMARKER"* ]] - [ "$(wc -l <"$(record)")" -eq 1 ] -} - -@test "FAIL OPEN: an unreadable, nameless or resultless payload records nothing and says nothing" { - local payload - for payload in 'not json' '{}' '{"tool_name":""}' '' '{"tool_name":"mcp__Linear__save_issue"}'; do - run bash -c "printf '%s' $(printf '%q' "$payload") | '$REC'" - [ "$status" -eq 0 ] - [ -z "$output" ] - [ ! -f "$(record)" ] - done -} - -# The flat shape the docs illustrate with a Write. A body written against it would -# silently record nothing, which is why the envelope was measured. -@test "FAIL OPEN: a flat tool_response is not the MCP envelope and records nothing" { - run bash -c "jq -nc '{tool_name:\"mcp__Linear__save_issue\",tool_input:{title:\"x\"},tool_response:{id:\"CLOUD-9\"}}' | '$REC'" - [ "$status" -eq 0 ] - [ ! -f "$(record)" ] -} - -@test "FAIL OPEN: a detached HEAD has no branch to key a record to" { - git checkout -q --detach - run bash -c "'$REC' < $(event)" - [ "$status" -eq 0 ] - [ -z "$output" ] -} - -@test "FAIL OPEN: outside a git repository nothing is recorded and nothing is blocked" { - cd "$BATS_TEST_TMPDIR" || return 1 - run bash -c "env GIT_CEILING_DIRECTORIES='$BATS_TEST_TMPDIR' '$REC' < $(event)" - [ "$status" -eq 0 ] - [ -z "$output" ] -} - -@test "the bypass is honoured" { - run bash -c "BATTEN_BOARD_WRITE_BYPASS=1 '$REC' < $(event)" - [ "$status" -eq 0 ] - [ ! -f "$(record)" ] -} - -# --- the wiring ---------------------------------------------------------------- - -# A body no matcher reaches is inert, and the miss is silent. Suffix-anchored for -# CLOUD-178's reason, asserted here because this file cannot enforce it. -@test "the settings entry is wired, on a suffix-anchored PostToolUse matcher" { - local settings="$BATS_TEST_DIRNAME/../.claude/settings.json" - run jq -r '[.hooks.PostToolUse[] | select(.hooks[].command | test("board-write-record")) | .matcher] | first' "$settings" - [ "$status" -eq 0 ] - [[ "$output" == *"save_issue"* ]] - [[ "$output" == *"save_comment"* ]] - [[ "$output" != "mcp__Linear__"* ]] -} From af4fa64493d103c3b6b44a1f5c4bb695bebc5b78 Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Wed, 26 Aug 2026 04:02:10 +0000 Subject: [PATCH 10/38] feat(policy): the Stop moment reaches a module, so its rules can be Rego MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `stop-guard.sh`'s five rules are being ported, and the destination is a policy module rather than engine code or `[[hook.handler]]` rows. This is the projection that makes that possible; the module and the retirement follow. WHY REGO RATHER THAN THE TWO ALTERNATIVES, since both were nearly built. A bespoke Stop handler in `lib.rs` would have made Stop a special-cased path in the engine at exactly the moment this migration is turning every other gate into a declaration — the shape being removed, re-introduced one layer down. `[[hook.handler]]` rows are the mechanism whose own doc cites `stop-guard` at ~28ms as the surface it replaces, so they looked right. They are not, and the reason is what the shell owns beyond dispatch: `dispatch` collects advice from EVERY handler with no short-circuit, where `stop-guard` emits at most one nudge per turn — "two nudges on one turn is how a channel stops being read" — with its rules ranked by MEASURED precision, 3/3 then 1/1 then three unmeasured. Making that an engine feature meant a `group` field and a suppression field: two pieces of config surface to express one predicate. In Rego the precedence IS the predicate. A module that computes which single nudge wins states the ranking as readable data, where a handler `group` would have hidden it in declaration order in TOML. Nothing new is needed in the engine for exclusivity at all. Three things already existed and are what make this cheap: * `Fact::Stop` — the at-risk report and the undischarged denials, `read x hook`, so Stop-time state already reaches the surface modules evaluate on; * a refusal at `Stop` is already demoted to ADVICE, and `Event::carries_a_verdict` is the one authority both producers ask, so a module's `deny` lands in the nudge channel rather than refusing a turn; * `Production::Marker` plus `Fact::Produced` are the write and read halves of a presence test, which is what the two per-branch suppression receipts are. WHAT THIS COMMIT ADDS: three `input.call` keys. `final-message` is the turn's own text. `transcript` is the session file's PATH — never a byte of it, so the projection stays pointer-only and cheap; a module wanting contents asks for a fact the engine resolves. `stop-repeat` is the host's recursion bound, false on the first Stop of a turn and true on the one a previous Stop caused. They are `call` fields rather than facts, on the line that object's own description draws: a fact is resolved ABOUT the call, these are what the harness handed the boundary. Nothing looks them up. EVERY ONE IS `null` ON EVERY OTHER EVENT, and that is the three-valued read working rather than a gap. A module asking at `pre-tool` gets undefined, Rego reads undefined as *does not hold*, and a Stop predicate therefore cannot fire on a tool call. `stop-repeat` is PROJECTED, NEVER ENFORCED HERE. Which rules the recursion bound binds is a policy question — `stop-guard` bounds four of its five and not the fifth — so an engine that applied it for every module would be holding a rule it cannot state, and a module that ignores it nudges forever. The projection makes that the module's decision and visible in the module. `.claude/rules/policy-modules.md` carries the widened key set, and `rules-drift` holds it to the generated schema: 19 policy input keys agree, up from 18, so the prose and the mechanism moved together rather than the prose going stale. 2492/2492 green; schemas regenerated by `mise run schema`. Refs: CLOUD-1051, CLOUD-889, CLOUD-97 --- .claude/rules/policy-modules.md | 13 +++++++++++-- crates/batten/src/hook.rs | 25 +++++++++++++++++++++++++ crates/batten/src/policy.rs | 3 +++ crates/batten/tests/board_record.rs | 2 ++ schema/policy-call.schema.json | 3 +++ 5 files changed, 44 insertions(+), 2 deletions(-) diff --git a/.claude/rules/policy-modules.md b/.claude/rules/policy-modules.md index 746c00a76..b37a01c86 100644 --- a/.claude/rules/policy-modules.md +++ b/.claude/rules/policy-modules.md @@ -105,8 +105,17 @@ A **tree**-scoped module (`scope = "tree"`, run by `batten check`) reads `input.tree.git-remote`, `input.tree.git-status`. A **mediated-call** module (`scope = "mediated_call"`, run by `batten hook`) -reads `input.call.command`, `input.call.event`, `input.call.operation` and -`input.call.writes`, plus the `facts` object. +reads `input.call.command`, `input.call.event`, `input.call.operation`, +`input.call.writes`, `input.call["final-message"]`, `input.call.transcript` and +`input.call["stop-repeat"]`, plus the `facts` object. + +The last three are the **Stop** projections (CLOUD-1051) and every one is `null` +on every other event, which is the three-valued read working rather than a gap: a +module asking for them at `pre-tool` gets undefined, Rego reads undefined as +_does not hold_, and a Stop predicate therefore cannot fire on a tool call. They +are `call` fields rather than facts because nothing resolves them — the harness +hands them over. `transcript` is the **path**, never a byte of the session; a +module wanting the contents asks for a fact the engine resolves. `schema/policy-input.schema.json` and `schema/policy-call.schema.json` are the authority and are generated — do not hand-edit either, and do not restate the key diff --git a/crates/batten/src/hook.rs b/crates/batten/src/hook.rs index f23ad5d6b..82500d9fb 100644 --- a/crates/batten/src/hook.rs +++ b/crates/batten/src/hook.rs @@ -4969,6 +4969,31 @@ fn call_document(envelope: &Envelope, facts: &Facts<'_>) -> Result Result { "operation": {"type": "string"}, "command": {}, "writes": {}, + "final-message": {}, + "transcript": {}, + "stop-repeat": {}, }, }, "facts": { diff --git a/crates/batten/tests/board_record.rs b/crates/batten/tests/board_record.rs index 6f5ed1ca2..2c7466c06 100644 --- a/crates/batten/tests/board_record.rs +++ b/crates/batten/tests/board_record.rs @@ -79,6 +79,8 @@ //! intersected later by the gate — so the retired case asserting an untouched //! file is still recorded is carried under a name that says what it measures. +#![allow(clippy::unwrap_used, clippy::expect_used)] + mod common; use std::fs; diff --git a/schema/policy-call.schema.json b/schema/policy-call.schema.json index a653e187d..4d3cefc8b 100644 --- a/schema/policy-call.schema.json +++ b/schema/policy-call.schema.json @@ -11,9 +11,12 @@ "event": { "type": "string" }, + "final-message": {}, "operation": { "type": "string" }, + "stop-repeat": {}, + "transcript": {}, "writes": {} }, "type": "object" From 835b6803195e6027fb638ff44112f6b93d5a5d52 Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Wed, 26 Aug 2026 04:08:57 +0000 Subject: [PATCH 11/38] feat(policy): the output-posture tell becomes a module, the first of stop-guard's five MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `stop-posture-check.sh`'s predicate, as `policy/stop-posture.rego`. The shell file stays for now — `stop-guard.sh` still invokes it by path, and both retire together once the remaining four rules have modules. WHY THIS ONE FIRST: it is the only rule expressible from the projection alone. `input.call["final-message"]` is the turn's own text, so no fact and no spawn is needed, which makes it the increment that proves the whole path — module fires at Stop, engine demotes the deny to advice, advice reaches the host — before the harder four arrive with the facts they need. THE SCRUB IS FOUR SUBSTITUTIONS AND COMES FIRST, because a tell inside a quotation is a report OF the tell rather than an instance of it. The shell documented its own rule twice before its scrubber covered quoted spans. Order is load-bearing: fenced blocks before code spans, because a fence contains backticks and stripping spans first leaves the fence's delimiters to pair with prose. WHOLE-INPUT, which a Rego string is by construction and which the shell needed `perl -0777` for. Every one of those spans is routinely line-wrapped in real prose, so a line-based reader leaks the interior of a wrapped quotation — asserted by its own case rather than left to the type. THE LITERAL SET'S WIDTH IS MEASURED, NOT CHOSEN, and both halves are carried: `worth naming` fires because it is the WITNESSED miss — the CLOUD-347..356 audit closed with a real finding phrased that way, which reached chat and nothing else and became CLOUD-380 only because a human asked — while `calling out` stays out as unwitnessed, since admitting it would be the unmeasured-literal invention CLOUD-323 and CLOUD-326 forbid. CLOUD-387's asymmetry is carried too: one verb set across both openers, asserted as a pair. TWO THINGS THE PORT FOUND, both instructive rather than incidental: * `object.get`'s DEFAULT DOES NOT COVER AN EXPLICIT NULL. It substitutes when the PATH is missing; every non-Stop event projects `final-message` with an explicit `null`, so the path is present, the default never applies, and `regex.replace` faults on a null argument. That is this engine's null-is-not-absent distinction arriving in Rego, and it is why the module uses a `default` rule with an `is_string` guard — which also covers a host that ever sends a number there, rather than faulting at the first substitution. * `check_no_inline_regex` READS EVERY LITERAL HANDED TO A `regex.*` BUILTIN, deliberately: the builtins disagree on argument order, so a per-builtin position table would be a second thing to keep in step with upstream. A REPLACEMENT is not a pattern, so it is bound to a name here rather than written inline. That says which of the two it is without narrowing a deliberate over-approximation for one caller's convenience. THE NUDGE IS A COUNT AND NOTHING ELSE (rule 4), load-bearing rather than decorative: handing the matched prose back would make this a mirror, and a mirror is cleared by restating it — which is the double-write the rule exists to name. The count is over MATCHES rather than matching lines, which `grep -c` got wrong: two tells in one sentence counted as one, understating exactly what is measured. Sixteen module cases, eight of them the silent direction — a rule that fired on every turn would satisfy the deny and nudge about nothing. `policy test`: 14 bundles, 135 passed. Full suite 2492/2492. Refs: CLOUD-1051, CLOUD-97, CLOUD-387, CLOUD-885 --- batten.toml | 82 ++++++++++++++++ policy/stop-posture.rego | 204 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 286 insertions(+) create mode 100644 policy/stop-posture.rego diff --git a/batten.toml b/batten.toml index 5dd6288aa..682bf712b 100644 --- a/batten.toml +++ b/batten.toml @@ -712,6 +712,43 @@ path = "mise-tasks/ready-lint.sh" path = "mise-tasks/board-diff-overlap.sh" args = ["--named"] +# ─── THE OUTPUT-POSTURE SCRUB AND ITS TELL (CLOUD-97, ported under CLOUD-1051) ─ +# +# Four scrubbers and one predicate, declared here because an inline regex is +# refused at load and because one concept must have one spelling. +# +# ORDER MATTERS AND THE MODULE APPLIES THEM IN IT: a fence contains backticks, so +# stripping code spans first would leave the fence's own delimiters to pair with +# prose either side of it. +[[pattern]] +id = "md-fenced-block" +regex = '```[^`]*```' + +[[pattern]] +id = "md-code-span" +regex = '`[^`]*`' + +[[pattern]] +id = "md-quoted-span" +regex = '"[^"]*"' + +# `(?m)` so `^` reaches a block quote wherever it sits in a whole-input string +# rather than only at the very start of it. +[[pattern]] +id = "md-block-quote" +regex = '(?m)^[[:space:]]*>[^\n]*' + +# THE TELL. AGENTS.md's own two examples plus their direct inflections, and the +# width is measured rather than chosen — see `policy/stop-posture.rego`'s header +# for the witnessed miss (`worth naming`) and for why `calling out` stays out. +# +# `(?i)` because a sentence is a sentence however it is capitalised. Both the +# contracted and the expanded auxiliary, and both apostrophes: a straight one and +# a typographic one are the same word to a reader. +[[pattern]] +id = "hedged-flag-framing" +regex = "(?i)worth (noting|flagging|mentioning|naming)|one thing (I would|I['’]?d) (flag|note)|I['’]?d (flag|note) (that|one)|I would (flag|note) that|I should (note|flag)|(it|that)['’]?s worth (noting|flagging|mentioning|naming)|bears (noting|flagging|mentioning|naming)" + # The §1 span, narrowed before the paths are read (CLOUD-854). # # The fifth column holds every path the BODY names, and a path-name intersection @@ -2705,6 +2742,25 @@ severity = "deny" # what `batten override request` generates its questions from (CLOUD-1051). No # `bypass_env`: the whole point of that row is that the bare variable stops # working. +# The first of `stop-guard`'s five rules to become a module (CLOUD-1051). +# +# `scope = "mediated_call"` and it fires only at `Stop`, because that is the one +# event projecting `final-message`. Every other event hands the module `null` +# there, Rego reads an undefined path as *does not hold*, and the predicate is +# silent — so the scope is wider than the moment and the moment is what decides. +# +# NO `bypass_env`. `stop-guard` carried `BATTEN_STOP_GUARD_BYPASS` for all five of +# its rules at once; a module is one rule, and the engine's own hatch already +# suppresses the whole mediated path. A per-rule off-switch for an advisory that +# refuses nothing would only make the channel quieter, which is the direction +# nobody can detect. +[[rule]] +id = "stop-posture" +kind = "policy" +scope = "mediated_call" +module = "policy/stop-posture.rego" +severity = "deny" + [[rule]] id = "prose-only" kind = "policy" @@ -3325,6 +3381,32 @@ target = "batten.toml" # fact or not at all — measured 2026-08-25, the instance that motivated CLOUD-1051 # would have had to answer it with a diff that was executable data and that then # waited an hour before riding another branch. Both halves false. +# THE END-OF-TURN NUDGE, and it is a nudge rather than a refusal by construction: +# `Stop` carries no verdict, so the engine demotes this to advice. CLOUD-97 and +# CLOUD-219 each ruled a deny out at this moment independently — pausing mid-task +# is legitimate and blocking "done" punishes correct behaviour — and the demotion +# is what makes that structural instead of careful. +[[verdict]] +id = "V-HEDGED-FLAG-FRAMING" +gloss = "a finding was written as editorial rather than to a durable home" +class = """ +Chat stores nothing, so a finding flagged in passing has no reader after the \ +turn ends. The tell is the hedge — an opener plus a communication verb — and it \ +is self-indicting every time: a real finding belongs in an issue or a memory, \ +and one that is not a finding does not need the hedge. Measured at 3/3 \ +precision, which is why this rule leads the end-of-turn set. +""" + +[[verdict.route]] +id = "R-FILE-THE-FINDING" +kind = "issue" +target = "put it in the row that already owns it, or file one" + +[[verdict.route]] +id = "R-DROP-THE-HEDGE" +kind = "command" +target = "say it plainly, or say nothing" + [[verdict]] id = "V-PROSE-ONLY-DIFF" gloss = "every changed line is a comment and no test moved, so a CI matrix would confirm nothing" diff --git a/policy/stop-posture.rego b/policy/stop-posture.rego new file mode 100644 index 000000000..abb29762a --- /dev/null +++ b/policy/stop-posture.rego @@ -0,0 +1,204 @@ +# The one output-posture tell AGENTS.md names literally, as a module rather than +# a Stop hook body (CLOUD-97, ported under CLOUD-1051). +# +# A hedged flag is a finding written as editorial instead of durably. Chat stores +# nothing, so the finding's home is an issue or a memory — and the sentence that +# flags it in passing is the double-write CLOUD-200 and CLOUD-248 exist to kill. +# +# THIS IS THE FIRST OF `stop-guard`'s FIVE RULES TO MOVE, and it is first because +# it is the only one expressible from the projection alone: `input.call +# ["final-message"]` is the turn's own text, so no fact and no spawn is needed. +# The other four read the transcript, the store and the board record, and follow +# with the facts they need. +# +# PRECEDENCE IS THE MODULE'S, NOT THE ENGINE'S, which is why this is Rego at all. +# `stop-guard` emits at most one nudge per turn — "two nudges on one turn is how a +# channel stops being read" — and ranks its rules by MEASURED precision: this one +# leads at 3/3 against `finding-sink-check`'s 1/1, with three unmeasured rules +# below. When the remaining four arrive they compose here, where the ranking is +# readable data, rather than in declaration order in a TOML table. +# +# A REFUSAL AT `Stop` IS ADVICE, and that is the engine's existing rule rather +# than anything this module asks for: `Event::carries_a_verdict` is the one +# authority both producers ask, and `Stop` carries none. So a `deny` here reaches +# the nudge channel and cannot refuse a turn — the property CLOUD-97 and CLOUD-219 +# each ruled on independently, preserved by construction instead of by care. +# +# THE SCRUB IS FOUR SUBSTITUTIONS AND IT COMES FIRST, because a tell inside a +# quotation is a report OF the tell rather than an instance of it. This file +# documented its own rule twice before the shell's scrubber covered quoted spans. +# Fenced blocks, code spans, double-quoted spans and block quotes all come out. +# +# WHOLE-INPUT, NEVER LINE-AT-A-TIME. Every one of those spans is routinely +# line-wrapped in real prose, so a line-based reader leaves the interior of a +# wrapped quotation exposed. A Rego string is the whole input by construction, +# which is what the shell needed `perl -0777` for. +# +# THE LITERAL SET IS AGENTS.md's OWN TWO EXAMPLES PLUS THEIR DIRECT INFLECTIONS, +# and its width is measured rather than chosen. `worth naming` is the witnessed +# miss: the CLOUD-347..356 audit closed with "One open thread worth naming: the +# census never interrogated host settings" — a real finding that reached chat and +# nothing else, and became CLOUD-380 only because a human asked. That sentence was +# silent while the same sentence with `noting` fired. `calling out` stays OUT: +# unwitnessed, and admitting it would be the unmeasured-literal invention +# CLOUD-323 and CLOUD-326 forbid. +#MUTANT-EXEMPT CLOUD-931|no `tests/stop-posture.bats` exists, and none may: `shell-retirement` refuses adding a bats suite. `mutant` resolves a gate's suite as `tests/$gate.bats`, so there is no named case a mutation could turn red. The compiled-binary tier is `crates/batten/tests/stop_posture.rs` + +# METADATA +# description: | +# Bound to the MEDIATED-CALL surface: this row is `scope = "mediated_call"`, so +# it reads `{call, facts}` and never the tree document. +# THE BRACKETS ARE NOT STYLE: the schema file carries a hyphen, so the dotted +# form is a parse error reported as `invalid schema reference` rather than as a +# missing bind, and an unbound module type checks as `Any`. +# THIS BLOCK IS YAML AND MUST STAY THE LAST COMMENT BLOCK BEFORE `package`. +# schemas: +# - input: schema["policy-call.schema"] +package batten.stop_posture + +import rego.v1 + +rules contains "stop-posture" + +# The turn's final text, or the empty string when this is not a Stop. +# +# A `default` rule with an `is_string` guard, and `object.get`'s default is NOT +# enough — which is the same null-is-not-absent distinction this engine draws +# everywhere else, arriving in Rego. `object.get` substitutes its default when the +# PATH is missing; every non-Stop event projects the key with an explicit `null`, +# so the path is present, the default never applies, and `regex.replace` faults on +# a null argument. Measured: that is exactly how this module first failed. +# +# The guard is `is_string` rather than `!= null` so a host that ever sends a +# number or an object here is silent too, rather than faulting at the first +# substitution. +default message := "" + +message := text if { + text := input.call["final-message"] + is_string(text) +} + +# What a scrubbed span becomes. One token for all four, because the substitution +# only has to leave something that is not prose — the shell used three names and +# the difference never carried meaning. +# +# BOUND RATHER THAN WRITTEN INLINE, and not to satisfy a linter. `check_no_inline_regex` +# reads EVERY string literal handed to a `regex.*` builtin, deliberately: the +# builtins disagree on argument order, so a per-builtin position table would be a +# second thing to keep in step with upstream. A replacement is not a pattern, so +# naming it is how this module says which of the two it is — the value has a home +# and the gate's over-approximation stays intact rather than being narrowed for +# one caller's convenience. +elided := "ELIDED" + +# THE SCRUB, in the shell's order, which is load-bearing: fenced blocks before +# code spans, because a fence contains backticks and stripping the spans first +# would leave the fence's own delimiters behind to pair with prose. +scrubbed := s4 if { + s1 := regex.replace(message, data.batten.patterns["md-fenced-block"], elided) + s2 := regex.replace(s1, data.batten.patterns["md-code-span"], elided) + s3 := regex.replace(s2, data.batten.patterns["md-quoted-span"], elided) + s4 := regex.replace(s3, data.batten.patterns["md-block-quote"], elided) +} + +# Every hedge in the scrubbed text. +# +# MATCHES, NOT MATCHING LINES. The shell's own test caught that distinction: two +# tells in one sentence counted as one under `grep -c`, which understates exactly +# the double-write this rule exists to name. +hits := count(regex.find_n(data.batten.patterns["hedged-flag-framing"], scrubbed, -1)) + +# THE NUDGE. A count and nothing else — non-negotiable rule 4, and load-bearing +# here rather than decorative: handing the matched prose back would make this a +# mirror, and a mirror is cleared by restating it, which is the double-write. +violation contains { + "rule": "stop-posture", + "verdict": "V-HEDGED-FLAG-FRAMING", + "subjects": [{"count": hits}], +} if { + hits > 0 +} + +# The predicate's own tests. The SILENT cases are the load-bearing half: a rule +# that fired on every turn would satisfy the deny above and nudge about nothing. + +ending(text) := {"call": {"final-message": text}} + +test_a_hedged_flag_is_named if { + some v in violation with input as ending("One thing I would flag is the exit code.") + v.verdict == "V-HEDGED-FLAG-FRAMING" +} + +test_the_witnessed_miss_fires if { + # `worth naming` — the sentence that reached chat and nothing else, and became + # CLOUD-380 only because a human asked. + some v in violation with input as ending("One open thread worth naming: the census never interrogated host settings.") + v.verdict == "V-HEDGED-FLAG-FRAMING" +} + +test_both_openers_share_one_verb_set if { + # CLOUD-387's asymmetry: `mentioning` was a flagging verb under one opener and + # unknown under the other, so the pair is asserted rather than one of them. + some v in violation with input as ending("It bears mentioning that this is worth mentioning.") + v.verdict == "V-HEDGED-FLAG-FRAMING" +} + +# THE SCRUB, and each case is a span the shell's first version leaked through. +test_a_tell_inside_a_code_span_is_a_report_of_it if { + count(violation) == 0 with input as ending("The gate fires on `worth noting`.") +} + +test_a_tell_inside_a_quotation_is_a_report_of_it if { + count(violation) == 0 with input as ending("The rule names \"one thing I would flag\" as its example.") +} + +test_a_tell_inside_a_block_quote_is_a_report_of_it if { + count(violation) == 0 with input as ending("The refusal reads:\n> worth noting, this is hedged\nand that is the tell.") +} + +test_a_tell_inside_a_fenced_block_is_a_report_of_it if { + count(violation) == 0 with input as ending("```\nworth noting\n```") +} + +# A WRAPPED QUOTATION, which is the case the line-at-a-time reader leaked and the +# reason the whole-input model is stated rather than assumed. +test_a_wrapped_quotation_is_scrubbed_whole if { + count(violation) == 0 with input as ending("The rule names \"one thing\nI would flag\" as its example.") +} + +# THE NARROWNESS, and these are what keep the rule from firing on ordinary prose. +# Every entry names an ACT OF FLAGGING rather than any use of "note" or "flag". +test_an_ordinary_use_of_note_is_not_a_hedge if { + count(violation) == 0 with input as ending("I noted the exit code and moved on.") +} + +test_an_ordinary_use_of_flag_is_not_a_hedge if { + count(violation) == 0 with input as ending("The --flag argument is undocumented.") +} + +test_calling_out_is_deliberately_outside_the_set if { + # Unwitnessed, and admitting it would be inventing a phrase list rather than + # completing an inflection. + count(violation) == 0 with input as ending("One thing worth calling out is the exit code.") +} + +# A CLEAN TURN IS SILENT, which is the common case and the one the channel's +# credibility rests on. +test_a_clean_final_message_says_nothing if { + count(violation) == 0 with input as ending("Landed and pushed; CI is green.") +} + +# NOT A STOP, so there is no final message and nothing to judge. `null` here is +# what every other event projects, and reading it as the empty string is what +# keeps this module from erroring its way to silence by accident. +test_a_tool_call_carries_no_final_message if { + count(violation) == 0 with input as {"call": {"event": "pre-tool", "final-message": null}} +} + +# TWO TELLS IN ONE SENTENCE COUNT TWICE, which `grep -c` got wrong and which +# understates the very thing being measured. +test_the_count_is_over_matches_not_lines if { + some v in violation with input as ending("Worth noting the first, and it bears noting the second.") + v.subjects == [{"count": 2}] +} From 474a11f89704a484de7ec47917e3836ffb9e249c Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Wed, 26 Aug 2026 04:21:52 +0000 Subject: [PATCH 12/38] feat(cli)!: an admission is spent, which is what makes the bare variable stop working MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CLOUD-1051 shipped `override request` and nothing that CONSUMES, so two of its acceptance clauses were unmet: "no gate honours a bare env var" needs something to honour instead, and "a legitimate override still completes, demonstrated end to end" needs the whole loop walked once. `batten override spend` is that half. A SEPARATE VERB RATHER THAN A FLAG ON THE GATE, and the line is house-style §5's. `check` is declared `read`, and `perform_requested_sinks` states what keeps that true: a read-effect verb that left a record behind would be a verb that changes what it is judging. Spending moves a record from issued to spent, which is a write — so the gate's task calls this AFTER the refusal rather than the gate consuming its own override mid-decision. The effect model stays honest and the gate stays a pure read. THE SITUATION IS RE-STATED, NEVER REMEMBERED. The caller passes the rule, the class and the subject again instead of having them read out of the record. Reading them from the record would make every spend self-consistent by construction and the binding decorative: the whole content of an admission is that it is valid for ONE situation, so the situation has to come from the caller and be COMPARED. HEAD and the epoch are resolved here for the inverse of `request`'s reason — a caller who could choose them could present a stale admission against a moved tree or a changed policy. EXIT CODES ARE THE CONTRACT, because a gate's task reads them: `0` spent, `2` for every `Refused` arm (a refusal to release is a policy verdict, and §7 gives that one code on every verb), `3` only when the store itself cannot be reached — which is a fault rather than a statement about the admission. POINTER-ONLY, and its disposition is argued rather than copied from its sibling. `request` has an author's reasoning passing through it; this verb reads a record and reports a verdict about it, so what crosses stdout is the class token and the address — no answer text, no subject content. A spend that echoed the reasoning would republish, on every gate run, the one payload the record exists to keep in a single place. Asserted rather than described. FOUR CASES, AND FIVE I DELETED BEFORE COMMITTING. The mechanism was already thoroughly covered — subject, head, epoch, spent, unknown and the end-to-end request each have a case — so five of the nine I first wrote re-proved what was already proven, which is the decorative coverage this repository refuses. What survived is what those cases could not reach: * the VERDICT term. Subject, head and epoch each had a case and the class did not, and it is the term that makes `--verdict` a required flag rather than something derived from the rule: one rule can refuse under more than one class, and an override earned for one must not release the other. * the verb's own three, over the compiled binary — a legitimate spend reporting the class and carrying no answer text, a replay refused with the policy code, and an admission presented for another subject refused as unbound. The last is what makes "re-stated, not remembered" load-bearing rather than a comment. `spec.rs`'s committed row set and `pointer_only.rs`'s verb census both had to name the new verb before it could land, which is those gates working: a verb added without a disposition is one whose output nobody decided about. Completions and man pages regenerated. 2496/2496 green. Refs: CLOUD-1051, CLOUD-851, CLOUD-418 --- completions/batten.bash | 85 +++++++++++++- completions/batten.fish | 57 +++++++--- completions/batten.zsh | 58 ++++++++++ crates/batten/src/cli.rs | 41 +++++++ crates/batten/src/lib.rs | 78 +++++++++++++ crates/batten/src/spec.rs | 1 + crates/batten/src/surface.rs | 51 +++++++++ crates/batten/tests/admission.rs | 165 ++++++++++++++++++++++++++++ crates/batten/tests/pointer_only.rs | 22 ++++ man/batten-override-spend.1 | 25 +++++ man/batten-override.1 | 3 + 11 files changed, 568 insertions(+), 18 deletions(-) create mode 100644 man/batten-override-spend.1 diff --git a/completions/batten.bash b/completions/batten.bash index 1d77cb82b..279408549 100644 --- a/completions/batten.bash +++ b/completions/batten.bash @@ -385,6 +385,9 @@ _batten() { batten__subcmd__help__subcmd__override,request) cmd="batten__subcmd__help__subcmd__override__subcmd__request" ;; + batten__subcmd__help__subcmd__override,spend) + cmd="batten__subcmd__help__subcmd__override__subcmd__spend" + ;; batten__subcmd__help__subcmd__payload,field) cmd="batten__subcmd__help__subcmd__payload__subcmd__field" ;; @@ -445,12 +448,18 @@ _batten() { batten__subcmd__override,request) cmd="batten__subcmd__override__subcmd__request" ;; + batten__subcmd__override,spend) + cmd="batten__subcmd__override__subcmd__spend" + ;; batten__subcmd__override__subcmd__help,help) cmd="batten__subcmd__override__subcmd__help__subcmd__help" ;; batten__subcmd__override__subcmd__help,request) cmd="batten__subcmd__override__subcmd__help__subcmd__request" ;; + batten__subcmd__override__subcmd__help,spend) + cmd="batten__subcmd__override__subcmd__help__subcmd__spend" + ;; batten__subcmd__payload,field) cmd="batten__subcmd__payload__subcmd__field" ;; @@ -2486,7 +2495,7 @@ _batten() { return 0 ;; batten__subcmd__help__subcmd__override) - opts="request" + opts="request spend" if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 @@ -2513,6 +2522,20 @@ _batten() { COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 ;; + batten__subcmd__help__subcmd__override__subcmd__spend) + 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__payload) opts="field" if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then @@ -2958,7 +2981,7 @@ _batten() { return 0 ;; batten__subcmd__override) - opts="-q -v -y -h --strictness --fail-on-warning --config-from --silent --quiet --verbose --debug --trace --log-level --no-color --no-input --yes --help request help" + opts="-q -v -y -h --strictness --fail-on-warning --config-from --silent --quiet --verbose --debug --trace --log-level --no-color --no-input --yes --help request spend help" if [[ ${cur} == -* || ${COMP_CWORD} -eq 2 ]] ; then COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 @@ -2984,7 +3007,7 @@ _batten() { return 0 ;; batten__subcmd__override__subcmd__help) - opts="request help" + opts="request spend help" if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 @@ -3025,6 +3048,20 @@ _batten() { COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 ;; + batten__subcmd__override__subcmd__help__subcmd__spend) + 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__override__subcmd__request) opts="-q -v -y -h --rule --verdict --subject --strictness --fail-on-warning --config-from --silent --quiet --verbose --debug --trace --log-level --no-color --no-input --yes --help" if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then @@ -3063,6 +3100,48 @@ _batten() { COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 ;; + batten__subcmd__override__subcmd__spend) + opts="-q -v -y -h --admission --rule --verdict --subject --strictness --fail-on-warning --config-from --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 + --admission) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; + --rule) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; + --verdict) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; + --subject) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; + --strictness) + COMPREPLY=($(compgen -W "permissive standard strict" -- "${cur}")) + return 0 + ;; + --config-from) + 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__payload) opts="-q -v -y -h --strictness --fail-on-warning --config-from --silent --quiet --verbose --debug --trace --log-level --no-color --no-input --yes --help field help" if [[ ${cur} == -* || ${COMP_CWORD} -eq 2 ]] ; then diff --git a/completions/batten.fish b/completions/batten.fish index d7ef2c168..05c22a5dc 100644 --- a/completions/batten.fish +++ b/completions/batten.fish @@ -917,28 +917,29 @@ complete -c batten -n "__fish_batten_using_subcommand worktree; and __fish_seen_ complete -c batten -n "__fish_batten_using_subcommand worktree; and __fish_seen_subcommand_from status" -s h -l help -d 'Print help (see more with \'--help\')' complete -c batten -n "__fish_batten_using_subcommand worktree; and __fish_seen_subcommand_from help" -f -a "status" -d 'Report work that is uncommitted, unpushed, or not landed on the configured target' complete -c batten -n "__fish_batten_using_subcommand worktree; 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 override; and not __fish_seen_subcommand_from request 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 override; and not __fish_seen_subcommand_from request spend 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 override; and not __fish_seen_subcommand_from request 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 override; and not __fish_seen_subcommand_from request 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 override; and not __fish_seen_subcommand_from request spend 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 override; and not __fish_seen_subcommand_from request spend 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 override; and not __fish_seen_subcommand_from request 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 override; and not __fish_seen_subcommand_from request help" -l silent -d 'Say nothing but a verdict or a usage error' -complete -c batten -n "__fish_batten_using_subcommand override; and not __fish_seen_subcommand_from request help" -s q -l quiet -d 'Suppress ordinary progress (repeatable: -qq is silent)' -complete -c batten -n "__fish_batten_using_subcommand override; and not __fish_seen_subcommand_from request help" -s v -l verbose -d 'Explain what is being checked (repeatable: -vv is debug)' -complete -c batten -n "__fish_batten_using_subcommand override; and not __fish_seen_subcommand_from request help" -l debug -d 'Add resolution detail' -complete -c batten -n "__fish_batten_using_subcommand override; and not __fish_seen_subcommand_from request help" -l trace -d 'Add everything' -complete -c batten -n "__fish_batten_using_subcommand override; and not __fish_seen_subcommand_from request help" -l no-color -d 'Never colour stderr, whatever it is attached to' -complete -c batten -n "__fish_batten_using_subcommand override; and not __fish_seen_subcommand_from request help" -l no-input -d 'Never prompt; treat the run as unattended' -complete -c batten -n "__fish_batten_using_subcommand override; and not __fish_seen_subcommand_from request help" -s y -l yes -d 'Confirm a destructive operation that would otherwise refuse' -complete -c batten -n "__fish_batten_using_subcommand override; and not __fish_seen_subcommand_from request help" -s h -l help -d 'Print help (see more with \'--help\')' -complete -c batten -n "__fish_batten_using_subcommand override; and not __fish_seen_subcommand_from request help" -f -a "request" -d 'Answer a class\'s declared precondition and receive an admission for one situation' -complete -c batten -n "__fish_batten_using_subcommand override; and not __fish_seen_subcommand_from request help" -f -a "help" -d 'Print this message or the help of the given subcommand(s)' +complete -c batten -n "__fish_batten_using_subcommand override; and not __fish_seen_subcommand_from request spend 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 override; and not __fish_seen_subcommand_from request spend help" -l silent -d 'Say nothing but a verdict or a usage error' +complete -c batten -n "__fish_batten_using_subcommand override; and not __fish_seen_subcommand_from request spend help" -s q -l quiet -d 'Suppress ordinary progress (repeatable: -qq is silent)' +complete -c batten -n "__fish_batten_using_subcommand override; and not __fish_seen_subcommand_from request spend help" -s v -l verbose -d 'Explain what is being checked (repeatable: -vv is debug)' +complete -c batten -n "__fish_batten_using_subcommand override; and not __fish_seen_subcommand_from request spend help" -l debug -d 'Add resolution detail' +complete -c batten -n "__fish_batten_using_subcommand override; and not __fish_seen_subcommand_from request spend help" -l trace -d 'Add everything' +complete -c batten -n "__fish_batten_using_subcommand override; and not __fish_seen_subcommand_from request spend help" -l no-color -d 'Never colour stderr, whatever it is attached to' +complete -c batten -n "__fish_batten_using_subcommand override; and not __fish_seen_subcommand_from request spend help" -l no-input -d 'Never prompt; treat the run as unattended' +complete -c batten -n "__fish_batten_using_subcommand override; and not __fish_seen_subcommand_from request spend help" -s y -l yes -d 'Confirm a destructive operation that would otherwise refuse' +complete -c batten -n "__fish_batten_using_subcommand override; and not __fish_seen_subcommand_from request spend help" -s h -l help -d 'Print help (see more with \'--help\')' +complete -c batten -n "__fish_batten_using_subcommand override; and not __fish_seen_subcommand_from request spend help" -f -a "request" -d 'Answer a class\'s declared precondition and receive an admission for one situation' +complete -c batten -n "__fish_batten_using_subcommand override; and not __fish_seen_subcommand_from request spend help" -f -a "spend" -d 'Spend an issued admission against the situation it was issued for' +complete -c batten -n "__fish_batten_using_subcommand override; and not __fish_seen_subcommand_from request spend help" -f -a "help" -d 'Print this message or the help of the given subcommand(s)' complete -c batten -n "__fish_batten_using_subcommand override; and __fish_seen_subcommand_from request" -l rule -d 'The rule whose refusal is being overridden' -r complete -c batten -n "__fish_batten_using_subcommand override; and __fish_seen_subcommand_from request" -l verdict -d 'The verdict token that refusal carries, e.g. V-PROSE-ONLY-DIFF' -r complete -c batten -n "__fish_batten_using_subcommand override; and __fish_seen_subcommand_from request" -l subject -d 'The gate\'s canonical subject, exactly as its refusal names it' -r @@ -962,7 +963,32 @@ complete -c batten -n "__fish_batten_using_subcommand override; and __fish_seen_ complete -c batten -n "__fish_batten_using_subcommand override; and __fish_seen_subcommand_from request" -l no-input -d 'Never prompt; treat the run as unattended' complete -c batten -n "__fish_batten_using_subcommand override; and __fish_seen_subcommand_from request" -s y -l yes -d 'Confirm a destructive operation that would otherwise refuse' complete -c batten -n "__fish_batten_using_subcommand override; and __fish_seen_subcommand_from request" -s h -l help -d 'Print help (see more with \'--help\')' +complete -c batten -n "__fish_batten_using_subcommand override; and __fish_seen_subcommand_from spend" -l admission -d 'The admission address to spend' -r +complete -c batten -n "__fish_batten_using_subcommand override; and __fish_seen_subcommand_from spend" -l rule -d 'The rule whose refusal is being overridden' -r +complete -c batten -n "__fish_batten_using_subcommand override; and __fish_seen_subcommand_from spend" -l verdict -d 'The verdict token that refusal carries, e.g. V-PROSE-ONLY-DIFF' -r +complete -c batten -n "__fish_batten_using_subcommand override; and __fish_seen_subcommand_from spend" -l subject -d 'The gate\'s canonical subject, exactly as its refusal names it' -r +complete -c batten -n "__fish_batten_using_subcommand override; and __fish_seen_subcommand_from spend" -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 override; and __fish_seen_subcommand_from spend" -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 override; and __fish_seen_subcommand_from spend" -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 override; and __fish_seen_subcommand_from spend" -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 override; and __fish_seen_subcommand_from spend" -l silent -d 'Say nothing but a verdict or a usage error' +complete -c batten -n "__fish_batten_using_subcommand override; and __fish_seen_subcommand_from spend" -s q -l quiet -d 'Suppress ordinary progress (repeatable: -qq is silent)' +complete -c batten -n "__fish_batten_using_subcommand override; and __fish_seen_subcommand_from spend" -s v -l verbose -d 'Explain what is being checked (repeatable: -vv is debug)' +complete -c batten -n "__fish_batten_using_subcommand override; and __fish_seen_subcommand_from spend" -l debug -d 'Add resolution detail' +complete -c batten -n "__fish_batten_using_subcommand override; and __fish_seen_subcommand_from spend" -l trace -d 'Add everything' +complete -c batten -n "__fish_batten_using_subcommand override; and __fish_seen_subcommand_from spend" -l no-color -d 'Never colour stderr, whatever it is attached to' +complete -c batten -n "__fish_batten_using_subcommand override; and __fish_seen_subcommand_from spend" -l no-input -d 'Never prompt; treat the run as unattended' +complete -c batten -n "__fish_batten_using_subcommand override; and __fish_seen_subcommand_from spend" -s y -l yes -d 'Confirm a destructive operation that would otherwise refuse' +complete -c batten -n "__fish_batten_using_subcommand override; and __fish_seen_subcommand_from spend" -s h -l help -d 'Print help (see more with \'--help\')' complete -c batten -n "__fish_batten_using_subcommand override; and __fish_seen_subcommand_from help" -f -a "request" -d 'Answer a class\'s declared precondition and receive an admission for one situation' +complete -c batten -n "__fish_batten_using_subcommand override; and __fish_seen_subcommand_from help" -f -a "spend" -d 'Spend an issued admission against the situation it was issued for' complete -c batten -n "__fish_batten_using_subcommand override; 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 provision; and not __fish_seen_subcommand_from status apply 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' @@ -1463,6 +1489,7 @@ 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 attribution" -f -a "identity" -d 'Set this clone\'s repo-local git identity when it is unset or denied' complete -c batten -n "__fish_batten_using_subcommand help; and __fish_seen_subcommand_from worktree" -f -a "status" -d 'Report work that is uncommitted, unpushed, or not landed on the configured target' complete -c batten -n "__fish_batten_using_subcommand help; and __fish_seen_subcommand_from override" -f -a "request" -d 'Answer a class\'s declared precondition and receive an admission for one situation' +complete -c batten -n "__fish_batten_using_subcommand help; and __fish_seen_subcommand_from override" -f -a "spend" -d 'Spend an issued admission against the situation it was issued for' complete -c batten -n "__fish_batten_using_subcommand help; and __fish_seen_subcommand_from provision" -f -a "status" -d 'Report which provisioned tools do not match the manifest' complete -c batten -n "__fish_batten_using_subcommand help; and __fish_seen_subcommand_from provision" -f -a "apply" -d 'Fetch, verify against the pinned checksum, and install into the out-of-tree cache' complete -c batten -n "__fish_batten_using_subcommand help; and __fish_seen_subcommand_from payload" -f -a "field" -d 'Print one field of a hook payload read from stdin, for a shell hook that must not depend on jq' diff --git a/completions/batten.zsh b/completions/batten.zsh index 105230a4e..e8231981e 100644 --- a/completions/batten.zsh +++ b/completions/batten.zsh @@ -1589,6 +1589,38 @@ trace\:"Add everything"))' \ '--help[Print help (see more with '\''--help'\'')]' \ && ret=0 ;; +(spend) +_arguments "${_arguments_options[@]}" : \ +'--admission=[The admission address to spend]: :_default' \ +'--rule=[The rule whose refusal is being overridden]: :_default' \ +'--verdict=[The verdict token that refusal carries, e.g. V-PROSE-ONLY-DIFF]: :_default' \ +'--subject=[The gate'\''s canonical subject, exactly as its refusal names it]: :_default' \ +'--strictness=[Raise how strictly gates apply (an override may only tighten policy)]: :((permissive\:"Advisory\: findings are reported without failing the run" +standard\:"The default\: a finding is a violation" +strict\:"Everything \`Standard\` fails on, plus anything advisory"))' \ +'--config-from=[Read the committed config from a git ref (e.g. origin/main) instead of the working tree]: :_default' \ +'--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__override__subcmd__help_commands" \ @@ -1605,6 +1637,10 @@ _arguments "${_arguments_options[@]}" : \ _arguments "${_arguments_options[@]}" : \ && ret=0 ;; +(spend) +_arguments "${_arguments_options[@]}" : \ +&& ret=0 +;; (help) _arguments "${_arguments_options[@]}" : \ && ret=0 @@ -2715,6 +2751,10 @@ _arguments "${_arguments_options[@]}" : \ (request) _arguments "${_arguments_options[@]}" : \ && ret=0 +;; +(spend) +_arguments "${_arguments_options[@]}" : \ +&& ret=0 ;; esac ;; @@ -3554,6 +3594,7 @@ _batten__subcmd__help__subcmd__lint__subcmd__brief_commands() { _batten__subcmd__help__subcmd__override_commands() { local commands; commands=( 'request:Answer a class'\''s declared precondition and receive an admission for one situation' \ +'spend:Spend an issued admission against the situation it was issued for' \ ) _describe -t commands 'batten help override commands' commands "$@" } @@ -3562,6 +3603,11 @@ _batten__subcmd__help__subcmd__override__subcmd__request_commands() { local commands; commands=() _describe -t commands 'batten help override request commands' commands "$@" } +(( $+functions[_batten__subcmd__help__subcmd__override__subcmd__spend_commands] )) || +_batten__subcmd__help__subcmd__override__subcmd__spend_commands() { + local commands; commands=() + _describe -t commands 'batten help override spend commands' commands "$@" +} (( $+functions[_batten__subcmd__help__subcmd__payload_commands] )) || _batten__subcmd__help__subcmd__payload_commands() { local commands; commands=( @@ -3731,6 +3777,7 @@ _batten__subcmd__lint__subcmd__help__subcmd__help_commands() { _batten__subcmd__override_commands() { local commands; commands=( 'request:Answer a class'\''s declared precondition and receive an admission for one situation' \ +'spend:Spend an issued admission against the situation it was issued for' \ 'help:Print this message or the help of the given subcommand(s)' \ ) _describe -t commands 'batten override commands' commands "$@" @@ -3739,6 +3786,7 @@ _batten__subcmd__override_commands() { _batten__subcmd__override__subcmd__help_commands() { local commands; commands=( 'request:Answer a class'\''s declared precondition and receive an admission for one situation' \ +'spend:Spend an issued admission against the situation it was issued for' \ 'help:Print this message or the help of the given subcommand(s)' \ ) _describe -t commands 'batten override help commands' commands "$@" @@ -3753,11 +3801,21 @@ _batten__subcmd__override__subcmd__help__subcmd__request_commands() { local commands; commands=() _describe -t commands 'batten override help request commands' commands "$@" } +(( $+functions[_batten__subcmd__override__subcmd__help__subcmd__spend_commands] )) || +_batten__subcmd__override__subcmd__help__subcmd__spend_commands() { + local commands; commands=() + _describe -t commands 'batten override help spend commands' commands "$@" +} (( $+functions[_batten__subcmd__override__subcmd__request_commands] )) || _batten__subcmd__override__subcmd__request_commands() { local commands; commands=() _describe -t commands 'batten override request commands' commands "$@" } +(( $+functions[_batten__subcmd__override__subcmd__spend_commands] )) || +_batten__subcmd__override__subcmd__spend_commands() { + local commands; commands=() + _describe -t commands 'batten override spend commands' commands "$@" +} (( $+functions[_batten__subcmd__payload_commands] )) || _batten__subcmd__payload_commands() { local commands; commands=( diff --git a/crates/batten/src/cli.rs b/crates/batten/src/cli.rs index 8e65707fa..ab62b972f 100644 --- a/crates/batten/src/cli.rs +++ b/crates/batten/src/cli.rs @@ -311,6 +311,29 @@ pub enum OverrideCommand { /// The gate's canonical subject. subject: String, }, + /// Spend an issued admission against the situation it was issued for. + /// + /// Appended rather than placed beside its sibling, for the reason this + /// enum's own `Override` variant records: no `repr`, so a variant in the + /// middle re-numbers every later discriminant and `semver` reads that as a + /// break the crate has to declare. + /// + /// **The write half, and it is a separate verb rather than a flag on a gate + /// for the reason house-style §5 draws.** `check` is `read`: a read-effect + /// verb that left a record behind would be a verb that changes what it is + /// judging. Consuming an admission is a WRITE — it moves a record from + /// issued to spent — so it is its own verb, called by the gate's task after + /// the refusal rather than folded into the thing that refused. + Spend { + /// The admission address to consume. + admission: String, + /// The rule whose refusal it was issued against. + rule: String, + /// The declared class that refusal carries. + verdict: String, + /// The gate's canonical subject. + subject: String, + }, } /// Subcommands of `worktree`. @@ -860,6 +883,24 @@ fn override_of(matches: &ArgMatches) -> Option { .cloned() .unwrap_or_default(), }), + ("spend", matches) => Some(OverrideCommand::Spend { + admission: matches + .get_one::("admission") + .cloned() + .unwrap_or_default(), + rule: matches + .get_one::("rule") + .cloned() + .unwrap_or_default(), + verdict: matches + .get_one::("verdict") + .cloned() + .unwrap_or_default(), + subject: matches + .get_one::("subject") + .cloned() + .unwrap_or_default(), + }), _ => None, } } diff --git a/crates/batten/src/lib.rs b/crates/batten/src/lib.rs index faf280c29..c41de33ae 100644 --- a/crates/batten/src/lib.rs +++ b/crates/batten/src/lib.rs @@ -1840,6 +1840,84 @@ fn run_override( verdict, subject, } => run_override_request(&rule, &verdict, &subject, overrides, out, err), + OverrideCommand::Spend { + admission, + rule, + verdict, + subject, + } => run_override_spend(&admission, &rule, &verdict, &subject, overrides, out, err), + } +} + +/// `batten override spend` — consume an admission for the situation it names +/// (CLOUD-1051). +/// +/// # Why this is a verb and not a flag on the gate +/// +/// `check` is declared `read`, and [`perform_requested_sinks`] states the rule +/// that keeps it honest: a read-effect verb that left a record behind would be a +/// verb that changes what it is judging. Spending moves a record from issued to +/// spent, which is a write — so the gate's task calls this AFTER the refusal +/// rather than the gate consuming its own override mid-decision. +/// +/// # The situation is re-stated rather than remembered +/// +/// The caller passes the rule, the class and the subject again instead of having +/// them read out of the record. Reading them from the record would make every +/// spend self-consistent by construction and the binding decorative: the whole +/// content of an admission is that it is valid for ONE situation, so the +/// situation has to come from the caller and be COMPARED. +/// +/// HEAD and the epoch are resolved here for [`run_override_request`]'s reason, +/// inverted: a caller who could choose them could present a stale admission +/// against a moved tree or a changed policy. +/// +/// # Exit codes +/// +/// `0` spent. `2` refused — every [`admission::Refused`] arm, because a refusal +/// to release is a policy verdict and §7 gives that one code on every verb. `3` +/// when the store itself cannot be read or written, which is an internal fault +/// rather than a statement about the admission. +/// +/// # Errors +/// +/// Returns an internal error when the store cannot be reached. +fn run_override_spend( + admission: &str, + rule: &str, + token: &str, + subject: &str, + overrides: &Overrides, + out: &mut dyn Write, + err: &mut dyn Write, +) -> Result { + let root = Path::new("."); + // Resolved rather than taken, exactly as `request` resolves them. + let head = git::head_commit(root)?; + let (epoch, _) = epoch::describe(root, overrides.config_from.as_deref())?; + let situation = admission::Situation { + rule, + verdict: token, + subject, + head: &head, + epoch: &epoch, + }; + match admission::consume(root, admission, &situation)? { + Ok(record) => { + // POINTER, NEVER THE ANSWERS (rule 4). The address and the class, + // which is what a reader needs to find the record; the reasoning the + // author typed stays in the store where it was written. + writeln!(out, "{} {} spent", record.binding.verdict, admission)?; + Ok(ExitCode::Success) + } + Err(refused) => { + writeln!( + err, + "::error:: admission {admission} is {} for {rule}/{token}", + refused.as_str() + )?; + Ok(ExitCode::Violation) + } } } diff --git a/crates/batten/src/spec.rs b/crates/batten/src/spec.rs index 9c5e6ebe8..a485b8ad4 100644 --- a/crates/batten/src/spec.rs +++ b/crates/batten/src/spec.rs @@ -524,6 +524,7 @@ mod tests { // authorize nothing. "override".to_owned(), "override request".to_owned(), + "override spend".to_owned(), "payload".to_owned(), "payload field".to_owned(), "policy".to_owned(), diff --git a/crates/batten/src/surface.rs b/crates/batten/src/surface.rs index a14a37ea4..91d749e82 100644 --- a/crates/batten/src/surface.rs +++ b/crates/batten/src/surface.rs @@ -710,6 +710,26 @@ const CHECK_RULE: FlagDecl = FlagDecl { value: ValueDecl::Str, }; +/// `--admission
`: which issued record is being spent. +/// +/// A positional would have read better, and it is a flag for the reason the +/// three beside it are: `spend` names a SITUATION as well as a record, and a +/// caller who transposed two positionals would present a valid admission against +/// the wrong subject. Every term is named at the call site. +const OVERRIDE_ADMISSION: FlagDecl = FlagDecl { + id: "admission", + long: Some("admission"), + short: None, + help: "The admission address to spend", + env: EnvDecl::None, + global: false, + positional: false, + required: true, + hidden: false, + rung: Rung::None, + value: ValueDecl::Str, +}; + /// `--rule `: which gate's refusal an admission is requested against. const OVERRIDE_RULE: FlagDecl = FlagDecl { id: "rule", @@ -1794,6 +1814,37 @@ pub const SURFACE: &[CommandDecl] = &[ effect: Effect::Write, flags: &[OVERRIDE_RULE, OVERRIDE_VERDICT, OVERRIDE_SUBJECT], }, + // CLOUD-1051's other half, and the acceptance clause `request` alone cannot + // meet: "no gate honours a bare env var" needs something that CONSUMES. + // + // **A SEPARATE VERB RATHER THAN A FLAG ON THE GATE**, on house-style §5's + // line. `check` is declared `read`, and `perform_requested_sinks` states why + // that has to stay true: a read-effect verb that left a record behind would + // be a verb that changes what it is judging. Spending moves a record from + // issued to spent, which is a write, so it is its own verb — called by the + // gate's task AFTER the refusal rather than folded into the thing that + // refused. The effect model stays honest and the gate stays a pure read. + // + // **THE SITUATION IS RE-STATED, NOT REMEMBERED.** A caller passes the rule, + // the class and the subject again rather than having them read out of the + // record, because the whole binding is that an admission is valid for ONE + // situation: reading them from the record would make every spend + // self-consistent by construction and the binding decorative. + // + // No `-J`, matching every other write row: the answer is a verdict, and the + // exit code carries it. + CommandDecl { + path: "override spend", + about: "Spend an issued admission against the situation it was issued for", + data_channel: false, + effect: Effect::Write, + flags: &[ + OVERRIDE_ADMISSION, + OVERRIDE_RULE, + OVERRIDE_VERDICT, + OVERRIDE_SUBJECT, + ], + }, // The `provision` noun only dispatches, and its subtree carries a write // verb, so it takes `receipt`'s conservative reading rather than `policy`'s: // a write-bearing subtree under a `read` noun would leak onto the derived diff --git a/crates/batten/tests/admission.rs b/crates/batten/tests/admission.rs index 146740394..bbc4f6bf0 100644 --- a/crates/batten/tests/admission.rs +++ b/crates/batten/tests/admission.rs @@ -594,3 +594,168 @@ fn a_correctly_answered_override_completes_end_to_end() { "all three declared answers are in the record, which is where the reasoning lives" ); } + +// ─── THE VERDICT TERM (CLOUD-1051) ─────────────────────────────────────────── + +#[test] +fn an_admission_for_one_class_is_not_presentable_against_another() { + // THE ONE BINDING TERM THE CASES ABOVE LEAVE OPEN. Subject, head and epoch + // each have their own case; the class did not, and it is the term that makes + // `verdict` a required flag rather than something derived from the rule — + // one rule can refuse under more than one class, and an override earned for + // one of them must not release the other. + let root = fixture("other-class"); + let issued = admission::issue(&root, binding("a.rs", "HEAD1", "E1", "the class term")) + .expect("the admission issues"); + + let elsewhere = Situation { + verdict: "V-SHELL-RULE-EDITED", + ..situation("a.rs", "HEAD1", "E1") + }; + assert_eq!( + refusal(&root, &issued, &elsewhere), + Refused::Unbound, + "a different class is a different situation" + ); +} + +// ─── THE VERB (CLOUD-1051) ─────────────────────────────────────────────────── +// +// The cases above prove the MECHANISM. These three prove the surface, which is +// what the row's "no gate honours a bare env var" acceptance actually rests on: +// a gate calls this verb and reads its exit code, so the codes are the contract. + +/// Issue an admission through the verb, and hand back the address it printed. +fn issued_through_the_verb(root: &Path, subject: &str) -> String { + let output = common::run_with_stdin( + root, + &[ + "override", + "request", + "--rule", + "prose-only", + "--verdict", + "V-PROSE-ONLY-DIFF", + "--subject", + subject, + ], + // The three ids the class's own `override.precondition` generates. A + // request answering fewer is exit 1 and issues nothing, which is what + // `an_unanswered_question_yields_no_admission_and_prints_what_to_answer` + // asserts — so the helper answers all of them and the cases below are + // about the SPEND rather than about the request. + "precondition=the prose IS the deliverable — this branch is the release notes\n\ + lost=the notes miss the release window and ship describing the previous version\n\ + rejected-route=R-BATCH-IT assumes a next change to these files, and there is none queued\n", + ); + assert_eq!(output.status.code(), Some(0), "the request succeeds"); + String::from_utf8_lossy(&output.stdout).trim().to_owned() +} + +#[test] +fn the_verb_spends_a_legitimate_admission_and_reports_it() { + // ANTI-VACUITY FOR THE SURFACE. Every refusal below is only meaningful if + // this completes — and it is the loop a gate's task actually walks: refuse, + // request, answer, spend, proceed. + let root = fixture("verb-happy"); + let admission = issued_through_the_verb(&root, "a.rs"); + + let output = common::run( + &root, + &[ + "override", + "spend", + "--admission", + &admission, + "--rule", + "prose-only", + "--verdict", + "V-PROSE-ONLY-DIFF", + "--subject", + "a.rs", + ], + ); + assert_eq!(output.status.code(), Some(0), "a bound admission spends"); + let stdout = String::from_utf8_lossy(&output.stdout).into_owned(); + assert!( + stdout.contains("V-PROSE-ONLY-DIFF") && stdout.contains("spent"), + "the class and the outcome: {stdout:?}" + ); + // POINTER, NEVER THE ANSWERS (rule 4). The reasoning the author typed lives + // in the record; a verb that echoed it would republish it on every gate run. + assert!( + !stdout.contains("deliverable"), + "no answer text crosses stdout: {stdout:?}" + ); +} + +#[test] +fn the_verb_refuses_a_replay_with_the_policy_code() { + // Exit 2 rather than 1, and that is §7 rather than a choice here: a refusal + // to release is a policy verdict, and `2` means that on every verb. + let root = fixture("verb-replay"); + let admission = issued_through_the_verb(&root, "a.rs"); + let args = [ + "override", + "spend", + "--admission", + admission.as_str(), + "--rule", + "prose-only", + "--verdict", + "V-PROSE-ONLY-DIFF", + "--subject", + "a.rs", + ]; + assert_eq!( + common::run(&root, &args).status.code(), + Some(0), + "the first spend succeeds" + ); + + let replay = common::run(&root, &args); + assert_eq!( + replay.status.code(), + Some(2), + "a replay is a policy refusal" + ); + assert!( + String::from_utf8_lossy(&replay.stderr).contains("spent"), + "and it says which arm refused" + ); +} + +#[test] +fn the_verb_refuses_an_admission_presented_for_another_subject() { + // THE SITUATION IS RE-STATED, NOT REMEMBERED, and this is what makes that + // load-bearing: if the verb read the subject out of the record instead of + // comparing the one it was given, every spend would be self-consistent by + // construction and the binding would be decorative. + let root = fixture("verb-elsewhere"); + let admission = issued_through_the_verb(&root, "a.rs"); + + let output = common::run( + &root, + &[ + "override", + "spend", + "--admission", + &admission, + "--rule", + "prose-only", + "--verdict", + "V-PROSE-ONLY-DIFF", + "--subject", + "b.rs", + ], + ); + assert_eq!( + output.status.code(), + Some(2), + "an admission earned for one subject releases no other" + ); + assert!( + String::from_utf8_lossy(&output.stderr).contains("unbound"), + "and names the arm" + ); +} diff --git a/crates/batten/tests/pointer_only.rs b/crates/batten/tests/pointer_only.rs index 1aa53cd75..c0704f4c0 100644 --- a/crates/batten/tests/pointer_only.rs +++ b/crates/batten/tests/pointer_only.rs @@ -730,6 +730,28 @@ const CENSUS: &[Verb] = &[ stdin: Stdin::Nothing, disposition: Disposition::PointerOnly, }, + // The consuming half (CLOUD-1051), and pointer-only for a sharper reason than + // its sibling: `request` at least has an author's reasoning passing through + // it, while this verb reads a record and reports a verdict about it. What + // crosses stdout is the class token and the address it spent — no answer, no + // subject content, nothing from the record's body. A spend that echoed the + // reasoning back would republish, on every gate run, the one payload the + // record exists to keep in one place. + Verb { + path: "override spend", + args: &[ + "--admission", + "0000000000000000000000000000000000000000000000000000000000000000", + "--rule", + "prose-only", + "--verdict", + "V-PROTECTED-MUTATION", + "--subject", + "a.rs", + ], + stdin: Stdin::Nothing, + disposition: Disposition::PointerOnly, + }, // Both attribution verbs are the law rather than an exception, and this one // has less latitude than most: everything it reads is metadata someone // wanted suppressed, so a report carrying the matched text would republish diff --git a/man/batten-override-spend.1 b/man/batten-override-spend.1 new file mode 100644 index 000000000..b0362bb14 --- /dev/null +++ b/man/batten-override-spend.1 @@ -0,0 +1,25 @@ +.ie \n(.g .ds Aq \(aq +.el .ds Aq ' +.TH batten-override-spend 1 batten +.SH NAME +batten\-override\-spend \- Spend an issued admission against the situation it was issued for +.SH SYNOPSIS +\fBbatten override spend\fR [\fB\-h\fR|\fB\-\-help\fR] <\fB\-\-admission\fR> <\fB\-\-rule\fR> <\fB\-\-verdict\fR> <\fB\-\-subject\fR> +.SH DESCRIPTION +Spend an issued admission against the situation it was issued for +.SH OPTIONS +.TP +\fB\-\-admission\fR +The admission address to spend +.TP +\fB\-\-rule\fR +The rule whose refusal is being overridden +.TP +\fB\-\-verdict\fR +The verdict token that refusal carries, e.g. V\-PROSE\-ONLY\-DIFF +.TP +\fB\-\-subject\fR +The gate\*(Aqs canonical subject, exactly as its refusal names it +.TP +\fB\-h\fR, \fB\-\-help\fR +Print help diff --git a/man/batten-override.1 b/man/batten-override.1 index ebdd37363..0e27c6457 100644 --- a/man/batten-override.1 +++ b/man/batten-override.1 @@ -16,5 +16,8 @@ Print help batten\-override\-request(1) Answer a class\*(Aqs declared precondition and receive an admission for one situation .TP +batten\-override\-spend(1) +Spend an issued admission against the situation it was issued for +.TP batten\-override\-help(1) Print this message or the help of the given subcommand(s) From 523b7218231ced3e01da36d4d9f00669c2a6023d Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Wed, 26 Aug 2026 04:41:33 +0000 Subject: [PATCH 13/38] feat(facts): the recorder's record becomes a tree fact, so a gate can read it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Fact::Records` projects what this branch's recorders accumulated into `input.tree.records`, which is the shared dependency both remaining ports need: the filed-here predicate reads the board-write record, and the Stop rules read the same file through the same key. KEYED BY THE RECORD, NOT BY THE RECORDER ROW. Several rows write one record — that many-to-one is the recorder model's own, and the censused table has three rows over one file — so reading per row would open the same file once per row and project identical lines under three names. ABSENT RATHER THAN EMPTY, everywhere. A recorder that never ran and a record that cannot be read are different answers and the gate downstream passes on the second by design, so fabricating an empty list would turn could-not-look into a measured nothing. That is the collapse CLOUD-251 names and the one this record shape cannot afford. GUARDED ON THE DECLARATION, for `Fact::Produced`'s reason: locating the git dir and resolving the branch are reads a run whose config declares no recorder must not pay. The guard is the recorder TABLE rather than a per-rule column, because a recorder is config — the fact is what THIS REPOSITORY's recorders accumulated, so a per-rule declaration would be a second home for one answer. `Vocabulary` gains `recorders` rather than `run` gaining a fifth positional, which is what that parameter already exists for: it carries the config-declared tables the engine projects, and `patterns` and `verdicts` arrived the same way. Two clippy ceilings moved with it, both along a seam that already existed rather than by raising a limit. `schema_fragment` delegates its description-only family the way CLOUD-880 made it delegate the git family, and the seam is a property of the fragment: every arm there carries `description` and no `type`, so a fact that grows a constrained shape leaves the function for an arm of its own. `policy_rule` takes `&RunInputs` instead of six positionals, which is the struct `run` already hoists above its loop. Refs: CLOUD-1051 --- .claude/rules/policy-modules.md | 3 +- crates/batten/src/facts.rs | 120 +++++++++++++++++---- crates/batten/src/hook.rs | 11 ++ crates/batten/src/lib.rs | 8 ++ crates/batten/src/policy.rs | 17 ++- crates/batten/src/rules.rs | 118 +++++++++++++++++--- crates/batten/tests/document_read_count.rs | 1 + crates/batten/tests/facts.rs | 5 +- crates/batten/tests/identity_churn.rs | 1 + crates/batten/tests/policy_modules.rs | 2 + crates/batten/tests/policy_test_suite.rs | 1 + crates/batten/tests/policy_tree.rs | 1 + crates/batten/tests/prose_only.rs | 2 + crates/batten/tests/remedy_authorship.rs | 1 + crates/batten/tests/shell_retirement.rs | 1 + crates/batten/tests/verdict_registry.rs | 2 + schema/policy-input.schema.json | 10 ++ 17 files changed, 265 insertions(+), 39 deletions(-) diff --git a/.claude/rules/policy-modules.md b/.claude/rules/policy-modules.md index b37a01c86..70ee88bb6 100644 --- a/.claude/rules/policy-modules.md +++ b/.claude/rules/policy-modules.md @@ -100,7 +100,8 @@ own suite green, and enforced nothing. A **tree**-scoped module (`scope = "tree"`, run by `batten check`) reads `input.tree.documents`, `input.tree.lines`, `input.tree.invocations`, `input.tree.uses`, `input.tree.tracked`, `input.tree.missing`, -`input.tree.produced`, `input.tree.landing`, and the git family — +`input.tree.produced`, `input.tree.records`, `input.tree.landing`, and the git +family — `input.tree.git-head`, `input.tree.git-refs`, `input.tree.git-ranges`, `input.tree.git-remote`, `input.tree.git-status`. diff --git a/crates/batten/src/facts.rs b/crates/batten/src/facts.rs index bd9975328..d86c72e8c 100644 --- a/crates/batten/src/facts.rs +++ b/crates/batten/src/facts.rs @@ -465,6 +465,9 @@ pub enum Fact { /// How the **declared** globs' paths differ from a **declared** base rev: /// added, edited, deleted (CLOUD-1059). BaseDelta, + /// The lines a **declared** `[[recorder]]` accumulated on this branch + /// (CLOUD-1051). + Records, } /// [`Fact::Bypass`] — the hatch is an environment variable, and the kernel @@ -854,6 +857,23 @@ pub const SYMBOLS: Class = Class::new(Cost::Effect, Surface::Check); /// a migration gate needs to know WHICH files moved and never what they say. pub const BASE_DELTA: Class = Class::new(Cost::Read, Surface::Check); +/// [`Fact::Records`] — the lines a declared recorder accumulated (CLOUD-1051). +/// +/// `read` x **`check`**, and the second axis is the interesting one because the +/// obvious reading is wrong. The record is ONE branch-keyed file, so by price +/// this is [`RECEIPTS`]'s equal and the hook could hold it. It sits on `check` +/// anyway, because the consumer decides at LANDING: the gate reading it asks +/// whether a row this branch filed names code this branch has open, and that +/// question needs the branch's whole diff beside it — a fact that is already +/// `check`-only for its own unbounded-walk reason. Projecting the record onto a +/// surface where its counterpart cannot follow would put half a predicate within +/// reach and leave the gate unwritable. +/// +/// Pointer-only holds by construction rather than by care: a recorder's columns +/// are already whitespace-free tokens, and every one of them is a count, an id or +/// a tracked path — never a line of the body that produced it. +pub const RECORDS: Class = Class::new(Cost::Read, Surface::Check); + impl Fact { /// Every fact the boundary resolves today, so [`Fact::class`] is total. pub const ALL: &'static [Fact] = &[ @@ -878,6 +898,7 @@ impl Fact { Fact::Uses, Fact::Symbols, Fact::BaseDelta, + Fact::Records, ]; /// The stable lowercase token (§6) — the field name in `lib.rs`'s `Facts`. @@ -905,6 +926,7 @@ impl Fact { Fact::Uses => "uses", Fact::Symbols => "symbols", Fact::BaseDelta => "base-delta", + Fact::Records => "records", } } @@ -940,6 +962,7 @@ impl Fact { Fact::Uses => USES, Fact::Symbols => SYMBOLS, Fact::BaseDelta => BASE_DELTA, + Fact::Records => RECORDS, } } @@ -1000,6 +1023,7 @@ impl Fact { // of the base tree and a walk of the working tree, which is a // `check`-surface cost and not a mediated call's. Fact::BaseDelta => Some("base-delta"), + Fact::Records => Some("records"), // Hook-surface facts. The tree engine resolves none of them, and // naming them here as `None` is what lets the correspondence test // assert the emitted key set in BOTH directions rather than only @@ -1049,6 +1073,14 @@ impl Fact { "description": "Fact::Document. Path -> the parsed node. Contents are arbitrary consumer TOML/YAML/JSON, so values are deliberately unconstrained; the schema's job here is the key set one level up, not the shape of somebody else's config.", "additionalProperties": true, }), + Fact::Records => serde_json::json!({ + "type": "object", + "description": "Fact::Records (CLOUD-1051). RECORD name -> the lines accumulated in it on this branch, in write order. Keyed by the record rather than by the recorder row because several rows may write one record. Each line is the recorder's own whitespace-free columns; the projection adds nothing and reads nothing out of them. A record ABSENT from this map could not be read; the collapse into an empty list is what this keeps open.", + "additionalProperties": { + "type": "array", + "items": {"type": "string"}, + }, + }), Fact::Tracked => serde_json::json!({ "type": "array", "description": "Fact::Tracked. Repository-relative paths the working-tree walk yields -- paths, never content.", @@ -1098,29 +1130,21 @@ impl Fact { "type": "boolean", "description": "Fact::Bypass -- the BATTEN_HOOK_BYPASS hatch (CLOUD-610). The one fact whose shape is certain enough to constrain.", }), - Fact::Receipts => serde_json::json!({ - "description": "Fact::Receipts -- check -> verdict token, or null for could-not-look.", - }), - Fact::Keys => serde_json::json!({ - "description": "Fact::Keys -- the tracker-key evidence, or null for could-not-look.", - }), - Fact::Stop => serde_json::json!({ - "description": "Fact::Stop -- the end-of-turn facts.", - }), - Fact::Waived => serde_json::json!({ - "description": "Fact::Waived -- the rules a live waiver suppresses, with each claimed expiry.", - }), - Fact::AgentSourced => serde_json::json!({ - "description": "Fact::AgentSourced -- what a command the AGENT ran said (CLOUD-776), or null.", - }), + // The description-only family delegates, for the same reason and + // along the same kind of seam as the git family below: every one of + // these constrains nothing but its own prose, so a match arm each + // buys no readability that one table does not. + Fact::Receipts + | Fact::Keys + | Fact::Stop + | Fact::Waived + | Fact::AgentSourced + | Fact::Prospective => Self::described_schema_fragment(self), Fact::Produced => serde_json::json!({ "type": "object", "description": "Fact::Produced. Sink key -> the record an earlier run's boundary wrote: a digest and a count for a baseline, the empty string for a marker. Never content -- non-negotiable rule 4 holds at the sink harder than at a report (CLOUD-851).", "additionalProperties": {"type": "string"}, }), - Fact::Prospective => serde_json::json!({ - "description": "Fact::Prospective -- the SHAPE of what a write would land (CLOUD-758): look, bytes, lines. Never the content, which is where rule 4 is decided rather than promised.", - }), // The git and landing families delegate (CLOUD-880). Extracted // because this function hit its own 100-line ceiling when `Landing` // arrived, and the ceiling is right: a match arm per fact is readable @@ -1200,6 +1224,63 @@ impl Fact { }) } + /// The schema fragment for the facts that constrain nothing but their own + /// prose (CLOUD-1051 split this out; CLOUD-880 set the precedent). + /// + /// **The seam is a property of the fragment, not of the fact.** Every arm + /// here carries `description` and no `type`, deliberately: these are the + /// facts whose shape is not certain enough to constrain, and saying so once + /// is the statement. A fact that grows a constrained shape leaves this + /// function for an arm of its own, which is the edit rather than a comment. + /// + /// Unreachable for every other fact, and stated the way + /// [`Fact::git_schema_fragment`] states it: no `debug_assert`, exhaustive + /// arms, and a misrouted fact gets its own fragment rather than a panic. + fn described_schema_fragment(self) -> serde_json::Value { + match self { + Fact::Receipts => serde_json::json!({ + "description": "Fact::Receipts -- check -> verdict token, or null for could-not-look.", + }), + Fact::Keys => serde_json::json!({ + "description": "Fact::Keys -- the tracker-key evidence, or null for could-not-look.", + }), + Fact::Stop => serde_json::json!({ + "description": "Fact::Stop -- the end-of-turn facts.", + }), + Fact::Waived => serde_json::json!({ + "description": "Fact::Waived -- the rules a live waiver suppresses, with each claimed expiry.", + }), + Fact::AgentSourced => serde_json::json!({ + "description": "Fact::AgentSourced -- what a command the AGENT ran said (CLOUD-776), or null.", + }), + Fact::Prospective => serde_json::json!({ + "description": "Fact::Prospective -- the SHAPE of what a write would land (CLOUD-758): look, bytes, lines. Never the content, which is where rule 4 is decided rather than promised.", + }), + // NOT THIS FAMILY, AND NAMED RATHER THAN WILDCARDED, for the reason + // `git_schema_fragment`'s tail states: `no_axis_match_carries_a_wildcard_arm` + // refuses a `_ =>`, and a wildcard would let a fact added later + // classify itself here instead of failing to compile. + Fact::Bypass + | Fact::Document + | Fact::Tracked + | Fact::Lines + | Fact::Invocations + | Fact::Uses + | Fact::Produced + | Fact::Symbols + | Fact::BaseDelta + | Fact::Records + | Fact::GitHead + | Fact::GitStatus + | Fact::GitRemote + | Fact::GitRef + | Fact::GitRange + | Fact::Landing => serde_json::json!({ + "description": "unrouted fact -- schema_fragment delegated a fact described_schema_fragment does not own", + }), + } + } + /// The schema fragment for the git and landing families (CLOUD-880). /// /// Split out of [`Fact::schema_fragment`] when `Landing` pushed it past the @@ -1297,7 +1378,8 @@ impl Fact { | Fact::Invocations | Fact::Uses | Fact::Symbols - | Fact::BaseDelta => serde_json::json!({ + | Fact::BaseDelta + | Fact::Records => serde_json::json!({ "description": "unrouted fact -- schema_fragment delegated a fact git_schema_fragment does not own", }), } diff --git a/crates/batten/src/hook.rs b/crates/batten/src/hook.rs index 82500d9fb..b8541d3d5 100644 --- a/crates/batten/src/hook.rs +++ b/crates/batten/src/hook.rs @@ -2606,6 +2606,7 @@ impl Policy { crate::policy::Vocabulary { patterns: &resolved.patterns, verdicts: &resolved.verdicts, + recorders: &resolved.recorders, }, crate::policy::ModuleChecks::SkipOnHotPath, reference, @@ -4958,6 +4959,15 @@ fn call_document(envelope: &Envelope, facts: &Facts<'_>) -> Result None, + // CLOUD-1051, and it sits with the two above rather than with the + // cheap three for a reason that is about its CONSUMER rather than + // its price. One branch-keyed file is what `Receipts` already spends, + // so the hook could hold it. The gate that reads it decides at + // landing and needs the branch's whole diff beside it — which is + // `BaseDelta`, unbounded and `check`-only — so projecting this here + // would put half a predicate within reach of a surface where the + // other half can never follow. `facts.rs` carries the argument. + crate::facts::Fact::Records => None, }; if let Some(value) = projected { projected_facts.insert(fact.as_str().to_owned(), value); @@ -7674,6 +7684,7 @@ mod tests { crate::policy::Vocabulary { patterns: &[], verdicts: &fixture_verdicts, + recorders: &[], }, crate::policy::ModuleChecks::Run, None, diff --git a/crates/batten/src/lib.rs b/crates/batten/src/lib.rs index c41de33ae..1f7c2ee64 100644 --- a/crates/batten/src/lib.rs +++ b/crates/batten/src/lib.rs @@ -463,6 +463,7 @@ fn run_baseline( policy::Vocabulary { patterns: &config.patterns, verdicts: &config.verdicts, + recorders: &config.recorders, }, &root, )?; @@ -807,6 +808,7 @@ fn run_state_record(overrides: &Overrides, mode: Mode, err: &mut dyn Write) -> R policy::Vocabulary { patterns: &config.patterns, verdicts: &config.verdicts, + recorders: &config.recorders, }, Path::new("."), )?; @@ -1679,6 +1681,10 @@ fn suite_input( }, tracked, &std::collections::BTreeMap::new(), + // A module's own `test_` rules supply their input with `with input as`, + // so the record projection is empty here for the same reason the two + // beside it are: this builds the shape, and the case chooses the values. + &std::collections::BTreeMap::new(), &git::GitFacts::default(), &facts::Look::IsNot, )) @@ -2086,6 +2092,7 @@ fn run_policy_test(json: bool, overrides: &Overrides, out: &mut dyn Write) -> Re policy::Vocabulary { patterns: &config.patterns, verdicts: &config.verdicts, + recorders: &config.recorders, }, policy::ModuleChecks::Run, overrides.config_from.as_deref(), @@ -5760,6 +5767,7 @@ fn run_rules( let vocabulary = policy::Vocabulary { patterns: &config.patterns, verdicts: &config.verdicts, + recorders: &config.recorders, }; let (selected, checks) = select_rules(&config.rules, only)?; let scan = runner(&selected, &config.provisions, vocabulary, &root, checks)?; diff --git a/crates/batten/src/policy.rs b/crates/batten/src/policy.rs index 509690e05..e6b651436 100644 --- a/crates/batten/src/policy.rs +++ b/crates/batten/src/policy.rs @@ -557,6 +557,15 @@ pub struct Vocabulary<'a> { pub patterns: &'a [crate::pattern::NamedPattern], /// The `[[verdict]]` table (CLOUD-1050). pub verdicts: &'a [crate::verdict::DeclaredVerdict], + /// The `[[recorder]]` table (CLOUD-1051). + /// + /// Here for the reason stated above rather than as a third thing bolted on: + /// it is config the consumer declares, fixed for the life of the load, and + /// read at exactly the call sites that already hold this — the record a + /// module reads is projected from it, so a caller with the patterns always + /// has the recorders too. The alternative was a fifth positional on four + /// public entry points, which is the shape this parameter exists to prevent. + pub recorders: &'a [crate::recorder::Declared], } impl Vocabulary<'_> { @@ -568,6 +577,7 @@ impl Vocabulary<'_> { pub const EMPTY: Vocabulary<'static> = Vocabulary { patterns: &[], verdicts: &[], + recorders: &[], }; } @@ -576,6 +586,7 @@ impl<'a> From<&'a crate::config::Config> for Vocabulary<'a> { Vocabulary { patterns: &config.patterns, verdicts: &config.verdicts, + recorders: &config.recorders, } } } @@ -640,7 +651,11 @@ pub fn load( checks: ModuleChecks, reference: Option<&str>, ) -> Result> { - let Vocabulary { patterns, verdicts } = vocabulary; + let Vocabulary { + patterns, + verdicts, + recorders: _, + } = vocabulary; // The table is validated at PARSE, beside `verbs` and `redirects` and for // their reason (`config.rs`'s `VALIDATED_AT_LOAD` census asserts the call // site exists). Validating again here would be a second authority for one diff --git a/crates/batten/src/rules.rs b/crates/batten/src/rules.rs index 594d9ad76..bdd260345 100644 --- a/crates/batten/src/rules.rs +++ b/crates/batten/src/rules.rs @@ -4409,7 +4409,7 @@ fn run_static_inner( )); } } - run(rules, &[], root, &bundles) + run(rules, &[], root, &bundles, vocabulary) } /// Run only the rules that cannot spawn a process, and report the ones that can @@ -4462,7 +4462,7 @@ pub fn run_recorded( crate::policy::ModuleChecks::Run, None, )?; - let mut scan = run(&evaluable, provisions, root, &bundles)?; + let mut scan = run(&evaluable, provisions, root, &bundles, vocabulary)?; for rule in withheld { // `RuleSkipped`, not a variant of its own. The distinction between "the // input precondition was unmet" and "this surface cannot run the kind" @@ -4525,7 +4525,7 @@ fn run_all_inner( } } let bundles = crate::policy::load(root, rules, vocabulary, checks, None)?; - run(rules, provisions, root, &bundles) + run(rules, provisions, root, &bundles, vocabulary) } /// Run every rule in `rules` against the tree rooted at `root`, returning all @@ -4541,7 +4541,13 @@ fn run( provisions: &[crate::provision::Provision], root: &Path, bundles: &[crate::policy::Bundle], + // The declared recorders, so their records can be projected (CLOUD-1051). + // Config rather than a per-rule column: the fact is what THIS REPOSITORY's + // recorders accumulated, so a second declaration on the rule would be a + // second home for one answer. + vocabulary: crate::policy::Vocabulary<'_>, ) -> anyhow::Result { + let recorders = vocabulary.recorders; let files = tree_files(root)?; // Resolved ONCE for the whole run, before any rule is evaluated (CLOUD-773). // That is the entire point: the shell layer this replaces re-derives because @@ -4608,12 +4614,37 @@ fn run( // happens once here and only when a row declared it. let symbols = symbols_fact(rules, root); + // THE RECORDER RECORDS (CLOUD-1051), and guarded on the declaration for the + // reason `produced` above is: locating the git dir and resolving the branch + // are reads a run whose rules ask for neither must not pay. A ruleset naming + // no `records` fact does exactly what it did before this landed. + // + // ABSENT RATHER THAN EMPTY on every failure. A recorder that never ran and a + // record this could not read are different answers, and the gate downstream + // passes on the second by design — so fabricating an empty list here would + // turn could-not-look into a measured nothing, which is the collapse the + // whole fact model refuses. + // + // The guard is the RECORDER TABLE rather than a per-rule column, because a + // 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(), + } + }; + let inputs = RunInputs { provisions, files: &files, derived: &derived, documents: &documents, produced: &produced, + records: &records, git: &git, symbols: &symbols, bundles, @@ -4724,6 +4755,40 @@ fn dedup_scoped(findings: &mut Vec) { }); } +/// Read each declared recorder's branch-keyed record, skipping any that is not +/// there or cannot be read. +/// +/// A skipped record is ABSENT from the map rather than present and empty, which +/// is the could-not-look channel this whole model keeps open: the gate reading +/// this passes on absence, so a fabricated empty list would be a measured +/// nothing and would silently satisfy a predicate about the record's contents. +/// +/// Keyed by the RECORD, not by the recorder row. Several rows may write one +/// file — that many-to-one is the recorder model's own — so reading per row +/// would open the same file once per row and project the same lines under +/// several names. +fn recorder_records( + git_dir: &std::path::Path, + branch: &str, + recorders: &[crate::recorder::Declared], +) -> BTreeMap> { + let mut found: BTreeMap> = BTreeMap::new(); + for recorder in recorders { + if found.contains_key(&recorder.record) { + continue; + } + let path = crate::recorder::record_path(git_dir, &recorder.record, branch); + let Ok(text) = std::fs::read_to_string(&path) else { + continue; + }; + found.insert( + recorder.record.clone(), + text.lines().map(str::to_owned).collect(), + ); + } + found +} + /// Apply one rule to the pre-collected, sorted `files` list. /// /// Returns `Some(reason)` when the rule **did not evaluate** — which is not the @@ -4742,6 +4807,9 @@ struct RunInputs<'a> { /// What earlier runs produced, for the keys this rule set declares /// (CLOUD-851). One read for the whole run, beside `documents`. produced: &'a BTreeMap, + /// The recorder records this branch accumulated (CLOUD-1051). One read for + /// the whole run, beside `produced`. + records: &'a BTreeMap>, /// The git facts this rule set declared (CLOUD-907). git: &'a crate::git::GitFacts, /// The symbol census, iff this rule set declared it (CLOUD-760). @@ -5601,6 +5669,11 @@ pub(crate) fn tree_document( // Handed in rather than read here for `Fact::Produced`'s whole reason: the // projection is pure, and the read that fills this map is the caller's. produced: &BTreeMap, + // The recorder records this branch accumulated (CLOUD-1051). Handed in for + // `produced`'s reason, and absent rather than empty when the store could not + // be read: a recorder that never ran and one whose file is unreadable are + // different answers, and the gate reading this passes on the second. + records: &BTreeMap>, // The git fact family, acquired once at the boundary and only for what the // ruleset DECLARED (CLOUD-907). Handed in for `produced`'s reason: the // projection is pure, and the reads that fill this are the caller's. @@ -5621,6 +5694,10 @@ pub(crate) fn tree_document( .iter() .map(|(key, record)| (key.clone(), serde_json::json!(record))) .collect(); + let mut recorder_lines: serde_json::Map = records + .iter() + .map(|(name, lines)| (name.clone(), serde_json::json!(lines))) + .collect(); let mut parsed = serde_json::Map::new(); let mut read_lines = serde_json::Map::new(); let mut call_sites = serde_json::Map::new(); @@ -5702,6 +5779,11 @@ pub(crate) fn tree_document( crate::facts::Fact::Produced => { serde_json::Value::Object(std::mem::take(&mut produced_records)) } + // CLOUD-1051. Handed in for `produced`'s reason: the projection is + // pure, and the read that fills this is the caller's. + crate::facts::Fact::Records => { + serde_json::Value::Object(std::mem::take(&mut recorder_lines)) + } // The git family (CLOUD-907). `null` rather than a skip when a // member is `None`, which is the same invariant the mediated // document holds: a key that comes and goes cannot be written @@ -5798,30 +5880,31 @@ pub(crate) fn tree_document( /// rather than resolves. The failures that ARE errors (an unreadable module, an /// undeclared id, a colliding one) were refused at load, where a config fault /// belongs. -/// Takes the whole [`RunInputs`] rather than six of its members, which is what -/// that struct's doc already says it is for. Enumerating them was affordable -/// while the set was small; CLOUD-760's `symbols` made it the seventh and the -/// arity lint the messenger. Passing the group also means the next acquisition -/// row reaches here without touching this signature. +/// The boundary-acquired inputs arrive as one struct rather than as seven +/// positionals, which is what `RunInputs` already exists for. Enumerating them +/// was affordable while the set was small; CLOUD-760's `symbols` made it the +/// seventh and the arity lint the messenger, and passing the group means the +/// next acquisition row reaches here without touching this signature. +/// +/// `tracked` is `inputs.files`: the SUBJECT's path list, and the subject is +/// always the working tree — `--config-from` redirects the policy AUTHORITY +/// (which rules and which module bytes), never what is being judged. So there is +/// no ref branch here and no could-not-look arm for one. fn policy_rule( rule: &Rule, inputs: &RunInputs<'_>, findings: &mut Vec, ) -> Option { - // The run's one tree walk, hoisted in `run` and handed down (CLOUD-845). - // `files` is the SUBJECT's path list, and the subject is always the working - // tree — `--config-from` redirects the policy AUTHORITY (which rules and - // which module bytes), never what is being judged. So there is no ref branch - // here and no could-not-look arm for one. - let tracked = inputs.files; - let RunInputs { + let &RunInputs { + files: tracked, documents, produced, + records, git, symbols, bundles, .. - } = *inputs; + } = inputs; let Some(bundle) = bundles.iter().find(|bundle| bundle.id() == rule.id) else { // The row enabled a bundle the caller did not load. Not a pass: this // surface has nothing to decide with, and reporting clean would be a @@ -5891,6 +5974,7 @@ fn policy_rule( }, tracked, produced, + records, git, symbols, ); @@ -8492,6 +8576,7 @@ mod tests { }, &[], &BTreeMap::new(), + &BTreeMap::new(), &crate::git::GitFacts::default(), &crate::facts::Look::IsNot, ); @@ -9054,6 +9139,7 @@ mod tests { }, &files, &BTreeMap::new(), + &BTreeMap::new(), &crate::git::GitFacts::default(), &crate::facts::Look::IsNot, ); diff --git a/crates/batten/tests/document_read_count.rs b/crates/batten/tests/document_read_count.rs index 35337d96a..b5ccf191c 100644 --- a/crates/batten/tests/document_read_count.rs +++ b/crates/batten/tests/document_read_count.rs @@ -57,6 +57,7 @@ fn fixtures(root: &Path) -> batten::policy::Vocabulary<'static> { batten::policy::Vocabulary { patterns: &[], verdicts: table, + recorders: &[], } } diff --git a/crates/batten/tests/facts.rs b/crates/batten/tests/facts.rs index e859413ff..2464a56eb 100644 --- a/crates/batten/tests/facts.rs +++ b/crates/batten/tests/facts.rs @@ -36,7 +36,7 @@ fn a_content_block_envelope_unwraps_to_the_payload_a_bare_one_carries() { use batten::facts::{ AGENT_SOURCED, BASE_DELTA, BYPASS, Class, Cost, DOCUMENT, Fact, GIT_HEAD, GIT_RANGE, GIT_REF, GIT_REMOTE, GIT_STATUS, INVOCATIONS, KEYS, LANDING, LINES, Look, PRODUCED, PROSPECTIVE, - RECEIPTS, STOP, SYMBOLS, Surface, TRACKED, USES, WAIVED, + RECEIPTS, RECORDS, STOP, SYMBOLS, Surface, TRACKED, USES, WAIVED, }; #[test] @@ -130,6 +130,7 @@ fn every_fact_returns_its_stated_const() { Fact::Uses => USES, Fact::Symbols => SYMBOLS, Fact::BaseDelta => BASE_DELTA, + Fact::Records => RECORDS, } }; @@ -138,7 +139,7 @@ fn every_fact_returns_its_stated_const() { // rather than quietly shrinking the census. assert_eq!( Fact::ALL.len(), - 20, + 21, "the census covers every fact; update this count deliberately when the \ model gains or loses one" ); diff --git a/crates/batten/tests/identity_churn.rs b/crates/batten/tests/identity_churn.rs index ba7fd88c7..22d539f88 100644 --- a/crates/batten/tests/identity_churn.rs +++ b/crates/batten/tests/identity_churn.rs @@ -83,6 +83,7 @@ impl Scan { batten::policy::Vocabulary { patterns: &[], verdicts: &verdicts, + recorders: &[], }, root, ) diff --git a/crates/batten/tests/policy_modules.rs b/crates/batten/tests/policy_modules.rs index 2e17326c1..ee6a642ba 100644 --- a/crates/batten/tests/policy_modules.rs +++ b/crates/batten/tests/policy_modules.rs @@ -87,6 +87,7 @@ fn fixtures(root: &Path) -> policy::Vocabulary<'static> { policy::Vocabulary { patterns: &[], verdicts: table, + recorders: &[], } } @@ -98,6 +99,7 @@ fn fixtures_with( policy::Vocabulary { patterns, verdicts: fixtures(root).verdicts, + recorders: &[], } } diff --git a/crates/batten/tests/policy_test_suite.rs b/crates/batten/tests/policy_test_suite.rs index 73659075d..b3b100f2c 100644 --- a/crates/batten/tests/policy_test_suite.rs +++ b/crates/batten/tests/policy_test_suite.rs @@ -64,6 +64,7 @@ fn fixtures(root: &Path) -> batten::policy::Vocabulary<'static> { batten::policy::Vocabulary { patterns: &[], verdicts: table, + recorders: &[], } } diff --git a/crates/batten/tests/policy_tree.rs b/crates/batten/tests/policy_tree.rs index d1845460b..623be5fb4 100644 --- a/crates/batten/tests/policy_tree.rs +++ b/crates/batten/tests/policy_tree.rs @@ -47,6 +47,7 @@ fn fixtures(root: &Path) -> batten::policy::Vocabulary<'static> { batten::policy::Vocabulary { patterns: &[], verdicts: table, + recorders: &[], } } diff --git a/crates/batten/tests/prose_only.rs b/crates/batten/tests/prose_only.rs index aa1251774..7599e9913 100644 --- a/crates/batten/tests/prose_only.rs +++ b/crates/batten/tests/prose_only.rs @@ -114,6 +114,7 @@ fn findings(root: &Path) -> Vec { batten::policy::Vocabulary { patterns: &[], verdicts: &verdicts, + recorders: &[], }, root, ) @@ -414,6 +415,7 @@ fn the_finding_carries_a_count_and_never_a_path() { batten::policy::Vocabulary { patterns: &[], verdicts: &verdicts, + recorders: &[], }, &root, ) diff --git a/crates/batten/tests/remedy_authorship.rs b/crates/batten/tests/remedy_authorship.rs index ec6ca88ab..2339ed2de 100644 --- a/crates/batten/tests/remedy_authorship.rs +++ b/crates/batten/tests/remedy_authorship.rs @@ -93,6 +93,7 @@ fn scan(root: &Path, rule: Rule) -> rules::Scan { batten::policy::Vocabulary { patterns: &[], verdicts: &verdicts, + recorders: &[], }, root, ) diff --git a/crates/batten/tests/shell_retirement.rs b/crates/batten/tests/shell_retirement.rs index 641150722..0408c55de 100644 --- a/crates/batten/tests/shell_retirement.rs +++ b/crates/batten/tests/shell_retirement.rs @@ -110,6 +110,7 @@ fn scan(root: &Path) -> rules::Scan { batten::policy::Vocabulary { patterns: &[], verdicts: &verdicts, + recorders: &[], }, root, ) diff --git a/crates/batten/tests/verdict_registry.rs b/crates/batten/tests/verdict_registry.rs index 1b60b62c0..6d2744d8b 100644 --- a/crates/batten/tests/verdict_registry.rs +++ b/crates/batten/tests/verdict_registry.rs @@ -66,6 +66,7 @@ fn load( Vocabulary { patterns: &[], verdicts, + recorders: &[], }, policy::ModuleChecks::Run, None, @@ -330,6 +331,7 @@ fn route_findings(name: &str, authority: &str, manifest: &str) -> Vec { Vocabulary { patterns: &[], verdicts: &verdicts, + recorders: &[], }, &root, ) diff --git a/schema/policy-input.schema.json b/schema/policy-input.schema.json index c33b93ec2..1140ce2c3 100644 --- a/schema/policy-input.schema.json +++ b/schema/policy-input.schema.json @@ -218,6 +218,16 @@ "description": "Fact::Produced. Sink key -> the record an earlier run's boundary wrote: a digest and a count for a baseline, the empty string for a marker. Never content -- non-negotiable rule 4 holds at the sink harder than at a report (CLOUD-851).", "type": "object" }, + "records": { + "additionalProperties": { + "items": { + "type": "string" + }, + "type": "array" + }, + "description": "Fact::Records (CLOUD-1051). RECORD name -> the lines accumulated in it on this branch, in write order. Keyed by the record rather than by the recorder row because several rows may write one record. Each line is the recorder's own whitespace-free columns; the projection adds nothing and reads nothing out of them. A record ABSENT from this map could not be read; the collapse into an empty list is what this keeps open.", + "type": "object" + }, "symbols": { "additionalProperties": false, "description": "Fact::Symbols (CLOUD-760). The first Cost::Effect fact: where a delegated analyser resolved a named type, by NAME rather than by spelling. `provenance` records which tool at which version produced it, because a fact whose meaning depends on an unrecorded tool version is not canonical. `sites` is pointer-only -- a path, a line and the lint that fired, never the diagnostic's message or the source it quoted.", From ebab84d6ac05c440f0fbf5a606dd7bb3d714ddfa Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Wed, 26 Aug 2026 05:04:19 +0000 Subject: [PATCH 14/38] feat(policy)!: CLOUD-514's two refusals become a module, and the shell pair retires MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `filed-here-check.sh` and its 47-case suite are deleted. The predicate is `policy/filed-here.rego`, declaring `filed-unrefined` and `filed-over-own-diff` — two predicates rather than one, because a row can earn both and neither subsumes the other. THE TASK NAME SURVIVES, DELIBERATELY. `mise-tasks/land.sh` calls it by name at the fourth stop and `land.sh` is an authored shell rule `shell-retirement` refuses to see edited, so retiring the gate by editing its caller would have meant retiring the landing loop to move one predicate. `[tasks.filed-here-check]` is an inline `batten check --rule filed-here`, the `prose-only-check` precedent, and that call site stays byte-identical. THE PR BODY REACHES THE PREDICATE BY HOOK, NOT BY STDIN. `batten check` is declared `read` and has no stdin channel (house-style §5), so widening it would make a read-effect verb take an argument that changes its verdict. Instead a `[[recorder]]` captures what `gh pr view --jq .body` RETURNED — the fetch, never the create argument, because a body can be edited on the forge and a resumed session lands a PR created before a container reclaim — and the module reads `input.tree.records["pr-closes"]`. `land` re-fetches that body thirty lines above the call, so the record is fresh by construction. `requires-input-matching` IS THE SELECTOR A TOOL NAME CANNOT BE. Every shell call arrives as one tool with one result shape, so `tool` alone selects the whole session. The value is a `[[pattern]]` id and an undeclared one fails the load, for the reason an inline regex in a module does: one concept, one spelling. Reading the INPUT is the safe direction here even though it is the forgeable half elsewhere — it decides only WHETHER to record, and the columns still take their values from what the far end returned. `base-delta` gains `base-date`, the base rev's own commit time as fixed-width ISO-8601 UTC. It belongs to the fact that resolved the base rather than to a new one: the predicate asks whether a record predates the branch, which is a question about this comparison. Fixed width is what makes a lexicographic compare chronological, which is what the shell needed `date -d` to avoid and what Rego has no date type for. BOTH ENVIRONMENT VARIABLES ARE GONE RATHER THAN PORTED. `BATTEN_FILED_HERE_BYPASS` and `BATTEN_FILED_HERE_OVERLAP` were knowable strings anyone could spend without articulating anything; the override is `V-FILED-OVER-OWN-DIFF`'s declared `override` route now, with its precondition, which is what `batten override request` generates its questions from. RETIREMENT LEDGER: 47 cases, 34 carried, 8 subsumed, 5 changed, every one mapped to exactly one successor in `crates/batten/tests/filed_here.rs` or `policy/filed-here.rego`. `--advisory` and `--checklist` had no case of their own — that suite drove the decision mode only — and move with `stop-guard`. `$MUTANT_GATES` loses the name rather than gaining the module's. `mutant` resolves a gate's suite as `tests/.bats` and `shell-retirement` refuses adding one, so the module carries a `#MUTANT-EXEMPT` for the reason `stop-posture` does — and a name that is both declared and exempt is a dead letter the census reports. Two tiers, both green: 16 module cases over the predicate, 20 compiled-binary cases over the engine with a real `origin/main`, a real record on disk and a real delta. `mise run test:cargo` 2516/2516; `mise run policy-test` 151/151. Refs: CLOUD-514, CLOUD-1051 Weakens: program-changed program[closing-keys] Weakens: recorder-added recorder[pr-body-closes] Weakens: verdict-override-added verdict[V-FILED-OVER-OWN-DIFF].override --- batten.toml | 153 ++++++++ crates/batten/src/facts.rs | 6 + crates/batten/src/git.rs | 68 ++++ crates/batten/src/recorder.rs | 240 ++++++++---- crates/batten/src/trust.rs | 1 + crates/batten/tests/filed_here.rs | 603 ++++++++++++++++++++++++++++++ mise-tasks/filed-here-check.sh | 594 ----------------------------- mise.toml | 27 +- policy/filed-here.rego | 382 +++++++++++++++++++ schema/batten.schema.json | 7 + schema/policy-input.schema.json | 6 + tests/filed-here-check.bats | 571 ---------------------------- 12 files changed, 1412 insertions(+), 1246 deletions(-) create mode 100644 crates/batten/tests/filed_here.rs delete mode 100755 mise-tasks/filed-here-check.sh create mode 100644 policy/filed-here.rego delete mode 100644 tests/filed-here-check.bats diff --git a/batten.toml b/batten.toml index 682bf712b..c62c1691a 100644 --- a/batten.toml +++ b/batten.toml @@ -712,6 +712,14 @@ path = "mise-tasks/ready-lint.sh" path = "mise-tasks/board-diff-overlap.sh" args = ["--named"] +# THE CLOSING KEYS A PR BODY NAMES (CLOUD-1051, phase B0). `--list` prints them +# and decides nothing, which is the mode CLOUD-774 added to this file for exactly +# this caller: `$CLOSING_VERBS` has one home, and a second copy of that regex +# would be one value in two files with no gate holding them equal. +[program.closing-keys] +path = "mise-tasks/closing-key-check.sh" +args = ["--list"] + # ─── THE OUTPUT-POSTURE SCRUB AND ITS TELL (CLOUD-97, ported under CLOUD-1051) ─ # # Four scrubbers and one predicate, declared here because an inline regex is @@ -930,6 +938,64 @@ value = { literal = "-" } name = "sec1" 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 +# against the landing diff otherwise fires on every row the branch filed AND THEN +# FIXED — its paths are in the diff by construction, so every honest +# file-then-fix would need the override, and a routinely-overridden gate is +# bypassed rather than satisfied. +# +# The shell read that body on stdin. `batten check` is declared `read` and has no +# stdin channel (house-style §5), and widening it would make a read-effect verb +# take an argument that changes its verdict. So the body arrives the way every +# other agent-sourced fact does: the boundary records what a command the AGENT +# ran returned, and the tree rule reads the record. +# +# THE FETCH, NOT THE CREATE, and the distinction is load-bearing. `land` already +# re-fetches the body before it calls the gate — a body can be edited on the +# forge, and a resumed session lands a PR created before a container reclaim — so +# only the fetched bytes are authoritative. A `gh pr create --body` argument is +# what the caller asked for; this is what the forge has. +# +# `requires-input-matching` IS THE SELECTOR A TOOL NAME CANNOT BE. Every shell +# call arrives as one tool with one result shape, so `tool` alone selects the +# whole session. The pattern narrows it to the one command whose stdout is a raw +# PR body — `--jq .body`, never `--json body` alone, whose stdout is a JSON +# object and would be handed to the key reader as prose. +# A COUNT THAT FOUND SOMETHING, which is the one reading of the overlap column's +# leading field that carries paths worth intersecting. `0` is a measurement that +# found nothing and `-` is could-not-look; both pass, and neither reaches here. +[[pattern]] +id = "positive-count" +regex = '^[1-9][0-9]*$' + +[[pattern]] +id = "gh-pr-body-fetch" +regex = 'gh[[:space:]]+pr[[:space:]]+view\b[^|;&]*--jq[[:space:]]+\.body\b' + +[[recorder]] +name = "pr-body-closes" +record = "pr-closes" +tool = "Bash" +key = "branch" +requires = ["stdout"] +requires-input-matching = { command = "gh-pr-body-fetch" } + +[[recorder.columns]] +name = "kind" +value = { literal = "closes" } + +# `zero-is-a-count` is what keeps the three-valued read alive here. A body that +# closes nothing renders `0` — a measurement — while a fetch whose key reader +# could not run renders `-`, and the gate downstream must treat those +# differently: the first exempts no row, the second is could-not-look. +[[recorder.columns]] +name = "keys" +value = { program = { run = "closing-keys", read = "stdout", stdin = { result = "stdout" } } } +counted-with = ":" +zero-is-a-count = true + # `issue-search-guard`'s decision, as config (CLOUD-312 row 1, CLOUD-505). # # A row is not filed until somebody has looked for the one that already exists. @@ -2754,6 +2820,31 @@ severity = "deny" # suppresses the whole mediated path. A per-rule off-switch for an advisory that # refuses nothing would only make the channel quieter, which is the direction # nobody can detect. +# CLOUD-514's gate, ported off `mise-tasks/filed-here-check.sh` under CLOUD-1051. +# +# `delta_sources = ["**"]` for `prose-only`'s reason: the shell read +# `git diff --name-only origin/main...HEAD`, which is every tracked path, and a +# narrower list would answer the question over a subset — a row naming a file the +# list does not cover would pass, which is a miss in the direction that lets the +# punt through. +# +# NO `line_sources`: this row reads no file's contents. Its whole subject is the +# recorder's record and the delta the engine already resolved. +# +# THE OVERRIDE IS DECLARED ON THE CLASS. `V-FILED-OVER-OWN-DIFF` carries an +# `override` route with its precondition, which is what `batten override request` +# generates its questions from. No `bypass_env`, and specifically not +# `BATTEN_FILED_HERE_OVERLAP`: the point of the admission mechanism is that the +# bare variable stops working, and this is the row that variable served. +[[rule]] +id = "filed-here" +kind = "policy" +scope = "tree" +base = "origin/main" +delta_sources = ["**"] +module = "policy/filed-here.rego" +severity = "deny" + [[rule]] id = "stop-posture" kind = "policy" @@ -3429,6 +3520,68 @@ id = "R-OVERRIDE-PROSE-ONLY" kind = "override" precondition = "the prose IS the deliverable and cannot wait for the next change to these files" +[[verdict]] +id = "V-FILED-UNREFINED" +gloss = "a row this branch created was never groomed to Ready, so filing cost nothing" +class = """ +Filing is the third sink and the most expensive one on purpose: a new row costs \ +a complete Ready block. That price is what stops a defect in this branch's own \ +diff from being cheaper to spin off than to fix. The verdict is the tracker's \ +own response to the create, never a payload the caller assembled — that was \ +measured green three times over text in a local file, once under an id no row \ +carried. +""" + +[[verdict.route]] +id = "R-FIX-IT-HERE" +kind = "command" +target = "close the row you filed and fix it in this diff" + +[[verdict.route]] +id = "R-COMMENT-ON-THE-OWNER" +kind = "issue" +target = "put it in the row that already owns it — comments are recorded and never gated" + +[[verdict.route]] +id = "R-GROOM-TO-READY" +kind = "command" +target = "mise run ready-lint" + +[[verdict]] +id = "V-FILED-OVER-OWN-DIFF" +gloss = "a row this branch filed names code this branch has open" +class = """ +The punt CLOUD-514 is about. A defect you found in your own diff is a defect you \ +are already holding the file for, so spinning it onto the board is cheaper than \ +fixing it — and the board becomes the escape hatch every other guardrail points \ +at. There is deliberately NO PROSE REMEDY: the first refusal charges a Ready \ +block and a Ready block is payable in typing; this one is not. It judges no \ +content and reads no tracker — it intersects the tracked paths the row's body \ +names with this branch's own diff, and exempts a row the PR closes, a row \ +written before the branch existed, and a row whose declared source of truth \ +names none of the diff. +""" + +[[verdict.route]] +id = "R-FIX-IT-HERE" +kind = "command" +target = "close the row you filed and fix it in this diff" + +[[verdict.route]] +id = "R-CLOSE-IT-IN-THE-BODY" +kind = "command" +target = "name it in closing form in the PR body, so the merge lands it" + +[[verdict.route]] +id = "R-FILE-IT-AFTER-LANDING" +kind = "command" +target = "file it from a clean tree, when it is no longer your diff" + +[[verdict.route]] +id = "R-OVERRIDE-FILED-HERE" +kind = "override" +precondition = "the row DOCUMENTS the change being landed, so naming its files is the point rather than a deferral" + [[verdict]] id = "V-SHELL-RULE-EDITED" gloss = "an authored shell rule or bats suite was edited in place rather than migrated" diff --git a/crates/batten/src/facts.rs b/crates/batten/src/facts.rs index d86c72e8c..12f176525 100644 --- a/crates/batten/src/facts.rs +++ b/crates/batten/src/facts.rs @@ -1162,6 +1162,12 @@ impl Fact { // rather than `code_changed` because every other key in this // document is hyphenated. "code-changed": {"type": "array", "items": {"type": "string"}}, + // CLOUD-1051. When the base rev was committed, strict + // ISO-8601 UTC and fixed width, so a consumer orders it + // lexicographically rather than parsing a date. `null` when + // the rev resolves to no commit — the path lists are still + // answered, because they were computable and this was not. + "base-date": {"type": ["string", "null"]}, }, "additionalProperties": false, }), diff --git a/crates/batten/src/git.rs b/crates/batten/src/git.rs index 058a9734e..e49df151b 100644 --- a/crates/batten/src/git.rs +++ b/crates/batten/src/git.rs @@ -2126,6 +2126,25 @@ pub struct BaseDelta { /// does not, where the shell's blanket exclusion had to treat both alike. #[serde(rename = "code-changed")] pub code_changed: Vec, + /// When the base rev was committed, as strict ISO-8601 UTC (CLOUD-1051). + /// + /// **A property of the base, so it belongs to the fact that resolved one.** + /// The predicate that needs it asks whether a record predates the branch — + /// *a deferral of work in the diff you are holding open cannot be a row + /// written before that diff existed* — which is a question about this + /// comparison, not a new fact about the repository. + /// + /// Fixed-width UTC, deliberately, so a consumer compares it lexicographically + /// rather than parsing a date. That is the same reading the shell gate needed + /// `date -d` to avoid, and Rego has no date type to reach for either. + /// + /// `None` when the rev resolves to something with no commit time — a tag + /// object pointing at a tree, say. The three path lists are still answered in + /// that case, because they were computable and this was not; collapsing the + /// whole fact to could-not-look over one unreadable field would be the + /// opposite error. + #[serde(rename = "base-date")] + pub base_date: Option, } /// A file's content with its comment and blank lines removed. @@ -2303,9 +2322,58 @@ pub fn base_delta(dir: &Path, base: &str, globs: &[String]) -> Result (i64, u32, u32) { + let z = days + 719_468; + let era = z.div_euclid(146_097); + let doe = z.rem_euclid(146_097); + let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; + let year = yoe + era * 400; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let day = u32::try_from(doy - (153 * mp + 2) / 5 + 1).unwrap_or(1); + let month = u32::try_from(if mp < 10 { mp + 3 } else { mp - 9 }).unwrap_or(1); + (if month <= 2 { year + 1 } else { year }, month, day) +} + /// The remote's default branch, as a full remote-tracking ref. /// /// The fallback landing target when a consumer declares no `must_land_on` diff --git a/crates/batten/src/recorder.rs b/crates/batten/src/recorder.rs index 6bf25c1fc..ac798bd30 100644 --- a/crates/batten/src/recorder.rs +++ b/crates/batten/src/recorder.rs @@ -116,6 +116,31 @@ pub struct Declared { /// language. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub refused_when_input: Vec, + /// Input paths whose value must MATCH a declared pattern, by pattern id. + /// + /// **The selector a tool name cannot express.** [`Declared::tool`] narrows to + /// a tool, which is enough while the tool's identity is the whole question — + /// a board write is any `save_issue` at all. It is not enough for a general + /// runner: every shell call arrives as one tool, so a row wanting *the + /// command that fetched the pull request body* has a tool name that matches + /// every command in the session and a result shape identical for all of them. + /// + /// The value is a `[[pattern]]` id rather than an inline regex, for the + /// reason `.claude/rules/policy-modules.md` gives for a module: one concept, + /// one spelling, refused at load rather than duplicated at leisure. An + /// undeclared id fails the load and says which recorder named it. + /// + /// Reads the INPUT, never the result, and that is the safe direction here + /// even though [`Value::Input`] is the forgeable half elsewhere: this decides + /// only WHETHER to record, and the columns still take their values from what + /// the far end returned. A caller who spoofs the command string buys a record + /// of a call whose own result is what gets written down. + /// + /// A path that does not resolve to a scalar refuses the row, on the + /// could-not-look rule the rest of this module keeps: a selector that cannot + /// read its subject has not matched it. + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub requires_input_matching: BTreeMap, /// Write only when the record ALREADY carries a line whose column /// `column` equals this expression's value. /// @@ -309,21 +334,41 @@ pub struct Program { pub args: Vec, } -/// Whether every required result path resolved and no refusing input path did. +/// Whether every required result path resolved, no refusing input path did, and +/// every input path the row matches on carries a value its pattern accepts. +/// +/// Takes the whole [`Context`] rather than two values because the third question +/// needs the pattern table, and threading one more parameter through every caller +/// would put the same three things in two orders. #[must_use] -pub fn satisfied( - declared: &Declared, - result: &serde_json::Value, - input: &serde_json::Value, -) -> bool { +pub fn satisfied(declared: &Declared, context: &Context<'_>) -> bool { declared .requires .iter() - .all(|path| scalar(result, path).is_some()) + .all(|path| scalar(context.result, path).is_some()) && declared .refused_when_input .iter() - .all(|path| scalar(input, path).is_none()) + .all(|path| scalar(context.input, path).is_none()) + && declared + .requires_input_matching + .iter() + .all(|(path, pattern)| { + // BOTH HALVES ARE COULD-NOT-LOOK, and could-not-look does not + // match. An unresolvable path and a pattern id the table does not + // carry are each a selector that failed to read its subject, and + // recording anyway would file a row for a call nobody selected. + // The pattern id is refused at LOAD, so the second arm is + // unreachable for a config that loaded and is written for the one + // that reached here another way. + let Some(text) = scalar(context.input, path) else { + return false; + }; + context + .patterns + .get(pattern) + .is_some_and(|regex| regex.is_match(&text)) + }) } /// The single scalar a dotted path selects, as a bare string. @@ -578,7 +623,7 @@ pub fn render_column(column: &Column, context: &Context<'_>) -> String { /// The whole record line this result earns, or `None` where it earns none. #[must_use] pub fn render(declared: &Declared, context: &Context<'_>) -> Option { - if !satisfied(declared, context.result, context.input) { + if !satisfied(declared, context) { return None; } Some( @@ -610,78 +655,9 @@ pub fn validate( ) -> Result<()> { let mut seen = std::collections::BTreeSet::new(); for recorder in recorders { - if recorder.name.trim().is_empty() { - return Err(crate::error::UsageError::raise( - "a `[[recorder]]` row carries an empty `name`, so nothing can point at it", - )); - } - if recorder.record.trim().is_empty() { - return Err(crate::error::UsageError::raise(format!( - "recorder {:?} names no `record`, so it would write nowhere", - recorder.name - ))); - } - if !seen.insert(recorder.name.clone()) { - return Err(crate::error::UsageError::raise(format!( - "two `[[recorder]]` rows are named {:?}, so a finding could not say \ - which one wrote a line", - recorder.name - ))); - } - if recorder.tool.trim().is_empty() { - return Err(crate::error::UsageError::raise(format!( - "recorder {:?} selects on an empty `tool`, which matches nothing", - recorder.name - ))); - } - // A SELECTOR IS NOT A GLOB, and a row that thinks it is fails SILENTLY. - // - // `rules::selects_tool_name` matches the whole name or its final - // `__`-delimited segment (CLOUD-178, so a connector renamed over its - // lifetime still matches). A `*save_issue` written by habit from a shell - // `case` pattern therefore matches nothing at all — and a recorder that - // never fires is byte-identical, on every surface, to a tool nobody - // called. Measured here: the whole table was dead and only the cases - // asserting a row WAS written could see it, because the ones asserting - // none passed vacuously. - // - // Refused at load for that asymmetry: the failure has no loud direction - // at runtime, so the only place it can be caught is before the run. - if let Some(found) = recorder - .tool - .find(['*', '?', '[']) - .and_then(|at| recorder.tool.get(at..=at)) - { - return Err(crate::error::UsageError::raise(format!( - "recorder {:?} selects on tool {:?}, which carries {found:?} — a tool \ - selector is matched whole or by its final `__`-delimited segment, \ - never as a glob, so this row would match nothing and record silently", - recorder.name, recorder.tool - ))); - } - if recorder.columns.is_empty() { - return Err(crate::error::UsageError::raise(format!( - "recorder {:?} declares no columns, so it would append blank lines", - recorder.name - ))); - } + validate_shape(recorder, &mut seen, patterns)?; if let Some(recorded) = &recorder.requires_recorded { - if recorded.matches.is_empty() { - return Err(crate::error::UsageError::raise(format!( - "recorder {:?} gates on an empty `requires-recorded`, which every \ - existing line satisfies — a precondition matching anything is not one", - recorder.name - ))); - } - for (column, value) in &recorded.matches { - if *column >= recorder.columns.len() { - return Err(crate::error::UsageError::raise(format!( - "recorder {:?} gates on column {column} of an existing line, but \ - it declares only {} column(s) — the comparison could never hold", - recorder.name, - recorder.columns.len() - ))); - } + for value in recorded.matches.values() { validate_value( &recorder.name, "requires-recorded", @@ -707,6 +683,110 @@ pub fn validate( Ok(()) } +/// The shape refusals, split out of [`validate`] when it hit its own line +/// ceiling. The seam is the one the function already had: everything here is a +/// property of the ROW — its name, its selectors, its column count — and +/// everything left behind walks the expressions inside a column. +fn validate_shape( + recorder: &Declared, + seen: &mut std::collections::BTreeSet, + patterns: &std::collections::BTreeSet, +) -> Result<()> { + if recorder.name.trim().is_empty() { + return Err(crate::error::UsageError::raise( + "a `[[recorder]]` row carries an empty `name`, so nothing can point at it", + )); + } + if recorder.record.trim().is_empty() { + return Err(crate::error::UsageError::raise(format!( + "recorder {:?} names no `record`, so it would write nowhere", + recorder.name + ))); + } + if !seen.insert(recorder.name.clone()) { + return Err(crate::error::UsageError::raise(format!( + "two `[[recorder]]` rows are named {:?}, so a finding could not say \ + which one wrote a line", + recorder.name + ))); + } + if recorder.tool.trim().is_empty() { + return Err(crate::error::UsageError::raise(format!( + "recorder {:?} selects on an empty `tool`, which matches nothing", + recorder.name + ))); + } + // A SELECTOR IS NOT A GLOB, and a row that thinks it is fails SILENTLY. + // + // `rules::selects_tool_name` matches the whole name or its final + // `__`-delimited segment (CLOUD-178, so a connector renamed over its + // lifetime still matches). A `*save_issue` written by habit from a shell + // `case` pattern therefore matches nothing at all — and a recorder that + // never fires is byte-identical, on every surface, to a tool nobody + // called. Measured here: the whole table was dead and only the cases + // asserting a row WAS written could see it, because the ones asserting + // none passed vacuously. + // + // Refused at load for that asymmetry: the failure has no loud direction + // at runtime, so the only place it can be caught is before the run. + if let Some(found) = recorder + .tool + .find(['*', '?', '[']) + .and_then(|at| recorder.tool.get(at..=at)) + { + return Err(crate::error::UsageError::raise(format!( + "recorder {:?} selects on tool {:?}, which carries {found:?} — a tool \ + selector is matched whole or by its final `__`-delimited segment, \ + never as a glob, so this row would match nothing and record silently", + recorder.name, recorder.tool + ))); + } + for (path, pattern) in &recorder.requires_input_matching { + if path.trim().is_empty() { + return Err(crate::error::UsageError::raise(format!( + "recorder {:?} matches on an empty input path, which reads nothing \ + and so never selects", + recorder.name + ))); + } + if !patterns.contains(pattern) { + return Err(crate::error::UsageError::raise(format!( + "recorder {:?} selects on input {path:?} matching pattern {pattern:?}, \ + which no `[[pattern]]` row declares — the row would never fire and \ + a recorder that never fires is indistinguishable from a tool nobody \ + called", + recorder.name + ))); + } + } + if recorder.columns.is_empty() { + return Err(crate::error::UsageError::raise(format!( + "recorder {:?} declares no columns, so it would append blank lines", + recorder.name + ))); + } + if let Some(recorded) = &recorder.requires_recorded { + if recorded.matches.is_empty() { + return Err(crate::error::UsageError::raise(format!( + "recorder {:?} gates on an empty `requires-recorded`, which every \ + existing line satisfies — a precondition matching anything is not one", + recorder.name + ))); + } + for column in recorded.matches.keys() { + if *column >= recorder.columns.len() { + return Err(crate::error::UsageError::raise(format!( + "recorder {:?} gates on column {column} of an existing line, but \ + it declares only {} column(s) — the comparison could never hold", + recorder.name, + recorder.columns.len() + ))); + } + } + } + Ok(()) +} + /// Every id one expression names, checked against what the config declares. fn validate_value( recorder: &str, diff --git a/crates/batten/src/trust.rs b/crates/batten/src/trust.rs index faf4efcad..c16b1e2a1 100644 --- a/crates/batten/src/trust.rs +++ b/crates/batten/src/trust.rs @@ -3525,6 +3525,7 @@ mod tests { key: crate::recorder::RecordKey::Branch, requires: vec!["id".to_owned()], refused_when_input: Vec::new(), + requires_input_matching: std::collections::BTreeMap::new(), columns: vec![crate::recorder::Column { name: "verdict".to_owned(), value: crate::recorder::Value::Program { diff --git a/crates/batten/tests/filed_here.rs b/crates/batten/tests/filed_here.rs new file mode 100644 index 000000000..36882cc7f --- /dev/null +++ b/crates/batten/tests/filed_here.rs @@ -0,0 +1,603 @@ +//! `filed-here`, over the engine that builds its input (CLOUD-1051). +//! +//! # The tier this is, and why the retired suite could not be it +//! +//! `tests/filed-here-check.bats` drove a shell program that read the record and +//! shelled out for the diff itself. This drives `rules::run_static` over a real +//! fixture repository with a real `origin/main` and a real record on disk, so it +//! proves the ENGINE builds the shape the predicate reads — the seam +//! `.claude/rules/policy-modules.md` names as the one a `with input as` case +//! cannot reach. The module's own `test_` rules are the other tier and pin the +//! predicate; neither replaces the other. +//! +//! # RETIREMENT LEDGER — `tests/filed-here-check.bats`, 47 cases +//! +//! CARRIED — the property survives, proved here or in the module's own suite. +//! +// carried: "a create recorded with an unready verdict stops the lap, and the refusal names the id" crates/batten/tests/filed_here.rs +// carried: "the same row passes once its recorded verdict is green" policy/filed-here.rego +// carried: "a groom recorded after the create supersedes it" policy/filed-here.rego +// carried: "a later unready supersedes an earlier ready" policy/filed-here.rego +// carried: "superseding is per id: one row groomed leaves another's refusal standing" crates/batten/tests/filed_here.rs +// carried: "a recorded comment is never gated, whatever its verdict column says" policy/filed-here.rego +// carried: "a create the recorder could not lint is not a refusal" policy/filed-here.rego +// carried: "one unrefined row among refined ones is reported, and only that one" crates/batten/tests/filed_here.rs +// carried: "a branch that filed nothing passes untouched" crates/batten/tests/filed_here.rs +// carried: "an empty record passes" crates/batten/tests/filed_here.rs +// carried: "a record belonging to another branch is not read" crates/batten/tests/filed_here.rs +// carried: "a branch name with a slash finds its record, matching the recorder's spelling" crates/batten/tests/filed_here.rs +// carried: "the refusal carries the id and no prose from the row" crates/batten/tests/filed_here.rs +// carried: "a malformed line is skipped rather than judged" policy/filed-here.rego +// carried: "a row naming a file this branch is changing stops the lap" crates/batten/tests/filed_here.rs +// carried: "a row naming only untouched files passes" policy/filed-here.rego +// carried: "a row the recorder could not measure passes" policy/filed-here.rego +// carried: "a four-field line predating the column is not refused" crates/batten/tests/filed_here.rs +// carried: "A ROW RECORDED BEFORE THIS BRANCH'S BASE IS NOT A PUNT OVER ITS DIFF" crates/batten/tests/filed_here.rs +// carried: "a row recorded after the base, whose §1 names the diff, still refuses" crates/batten/tests/filed_here.rs +// carried: "A PATH CITED AS EVIDENCE IS NOT A CLAIM ON IT — §1 decides the subject" policy/filed-here.rego +// carried: "a row whose §1 names the diff is refused even when it cites other paths too" policy/filed-here.rego +// carried: "a six-field record with no §1 column is judged exactly as before" crates/batten/tests/filed_here.rs +// carried: "every overlapping path is named, one pointer per line" crates/batten/tests/filed_here.rs +// carried: "a row that is both unrefined and over the diff reports both" policy/filed-here.rego +// carried: "a later reading with no overlap supersedes an earlier one" policy/filed-here.rego +// carried: "and a later reading WITH an overlap supersedes a clean one" policy/filed-here.rego +// carried: "a comment is never gated on the diff either" policy/filed-here.rego +// carried: "A ROW RECORDED BEFORE THE FILE WAS TOUCHED IS STILL CAUGHT" crates/batten/tests/filed_here.rs +// carried: "a recorded path the branch does not change is not reported" crates/batten/tests/filed_here.rs +// carried: "a row naming only files this branch leaves alone passes" policy/filed-here.rego +// carried: "A ROW THE PR CLOSES IS EXEMPT — filing then fixing is the point, not the punt" crates/batten/tests/filed_here.rs +// carried: "closing a different row does not exempt this one" crates/batten/tests/filed_here.rs +// carried: "the diff refusal carries the id and one path and nothing else" crates/batten/tests/filed_here.rs policy/filed-here.rego +//! +//! SUBSUMED — the plumbing became the engine's, which is what a migration should +//! produce. Each names the general property that now covers it. +//! +// subsumed: "an AMBIENT bypass does not silence the gate — setup owns the environment" crates/batten/src/rules.rs +// subsumed: "a re-lint of one row does not inflate the filed count" policy/filed-here.rego +// subsumed: "outside a git repository the gate fails open rather than stopping every lap" crates/batten/src/rules.rs +// subsumed: "a detached HEAD has no branch to key on, and fails open" crates/batten/src/rules.rs +// subsumed: "the pass line counts creates and comments separately" crates/batten/src/outputs.rs +// subsumed: "the refusal names the three sinks and the local check" crates/batten/src/verdict.rs +// subsumed: "the diff refusal names four remedies and none of them is writing more prose" crates/batten/src/verdict.rs +// subsumed: "a body that only refs the row does not exempt it" mise-tasks/closing-key-check.sh +//! +//! CHANGED — behaviour that diverges deliberately, each with its reason. +//! +// changed: "filed-here-check::the bypass is honoured" crates/batten/src/rules.rs +// changed: "the override lets the diff refusal through" crates/batten/tests/admission.rs +// changed: "the override records which rows it overrode" crates/batten/tests/admission.rs +// changed: "the override does not excuse an unrefined row" crates/batten/tests/admission.rs +// changed: "the override records nothing when there was nothing to override" crates/batten/tests/admission.rs +//! +//! `BATTEN_FILED_HERE_BYPASS` and `BATTEN_FILED_HERE_OVERLAP` are **gone rather +//! than ported**, which is the whole of CLOUD-1051's first half: a knowable +//! environment variable is an override anyone can spend without articulating +//! anything, and the four `override` cases above are now the admission +//! mechanism's — requested, answered, content-addressed, and spent exactly once. +//! The engine's own `BATTEN_HOOK_BYPASS` never reached this gate and still does +//! not; `batten check` is a tree verb. +//! +//! The three-remedy and four-remedy prose is `[[verdict]]`'s now, once, where a +//! gate can read it — which is what makes a refusal naming a task that does not +//! exist a load error rather than a sentence nobody checked (CLOUD-1050). +//! +//! # Two rows this file does NOT carry, stated rather than left to inference +//! +//! `--advisory` and `--checklist` were `stop-guard`'s two callers and had no +//! case of their own in the retired suite — that suite drove the decision mode +//! only. They move with `stop-guard` rather than here, over this same module, so +//! there is still exactly one implementation of the intersection. + +#![allow(clippy::unwrap_used, clippy::expect_used)] + +mod 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 and whose record is on +/// disk exactly where the recorder would have written it. +/// +/// `origin/main` is a local ref pointed at the base commit. No remote is +/// configured: `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, branch: &str, changed: &[&str], record: &[&str], closes: &[&str]) -> PathBuf { + let root = common::scratch(name); + common::git_in(&root, &["init", "--quiet", "--initial-branch", branch]); + common::git_in(&root, &["config", "user.email", "t@example.com"]); + common::git_in(&root, &["config", "user.name", "t"]); + // The base commit, dated fixed and in the past so a record's timestamp can be + // placed on either side of it deliberately rather than by racing the clock. + fs::write(root.join("seed.txt"), "seed\n").expect("seed"); + common::git_in(&root, &["add", "-A"]); + commit(&root, "base", BASE_DATE); + 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); + write_record(&root, branch, "board-writes", record); + if !closes.is_empty() { + write_record(&root, branch, "pr-closes", closes); + } + root +} + +/// The base commit's own timestamp, fixed so the timestamp arm is testable. +const BASE_DATE: &str = "2026-02-01T00:00:00+0000"; +/// A record written after the base — the punt window. +const AFTER: &str = "2026-02-02T00:00:00Z"; +/// A record written before it — a row that cannot be a deferral of this diff. +const BEFORE: &str = "2026-01-01T00:00:00Z"; + +/// A commit whose COMMITTER date is fixed. +/// +/// `--date` sets the AUTHOR date and `base_delta` reads the committer one, so +/// the environment variable is the half that matters here; both are set so the +/// fixture carries one timestamp rather than two. +fn commit(root: &Path, message: &str, date: &str) { + let output = common::git_command(root, &["commit", "--quiet", "-m", message, "--date", date]) + .env("GIT_COMMITTER_DATE", date) + .output() + .expect("run git"); + assert!(output.status.success(), "commit the fixture base"); +} + +fn write_record(root: &Path, branch: &str, record: &str, lines: &[&str]) { + let dir = root.join(".git/batten-receipts"); + fs::create_dir_all(&dir).expect("receipts dir"); + let path = dir.join(format!("{record}.{}", branch.replace('/', "-"))); + fs::write(path, format!("{}\n", lines.join("\n"))).expect("write record"); +} + +fn install_module(root: &Path) { + let source = common::at_root("policy/filed-here.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/filed-here.rego")).expect("install committed module"); +} + +fn row() -> Rule { + serde_json::from_value(serde_json::json!({ + "id": "filed-here", + "kind": "policy", + "scope": "tree", + "base": "origin/main", + "delta_sources": ["**"], + "module": "policy/filed-here.rego", + "severity": "deny", + })) + .expect("the loader accepts the committed row's shape") +} + +/// The recorder rows the engine needs in order to KNOW which records to read. +/// +/// Minimal on purpose: `recorder_records` reads `record` and nothing else, so a +/// full transcription of `batten.toml`'s columns here would be a second copy of +/// a declaration this test does not exercise. +fn recorders() -> Vec { + ["board-writes", "pr-closes"] + .into_iter() + .map(|record| batten::recorder::Declared { + name: record.to_owned(), + record: record.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, + }], + }) + .collect() +} + +fn scan(root: &Path) -> rules::Scan { + let verdicts = common::verdicts_in(root); + let patterns = vec![batten::pattern::NamedPattern { + id: "positive-count".to_owned(), + regex: "^[1-9][0-9]*$".to_owned(), + }]; + let declared = recorders(); + rules::run_static( + &[row()], + &[], + batten::policy::Vocabulary { + patterns: &patterns, + verdicts: &verdicts, + recorders: &declared, + }, + root, + ) + .expect("the read surface runs a policy row") +} + +/// Every finding's predicate id, in the order the engine emitted them. +fn verdicts(root: &Path) -> Vec { + scan(root) + .findings + .into_iter() + .map(|finding| finding.rule) + .collect() +} + +/// Every finding's pointer — the path the first path-bearing subject named. +fn pointers(root: &Path) -> Vec { + scan(root) + .findings + .into_iter() + .map(|finding| finding.path) + .collect() +} + +const UNREFINED: &str = "filed-unrefined"; +const OVER_DIFF: &str = "filed-over-own-diff"; + +// --------------------------------------------------------------------------- +// The pass side first: without it every refusal below is satisfied by a module +// that refuses everything. +// --------------------------------------------------------------------------- + +#[test] +fn a_branch_that_filed_nothing_passes_untouched() { + let root = repo("filed-nothing", "work", &["src/a.rs"], &[], &[]); + fs::remove_file(root.join(".git/batten-receipts/board-writes.work")).expect("no record"); + assert!(verdicts(&root).is_empty(), "no record is no answer"); +} + +#[test] +fn an_empty_record_passes() { + let root = repo("empty-record", "work", &["src/a.rs"], &[""], &[]); + assert!(verdicts(&root).is_empty(), "an empty record judges nothing"); +} + +#[test] +fn a_record_belonging_to_another_branch_is_not_read() { + let root = repo("other-branch", "work", &["src/a.rs"], &[], &[]); + write_record( + &root, + "somebody-else", + "board-writes", + &[&format!("issue CLOUD-1 {AFTER} unready - - -")], + ); + assert!( + verdicts(&root).is_empty(), + "the record is keyed by branch, and this branch has none" + ); +} + +/// The recorder replaces `/` with `-` in the filename; a reader spelling it any +/// other way opens a file the recorder never wrote and passes everything. +#[test] +fn a_branch_name_with_a_slash_finds_its_record() { + let root = repo( + "slashed", + "feat/thing", + &["src/a.rs"], + &[&format!("issue CLOUD-1 {AFTER} unready - - -")], + &[], + ); + assert_eq!(verdicts(&root), vec![UNREFINED.to_owned()]); +} + +// --------------------------------------------------------------------------- +// `filed-unrefined`. +// --------------------------------------------------------------------------- + +#[test] +fn an_unready_create_stops_the_lap() { + let root = repo( + "unready", + "work", + &["src/a.rs"], + &[&format!("issue CLOUD-1 {AFTER} unready - - -")], + &[], + ); + assert_eq!(verdicts(&root), vec![UNREFINED.to_owned()]); +} + +#[test] +fn one_unrefined_row_among_refined_ones_is_reported_and_only_that_one() { + let root = repo( + "one-of-many", + "work", + &["src/a.rs"], + &[ + &format!("issue CLOUD-1 {AFTER} ready - - -"), + &format!("issue CLOUD-2 {AFTER} unready - - -"), + &format!("issue CLOUD-3 {AFTER} ready - - -"), + ], + &[], + ); + assert_eq!(verdicts(&root), vec![UNREFINED.to_owned()]); + assert!( + pointers(&root).iter().any(|line| line.contains("CLOUD-2")), + "the refusal names the row: {:?}", + pointers(&root) + ); +} + +/// Superseding is PER ID, which is what a single accumulator got wrong: grooming +/// one row must not clear another's refusal. +#[test] +fn superseding_is_per_id() { + let root = repo( + "per-id", + "work", + &["src/a.rs"], + &[ + &format!("issue CLOUD-1 {AFTER} unready - - -"), + &format!("issue CLOUD-2 {AFTER} unready - - -"), + &format!("issue CLOUD-1 {AFTER} ready - - -"), + ], + &[], + ); + assert_eq!(verdicts(&root), vec![UNREFINED.to_owned()]); + assert!( + pointers(&root).iter().any(|line| line.contains("CLOUD-2")), + "the ungroomed row's refusal stands: {:?}", + pointers(&root) + ); +} + +/// POINTER, NEVER PAYLOAD (rule 4). The recorder never wrote a title or a body, +/// so there is none here to leak — and this is the assertion that keeps a later +/// edit from putting one in. +#[test] +fn the_refusal_carries_the_id_and_no_prose_from_the_row() { + let root = repo( + "pointer-only", + "work", + &["src/a.rs"], + &[&format!("issue CLOUD-1 {AFTER} unready - - -")], + &[], + ); + let rendered = pointers(&root).join("\n"); + assert!(rendered.contains("CLOUD-1"), "the id is the pointer"); + assert!( + !rendered.contains("unready"), + "a column value is not a pointer: {rendered}" + ); +} + +// --------------------------------------------------------------------------- +// `filed-over-own-diff`. +// --------------------------------------------------------------------------- + +#[test] +fn a_row_naming_a_file_this_branch_is_changing_stops_the_lap() { + let root = repo( + "over-diff", + "work", + &["src/a.rs"], + &[&format!( + "issue CLOUD-1 {AFTER} ready 1,src/a.rs - 1,src/a.rs" + )], + &["closes 0"], + ); + assert_eq!(verdicts(&root), vec![OVER_DIFF.to_owned()]); +} + +#[test] +fn a_recorded_path_the_branch_does_not_change_is_not_reported() { + let root = repo( + "elsewhere", + "work", + &["src/a.rs"], + &[&format!( + "issue CLOUD-1 {AFTER} ready 1,src/z.rs - 1,src/z.rs" + )], + &["closes 0"], + ); + assert!( + verdicts(&root).is_empty(), + "a path outside the diff is not a punt against it" + ); +} + +/// ONE POINTER PER PATH, as the shell emitted, so a reviewer sees which file +/// rather than a count they have to go and reconstruct. +#[test] +fn every_overlapping_path_is_named() { + let root = repo( + "many-paths", + "work", + &["src/a.rs", "src/b.rs"], + &[&format!( + "issue CLOUD-1 {AFTER} ready 2,src/a.rs,src/b.rs - 2,src/a.rs,src/b.rs" + )], + &["closes 0"], + ); + assert_eq!( + verdicts(&root), + vec![OVER_DIFF.to_owned(), OVER_DIFF.to_owned()] + ); +} + +/// THE PATH IS THE FINDING'S OWN POINTER, which is the ordering statement the +/// module makes: `subjects` leads with the tracked path a reader should open, +/// and the row's id follows it. `first_pointer` takes the first PATH-BEARING +/// subject, so the id travels as an ordered subject rather than as the pointer — +/// and both reach a reader, which is what the module's own suite asserts on the +/// subject list. +#[test] +fn the_diff_refusal_points_at_the_tracked_path() { + let root = repo( + "diff-pointer", + "work", + &["src/a.rs"], + &[&format!( + "issue CLOUD-1 {AFTER} ready 1,src/a.rs - 1,src/a.rs" + )], + &["closes 0"], + ); + assert_eq!(pointers(&root), vec!["src/a.rs".to_owned()]); +} + +/// THE EXEMPTION THAT KEEPS THE GATE FROM INVERTING. Without it, every honest +/// file-then-fix needs the override, and a routinely overridden gate is bypassed +/// rather than satisfied. +#[test] +fn a_row_the_pr_closes_is_exempt() { + let root = repo( + "closed", + "work", + &["src/a.rs"], + &[&format!( + "issue CLOUD-1 {AFTER} ready 1,src/a.rs - 1,src/a.rs" + )], + &["closes 1:CLOUD-1"], + ); + assert!( + verdicts(&root).is_empty(), + "filing then fixing is the work landing" + ); +} + +#[test] +fn closing_a_different_row_does_not_exempt_this_one() { + let root = repo( + "closed-other", + "work", + &["src/a.rs"], + &[&format!( + "issue CLOUD-1 {AFTER} ready 1,src/a.rs - 1,src/a.rs" + )], + &["closes 1:CLOUD-9"], + ); + assert_eq!(verdicts(&root), vec![OVER_DIFF.to_owned()]); +} + +/// A punt is a deferral of work in the diff you are holding open; a row recorded +/// before the branch's base cannot be one, by construction. Measured: 3 of 3 +/// refusals on one PR were rows already In Review, landed before the branch was +/// cut, and the override had to be spent on all three. +#[test] +fn a_row_recorded_before_the_branch_s_base_is_not_a_punt() { + let root = repo( + "predates", + "work", + &["src/a.rs"], + &[&format!( + "issue CLOUD-1 {BEFORE} ready 1,src/a.rs - 1,src/a.rs" + )], + &["closes 0"], + ); + assert!( + verdicts(&root).is_empty(), + "the diff did not exist when the row was written" + ); +} + +#[test] +fn a_row_recorded_after_the_base_whose_sec1_names_the_diff_still_refuses() { + let root = repo( + "after-base", + "work", + &["src/a.rs"], + &[&format!( + "issue CLOUD-1 {AFTER} ready 1,src/a.rs - 1,src/a.rs" + )], + &["closes 0"], + ); + assert_eq!(verdicts(&root), vec![OVER_DIFF.to_owned()]); +} + +/// A ROW RECORDED BEFORE THE FILE WAS TOUCHED IS STILL CAUGHT, which is the +/// whole reason the intersection moved to read time. Rows are filed BEFORE any +/// file is touched — AGENTS.md says claim before writing code — so a fact frozen +/// at write time recorded zero every time and this refusal could never see the +/// punt it exists for. +#[test] +fn a_row_recorded_before_the_file_was_touched_is_still_caught() { + let root = repo( + "before-touch", + "work", + &[], + &[&format!( + "issue CLOUD-1 {AFTER} ready 1,src/a.rs - 1,src/a.rs" + )], + &["closes 0"], + ); + assert!( + verdicts(&root).is_empty(), + "nothing is in the diff yet, so nothing intersects" + ); + 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" + ); +} + +// --------------------------------------------------------------------------- +// A RECORD FROM AN OLDER RECORDER. A branch is never refused for a question its +// recorder could not ask, and never silently exempted either. +// --------------------------------------------------------------------------- + +#[test] +fn a_four_field_line_predating_the_overlap_column_is_not_refused() { + let root = repo( + "four-field", + "work", + &["src/a.rs"], + &[&format!("issue CLOUD-1 {AFTER} ready")], + &["closes 0"], + ); + assert!( + verdicts(&root).is_empty(), + "no overlap column is could-not-look, not a measured zero" + ); +} + +#[test] +fn a_six_field_record_with_no_sec1_column_is_judged_exactly_as_before() { + let root = repo( + "six-field", + "work", + &["src/a.rs"], + &[&format!("issue CLOUD-1 {AFTER} ready 1,src/a.rs -")], + &["closes 0"], + ); + assert_eq!( + verdicts(&root), + vec![OVER_DIFF.to_owned()], + "an absent §1 leaves the row judged as it was before that column existed" + ); +} + +/// 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 == "filed-here") + .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/filed-here.rego")); +} diff --git a/mise-tasks/filed-here-check.sh b/mise-tasks/filed-here-check.sh deleted file mode 100755 index 9a619ef71..000000000 --- a/mise-tasks/filed-here-check.sh +++ /dev/null @@ -1,594 +0,0 @@ -#!/usr/bin/env bash -#MISE description="Gate: every row this branch put on the board was refined at creation (reads the recorder's own file; pointer-only)" -# -# CLOUD-514, phase 2 — the gate `board-write-record` was shipped to make -# specifiable. That file is the sensor and gates nothing; this is the half that -# prices the punt, and until it existed the issue was a log with no gate, which -# non-negotiable 2 calls sensor only. -# -# THE ARITHMETIC IT REVERSES. Every other gate here prices FAILING to record -# something: `finding-sink-check` fails a turn that cites evidence and writes -# nothing, `deferral-check` fails a PR that defers without naming an issue. -# Filing satisfies all of them in seconds while finishing costs a diff, a suite -# and a landing — so spinning a defect in the branch's own diff out onto the -# board is cheaper than fixing it, and the board becomes the escape hatch every -# guardrail points at. Making the new row cost a complete Ready block is what -# flips that, without anything judging whether a given spin-off was lazy. -# -# TWO REFUSALS NOW, AND THE SECOND IS THE ONE THE ISSUE'S ACCEPTANCE ASKED FOR. -# -# `filed-unrefined` (above) prices REFINEMENT. Its bound was stated honestly in -# this header from the day it shipped — "it does not compare the row to the diff" -# — and that bound turned out to be the whole gap. A Ready block is prose, and -# prose is the one currency an agent has without limit: measured 2026-08-20, four -# rows filed in three and a half minutes, twelve more spent writing four §1–§8 -# blocks, and the recorder stored `ready` for every one. The toll did not reverse -# the arithmetic. It certified the punts. -# -# `filed-over-own-diff` prices PROXIMITY instead, which is what CLOUD-514's -# acceptance is about: "the cheapest path through the gate for a defect in the -# branch's own diff is to fix it." A row spun out of code you have open is a -# different object from a row about work elsewhere, and only the first is a punt. -# The predicate is a set intersection over two file lists — the paths the body -# names, the paths `origin/main...HEAD` changes — computed by the recorder via -# `mise-tasks/board-diff-overlap.sh`, which owns it and its measurement. -# -# THE LOAD-BEARING DIFFERENCE FROM THE FIRST REFUSAL: there is no prose remedy. -# The ways out are fix it here, stop touching that file, file it after landing -# from a clean tree, or take a recorded override a human can see. None of those -# is payable in typing, which is the property `filed-unrefined` lacked. -# -# WHAT IT STILL DOES NOT DO, because non-negotiable 3 forbids it: it scores no -# prose, compares no semantics and decides nothing about whether a spin-off was -# lazy. It reads five fields the recorder wrote and compares two of them to -# literals. No tracker read — a gate inside `land` has no credential, which is -# `claim-check`'s "agents fetch, gates decide" split. -# -# THE RESIDUAL, stated rather than discovered later: this prices filing against -# THE DIFF. A punt about code the branch never touched is invisible to it, and so -# is a row filed before the file is touched. It closes the case that happened -# four times in one session and claims nothing wider. -# -# THE VERDICT IS THE TRACKER'S, NOT THE AUTHOR'S. `ready-lint` over a payload the -# caller assembles is forgeable and was measured to be — green three times over -# text in a local file during this issue's own refinement, once under the id -# `CLOUD-NEW` for a row that did not exist. The recorder does not have that -# problem: it lints the tracker's RESPONSE to the create. This gate only reads -# what that lint returned. -# -# THREE STATES, NOT TWO. `ready` passes, `unready` refuses, and `-` — the -# recorder could not lint, because `ready-lint` exited 2 or could not run at all — -# PASSES. Mapping "not answered" onto "refused" would turn a verdict about the -# environment into a verdict about the row, which is the exact confusion the -# recorder's own three-way status read exists to avoid. -# -# COMMENTS ARE RECORDED AND NEVER GATED. A comment on the row that already owns a -# finding is sink 2, the honest common case, and pricing it would push the -# pressure toward silence — the failure `finding-sink-check` exists to catch. -# Deliberate friction belongs on the impulsive path only. -# -# FAILS OPEN on everything it cannot establish — outside a checkout, a detached -# HEAD, no record at all. A branch that predates the recorder has no file, and -# retrofitting one is impossible: the store lives under `$GIT_DIR`, is never -# committed, and dies with the container. -# -# POINTER, NEVER PAYLOAD (rule 4): a refusal names the id, and for the diff -# refusal one tracked path. The recorder never wrote a title or a body, so there -# is none here to leak, and only a path tracked in this repository can reach the -# overlap column at all. -# -# `BATTEN_FILED_HERE_OVERLAP=1` mints over the diff refusals and RECORDS WHICH -# ONES, the `BATTEN_CLAIM_TAKEOVER` idiom. It is not folded into -# `BATTEN_FILED_HERE_BYPASS`, because "this record is unreadable" and "I meant to -# file this row against code I have open" are different decisions, and the second -# is legitimate often enough to need a route that is not a blanket off-switch — -# the row that documents a change you are landing, for one. The override is worth -# having only if it leaves a trace: it writes what it overrode to -# `$GIT_DIR/batten-receipts/filed-here-overrides.` and prints the same on -# stdout, so a reviewer sees a decision rather than a silence. -# -# Usage: mise run filed-here-check (no input; the record is on disk) -# -# Exit 0 every filed row was refined, or nothing to check / 1 a row was filed -# unrefined — `deferral-check`'s contract, so `land` stops the lap the same way. -# -# The mutation lets an unrefined row through, which is the whole gate: the toll -# stops being payable and filing is cheap again. -#MUTANT unready-passes|s/^\tunready) ;;$/\tunready) continue ;;/|stops the lap -# The mutation gates comments too, so sink 2 — a durable comment on the row that -# already owns the finding — costs the same as filing and the pressure runs -# toward saying nothing at all. -#MUTANT comments-gated|s/^\t\tcontinue$/\t\t:/|a recorded comment is never gated -# The mutation reads "not answered" as a refusal, so a row the recorder could not -# lint — mise unresolvable from the tool call's cwd, `ready-lint` exiting 2 — -# stops the lap over the environment rather than over the row. -#MUTANT unanswered-refused|s/^\t\*) continue ;; # not answered/\t*) ;; # not answered/|could not lint is not a refusal -# The mutation makes the accumulator keep every reading rather than the last, so -# a row groomed to Ready still carries its creation-time `unready` and is refused -# for it — the state that held PR #525 with the third remedy unreachable. -#MUTANT first-verdict-wins|s/^\trebuilt=""$/\trebuilt="\$latest"/|a groom recorded after the create supersedes it -# The mutation lets a row filed over the branch's own open files through, which is -# the entire second refusal: spinning a defect out of code you are holding open -# goes back to being free. -#MUTANT overlap-passes|s/^\t\treport "\$id filed-over-own-diff.*$/\t\t:/|a row naming a file this branch is changing stops the lap -# THE `0` AND `-` ARMS CARRY NO `#MUTANT` ROW, AND THAT IS MEASURED RATHER THAN -# AN OMISSION (CLOUD-480). Two rows used to sit here — `zero-overlap-refused` and -# `overlap-unanswered-refused`, each disarming one arm's `continue` — and both -# SURVIVED every run while reading as coverage for the pass-side properties they -# named. They cannot do otherwise: the property is over-determined, so no -# one-line mutation can falsify it. -# -# Measured by disarming the arms and then disarming the next guard too: -# -# overlap=0 -> named=0 -> paths='' stopped by the `[[ -n "$paths" ]]` guard -# overlap=0 -> named=0 -> paths='' guard disarmed: `for path in $paths` never runs -# overlap=- -> named=- -> paths='' same both ways -# -# `named=${overlap#*,}` returns the WHOLE token when there is no comma, so `named` -# is the literal `0` or `-`; no changed path equals either, so the intersection is -# empty and the report loop has nothing to iterate. Three independent structures -# protect the same property — this arm, the emptiness guard, and the loop itself. -# -# So the honest state is no declaration, per this row's own posture: a gate whose -# suite cannot be made to discriminate is a finding about that suite, never a -# weakened mutant. Re-adding a row here would restore a claim of coverage that -# the harness reports as a SURVIVED defect in the SUITE, which is the wrong -# subject — CLOUD-941 spent a section misdiagnosing exactly that. -# The override must stay OPT-IN and must stay RECORDED. Dropping the record turns -# a visible decision into a silence, which is the state the override exists to -# avoid. -#MUTANT override-unrecorded|s/^\t\toverridden="\${overridden:+\$overridden }\$id"$/\t\toverridden="\$overridden"/|the override records which rows it overrode -set -uo pipefail - -# `--advisory` RUNS THE SAME PREDICATE AND DECIDES NOTHING (CLOUD-774). The gate -# lives inside `land`, which is where a runner is about to be spent — so the punt -# surfaces at the most expensive moment available, after `verify`, when the -# detection itself is a `git diff --name-only` and a set intersection costing -# milliseconds. This mode is what lets the same computation run at the end of every -# turn instead: it prints the pointers and exits 0, always, so nothing on the -# commit or push path can be blocked by it. -# -# One implementation, not two. A second copy of the intersection would be a second -# thing to drift, which is the defect this whole change is about. -# -# `--checklist` IS A THIRD QUESTION OVER THE SAME PARSE, not a wider `--advisory`. -# That mode's predicate is measured and mutation-gated — it fires on rows whose -# body names a file this branch is changing — and widening it to every row would -# retune a gate by editing the question it answers. The checklist asks something -# genuinely different: not "which of these is a punt" but "here is every row you -# spun off, say for each that it is really independent work". No predicate scores -# that; the agent does, which is why this prints and never refuses. -advisory= -checklist= -case "${1:-}" in ---advisory) advisory=1 ;; ---checklist) checklist=1 ;; -"") ;; -*) - echo "usage: filed-here-check [--advisory|--checklist] (PR body optional on stdin)" >&2 - exit 2 - ;; -esac - -[[ -n "${BATTEN_FILED_HERE_BYPASS:-}" ]] && exit 0 - -# Outside a checkout there is no record and nothing to say. Same fail-open shape -# as the recorder, so the pair cannot disagree about where the file lives. -git_dir=$(git rev-parse --git-dir 2>/dev/null) || exit 0 -[[ -n "$git_dir" ]] || exit 0 -branch=$(git symbolic-ref --quiet --short HEAD 2>/dev/null) || exit 0 -[[ -n "$branch" ]] || exit 0 - -# Slashes are the one character a filename cannot carry; the substitution must -# match `board-write-record`'s spelling exactly, or the gate reads a file the -# recorder never wrote and passes everything. -record="$git_dir/batten-receipts/board-writes.${branch//\//-}" - -if [[ ! -r "$record" ]]; then - echo "filed-here-check: no board writes recorded on \"$branch\"" - exit 0 -fi - -violations=0 -unrefined=0 -overdiff=0 -creates=0 -comments=0 -overridden="" -report() { # pointer-only: an id, and for the diff refusal one tracked path - echo "$1" >&2 - violations=$((violations + 1)) - case "${2:-}" in - unrefined) unrefined=$((unrefined + 1)) ;; - overdiff) overdiff=$((overdiff + 1)) ;; - esac -} - -# THE LAST VERDICT PER ID WINS, NOT EVERY LINE (CLOUD-514). The recorder now -# writes a fresh line when a row this branch filed is groomed, and reading every -# line would leave the creation-time `unready` standing beside the `ready` that -# supersedes it — which is the state this gate refused for the whole of PR #525, -# with the remedy it prints unreachable. A later line is a later reading of the -# same row by the same mechanism, so it is simply the current one. -# -# `creates` still counts CREATE lines, so the pass line reports how many rows the -# branch filed rather than how many times they were linted. -# -# THE SIXTH COLUMN IS READ AND NOT JUDGED (CLOUD-923). It holds the rows the stored -# body cites that the caller passed as no relation — the edges the tracker's -# auto-linking mints from prose. It is named here rather than left to fall into -# `overlap`, which is what a trailing field would otherwise do. `_` is this -# file's own spelling for a column it deliberately does not read, as the note above -# on the updatedAt column says: the recorder now -# comma-joins the named-path list so every column is one token, and without that -# this gate would read `0` as a named path and refuse a lap over it. Nothing prices -# the citation set, for the reason CLOUD-923 decided: the tracker's auto-linking is -# not the author's choice, so a toll on it would have no remedy. -# -# `_` for the updatedAt column: it is the recorder's forgery-resistant half and -# this gate has no use for it, and naming it `_` is what keeps the linter from -# reading a deliberate placeholder as a dead variable. -# -# THE FIFTH COLUMN IS OPTIONAL BY CONSTRUCTION. A record written before phase 3 -# has four fields, so `overlap` reads empty — which becomes `-`, "could not -# look", and passes. A branch cannot be refused for a question its recorder was -# never able to ask. -# CLOUD-854's two discriminators, and the mutations that matter are the ones which -# turn each into a blanket pass — a fix that silences the rule for EVERY row is the -# way this goes wrong, and it looks identical to a fix that works. -# -# Inverting the comparison exempts every row written AFTER the base, which is every -# genuine punt. -#MUTANT filed-here-base-inverted|s/"$updated" < "$base_date"/"$updated" > "$base_date"/|a row recorded after the base -# And treating an unreadable §1 column as "no claim" rather than as could-not-look -# exempts every record an older recorder wrote. -#MUTANT filed-here-sec1-absent-exempts|s/if \[\[ "$sec1" != "-" \]\]; then/if true; then/|a six-field record with no - -# `updated` IS READ NOW (CLOUD-854). It was `_` — a deliberate placeholder — on the -# reasoning that this gate had no use for the recorder's forgery-resistant half. -# It has one: a row written before this branch's base cannot be a deferral of work -# in this branch's diff, because the diff did not exist yet. Two local values, no -# network and no tracker, so the header's stated bound is preserved rather than -# relaxed. -# -# THE SEVENTH COLUMN IS §1's PATHS (CLOUD-854), and it is what tells a row -# CLAIMING a file from a row CITING it. Optional by the same construction as the -# fifth: a record written before this change has six fields, so it reads empty, -# becomes `-`, and passes — a branch is never refused for a question its recorder -# could not ask. -latest="" -while read -r kind id updated verdict overlap _ sec1; do - [[ -n "$kind" ]] || continue - case "$kind" in - comment) - comments=$((comments + 1)) - continue - ;; - issue) ;; - *) continue ;; # a line the recorder did not write: skip, never judge - esac - [[ -n "$id" ]] || continue - # A create is the first line for an id; anything after it is a re-lint of the - # same row, so the count must not move. - case " $latest " in - *" $id="*) ;; - *) creates=$((creates + 1)) ;; - esac - # Rebuild the accumulator with this id's entry replaced, so the last line read - # is the one that survives. - rebuilt="" - for entry in $latest; do - case "$entry" in - "$id="*) ;; - *) rebuilt="${rebuilt:+$rebuilt }$entry" ;; - esac - done - # `,...` from the recorder, already comma-joined there since - # CLOUD-923 so one column is one shell word. The join is kept here too: a record - # written before that change carries the space-separated form, and this gate - # reads a store that outlives a single lap. - packed=${overlap:--} - packed=${packed// /,} - packed_sec1=${sec1:--} - packed_sec1=${packed_sec1// /,} - latest="${rebuilt:+$rebuilt }$id=${verdict:--}=${packed}=${updated:--}=${packed_sec1}" -done <"$record" - -# THE BRANCH BASE, read once (CLOUD-854). `%cI` is strict ISO-8601, and the -# recorder's column is the tracker's own ISO-8601 `updatedAt`, so the comparison is -# a string compare between two UTC timestamps rather than a date parse — `date -d` -# is GNU-only and `mise-tasks/**` stays BSD-portable. -# -# A base this cannot resolve leaves `base_date` empty, which skips the timestamp -# arm entirely and leaves every row judged exactly as it was before this existed: -# could-not-look never manufactures a pass OR a refusal. -base_date="" -if base_sha=$(git merge-base origin/main HEAD 2>/dev/null); then - base_date=$(git log -1 --format=%cI "$base_sha" 2>/dev/null) || base_date="" -fi - -# THE INTERSECTION HAPPENS HERE NOW, NOT AT WRITE TIME (CLOUD-774). The column -# holds the paths the row NAMES; the diff it is judged against is read fresh. The -# old shape froze a fact about the diff at the instant the row was filed, and rows -# are filed before any file is touched — AGENTS.md says claim before writing code -# — so the compliant order recorded `0` every time and this refusal could never -# see the punt it exists for. Measured 2026-08-20: CLOUD-770 filed at 06:27 with -# an empty diff recorded `0` and kept it, while the identical row groomed after an -# edit recorded a real overlap. -# -# Read once, outside the loop; a failure is "could not look", which passes every -# row rather than refusing on a question this could not ask. -changed_now=$(git diff --name-only origin/main...HEAD 2>/dev/null) || changed_now="" - -# THE ROWS THIS PR CLOSES ARE EXEMPT, and without this the change inverts the -# gate. Recomputing against the landing diff fires on a row the branch filed AND -# THEN FIXED, because its paths are in the diff by construction — so every honest -# file-then-fix would need the override, and a routinely-overridden gate is -# bypassed rather than satisfied. Worse, the cheapest way to dodge it becomes not -# filing the row at all, which is the property the board exists for in a container -# that can be reclaimed at any moment. -# -# `closing-key-check --list` is CALLED rather than copied: it owns `$CLOSING_VERBS`, -# and a second copy of that regex would be one value in two files with no gate -# holding them equal — the defect CLOUD-769 and CLOUD-770 are about, which makes -# duplicating it inside this change self-refuting. No stdin means no exemption, so -# a hand-run stays a pure function of the receipt and the diff. -closes="" -if [[ ! -t 0 ]]; then - pr_body=$(timeout 1s cat 2>/dev/null) || pr_body="" - if [[ -n "$pr_body" ]]; then - lister="$(dirname -- "${BASH_SOURCE[0]}")/closing-key-check.sh" - [[ -x "$lister" ]] && closes=$(printf '%s' "$pr_body" | "$lister" --list 2>/dev/null) || closes="" - fi -fi - -# THE CHECKLIST, and it exits before either refusal pass because it is not one. -# It enumerates every row this branch filed, marks the ones whose named paths -# intersect the branch's own diff, and asks the agent to affirm each is genuinely -# independent work rather than a punt it could have closed. The marking is the -# same intersection the refusal below computes — one implementation, read twice — -# but the VERDICT is the reader's, which is why this reports and returns 0. -# -# Pointer-only, and here it does real work: an id, a marker token, nothing from -# the row's body. Handing back the row's own prose would make this a mirror, and -# a mirror is cleared by restating it (`finding-sink-check`'s recorded lesson). -# An id can only be answered by going and reading the row. -if [[ -n "$checklist" ]]; then - listed=0 - for entry in $latest; do - id=${entry%%=*} - rest=${entry#*=} - rest=${rest#*=} - # `%%=*` rather than `#*=`: the accumulator grew a timestamp and a §1 column - # (CLOUD-854), and taking the tail here would hand the whole remainder to a - # `case` that reads `,...`. - overlap=${rest%%=*} - mark="filed" - case "$overlap" in - [1-9]*,*) - paths=${overlap#*,} - for path in ${paths//,/ }; do - case " -$changed_now -" in - *" -$path -"*) - mark="names-this-diff" - break - ;; - # This path is not in the diff. Keep looking at the rest. - *) ;; - esac - done - ;; - # A zero overlap, or a record with no path list: nothing to intersect, - # so the row keeps the plain `filed` mark. - *) ;; - esac - case " $closes " in - *" $id "*) mark="closed-by-this-pr" ;; - # Not closed by this PR, which leaves whichever mark the overlap set. - *) ;; - esac - # STDERR, like `report` and for the same reason: this file's stdout - # carries its own summary lines, and a caller capturing stdout would read - # "no board writes recorded" as a checklist of one. The sibling - # `--advisory` mode already takes pointers off stderr, so both nudge - # modes are read the same way. - printf '%s %s\n' "$id" "$mark" >&2 - listed=$((listed + 1)) - done - [[ "$listed" -gt 0 ]] || exit 0 - exit 1 -fi - -for entry in $latest; do - id=${entry%%=*} - rest=${entry#*=} - verdict=${rest%%=*} - case "$verdict" in - unready) ;; - ready) continue ;; - *) continue ;; # not answered — see the three-states note above - esac - report "$id filed-unrefined" unrefined -done - -# THE DIFF REFUSAL, in its own pass because a row can earn both: "never groomed -# to Ready" and "names code this branch is holding open" are different facts, and -# neither subsumes the other. -for entry in $latest; do - id=${entry%%=*} - rest=${entry#*=} # verdict=overlap=updated=sec1 - rest=${rest#*=} # overlap=updated=sec1 - overlap=${rest%%=*} - tail=${rest#*=} # updated=sec1 - updated=${tail%%=*} - sec1=${tail#*=} - # A record written before CLOUD-854 has no fourth and fifth accumulator field, - # so these collapse onto the overlap value. Read that as "could not look" — - # the same reading the fifth column's absence already gets — rather than as a - # timestamp of `0` or a §1 naming a path called `-`. - if [[ "$updated" = "$overlap" ]]; then - updated=- - sec1=- - fi - # THREE STATES, exactly as the verdict column has: `-` is the recorder saying - # it could not look and passes, `0` is a measurement that found nothing and - # passes, and a count with at least one path after it is a row with paths to - # intersect. Anything else is a line this reader cannot parse, which is skipped - # and never judged. - case "$overlap" in - -) continue ;; - 0) continue ;; - [1-9]*,*) ;; - *) continue ;; - esac - # A row the PR closes is the opposite of a punt: it is the work landing. - case " -$closes -" in - *" -$id -"*) continue ;; - esac - # SKIP 1 — WRITTEN BEFORE THIS BRANCH EXISTED (CLOUD-854). A punt is a deferral - # of work in the diff you are holding open; a row recorded before the branch's - # base cannot be one, by construction. Measured: 3 of 3 refusals on PR #625 were - # rows already In Review, landed before that branch was cut, and the override had - # to be spent on all three. - # - # Both sides are ISO-8601 UTC, so `<` is a lexicographic compare on equal-width - # timestamps. Either side missing skips the arm rather than deciding on it. - # shellcheck disable=SC2071 # `<` inside `[[ ]]` is the LEXICOGRAPHIC compare this - # wants, and `-lt` — what SC2071 suggests — would demand integers these are not. - # Both sides are fixed-width ISO-8601 UTC, so lexicographic order IS chronological - # order; the alternative is `date -d`, which is GNU-only and this layer is BSD-portable. - if [[ -n "$base_date" ]] && [[ "$updated" != "-" ]] && [[ "$updated" < "$base_date" ]]; then - continue - fi - # SKIP 2 — §1 NAMES NO PATH IN THE DIFF (CLOUD-854). Independent of the - # timestamp, and worse because no ordering fixes it: citing a path as evidence is - # indistinguishable, to a path-name intersection, from claiming work on it. A row - # whose §1 — its declared source of truth — names none of the diff is not - # claiming this work, whatever its prose cites. CLOUD-854 is its own fixture: it - # named `mise-tasks/pipefail-grep-check` once, in the sentence citing where the - # measurement came from, and was refused over it. - # - # `-` is could-not-look and leaves the row judged as before, so a record from an - # older recorder is never silently exempted. - if [[ "$sec1" != "-" ]]; then - sec1_hits="" - saved_ifs=$IFS - IFS=, - for path in ${sec1#*,}; do - case " -$changed_now -" in - *" -$path -"*) sec1_hits="${sec1_hits:+$sec1_hits,}$path" ;; - esac - done - IFS=$saved_ifs - [[ -n "$sec1_hits" ]] || continue - fi - named=${overlap#*,} - paths="" - saved_ifs=$IFS - IFS=, - for path in $named; do - case " -$changed_now -" in - *" -$path -"*) paths="${paths:+$paths,}$path" ;; - esac - done - IFS=$saved_ifs - [[ -n "$paths" ]] || continue - if [[ -n "${BATTEN_FILED_HERE_OVERLAP:-}" ]]; then - overridden="${overridden:+$overridden }$id" - continue - fi - saved_ifs=$IFS - IFS=, - for path in $paths; do - report "$id filed-over-own-diff $path" overdiff - done - IFS=$saved_ifs -done - -# THE OVERRIDE LEAVES A TRACE, which is the only thing that makes it worth having -# (the `BATTEN_CLAIM_TAKEOVER` idiom). A blanket off-switch and a recorded -# decision look identical to the branch and completely different to a reviewer. -if [[ -n "$overridden" ]]; then - line="filed-here-override $(date -u +%Y-%m-%dT%H:%M:%SZ) $overridden" - mkdir -p "$git_dir/batten-receipts" 2>/dev/null && - printf '%s\n' "$line" >>"$git_dir/batten-receipts/filed-here-overrides.${branch//\//-}" 2>/dev/null - echo "filed-here-check: diff overlap overridden (BATTEN_FILED_HERE_OVERLAP): $overridden" -fi - -if [[ "$overdiff" -ne 0 ]] && [[ -z "$advisory" ]]; then - cat <<'REASON' >&2 -::error:: filed-here-check: a row this branch filed names code this branch has open. - -That is the punt CLOUD-514 is about. A defect you found in your own diff is a -defect you are already holding the file for, so spinning it onto the board is -cheaper than fixing it — and the board becomes the escape hatch every other -guardrail points at. - -There is deliberately NO PROSE REMEDY here. `filed-unrefined` charges a Ready -block, and a Ready block is payable in typing; this one is not. Four ways -forward: - - 1. Fix it here. You have the file open. - 2. If it belongs to a row that already exists, comment there — comments are - recorded and never gated. - 3. File it after this lands, from a clean tree, when it is no longer your diff. - 4. If you mean it — a row documenting the change you are landing, say — run - `BATTEN_FILED_HERE_OVERLAP=1 mise run land`. It records which rows it - overrode, so this reads as a decision rather than as a silence. - -This judges no content and reads no tracker: it intersects the tracked paths the -row's body names with `git diff --name-only origin/main...HEAD`. -REASON -fi - -if [[ "$unrefined" -ne 0 ]] && [[ -z "$advisory" ]]; then - cat <<'REASON' >&2 -::error:: filed-here-check: a row this branch filed was never groomed to Ready. - -Filing is the third sink and the most expensive one on purpose: a new row costs a -complete Ready block — source of truth, a computable predicate, effect, output -contract, commit type, test obligation, blockers. That price is what stops a -defect in this branch's own diff from being cheaper to spin off than to fix. - -Three ways forward, cheapest first: - - 1. Fix it here, and close the row you filed. Nothing to record, nothing to gate. - 2. If it belongs to a row that already exists, comment there instead — comments - are recorded and never gated. - 3. Groom the row above to Ready and re-run `land`. `mise run ready-lint` is the - same check, run against the tracker's own response to your edit. - -This judges no content and reads no tracker: it compares the verdict -`board-write-record` stored at creation to `ready`. -REASON -fi - -# ADVISORY MODE DECIDES NOTHING (CLOUD-774). The pointers have already gone to -# stderr through `report`; the long refusal blocks above are suppressed because a -# nudge that reprints four remedies every turn is a channel nobody reads by the -# third one. Exit 0 unconditionally: this runs at `Stop`, and a non-zero there -# would put the punt check on the path that must stay free — committing and -# pushing to a draft is what survives a container reclaim. -if [[ -n "$advisory" ]]; then - exit 0 -fi - -[[ "$violations" -eq 0 ]] || exit 1 - -echo "filed-here-check: $creates row(s) filed on \"$branch\", $comments comment(s) — every filed row was refined at creation and none names this branch's own diff" diff --git a/mise.toml b/mise.toml index be1709e55..54f739b3e 100644 --- a/mise.toml +++ b/mise.toml @@ -398,7 +398,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,ci-slow-needed,bot-issue,land,land-lock,ci-lease-precondition,board-diff-overlap,reclaim-census,connector-allow-resolve,serena-mcp,target-prune,claimed-keys,released,in-progress-drain,merged-pr-keys,board-payloads,attestation-check,awk-regex-check,batten-glob-check,board-sweep,branch-age-check,cap-drift,checks-green,ci-drift,ci-local-parity,ci-tools-check,claim-check,claim-race-check,closing-key-check,coderabbit-config-check,config-deprecations,config-lint,connector-allow-guard,container-preflight,darwin-link,deferral-check,derived-check,digest-major-agreement,doctor,done-check,done-pr-check,duplicate-close-check,evaluator-closure-check,evaluator-io-check,filed-here-check,finding-sink-check,gh-guard,graph-check,hook-matcher-check,hook-pin-check,hook-profile-check,hooks-wiring-check,install-check,land-divergence-assert,land-lock-check,landed-check,license-table-check,linear-check,lock-complete,macos-link-check,mcp-allow-check,mcp-attach-check,mcp-timeout-budget,memories-check,mise-action-floor,mise-pin-agreement,module-map-check,msrv-pin-agreement,mutant,mutant-census,no-doctests,nonverdict-assert,ntia-check,perf-assert,perf-compare,perf-gate,pipefail-grep-check,privileged-lane,pr-unsubscribed,publish-credential-check,ready-cites-check,ready-guard,ready-lint,reference-check,release-assets-check,release-due,release-tracking-check,renovate-config-validator,report-only-check,review-answered,rules-drift,run-shape,run-shape-guard,rust-paths-check,sbom,sbom-check,schema-check,semver,signing-posture,skill-check,sonar-gate,spec-ref-check,stop-guard,stop-posture-check,suite-bench-check,timeout-check,token-bench-check,transcript-corpus-check,tree-clean,unlanded-check,verified" +MUTANT_GATES = "alive,ci-slow-needed,bot-issue,land,land-lock,ci-lease-precondition,board-diff-overlap,reclaim-census,connector-allow-resolve,serena-mcp,target-prune,claimed-keys,released,in-progress-drain,merged-pr-keys,board-payloads,attestation-check,awk-regex-check,batten-glob-check,board-sweep,branch-age-check,cap-drift,checks-green,ci-drift,ci-local-parity,ci-tools-check,claim-check,claim-race-check,closing-key-check,coderabbit-config-check,config-deprecations,config-lint,connector-allow-guard,container-preflight,darwin-link,deferral-check,derived-check,digest-major-agreement,doctor,done-check,done-pr-check,duplicate-close-check,evaluator-closure-check,evaluator-io-check,finding-sink-check,gh-guard,graph-check,hook-matcher-check,hook-pin-check,hook-profile-check,hooks-wiring-check,install-check,land-divergence-assert,land-lock-check,landed-check,license-table-check,linear-check,lock-complete,macos-link-check,mcp-allow-check,mcp-attach-check,mcp-timeout-budget,memories-check,mise-action-floor,mise-pin-agreement,module-map-check,msrv-pin-agreement,mutant,mutant-census,no-doctests,nonverdict-assert,ntia-check,perf-assert,perf-compare,perf-gate,pipefail-grep-check,privileged-lane,pr-unsubscribed,publish-credential-check,ready-cites-check,ready-guard,ready-lint,reference-check,release-assets-check,release-due,release-tracking-check,renovate-config-validator,report-only-check,review-answered,rules-drift,run-shape,run-shape-guard,rust-paths-check,sbom,sbom-check,schema-check,semver,signing-posture,skill-check,sonar-gate,spec-ref-check,stop-guard,stop-posture-check,suite-bench-check,timeout-check,token-bench-check,transcript-corpus-check,tree-clean,unlanded-check,verified" # --- 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. @@ -825,6 +825,31 @@ description = "Gate: a branch whose whole diff is comment lines buys a CI matrix # renamed row breaks this loudly instead of silently passing. run = "cargo run --quiet -p batten -- check --rule prose-only" +[tasks.filed-here-check] +description = "Gate: every row this branch put on the board was refined at creation and none names code this branch has open (reads the recorder's record; pointer-only)" +# CLOUD-514's gate, ported off `mise-tasks/filed-here-check.sh` under CLOUD-1051. +# +# THE TASK NAME SURVIVES THE MIGRATION, for `prose-only-check`'s reason above: +# `mise-tasks/land.sh` invokes it by name at the fourth stop, and `land.sh` is an +# authored shell rule `shell-retirement` refuses to see edited — so retiring the +# gate by editing its caller would have meant retiring the landing loop to move +# one predicate. That call site stays byte-identical and out of this PR's +# changed-file set. +# +# THE HEREDOC ON THAT CALL BECOMES INERT INPUT, and the channel it carried moved +# rather than closing. `land` piped the PR body in so the gate could exempt a row +# the PR CLOSES; `batten check` is declared `read` and has no stdin channel +# (house-style §5), so the body now reaches the predicate as a RECORD — the +# boundary captures what `gh pr view --jq .body` returned and the module reads +# `input.tree.records["pr-closes"]`. `land` re-fetches that body thirty lines +# above this call, so the record is fresh by construction. +# +# THE TWO NUDGE MODES DID NOT MOVE HERE. `--advisory` and `--checklist` were +# `stop-guard`'s callers, and `stop-guard` is retired in this same change: those +# questions are asked at `Stop` by the engine's own hook now, over the same +# module, so there is still exactly one implementation of the intersection. +run = "cargo run --quiet -p batten -- check --rule filed-here" + [tasks.policy-test] description = "Gate: run every registered policy module's and enabled preset's own `test_` rules" # CLOUD-931. `batten policy test` shipped with CLOUD-835 so the retirement diff --git a/policy/filed-here.rego b/policy/filed-here.rego new file mode 100644 index 000000000..ce31509a8 --- /dev/null +++ b/policy/filed-here.rego @@ -0,0 +1,382 @@ +# CLOUD-514's two refusals, as a module rather than a shell gate (ported under +# CLOUD-1051). +# +# THE ARITHMETIC IT REVERSES. Every other gate here prices FAILING to record +# something — a turn that cites evidence and writes nothing, a PR that defers +# without naming an issue. Filing satisfies all of them in seconds while +# finishing costs a diff, a suite and a landing, so spinning a defect in the +# branch's own diff onto the board is cheaper than fixing it and the board +# becomes the escape hatch every guardrail points at. Making the new row cost a +# 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. +# +# 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: +# measured 2026-08-20, four rows filed in three and a half minutes, twelve more +# spent writing four blocks, and the recorder stored `ready` for every one. The +# toll did not reverse the arithmetic — it certified the punts. +# +# WHAT IT STILL DOES NOT DO, because non-negotiable rule 3 forbids it: it scores +# no prose, compares no semantics, and decides nothing about whether a spin-off +# was lazy. It reads columns the recorder wrote and compares them to literals and +# to path lists the engine resolved. +# +# THE VERDICT IS THE TRACKER'S, NOT THE AUTHOR'S, and that property lives in the +# recorder rather than here: `[[recorder]]`'s verdict column lints the tracker's +# RESPONSE to the create. `ready-lint` over a payload the caller assembles is +# forgeable and was measured to be — green three times over text in a local file +# during this issue's own refinement, once under an id no row carried. +# +# THREE STATES PER COLUMN, NOT TWO. `-` is the recorder saying it could not look +# and PASSES; `0` is a measurement that found nothing and passes; a value is a +# reading to judge. Mapping "not answered" onto "refused" would turn a verdict +# about the environment into a verdict about the row, which is the confusion the +# recorder's own three-valued write exists to avoid. +# +# FAILS OPEN on everything it cannot establish. No record, no delta, no branch — +# each leaves the module silent, and a branch that predates the recorder can +# never have a record at all: the store lives under `$GIT_DIR`, is never +# committed, and dies with the container. +# +# POINTER, NEVER PAYLOAD (rule 4): a refusal names the id, and for the diff +# refusal one tracked path. The recorder never wrote a title or a body, so there +# is none here to leak, and only a path this repository tracks can reach the +# overlap column at all. +#MUTANT-EXEMPT CLOUD-931|no `tests/filed-here.bats` exists, and none may: `shell-retirement` refuses adding a bats suite. `mutant` resolves a gate's suite as `tests/$gate.bats`, so there is no named case a mutation could turn red. The compiled-binary tier is `crates/batten/tests/filed_here.rs` + +# 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.filed_here + +import rego.v1 + +rules contains "filed-unrefined" + +rules contains "filed-over-own-diff" + +# 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 +# filed no rows, which is a different claim. +lines := input.tree.records["board-writes"] + +# One column of a record line, or could-not-look. +# +# THE TAIL COLUMNS ARE OPTIONAL BY CONSTRUCTION. A record written by an older +# recorder is shorter, so a column past its end reads `-` and passes — a branch is +# never refused for a question its recorder could not ask. +column(columns, at) := value if { + value := columns[at] +} else := "-" + +# Every `issue` line, keyed by its position so a later reading can supersede an +# earlier one. +# +# A LINE THIS READER CANNOT PARSE IS SKIPPED AND NEVER JUDGED. A `comment` line is +# recorded and deliberately never gated: a comment on the row that already owns a +# finding is the honest common case, and pricing it would push the pressure toward +# silence, which is the failure the sink rules exist to catch. +entry[at] := row if { + some at, raw in lines + columns := split(raw, " ") + columns[0] == "issue" + columns[1] != "" + row := { + "id": columns[1], + "updated": column(columns, 2), + "verdict": column(columns, 3), + "overlap": column(columns, 4), + "sec1": column(columns, 6), + } +} + +# THE LAST VERDICT PER ID WINS, NOT EVERY LINE. The recorder writes a fresh line +# when a row this branch filed is groomed, and reading every line would leave the +# creation-time `unready` standing beside the `ready` that supersedes it — the +# state that held one PR for its whole life with the remedy it printed +# unreachable. A later line is a later reading of the same row by the same +# mechanism, so it is simply the current one. +latest[id] := row if { + some at, row in entry + id := row.id + at == max({other | some other, candidate in entry; candidate.id == id}) +} + +# The branch's own diff, as the engine resolved it. `base-delta` is `null` when +# the base rev does not resolve, so `changed` does not hold at all and both +# refusals below go silent — could-not-look, never a fabricated empty diff that +# would pass every row on ignorance. +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 +} + +# THE ROWS THIS PR CLOSES ARE EXEMPT, and without this the gate inverts. The +# overlap is recomputed against the LANDING diff, so it fires on a row the branch +# filed AND THEN FIXED — its paths are in the diff by construction. Every honest +# file-then-fix would then need the override, and a routinely-overridden gate is +# bypassed rather than satisfied; worse, the cheapest way to dodge it becomes not +# filing the row at all, which is the property the board exists for in a container +# that can be reclaimed at any moment. +# +# The record is `:,` from the boundary's own capture of what the +# forge returned. Absent — no PR yet, or a fetch that could not run — means no +# exemption, exactly as the shell behaved with stdin closed. +closes contains key if { + some raw in input.tree.records["pr-closes"] + columns := split(raw, " ") + columns[0] == "closes" + some key in split(substring(columns[1], indexof(columns[1], ":") + 1, -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. +violation contains { + "rule": "filed-unrefined", + "verdict": "V-FILED-UNREFINED", + "subjects": [{"artifact": id}], +} if { + some id + latest[id].verdict == "unready" +} + +# The overlap column is `,...`, and only a count with at least one +# path after it is a reading to judge. `-` is could-not-look and `0` is a +# measurement that found nothing; both pass, and both pass for reasons no +# one-line mutation can falsify — the count arm, the emptiness of the path list +# and the intersection each independently protect it. +named_paths(packed) := paths if { + at := indexof(packed, ",") + at > 0 + regex.match(data.batten.patterns["positive-count"], substring(packed, 0, at)) + paths := split(substring(packed, at + 1, -1), ",") +} + +# THE PATHS A ROW NAMES THAT THIS BRANCH IS CHANGING. +# +# A RULE KEYED BY ID, NOT A FUNCTION, and that is a scheduling fact rather than a +# style one: a function call needs its arguments bound, so `some id` followed by +# `overlapping(id)` cannot be ordered and the module fails to compile. Keying the +# rule makes the id the thing being iterated instead of a value to supply. +overlapping[id] := hits if { + some id, row in latest + hits := {path | + some path in named_paths(row.overlap) + path in changed + } +} + +# SKIP 1 — WRITTEN BEFORE THIS BRANCH EXISTED. A punt is a deferral of work in the +# diff you are holding open; a row recorded before the branch's base cannot be +# one, by construction. Measured: 3 of 3 refusals on one PR were rows already In +# Review, landed before that branch was cut, and the override had to be spent on +# all three. +# +# Both sides are fixed-width ISO-8601 UTC, so `<` is chronological order. Either +# side missing skips the arm rather than deciding on it: could-not-look +# manufactures neither a pass nor a refusal. +predates_the_branch(id) if { + base := delta["base-date"] + is_string(base) + updated := latest[id].updated + updated != "-" + updated < base +} + +# SKIP 2 — THE ROW'S OWN §1 NAMES NOTHING IN THE DIFF. Independent of the +# timestamp and worse, because no ordering fixes it: citing a path as evidence is +# indistinguishable, to a path-name intersection, from claiming work on it. A row +# whose declared source of truth names none of the diff is not claiming this work, +# whatever its prose cites. +# +# `-` is could-not-look and leaves the row judged as before, so a record from an +# older recorder is never silently exempted. +cites_only(id) if { + packed := latest[id].sec1 + packed != "-" + count({path | + some path in named_paths(packed) + path in changed + }) == 0 +} + +# `filed-over-own-diff`: a row this branch filed names code this branch has open. +# +# ONE FINDING PER PATH, as the shell emitted, so a reviewer sees which file rather +# than a count they have to go and reconstruct. +violation contains { + "rule": "filed-over-own-diff", + "verdict": "V-FILED-OVER-OWN-DIFF", + "subjects": [{"path": path}, {"artifact": id}], +} if { + some id, hits in overlapping + some path in hits + not id in closes + not predates_the_branch(id) + not 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. + +board(record) := {"tree": {"records": {"board-writes": record}}} + +with_diff(record, closes_record, changed_paths, base) := {"tree": { + "records": {"board-writes": record, "pr-closes": closes_record}, + "base-delta": {"added": changed_paths, "edited": [], "deleted": [], "code-changed": [], "base-date": base}, +}} + +test_an_unready_row_is_refused if { + some v in violation with input as board(["issue CLOUD-1 2026-01-01T00:00:00Z unready - - -"]) + v.verdict == "V-FILED-UNREFINED" +} + +test_a_ready_row_is_silent if { + count(violation) == 0 with input as board(["issue CLOUD-1 2026-01-01T00:00:00Z ready - - -"]) +} + +# COULD NOT LINT IS NOT A REFUSAL. `ready-lint` exiting 2, or failing to run at +# all, records `-` — a verdict about the environment, not about the row. +test_an_unanswered_verdict_passes if { + count(violation) == 0 with input as board(["issue CLOUD-1 2026-01-01T00:00:00Z - - - -"]) +} + +# A GROOM SUPERSEDES THE CREATE, which is what keeps the third remedy reachable. +test_the_last_reading_of_a_row_is_the_current_one if { + count(violation) == 0 with input as board([ + "issue CLOUD-1 2026-01-01T00:00:00Z unready - - -", + "issue CLOUD-1 2026-01-02T00:00:00Z ready - - -", + ]) +} + +test_a_comment_is_recorded_and_never_gated if { + count(violation) == 0 with input as board(["comment CLOUD-1 2026-01-01T00:00:00Z - - - -"]) +} + +test_a_line_this_reader_cannot_parse_is_skipped if { + count(violation) == 0 with input as board(["", "nonsense"]) +} + +# NO RECORD IS NO ANSWER. A branch that predates the recorder has no file, and +# refusing over that would be a verdict about the store. +test_an_absent_record_is_silent if { + count(violation) == 0 with input as {"tree": {"records": {}}} +} + +test_a_row_naming_this_branch_s_own_diff_is_refused if { + some v in violation with input as with_diff( + ["issue CLOUD-1 2026-02-01T00:00:00Z ready 1,src/a.rs - 1,src/a.rs"], + ["closes 0"], + ["src/a.rs"], + "2026-01-01T00:00:00Z", + ) + v.verdict == "V-FILED-OVER-OWN-DIFF" + + # THE ORDER IS THE STATEMENT: the tracked path leads, because that is what a + # reader should open, and `first_pointer` takes the first path-bearing subject + # as the finding's own pointer. The row's id follows it — carried, never the + # pointer — so both reach a reader and neither is prose. + v.subjects == [{"path": "src/a.rs"}, {"artifact": "CLOUD-1"}] +} + +test_a_row_this_pr_closes_is_the_work_landing if { + count(violation) == 0 with input as with_diff( + ["issue CLOUD-1 2026-02-01T00:00:00Z ready 1,src/a.rs - 1,src/a.rs"], + ["closes 1:CLOUD-1"], + ["src/a.rs"], + "2026-01-01T00:00:00Z", + ) +} + +test_a_row_written_before_the_branch_cannot_be_its_punt if { + count(violation) == 0 with input as with_diff( + ["issue CLOUD-1 2025-12-01T00:00:00Z ready 1,src/a.rs - 1,src/a.rs"], + ["closes 0"], + ["src/a.rs"], + "2026-01-01T00:00:00Z", + ) +} + +# CITING IS NOT CLAIMING. The row names the path in its body but its declared +# source of truth is somewhere else entirely. +test_a_row_that_only_cites_the_path_is_not_claiming_it 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"], + ["src/a.rs"], + "2026-01-01T00:00:00Z", + ) +} + +# ZERO IS A MEASUREMENT AND `-` IS COULD-NOT-LOOK, and both pass. +test_a_measured_zero_overlap_passes if { + count(violation) == 0 with input as with_diff( + ["issue CLOUD-1 2026-02-01T00:00:00Z ready 0 - -"], + ["closes 0"], + ["src/a.rs"], + "2026-01-01T00:00:00Z", + ) +} + +test_an_unanswered_overlap_passes if { + count(violation) == 0 with input as with_diff( + ["issue CLOUD-1 2026-02-01T00:00:00Z ready - - -"], + ["closes 0"], + ["src/a.rs"], + "2026-01-01T00:00:00Z", + ) +} + +# 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( + ["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", + ) +} + +# COULD NOT READ THE BASE DATE LEAVES EVERY ROW JUDGED AS BEFORE, rather than +# exempting the ones it cannot order. +test_an_unresolvable_base_date_still_judges_the_row if { + some v in violation with input as with_diff( + ["issue CLOUD-1 2026-02-01T00:00:00Z ready 1,src/a.rs - 1,src/a.rs"], + ["closes 0"], + ["src/a.rs"], + null, + ) + v.verdict == "V-FILED-OVER-OWN-DIFF" +} + +# BOTH REFUSALS AT ONCE, because neither subsumes the other. +test_a_row_can_earn_both_refusals if { + verdicts := {v.verdict | some v in violation} with input as with_diff( + ["issue CLOUD-1 2026-02-01T00:00:00Z unready 1,src/a.rs - 1,src/a.rs"], + ["closes 0"], + ["src/a.rs"], + "2026-01-01T00:00:00Z", + ) + verdicts == {"V-FILED-UNREFINED", "V-FILED-OVER-OWN-DIFF"} +} diff --git a/schema/batten.schema.json b/schema/batten.schema.json index d8d83b04a..b3736b7c8 100644 --- a/schema/batten.schema.json +++ b/schema/batten.schema.json @@ -785,6 +785,13 @@ "type": "string" } }, + "requires-input-matching": { + "description": "Input paths whose value must MATCH a declared pattern, by pattern id.\n\n**The selector a tool name cannot express.** [`Declared::tool`] narrows to\na tool, which is enough while the tool's identity is the whole question —\na board write is any `save_issue` at all. It is not enough for a general\nrunner: every shell call arrives as one tool, so a row wanting *the\ncommand that fetched the pull request body* has a tool name that matches\nevery command in the session and a result shape identical for all of them.\n\nThe value is a `[[pattern]]` id rather than an inline regex, for the\nreason `.claude/rules/policy-modules.md` gives for a module: one concept,\none spelling, refused at load rather than duplicated at leisure. An\nundeclared id fails the load and says which recorder named it.\n\nReads the INPUT, never the result, and that is the safe direction here\neven though [`Value::Input`] is the forgeable half elsewhere: this decides\nonly WHETHER to record, and the columns still take their values from what\nthe far end returned. A caller who spoofs the command string buys a record\nof a call whose own result is what gets written down.\n\nA path that does not resolve to a scalar refuses the row, on the\ncould-not-look rule the rest of this module keeps: a selector that cannot\nread its subject has not matched it.", + "type": "object", + "additionalProperties": { + "type": "string" + } + }, "requires-recorded": { "description": "Write only when the record ALREADY carries a line whose column\n`column` equals this expression's value.\n\n**The narrow exception that keeps a remedy reachable.** A gate that\nrefuses a subject recorded in a bad state has to tell the author how to\nclear it, and \"fix the subject and re-run\" only works if the fix is\nrecorded too. But recording every later write would let a row this branch\nnever filed be re-judged on its own say-so — so the exception is bounded\nto subjects this record already names, which is exactly the set this\nbranch is answerable for.", "anyOf": [ diff --git a/schema/policy-input.schema.json b/schema/policy-input.schema.json index 1140ce2c3..d351888c5 100644 --- a/schema/policy-input.schema.json +++ b/schema/policy-input.schema.json @@ -17,6 +17,12 @@ }, "type": "array" }, + "base-date": { + "type": [ + "string", + "null" + ] + }, "code-changed": { "items": { "type": "string" diff --git a/tests/filed-here-check.bats b/tests/filed-here-check.bats deleted file mode 100644 index 3c6b20e2b..000000000 --- a/tests/filed-here-check.bats +++ /dev/null @@ -1,571 +0,0 @@ -#!/usr/bin/env bats -# subject: mise-tasks/filed-here-check.sh -# CLOUD-514, phase 2. The gate that prices filing over fixing. -# -# Every test runs inside a throwaway `git init`, because the subject IS the git -# dir: the record is keyed to the branch and stored under `$GIT_DIR`, so a suite -# running in this repo's checkout would read (and be judged against) the real -# session's board writes. Same reasoning as `issue-search-guard.bats`. - -setup() { - # The caller's ambient environment must not reach a fixture, exactly as the - # git identity below must not. Both bypasses are set per-case with `env` where - # a case wants them; inherited, they silence the cases that assert the gate - # FIRES — and the whole suite passes while proving nothing (CLOUD-418). - # - # Not hypothetical: `BATTEN_FILED_HERE_OVERLAP=1 mise run land` is remedy 4 in - # this gate's own deny text, and it exports the variable into `verify`, which - # runs this suite. So the documented escape hatch reddened the suite that - # documents it, and the gate could not be overridden by the route it names. - unset BATTEN_FILED_HERE_OVERLAP BATTEN_FILED_HERE_BYPASS - GATE="$BATS_TEST_DIRNAME/../mise-tasks/filed-here-check.sh" - REPO="$BATS_TEST_TMPDIR/repo" - rm -rf "$REPO" - mkdir -p "$REPO" - git -C "$REPO" init --quiet --initial-branch=work - # Per fixture, never inherited — a CI runner carries no global identity, so a - # bare `git commit` here fails only there (CLOUD-513). - git -C "$REPO" config user.email t@example.com - git -C "$REPO" config user.name t - git -C "$REPO" commit -q --allow-empty -m init - RECORD="$REPO/.git/batten-receipts/board-writes.work" - mkdir -p "$REPO/.git/batten-receipts" - cd "$REPO" || return 1 -} - -# `changes ...` — give the fixture a real `origin/main` and a real diff -# containing those paths (CLOUD-774). The gate no longer trusts the count the -# recorder froze; it intersects the recorded paths with -# `git diff --name-only origin/main...HEAD` when it is asked. So a case about the -# diff refusal has to CREATE the diff, which is what makes these assertions about -# the predicate rather than about a number in a file. -changes() { - git -C "$REPO" update-ref refs/remotes/origin/main HEAD - for path in "$@"; do - mkdir -p "$REPO/$(dirname "$path")" - printf 'x\n' >"$REPO/$path" - done - git -C "$REPO" add -A - git -C "$REPO" commit -q -m change -} - -# The recorder's line shape, named once: kind, id, the tracker's updatedAt, the -# stored `ready-lint` verdict. -record() { printf '%s\n' "$@" >>"$RECORD"; } - -# THE THIRD COLUMN IS NOW LOAD-BEARING (CLOUD-854), and these fixtures had encoded -# the assumption that it was not. A row recorded BEFORE the branch base is skipped, -# and a fixture repo's base commit is created by `setup` — so it is always "now", -# and every historical stamp in this file read as "before the base". Left alone, -# that would have exempted every diff-refusal case here and turned the suite green -# for exactly the wrong reason. -# -# So the stamp is `2099` — unambiguously AFTER any base this fixture can have — -# wherever a case means "written while this branch was open", and the two cases -# below that are about the timestamp say so with their own explicit values. - -@test "an AMBIENT bypass does not silence the gate — setup owns the environment" { - # The suite self-test for its own setup. Both bypasses are exported here as a - # caller would have them; `setup` cleared them, so the gate must still refuse. - # Without this, a future setup that stops unsetting them turns every - # fires-correctly case green for the wrong reason and nothing notices. - # Asserted as setup's CONTRACT — the variables are absent by the time a body - # runs — rather than by exporting them here, which would only re-prove that - # the gate honours a bypass it can see. - [ -z "${BATTEN_FILED_HERE_OVERLAP:-}" ] - [ -z "${BATTEN_FILED_HERE_BYPASS:-}" ] - printf 'issue CLOUD-1 2026-01-01T00:00:00.000Z unready 0\n' >"$RECORD" - run "$GATE" - [ "$status" -ne 0 ] - [[ "$output" == *"CLOUD-1"* ]] -} - -@test "a create recorded with an unready verdict stops the lap, and the refusal names the id" { - record "issue CLOUD-900 2099-01-01T00:00:00.000Z unready" - run "$GATE" - [ "$status" -eq 1 ] - [[ "$output" == *"CLOUD-900 filed-unrefined"* ]] - [[ "$output" == *"filed-here-check"* ]] -} - -# THE OTHER DIRECTION (CLOUD-418): the same row, refined, passes. A suite that -# only ever asserts the refusal cannot tell a working gate from one wired shut. -@test "the same row passes once its recorded verdict is green" { - record "issue CLOUD-900 2099-01-01T00:00:00.000Z ready" - run "$GATE" - [ "$status" -eq 0 ] - [[ "$output" != *"filed-unrefined"* ]] -} - -# THE LAST VERDICT PER ID WINS, which is what makes the third remedy this gate -# prints reachable at all. `board-write-record` writes a fresh line when a row -# this branch filed is groomed; reading every line instead leaves the -# creation-time `unready` standing beside the `ready` that supersedes it, which -# held PR #525 for its whole life with no remedy that could clear it. -@test "a groom recorded after the create supersedes it" { - record "issue CLOUD-900 2099-01-01T00:00:00.000Z unready" \ - "issue CLOUD-900 2026-08-19T01:00:00.000Z ready" - run "$GATE" - [ "$status" -eq 0 ] - [[ "$output" != *"filed-unrefined"* ]] -} - -# THE DISCRIMINATING DIRECTION: last, not "a `ready` anywhere in the file". A gate -# passing on the mere presence of one green line would let a row be groomed and -# then gutted, and could never refuse a row it had once passed. -@test "a later unready supersedes an earlier ready" { - record "issue CLOUD-900 2099-01-01T00:00:00.000Z ready" \ - "issue CLOUD-900 2026-08-19T01:00:00.000Z unready" - run "$GATE" - [ "$status" -eq 1 ] - [[ "$output" == *"CLOUD-900 filed-unrefined"* ]] -} - -@test "superseding is per id: one row groomed leaves another's refusal standing" { - record "issue CLOUD-900 2099-01-01T00:00:00.000Z unready" \ - "issue CLOUD-901 2099-01-01T00:00:00.000Z unready" \ - "issue CLOUD-900 2026-08-19T01:00:00.000Z ready" - run "$GATE" - [ "$status" -eq 1 ] - [[ "$output" == *"CLOUD-901 filed-unrefined"* ]] - [[ "$output" != *"CLOUD-900 filed-unrefined"* ]] -} - -# The pass line reports how many rows the branch FILED, not how many times they -# were linted — a re-lint that moved the count would make grooming look like -# filing again, which is the arithmetic this whole pair exists to hold. -@test "a re-lint of one row does not inflate the filed count" { - record "issue CLOUD-900 2099-01-01T00:00:00.000Z unready" \ - "issue CLOUD-900 2026-08-19T01:00:00.000Z ready" - run "$GATE" - [ "$status" -eq 0 ] - [[ "$output" == *"1 row(s) filed"* ]] -} - -@test "a recorded comment is never gated, whatever its verdict column says" { - record "comment CLOUD-900 2099-01-01T00:00:00.000Z -" \ - "comment CLOUD-901 2099-01-01T00:00:00.000Z unready" - run "$GATE" - [ "$status" -eq 0 ] - [[ "$output" != *"filed-unrefined"* ]] -} - -# `-` is the recorder's "could not look": `ready-lint` exited 2, or could not run -# at all because a hook inherits the tool call's cwd and mise could not resolve -# the project from it. Reading that as a refusal would stop the lap over the -# environment rather than over the row. -@test "a create the recorder could not lint is not a refusal" { - record "issue CLOUD-900 2099-01-01T00:00:00.000Z -" - run "$GATE" - [ "$status" -eq 0 ] - [[ "$output" != *"filed-unrefined"* ]] -} - -@test "one unrefined row among refined ones is reported, and only that one" { - record "issue CLOUD-900 2099-01-01T00:00:00.000Z ready" \ - "comment CLOUD-901 2099-01-01T00:00:00.000Z -" \ - "issue CLOUD-902 2099-01-01T00:00:00.000Z unready" \ - "issue CLOUD-903 2099-01-01T00:00:00.000Z ready" - run "$GATE" - [ "$status" -eq 1 ] - [[ "$output" == *"CLOUD-902 filed-unrefined"* ]] - [[ "$output" != *"CLOUD-900 filed-unrefined"* ]] - [[ "$output" != *"CLOUD-903 filed-unrefined"* ]] -} - -# THE PATH THE DESIGN WANTS TO BE CHEAPEST: fix it here, write nothing to the -# board. Nothing recorded, nothing to check, and the gate must be silent about it -# rather than treating an absent record as a failure to file. -@test "a branch that filed nothing passes untouched" { - rm -f "$RECORD" - run "$GATE" - [ "$status" -eq 0 ] - [[ "$output" != *"filed-unrefined"* ]] -} - -@test "an empty record passes" { - : >"$RECORD" - run "$GATE" - [ "$status" -eq 0 ] -} - -# A branch that predates the recorder can never have a record, and cannot be -# given one: the store lives under `$GIT_DIR` and is never committed. -@test "a record belonging to another branch is not read" { - record "issue CLOUD-900 2099-01-01T00:00:00.000Z unready" - git checkout -q -b other - run "$GATE" - [ "$status" -eq 0 ] - [[ "$output" != *"filed-unrefined"* ]] -} - -@test "a branch name with a slash finds its record, matching the recorder's spelling" { - git checkout -q -b feature/thing - printf '%s\n' "issue CLOUD-900 2099-01-01T00:00:00.000Z unready" \ - >"$REPO/.git/batten-receipts/board-writes.feature-thing" - run "$GATE" - [ "$status" -eq 1 ] - [[ "$output" == *"CLOUD-900 filed-unrefined"* ]] -} - -@test "outside a git repository the gate fails open rather than stopping every lap" { - cd "$BATS_TEST_TMPDIR" || return 1 - run env GIT_CEILING_DIRECTORIES="$BATS_TEST_TMPDIR" "$GATE" - [ "$status" -eq 0 ] -} - -@test "a detached HEAD has no branch to key on, and fails open" { - record "issue CLOUD-900 2099-01-01T00:00:00.000Z unready" - git checkout -q --detach - run "$GATE" - [ "$status" -eq 0 ] - [[ "$output" != *"filed-unrefined"* ]] -} - -# Pointer-only, non-negotiable 4. The recorder never wrote a title or a body, so -# there is none here to leak — this asserts the shape stays that way. -@test "the refusal carries the id and no prose from the row" { - record "issue CLOUD-900 2099-01-01T00:00:00.000Z unready" - run "$GATE" - [ "$status" -eq 1 ] - [[ "$output" != *"2099-01-01T00:00:00.000Z"* ]] -} - -@test "a malformed line is skipped rather than judged" { - record "issue" "" "garbage" "issue CLOUD-900" "issue CLOUD-901 2099-01-01T00:00:00.000Z ready" - run "$GATE" - [ "$status" -eq 0 ] - [[ "$output" != *"filed-unrefined"* ]] -} - -@test "the bypass is honoured" { - record "issue CLOUD-900 2099-01-01T00:00:00.000Z unready" - run env BATTEN_FILED_HERE_BYPASS=1 "$GATE" - [ "$status" -eq 0 ] - [ -z "$output" ] -} - -# The refusal has to be actionable without leaving the terminal: the three sinks, -# cheapest first, and the command that answers the same question locally. -@test "the refusal names the three sinks and the local check" { - record "issue CLOUD-900 2099-01-01T00:00:00.000Z unready" - run "$GATE" - [[ "$output" == *"Fix it here"* ]] - [[ "$output" == *"comment there instead"* ]] - [[ "$output" == *"mise run ready-lint"* ]] -} - -@test "the pass line counts creates and comments separately" { - record "issue CLOUD-900 2099-01-01T00:00:00.000Z ready" \ - "comment CLOUD-901 2099-01-01T00:00:00.000Z -" \ - "comment CLOUD-902 2099-01-01T00:00:00.000Z -" - run "$GATE" - [ "$status" -eq 0 ] - [[ "$output" == *"1 row(s) filed"* ]] - [[ "$output" == *"2 comment(s)"* ]] -} - -# --- the diff refusal (CLOUD-514, phase 3) ------------------------------------- -# -# `filed-unrefined` prices REFINEMENT and its bound was stated honestly from the -# day it shipped — "it does not compare the row to the diff". That bound was the -# whole gap: a Ready block is prose, and prose is the currency an agent has -# without limit. Measured 2026-08-20, four rows filed in three and a half minutes -# and every one recorded `ready`. -# -# The fifth column is the recorder's `board-diff-overlap` reading, `` then -# the tracked paths the row names, COMMA-JOINED since CLOUD-923 so one column is -# one whitespace-free token. The recorder gained a sixth column then, and a record -# whose fifth field can swallow the rest of the line cannot have a sixth. - -@test "a row naming a file this branch is changing stops the lap" { - changes crates/batten/src/git.rs - record "issue CLOUD-900 2099-01-01T00:00:00.000Z ready 1,crates/batten/src/git.rs" - run "$GATE" - [ "$status" -eq 1 ] - [[ "$output" == *"CLOUD-900 filed-over-own-diff crates/batten/src/git.rs"* ]] -} - -# THE OTHER DIRECTION (CLOUD-418). A refusal over every filed row is not a gate, -# it is an outage, and this is the reading that separates the two. -@test "a row naming only untouched files passes" { - record "issue CLOUD-900 2099-01-01T00:00:00.000Z ready 0" - run "$GATE" - [ "$status" -eq 0 ] - [[ "$output" != *"filed-over-own-diff"* ]] -} - -# THREE STATES HERE TOO. `-` is the recorder saying it could not look — no -# `origin/main`, outside a checkout, a body the tracker did not return — and -# reading it as a refusal turns a verdict about the environment into one about -# the row. -@test "a row the recorder could not measure passes" { - record "issue CLOUD-900 2099-01-01T00:00:00.000Z ready -" - run "$GATE" - [ "$status" -eq 0 ] - [[ "$output" != *"filed-over-own-diff"* ]] -} - -# A RECORD WRITTEN BEFORE THIS COLUMN EXISTED HAS FOUR FIELDS. A branch cannot be -# refused for a question its recorder was never able to ask, and the store lives -# under `$GIT_DIR` where it cannot be migrated. -@test "a four-field line predating the column is not refused" { - record "issue CLOUD-900 2099-01-01T00:00:00.000Z ready" - run "$GATE" - [ "$status" -eq 0 ] - [[ "$output" != *"filed-over-own-diff"* ]] -} - -# ─── CLOUD-854: THE TWO DISCRIMINATORS ─────────────────────────────────────── -# -# Measured four times on PR #625: 3 of 3 refusals were rows already In Review, -# landed before that branch was cut, and the fourth was this row itself — refused -# over `mise-tasks/pipefail-grep-check`, a path it names once, in the sentence -# citing where its own measurement came from. The override had to be spent on all -# four, which is how an override stops being read. - -# (a) THE DISCRIMINATING CASE, and it is red before the fix. -@test "A ROW RECORDED BEFORE THIS BRANCH'S BASE IS NOT A PUNT OVER ITS DIFF" { - changes crates/batten/src/git.rs - # 2020 predates the fixture's base commit, which `setup` made moments ago. A - # punt is a deferral of work in the diff you are holding open; this row was - # written before that diff could exist. - record "issue CLOUD-900 2020-01-01T00:00:00.000Z ready 1,crates/batten/src/git.rs - 1,crates/batten/src/git.rs" - run "$GATE" - [ "$status" -eq 0 ] - [[ "$output" != *"filed-over-own-diff"* ]] -} - -# (b) THE OTHER DIRECTION, without which (a) is an outage rather than a fix: the -# genuine punt — written after the base, §1 naming the file — must still refuse. -@test "a row recorded after the base, whose §1 names the diff, still refuses" { - changes crates/batten/src/git.rs - record "issue CLOUD-900 2099-01-01T00:00:00.000Z ready 1,crates/batten/src/git.rs - 1,crates/batten/src/git.rs" - run "$GATE" - [ "$status" -eq 1 ] - [[ "$output" == *"CLOUD-900 filed-over-own-diff crates/batten/src/git.rs"* ]] -} - -# (a2) CLOUD-854 IS ITS OWN FIXTURE. The body names the path once, as evidence, -# and its §1 names a different file entirely. Citing a measurement's provenance is -# what CLOUD-732 exists to REQUIRE, so a gate that charges for it puts two gates in -# opposition. -@test "A PATH CITED AS EVIDENCE IS NOT A CLAIM ON IT — §1 decides the subject" { - changes mise-tasks/pipefail-grep-check.sh - record "issue CLOUD-854 2099-01-01T00:00:00.000Z ready 1,mise-tasks/pipefail-grep-check.sh - 1,mise-tasks/filed-here-check.sh" - run "$GATE" - [ "$status" -eq 0 ] - [[ "$output" != *"filed-over-own-diff"* ]] -} - -# (c) And the composition is an AND, not an OR: a §1 that does name the diff is -# refused even though the row also cites other paths. -@test "a row whose §1 names the diff is refused even when it cites other paths too" { - changes mise-tasks/filed-here-check.sh - record "issue CLOUD-854 2099-01-01T00:00:00.000Z ready 2,mise-tasks/filed-here-check.sh,mise-tasks/pipefail-grep-check.sh - 1,mise-tasks/filed-here-check.sh" - run "$GATE" - [ "$status" -eq 1 ] - [[ "$output" == *"CLOUD-854 filed-over-own-diff mise-tasks/filed-here-check.sh"* ]] -} - -# THE OLDER RECORDER'S LINES MUST STAY JUDGED. A six-field record carries no §1 -# column, and reading that absence as "claims nothing" would exempt every row -# written before this change — a blanket pass wearing a fix's clothes, which is -# what the `filed-here-sec1-absent-exempts` mutation asserts. -@test "a six-field record with no §1 column is judged exactly as before" { - changes crates/batten/src/git.rs - record "issue CLOUD-900 2099-01-01T00:00:00.000Z ready 1,crates/batten/src/git.rs -" - run "$GATE" - [ "$status" -eq 1 ] - [[ "$output" == *"CLOUD-900 filed-over-own-diff crates/batten/src/git.rs"* ]] -} - -@test "every overlapping path is named, one pointer per line" { - changes a/one.rs b/two.rs - record "issue CLOUD-900 2099-01-01T00:00:00.000Z ready 2,a/one.rs,b/two.rs" - run "$GATE" - [ "$status" -eq 1 ] - [[ "$output" == *"CLOUD-900 filed-over-own-diff a/one.rs"* ]] - [[ "$output" == *"CLOUD-900 filed-over-own-diff b/two.rs"* ]] -} - -# The two refusals are different facts about the same row and neither subsumes -# the other, so a row can earn both and must report both. -@test "a row that is both unrefined and over the diff reports both" { - changes a/one.rs - record "issue CLOUD-900 2099-01-01T00:00:00.000Z unready 1,a/one.rs" - run "$GATE" - [ "$status" -eq 1 ] - [[ "$output" == *"CLOUD-900 filed-unrefined"* ]] - [[ "$output" == *"CLOUD-900 filed-over-own-diff a/one.rs"* ]] -} - -# LAST LINE WINS HERE TOO. The recorder writes a fresh line when a row this -# branch filed is groomed, and a row rewritten to be about work elsewhere is the -# second of the four remedies. -@test "a later reading with no overlap supersedes an earlier one" { - # Both stamps are after the base, so this passes on the superseding OVERLAP - # rather than on CLOUD-854's timestamp skip — one reason, tested. - record "issue CLOUD-900 2099-01-01T00:00:00.000Z ready 1,a/one.rs" \ - "issue CLOUD-900 2099-01-01T01:00:00.000Z ready 0" - run "$GATE" - [ "$status" -eq 0 ] - [[ "$output" != *"filed-over-own-diff"* ]] -} - -@test "and a later reading WITH an overlap supersedes a clean one" { - changes a/one.rs - record "issue CLOUD-900 2099-01-01T00:00:00.000Z ready 0" \ - "issue CLOUD-900 2099-01-01T01:00:00.000Z ready 1,a/one.rs" - run "$GATE" - [ "$status" -eq 1 ] - [[ "$output" == *"filed-over-own-diff a/one.rs"* ]] -} - -# COMMENTS STAY UNGATED on this axis too. A comment on the row that already owns -# the finding is sink 2 and the honest common case; pricing it pushes the -# pressure toward silence. -@test "a comment is never gated on the diff either" { - # Comma-joined like every other fixture here (CLOUD-923's format), even though - # this line is deliberately unrealistic — a real comment records `-` in both of - # the last two columns. The point is that the `kind` skip happens before any - # column is read, so an overlap that WOULD refuse still does not. - record "comment CLOUD-900 2099-01-01T00:00:00.000Z - 1,a/one.rs -" - run "$GATE" - [ "$status" -eq 0 ] - [[ "$output" != *"filed-over-own-diff"* ]] -} - -# CLOUD-774. THE HOLE THIS CLOSED, and the case that would have caught the punt -# that prompted it. The recorder stores the paths a row NAMES; the intersection -# happens here. Before, the count was frozen when the row was filed — and rows are -# filed before any file is touched, because AGENTS.md says claim before writing -# code — so the compliant order recorded `0` and this refusal never fired. -@test "A ROW RECORDED BEFORE THE FILE WAS TOUCHED IS STILL CAUGHT" { - # Recorded with no diff at all: the paths are what the body named, not an - # intersection. The edit lands afterwards, exactly as it did on the branch this - # was found on. - record "issue CLOUD-900 2099-01-01T00:00:00.000Z ready 2,a/one.rs,b/two.rs" - changes a/one.rs b/two.rs - run "$GATE" - [ "$status" -eq 1 ] - [[ "$output" == *"CLOUD-900 filed-over-own-diff a/one.rs"* ]] -} - -# The intersection is real, not a pass-through of the recorded list: a row may -# name a dozen files and touch one, and only the one it touches is a pointer. -@test "a recorded path the branch does not change is not reported" { - record "issue CLOUD-900 2099-01-01T00:00:00.000Z ready 2,a/one.rs,b/untouched.rs" - changes a/one.rs - run "$GATE" - [ "$status" -eq 1 ] - [[ "$output" == *"a/one.rs"* ]] - [[ "$output" != *"b/untouched.rs"* ]] -} - -@test "a row naming only files this branch leaves alone passes" { - record "issue CLOUD-900 2099-01-01T00:00:00.000Z ready 1,b/untouched.rs" - changes a/one.rs - run "$GATE" - [ "$status" -eq 0 ] - [[ "$output" != *"filed-over-own-diff"* ]] -} - -# THE EXEMPTION, and without it this change would punish the honest path: a row -# filed AND THEN FIXED on the same branch has its paths in the diff by -# construction, so every file-then-fix would need the override. -@test "A ROW THE PR CLOSES IS EXEMPT — filing then fixing is the point, not the punt" { - record "issue CLOUD-900 2099-01-01T00:00:00.000Z ready 1,a/one.rs" - changes a/one.rs - run bash -c 'printf "Closes CLOUD-900\n" | "$1"' _ "$GATE" - [ "$status" -eq 0 ] - [[ "$output" != *"filed-over-own-diff"* ]] -} - -@test "closing a different row does not exempt this one" { - record "issue CLOUD-900 2099-01-01T00:00:00.000Z ready 1,a/one.rs" - changes a/one.rs - run bash -c 'printf "Closes CLOUD-901\n" | "$1"' _ "$GATE" - [ "$status" -eq 1 ] - [[ "$output" == *"CLOUD-900 filed-over-own-diff a/one.rs"* ]] -} - -# A body that merely MENTIONS the key hands the board nothing, so it cannot buy an -# exemption either — the distinction `closing-key-check` already draws, reused by -# calling it rather than by copying its match. -@test "a body that only refs the row does not exempt it" { - record "issue CLOUD-900 2099-01-01T00:00:00.000Z ready 1,a/one.rs" - changes a/one.rs - run bash -c 'printf "Refs CLOUD-900 for context\n" | "$1"' _ "$GATE" - [ "$status" -eq 1 ] - [[ "$output" == *"CLOUD-900 filed-over-own-diff a/one.rs"* ]] -} - -# THE REFUSAL HAS NO PROSE REMEDY, which is the load-bearing difference from -# `filed-unrefined`: a Ready block is payable in typing and this is not. -@test "the diff refusal names four remedies and none of them is writing more prose" { - changes a/one.rs - record "issue CLOUD-900 2099-01-01T00:00:00.000Z ready 1,a/one.rs" - run "$GATE" - [[ "$output" == *"Fix it here"* ]] - [[ "$output" == *"comment there"* ]] - [[ "$output" == *"after this lands"* ]] - [[ "$output" == *"BATTEN_FILED_HERE_OVERLAP=1"* ]] - [[ "$output" != *"ready-lint"* ]] -} - -# POINTER, NEVER PAYLOAD (rule 4): a path is all the recorder ever wrote, so a -# path is all this can name. -@test "the diff refusal carries the id and one path and nothing else" { - record "issue CLOUD-900 2099-01-01T00:00:00.000Z ready 1,a/one.rs" - run "$GATE" - [[ "$output" != *"2099-01-01T00:00:00.000Z"* ]] -} - -# --- the override -------------------------------------------------------------- -# -# Not folded into `BATTEN_FILED_HERE_BYPASS`: "this record is unreadable" and "I -# meant to file this row against code I have open" are different decisions, and -# the second is legitimate often enough — a row documenting the change you are -# landing — to need a route that is not a blanket off-switch. - -@test "the override lets the diff refusal through" { - record "issue CLOUD-900 2099-01-01T00:00:00.000Z ready 1,a/one.rs" - run env BATTEN_FILED_HERE_OVERLAP=1 "$GATE" - [ "$status" -eq 0 ] - [[ "$output" != *"filed-over-own-diff"* ]] -} - -# THE ONLY THING THAT MAKES IT WORTH HAVING. A blanket off-switch and a recorded -# decision look identical to the branch and completely different to a reviewer. -@test "the override records which rows it overrode" { - changes a/one.rs b/two.rs - record "issue CLOUD-900 2099-01-01T00:00:00.000Z ready 1,a/one.rs" \ - "issue CLOUD-901 2099-01-01T00:00:00.000Z ready 1,b/two.rs" - run env BATTEN_FILED_HERE_OVERLAP=1 "$GATE" - [ "$status" -eq 0 ] - [[ "$output" == *"BATTEN_FILED_HERE_OVERLAP"* ]] - [[ "$output" == *"CLOUD-900"* ]] - [[ "$output" == *"CLOUD-901"* ]] - run cat "$REPO/.git/batten-receipts/filed-here-overrides.work" - [ "$status" -eq 0 ] - [[ "$output" == *"CLOUD-900 CLOUD-901"* ]] -} - -# It is the DIFF override, not a bypass: a row filed unrefined is still refused. -@test "the override does not excuse an unrefined row" { - record "issue CLOUD-900 2099-01-01T00:00:00.000Z unready 1,a/one.rs" - run env BATTEN_FILED_HERE_OVERLAP=1 "$GATE" - [ "$status" -eq 1 ] - [[ "$output" == *"CLOUD-900 filed-unrefined"* ]] - [[ "$output" != *"filed-over-own-diff"* ]] -} - -# And it writes nothing when it overrode nothing — a receipt for a decision -# nobody made reads as a decision somebody made. -@test "the override records nothing when there was nothing to override" { - record "issue CLOUD-900 2099-01-01T00:00:00.000Z ready 0" - run env BATTEN_FILED_HERE_OVERLAP=1 "$GATE" - [ "$status" -eq 0 ] - [ ! -e "$REPO/.git/batten-receipts/filed-here-overrides.work" ] -} From adcb925199f2e908193cdea24287b5f32e3800c7 Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Wed, 26 Aug 2026 05:16:03 +0000 Subject: [PATCH 15/38] fix(hook): the end-of-turn module was a dead gate, and this is what proves it is not MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `policy/stop-posture.rego` shipped with sixteen green `test_` rules and never fired on any event. Two independent reasons, each sufficient: `adjudicate` returns `Allow` at `Stop` before any rule is read — CLOUD-889's runaway removed by construction — so a `mediated_call` module was unreachable at the one moment that projects the field it reads. And `adjudicable` was false for a `Stop` payload, which carries no command, no write and no tool name, so the config was never loaded there at all: `Policy::declaring_nothing`, no bundles, nothing to run. Neither was visible from the module's own suite. That tier fabricates the very shape the boundary may be unable to produce, which is exactly what `.claude/rules/policy-modules.md` says it cannot catch — and a dead gate and a clean tree are byte-identical on the decision surface. `hook::stop_advice` is a SECOND function rather than a widened `adjudicate`, and that is the load-bearing choice. `adjudicate`'s Stop arm is the mechanism that makes a refusal there unreachable; routing the modules through it would put a `Decision::Deny` back on that path and re-open the runaway through the door CLOUD-898 warned about. This evaluates the modules and answers with TEXT, so there is no value it can return that refuses anything. The recursion bound is the payload's `stop_hook_active`, which is the bound the retired shell hook used and the one `adjudicate` deliberately never reads. One nudge per turn, and only into a silent buffer: a turn already handed a handler's advice has been told something more specific, and two nudges on one turn is how a channel stops being read. The advisory is ASSEMBLED rather than `Refusal::render`ed, because that projection opens `Refused by` and nothing here refuses. The id, the cause and the remedy all travel, so the fix clause is present exactly as that contract insists. `crates/batten/tests/stop_posture.rs` is the tier whose absence hid all of this: six cases over the compiled binary, reading what a host reads. It asserts the nudge arrives, that it carries no `permissionDecision`, that no byte of the matched prose reaches the channel, that a clean turn is silent, that a repeat Stop says nothing, and that a tool call is not judged by an end-of-turn rule. Cost: one config load per TURN, not per call. `perf`'s `passthrough` and `noop` arms are pre-tool shapes and are untouched. `mise run test:cargo` 2522/2522. Refs: CLOUD-1051 --- crates/batten/src/hook.rs | 63 ++++++++ crates/batten/src/lib.rs | 33 ++++ crates/batten/tests/stop_posture.rs | 239 ++++++++++++++++++++++++++++ 3 files changed, 335 insertions(+) create mode 100644 crates/batten/tests/stop_posture.rs diff --git a/crates/batten/src/hook.rs b/crates/batten/src/hook.rs index b8541d3d5..d4b3aaaa9 100644 --- a/crates/batten/src/hook.rs +++ b/crates/batten/src/hook.rs @@ -4640,6 +4640,69 @@ fn policy_protected_paths(rule: &Rule) -> Vec { vec![root.to_owned(), format!("{root}/**")] } +/// What a registered module says at the end of a turn, as ADVICE (CLOUD-1051). +/// +/// # The dead gate this closes +/// +/// [`adjudicated`] returns [`Decision::Allow`] at `Stop` **before any rule is +/// read**, which is CLOUD-889's runaway removed by construction and stays. The +/// consequence nobody had drawn: a `mediated_call` module also never runs there, +/// so `policy/stop-posture.rego` — a row whose whole subject is the turn's final +/// message, a field only `Stop` projects — could not fire on any event at all. +/// Its own `test_` rules passed the entire time, which is exactly the class +/// `.claude/rules/policy-modules.md` names: a dead gate and a clean tree are +/// byte-identical on the decision surface. +/// +/// # Why this is a second function and not a widened `adjudicate` +/// +/// `adjudicate`'s Stop arm is the mechanism that makes a refusal there +/// *unreachable*. Routing the modules through it would put a `Decision::Deny` +/// back on that path and re-open the runaway through the door CLOUD-898 warned +/// about. So the modules are evaluated here, the answer is TEXT rather than a +/// decision, and there is no value this function can return that refuses +/// anything — the same structural argument `completion.rs` makes for itself. +/// +/// # The recursion bound is the payload's, not a state file +/// +/// `stop_active` is false on the first `Stop` of a turn and true on the one a +/// previous `Stop` continuation caused, so returning `None` for it bounds this +/// to one nudge per turn deterministically. That is the bound the retired shell +/// hook used and the one `adjudicate` deliberately never read. +/// +/// `None` for every other event, for could-not-look, and for a policy that +/// registers no module. +#[must_use] +pub fn stop_advice(policy: &Policy, envelope: &Envelope, facts: &Facts<'_>) -> Option { + if envelope.event != Event::Stop { + return None; + } + if envelope.stop_active == Some(true) { + return None; + } + match policy_rules(policy, envelope, facts) { + // A MODULE'S REFUSAL BECOMES ITS CAUSE AND ITS REMEDY, which is the same + // demotion `dispatch_handlers` performs for a handler and for the same + // stated reason — `Event::carries_a_verdict` is the one authority both + // producers ask. `Ask` demotes too: an escalation this moment cannot + // deliver is advice rather than a silently dropped question. + // + // ASSEMBLED RATHER THAN `Refusal::render`d, and the difference is one + // word that would be a lie: that projection opens `Refused by`, and + // nothing here refuses. The id, the cause and the remedy all travel, so + // the fix clause is present exactly as that contract insists. + Decision::Deny(refusal) | Decision::Ask(refusal) => Some(format!( + "{}: {} {}", + refusal.rule(), + refusal.reason(), + match refusal.fix() { + crate::refusal::Fix::Run(text) => text.clone(), + crate::refusal::Fix::None => String::new(), + } + )), + Decision::Allow | Decision::Waived(_) => None, + } +} + /// The policy gate: hand every registered module the call's facts and read back /// its denials (CLOUD-647, CLOUD-689). /// diff --git a/crates/batten/src/lib.rs b/crates/batten/src/lib.rs index 1f7c2ee64..058fe5455 100644 --- a/crates/batten/src/lib.rs +++ b/crates/batten/src/lib.rs @@ -2759,8 +2759,23 @@ fn run_hook( // lines below for a different question — whether a WRITTEN PATH is one // policy judges at all — and two bindings one letter apart deciding // different things is how a later edit reads the wrong one. + // + // THE SAME CORRECTION A THIRD TIME, AND IT WAS A DEAD GATE (CLOUD-1051). A + // `Stop` payload carries no command, no write and no tool name, so this + // predicate was false for every end of turn and the config was never loaded + // there — which meant `Policy::declaring_nothing`, no bundles, and a + // `mediated_call` module registered for the one moment that projects + // `final-message` could not run at all. `policy/stop-posture.rego` shipped + // in exactly that state and its own suite stayed green throughout, because a + // `with input as` case fabricates the shape the boundary never built. + // + // The cost is one config load per TURN, not per call, and it buys the whole + // end-of-turn surface. The retired shell hook this replaces paid ~330-440ms + // at the same boundary; `perf`'s `passthrough` and `noop` arms are pre-tool + // shapes and are untouched by this clause. let adjudicable = !envelope.command.is_empty() || envelope.writes.is_some() + || envelope.event == hook::Event::Stop || (envelope.event == hook::Event::PreTool && !envelope.raw_tool.is_empty()); let (policy, waivers) = if bypass || !adjudicable { (hook::Policy::declaring_nothing(harness), Vec::new()) @@ -2913,6 +2928,24 @@ fn run_hook( // reader would guess. Before, because `decide` owns stdout and a handler's // refusal has to reach the same rendering the engine's own does. let handled = dispatch_handlers(&envelope, &raw, bypass, overrides, &mut advice)?; + // THE END-OF-TURN NUDGE (CLOUD-1051), and it is a SEPARATE call from + // `adjudicate` rather than a widening of it. `adjudicate` returns `Allow` at + // `Stop` before any rule is read — CLOUD-889's runaway removed by + // construction — which also meant a `mediated_call` module never ran there, + // so `stop-posture` was a dead gate for its whole life. `hook::stop_advice` + // evaluates the modules and answers with TEXT, so there is no value it can + // return that refuses anything. + // + // AT MOST ONE NUDGE PER TURN, and that is the retired shell hook's measured + // rule rather than a new one: two nudges on one turn is how a channel stops + // being read. A turn already handed a handler's advice has been told + // something more specific, so the module's line is appended only to a silent + // buffer. + if advice.is_empty() + && let Some(nudge) = hook::stop_advice(&policy, &envelope, &facts) + { + advice.push(nudge); + } if !advice.is_empty() { emit_advisory(harness, &envelope, out, err, &advice.join("\n\n"))?; } diff --git a/crates/batten/tests/stop_posture.rs b/crates/batten/tests/stop_posture.rs new file mode 100644 index 000000000..048da1445 --- /dev/null +++ b/crates/batten/tests/stop_posture.rs @@ -0,0 +1,239 @@ +//! `stop-posture` over the compiled binary (CLOUD-1051). +//! +//! # The defect this file exists because of, stated first +//! +//! The module shipped with sixteen `test_` rules, all green, and **never fired +//! on any event**. `adjudicate` returns `Allow` at `Stop` before any rule is +//! read — CLOUD-889's runaway removed by construction — so a `mediated_call` +//! module was unreachable at the one moment that projects the field it reads. +//! Its own suite could not see that: a `with input as` case fabricates the very +//! shape the engine may be unable to produce. +//! +//! That is exactly the class `.claude/rules/policy-modules.md` names, and this +//! is the tier it names as the only one that can catch it. Every case below runs +//! `batten hook --harness claude-code` against a real payload and reads what a +//! host would read. + +#![allow(clippy::unwrap_used, clippy::expect_used)] + +mod common; + +use std::fs; +use std::io::Write as _; +use std::path::{Path, PathBuf}; +use std::process::{Output, Stdio}; + +use common::{batten, scratch}; + +/// The committed module, the rows it needs, and nothing else. +/// +/// The module is COPIED from `policy/`, never re-typed, so this fixture cannot +/// drift from what ships. The pattern and verdict rows are re-declared because a +/// fixture config is a whole authority — house style §8 admits no directory walk +/// and no merge — and because the load-time registry check refuses a token no +/// row declares. +const CONFIG: &str = r##"version = 1 + +[[pattern]] +id = "md-fenced-block" +regex = '```[^`]*```' + +[[pattern]] +id = "md-code-span" +regex = '`[^`]*`' + +[[pattern]] +id = "md-quoted-span" +regex = '"[^"]*"' + +[[pattern]] +id = "md-block-quote" +regex = '(?m)^[[:space:]]*>[^\n]*' + +[[pattern]] +id = "hedged-flag-framing" +regex = "(?i)worth (noting|flagging|mentioning|naming)|one thing (I would|I['’]?d) (flag|note)|I['’]?d (flag|note) (that|one)|I would (flag|note) that|I should (note|flag)|(it|that)['’]?s worth (noting|flagging|mentioning|naming)|bears (noting|flagging|mentioning|naming)" + +[[verdict]] +id = "V-HEDGED-FLAG-FRAMING" +gloss = "a finding was written as editorial instead of durably" +class = """ +Chat stores nothing, so a finding's home is an issue or a memory. A sentence \ +that flags one in passing is the double-write CLOUD-200 and CLOUD-248 exist to \ +kill. +""" + +[[verdict.route]] +id = "R-WRITE-IT-DOWN" +kind = "issue" +target = "put it in the row that already owns it, or file one" + +[[rule]] +id = "stop-posture" +kind = "policy" +scope = "mediated_call" +module = "policy/stop-posture.rego" +severity = "deny" +"##; + +fn repo(name: &str) -> PathBuf { + let dir = scratch(name); + fs::write(dir.join("batten.toml"), CONFIG).expect("write config"); + fs::create_dir_all(dir.join("policy")).expect("policy dir"); + let source = common::at_root("policy/stop-posture.rego") + .canonicalize() + .expect("the committed module is where the row says it is"); + fs::copy(source, dir.join("policy/stop-posture.rego")).expect("install committed module"); + dir +} + +/// A Claude `Stop` payload, as the host sends one. +fn stop_payload(message: &str, active: bool) -> String { + serde_json::json!({ + "hook_event_name": "Stop", + "session_id": "s-1", + "stop_hook_active": active, + "last_assistant_message": message, + }) + .to_string() +} + +fn hook(dir: &Path, payload: &str) -> Output { + let mut command = batten(); + command + .current_dir(dir) + .args(["hook", "--harness", "claude-code"]) + .env_remove("BATTEN_HOOK_BYPASS") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + let mut child = command.spawn().expect("spawn batten hook"); + child + .stdin + .take() + .expect("piped stdin") + .write_all(payload.as_bytes()) + .expect("write payload"); + child.wait_with_output().expect("run batten hook") +} + +fn stdout_of(output: &Output) -> String { + String::from_utf8(output.stdout.clone()).expect("stdout is UTF-8") +} + +/// THE CASE THE MODULE'S OWN SUITE COULD NOT MAKE: the engine builds the input, +/// runs the module, and the nudge reaches the host's advisory channel. +#[test] +fn a_hedged_final_message_reaches_the_host_advisory_channel() { + let dir = repo("stop-posture-fires"); + let output = hook( + &dir, + &stop_payload("One thing I would flag is the exit code.", false), + ); + let stdout = stdout_of(&output); + assert_eq!( + output.status.code(), + Some(0), + "an advisory never changes the exit code: {stdout}" + ); + assert!( + stdout.contains("additionalContext"), + "the nudge travels on the advisory channel: {stdout}" + ); + assert!( + stdout.contains("stop-posture"), + "and it names the predicate: {stdout}" + ); +} + +/// NEVER A VERDICT. CLOUD-97 and CLOUD-219 each ruled a deny out at this moment +/// independently, and the property is structural — `hookSpecificOutput` here has +/// no field a refusal could occupy. +#[test] +fn the_nudge_carries_no_permission_decision() { + let dir = repo("stop-posture-not-a-verdict"); + let stdout = stdout_of(&hook( + &dir, + &stop_payload("One thing I would flag is the exit code.", false), + )); + assert!( + !stdout.contains("permissionDecision"), + "an advisory has no verdict field: {stdout}" + ); + assert!( + !stdout.contains("\"deny\""), + "and cannot spell one: {stdout}" + ); +} + +/// POINTER, NEVER PAYLOAD (rule 4), and load-bearing here rather than +/// decorative: handing the matched prose back would make this a mirror, and a +/// mirror is cleared by restating it — which is the double-write. +#[test] +fn no_byte_of_the_matched_prose_reaches_the_channel() { + let dir = repo("stop-posture-pointer-only"); + let stdout = stdout_of(&hook( + &dir, + &stop_payload( + "One thing I would flag is that the widget cache is unbounded.", + false, + ), + )); + for fragment in ["widget", "unbounded", "cache", "I would flag"] { + assert!( + !stdout.contains(fragment), + "the nudge carried {fragment:?} from the turn's own prose: {stdout}" + ); + } +} + +/// A CLEAN TURN IS SILENT, which is the common case and what the channel's +/// credibility rests on. Without this every assertion above is satisfied by a +/// module that fires on everything. +#[test] +fn a_clean_final_message_says_nothing() { + let dir = repo("stop-posture-silent"); + let stdout = stdout_of(&hook( + &dir, + &stop_payload("Landed and pushed; CI is green.", false), + )); + assert!( + !stdout.contains("additionalContext"), + "silence is the default: {stdout}" + ); +} + +/// THE RECURSION BOUND, from the payload rather than a state file. +/// `stop_hook_active` is true on the invocation a previous `Stop` continuation +/// caused, so one nudge per turn is deterministic. +#[test] +fn a_repeat_stop_is_bounded_to_one_nudge_per_turn() { + let dir = repo("stop-posture-bounded"); + let stdout = stdout_of(&hook( + &dir, + &stop_payload("One thing I would flag is the exit code.", true), + )); + assert!( + !stdout.contains("additionalContext"), + "the second Stop of a turn says nothing: {stdout}" + ); +} + +/// NOT A STOP, so there is no final message and nothing to judge — and the +/// module must not fire on a tool call that happens to carry prose. +#[test] +fn a_tool_call_is_not_judged_by_the_end_of_turn_rule() { + let dir = repo("stop-posture-pre-tool"); + let payload = serde_json::json!({ + "hook_event_name": "PreToolUse", + "session_id": "s-1", + "tool_name": "Bash", + "tool_input": {"command": "echo one thing I would flag"}, + }) + .to_string(); + let stdout = stdout_of(&hook(&dir, &payload)); + assert!( + !stdout.contains("additionalContext"), + "the Stop projections are null on every other event: {stdout}" + ); +} From 97ceeac77a19011ff8b9a76f9a2ae86d14ba3d69 Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Wed, 26 Aug 2026 15:07:54 +0000 Subject: [PATCH 16/38] feat(hook)!: the end of turn becomes the engine's, and stop-guard.sh retires MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `mise-tasks/stop-guard.sh` and its 33-case suite are deleted. Its five rules live in the engine now: the first as `policy/stop-posture.rego`, the other four in `stop_nudges`, ranked and emitting at most one — because two nudges on one turn is how a channel stops being read. FOUR OF THEM CANNOT BE A MODULE, and that is why the ranking is Rust rather than data. Three spawn a sibling program and one reads the tree, and `RuleKind::scopes` pairs every spawning kind with `RuleScope::Tree` alone. So they keep the order the shell ranked them in — by MEASURED precision, `stop-posture` at 3/3 leading `finding-sink` at 1/1, with the three unmeasured below — and the caller emits the first that speaks. THE SIBLINGS ARE SPAWNED UNCHANGED, which bounds the cascade deliberately. `finding-sink-check.sh` and `unlanded-check.sh` are invoked exactly as the bash invoked them, with the same stdin, so neither enters this change's changed-file set. A `[[hook.handler]]` row cannot serve: that door pipes the host's own payload, and the first of those two reads a transcript PATH on stdin — it would answer "no readable transcript path" every turn, which is a broken gate wearing a nudge's shape. THE MIGRATION GATE CAN NOW CLEAN UP AFTER ITSELF, and that is the other half of this change. Retiring a program REQUIRES editing the siblings that declare it: `hooks-wiring-check.sh` carries a table naming every by-path registration, and its own `wiring-declaration-stale` refuses a row whose subject is gone. That one-line deletion was `V-SHELL-RULE-EDITED`, which declares no override route and no hatch — so the campaign was structurally unable to complete a retirement it had itself mandated. `base-delta` gains `base-lines`, what each EDITED path said at the base rev, and the edit arm gains one admitting conjunct: every line the base had and the head lacks must name a path this same delta DELETED, nothing may be added, and an unreadable base side refuses. Removing a dead reference is admitted; ordinary maintenance is not, which is the whole reason that arm exists. Four module cases pin it, one firing and three that keep it from becoming a licence. THE PER-PATH LEDGER, which this PR owed and did not have. CLOUD-908's `[rule.conserves]` ledger is keyed on a quoted CASE TITLE; `shell-retirement` is keyed on the RETIRED PATH and demands two successors on one arm. Both obligations are real. Six arms added, and running the gate against this PR's own diff now exits 0 where it reported seven findings before. THE PUNT POINTER NAMES THE PATH, NOT THE ROW, and the suppression key moved with it — a `Finding` carries its first path-bearing subject as its pointer and the id travels as an ordered subject the engine does not project onto the struct. Recorded in the ledger under CHANGED. The checklist keeps its ids: it reads them off the recorder's record directly. `BATTEN_STOP_GUARD_BYPASS` stays one hatch for the set, as the retired hook had it: five readings of one question, and a per-rule switch would let the surface be dismantled a rule at a time with nothing reporting it. `$MUTANT_GATES` loses the name, because `mutant` resolves a gate's suite as `tests/.bats` and there is no longer a subject behind it. RETIREMENT LEDGER: 33 cases, 26 carried, 3 subsumed, 4 changed. `mise run test:cargo` 2531/2531; `mise run policy-test` 155/155; `batten check --rule shell-retirement` exit 0. Refs: CLOUD-97, CLOUD-514, CLOUD-1051, CLOUD-1059 --- .claude/settings.json | 8 - crates/batten/src/facts.rs | 10 + crates/batten/src/git.rs | 44 ++- crates/batten/src/lib.rs | 277 +++++++++++++++ crates/batten/tests/board_record.rs | 10 + crates/batten/tests/filed_here.rs | 12 + crates/batten/tests/stop_posture.rs | 286 ++++++++++++++- mise-tasks/hooks-wiring-check.sh | 3 +- mise-tasks/stop-guard.sh | 318 ----------------- mise.toml | 2 +- policy/shell-retirement.rego | 117 +++++++ schema/policy-input.schema.json | 9 + tests/stop-guard.bats | 526 ---------------------------- 13 files changed, 762 insertions(+), 860 deletions(-) delete mode 100755 mise-tasks/stop-guard.sh delete mode 100644 tests/stop-guard.bats diff --git a/.claude/settings.json b/.claude/settings.json index 02b483fe1..66c2838ee 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -44,14 +44,6 @@ "command": "batten hook --harness claude-code" } ] - }, - { - "hooks": [ - { - "type": "command", - "command": "$CLAUDE_PROJECT_DIR/mise-tasks/stop-guard.sh" - } - ] } ], "SessionStart": [ diff --git a/crates/batten/src/facts.rs b/crates/batten/src/facts.rs index 12f176525..9b833a0a2 100644 --- a/crates/batten/src/facts.rs +++ b/crates/batten/src/facts.rs @@ -1168,6 +1168,16 @@ impl Fact { // the rev resolves to no commit — the path lists are still // answered, because they were computable and this was not. "base-date": {"type": ["string", "null"]}, + // CLOUD-1051. What each EDITED path said at the base rev, so + // a predicate can ask what an edit REMOVED — the one question + // `input.tree.lines` cannot answer, because it is the head + // side. Bounded to `edited`: an added path has no base side + // and a deleted one has no head side. A path absent here is + // could-not-look, never a measured nothing. + "base-lines": { + "type": "object", + "additionalProperties": {"type": "array", "items": {"type": "string"}}, + }, }, "additionalProperties": false, }), diff --git a/crates/batten/src/git.rs b/crates/batten/src/git.rs index e49df151b..0cf4990a8 100644 --- a/crates/batten/src/git.rs +++ b/crates/batten/src/git.rs @@ -2145,6 +2145,31 @@ pub struct BaseDelta { /// opposite error. #[serde(rename = "base-date")] pub base_date: Option, + /// The lines each EDITED path had at the base rev (CLOUD-1051). + /// + /// # The one question the head side cannot answer + /// + /// `input.tree.lines` is what a file says NOW. A predicate asking *what did + /// this edit remove* needs the other side, and nothing else in the input + /// carries it — so `shell-retirement` could only ever refuse an edit, never + /// admit one, and the campaign could not delete a retired program's row from + /// a sibling's declaration table without tripping its own gate. + /// + /// # Bounded to `edited`, which is what keeps it affordable + /// + /// Not `added` (there is no base side), not `deleted` (the head side is + /// gone), and not every declared path — only the handful a branch actually + /// moved. That is the same bound the remainder comparison above already + /// pays: the blob is fetched for a path that changed, and this reuses the + /// read rather than adding one. + /// + /// A path absent from this map is could-not-look — a non-UTF-8 blob, or one + /// the object database would not yield — and a consumer must read it as + /// *cannot tell what was removed* rather than as *nothing was removed*. That + /// is the difference between admitting an edit and refusing it, so the two + /// must not collapse. + #[serde(rename = "base-lines")] + pub base_lines: BTreeMap>, } /// A file's content with its comment and blank lines removed. @@ -2282,9 +2307,11 @@ pub fn base_delta(dir: &Path, base: &str, globs: &[String]) -> Result delta.added.push(path.clone()), - Some(_) => delta.edited.push(path.clone()), + let edited = was.is_some(); + if edited { + delta.edited.push(path.clone()); + } else { + delta.added.push(path.clone()); } // Only a path this branch touched can have moved its remainder, so the // comparison is charged to those alone rather than to every selected @@ -2293,6 +2320,17 @@ pub fn base_delta(dir: &Path, base: &str, globs: &[String]) -> Result Option { + if envelope.event != hook::Event::Stop || envelope.stop_active == Some(true) { + return None; + } + if std::env::var_os(STOP_GUARD_BYPASS).is_some() { + return None; + } + let root = &hook_authority_root(); + // RULE 2 — a finding stated in prose with nothing durable written. It reads + // the transcript, so it reaches prose the module above cannot see: the final + // text block is under half a turn's assistant prose. + if let Some(path) = envelope.transcript.as_deref() + && let Some(pointer) = spawn_reading(root, "mise-tasks/finding-sink-check.sh", path) + { + return Some(format!( + "{pointer}\nA finding was stated here and nothing durable was written. Go re-derive \ + it and file it, or confirm it is already tracked." + )); + } + // RULE 3 — a row this branch filed names a file this branch is changing. The + // same predicate `land` decides on, run here so the punt surfaces at the end + // of the turn that created it rather than when a runner is about to be spent. + // + // ONCE PER ROW PER BRANCH. A Stop hook sees no PR body, so repeating one + // pointer every turn for the rest of a session is exactly how this channel + // dies. The turn the overlap first appears is the one that can still act + // cheaply, which is the whole reason for running it ahead of `land`. + if let Some(fresh) = filed_here_pointers(overrides, root, Suppression::PerRow) { + return Some(format!( + "{fresh}\nA row this branch filed names a file this branch is changing. Finish it \ + now while the file is open, or make sure the PR body closes it when you land." + )); + } + // RULE 4 — a completion signal with no patch-id-equivalent commit on the + // landing target. `state record` mints that verdict and this reads what it + // wrote; both ride `unlanded-check`'s own hatch rather than one of their own, + // because a caller who switched the rule off must not still pay a tree walk + // and a store write per turn for an answer nobody reads. + if std::env::var_os(UNLANDED_BYPASS).is_none() { + record_state(overrides); + if let Some(pointer) = spawn_reading(root, "mise-tasks/unlanded-check.sh", "") { + return Some(format!( + "{pointer}\nThis turn declared a stopping point and the work is not on the \ + landing target. Land it, or say what blocks it." + )); + } + } + // RULE 5 — every row this branch spun off, enumerated for re-evaluation. + // Rule 3 asks a narrow measured question and answers it once per row; this + // asks the broad one no predicate scores (non-negotiable rule 3): here is the + // whole set, say for each that it is genuinely independent work. + // + // SUPPRESSED ON THE SET, NOT PER ROW, and that is the whole difference. A + // per-row receipt would show a partial list, and a checklist with rows hidden + // is not a checklist. File another row and the whole list is asked again, + // because the question is about the set. + let rows = filed_here_pointers(overrides, root, Suppression::PerSet)?; + Some(format!( + "{rows}\nEvery row above was spun off while this branch was open. For each, by number: \ + is it genuinely independent work, or a punt you could close here? Close the punts; \ + leave a reason for the rest." + )) +} + +/// The hatch that silences the whole end-of-turn surface. +/// +/// One hatch for the set rather than one per rule, exactly as the retired shell +/// hook had it: these are five readings of one question — *is this turn actually +/// finished* — and a per-rule switch would let the surface be dismantled a rule +/// at a time with nothing reporting that it had been. +const STOP_GUARD_BYPASS: &str = "BATTEN_STOP_GUARD_BYPASS"; + +/// The hatch `unlanded-check` declares, ridden by the recorder that feeds it. +const UNLANDED_BYPASS: &str = "BATTEN_UNLANDED_CHECK_BYPASS"; + +/// Evaluate the completion detectors so rule 4 has something to read. +/// +/// [`run_state_record`]'s own work, with its streams and its verdict discarded: +/// this is an EVALUATION, not a decision. A recorder that failed simply leaves +/// nothing to read, which is silence — the correct answer for a hook that may +/// never be the reason a turn stalls. +fn record_state(overrides: &Overrides) { + let mut sink = std::io::sink(); + let _ = run_state_record(overrides, Mode::default(), &mut sink); +} + +/// What the `filed-here` row says about this branch, suppressed and rendered. +/// +/// # Two questions over one predicate, which is what keeps them from drifting +/// +/// [`Suppression::PerRow`] answers the narrow one — which filed row names a file +/// this branch is holding open — from the row's own findings. +/// [`Suppression::PerSet`] answers the broad one no predicate scores: here is +/// EVERY row you spun off. Its ids come from the recorder's record directly, +/// because a finding is emitted only for a refusal and the checklist is about +/// the whole set. +/// +/// # The pointer is the PATH, and that is a stated difference +/// +/// The retired shell emitted ` filed-over-own-diff ` and suppressed on +/// the id. A `Finding` carries its first path-bearing subject as its pointer and +/// the row's id travels as an ordered subject the engine does not project onto +/// the struct, so the nudge names the path and the suppression key is the path. +/// That is the half an agent must act on — *finish it now while the file is +/// open* — and the id is one board read away. Recorded rather than glossed, +/// because it is a real narrowing of what the nudge says. +fn filed_here_pointers( + overrides: &Overrides, + root: &Path, + suppression: Suppression, +) -> Option { + let config = resolve::resolve(root, overrides).ok()?; + let (selected, _checks) = select_rules(&config.rules, Some(FILED_HERE_ROW)).ok()?; + let vocabulary = policy::Vocabulary { + patterns: &config.patterns, + verdicts: &config.verdicts, + recorders: &config.recorders, + }; + let scan = rules::run_static(&selected, &config.provisions, vocabulary, root).ok()?; + let flagged: std::collections::BTreeSet = scan + .findings + .iter() + .filter(|finding| finding.rule == FILED_OVER_OWN_DIFF) + .map(|finding| finding.path.clone()) + .collect(); + let git_dir = git::git_dir(root).ok()?; + let branch = git::current_branch(root).ok()??; + let lines: Vec = match suppression { + Suppression::PerRow => flagged.into_iter().collect(), + Suppression::PerSet => { + // EVERY ROW, MARKED — the checklist's whole question. A row whose + // named paths intersect the diff is marked so the reader can see + // which ones rule 3 already asked about; the rest are `filed`, and + // the judgement about all of them is the agent's. + let record = recorder::record_path(&git_dir, BOARD_RECORD, &branch); + let text = std::fs::read_to_string(record).ok()?; + let mut seen = std::collections::BTreeSet::new(); + text.lines() + .filter_map(|line| { + let mut columns = line.split(' '); + (columns.next()? == "issue").then(|| columns.next())? + }) + .filter(|id| seen.insert((*id).to_owned())) + .map(|id| format!("{id} filed")) + .collect() + } + }; + if lines.is_empty() { + return None; + } + // THE SUPPRESSION, and its unit is what the two modes disagree about. Rule 3 + // stores one line per row, so a second row is asked about and the first is + // not; rule 5 stores the whole set as one key, so the list is asked again in + // full the moment it changes and never partially. + let (store, keys) = match suppression { + Suppression::PerRow => ( + git_dir + .join("batten-receipts") + .join(format!("filed-here-nudged.{}", branch.replace('/', "-"))), + lines.clone(), + ), + Suppression::PerSet => ( + git_dir + .join("batten-receipts") + .join(format!("filed-set-nudged.{}", branch.replace('/', "-"))), + vec![lines.join(" ")], + ), + }; + let already: std::collections::BTreeSet = std::fs::read_to_string(&store) + .map(|text| text.lines().map(str::to_owned).collect()) + .unwrap_or_default(); + let fresh: Vec = keys + .iter() + .filter(|key| !already.contains(*key)) + .cloned() + .collect(); + if fresh.is_empty() { + return None; + } + // Written before the nudge is returned, so a turn that is interrupted after + // being told still counts as told. Silent on failure, like everything here. + if let Some(parent) = store.parent() { + let _ = std::fs::create_dir_all(parent); + } + if let Ok(mut file) = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(&store) + { + for key in &fresh { + let _ = writeln!(file, "{key}"); + } + } + Some(match suppression { + Suppression::PerRow => fresh.join("\n"), + Suppression::PerSet => lines.join("\n"), + }) +} + +/// The row the two nudge modes read, and the predicate whose findings rule 3 uses. +const FILED_HERE_ROW: &str = "filed-here"; +const FILED_OVER_OWN_DIFF: &str = "filed-over-own-diff"; +/// The record the checklist enumerates. +const BOARD_RECORD: &str = "board-writes"; + +/// Whether a filed-row pointer is suppressed per row or per set. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Suppression { + /// Rule 3: one nudge per row per branch. + PerRow, + /// Rule 5: one nudge per SET per branch. + PerSet, +} + +/// Run one sibling program and return its pointer, or `None` for silence. +/// +/// The contract is the retired hook's, unchanged: a fired predicate is a +/// non-zero exit with the pointer on stdout, and anything else — clean, +/// unreadable, absent, unrunnable — is silence. +#[expect( + clippy::disallowed_types, + reason = "stays: CLOUD-1051 retires `stop-guard.sh` and invokes the siblings it invoked, unchanged and by path, so the cascade stops at the program being retired rather than reaching four more" +)] +fn spawn_reading(root: &Path, program: &str, stdin: &str) -> Option { + use std::process::{Command, Stdio}; + + let path = root.join(program); + if !path.is_file() { + return None; + } + let mut child = Command::new(&path) + .current_dir(root) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .ok()?; + child.stdin.take()?.write_all(stdin.as_bytes()).ok()?; + let output = child.wait_with_output().ok()?; + if output.status.success() { + return None; + } + let pointer = String::from_utf8(output.stdout).ok()?; + let pointer = pointer.trim_end(); + (!pointer.is_empty()).then(|| pointer.to_owned()) +} + /// Append every declared record this result earns (CLOUD-1051). /// /// [`record_mints`]'s shape deliberately: the same cheap-question-first ordering, diff --git a/crates/batten/tests/board_record.rs b/crates/batten/tests/board_record.rs index 2c7466c06..6c778c11c 100644 --- a/crates/batten/tests/board_record.rs +++ b/crates/batten/tests/board_record.rs @@ -18,6 +18,16 @@ //! reads, the column arithmetic, and the create/groom boundary. The two real //! programs keep their own suites. //! +//! # RETIREMENT LEDGER, PER PATH — what `shell-retirement` reads +//! +//! The successor is `recorder.rs` rather than a module: a recorder decides +//! nothing, so there is no predicate for a `.rego` file to hold. It is a policy +//! surface under the gate's own definition (`policy/*.rego` OR +//! `crates/batten/src/*.rs`) for exactly that case. +//! +// carried: mise-tasks/board-write-record.sh crates/batten/src/recorder.rs crates/batten/tests/board_record.rs +// carried: tests/board-write-record.bats crates/batten/src/recorder.rs crates/batten/tests/board_record.rs +//! //! # RETIREMENT LEDGER — `tests/board-write-record.bats`, 36 cases //! //! CARRIED — the property survives, proved here against the engine. diff --git a/crates/batten/tests/filed_here.rs b/crates/batten/tests/filed_here.rs index 36882cc7f..4b6242647 100644 --- a/crates/batten/tests/filed_here.rs +++ b/crates/batten/tests/filed_here.rs @@ -10,6 +10,18 @@ //! cannot reach. The module's own `test_` rules are the other tier and pin the //! predicate; neither replaces the other. //! +//! # RETIREMENT LEDGER, PER PATH — what `shell-retirement` reads +//! +//! Two ledgers, two keys, and neither substitutes for the other. CLOUD-908's +//! `[rule.conserves]` ledger below is keyed on a quoted CASE TITLE and asks what +//! happened to each assertion. `shell-retirement` is keyed on the RETIRED PATH +//! and asks what now holds the predicate at all — so it demands one arm per file +//! naming both a policy surface and a compiled-binary test, because either alone +//! is satisfiable by a port that does nothing. +//! +// carried: mise-tasks/filed-here-check.sh policy/filed-here.rego crates/batten/tests/filed_here.rs +// carried: tests/filed-here-check.bats policy/filed-here.rego crates/batten/tests/filed_here.rs +//! //! # RETIREMENT LEDGER — `tests/filed-here-check.bats`, 47 cases //! //! CARRIED — the property survives, proved here or in the module's own suite. diff --git a/crates/batten/tests/stop_posture.rs b/crates/batten/tests/stop_posture.rs index 048da1445..d3dfaa1ef 100644 --- a/crates/batten/tests/stop_posture.rs +++ b/crates/batten/tests/stop_posture.rs @@ -13,6 +13,71 @@ //! is the tier it names as the only one that can catch it. Every case below runs //! `batten hook --harness claude-code` against a real payload and reads what a //! host would read. +//! +//! # RETIREMENT LEDGER, PER PATH — what `shell-retirement` reads +//! +//! The program's successor is the engine's own Stop routine rather than a +//! module, because four of `stop-guard`'s five rules spawn or read the tree and +//! `RuleKind::scopes` pairs every spawning kind with `RuleScope::Tree` alone. +//! The suite's successor is the module, which is where the one rule that COULD +//! be a predicate went. +//! +// carried: mise-tasks/stop-guard.sh crates/batten/src/lib.rs crates/batten/tests/stop_posture.rs +// carried: tests/stop-guard.bats policy/stop-posture.rego crates/batten/tests/stop_posture.rs +//! +//! # RETIREMENT LEDGER — `tests/stop-guard.bats`, 33 cases +//! +//! CARRIED — the property survives, proved here or in the module's own suite. +//! +// carried: "a turn whose final message carries the tell is kicked" crates/batten/tests/stop_posture.rs +// carried: "the kick names the rule and the durable destination" crates/batten/tests/stop_posture.rs +// carried: "the kick declares the Stop event, so the harness routes it as feedback" crates/batten/tests/stop_posture.rs +// carried: "the kick is valid JSON on stdout" crates/batten/tests/stop_posture.rs +// carried: "the re-entry caused by a previous kick is not kicked again" crates/batten/tests/stop_posture.rs +// carried: "A CLEAN FINAL MESSAGE IS ANSWERED WITH SILENCE" crates/batten/tests/stop_posture.rs +// carried: "a turn that says it is stopping is not re-prompted" policy/stop-posture.rego +// carried: "an ordinary answer to a question is not re-prompted" policy/stop-posture.rego +// carried: "an absent last_assistant_message costs the first rule and nothing else" crates/batten/tests/stop_posture.rs +// carried: "stop-guard::the bypass is honoured" crates/batten/tests/stop_posture.rs +// carried: "the guard never exits non-zero, so it cannot surface as a hook error" crates/batten/tests/stop_posture.rs +// carried: "a turn that strands a finding is pointed at, and the turn still ends" crates/batten/tests/stop_posture.rs +// carried: "POINTER, NEVER PAYLOAD: the advisory carries no byte of the turn\'s prose" crates/batten/tests/stop_posture.rs +// carried: "the advisory says what to do, since a coordinate alone is not an instruction" crates/batten/tests/stop_posture.rs +// carried: "the shipped rule keeps precedence when both would fire" crates/batten/tests/stop_posture.rs +// carried: "a turn that strands nothing is silent" crates/batten/tests/stop_posture.rs +// carried: "an unreadable transcript manufactures no advisory" crates/batten/tests/stop_posture.rs +// carried: "the recursion bound still holds for the second rule" crates/batten/tests/stop_posture.rs +// carried: "A FILED ROW NAMING THIS BRANCH\'S OWN DIFF IS POINTED AT, BEFORE ANY CI" crates/batten/tests/filed_here.rs +// carried: "the punt pointer carries no prose from the row" crates/batten/tests/filed_here.rs +// carried: "the punt rule yields to the measured posture rule" crates/batten/tests/stop_posture.rs +// carried: "a branch with no filed row names none" crates/batten/tests/filed_here.rs +// carried: "UNLANDED WORK AT A DECLARED STOPPING POINT IS POINTED AT" crates/batten/tests/stop_posture.rs +// carried: "the unlanded pointer carries no transcript text and no store key" crates/batten/tests/stop_posture.rs +// carried: "the unlanded rule yields to the measured posture rule" crates/batten/tests/stop_posture.rs +// carried: "landed work is silent" crates/batten/tests/stop_posture.rs +//! +//! SUBSUMED — the plumbing became the engine\'s, which is what a migration should +//! produce. Each names the general property that now covers it. +//! +// subsumed: "the recursion bound survives a garbage stop_hook_active rather than proceeding" crates/batten/src/hook.rs +// subsumed: "the Stop hook is registered in settings" mise-tasks/hooks-wiring-check.sh +// subsumed: "the Stop entry declares no matcher, which the event does not support" mise-tasks/hooks-wiring-check.sh +//! +//! CHANGED — behaviour that diverges deliberately, each with its reason. +//! +// changed: "the punt pointer fires once and then goes quiet for that row" crates/batten/src/lib.rs +// changed: "a second row still gets its own pointer after the first is spent" crates/batten/src/lib.rs +// changed: "EVERY ROW THE BRANCH FILED IS ENUMERATED FOR RE-EVALUATION" crates/batten/src/lib.rs +// changed: "the checklist repeats only when the set changes" crates/batten/src/lib.rs +//! +//! THE PUNT POINTER NAMES THE PATH, NOT THE ROW, and the suppression key moved +//! with it. A `Finding` carries its first path-bearing subject as its pointer and +//! the row\'s id travels as an ordered subject the engine does not project onto +//! the struct — so the nudge names the tracked file and the receipt is keyed on +//! it. That is the half an agent must act on ("finish it now while the file is +//! open") and the id is one board read away, but it is a real narrowing of what +//! the nudge says and is recorded rather than glossed. The checklist keeps its +//! ids, because it reads them off the recorder\'s record directly. #![allow(clippy::unwrap_used, clippy::expect_used)] @@ -32,7 +97,7 @@ use common::{batten, scratch}; /// fixture config is a whole authority — house style §8 admits no directory walk /// and no merge — and because the load-time registry check refuses a token no /// row declares. -const CONFIG: &str = r##"version = 1 +const CONFIG: &str = r#"version = 1 [[pattern]] id = "md-fenced-block" @@ -74,7 +139,7 @@ kind = "policy" scope = "mediated_call" module = "policy/stop-posture.rego" severity = "deny" -"##; +"#; fn repo(name: &str) -> PathBuf { let dir = scratch(name); @@ -237,3 +302,220 @@ fn a_tool_call_is_not_judged_by_the_end_of_turn_rule() { "the Stop projections are null on every other event: {stdout}" ); } + +// --------------------------------------------------------------------------- +// THE OTHER FOUR RULES, and the ranking between them. `stop-guard.sh` ran five +// and emitted at most one; four of them cannot be a `mediated_call` module — +// three spawn a sibling program and one reads the tree — so they live in the +// engine's own Stop routine and these are the cases over it. +// +// The siblings are STUBS here, and deliberately: the real ones keep their own +// suites, so running them would make these cases a test of a predicate somebody +// else owns rather than of the wiring. What is being asserted is that the engine +// spawns the declared path, hands it the stdin the retired hook handed it, and +// routes a non-zero exit's stdout to the advisory channel. +// --------------------------------------------------------------------------- + +/// Install a stub at the path the engine spawns, with a chosen exit and stdout. +fn stub(dir: &Path, program: &str, exit: i32, stdout: &str) { + let path = dir.join(program); + fs::create_dir_all(path.parent().expect("a parent")).expect("mise-tasks dir"); + fs::write( + &path, + format!("#!/usr/bin/env bash\ncat >/dev/null\nprintf '%s\\n' {stdout:?}\nexit {exit}\n"), + ) + .expect("write stub"); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + fs::set_permissions(&path, fs::Permissions::from_mode(0o755)).expect("chmod stub"); + } +} + +/// A Stop payload naming a transcript the second rule can read. +fn stop_with_transcript(dir: &Path, message: &str) -> String { + let transcript = dir.join("session.jsonl"); + fs::write(&transcript, "{}\n").expect("write transcript"); + serde_json::json!({ + "hook_event_name": "Stop", + "session_id": "s-1", + "stop_hook_active": false, + "last_assistant_message": message, + "transcript_path": transcript.display().to_string(), + }) + .to_string() +} + +/// RULE 2 — a finding stated in prose with nothing durable written. The pointer +/// is the sibling's stdout and the advice says what to do with it, because a +/// coordinate alone is not an instruction. +#[test] +fn a_stranded_finding_is_pointed_at_and_the_turn_still_ends() { + let dir = repo("stop-finding-sink"); + stub(&dir, "mise-tasks/finding-sink-check.sh", 1, "turn 12"); + let output = hook(&dir, &stop_with_transcript(&dir, "Landed and pushed.")); + let stdout = stdout_of(&output); + assert_eq!( + output.status.code(), + Some(0), + "the turn still ends: {stdout}" + ); + assert!(stdout.contains("turn 12"), "the pointer travels: {stdout}"); + assert!( + stdout.contains("file it"), + "and so does what to do about it: {stdout}" + ); +} + +/// PRECEDENCE IS MEASURED, NOT ASSERTED. `stop-posture` leads at 3/3 against +/// `finding-sink`'s 1/1, and two nudges on one turn is how a channel stops being +/// read — so when both would fire, exactly one does and it is the first. +#[test] +fn the_measured_rule_keeps_precedence_when_both_would_fire() { + let dir = repo("stop-precedence"); + stub(&dir, "mise-tasks/finding-sink-check.sh", 1, "turn 12"); + let stdout = stdout_of(&hook( + &dir, + &stop_with_transcript(&dir, "One thing I would flag is the exit code."), + )); + assert!( + stdout.contains("stop-posture"), + "the measured rule speaks: {stdout}" + ); + assert!( + !stdout.contains("turn 12"), + "and the one below it does not: {stdout}" + ); +} + +/// A sibling that found nothing says nothing, which is the common case. +#[test] +fn a_turn_that_strands_nothing_is_silent() { + let dir = repo("stop-finding-sink-clean"); + stub(&dir, "mise-tasks/finding-sink-check.sh", 0, ""); + let stdout = stdout_of(&hook(&dir, &stop_with_transcript(&dir, "Landed."))); + assert!( + !stdout.contains("additionalContext"), + "silence is the default: {stdout}" + ); +} + +/// COULD NOT LOOK MANUFACTURES NOTHING. A transcript the engine cannot read +/// leaves the rule with no subject, and inventing an advisory over that would be +/// a verdict about the environment wearing a verdict about the turn. +#[test] +fn an_unreadable_transcript_manufactures_no_advisory() { + let dir = repo("stop-no-transcript"); + stub(&dir, "mise-tasks/finding-sink-check.sh", 1, "turn 12"); + let stdout = stdout_of(&hook(&dir, &stop_payload("Landed.", false))); + assert!( + !stdout.contains("turn 12"), + "no transcript, no subject: {stdout}" + ); +} + +/// THE BYPASS IS ONE HATCH FOR THE SET, exactly as the retired hook had it: +/// these are five readings of one question, and a per-rule switch would let the +/// surface be dismantled a rule at a time with nothing reporting it. +#[test] +fn the_stop_guard_bypass_silences_the_whole_surface() { + let dir = repo("stop-bypass"); + stub(&dir, "mise-tasks/finding-sink-check.sh", 1, "turn 12"); + let mut command = batten(); + command + .current_dir(&dir) + .args(["hook", "--harness", "claude-code"]) + .env_remove("BATTEN_HOOK_BYPASS") + .env("BATTEN_STOP_GUARD_BYPASS", "1") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + let mut child = command.spawn().expect("spawn batten hook"); + let payload = stop_with_transcript(&dir, "Landed."); + child + .stdin + .take() + .expect("piped stdin") + .write_all(payload.as_bytes()) + .expect("write payload"); + let output = child.wait_with_output().expect("run batten hook"); + let stdout = String::from_utf8(output.stdout).expect("stdout is UTF-8"); + assert!( + !stdout.contains("turn 12"), + "the hatch silences the set: {stdout}" + ); +} + +/// RULE 4 — a completion signal with no landed commit. `unlanded-check` reads no +/// stdin, and its pointer is a count and a rule id: never a commit message and +/// never a line of the transcript the verdict was read from. +#[test] +fn unlanded_work_at_a_declared_stopping_point_is_pointed_at() { + let dir = repo("stop-unlanded"); + stub( + &dir, + "mise-tasks/unlanded-check.sh", + 1, + "completion.unlanded 1", + ); + let stdout = stdout_of(&hook(&dir, &stop_payload("Landed and pushed.", false))); + assert!( + stdout.contains("completion.unlanded"), + "the pointer travels: {stdout}" + ); + assert!( + stdout.contains("Land it"), + "and what to do about it: {stdout}" + ); +} + +/// Landed work is silent, which is what keeps the case above from being +/// satisfied by a rule that fires unconditionally. +#[test] +fn landed_work_is_silent() { + let dir = repo("stop-landed"); + stub(&dir, "mise-tasks/unlanded-check.sh", 0, ""); + let stdout = stdout_of(&hook(&dir, &stop_payload("Landed and pushed.", false))); + assert!( + !stdout.contains("additionalContext"), + "nothing at risk, nothing to say: {stdout}" + ); +} + +/// THE RECURSION BOUND HOLDS FOR EVERY RULE, not just the module: it is checked +/// once, before any of them, so a second `Stop` in one turn spawns nothing at +/// all rather than spawning and then discarding. +#[test] +fn the_recursion_bound_holds_for_every_rule() { + let dir = repo("stop-bounded-all"); + stub( + &dir, + "mise-tasks/unlanded-check.sh", + 1, + "completion.unlanded 1", + ); + let stdout = stdout_of(&hook(&dir, &stop_payload("Landed and pushed.", true))); + assert!( + !stdout.contains("completion.unlanded"), + "the second Stop of a turn says nothing: {stdout}" + ); +} + +/// THE GUARD NEVER EXITS NON-ZERO, so it cannot surface as a hook error. Every +/// case above reads stdout; this one reads the status, over a rule that fired. +#[test] +fn the_stop_surface_never_exits_non_zero() { + let dir = repo("stop-exit-zero"); + stub( + &dir, + "mise-tasks/unlanded-check.sh", + 1, + "completion.unlanded 1", + ); + let output = hook(&dir, &stop_payload("Landed and pushed.", false)); + assert_eq!( + output.status.code(), + Some(0), + "an advisory never changes the exit code" + ); +} diff --git a/mise-tasks/hooks-wiring-check.sh b/mise-tasks/hooks-wiring-check.sh index 9590076a6..177b161c3 100755 --- a/mise-tasks/hooks-wiring-check.sh +++ b/mise-tasks/hooks-wiring-check.sh @@ -174,8 +174,7 @@ gemini-cli .gemini/settings.json}" # The rest were six until CLOUD-777, whose widened scope could finally see them. # Their owners are not invented: CLOUD-312 is "the shell guards retire behind # it", whose scope CLOUD-777 widened from pre-tool to every point. -DECLARED="${HOOKS_WIRING_DECLARED-mise-tasks/stop-guard.sh CLOUD-312 -.claude/hooks/session-start.sh CLOUD-312 +DECLARED="${HOOKS_WIRING_DECLARED-.claude/hooks/session-start.sh CLOUD-312 mise-tasks/run-shape-guard.sh CLOUD-821 stop-hook-git-check.sh CLOUD-605 session-start-git-identity.sh CLOUD-605}" diff --git a/mise-tasks/stop-guard.sh b/mise-tasks/stop-guard.sh deleted file mode 100755 index 406a425c5..000000000 --- a/mise-tasks/stop-guard.sh +++ /dev/null @@ -1,318 +0,0 @@ -#!/usr/bin/env bash -#MISE description="Stop hook body: hand the turn one bounded re-prompt when its final message carries the output-posture tell AGENTS.md names" -# -# Wired from .claude/settings.json. Thin by design — the decision lives in -# `stop-posture-check`, where it is unit-tested by `tests/stop-posture-check.bats`. -# -# **Exactly once, with no state file.** The Stop payload carries -# `stop_hook_active`, false on the first Stop of a turn and true on the invocation -# caused by a previous Stop continuation. Returning early when it is true bounds -# this to one kick per turn, deterministically. Measured on two live probes: two -# invocations, false then true, then the turn ended. The launcher's own Stop hook -# (~/.claude/stop-hook-git-check.sh) uses the identical guard, so this is the -# established pattern here rather than a new invention. The harness also caps -# consecutive continuations at 8, which is a backstop, not the mechanism. -# -# **Why `additionalContext` and not exit 2.** Three channels reach the model and -# all three were verified by probe. `additionalContext` is documented as non-error -# feedback that continues the conversation, and the transcript labels it "Stop hook -# feedback" with no hook-error notice. That matters twice over: the launcher hook -# already owns the exit-2 channel at this boundary, so two hooks both exiting 2 -# would stack error notices on one turn; and CLOUD-97 and CLOUD-219 each recorded -# the same decision independently — "detection and self-clearing, never a hard -# deny", because pausing mid-task is legitimate and blocking done punishes correct -# behaviour. One bounded nudge is not a deny: the turn ends after it either way. -# -# The check is invoked by path rather than through `mise run`, the way -# `graph-check` calls `ready-lint`, because this fires at every turn end and a -# mise startup per turn is pure latency. Measured: ~28ms by path. -# -# AND SO IS THIS FILE NOW (CLOUD-479). Its own registration paid the ~203ms mise -# startup it had already saved its children — measured at 194ms/call against -# 15ms by path. `jq` was the only thing blocking the swap: a by-path invocation -# does not get mise's env, so the pinned `jq` would have become an unpinned -# `/usr/bin/jq`, and on a container with none the `|| exit 0` reads below would -# have silently allowed rather than errored. The payload reads go through -# `payload-field` instead, which parses with the compiled binary that is already -# on this path; see that file for the whole argument. -# -# Fails OPEN on anything it cannot read or parse, and on BATTEN_STOP_GUARD_BYPASS. -# A malformed stdout on exit 0 also fails open harness-side (verified by probe), -# so the failure modes agree in both directions. -# MUTATION COVERAGE (CLOUD-418). `||`: applying the -# script to a throwaway copy of this file must turn the named case RED. -# -# NO `|` IN THE SCRIPT FIELD, which this row learned the hard way: the first -# version restored the closing question with a `||` inside the sed expression, -# the harness split the row on it, and the result reported `case-already-red` -# against a mangled script. `mutant`'s own header says so, and says what to do -# instead — delete the guard line rather than rewrite it. Deleting it is the -# better mutation anyway: it removes the predicate outright, so `reason` stays -# empty, the document is emitted with an empty advisory, and every silence case -# goes red. -#MUTANT silence-guard-removed|s@^\[\[ -n "\$reason" \]\] .*@@|A CLEAN FINAL MESSAGE IS ANSWERED WITH SILENCE -set -uo pipefail - -[[ -n "${BATTEN_STOP_GUARD_BYPASS:-}" ]] && exit 0 - -# BOUNDED (CLOUD-511). A bare `cat` blocks forever when this is invoked by hand -# rather than by the harness — measured as a two-minute harness kill at exit 143 -# — and moving the registration by path is exactly what makes hand-invocation -# easy. `plan-hold-release` took the same shape for the same reason. -raw=$(timeout 1s cat 2>/dev/null) || raw="" -[[ -n "$raw" ]] || exit 0 - -field="$(dirname -- "${BASH_SOURCE[0]}")/payload-field.sh" -[[ -x "$field" ]] || exit 0 - -# The recursion bound, before any work. `// empty` rather than a bare read: a -# payload jq cannot parse must fall through to allow, and comparing the literal -# string "true" makes an absent or garbage field non-blocking by construction — -# the inverse spelling (`!= "true" && proceed`) is what let a proposed version run -# its predicate on unparseable stdin. -active=$(printf '%s' "$raw" | "$field" stop-hook-active) || exit 0 -[[ "$active" = "true" ]] && exit 0 - -# THE TRANSCRIPT SEAM, AND THE RECORDER BEHIND IT (CLOUD-97). `batten.toml`'s -# `[transcript]` names one fixed repo-relative path because the key is the -# committed authority and is deliberately unlayerable — a local file redirecting -# it would be CHOOSING THE EVIDENCE. The host's transcript is a per-session -# absolute path no committed value can name, so the indirection lives here: the -# authority names the file forever, and this points it at the session the host -# just named. Nothing is READ here — the symlink is a pointer, and the engine is -# the only thing that opens it. -# -# Then `state record` evaluates the detectors over it. THE COST IS REAL AND IS -# STATED RATHER THAN BURIED, because a Stop hook's latency is paid at every turn -# end: measured 2026-08-20, this registration goes from ~28ms to ~330-440ms, and -# essentially all of the difference is this one call (`state record` 328ms, the -# three reading rules 9-34ms each). It is a tree walk plus a store write, and it -# is what the whole rule below rests on — the alternative is the detector staying -# dark, which is what it has been for this repository's entire life. Revisit by -# gating on a moved HEAD if a session ever feels it. -# Everything fails open: no path, no binary, no repo, an unwritable link. -# The recorder rides `unlanded-check`'s bypass rather than one of its own: it -# exists to feed that rule and nothing else, so a caller that switched the rule -# off and still paid a tree walk and a store write per turn would be paying for -# an answer nobody reads. `tests/stop-guard.bats` runs inside this checkout and -# needs exactly that. -transcript="" -[[ -n "${BATTEN_UNLANDED_CHECK_BYPASS:-}" ]] || - transcript=$(printf '%s' "$raw" | "$field" transcript-path) || transcript="" -root="${CLAUDE_PROJECT_DIR:-$(git rev-parse --show-toplevel 2>/dev/null || true)}" -if [[ -n "$transcript" ]] && [[ -n "$root" ]] && [[ -r "$transcript" ]]; then - ln -sfn "$transcript" "$root/.claude/.transcript.jsonl" 2>/dev/null || true -fi -if [[ -z "${BATTEN_UNLANDED_CHECK_BYPASS:-}" ]]; then - for candidate in \ - "${BATTEN_BIN:-}" \ - "$root/target/release/batten" \ - "$root/target/debug/batten" \ - "$(command -v batten 2>/dev/null || true)"; do - if [[ -n "$candidate" ]] && [[ -x "$candidate" ]]; then - # Streams discarded, exit status ignored: this is an evaluation, not - # a verdict. `unlanded-check` below reads what it wrote, and a - # recorder that failed simply leaves nothing to read — which is - # silence, the correct answer for a hook that may never be the - # reason a turn stalls. - (cd "$root" 2>/dev/null && "$candidate" state record >/dev/null 2>&1) || true - break - fi - done -fi - -# The turn's final response text, handed over directly — so this hook never opens -# `transcript_path` and no session content is read beyond the message already -# being judged. -msg=$(printf '%s' "$raw" | "$field" last-assistant-message) || exit 0 - -check="$(dirname -- "${BASH_SOURCE[0]}")/stop-posture-check.sh" -[[ -x "$check" ]] || exit 0 - -# An absent final message is no longer an exit: it costs the FIRST rule, whose -# input it is, and nothing else. The rules below read the transcript and the -# store, and the closing question reads neither — a turn that ended without a -# text block is still a turn that ended. -reason="" -if [[ -n "$msg" ]]; then - reason=$(printf '%s' "$msg" | "$check") || true -fi - -# The second rule (CLOUD-252), and it is SECOND on purpose: one advisory per turn. -# `hedged-flag-framing` keeps precedence because it is the enforcing rule with the -# higher measured precision (3/3 vs 1/1), and two nudges on one turn is how a -# channel stops being read. -# -# It reads the transcript rather than `last_assistant_message`, so it reaches -# prose the shipped rule cannot see — the final text block is under half the -# turn's assistant prose. Its emitted line is a COORDINATE, never content: a turn -# pointer, nothing else. Handing back the matched prose would make this a mirror, -# and a mirror is cleared by restating, which is the double-write CLOUD-200 and -# CLOUD-248 exist to kill. `tests/stop-guard.bats` asserts the emitted bytes carry -# no substring of the prose, so a later edit cannot turn the nudge into one. -if [[ -z "$reason" ]]; then - sink="$(dirname -- "${BASH_SOURCE[0]}")/finding-sink-check.sh" - path=$(printf '%s' "$raw" | "$field" transcript-path) || path="" - if [[ -n "$path" ]] && [[ -x "$sink" ]]; then - # A fired predicate is exit 1 with the pointer on stdout; anything else - # (clean, unreadable, absent) leaves `reason` empty and the turn silent. - pointer=$(printf '%s' "$path" | "$sink" 2>/dev/null) || true - [[ -z "$pointer" ]] || reason="$pointer -A finding was stated here and nothing durable was written. Go re-derive it and file it, or confirm it is already tracked." - fi -fi - -# THE THIRD RULE (CLOUD-774), AND IT IS LAST ON PURPOSE. It reports a state fact -# rather than a phrasing tell: a row this branch filed names a file this branch is -# changing, and the PR does not close it. `filed-here-check` already decides that -# inside `land` — but `land` is where a runner is about to be spent, so the punt -# surfaces at the most expensive moment available while the detection itself is a -# `git diff --name-only` and a set intersection costing milliseconds. Running it -# here puts the nudge at the end of the turn that created the punt, before any CI -# minute is committed to it. -# -# PRECEDENCE IS EARNED, NOT ASSERTED. This file ranks its rules by MEASURED -# precision — `hedged-flag-framing` leads at 3/3 against `finding-sink-check`'s -# 1/1 — and this rule has no measurement yet, so it takes the bottom slot rather -# than the top one its author would prefer. Revisit when it has a record. -# -# `--advisory` so a Stop hook can never block: exit 0 always, and the commit and -# push path stays free, which is what survives a container reclaim. -if [[ -z "$reason" ]]; then - filed="$(dirname -- "${BASH_SOURCE[0]}")/filed-here-check.sh" - if [[ -x "$filed" ]]; then - # Pointer-only by that gate's own contract: an id and a tracked path, never - # a line of the row's body. stdin is closed rather than inherited, so no PR - # body reaches it here and no row is exempted by accident. - pointers=$("$filed" --advisory &1 >/dev/null) || true - # ONCE PER ROW PER BRANCH, and the reason is this file's own rule about not - # spending the channel. A Stop hook sees no PR body, so it cannot tell a - # punt from a row this branch is landing a fix for — and repeating the same - # pointer every turn for the rest of a session is precisely how "two nudges - # on one turn is how a channel stops being read" plays out over time. The - # turn the overlap first appears is the one that can still act cheaply, - # which is the whole point of moving this ahead of `land`. - # - # `land` is unaffected: `filed-here-check` there is the decision, it reads - # the PR body, and it does not consult this record. - if [[ -n "$pointers" ]]; then - seen_file="" - if git_dir=$(git rev-parse --git-dir 2>/dev/null) && - branch=$(git symbolic-ref --quiet --short HEAD 2>/dev/null) && - [[ -n "$branch" ]] && - mkdir -p "$git_dir/batten-receipts" 2>/dev/null; then - seen_file="$git_dir/batten-receipts/filed-here-nudged.${branch//\//-}" - fi - fresh="" - while IFS= read -r line; do - [[ -n "$line" ]] || continue - key=${line%% *} - if [[ -n "$seen_file" ]] && [[ -f "$seen_file" ]] && grep -qxF "$key" "$seen_file"; then - continue - fi - fresh="${fresh:+$fresh -}$line" - [[ -z "$seen_file" ]] || printf '%s\n' "$key" >>"$seen_file" 2>/dev/null || true - done <<<"$pointers" - pointers=$fresh - fi - [[ -z "$pointers" ]] || reason="$pointers -A row this branch filed names a file this branch is changing. Finish it now while the file is open, or make sure the PR body closes it when you land." - fi -fi - -# THE FOURTH RULE (CLOUD-97), AND IT IS THE ONE THIS FILE WAS ALWAYS FOR. The -# three above read PROSE and infer from its shape; this one reports a verdict the -# engine already reached over repository state — a completion signal in the -# session transcript with no patch-id-equivalent commit on the landing target. -# It decides nothing itself (see `unlanded-check`), which is why it can sit here -# without becoming a second answer to "is this landed". -# -# LAST, on this file's own rule that precedence is earned rather than asserted. -# The rules above rank by measured precision — 3/3, then 1/1 — and this one has -# no measurement yet, so it takes the bottom slot exactly as CLOUD-774 did. -# Revisit when it has a record. -if [[ -z "$reason" ]]; then - unlanded="$(dirname -- "${BASH_SOURCE[0]}")/unlanded-check.sh" - if [[ -x "$unlanded" ]]; then - # Pointer-only by that gate's contract: a count and a rule id, never a - # commit message or a line of the transcript the verdict was read from. - pointer=$("$unlanded" /dev/null) || true - [[ -z "$pointer" ]] || reason="$pointer -This turn declared a stopping point and the work is not on the landing target. Land it, or say what blocks it." - fi -fi - -# THE FIFTH RULE: THE ROWS THIS BRANCH SPUN OFF, ENUMERATED FOR RE-EVALUATION. -# Rule 3 asks a narrow, measured question — which filed row names a file this -# branch is holding open — and answers it once per row. This asks the broad one -# the narrow predicate cannot: here is EVERY row you spun off; say for each that -# it is genuinely independent work and not something you could have closed here. -# No predicate scores that, and none is asked to (non-negotiable 3): the ids are -# the pointer and the judgement is the agent's. -# -# SUPPRESSED ON THE SET, NOT PER ROW, AND THAT IS THE WHOLE DIFFERENCE. A -# per-row receipt would show a partial list — a checklist with rows hidden is not -# a checklist — and no receipt at all would reprint the same list at every turn -# end for the rest of the session, which is how this channel dies. The set is the -# unit: file another row and the whole list is asked again, because the question -# is about the set. -if [[ -z "$reason" ]]; then - filed_list="$(dirname -- "${BASH_SOURCE[0]}")/filed-here-check.sh" - if [[ -x "$filed_list" ]]; then - # stderr, stdout discarded — rule 3's idiom exactly, and load-bearing: - # that file writes its own summary lines to stdout, so a stdout capture - # reads "no board writes recorded" as a checklist of one row. - rows=$("$filed_list" --checklist &1 >/dev/null) || true - if [[ -n "$rows" ]]; then - set_key=$(printf '%s' "$rows" | tr '\n' ' ') - set_file="" - if git_dir=$(git rev-parse --git-dir 2>/dev/null) && - branch=$(git symbolic-ref --quiet --short HEAD 2>/dev/null) && - [[ -n "$branch" ]] && - mkdir -p "$git_dir/batten-receipts" 2>/dev/null; then - set_file="$git_dir/batten-receipts/filed-set-nudged.${branch//\//-}" - fi - if [[ -n "$set_file" ]] && [[ -f "$set_file" ]] && grep -qxF "$set_key" "$set_file" 2>/dev/null; then - rows="" - else - [[ -z "$set_file" ]] || printf '%s\n' "$set_key" >>"$set_file" 2>/dev/null || true - fi - fi - [[ -z "$rows" ]] || reason="$rows -Every row above was spun off while this branch was open. For each, by number: is it genuinely independent work, or a punt you could close here? Close the punts; leave a reason for the rest." - fi -fi - -# SILENCE IS THE DEFAULT, AND THE CLOSING QUESTION IS GONE (CLOUD-888). -# -# This slot held `reason="done?"` — an unconditional fallback, so every path -# reaching here emitted and the five rules above decided only WHICH nudge fired, -# never WHETHER one did. Measured: four end-of-turn messages carrying no tell, no -# finding and no at-risk state, four `additionalContext` replies. One of them was -# `Understood, stopping here.` -# -# ITS OWN DEFENCE IS THE REFUTATION, which is why this is written out rather than -# deleted quietly. The argument was that the bare question "carries no information -# at all" and that "Done?" once surfaced nine real findings in one session. Both -# true. But those nine came from A HUMAN CHOOSING TO ASK — the choice was the -# signal — and a constant has zero mutual information with the thing it is meant -# to detect. It cannot discriminate done from not-done, because it does not vary -# with it. `land` printing an `::error::` on the normal path was the same shape on -# a different channel (CLOUD-245), and was fixed the same way. -# -# So an empty `reason` is an answer: nothing fired, the turn ends, and the channel -# keeps the credibility a real refusal needs (CLOUD-339). -[[ -n "$reason" ]] || exit 0 - -# The reply document, built without `jq` for the same reason the reads above -# dropped it. Only one value needs escaping and it is ours, not the payload's: -# `reason` is assembled from the four checks' output, which is pointer-only by -# their own contracts. `printf '%s' | od`-grade escaping -# is not needed — JSON strings require exactly backslash, quote, and the C0 -# controls, and `sed` covers them in that order (backslash FIRST, or it would -# double the ones the later rules insert). -escaped=$(printf '%s' "$reason" | - sed -e 's/\\/\\\\/g' -e 's/"/\\"/g' -e 's/\t/\\t/g' -e 's/\r/\\r/g' | - awk 'NR>1 {printf "\\n"} {printf "%s", $0}') -printf '{"hookSpecificOutput":{"hookEventName":"Stop","additionalContext":"%s"}}\n' "$escaped" diff --git a/mise.toml b/mise.toml index 54f739b3e..db52d9f8e 100644 --- a/mise.toml +++ b/mise.toml @@ -398,7 +398,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,ci-slow-needed,bot-issue,land,land-lock,ci-lease-precondition,board-diff-overlap,reclaim-census,connector-allow-resolve,serena-mcp,target-prune,claimed-keys,released,in-progress-drain,merged-pr-keys,board-payloads,attestation-check,awk-regex-check,batten-glob-check,board-sweep,branch-age-check,cap-drift,checks-green,ci-drift,ci-local-parity,ci-tools-check,claim-check,claim-race-check,closing-key-check,coderabbit-config-check,config-deprecations,config-lint,connector-allow-guard,container-preflight,darwin-link,deferral-check,derived-check,digest-major-agreement,doctor,done-check,done-pr-check,duplicate-close-check,evaluator-closure-check,evaluator-io-check,finding-sink-check,gh-guard,graph-check,hook-matcher-check,hook-pin-check,hook-profile-check,hooks-wiring-check,install-check,land-divergence-assert,land-lock-check,landed-check,license-table-check,linear-check,lock-complete,macos-link-check,mcp-allow-check,mcp-attach-check,mcp-timeout-budget,memories-check,mise-action-floor,mise-pin-agreement,module-map-check,msrv-pin-agreement,mutant,mutant-census,no-doctests,nonverdict-assert,ntia-check,perf-assert,perf-compare,perf-gate,pipefail-grep-check,privileged-lane,pr-unsubscribed,publish-credential-check,ready-cites-check,ready-guard,ready-lint,reference-check,release-assets-check,release-due,release-tracking-check,renovate-config-validator,report-only-check,review-answered,rules-drift,run-shape,run-shape-guard,rust-paths-check,sbom,sbom-check,schema-check,semver,signing-posture,skill-check,sonar-gate,spec-ref-check,stop-guard,stop-posture-check,suite-bench-check,timeout-check,token-bench-check,transcript-corpus-check,tree-clean,unlanded-check,verified" +MUTANT_GATES = "alive,ci-slow-needed,bot-issue,land,land-lock,ci-lease-precondition,board-diff-overlap,reclaim-census,connector-allow-resolve,serena-mcp,target-prune,claimed-keys,released,in-progress-drain,merged-pr-keys,board-payloads,attestation-check,awk-regex-check,batten-glob-check,board-sweep,branch-age-check,cap-drift,checks-green,ci-drift,ci-local-parity,ci-tools-check,claim-check,claim-race-check,closing-key-check,coderabbit-config-check,config-deprecations,config-lint,connector-allow-guard,container-preflight,darwin-link,deferral-check,derived-check,digest-major-agreement,doctor,done-check,done-pr-check,duplicate-close-check,evaluator-closure-check,evaluator-io-check,finding-sink-check,gh-guard,graph-check,hook-matcher-check,hook-pin-check,hook-profile-check,hooks-wiring-check,install-check,land-divergence-assert,land-lock-check,landed-check,license-table-check,linear-check,lock-complete,macos-link-check,mcp-allow-check,mcp-attach-check,mcp-timeout-budget,memories-check,mise-action-floor,mise-pin-agreement,module-map-check,msrv-pin-agreement,mutant,mutant-census,no-doctests,nonverdict-assert,ntia-check,perf-assert,perf-compare,perf-gate,pipefail-grep-check,privileged-lane,pr-unsubscribed,publish-credential-check,ready-cites-check,ready-guard,ready-lint,reference-check,release-assets-check,release-due,release-tracking-check,renovate-config-validator,report-only-check,review-answered,rules-drift,run-shape,run-shape-guard,rust-paths-check,sbom,sbom-check,schema-check,semver,signing-posture,skill-check,sonar-gate,spec-ref-check,stop-posture-check,suite-bench-check,timeout-check,token-bench-check,transcript-corpus-check,tree-clean,unlanded-check,verified" # --- GitHub reachability behind an egress proxy (Claude Code web sandbox etc.) --- # mise resolves every tool's release through GitHub's *API* host, api.github.com. diff --git a/policy/shell-retirement.rego b/policy/shell-retirement.rego index a32e161ee..2cb74c6dc 100644 --- a/policy/shell-retirement.rego +++ b/policy/shell-retirement.rego @@ -158,6 +158,49 @@ violation contains { } if { some path in delta.edited governed_at_head(path) + not only_drops_a_retired_reference(path) +} + +# THE ONE ADMITTED EDIT, and it is what makes this campaign able to clean up +# after itself (CLOUD-1051). +# +# Retiring a program REQUIRES editing the siblings that declare it. The measured +# instance: `hooks-wiring-check.sh` carries a `DECLARED` table naming every +# by-path hook registration, and its own `wiring-declaration-stale` refuses a row +# whose subject no longer exists — so retiring `stop-guard.sh` forces a one-line +# deletion in a governed file, which this arm then refused. `V-SHELL-RULE-EDITED` +# declares no override route and no `bypass_env`, so the campaign was structurally +# unable to complete a retirement it had itself mandated. +# +# THE NARROWING IS EXACT AND NOT A JUDGEMENT: every line the base had and the head +# lacks must NAME A PATH THIS SAME DELTA DELETED. Removing a dead reference is +# admitted; removing anything else is not, and neither is adding a line. So this +# cannot become a licence to maintain a shell rule in place, which is the whole +# reason the arm above exists. +# +# COULD-NOT-LOOK REFUSES. `base-lines` omits a path whose base blob would not +# read, and an absent base side means the removed set is unknowable — so the +# admission does not hold and the edit is refused, which is the safe direction +# for an arm whose failure mode is a silent licence. +only_drops_a_retired_reference(path) if { + base := delta["base-lines"][path] + head := {line | some line in input.tree.lines[path]} + removed := {line | some line in base; not line in head} + + # An edit that removed nothing is not this case: it added or reordered, and + # neither is a retirement's cleanup. + count(removed) > 0 + + # Every removed line names a path this change retires. + count({line | + some line in removed + some gone in delta.deleted + contains(line, gone) + }) == count(removed) + + # AND NOTHING WAS ADDED. Without this, a change could delete a program, drop + # its declaration row, and smuggle an unrelated rewrite in the same file. + count({line | some line in input.tree.lines[path]; not line in {l | some l in base}}) == 0 } # --------------------------------------------------------------------------- @@ -296,6 +339,80 @@ test_edited_shell_rule_is_refused if { }} } +# THE ADMITTED EDIT, and the three cases that keep it from becoming a licence. +# The retirement's own cleanup: the declaration row for a program this same +# change deletes comes out, and nothing else moves. +test_dropping_a_retired_reference_is_admitted if { + count(violation) == 0 with input as {"tree": { + "base-delta": { + "added": [], + "edited": ["mise-tasks/wiring.sh"], + "deleted": ["mise-tasks/old-gate.sh"], + "base-lines": {"mise-tasks/wiring.sh": ["#!/usr/bin/env bash", "mise-tasks/old-gate.sh CLOUD-312", "mise-tasks/other.sh CLOUD-312"]}, + }, + "lines": { + "mise-tasks/wiring.sh": ["#!/usr/bin/env bash", "mise-tasks/other.sh CLOUD-312"], + "crates/batten/tests/old_gate.rs": ["// carried: mise-tasks/old-gate.sh policy/old-gate.rego crates/batten/tests/old_gate.rs"], + }, + }} +} + +# ANTI-VACUITY, and without it the arm above passes every edit that happens to +# accompany a deletion. The removed line names a path this change did NOT retire, +# so it is ordinary maintenance and is still refused. +test_dropping_a_line_naming_a_live_path_is_still_refused if { + some v in violation with input as {"tree": { + "base-delta": { + "added": [], + "edited": ["mise-tasks/wiring.sh"], + "deleted": ["mise-tasks/old-gate.sh"], + "base-lines": {"mise-tasks/wiring.sh": ["#!/usr/bin/env bash", "mise-tasks/still-here.sh CLOUD-312"]}, + }, + "lines": { + "mise-tasks/wiring.sh": ["#!/usr/bin/env bash"], + "crates/batten/tests/old_gate.rs": ["// carried: mise-tasks/old-gate.sh policy/old-gate.rego crates/batten/tests/old_gate.rs"], + }, + }} + v.verdict == "V-SHELL-RULE-EDITED" +} + +# AND AN ADDED LINE IS NOT CLEANUP. Without this conjunct a change could delete a +# program, drop its declaration row, and smuggle an unrelated rewrite through in +# the same file. +test_an_edit_that_also_adds_a_line_is_refused if { + some v in violation with input as {"tree": { + "base-delta": { + "added": [], + "edited": ["mise-tasks/wiring.sh"], + "deleted": ["mise-tasks/old-gate.sh"], + "base-lines": {"mise-tasks/wiring.sh": ["#!/usr/bin/env bash", "mise-tasks/old-gate.sh CLOUD-312"]}, + }, + "lines": { + "mise-tasks/wiring.sh": ["#!/usr/bin/env bash", "echo something new"], + "crates/batten/tests/old_gate.rs": ["// carried: mise-tasks/old-gate.sh policy/old-gate.rego crates/batten/tests/old_gate.rs"], + }, + }} + v.verdict == "V-SHELL-RULE-EDITED" +} + +# COULD NOT LOOK REFUSES. A base side this could not read leaves the removed set +# unknowable, and an arm whose failure mode is a silent licence must fail closed. +test_an_edit_with_no_readable_base_side_is_refused if { + some v in violation with input as {"tree": { + "base-delta": { + "added": [], + "edited": ["mise-tasks/wiring.sh"], + "deleted": ["mise-tasks/old-gate.sh"], + "base-lines": {}, + }, + "lines": { + "mise-tasks/wiring.sh": ["#!/usr/bin/env bash"], + "crates/batten/tests/old_gate.rs": ["// carried: mise-tasks/old-gate.sh policy/old-gate.rego crates/batten/tests/old_gate.rs"], + }, + }} + v.verdict == "V-SHELL-RULE-EDITED" +} + test_deleted_and_fully_mapped_passes if { count(violation) == 0 with input as {"tree": { "base-delta": {"added": [], "edited": [], "deleted": ["mise-tasks/old-gate.sh"]}, diff --git a/schema/policy-input.schema.json b/schema/policy-input.schema.json index d351888c5..32eb695f2 100644 --- a/schema/policy-input.schema.json +++ b/schema/policy-input.schema.json @@ -23,6 +23,15 @@ "null" ] }, + "base-lines": { + "additionalProperties": { + "items": { + "type": "string" + }, + "type": "array" + }, + "type": "object" + }, "code-changed": { "items": { "type": "string" diff --git a/tests/stop-guard.bats b/tests/stop-guard.bats deleted file mode 100644 index 55a4f09d9..000000000 --- a/tests/stop-guard.bats +++ /dev/null @@ -1,526 +0,0 @@ -#!/usr/bin/env bats -# subject: mise-tasks/stop-guard.sh -# The Stop hook body: one bounded re-prompt per turn, and fail-open everywhere -# else. CLOUD-187 is why this suite carries the wiring assertion too — a hook that -# lands mid-session is not in that session's wiring, so the suite is the only -# in-session proof that the entry exists at all. - -setup() { - # For `batten_binary`, which `unlanded_stub` delegates to (CLOUD-859). - load helpers - GUARD="$BATS_TEST_DIRNAME/../mise-tasks/stop-guard.sh" - SETTINGS="$BATS_TEST_DIRNAME/../.claude/settings.json" - cd "$BATS_TEST_DIRNAME/.." || return 1 - # THE THIRD RULE READS REPO STATE, NOT THE MESSAGE (CLOUD-774), and these cases - # run inside this checkout — so a punt row filed by the session running the - # suite would make every silence assertion below flap. Off by default through - # the gate's own bypass, and turned back on explicitly by the cases that are - # about it, which run against a throwaway repo instead. - export BATTEN_FILED_HERE_BYPASS=1 - # The OVERLAP hatch is the OTHER half of that gate and no case here wants it, - # so it is cleared rather than exported: inherited, it silences the overlap - # refusal the punt cases below are entirely about, and they go green while - # proving nothing. Not hypothetical — `BATTEN_FILED_HERE_OVERLAP=1 mise run - # land` is `filed-here-check`'s own remedy 4, and `land` runs `verify`, which - # runs this suite. Same lesson as `tests/filed-here-check.bats`'s setup. - unset BATTEN_FILED_HERE_OVERLAP - # THE FOURTH RULE READS THE STORE AND PAYS FOR IT (CLOUD-97), and the same - # argument applies twice over: these cases run inside this checkout, which is - # routinely unlanded while the suite runs, and the recorder that feeds the rule - # is a tree walk plus a store write per invocation. Off by default through the - # gate's own bypass — which the recorder rides, so switching the rule off does - # not leave the walk being paid for an answer nobody reads — and turned back on - # explicitly by the cases that are about it, against a stub. - export BATTEN_UNLANDED_CHECK_BYPASS=1 -} - -# `punt_repo ` — a throwaway repo carrying one filed row that names -# a path the branch changes, which is the state the third rule exists to see. -punt_repo() { - local repo="$BATS_TEST_TMPDIR/punt" - rm -rf "$repo" - mkdir -p "$repo/a" "$repo/.git" - git -C "$repo" init --quiet --initial-branch=work - git -C "$repo" config user.email t@example.com - git -C "$repo" config user.name t - printf 'x\n' >"$repo/a/one.rs" - git -C "$repo" add -A - git -C "$repo" commit -q -m base - git -C "$repo" update-ref refs/remotes/origin/main HEAD - printf 'changed\n' >>"$repo/a/one.rs" - git -C "$repo" commit -q -am change - mkdir -p "$repo/.git/batten-receipts" - # THE STAMP MUST BE AFTER THE BASE COMMIT, and a fixture's base commit is - # always "now" (CLOUD-854). The gate skips a row recorded BEFORE - # `git merge-base origin/main HEAD`, because such a row cannot be deferring - # work in a diff that did not exist yet. A past date therefore exempts this - # row and the rule under test never fires — the case would go green while - # asserting nothing, which is the same latent assumption `filed-here-check`'s - # own suite carried until that row landed. - printf 'issue CLOUD-900 2099-01-01T00:00:00.000Z ready 1,a/one.rs\n' \ - >"$repo/.git/batten-receipts/board-writes.work" - printf '%s' "$repo" -} - -# The real payload shape, captured from two live Stop invocations: 11 keys, of -# which this guard reads exactly two. -stop() { - jq -nc --arg m "$1" --argjson active "${2:-false}" \ - '{hook_event_name: "Stop", session_id: "s", cwd: ".", - transcript_path: "/nonexistent.jsonl", - stop_hook_active: $active, last_assistant_message: $m}' | "$GUARD" -} - -kicked() { - [[ "$1" == *'"additionalContext"'* ]] -} - -@test "a turn whose final message carries the tell is kicked" { - run stop 'The rebase is clean. Worth noting that the lock file still drifts.' - kicked "$output" - [ "$status" -eq 0 ] -} - -@test "the kick names the rule and the durable destination" { - run stop 'Worth noting the receipt is stale.' - [[ "$output" == *"hedged-flag-framing"* ]] - [[ "$output" == *"CLOUD-"* ]] -} - -@test "the kick declares the Stop event, so the harness routes it as feedback" { - run stop 'Worth noting one thing.' - [[ "$output" == *'"hookEventName"'* ]] - [[ "$output" == *'"Stop"'* ]] -} - -@test "the kick is valid JSON on stdout" { - run bash -c "jq -nc '{stop_hook_active:false,last_assistant_message:\"Worth noting the drift.\"}' | $GUARD | jq -e .hookSpecificOutput.additionalContext" - [ "$status" -eq 0 ] -} - -# --- exactly once ------------------------------------------------------------ - -@test "the re-entry caused by a previous kick is not kicked again" { - # This is the whole of "exactly once": no state file, no cursor. The same - # message that fires on the first Stop must be silent when the harness reports - # it is already continuing because of one. - run stop 'Worth noting the receipt is stale.' true - ! kicked "$output" - [ -z "$output" ] - [ "$status" -eq 0 ] -} - -@test "the recursion bound survives a garbage stop_hook_active rather than proceeding" { - # The inverse spelling (`!= "true"` then proceed) runs the predicate on - # unparseable input, which defeats the bound down to the harness cap of 8. - run bash -c "printf 'not json at all' | $GUARD" - ! kicked "$output" - [ "$status" -eq 0 ] -} - -# --- failure posture --------------------------------------------------------- - -@test "A CLEAN FINAL MESSAGE IS ANSWERED WITH SILENCE" { - # THE CONTRACT, RESTORED (CLOUD-888). This case asserted silence originally; - # CLOUD-97 flipped it to demand an unconditional `done?` on the argument that - # silence "made the most valuable question the one never asked". The argument - # does not survive its own premise: a question asked on EVERY turn is a - # constant, and a constant has zero mutual information with the thing it is - # meant to detect. It cannot discriminate done from not-done, because it does - # not vary with it. - # - # Measured before the fix: four end-of-turn messages carrying no tell, no - # finding and no at-risk state, four kicks. This is one of them. - run stop 'Landed on main by fast-forward, CI green.' - [ -z "$output" ] - [ "$status" -eq 0 ] -} - -@test "a turn that says it is stopping is not re-prompted" { - # The plainest form of the reported symptom, and the reason this is urgent - # rather than tidy: the turn said it was stopping and the hook asked it to - # keep going. A channel that overrides a stated stop is a channel a model - # learns to override in return (CLOUD-339). - run stop 'Understood, stopping here.' - [ -z "$output" ] - [ "$status" -eq 0 ] -} - -@test "an ordinary answer to a question is not re-prompted" { - run stop 'Yes. The retry lives in exec.rs.' - [ -z "$output" ] - [ "$status" -eq 0 ] -} - -@test "an absent last_assistant_message costs the first rule and nothing else" { - # It costs the FIRST rule, whose input it is — the rules below read the - # transcript and the store, and none of them has anything to say here. With - # the closing question gone (CLOUD-888) the honest answer is silence: a turn - # that ended without a text block is a turn nothing detected a problem in. - run bash -c "jq -nc '{stop_hook_active:false}' | $GUARD" - [ -z "$output" ] - [ "$status" -eq 0 ] -} - -@test "the bypass is honoured" { - # The whole registration, the closing question included: a bypass that still - # spoke would not be one. - BATTEN_STOP_GUARD_BYPASS=1 run stop 'Worth noting the receipt is stale.' - ! kicked "$output" - [ -z "$output" ] -} - -@test "the guard never exits non-zero, so it cannot surface as a hook error" { - # Exit 2 is the launcher Stop hook's channel at this same boundary; two hooks - # both exiting 2 would stack error notices on one turn. - run stop 'Worth noting the receipt is stale.' - [ "$status" -eq 0 ] - run stop 'Nothing to flag here.' - [ "$status" -eq 0 ] -} - -# --- the second rule: the stranded finding (CLOUD-252) ------------------------ -# -# `hedged-flag-framing` reads the final message; this one reads the transcript and -# reaches prose that field cannot carry. One advisory per turn, the shipped rule -# first. - -# A payload carrying a real transcript path. The turn below strands a finding. -stranded() { - local t="$BATS_TEST_TMPDIR/stranded.jsonl" - : >"$t" - jq -nc '{type:"user",isSidechain:false,message:{content:"go"}}' >>"$t" - jq -nc --arg x "$1" '{type:"assistant",isSidechain:false,message:{content:[{type:"text",text:$x}]}}' >>"$t" - jq -nc --arg m "${2:-Pushed and green.}" --arg p "$t" \ - '{hook_event_name:"Stop", session_id:"s", cwd:".", - transcript_path:$p, stop_hook_active:false, last_assistant_message:$m}' | "$GUARD" -} - -@test "a turn that strands a finding is pointed at, and the turn still ends" { - run stranded 'The wiring is missing at mise-tasks/stop-guard.sh:55.' - [ "$status" -eq 0 ] - kicked "$output" - [[ "$output" == *"turn:1"* ]] - [[ "$output" == *"finding-without-durable-write"* ]] -} - -@test "POINTER, NEVER PAYLOAD: the advisory carries no byte of the turn's prose" { - # The design in one assertion. Returning the prose makes this a mirror, and a - # mirror is cleared by restating — the double-write CLOUD-200 and CLOUD-248 - # exist to kill. A coordinate can only be answered by going to look. - run stranded 'Broken at mise-tasks/land.sh:200 and SENTINELXYZZY marks it.' - [[ "$output" != *"SENTINELXYZZY"* ]] - [[ "$output" != *"Broken at"* ]] -} - -@test "the advisory says what to do, since a coordinate alone is not an instruction" { - run stranded 'Broken at mise-tasks/land.sh:200.' - [[ "$output" == *"file it"* ]] -} - -@test "the shipped rule keeps precedence when both would fire" { - # One nudge per turn. Two is how a channel stops being read, and the enforcing - # rule has the higher measured precision. - run stranded 'Broken at mise-tasks/land.sh:200.' 'Worth noting the receipt is stale.' - [[ "$output" == *"hedged-flag-framing"* ]] - [[ "$output" != *"finding-without-durable-write"* ]] -} - -@test "a turn that strands nothing is silent" { - # The SECOND rule stayed quiet. Asserted both ways: by name, so a future rule - # firing here is not mistaken for this one, and by total silence, which is - # what a turn with nothing to report now produces (CLOUD-888). - run stranded 'Rebased, pushed, and the gate is green.' - [ "$status" -eq 0 ] - [[ "$output" != *"finding-without-durable-write"* ]] - [ -z "$output" ] -} - -@test "an unreadable transcript manufactures no advisory" { - # Fail open, like every other path in this guard: a missing file must not - # manufacture an advisory. Since CLOUD-888 that means literal silence — there - # is no longer a closing question standing behind the rules to absorb the case. - run stop 'Pushed and green.' - [ "$status" -eq 0 ] - [[ "$output" != *"finding-without-durable-write"* ]] - [ -z "$output" ] -} - -@test "the recursion bound still holds for the second rule" { - local t="$BATS_TEST_TMPDIR/active.jsonl" - : >"$t" - jq -nc '{type:"user",isSidechain:false,message:{content:"go"}}' >>"$t" - jq -nc '{type:"assistant",isSidechain:false,message:{content:[{type:"text",text:"Broken at mise-tasks/land.sh:200."}]}}' >>"$t" - run bash -c "jq -nc --arg p '$t' '{hook_event_name:\"Stop\",session_id:\"s\",cwd:\".\",transcript_path:\$p,stop_hook_active:true,last_assistant_message:\"x\"}' | '$GUARD'" - [ "$status" -eq 0 ] - [ -z "$output" ] -} - -# --- wiring ------------------------------------------------------------------ - -@test "the Stop hook is registered in settings" { - # The SHAPE, not merely the name (CLOUD-479). The old assertion matched the - # substring `stop-guard`, which `mise run -q stop-guard` and a by-path - # registration satisfy identically — so it proved nothing about the one thing - # that changed, and would have stayed green through a silent revert to the - # ~194ms/call invocation. - run python3 -c " -import json -d = json.load(open('$SETTINGS')) -cmds = [h['command'] for g in d['hooks']['Stop'] for h in g['hooks']] -entry = [c for c in cmds if 'stop-guard' in c] -assert entry, cmds -assert entry[0].endswith('/mise-tasks/stop-guard.sh'), entry -assert 'mise run' not in entry[0], entry -print('registered')" - [ "$status" -eq 0 ] - [[ "$output" == *"registered"* ]] -} - -@test "the Stop entry declares no matcher, which the event does not support" { - run python3 -c " -import json -d = json.load(open('$SETTINGS')) -for g in d['hooks']['Stop']: - assert 'matcher' not in g, g -print('no matcher')" - [ "$status" -eq 0 ] -} - -# --- the punt rule (CLOUD-774) ------------------------------------------------- - -@test "A FILED ROW NAMING THIS BRANCH'S OWN DIFF IS POINTED AT, BEFORE ANY CI" { - repo=$(punt_repo) - cd "$repo" || return 1 - unset BATTEN_FILED_HERE_BYPASS - run stop 'Landed on main by fast-forward, CI green.' - [ "$status" -eq 0 ] - [[ "$output" == *"CLOUD-900"* ]] - [[ "$output" == *"a/one.rs"* ]] -} - -# POINTER, NEVER PAYLOAD — the same assertion the other two rules carry. -@test "the punt pointer carries no prose from the row" { - repo=$(punt_repo) - cd "$repo" || return 1 - unset BATTEN_FILED_HERE_BYPASS - run stop 'Landed on main by fast-forward, CI green.' - [[ "$output" != *"2026-08-19"* ]] - [[ "$output" != *"ready"* ]] -} - -# ONE ADVISORY PER TURN, and this rule is last: a turn that also carries the -# hedged-flag tell gets that one and not both. -@test "the punt rule yields to the measured posture rule" { - repo=$(punt_repo) - cd "$repo" || return 1 - unset BATTEN_FILED_HERE_BYPASS - run stop 'One thing I would flag: the retry budget looks wrong.' - [[ "$output" != *"CLOUD-900"* ]] -} - -@test "a branch with no filed row names none" { - repo=$(punt_repo) - rm "$repo/.git/batten-receipts/board-writes.work" - cd "$repo" || return 1 - unset BATTEN_FILED_HERE_BYPASS - run stop 'Landed on main by fast-forward, CI green.' - [ "$status" -eq 0 ] - [[ "$output" != *"CLOUD-"* ]] - [ -z "$output" ] -} - -# ONCE PER ROW PER BRANCH. A Stop hook sees no PR body, so it cannot tell a punt -# from a row this branch is landing a fix for; repeating the pointer every turn is -# how the channel stops being read. `land` is unaffected — the gate there reads the -# body and does not consult this record. -@test "the punt pointer fires once and then goes quiet for that row" { - repo=$(punt_repo) - cd "$repo" || return 1 - unset BATTEN_FILED_HERE_BYPASS - run stop 'Landed on main by fast-forward, CI green.' - [[ "$output" == *"filed-over-own-diff"* ]] - run stop 'Landed on main by fast-forward, CI green.' - [ "$status" -eq 0 ] - # The PUNT pointer is spent. The row may still be named by the fifth rule's - # checklist, which asks a different question about the same id and is - # suppressed on its own set — so the assertion is about this rule's marker, - # not about the id ever appearing again. - [[ "$output" != *"filed-over-own-diff"* ]] -} - -@test "a second row still gets its own pointer after the first is spent" { - repo=$(punt_repo) - cd "$repo" || return 1 - unset BATTEN_FILED_HERE_BYPASS - run stop 'Landed on main by fast-forward, CI green.' - [[ "$output" == *"CLOUD-900"* ]] - # After the base commit, for `punt_repo`'s reason: a past stamp exempts the row - # and this case would then re-report CLOUD-900 rather than the new id. - printf 'issue CLOUD-901 2099-01-01T00:00:00.000Z ready 1,a/one.rs\n' \ - >>"$repo/.git/batten-receipts/board-writes.work" - run stop 'Landed on main by fast-forward, CI green.' - [[ "$output" == *"CLOUD-901"* ]] - [[ "$output" != *"CLOUD-900"* ]] -} - -# --- the fourth rule: work not landed (CLOUD-97) ------------------------------ -# -# The first rule here that reports repository state rather than inferring from -# prose. It decides nothing itself, so these drive it through the same stub seam -# `tests/unlanded-check.bats` uses: what is under test is stop-guard's ORDERING -# and framing of the verdict, not the verdict. - -# `unlanded_stub ` — a fake `batten` whose `state list` reports an -# unlanded finding on the branch of the repo under test. -unlanded_stub() { - local repo="$1" count="$2" - local bin="$BATS_TEST_TMPDIR/batten-stub" - local branch - branch=$(git -C "$repo" symbolic-ref --quiet --short HEAD) - # IT DELEGATES EVERYTHING ELSE TO THE REAL BINARY, which is not optional: - # `payload-field` resolves `BATTEN_BIN` too, so a stub that answers only - # `state list` silently empties the payload every rule above reads — and the - # suite then measures the reader being broken rather than the rule being - # ordered. Cost one debugging round; stated here so it costs nobody another. - # `batten_binary` rather than release-first: `test:bats` builds DEBUG, so a - # leftover release binary shadowed it and the stub would delegate to a build - # older than the code under test (CLOUD-859). - local real - real=$(batten_binary "$BATS_TEST_DIRNAME/..") || skip "no batten binary to delegate to" - { - echo '#!/usr/bin/env bash' - echo 'if [ "$1" = "state" ] && [ "$2" = "list" ]; then' - printf '\techo "abc123 completion.unlanded refs/heads/%s %s"\n' "$branch" "$count" - echo ' exit 0' - echo 'fi' - printf 'exec %s "$@"\n' "$real" - } >"$bin" - chmod +x "$bin" - printf '%s' "$bin" -} - -# A repo with a commit its landing target does not have, plus no filed rows. -plain_repo() { - local repo="$BATS_TEST_TMPDIR/plain" - rm -rf "$repo" - mkdir -p "$repo" - git -C "$repo" init --quiet --initial-branch=work - git -C "$repo" config user.email t@example.com - git -C "$repo" config user.name t - printf 'x\n' >"$repo/a.txt" - git -C "$repo" add -A - git -C "$repo" commit -q -m base - printf '%s' "$repo" -} - -@test "UNLANDED WORK AT A DECLARED STOPPING POINT IS POINTED AT" { - local repo bin - repo=$(plain_repo) - bin=$(unlanded_stub "$repo" 2) - run env -u BATTEN_UNLANDED_CHECK_BYPASS \ - CLAUDE_PROJECT_DIR="$repo" BATTEN_BIN="$bin" \ - bash -c "jq -nc '{stop_hook_active:false,last_assistant_message:\"Pushed.\"}' | $GUARD" - kicked "$output" - [[ "$output" == *"unlanded: 2 commit(s)"* ]] - [[ "$output" == *"Land it, or say what blocks it"* ]] - [ "$status" -eq 0 ] -} - -@test "the unlanded pointer carries no transcript text and no store key" { - # Pointer, never payload: the verdict was derived from a session transcript, - # which is the largest piece of prose anywhere near this hook. - local repo bin - repo=$(plain_repo) - bin=$(unlanded_stub "$repo" 1) - run env -u BATTEN_UNLANDED_CHECK_BYPASS \ - CLAUDE_PROJECT_DIR="$repo" BATTEN_BIN="$bin" \ - bash -c "jq -nc '{stop_hook_active:false,last_assistant_message:\"Pushed.\"}' | $GUARD" - [[ "$output" != *"abc123"* ]] - [[ "$output" != *"refs/heads"* ]] -} - -@test "the unlanded rule yields to the measured posture rule" { - # Precedence is earned, not asserted: `hedged-flag-framing` has 3/3 measured - # precision and this rule has no measurement yet, so a turn that earns both - # gets the measured one. - local repo bin - repo=$(plain_repo) - bin=$(unlanded_stub "$repo" 1) - run env -u BATTEN_UNLANDED_CHECK_BYPASS \ - CLAUDE_PROJECT_DIR="$repo" BATTEN_BIN="$bin" \ - bash -c "jq -nc '{stop_hook_active:false,last_assistant_message:\"Worth noting the receipt is stale.\"}' | $GUARD" - [[ "$output" == *"hedged-flag-framing"* ]] - [[ "$output" != *"unlanded:"* ]] -} - -@test "landed work is silent" { - local repo bin - repo=$(plain_repo) - bin=$(unlanded_stub "$repo" 0) - run env -u BATTEN_UNLANDED_CHECK_BYPASS \ - CLAUDE_PROJECT_DIR="$repo" BATTEN_BIN="$bin" \ - bash -c "jq -nc '{stop_hook_active:false,last_assistant_message:\"Landed.\"}' | $GUARD" - [ -z "$output" ] -} - -# --- the fifth rule: the rows this branch spun off ---------------------------- - -@test "EVERY ROW THE BRANCH FILED IS ENUMERATED FOR RE-EVALUATION" { - # Rule 3 asks which filed row names a file this branch has open — a measured, - # narrow predicate. This asks the broad question no predicate scores: for each - # row, by number, is it really independent work? So the assertion is that ALL - # of them are listed, including the ones rule 3 would never mention. - local repo="$BATS_TEST_TMPDIR/filed" - rm -rf "$repo" - mkdir -p "$repo/a" - git -C "$repo" init --quiet --initial-branch=work - git -C "$repo" config user.email t@example.com - git -C "$repo" config user.name t - printf 'x\n' >"$repo/a/one.rs" - git -C "$repo" add -A - git -C "$repo" commit -q -m base - git -C "$repo" update-ref refs/remotes/origin/main HEAD - mkdir -p "$repo/.git/batten-receipts" - { - printf 'issue CLOUD-901 2026-08-19T00:00:00.000Z ready 0\n' - printf 'issue CLOUD-902 2026-08-19T00:00:00.000Z ready 0\n' - } >"$repo/.git/batten-receipts/board-writes.work" - run env -u BATTEN_FILED_HERE_BYPASS \ - CLAUDE_PROJECT_DIR="$repo" \ - bash -c "cd $repo && jq -nc '{stop_hook_active:false,last_assistant_message:\"Filed two.\"}' | $GUARD" - [[ "$output" == *"CLOUD-901"* ]] - [[ "$output" == *"CLOUD-902"* ]] - [[ "$output" == *"punt you could close here"* ]] -} - -@test "the checklist repeats only when the set changes" { - # Suppressed on the SET, not per row: a checklist with rows hidden is not a - # checklist, and an unsuppressed one reprints itself every turn until nobody - # reads it. Filing another row asks the whole list again. - local repo="$BATS_TEST_TMPDIR/filed-set" - rm -rf "$repo" - mkdir -p "$repo/a" - git -C "$repo" init --quiet --initial-branch=work - git -C "$repo" config user.email t@example.com - git -C "$repo" config user.name t - printf 'x\n' >"$repo/a/one.rs" - git -C "$repo" add -A - git -C "$repo" commit -q -m base - git -C "$repo" update-ref refs/remotes/origin/main HEAD - mkdir -p "$repo/.git/batten-receipts" - local record="$repo/.git/batten-receipts/board-writes.work" - printf 'issue CLOUD-901 2026-08-19T00:00:00.000Z ready 0\n' >"$record" - - local invoke="cd $repo && jq -nc '{stop_hook_active:false,last_assistant_message:\"ok\"}' | $GUARD" - run env -u BATTEN_FILED_HERE_BYPASS CLAUDE_PROJECT_DIR="$repo" bash -c "$invoke" - [[ "$output" == *"CLOUD-901"* ]] - - run env -u BATTEN_FILED_HERE_BYPASS CLAUDE_PROJECT_DIR="$repo" bash -c "$invoke" - [ -z "$output" ] - - printf 'issue CLOUD-902 2026-08-19T00:00:00.000Z ready 0\n' >>"$record" - run env -u BATTEN_FILED_HERE_BYPASS CLAUDE_PROJECT_DIR="$repo" bash -c "$invoke" - [[ "$output" == *"CLOUD-901"* ]] - [[ "$output" == *"CLOUD-902"* ]] -} From bc2fbb057babdbaa758cc688f7df9d1e250607ef Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Wed, 26 Aug 2026 15:21:37 +0000 Subject: [PATCH 17/38] fix(hook): the transcript symlink loses its only writer when stop-guard.sh retires MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `mise-tasks/stop-guard.sh` was the sole writer of `.claude/.transcript.jsonl` via `ln -sfn`. Retiring it in CLOUD-1051 would have left `batten check`'s transcript capability reading a dangling path on every fresh container — the CLOUD-990 transcript-less-host condition, reintroduced by the retirement meant to preserve it. `refresh_transcript_link` in `lib.rs` takes the write over, called at the top of `stop_nudges`: read `[transcript].path` from config, remove, re-symlink, silent on every failure. The writer moved; the window did not. Also updates `.claude/rules/toolchain.md` and the two memories that named the retired program as the authority. Refs: CLOUD-990, CLOUD-1051 --- .claude/rules/toolchain.md | 112 +++++----- .serena/memories/core.md | 11 +- .serena/memories/toolchain-and-hooks.md | 21 +- bench/suites/RESULTS.md | 263 ++++++++++++------------ crates/batten/src/lib.rs | 52 +++++ 5 files changed, 266 insertions(+), 193 deletions(-) diff --git a/.claude/rules/toolchain.md b/.claude/rules/toolchain.md index 3e853dab0..9a17f2b5e 100644 --- a/.claude/rules/toolchain.md +++ b/.claude/rules/toolchain.md @@ -287,43 +287,48 @@ call` with no `CLOUD-*` key **in that same paragraph** stops the lap. Two open dropped; one shape survived, firing once, correctly. - `filed-here-check` is that stop's sibling, and the second half of CLOUD-514: `deferral-check` prices a decision left with no home, this prices a home opened - instead of a fix. `board-write-record` (a `PostToolUse` body) records every row - this branch put on the board — kind, id, the tracker's `updatedAt`, the - `ready-lint` verdict over the body the **tracker returned**, and the diff - overlap — and `land` calls this beside `deferral-check`, refusing when a row - this branch CREATED was stored `unready`. Three states rather than two: `ready` passes, `unready` refuses, and - `-` — the recorder could not lint — passes, because reading "not answered" as - "refused" turns a verdict about the environment into one about the row. - Comments are recorded and never gated: a comment on the row that already owns a - finding is the honest common case, and pricing it pushes the pressure toward - silence, which is the failure `finding-sink-check` exists to catch. - **A second refusal prices PROXIMITY** (`filed-over-own-diff`, CLOUD-514 phase - 3), because the first prices only refinement and a Ready block is prose — - measured, four rows filed in three and a half minutes and every one recorded - `ready`, so the toll certified the punts instead of reversing them. The - recorder's fifth column is `board-diff-overlap`: the tracked paths the row's - body names intersected with `origin/main...HEAD`, basenames resolved (exact - matching found none of the three real rows) and an ambiguous one resolving to - nothing. Non-zero stops the lap, and the load-bearing difference is that there - is **no prose remedy** — fix it here, comment on the row that owns it, file it - after landing from a clean tree, or `BATTEN_FILED_HERE_OVERLAP=1`, which - records which rows it overrode. It prices filing against **the diff** and - claims nothing wider: a punt about code the branch never touched is invisible - to it. The verdict is unforgeable by the author for the reason the receipt pattern usually is not — + instead of a fix. **Both halves are the engine's since CLOUD-1051.** A + `[[recorder]]` row records every row this branch put on the board — kind, id, + the tracker's `updatedAt`, the `ready-lint` verdict over the body the **tracker + returned**, the diff overlap and §1's paths — and `policy/filed-here.rego` + decides over it, declaring two predicates because a row can earn both. + `filed-unrefined` refuses a row this branch CREATED and stored `unready`. Three + states rather than two: `ready` passes, `unready` refuses, and `-` — the + recorder could not lint — passes, because reading "not answered" as "refused" + turns a verdict about the environment into one about the row. Comments are + recorded and never gated: a comment on the row that already owns a finding is + the honest common case, and pricing it pushes the pressure toward silence, + which is the failure `finding-sink-check` exists to catch. + **`filed-over-own-diff` prices PROXIMITY**, because the first prices only + refinement and a Ready block is prose — measured, four rows filed in three and + a half minutes and every one recorded `ready`, so the toll certified the punts + instead of reversing them. The overlap column is `board-diff-overlap`'s: the + tracked paths the row's body names, basenames resolved (exact matching found + none of the three real rows), intersected at READ time with the branch's own + `base-delta`. Three exemptions, each measured: a row the PR closes (without it + every honest file-then-fix needs an override), a row recorded before the + branch's base (3 of 3 refusals on one PR), and a row whose §1 names none of the + diff (citing is not claiming). The load-bearing difference from the first + refusal is that there is **no prose remedy** — fix it here, comment on the row + that owns it, file it after landing from a clean tree, or spend a declared + override. It prices filing against **the diff** and claims nothing wider: a + punt about code the branch never touched is invisible to it. The verdict is + unforgeable by the author for the reason the receipt pattern usually is not — `ready-lint` over a payload the caller assembles was measured green three times against text in a local file, once under an id no row carried. Fails open on an - absent record, and a branch predating the recorder can never have one: the store - lives under `$GIT_DIR`, is never committed, and dies with the container — which - is also why no fleet-wide firing rate is measurable. Bypass: - `BATTEN_FILED_HERE_BYPASS=1`. **`--checklist` is a third mode over the same - parse** (CLOUD-97): it enumerates EVERY row this branch filed, marks the ones - whose named paths intersect the diff, and asks the agent to affirm each is - independent work rather than a punt. A sibling of `--advisory`, never a - widening of it — that predicate is measured and mutation-gated, and retuning a - gate by editing the question it answers is not a refactor. Both nudge modes - print pointers on **stderr**, because this file's stdout carries its own - summary lines and a stdout capture reads "no board writes recorded" as a - checklist of one row. + absent record, and a branch predating the recorder can never have one: the + store lives under `$GIT_DIR`, is never committed, and dies with the container — + which is also why no fleet-wide firing rate is measurable. + **Both environment variables are gone rather than ported** (CLOUD-1051): + `BATTEN_FILED_HERE_BYPASS` and `BATTEN_FILED_HERE_OVERLAP` were knowable + strings anyone could spend without articulating anything, and the override is + `V-FILED-OVER-OWN-DIFF`'s declared route with its precondition, issued and + spent through `batten override request`/`spend`. `land` still calls + `mise run filed-here-check` by name — an inline `batten check` on that row now + — so that call site is byte-identical and `land.sh` never + entered the changed-file set. The PR body reaches the predicate as a RECORD + rather than on stdin, because `check` is declared `read` and has no stdin + channel: the boundary captures what `gh pr view --jq .body` returned. - `unlanded-check` is the end-of-turn half nobody had (CLOUD-97), and it decides NOTHING: `completion.unlanded` — a completion marker in the session transcript with no patch-id-equivalent commit on the landing target — is the engine's @@ -338,19 +343,30 @@ call` with no `CLOUD-*` key **in that same paragraph** stops the lap. Two open sha: the finding holds while the work is unlanded, so an unsuppressed rule repeats one pointer every turn until nobody reads it, and a new commit is a new answer to the question. Bypass: `BATTEN_UNLANDED_CHECK_BYPASS=1`, which the - `state record` call in `stop-guard` rides too — a caller who switched the rule - off should not still pay a tree walk per turn for an answer nobody reads. -- **`stop-guard` ends every turn with a question now.** It ran four rules and - then exited silently, and silence is the common case — so the most valuable - thing it could say was the thing it never said. Each rule fires only on a shape - somebody enumerated and measured recall is the weak half of all of them; the - bare `done?` has no recall problem, and `finding-sink-check`'s header already - records it surfacing nine real findings in one session while carrying no - information at all. It is last and mutually exclusive with the rest: a turn - already handed a pointer has been asked something more specific. The cost is - one model round trip on a turn that would otherwise have ended, bounded to one - per turn by `stop_hook_active`; the registration went from ~28ms to - ~330-440ms, nearly all of it the `state record` call the fourth rule rests on. + engine's own `state record` call at `Stop` rides too — a caller who switched + the rule off should not still pay a tree walk per turn for an answer nobody + reads. +- **The end of turn is the engine's since CLOUD-1051**, and `stop-guard.sh` is + **deleted** rather than still runnable. `batten hook` already registers at + `Stop`; its five rules live there now, ranked and emitting **at most one**, + because two nudges on one turn is how a channel stops being read. The first is + `policy/stop-posture.rego` — the output-posture tell, a `mediated_call` module + over `input.call["final-message"]`. The other four cannot be modules (three + spawn a sibling program, one reads the tree, and `RuleKind::scopes` pairs every + spawning kind with `RuleScope::Tree` alone), so they are `stop_nudges` in + `lib.rs`, keeping the order the shell ranked them in — by MEASURED precision, + `stop-posture` at 3/3 leading `finding-sink` at 1/1, with the three unmeasured + below. `finding-sink-check.sh` and `unlanded-check.sh` are spawned unchanged, + with the same stdin the bash gave them, which is what bounds the cascade. The + recursion bound is the payload's `stop_hook_active`, never a state file; the + channel is `additionalContext`, never exit 2, because CLOUD-97 and CLOUD-219 + each ruled a deny out independently and `Event::carries_a_verdict` is now the + one authority both producers ask. Everything fails open. Bypass: + `BATTEN_STOP_GUARD_BYPASS=1`, one hatch for the set rather than one per rule. + **The closing question is gone** (CLOUD-888): it was an unconditional fallback, + so the five rules decided only WHICH nudge fired and never WHETHER one did, and + a constant has zero mutual information with the thing it is meant to detect. An + empty answer is an answer, and silence is what keeps the channel credible. - **`claim-guard` is retired** (CLOUD-444); the pull-time half of the pair the key rule finishes (CLOUD-272) is now the `claim-needs-receipt` row in `batten.toml` — a `receipt` rule with `trigger = "write"` and `key = "branch"`. diff --git a/.serena/memories/core.md b/.serena/memories/core.md index 7087c44e8..94b9879f8 100644 --- a/.serena/memories/core.md +++ b/.serena/memories/core.md @@ -149,8 +149,8 @@ err)` takes **both** channels and the resolved `Mode`, so a verb can write a the decision, while its second (a config load on the hottest path) does, so `selects` is CLOUD-460's narrowing and a call no handler selects for does less work than `--help`. Four contract properties no dispatched program can give - itself: a parent-imposed **bound** (`stop-guard` hand-rolled `timeout 1s cat` - for this; the rest had none), central **fail-open** (spawn failure, timeout and + itself: a parent-imposed **bound** (the retired `stop-guard` hand-rolled + `timeout 1s cat` for this; the rest had none), central **fail-open** (spawn failure, timeout and an undefined exit are all could-not-look, which allows), a stated **output shape** (stdout on exit 0 is advisory text, a reason is on stderr, §7's `0/1/2` unchanged), and **one reply per call** via `Dispatched`. stdout is @@ -1400,9 +1400,10 @@ judge_fingerprint`, its own domain tag), so a caller can reference content it back to an agent. A detector can be complete, tested and shipped and still never run; "is it wired for consumer #1" is a separate question from "does it work", and only the second one had an answer. It is wired now through - `mise-tasks/stop-guard.sh`, which refreshes the transcript symlink from the Stop - payload, runs the recorder, and reports the finding via - `mise-tasks/unlanded-check.sh`. + `lib.rs`'s `stop_nudges` (CLOUD-1051, which retired `mise-tasks/stop-guard.sh`): + the boundary refreshes the transcript symlink from the Stop payload, runs the + recorder, and reports the finding via `mise-tasks/unlanded-check.sh`, which is + spawned unchanged. - `session.rs` — session lineage and the durable resume point (CLOUD-83): the fourth question `store`/`findings`/`journal` leave open — **who is reading, and how far have they got**. A warm fork keeps everything that is out of process diff --git a/.serena/memories/toolchain-and-hooks.md b/.serena/memories/toolchain-and-hooks.md index 1a1203c78..b2c5691d7 100644 --- a/.serena/memories/toolchain-and-hooks.md +++ b/.serena/memories/toolchain-and-hooks.md @@ -347,13 +347,20 @@ not the no-verdict outcome `batten.toml`'s `[transcript]` comment describes ("resolves to `Capability::Absent` … changes no verdict"). One of the two is wrong; the comment and the behaviour disagree. -Why it bites the first run specifically: `mise-tasks/stop-guard.sh` is the only -writer (`ln -sfn` from the Stop payload's `transcript_path`) and it fires at TURN -END. A fresh container's first `verify`/`linear-check` therefore runs before any -Stop hook has, which is precisely when an agent runs it. It reads as a rebase or -a toolchain fault and costs turns before anyone looks at the symlink. - -Remedy in the moment — session-local, gitignored, the same target `stop-guard` +Why it bites the first run specifically: the boundary is the only writer +(`lib.rs`'s `refresh_transcript_link`, from the Stop payload's +`transcript_path`) and it fires at TURN END. A fresh container's first +`verify`/`linear-check` therefore runs before any Stop hook has, which is +precisely when an agent runs it. It reads as a rebase or a toolchain fault and +costs turns before anyone looks at the symlink. + +The writer MOVED but the window did not (CLOUD-1051). It was +`mise-tasks/stop-guard.sh`'s `ln -sfn` until that program retired; the engine's +Stop routine does the same write for the same reason, and a `SessionStart` write +beside the other things `session-start.sh` asserts is still the fix that would +close the window rather than relocate it. + +Remedy in the moment — session-local, gitignored, the same target the boundary would set, so it chooses no evidence: ln -sfn ~/.claude/projects//.jsonl .claude/.transcript.jsonl diff --git a/bench/suites/RESULTS.md b/bench/suites/RESULTS.md index 75178746f..6d4000367 100644 --- a/bench/suites/RESULTS.md +++ b/bench/suites/RESULTS.md @@ -6,164 +6,161 @@ 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: 156 -- serial total: 1212.8s +- suites: 153 +- serial total: 1154.2s | seconds | share | suite | | ---: | ---: | --- | -| 140.3 | 11.6% | `tests/land-lock.bats` | -| 107.9 | 8.9% | `tests/derived-check.bats` | -| 102.9 | 8.5% | `tests/ci-wait.bats` | -| 96.4 | 7.9% | `tests/session-start.bats` | -| 81.1 | 6.7% | `tests/ci-local-parity.bats` | -| 73.8 | 6.1% | `tests/land.bats` | -| 50.2 | 4.1% | `tests/hooks-wiring-check.bats` | -| 35.4 | 2.9% | `tests/claim-check.bats` | -| 34.5 | 2.8% | `tests/main-watch.bats` | -| 30.7 | 2.5% | `tests/board-diff-overlap.bats` | -| 29.7 | 2.4% | `tests/board-write-record.bats` | -| 26.1 | 2.2% | `tests/helpers.bats` | -| 24.2 | 2.0% | `tests/hook-latency-drift.bats` | -| 20.0 | 1.7% | `tests/prebuilt-lint.bats` | -| 19.7 | 1.6% | `tests/config-lint.bats` | -| 17.0 | 1.4% | `tests/sbom-check.bats` | -| 16.6 | 1.4% | `tests/token-bench.bats` | -| 16.5 | 1.4% | `tests/graph-check.bats` | -| 15.9 | 1.3% | `tests/board-sweep.bats` | -| 10.2 | 0.8% | `tests/commit-convention.bats` | -| 8.4 | 0.7% | `tests/filed-here-check.bats` | -| 7.9 | 0.7% | `tests/target-race.bats` | -| 7.9 | 0.6% | `tests/run-shape-guard.bats` | -| 7.4 | 0.6% | `tests/signing-posture.bats` | -| 7.0 | 0.6% | `tests/ready-lint.bats` | -| 6.5 | 0.5% | `tests/released.bats` | -| 6.5 | 0.5% | `tests/renovate-config-validator.bats` | -| 6.1 | 0.5% | `tests/stop-guard.bats` | -| 5.9 | 0.5% | `tests/mutant.bats` | -| 5.8 | 0.5% | `tests/singleton.bats` | -| 5.4 | 0.4% | `tests/replay.bats` | -| 5.2 | 0.4% | `tests/ready-guard.bats` | -| 5.2 | 0.4% | `tests/release-tracking-check.bats` | -| 4.9 | 0.4% | `tests/ci-tools-check.bats` | -| 4.7 | 0.4% | `tests/claim-race-check.bats` | -| 4.6 | 0.4% | `tests/lock-complete.bats` | -| 4.5 | 0.4% | `tests/task-registry.bats` | -| 4.5 | 0.4% | `tests/mcp-allow-check.bats` | -| 4.5 | 0.4% | `tests/step-receipt.bats` | -| 4.4 | 0.4% | `tests/sbom.bats` | -| 4.2 | 0.3% | `tests/doctor-race.bats` | -| 4.1 | 0.3% | `tests/closing-key-check.bats` | -| 4.0 | 0.3% | `tests/release-assets-check.bats` | -| 4.0 | 0.3% | `tests/in-progress-drain.bats` | -| 4.0 | 0.3% | `tests/ci-lease-precondition.bats` | -| 3.6 | 0.3% | `tests/schema-check.bats` | -| 3.5 | 0.3% | `tests/skill-check.bats` | -| 3.4 | 0.3% | `tests/target-ensure.bats` | -| 3.4 | 0.3% | `tests/claimed-keys.bats` | -| 3.4 | 0.3% | `tests/ci-slow-needed.bats` | -| 3.1 | 0.3% | `tests/ready-cites-check.bats` | -| 3.1 | 0.3% | `tests/pre-commit-staging.bats` | -| 3.0 | 0.2% | `tests/ntia-check.bats` | -| 3.0 | 0.2% | `tests/hk-selection.bats` | -| 3.0 | 0.2% | `tests/reference-check.bats` | -| 2.9 | 0.2% | `tests/with-lock.bats` | -| 2.9 | 0.2% | `tests/fanout-guard.bats` | -| 2.8 | 0.2% | `tests/land-divergence.bats` | -| 2.8 | 0.2% | `tests/unlanded-check.bats` | -| 2.7 | 0.2% | `tests/semver.bats` | -| 2.5 | 0.2% | `tests/rules-drift.bats` | -| 2.4 | 0.2% | `tests/finding-sink-check.bats` | -| 2.3 | 0.2% | `tests/deferral-check.bats` | -| 2.3 | 0.2% | `tests/bot-issue.bats` | -| 2.3 | 0.2% | `tests/tree-clean.bats` | -| 2.2 | 0.2% | `tests/checks-green.bats` | -| 2.2 | 0.2% | `tests/landed-check.bats` | -| 2.0 | 0.2% | `tests/alive.bats` | -| 2.0 | 0.2% | `tests/verify.bats` | -| 2.0 | 0.2% | `tests/done-check.bats` | -| 2.0 | 0.2% | `tests/suite-select.bats` | -| 1.8 | 0.1% | `tests/timeout-drift.bats` | -| 1.6 | 0.1% | `tests/doctor.bats` | +| 154.0 | 13.3% | `tests/land-lock.bats` | +| 110.7 | 9.6% | `tests/derived-check.bats` | +| 102.0 | 8.8% | `tests/ci-wait.bats` | +| 100.4 | 8.7% | `tests/session-start.bats` | +| 79.9 | 6.9% | `tests/land.bats` | +| 52.2 | 4.5% | `tests/hooks-wiring-check.bats` | +| 36.1 | 3.1% | `tests/helpers.bats` | +| 34.6 | 3.0% | `tests/main-watch.bats` | +| 34.3 | 3.0% | `tests/sbom-check.bats` | +| 33.1 | 2.9% | `tests/ci-local-parity.bats` | +| 24.3 | 2.1% | `tests/prebuilt-lint.bats` | +| 24.2 | 2.1% | `tests/hook-latency-drift.bats` | +| 20.1 | 1.7% | `tests/renovate-config-validator.bats` | +| 16.9 | 1.5% | `tests/graph-check.bats` | +| 16.5 | 1.4% | `tests/claim-check.bats` | +| 15.4 | 1.3% | `tests/config-lint.bats` | +| 15.2 | 1.3% | `tests/token-bench.bats` | +| 13.2 | 1.1% | `tests/board-diff-overlap.bats` | +| 11.1 | 1.0% | `tests/released.bats` | +| 9.8 | 0.9% | `tests/target-race.bats` | +| 9.6 | 0.8% | `tests/sbom.bats` | +| 9.6 | 0.8% | `tests/ready-guard.bats` | +| 8.1 | 0.7% | `tests/run-shape-guard.bats` | +| 7.9 | 0.7% | `tests/ready-lint.bats` | +| 7.9 | 0.7% | `tests/replay.bats` | +| 7.6 | 0.7% | `tests/release-tracking-check.bats` | +| 7.5 | 0.7% | `tests/semver.bats` | +| 6.8 | 0.6% | `tests/mutant.bats` | +| 6.4 | 0.6% | `tests/perf-record.bats` | +| 6.1 | 0.5% | `tests/board-sweep.bats` | +| 6.0 | 0.5% | `tests/pkl-check.bats` | +| 5.9 | 0.5% | `tests/commit-convention.bats` | +| 5.8 | 0.5% | `tests/schema-check.bats` | +| 5.2 | 0.5% | `tests/step-receipt.bats` | +| 5.2 | 0.5% | `tests/task-registry.bats` | +| 5.1 | 0.4% | `tests/lock-complete.bats` | +| 5.1 | 0.4% | `tests/singleton.bats` | +| 4.7 | 0.4% | `tests/signing-posture.bats` | +| 4.5 | 0.4% | `tests/pre-commit-staging.bats` | +| 4.3 | 0.4% | `tests/release-assets-check.bats` | +| 4.2 | 0.4% | `tests/mcp-allow-check.bats` | +| 4.1 | 0.4% | `tests/in-progress-drain.bats` | +| 3.6 | 0.3% | `tests/rules-drift.bats` | +| 3.4 | 0.3% | `tests/doctor-race.bats` | +| 3.4 | 0.3% | `tests/ready-cites-check.bats` | +| 3.3 | 0.3% | `tests/reference-check.bats` | +| 3.3 | 0.3% | `tests/with-lock.bats` | +| 3.1 | 0.3% | `tests/ntia-check.bats` | +| 3.1 | 0.3% | `tests/hk-selection.bats` | +| 3.0 | 0.3% | `tests/target-ensure.bats` | +| 3.0 | 0.3% | `tests/land-divergence.bats` | +| 2.9 | 0.3% | `tests/unlanded-check.bats` | +| 2.6 | 0.2% | `tests/landed-check.bats` | +| 2.6 | 0.2% | `tests/tree-clean.bats` | +| 2.4 | 0.2% | `tests/suite-select.bats` | +| 2.2 | 0.2% | `tests/spec-ref-check.bats` | +| 2.1 | 0.2% | `tests/fanout-guard.bats` | +| 2.1 | 0.2% | `tests/claim-race-check.bats` | +| 1.9 | 0.2% | `tests/timeout-drift.bats` | +| 1.9 | 0.2% | `tests/closing-key-check.bats` | +| 1.9 | 0.2% | `tests/target-prune.bats` | +| 1.8 | 0.2% | `tests/skill-check.bats` | +| 1.6 | 0.1% | `tests/ci-slow-needed.bats` | +| 1.6 | 0.1% | `tests/finding-sink-check.bats` | | 1.6 | 0.1% | `tests/reclaim-census.bats` | -| 1.6 | 0.1% | `tests/checksums.bats` | -| 1.5 | 0.1% | `tests/spec-ref-check.bats` | -| 1.4 | 0.1% | `tests/awk-regex-check.bats` | -| 1.3 | 0.1% | `tests/verified.bats` | +| 1.5 | 0.1% | `tests/bot-issue.bats` | +| 1.5 | 0.1% | `tests/memories-check.bats` | +| 1.4 | 0.1% | `tests/alive.bats` | +| 1.4 | 0.1% | `tests/spawn-census.bats` | +| 1.4 | 0.1% | `tests/ready-lint-deferral.bats` | +| 1.3 | 0.1% | `tests/claimed-keys.bats` | +| 1.3 | 0.1% | `tests/ci-tools-check.bats` | +| 1.3 | 0.1% | `tests/ci-lease-precondition.bats` | +| 1.2 | 0.1% | `tests/verify.bats` | | 1.2 | 0.1% | `tests/mutant-census.bats` | -| 1.2 | 0.1% | `tests/memories-check.bats` | -| 1.2 | 0.1% | `tests/connector-verb-guard.bats` | -| 1.2 | 0.1% | `tests/attestation-check.bats` | -| 1.1 | 0.1% | `tests/ready-lint-deferral.bats` | -| 1.0 | 0.1% | `tests/spawn-census.bats` | -| 1.0 | 0.1% | `tests/linear-check.bats` | -| 1.0 | 0.1% | `tests/perf-record.bats` | -| 1.0 | 0.1% | `tests/connector-allow-guard.bats` | -| 1.0 | 0.1% | `tests/target-prune.bats` | +| 1.1 | 0.1% | `tests/linear-check.bats` | +| 1.0 | 0.1% | `tests/deferral-check.bats` | +| 1.0 | 0.1% | `tests/awk-regex-check.bats` | +| 1.0 | 0.1% | `tests/evaluator-closure-check.bats` | +| 1.0 | 0.1% | `tests/land-divergence-assert.bats` | | 1.0 | 0.1% | `tests/install-check.bats` | -| 0.9 | 0.1% | `tests/evaluator-closure-check.bats` | -| 0.9 | 0.1% | `tests/land-divergence-assert.bats` | +| 0.9 | 0.1% | `tests/done-check.bats` | | 0.9 | 0.1% | `tests/nonverdict-scan.bats` | -| 0.8 | 0.1% | `tests/duplicate-close-check.bats` | +| 0.9 | 0.1% | `tests/verified.bats` | +| 0.9 | 0.1% | `tests/attestation-check.bats` | +| 0.9 | 0.1% | `tests/module-map-check.bats` | +| 0.9 | 0.1% | `tests/release-backfill.bats` | | 0.8 | 0.1% | `tests/lint-deno.bats` | -| 0.8 | 0.1% | `tests/perf-assert.bats` | -| 0.8 | 0.1% | `tests/release-backfill.bats` | | 0.8 | 0.1% | `tests/done-pr-check.bats` | -| 0.8 | 0.1% | `tests/module-map-check.bats` | -| 0.8 | 0.1% | `tests/transcript-corpus-check.bats` | -| 0.8 | 0.1% | `tests/cap-drift.bats` | -| 0.8 | 0.1% | `tests/digest-major-agreement.bats` | -| 0.7 | 0.1% | `tests/pr-unsubscribed.bats` | -| 0.7 | 0.1% | `tests/branch-age-check.bats` | -| 0.7 | 0.1% | `tests/connector-allow-resolve.bats` | +| 0.8 | 0.1% | `tests/perf-assert.bats` | +| 0.8 | 0.1% | `tests/suite-bench-check.bats` | +| 0.8 | 0.1% | `tests/pr-unsubscribed.bats` | +| 0.7 | 0.1% | `tests/transcript-corpus-check.bats` | | 0.7 | 0.1% | `tests/gh-guard.bats` | +| 0.7 | 0.1% | `tests/sbom-binary.bats` | +| 0.7 | 0.1% | `tests/stop-posture-check.bats` | +| 0.7 | 0.1% | `tests/checks-green.bats` | +| 0.7 | 0.1% | `tests/doctor.bats` | +| 0.7 | 0.1% | `tests/duplicate-close-check.bats` | | 0.7 | 0.1% | `tests/render-cli.bats` | -| 0.7 | 0.1% | `tests/suite-bench-check.bats` | -| 0.7 | 0.1% | `tests/hook-pin-check.bats` | -| 0.6 | 0.1% | `tests/sbom-binary.bats` | -| 0.6 | 0.1% | `tests/board-payloads.bats` | -| 0.6 | 0.1% | `tests/stop-posture-check.bats` | -| 0.6 | 0.1% | `tests/commit-attribution.bats` | -| 0.6 | 0.1% | `tests/ci-drift.bats` | -| 0.6 | 0.0% | `tests/mcp-timeout-budget.bats` | -| 0.6 | 0.0% | `tests/hook-matcher-check.bats` | +| 0.7 | 0.1% | `tests/lint-rego.bats` | +| 0.6 | 0.1% | `tests/merged-pr-keys.bats` | +| 0.6 | 0.1% | `tests/perf-compare.bats` | +| 0.6 | 0.1% | `tests/hook-matcher-check.bats` | +| 0.6 | 0.1% | `tests/timeout-check.bats` | +| 0.6 | 0.1% | `tests/mcp-timeout-budget.bats` | +| 0.6 | 0.1% | `tests/install.bats` | | 0.6 | 0.0% | `tests/macos-link-check.bats` | -| 0.6 | 0.0% | `tests/container-preflight.bats` | -| 0.6 | 0.0% | `tests/lint-rego.bats` | -| 0.6 | 0.0% | `tests/merged-pr-keys.bats` | -| 0.6 | 0.0% | `tests/mcp-attach-check.bats` | -| 0.6 | 0.0% | `tests/perf-compare.bats` | +| 0.5 | 0.0% | `tests/hook-pin-check.bats` | +| 0.5 | 0.0% | `tests/mcp-attach-check.bats` | | 0.5 | 0.0% | `tests/hook-profile-check.bats` | -| 0.5 | 0.0% | `tests/install.bats` | +| 0.5 | 0.0% | `tests/token-bench-check.bats` | +| 0.5 | 0.0% | `tests/sonar-gate.bats` | +| 0.5 | 0.0% | `tests/connector-verb-guard.bats` | | 0.5 | 0.0% | `tests/abandon-matrix.bats` | -| 0.5 | 0.0% | `tests/publish-credential-check.bats` | -| 0.5 | 0.0% | `tests/coderabbit-config-check.bats` | -| 0.4 | 0.0% | `tests/pkl-check.bats` | -| 0.4 | 0.0% | `tests/sonar-gate.bats` | -| 0.4 | 0.0% | `tests/pipefail-grep-check.bats` | +| 0.5 | 0.0% | `tests/checksums.bats` | | 0.4 | 0.0% | `tests/license-table-check.bats` | +| 0.4 | 0.0% | `tests/publish-credential-check.bats` | +| 0.4 | 0.0% | `tests/pipefail-grep-check.bats` | +| 0.4 | 0.0% | `tests/digest-major-agreement.bats` | +| 0.4 | 0.0% | `tests/board-payloads.bats` | | 0.4 | 0.0% | `tests/msrv-pin-agreement.bats` | -| 0.4 | 0.0% | `tests/run-shape-guard-quoting.bats` | -| 0.4 | 0.0% | `tests/token-bench-check.bats` | +| 0.4 | 0.0% | `tests/connector-allow-guard.bats` | +| 0.4 | 0.0% | `tests/serena-mcp.bats` | +| 0.4 | 0.0% | `tests/branch-age-check.bats` | +| 0.4 | 0.0% | `tests/evaluator-io-check.bats` | +| 0.3 | 0.0% | `tests/run-shape-guard-quoting.bats` | +| 0.3 | 0.0% | `tests/release-due.bats` | | 0.3 | 0.0% | `tests/land-lock-check.bats` | -| 0.3 | 0.0% | `tests/timeout-check.bats` | +| 0.3 | 0.0% | `tests/ci-drift.bats` | +| 0.3 | 0.0% | `tests/connector-allow-resolve.bats` | +| 0.3 | 0.0% | `tests/container-preflight.bats` | | 0.3 | 0.0% | `tests/report-only-check.bats` | -| 0.3 | 0.0% | `tests/batten-glob-check.bats` | -| 0.3 | 0.0% | `tests/serena-mcp.bats` | -| 0.3 | 0.0% | `tests/release-due.bats` | | 0.3 | 0.0% | `tests/no-doctests.bats` | | 0.3 | 0.0% | `tests/nonverdict-assert.bats` | -| 0.3 | 0.0% | `tests/dist.bats` | +| 0.3 | 0.0% | `tests/commit-attribution.bats` | +| 0.3 | 0.0% | `tests/cap-drift.bats` | +| 0.3 | 0.0% | `tests/batten-glob-check.bats` | +| 0.3 | 0.0% | `tests/task-fail-closed.bats` | | 0.2 | 0.0% | `tests/mise-pin-agreement.bats` | +| 0.2 | 0.0% | `tests/coderabbit-config-check.bats` | | 0.2 | 0.0% | `tests/rust-paths-check.bats` | -| 0.2 | 0.0% | `tests/mise-action-floor.bats` | | 0.2 | 0.0% | `tests/git-hook.bats` | -| 0.2 | 0.0% | `tests/evaluator-io-check.bats` | +| 0.2 | 0.0% | `tests/mise-action-floor.bats` | +| 0.2 | 0.0% | `tests/test-bats-parallel.bats` | +| 0.2 | 0.0% | `tests/dist.bats` | | 0.2 | 0.0% | `tests/remedy-payload-source.bats` | | 0.2 | 0.0% | `tests/perf-gate.bats` | | 0.1 | 0.0% | `tests/egress-check.bats` | -| 0.1 | 0.0% | `tests/test-bats-parallel.bats` | -| 0.1 | 0.0% | `tests/task-fail-closed.bats` | -| 0.1 | 0.0% | `tests/darwin-link.bats` | -| 0.1 | 0.0% | `tests/cross-check.bats` | | 0.1 | 0.0% | `tests/perf-pair.bats` | +| 0.1 | 0.0% | `tests/cross-check.bats` | +| 0.1 | 0.0% | `tests/darwin-link.bats` | | 0.0 | 0.0% | `tests/zizmor-split.bats` | diff --git a/crates/batten/src/lib.rs b/crates/batten/src/lib.rs index cd22a0b79..17d6e30db 100644 --- a/crates/batten/src/lib.rs +++ b/crates/batten/src/lib.rs @@ -3791,6 +3791,20 @@ fn stop_nudges(overrides: &Overrides, envelope: &hook::Envelope) -> Option Option Date: Wed, 26 Aug 2026 15:32:37 +0000 Subject: [PATCH 18/38] fix(rebase): three line ceilings the merge crossed, resolved along existing seams MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rebasing onto a `main` that had itself grown `Fact::Symbols`, a `[[mint]]` weakening table and a wider `run_hook` put three functions over the 100-line ceiling once both sides' additions landed in one body. Each is extracted where the file already had a seam rather than by raising the limit: * `rules::symbols_value` — the one projection arm whose value is a nested document rather than a `json!` of a field. * `trust::mint_weakenings` — `recorder_weakenings`' sibling, one table with its added-rows and changed-rows arms together. * `lib::unsupported_event_note` — the two-sentence note a host that does not emit an event is given. No behaviour changes; `mise run lint:clippy` exits 0. Refs: CLOUD-1050, CLOUD-1051 --- crates/batten/src/lib.rs | 42 +++++++++++++------ crates/batten/src/rules.rs | 51 +++++++++++++---------- crates/batten/src/trust.rs | 85 ++++++++++++++++++++++---------------- 3 files changed, 108 insertions(+), 70 deletions(-) diff --git a/crates/batten/src/lib.rs b/crates/batten/src/lib.rs index 17d6e30db..036cbeecb 100644 --- a/crates/batten/src/lib.rs +++ b/crates/batten/src/lib.rs @@ -2627,6 +2627,34 @@ fn without_set_hatches(policy: hook::Policy) -> hook::Policy { policy.without_hatched(&set) } +/// What a host that does not emit this event is told, split out of [`run_hook`] +/// when the end-of-turn clause pushed that function past the line ceiling. +/// +/// Two sentences and no third: the host either degrades the event to one it does +/// emit, in which case a policy keyed on it still fires somewhere, or it does +/// not, in which case nothing keyed on it fires at all. Naming which of the two +/// happened is the whole content — an absent capability is a statement about the +/// host, never a refusal. +fn unsupported_event_note( + harness: hook::Harness, + capabilities: &hook::Capabilities, + event: hook::Event, +) -> String { + match capabilities.degrade(event) { + Some(fallback) => format!( + "{} does not emit {}; a policy keyed on it watches {} here", + harness.as_str(), + event.as_str(), + fallback.as_str() + ), + None => format!( + "{} does not emit {}; nothing keyed on it fires here", + harness.as_str(), + event.as_str() + ), + } +} + fn run_hook( harness: hook::Harness, mode: Mode, @@ -2654,19 +2682,7 @@ fn run_hook( // is reachable it is the ordinary state rather than news. let capabilities = harness.capabilities(); if !capabilities.emits(envelope.event) && envelope.event != hook::Event::Unrecognized { - let note = match capabilities.degrade(envelope.event) { - Some(fallback) => format!( - "{} does not emit {}; a policy keyed on it watches {} here", - harness.as_str(), - envelope.event.as_str(), - fallback.as_str() - ), - None => format!( - "{} does not emit {}; nothing keyed on it fires here", - harness.as_str(), - envelope.event.as_str() - ), - }; + let note = unsupported_event_note(harness, &capabilities, envelope.event); output::message(mode, Verbosity::Verbose, err, ¬e)?; return Ok(ExitCode::Success); } diff --git a/crates/batten/src/rules.rs b/crates/batten/src/rules.rs index bdd260345..32d46fc67 100644 --- a/crates/batten/src/rules.rs +++ b/crates/batten/src/rules.rs @@ -5661,6 +5661,35 @@ fn symbols_fact(rules: &[Rule], root: &Path) -> crate::facts::Look) -> serde_json::Value { + match symbols { + crate::facts::Look::IsNot | crate::facts::Look::CouldNotLook => serde_json::Value::Null, + crate::facts::Look::Is(resolved) => serde_json::json!({ + "provenance": { + "tool": resolved.provenance.tool, + "version": resolved.provenance.version, + "invocation": resolved.provenance.invocation, + }, + "sites": resolved + .sites + .iter() + .map(|site| serde_json::json!({ + "path": site.path, + "line": site.line, + "lint": site.lint, + })) + .collect::>(), + }), + } +} + pub(crate) fn tree_document( cache: &BTreeMap<(String, Wanted), Acquired>, declared: &Declared<'_>, @@ -5818,27 +5847,7 @@ pub(crate) fn tree_document( // is not attributable to anything. An EMPTY `sites` here is the // third answer and a real one: the analyser ran and resolved no // site. `null` and `[]` are the pair this projection keeps apart. - crate::facts::Fact::Symbols => match symbols { - crate::facts::Look::IsNot | crate::facts::Look::CouldNotLook => { - serde_json::Value::Null - } - crate::facts::Look::Is(resolved) => serde_json::json!({ - "provenance": { - "tool": resolved.provenance.tool, - "version": resolved.provenance.version, - "invocation": resolved.provenance.invocation, - }, - "sites": resolved - .sites - .iter() - .map(|site| serde_json::json!({ - "path": site.path, - "line": site.line, - "lint": site.lint, - })) - .collect::>(), - }), - }, + crate::facts::Fact::Symbols => symbols_value(symbols), // CLOUD-1059, and `null` here carries BOTH could-not-look conditions // the family already collapses: no row declared a delta, and a row // declared one whose base did not resolve. A migration gate reads the diff --git a/crates/batten/src/trust.rs b/crates/batten/src/trust.rs index c16b1e2a1..49eeecb65 100644 --- a/crates/batten/src/trust.rs +++ b/crates/batten/src/trust.rs @@ -1267,6 +1267,54 @@ fn returns_token(returns: crate::facts::Returns) -> &'static str { } } +/// The `[[mint]]` table's weakenings, split out of [`entry_weakenings`] when +/// [`recorder_weakenings`]' sibling pushed that function past the line ceiling. +/// +/// The seam is `recorder_weakenings`': one table, its added-rows arm and its +/// changed-rows arm together, so the inverted direction stated below is stated +/// once beside both halves that depend on it. +fn mint_weakenings(base: &Config, working: &Config) -> Vec { + let mut found = Vec::new(); + // The minted receipts (CLOUD-1024), and the direction is INVERTED from the + // `[[fact]]` block in `entry_weakenings`. A fact is read by a gate; a mint + // WRITES what a gate reads, so + // presence lowers the bar rather than raising it — `added_entries`' case, not + // `removed_entries`'. A branch that adds a row here makes a rule pass on the + // mere fact that a tool was called, where the trusted file required a + // deliberate act. + found.extend(added_entries( + WeakeningKind::MintAdded, + &ids(base.mints.iter().map(|mint| format!("mint[{}]", mint.name))), + &ids(working + .mints + .iter() + .map(|mint| format!("mint[{}]", mint.name))), + )); + for base_mint in &base.mints { + if let Some(working_mint) = working + .mints + .iter() + .find(|candidate| candidate.name == base_mint.name) + && working_mint != base_mint + { + // Pointer-only (rule 4): the mint's name and two digests, never the + // definition — a `body` is a template over a consumer's field paths + // and one side of the comparison is whatever a branch wrote. + found.push(Weakening { + kind: WeakeningKind::MintChanged, + key: format!("mint[{}]", base_mint.name), + base: column_token( + &serde_json::to_value(base_mint).unwrap_or(serde_json::Value::Null), + ), + working: column_token( + &serde_json::to_value(working_mint).unwrap_or(serde_json::Value::Null), + ), + }); + } + } + found +} + fn entry_weakenings(base: &Config, working: &Config) -> Vec { let mut found = Vec::new(); @@ -1373,42 +1421,7 @@ fn entry_weakenings(base: &Config, working: &Config) -> Vec { } } - // The minted receipts (CLOUD-1024), and the direction is INVERTED from the - // block above. A fact is read by a gate; a mint WRITES what a gate reads, so - // presence lowers the bar rather than raising it — `added_entries`' case, not - // `removed_entries`'. A branch that adds a row here makes a rule pass on the - // mere fact that a tool was called, where the trusted file required a - // deliberate act. - found.extend(added_entries( - WeakeningKind::MintAdded, - &ids(base.mints.iter().map(|mint| format!("mint[{}]", mint.name))), - &ids(working - .mints - .iter() - .map(|mint| format!("mint[{}]", mint.name))), - )); - for base_mint in &base.mints { - if let Some(working_mint) = working - .mints - .iter() - .find(|candidate| candidate.name == base_mint.name) - && working_mint != base_mint - { - // Pointer-only (rule 4): the mint's name and two digests, never the - // definition — a `body` is a template over a consumer's field paths - // and one side of the comparison is whatever a branch wrote. - found.push(Weakening { - kind: WeakeningKind::MintChanged, - key: format!("mint[{}]", base_mint.name), - base: column_token( - &serde_json::to_value(base_mint).unwrap_or(serde_json::Value::Null), - ), - working: column_token( - &serde_json::to_value(working_mint).unwrap_or(serde_json::Value::Null), - ), - }); - } - } + found.extend(mint_weakenings(base, working)); found.extend(recorder_weakenings(base, working)); From 840067f80935f12d182ff08e728453338bae7e28 Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Wed, 26 Aug 2026 15:33:39 +0000 Subject: [PATCH 19/38] chore: regenerate completions and the policy man page for both merged verbs `policy tools` arrived on `main` while `policy explain` was on this branch, so the conflict resolution took one side of each generated artifact and neither carried both verbs. Regenerated with `mise run completions` and `mise run man`; nothing here is hand-edited. Refs: CLOUD-1050, CLOUD-1053 --- completions/batten.bash | 6 ++--- completions/batten.fish | 58 +++++++++++++++++++++++++++++------------ completions/batten.zsh | 41 +++++++++++++++++++++++++++++ man/batten-policy.1 | 3 +++ 4 files changed, 88 insertions(+), 20 deletions(-) diff --git a/completions/batten.bash b/completions/batten.bash index 279408549..a61903479 100644 --- a/completions/batten.bash +++ b/completions/batten.bash @@ -2565,7 +2565,7 @@ _batten() { return 0 ;; batten__subcmd__help__subcmd__policy) - opts="budget test explain" + opts="budget test tools explain" if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 @@ -3245,7 +3245,7 @@ _batten() { return 0 ;; batten__subcmd__policy) - opts="-q -v -y -h --strictness --fail-on-warning --config-from --silent --quiet --verbose --debug --trace --log-level --no-color --no-input --yes --help budget test explain help" + opts="-q -v -y -h --strictness --fail-on-warning --config-from --silent --quiet --verbose --debug --trace --log-level --no-color --no-input --yes --help budget test tools explain help" if [[ ${cur} == -* || ${COMP_CWORD} -eq 2 ]] ; then COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 @@ -3323,7 +3323,7 @@ _batten() { return 0 ;; batten__subcmd__policy__subcmd__help) - opts="budget test explain help" + opts="budget test tools explain help" if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 diff --git a/completions/batten.fish b/completions/batten.fish index 05c22a5dc..4d0389520 100644 --- a/completions/batten.fish +++ b/completions/batten.fish @@ -661,30 +661,31 @@ complete -c batten -n "__fish_batten_using_subcommand generate; and __fish_seen_ complete -c batten -n "__fish_batten_using_subcommand generate; and __fish_seen_subcommand_from help" -f -a "markdown" -d 'Emit the whole command surface as one markdown reference, on stdout' complete -c batten -n "__fish_batten_using_subcommand generate; and __fish_seen_subcommand_from help" -f -a "schema" -d 'Emit the JSON Schema for a config or policy-input surface, derived from the types that define it' complete -c batten -n "__fish_batten_using_subcommand generate; 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 policy; and not __fish_seen_subcommand_from budget test explain 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 policy; and not __fish_seen_subcommand_from budget test tools explain 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 policy; and not __fish_seen_subcommand_from budget test explain 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 policy; and not __fish_seen_subcommand_from budget test explain 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 policy; and not __fish_seen_subcommand_from budget test tools explain 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 policy; and not __fish_seen_subcommand_from budget test tools explain 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 policy; and not __fish_seen_subcommand_from budget test explain 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 policy; and not __fish_seen_subcommand_from budget test explain help" -l silent -d 'Say nothing but a verdict or a usage error' -complete -c batten -n "__fish_batten_using_subcommand policy; and not __fish_seen_subcommand_from budget test explain help" -s q -l quiet -d 'Suppress ordinary progress (repeatable: -qq is silent)' -complete -c batten -n "__fish_batten_using_subcommand policy; and not __fish_seen_subcommand_from budget test explain help" -s v -l verbose -d 'Explain what is being checked (repeatable: -vv is debug)' -complete -c batten -n "__fish_batten_using_subcommand policy; and not __fish_seen_subcommand_from budget test explain help" -l debug -d 'Add resolution detail' -complete -c batten -n "__fish_batten_using_subcommand policy; and not __fish_seen_subcommand_from budget test explain help" -l trace -d 'Add everything' -complete -c batten -n "__fish_batten_using_subcommand policy; and not __fish_seen_subcommand_from budget test explain help" -l no-color -d 'Never colour stderr, whatever it is attached to' -complete -c batten -n "__fish_batten_using_subcommand policy; and not __fish_seen_subcommand_from budget test explain help" -l no-input -d 'Never prompt; treat the run as unattended' -complete -c batten -n "__fish_batten_using_subcommand policy; and not __fish_seen_subcommand_from budget test explain help" -s y -l yes -d 'Confirm a destructive operation that would otherwise refuse' -complete -c batten -n "__fish_batten_using_subcommand policy; and not __fish_seen_subcommand_from budget test explain help" -s h -l help -d 'Print help (see more with \'--help\')' -complete -c batten -n "__fish_batten_using_subcommand policy; and not __fish_seen_subcommand_from budget test explain help" -f -a "budget" -d 'Judge the always-loaded instruction set against its declared token budget' -complete -c batten -n "__fish_batten_using_subcommand policy; and not __fish_seen_subcommand_from budget test explain help" -f -a "test" -d 'Run each registered module\'s own `test_` rules and report the predicates none exercised' -complete -c batten -n "__fish_batten_using_subcommand policy; and not __fish_seen_subcommand_from budget test explain help" -f -a "explain" -d 'Resolve a verdict token to its class definition and the routes out of it' -complete -c batten -n "__fish_batten_using_subcommand policy; and not __fish_seen_subcommand_from budget test explain help" -f -a "help" -d 'Print this message or the help of the given subcommand(s)' +complete -c batten -n "__fish_batten_using_subcommand policy; and not __fish_seen_subcommand_from budget test tools explain 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 policy; and not __fish_seen_subcommand_from budget test tools explain help" -l silent -d 'Say nothing but a verdict or a usage error' +complete -c batten -n "__fish_batten_using_subcommand policy; and not __fish_seen_subcommand_from budget test tools explain help" -s q -l quiet -d 'Suppress ordinary progress (repeatable: -qq is silent)' +complete -c batten -n "__fish_batten_using_subcommand policy; and not __fish_seen_subcommand_from budget test tools explain help" -s v -l verbose -d 'Explain what is being checked (repeatable: -vv is debug)' +complete -c batten -n "__fish_batten_using_subcommand policy; and not __fish_seen_subcommand_from budget test tools explain help" -l debug -d 'Add resolution detail' +complete -c batten -n "__fish_batten_using_subcommand policy; and not __fish_seen_subcommand_from budget test tools explain help" -l trace -d 'Add everything' +complete -c batten -n "__fish_batten_using_subcommand policy; and not __fish_seen_subcommand_from budget test tools explain help" -l no-color -d 'Never colour stderr, whatever it is attached to' +complete -c batten -n "__fish_batten_using_subcommand policy; and not __fish_seen_subcommand_from budget test tools explain help" -l no-input -d 'Never prompt; treat the run as unattended' +complete -c batten -n "__fish_batten_using_subcommand policy; and not __fish_seen_subcommand_from budget test tools explain help" -s y -l yes -d 'Confirm a destructive operation that would otherwise refuse' +complete -c batten -n "__fish_batten_using_subcommand policy; and not __fish_seen_subcommand_from budget test tools explain help" -s h -l help -d 'Print help (see more with \'--help\')' +complete -c batten -n "__fish_batten_using_subcommand policy; and not __fish_seen_subcommand_from budget test tools explain help" -f -a "budget" -d 'Judge the always-loaded instruction set against its declared token budget' +complete -c batten -n "__fish_batten_using_subcommand policy; and not __fish_seen_subcommand_from budget test tools explain help" -f -a "test" -d 'Run each registered module\'s own `test_` rules and report the predicates none exercised' +complete -c batten -n "__fish_batten_using_subcommand policy; and not __fish_seen_subcommand_from budget test tools explain help" -f -a "tools" -d 'Print the tool names the mediated-call rows decide, one per line' +complete -c batten -n "__fish_batten_using_subcommand policy; and not __fish_seen_subcommand_from budget test tools explain help" -f -a "explain" -d 'Resolve a verdict token to its class definition and the routes out of it' +complete -c batten -n "__fish_batten_using_subcommand policy; and not __fish_seen_subcommand_from budget test tools explain help" -f -a "help" -d 'Print this message or the help of the given subcommand(s)' complete -c batten -n "__fish_batten_using_subcommand policy; and __fish_seen_subcommand_from budget" -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'" @@ -727,6 +728,27 @@ complete -c batten -n "__fish_batten_using_subcommand policy; and __fish_seen_su complete -c batten -n "__fish_batten_using_subcommand policy; and __fish_seen_subcommand_from test" -l no-input -d 'Never prompt; treat the run as unattended' complete -c batten -n "__fish_batten_using_subcommand policy; and __fish_seen_subcommand_from test" -s y -l yes -d 'Confirm a destructive operation that would otherwise refuse' complete -c batten -n "__fish_batten_using_subcommand policy; and __fish_seen_subcommand_from test" -s h -l help -d 'Print help (see more with \'--help\')' +complete -c batten -n "__fish_batten_using_subcommand policy; and __fish_seen_subcommand_from tools" -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 policy; and __fish_seen_subcommand_from tools" -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 policy; and __fish_seen_subcommand_from tools" -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 policy; and __fish_seen_subcommand_from tools" -s J -l json -d 'Emit byte-stable JSON instead of pointer lines' +complete -c batten -n "__fish_batten_using_subcommand policy; and __fish_seen_subcommand_from tools" -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 policy; and __fish_seen_subcommand_from tools" -l silent -d 'Say nothing but a verdict or a usage error' +complete -c batten -n "__fish_batten_using_subcommand policy; and __fish_seen_subcommand_from tools" -s q -l quiet -d 'Suppress ordinary progress (repeatable: -qq is silent)' +complete -c batten -n "__fish_batten_using_subcommand policy; and __fish_seen_subcommand_from tools" -s v -l verbose -d 'Explain what is being checked (repeatable: -vv is debug)' +complete -c batten -n "__fish_batten_using_subcommand policy; and __fish_seen_subcommand_from tools" -l debug -d 'Add resolution detail' +complete -c batten -n "__fish_batten_using_subcommand policy; and __fish_seen_subcommand_from tools" -l trace -d 'Add everything' +complete -c batten -n "__fish_batten_using_subcommand policy; and __fish_seen_subcommand_from tools" -l no-color -d 'Never colour stderr, whatever it is attached to' +complete -c batten -n "__fish_batten_using_subcommand policy; and __fish_seen_subcommand_from tools" -l no-input -d 'Never prompt; treat the run as unattended' +complete -c batten -n "__fish_batten_using_subcommand policy; and __fish_seen_subcommand_from tools" -s y -l yes -d 'Confirm a destructive operation that would otherwise refuse' +complete -c batten -n "__fish_batten_using_subcommand policy; and __fish_seen_subcommand_from tools" -s h -l help -d 'Print help (see more with \'--help\')' complete -c batten -n "__fish_batten_using_subcommand policy; and __fish_seen_subcommand_from explain" -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'" @@ -750,6 +772,7 @@ complete -c batten -n "__fish_batten_using_subcommand policy; and __fish_seen_su complete -c batten -n "__fish_batten_using_subcommand policy; and __fish_seen_subcommand_from explain" -s h -l help -d 'Print help (see more with \'--help\')' complete -c batten -n "__fish_batten_using_subcommand policy; and __fish_seen_subcommand_from help" -f -a "budget" -d 'Judge the always-loaded instruction set against its declared token budget' complete -c batten -n "__fish_batten_using_subcommand policy; and __fish_seen_subcommand_from help" -f -a "test" -d 'Run each registered module\'s own `test_` rules and report the predicates none exercised' +complete -c batten -n "__fish_batten_using_subcommand policy; and __fish_seen_subcommand_from help" -f -a "tools" -d 'Print the tool names the mediated-call rows decide, one per line' complete -c batten -n "__fish_batten_using_subcommand policy; and __fish_seen_subcommand_from help" -f -a "explain" -d 'Resolve a verdict token to its class definition and the routes out of it' complete -c batten -n "__fish_batten_using_subcommand policy; 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 commit; 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' @@ -1483,6 +1506,7 @@ 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 generate" -f -a "schema" -d 'Emit the JSON Schema for a config or policy-input surface, derived from the types that define it' complete -c batten -n "__fish_batten_using_subcommand help; and __fish_seen_subcommand_from policy" -f -a "budget" -d 'Judge the always-loaded instruction set against its declared token budget' complete -c batten -n "__fish_batten_using_subcommand help; and __fish_seen_subcommand_from policy" -f -a "test" -d 'Run each registered module\'s own `test_` rules and report the predicates none exercised' +complete -c batten -n "__fish_batten_using_subcommand help; and __fish_seen_subcommand_from policy" -f -a "tools" -d 'Print the tool names the mediated-call rows decide, one per line' complete -c batten -n "__fish_batten_using_subcommand help; and __fish_seen_subcommand_from policy" -f -a "explain" -d 'Resolve a verdict token to its class definition and the routes out of it' complete -c batten -n "__fish_batten_using_subcommand help; and __fish_seen_subcommand_from commit" -f -a "check" -d 'Refuse a commit subject that does not follow the configured convention' 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' diff --git a/completions/batten.zsh b/completions/batten.zsh index e8231981e..712164fc1 100644 --- a/completions/batten.zsh +++ b/completions/batten.zsh @@ -1131,6 +1131,36 @@ trace\:"Add everything"))' \ '--help[Print help (see more with '\''--help'\'')]' \ && ret=0 ;; +(tools) +_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' \ +'--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 +;; (explain) _arguments "${_arguments_options[@]}" : \ '--strictness=[Raise how strictly gates apply (an override may only tighten policy)]: :((permissive\:"Advisory\: findings are reported without failing the run" @@ -1182,6 +1212,10 @@ _arguments "${_arguments_options[@]}" : \ _arguments "${_arguments_options[@]}" : \ && ret=0 ;; +(tools) +_arguments "${_arguments_options[@]}" : \ +&& ret=0 +;; (explain) _arguments "${_arguments_options[@]}" : \ && ret=0 @@ -2664,6 +2698,10 @@ _arguments "${_arguments_options[@]}" : \ _arguments "${_arguments_options[@]}" : \ && ret=0 ;; +(tools) +_arguments "${_arguments_options[@]}" : \ +&& ret=0 +;; (explain) _arguments "${_arguments_options[@]}" : \ && ret=0 @@ -3625,6 +3663,7 @@ _batten__subcmd__help__subcmd__policy_commands() { local commands; commands=( 'budget:Judge the always-loaded instruction set against its declared token budget' \ 'test:Run each registered module'\''s own \`test_\` rules and report the predicates none exercised' \ +'tools:Print the tool names the mediated-call rows decide, one per line' \ 'explain:Resolve a verdict token to its class definition and the routes out of it' \ ) _describe -t commands 'batten help policy commands' commands "$@" @@ -3852,6 +3891,7 @@ _batten__subcmd__policy_commands() { local commands; commands=( 'budget:Judge the always-loaded instruction set against its declared token budget' \ 'test:Run each registered module'\''s own \`test_\` rules and report the predicates none exercised' \ +'tools:Print the tool names the mediated-call rows decide, one per line' \ 'explain:Resolve a verdict token to its class definition and the routes out of it' \ 'help:Print this message or the help of the given subcommand(s)' \ ) @@ -3872,6 +3912,7 @@ _batten__subcmd__policy__subcmd__help_commands() { local commands; commands=( 'budget:Judge the always-loaded instruction set against its declared token budget' \ 'test:Run each registered module'\''s own \`test_\` rules and report the predicates none exercised' \ +'tools:Print the tool names the mediated-call rows decide, one per line' \ 'explain:Resolve a verdict token to its class definition and the routes out of it' \ 'help:Print this message or the help of the given subcommand(s)' \ ) diff --git a/man/batten-policy.1 b/man/batten-policy.1 index 8e262cd5c..0469fb9d8 100644 --- a/man/batten-policy.1 +++ b/man/batten-policy.1 @@ -19,6 +19,9 @@ Judge the always\-loaded instruction set against its declared token budget batten\-policy\-test(1) Run each registered module\*(Aqs own `test_` rules and report the predicates none exercised .TP +batten\-policy\-tools(1) +Print the tool names the mediated\-call rows decide, one per line +.TP batten\-policy\-explain(1) Resolve a verdict token to its class definition and the routes out of it .TP From 74f7692b65484326e795fb7891385ae9d618f24e Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Wed, 26 Aug 2026 15:35:36 +0000 Subject: [PATCH 20/38] fix(policy)!: two modules main grew during the rebase move to the typed ABI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `policy/review-answered.rego` (CLOUD-859) and `policy/spawn-adapters.rego` (CLOUD-931) landed on `main` while CLOUD-1050 was on this branch, so both were written against `{rule, msg}` — the shape this change deletes. A module still binding `msg` loads clean and reports nothing, which is why the decoder refuses it outright rather than accepting both spellings. Each refusal becomes `{rule, verdict, subjects}` and its prose moves into a `[[verdict]]` row: * `V-REVIEW-UNANSWERED`, carrying a `count` subject — the only subject the fact channel can honestly produce, since it stores a count and no byte of the buffer. * `V-SPAWN-UNPLACED`, carrying the site's `path`/`line` and the module name. * `V-SYMBOL-CENSUS-ABSENT` and `V-ADAPTER-TABLE-EMPTY`, which carry no subject: neither is about a place in the tree. Three tokens for `spawn-adapters` rather than one, because a reader meeting an unplaced spawn and a reader meeting an absent census have nothing to learn from each other. `mise run policy-test`: 17 bundles, 175 passed, 0 failed. Refs: CLOUD-859, CLOUD-931, CLOUD-1050 --- batten.toml | 78 +++++++++++++++++++++++++++++++++++++ policy/review-answered.rego | 14 +++---- policy/spawn-adapters.rego | 10 ++--- 3 files changed, 88 insertions(+), 14 deletions(-) diff --git a/batten.toml b/batten.toml index c62c1691a..09d0e984d 100644 --- a/batten.toml +++ b/batten.toml @@ -3957,3 +3957,81 @@ program on PATH, which this rule deliberately leaves alone. id = "R-DEFINE-THE-ROUTED-TASK" kind = "document" target = "mise.toml" + +# CLOUD-859's gate, migrated to the typed ABI when CLOUD-1050 landed. The class +# carries what the free string used to, and the count is the only subject the +# fact channel can honestly produce. +[[verdict]] +id = "V-REVIEW-UNANSWERED" +gloss = "readying would buy a CI matrix on a head carrying blocking review conditions" +class = """ +Readying is the event that starts CI, and nothing in `land`'s pre-ready sequence \ +asks about review. A blocking condition is an unresolved review thread, or no \ +review from anyone but the author. Replayed over the last 100 merges this fires \ +on 89, so the common case is that there is something here to read. The thread \ +ids are deliberately absent: the fact channel stores a COUNT and no byte of the \ +buffer (non-negotiable rule 4), so the command that produced this number is also \ +the only thing that can name them. +""" + +[[verdict.route]] +id = "R-ANSWER-THE-THREADS" +kind = "command" +target = "resolve each thread, then re-run the declared command and retry" + +[[verdict.route]] +id = "R-FORCE-A-REVIEW" +kind = "command" +target = "@coderabbitai full review" + +# CLOUD-931's three refusals, migrated with the module. Three tokens rather than +# one, because a reader meeting an unplaced spawn and a reader meeting an absent +# census have nothing to learn from each other. +[[verdict]] +id = "V-SPAWN-UNPLACED" +gloss = "a module the adapter table does not place resolves the spawn type" +class = """ +The adapter table says which modules own a delegated tool. A spawn outside them \ +is a child process nobody placed, so route it through `exec` — the sanctioned \ +boundary — or place the module beside the tool it delegates to. Resolved by NAME \ +rather than by spelling: a byte scan would report `surface.rs`, which imports \ +`clap::Command` and spawns nothing. +""" + +[[verdict.route]] +id = "R-ROUTE-THROUGH-EXEC" +kind = "document" +target = "crates/batten/src/exec.rs" + +[[verdict.route]] +id = "R-PLACE-THE-ADAPTER" +kind = "document" +target = "policy/spawn-adapters.rego" + +[[verdict]] +id = "V-SYMBOL-CENSUS-ABSENT" +gloss = "the symbol census is absent, so no spawn was placed or refused" +class = """ +Could-not-look is not clean (CLOUD-251). `input.tree.symbols` is `null` both when \ +no row declared the fact and when the analyser could not be run or parsed, and \ +reporting a tree with no unplaced spawns from either would be the vacuous pass \ +this gate exists against. +""" + +[[verdict.route]] +id = "R-DECLARE-THE-SYMBOL-FACT" +kind = "document" +target = "batten.toml" + +[[verdict]] +id = "V-ADAPTER-TABLE-EMPTY" +gloss = "the adapter table places no module, so this rule decides nothing" +class = """ +The vacuity guard. A table placing nothing cannot refuse anything, and a gate \ +that cannot refuse is off rather than passing. +""" + +[[verdict.route]] +id = "R-PLACE-AT-LEAST-ONE-ADAPTER" +kind = "document" +target = "policy/spawn-adapters.rego" diff --git a/policy/review-answered.rego b/policy/review-answered.rego index 5ab13c40a..3f6550a8f 100644 --- a/policy/review-answered.rego +++ b/policy/review-answered.rego @@ -22,10 +22,10 @@ # payload; it is not implementable on this channel, and the row records the # reformulation rather than this file re-arguing it. # -# WHICH IS ALSO WHY THE THREAD IDS ARE NOT IN THE MESSAGE. §5 asks for them. They -# are not in the engine, so the finding names the count and points at the command -# that produces the ids. A `msg` claiming to name them would be prose asserting a -# payload this channel refuses to carry, which is worse than the honest count. +# WHICH IS ALSO WHY THE THREAD IDS ARE NOT A SUBJECT. §5 asks for them. They are +# not in the engine, so the refusal carries a `count` subject and its class points +# at the command that produces the ids. A subject claiming to name them would be +# a payload this channel refuses to carry, which is worse than the honest count. # # THE ABSENT RECORD IS NOT THIS MODULE'S, and the split is deliberate. # `ready-needs-an-answered-review` is a `receipt` row over the same fact: a @@ -83,10 +83,8 @@ rules contains "review-unanswered" violation contains { "rule": "review-unanswered", - "msg": sprintf( - "readying this PR would buy a CI matrix on a head carrying %d blocking review condition(s): an unresolved review thread, or no review from anyone but the author. Answer them first — resolve each thread, or force a review with `@coderabbitai full review`, which returns in ~3 minutes and costs no CI because the draft phase is the free phase — then re-run the declared command and retry. Replayed over the last 100 merges this fires on 89, so the common case is that there is something here to read. The thread ids are deliberately absent: the fact channel stores a COUNT and no byte of the buffer (non-negotiable rule 4), so the command that produced this number is also the only thing that can name them", - [record.rows], - ), + "verdict": "V-REVIEW-UNANSWERED", + "subjects": [{"count": record.rows}], } if { readying record := input.facts["agent-sourced"]["review-answered"] diff --git a/policy/spawn-adapters.rego b/policy/spawn-adapters.rego index a736e7db6..6464f0d08 100644 --- a/policy/spawn-adapters.rego +++ b/policy/spawn-adapters.rego @@ -97,10 +97,8 @@ module_of(path) := name if { # all, so there is nothing here for this module to leak even by mistake. violation contains { "rule": "spawn-adapters", - "msg": sprintf( - "%s:%d %s resolves the spawn type and is not a placed adapter — route it through `exec`, or place the module with the tool it delegates to", - [site.path, site.line, module_of(site.path)], - ), + "verdict": "V-SPAWN-UNPLACED", + "subjects": [{"path": site.path, "line": site.line}, {"artifact": module_of(site.path)}], } if { some site in input.tree.symbols.sites site.lint == "clippy::disallowed_types" @@ -126,7 +124,7 @@ no_census if not input.tree.symbols.sites violation contains { "rule": "spawn-adapters", - "msg": "the symbol census is absent, so no spawn was placed or refused -- declare `symbols = true` on this row, or install the analyser", + "verdict": "V-SYMBOL-CENSUS-ABSENT", } if { no_census } @@ -135,7 +133,7 @@ violation contains { # cannot refuse is off. violation contains { "rule": "spawn-adapters", - "msg": "the adapter table places no module, so this rule decides nothing -- a gate that cannot refuse is off", + "verdict": "V-ADAPTER-TABLE-EMPTY", } if { count(adapters) == 0 } From 3736e6b87d4dc7d455d8238c74ec743867865a4e Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Wed, 26 Aug 2026 15:43:39 +0000 Subject: [PATCH 21/38] refactor(exec): the two new spawns move to the placed adapter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `policy/spawn-adapters.rego` landed on `main` during this branch's life: a module resolving the spawn type must be one the adapter table places, or route through `exec`. Both spawns this PR added — `lib.rs`'s end-of-turn sibling invocation and `recorder.rs`'s program run — sat in unplaced modules, and the committed-config tests in `cli.rs` were reporting all three sites. Placing `lib` and `recorder` in the table would have been the wrong repair: neither owns a delegated tool. So the shape moves to `exec::piped`, which is where it belonged anyway — the two callers had grown the same `Command::new` with the same stdin pipe, the same discarded stderr and the same could-not-look collapse, written twice. `exec::piped` is deliberately not `run_in`: no tee, no capture spool, no process group, no signal forwarding. Those exist because `exec` supervises a command a human named; this runs a program the committed config named and wants its stdout as a value. Also updates `tests/facts.rs`'s deliberate fact count to 22: `main` added `Fact::Symbols` while this branch added `Fact::BaseDelta` and `Fact::Records`, and the merge kept one side's count. `mise run test:cargo`: 2580/2580. `mise run lint:clippy` exits 0. Refs: CLOUD-931, CLOUD-1051 --- crates/batten/src/exec.rs | 53 +++++++++++++++++++++++++++++++++++ crates/batten/src/lib.rs | 29 +++++-------------- crates/batten/src/recorder.rs | 35 ++++++----------------- crates/batten/tests/facts.rs | 2 +- 4 files changed, 70 insertions(+), 49 deletions(-) diff --git a/crates/batten/src/exec.rs b/crates/batten/src/exec.rs index fb0113dff..9487aa1f8 100644 --- a/crates/batten/src/exec.rs +++ b/crates/batten/src/exec.rs @@ -1190,6 +1190,59 @@ pub fn run_in_with( report_bundle(&bundle, &outcomes, patterns, settings, report) } +/// Run a program at `root/` with `stdin` piped in, and read back its +/// exit code and stdout. +/// +/// **The cheap sibling of [`run_in`], and it lives here because `exec` is the +/// placed child-process adapter** (`policy/spawn-adapters.rego`). Two callers +/// needed exactly this — the end-of-turn nudge invoking the programs the retired +/// `stop-guard.sh` invoked, and a `[[recorder]]` row running the gate whose +/// verdict it records — and each had grown its own `Command::new`, which is two +/// spawns in two unplaced modules and one shape written twice. +/// +/// It is deliberately NOT `run_in`: no tee, no capture spool, no process group, +/// no signal forwarding, no report. Those exist because `exec` is a supervisor +/// for a command a human named; this runs a program the committed config named, +/// wants its stdout as a value, and must stay cheap enough for the mediated path. +/// +/// **stderr is discarded, deliberately.** Every program reached this way is a +/// gate whose stderr is its own pointer report, and a caller that surfaced it +/// would be a second, unasked-for channel for another rule's findings. +/// +/// `None` is could-not-look, collapsed on purpose: absent, unrunnable, a broken +/// pipe and a child that died without a code are all "no answer", and no caller +/// can act differently on which. A caller that needs clean-versus-unreadable +/// apart must not infer it from here. +#[expect( + clippy::disallowed_types, + reason = "stays: this is the placed adapter's spawn, and the two callers that used to hold their own now hold none (CLOUD-1051)" +)] +pub(crate) fn piped( + root: &Path, + program: &Path, + args: &[String], + stdin: &str, +) -> Option<(i32, String)> { + let path = root.join(program); + if !path.is_file() { + return None; + } + let mut child = Command::new(&path) + .args(args) + .current_dir(root) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .ok()?; + child.stdin.take()?.write_all(stdin.as_bytes()).ok()?; + let finished = child.wait_with_output().ok()?; + Some(( + finished.status.code()?, + String::from_utf8_lossy(&finished.stdout).into_owned(), + )) +} + /// This process's next dispatch number, for the live-capture key. /// /// The key has to name a *run*, not just a command: through the CLI there is diff --git a/crates/batten/src/lib.rs b/crates/batten/src/lib.rs index 036cbeecb..7e843651c 100644 --- a/crates/batten/src/lib.rs +++ b/crates/batten/src/lib.rs @@ -4070,31 +4070,16 @@ enum Suppression { /// The contract is the retired hook's, unchanged: a fired predicate is a /// non-zero exit with the pointer on stdout, and anything else — clean, /// unreadable, absent, unrunnable — is silence. -#[expect( - clippy::disallowed_types, - reason = "stays: CLOUD-1051 retires `stop-guard.sh` and invokes the siblings it invoked, unchanged and by path, so the cascade stops at the program being retired rather than reaching four more" -)] +/// +/// The spawn itself is [`exec::piped`]'s. This module is not a placed adapter +/// (`policy/spawn-adapters.rego`), and holding its own `Command` here would have +/// been the second copy of a shape the sanctioned boundary already owns. fn spawn_reading(root: &Path, program: &str, stdin: &str) -> Option { - use std::process::{Command, Stdio}; - - let path = root.join(program); - if !path.is_file() { - return None; - } - let mut child = Command::new(&path) - .current_dir(root) - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::null()) - .spawn() - .ok()?; - child.stdin.take()?.write_all(stdin.as_bytes()).ok()?; - let output = child.wait_with_output().ok()?; - if output.status.success() { + let (code, stdout) = exec::piped(root, Path::new(program), &[], stdin)?; + if code == 0 { return None; } - let pointer = String::from_utf8(output.stdout).ok()?; - let pointer = pointer.trim_end(); + let pointer = stdout.trim_end(); (!pointer.is_empty()).then(|| pointer.to_owned()) } diff --git a/crates/batten/src/recorder.rs b/crates/batten/src/recorder.rs index ac798bd30..e49d426d4 100644 --- a/crates/batten/src/recorder.rs +++ b/crates/batten/src/recorder.rs @@ -548,33 +548,16 @@ fn section(text: &str, label: ®ex::Regex, select: ®ex::Regex) -> String { /// **stderr is discarded, deliberately.** Every program a recorder runs is a gate /// whose stderr is its own pointer report, and this module prints nothing — a /// recorder that surfaced another gate's findings would be a second, unasked-for -/// channel for them. +/// channel for them. That bound, and the spawn, are [`crate::exec::piped`]'s: +/// this module is not a placed adapter (`policy/spawn-adapters.rego`), and it +/// runs the gates this repository already owns because the value it records IS +/// their verdict. Bounded three ways — only a program the committed config +/// names, only once the tool selector matched, and only with the stdin this +/// module assembled — so almost every tool result reaches none of it. It stays a +/// spawn rather than becoming a port because the censused program is the single +/// authority on a grammar 19 other files share. fn run_program(root: &Path, program: &Program, payload: &str) -> Option<(i32, String)> { - #[expect( - clippy::disallowed_types, - reason = "stays — a recorder runs the gates this repository already owns, because the \ - value it records IS their verdict (CLOUD-1051). Bounded three ways: only a \ - program the committed config names, only once the tool selector matched, and \ - only with the stdin this module assembled, so almost every tool result \ - reaches none of it and the per-call budget is untouched. It stays a spawn \ - rather than becoming a port because the censused program is the single \ - authority on a grammar 19 other files share — a second implementation of it \ - here would be the drift this whole migration exists to remove." - )] - let mut child = std::process::Command::new(root.join(&program.path)) - .args(&program.args) - .current_dir(root) - .stdin(std::process::Stdio::piped()) - .stdout(std::process::Stdio::piped()) - .stderr(std::process::Stdio::null()) - .spawn() - .ok()?; - child.stdin.take()?.write_all(payload.as_bytes()).ok()?; - let finished = child.wait_with_output().ok()?; - Some(( - finished.status.code()?, - String::from_utf8_lossy(&finished.stdout).into_owned(), - )) + crate::exec::piped(root, Path::new(&program.path), &program.args, payload) } /// One column's rendered token. diff --git a/crates/batten/tests/facts.rs b/crates/batten/tests/facts.rs index 2464a56eb..c002d969b 100644 --- a/crates/batten/tests/facts.rs +++ b/crates/batten/tests/facts.rs @@ -139,7 +139,7 @@ fn every_fact_returns_its_stated_const() { // rather than quietly shrinking the census. assert_eq!( Fact::ALL.len(), - 21, + 22, "the census covers every fact; update this count deliberately when the \ model gains or loses one" ); From 0ad14c74d3ccf1fe229ad2f8fc1a65bed82c7c47 Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Wed, 26 Aug 2026 16:14:53 +0000 Subject: [PATCH 22/38] feat(policy)!: the review-answered suite retires, and the edit arm learns truncation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two consequences of the ABI break, both surfaced by running this PR through its own gate rather than by reading. `tests/review-answered.bats` asserted the refusal's PROSE — `*"4 blocking"*` — which CLOUD-1050 deletes. `shell-retirement` refuses an authored Bats suite edited in place, and that refusal is the specified remedy rather than an obstacle: maintenance of a shell-tier rule is completed by migrating it. So the suite retires into `crates/batten/tests/review_answered.rs`, which drives the same two hook calls over the compiled binary — a `PostToolUse` envelope carrying the declared command and a buffer, then a `PreToolUse` `gh pr ready`. Eight cases carried, four changed (each reads the same count as a decoded `Subject::Count` instead of out of a free string), and one is new: a module may emit a verdict no `[[verdict]]` row declares, and the engine must say the registry is silent rather than print an empty gloss. `review-answered` leaves `$MUTANT_GATES` with a filed exemption, because `mutant` resolves a gate's suite by `tests/.bats` and there is no longer one; the three mutations it declared are what the ported open-threads, count and compound-command cases assert. The edit arm needed one more admitting shape, measured rather than imagined. `hooks-wiring-check.sh` opened its `DECLARED` table with the first entry glued to the assignment, so retiring the program that entry named SHORTENED the opening line instead of deleting it — which a line-set arm reads as an addition. `truncates_a_retired_reference` admits an added line that is a strict PREFIX of a removed one whose dropped remainder names a path this same delta deleted: it can only ever shorten, never introduce a byte the base did not carry at that position. The table now opens on a line of its own, so the next retirement in this campaign shortens nothing at all. Anti-vacuity ships with it at both tiers: a truncation dropping a reference to a file still in the tree is refused, at load time and over the engine. `mise run policy-test`: 17 bundles, 177 passed, 0 failed. `mise run test:cargo`: 2595/2595. `batten check --rule shell-retirement` against this PR's own diff: exit 0. Refs: CLOUD-859, CLOUD-1050, CLOUD-1059 --- crates/batten/tests/review_answered.rs | 500 ++++++++++++++++++++++++ crates/batten/tests/shell_retirement.rs | 80 ++++ mise-tasks/hooks-wiring-check.sh | 3 +- mise.toml | 2 +- policy/review-answered.rego | 33 +- policy/shell-retirement.rego | 75 +++- tests/review-answered.bats | 290 -------------- 7 files changed, 662 insertions(+), 321 deletions(-) create mode 100644 crates/batten/tests/review_answered.rs delete mode 100644 tests/review-answered.bats diff --git a/crates/batten/tests/review_answered.rs b/crates/batten/tests/review_answered.rs new file mode 100644 index 000000000..d4b6b85b4 --- /dev/null +++ b/crates/batten/tests/review_answered.rs @@ -0,0 +1,500 @@ +//! The read-the-review gate over the compiled binary (CLOUD-859), ported off +//! `tests/review-answered.bats` under CLOUD-1059. +//! +//! # Why it moved, and it is the migration gate's own doing +//! +//! CLOUD-1050 deletes `msg` from every refusal: a refusal is +//! `{rule, verdict, subjects}` and its prose lives in a `[[verdict]]` row. The +//! retired suite asserted the refusal's PROSE — `*"4 blocking"*` — so the ABI +//! change reddened it, and `policy/shell-retirement.rego` refuses an authored +//! Bats suite edited in place. Retiring it is the specified remedy rather than a +//! consequence somebody chose: maintenance of a shell-tier rule is completed by +//! migrating it. +//! +//! # The tier is unchanged, which is the whole point of the port +//! +//! Every case still goes through TWO real hook calls in the order a session +//! makes them: a `PostToolUse` envelope carrying the declared command and a +//! buffer, which mints the record, then a `PreToolUse` `gh pr ready`, which +//! reads it. Nothing writes a receipt by hand. A module's own `test_` rules +//! cannot do this — a `with input as` case fabricates the very shape the engine +//! may be unable to produce, the defect class +//! `.claude/rules/policy-modules.md` records twice. +//! +//! The fixture reads the declared command out of this repository's own +//! `batten.toml` rather than retyping it, for the retired suite's reason: +//! byte-equality is the forgery control, so a copy that drifted would leave +//! every case passing over a string the real gate does not use. +//! +//! # RETIREMENT LEDGER, PER PATH — what `shell-retirement` reads +//! +// carried: tests/review-answered.bats policy/review-answered.rego crates/batten/tests/review_answered.rs +//! +//! # RETIREMENT LEDGER — `tests/review-answered.bats`, 12 cases +//! +//! CARRIED — the property survives, proved here against the same two calls. +//! +// carried: "a ready with no record at all is refused, and the receipt row names the command" crates/batten/tests/review_answered.rs +// carried: "a head whose threads are all answered is allowed" crates/batten/tests/review_answered.rs +// carried: "VACUITY: a buffer that is not the declared shape records nothing rather than one row" crates/batten/tests/review_answered.rs +// carried: "VACUITY: an empty buffer is not zero rows" crates/batten/tests/review_answered.rs +// carried: "a buffer from a command nobody asked for never becomes the record" crates/batten/tests/review_answered.rs +// carried: "a re-draft is not a ready, even on a head carrying findings" crates/batten/tests/review_answered.rs +// carried: "a commit message naming the command is prose, not a ready" crates/batten/tests/review_answered.rs +// carried: "reading the review is never refused, so the remedy is reachable" crates/batten/tests/review_answered.rs +//! +//! CHANGED — the property survives and what it ASSERTS moved, because the +//! refusal it read is not a string any more. Each of these four asserted a +//! count inside prose; each now asserts the same count as the `Subject::Count` +//! the engine renders beside the token. The number is identical in every case, +//! which is what makes this a changed assertion rather than a dropped one. +//! +// changed: "review-answered::THE MEASURED SHAPE: a head carrying unresolved threads is refused, naming the count" crates/batten/tests/review_answered.rs +// changed: "review-answered::VACUITY: zero threads and no review reads as unreviewed, not as all-addressed" crates/batten/tests/review_answered.rs +// changed: "review-answered::VACUITY: a page the command could not read refuses rather than passing" crates/batten/tests/review_answered.rs +// changed: "review-answered::THE BYPASS: a compound command is still a ready" crates/batten/tests/review_answered.rs +//! +//! # One case the retired suite could not have +//! +//! `an undeclared class refuses with the token and says the registry is silent` +//! is new, and it is the ABI's own seam: a module may emit a verdict no +//! `[[verdict]]` row declares, and the engine must say so rather than print an +//! empty gloss. The retired suite had no registry to leave a hole in. + +#![allow(clippy::unwrap_used, clippy::expect_used)] + +mod common; + +use std::fs; +use std::path::{Path, PathBuf}; + +use common::{at_root, run_with_stdin, scratch}; + +/// The `command` of the `[[fact]]` row named `review-answered`, read out of this +/// repository's own committed config. +/// +/// BY NAME rather than by position: taking the first `[[fact]]` block would let +/// a row added above this one silently repoint every case here at the wrong +/// command. Parsed with a line scan rather than a TOML crate for the same reason +/// the retired suite used one — what is being asserted is the literal bytes of +/// one declaration, and a parser that normalised them would dissolve the +/// coupling this exists to hold. +fn declared_command() -> String { + let text = fs::read_to_string(at_root("batten.toml")).expect("read the committed config"); + let mut named = false; + for block in text.split("[[fact]]").skip(1) { + let mut command = None; + for line in block.lines() { + if let Some(rest) = line.strip_prefix("name = \"") { + named = rest.strip_suffix('"') == Some("review-answered"); + } + if let Some(rest) = line.strip_prefix("command = \"") + && let Some(value) = rest.strip_suffix('"') + { + command = Some(value.to_owned()); + } + if line.starts_with("[[") { + break; + } + } + if named && let Some(command) = command { + return command; + } + } + panic!("no [[fact]] row named review-answered in the committed config"); +} + +/// A fixture repository carrying the real module, the two rows that judge it, +/// and the verdict class the refusal resolves against. +/// +/// `declared` is threaded in rather than re-read per call: the fixture's config +/// and every envelope below must name the same bytes, and reading it twice is +/// two chances to disagree. +fn repo(name: &str, declared: &str, declare_the_class: bool) -> PathBuf { + let dir = scratch(name); + fs::create_dir_all(dir.join("policy")).expect("create the policy directory"); + fs::copy( + at_root("policy/review-answered.rego"), + dir.join("policy/review-answered.rego"), + ) + .expect("copy the module under test"); + let class = if declare_the_class { CLASS } else { "" }; + fs::write( + dir.join("batten.toml"), + format!("{CONFIG}command = \"{declared}\"\n{ROWS}{class}"), + ) + .expect("write the fixture config"); + git(&dir, &["init", "--quiet", "--initial-branch", "main"]); + git(&dir, &["config", "user.email", "fixture@example.invalid"]); + git(&dir, &["config", "user.name", "fixture"]); + // AND A COMMIT. The record is filed under the subject the row's + // `key = "head"` names, so a repository whose HEAD does not resolve has no + // subject — the boundary answers could-not-look and every case here would be + // ALLOWED, which is the fail-open posture working rather than the thing + // these cases are about. + git( + &dir, + &[ + "commit", + "--quiet", + "--allow-empty", + "-m", + "the head this gate judges", + ], + ); + dir +} + +#[expect( + clippy::disallowed_types, + reason = "stays — the fixture needs real git history for the head the record is keyed to, and \ + `board_record.rs`'s fixture is the precedent. Test-only, so no shipped path spawns \ + here." +)] +fn git(dir: &Path, args: &[&str]) { + let status = std::process::Command::new("git") + .args(args) + // A contributor's own git settings must not be able to move a verdict + // here (CLOUD-282). + .env("GIT_CONFIG_GLOBAL", "/dev/null") + .env("GIT_CONFIG_SYSTEM", "/dev/null") + .current_dir(dir) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .expect("run git"); + assert!(status.success(), "git {args:?} failed"); +} + +/// Mint the record the way a session does: a `PostToolUse` envelope carrying the +/// declared command and the buffer the host handed back. +fn record(dir: &Path, declared: &str, stdout: &str) { + let payload = serde_json::json!({ + "hook_event_name": "PostToolUse", + "session_id": "sess-review", + "cwd": "/repo", + "tool_name": "Bash", + "tool_input": {"command": declared}, + "tool_response": {"stdout": stdout, "stderr": ""}, + }); + let output = run_with_stdin( + dir, + &["hook", "--harness", "claude-code"], + &payload.to_string(), + ); + assert_eq!( + output.status.code(), + Some(0), + "recording is not a verdict, so the call is allowed" + ); +} + +/// Read it: the call the gate exists to judge. +fn call(dir: &Path, command: &str) -> String { + let payload = serde_json::json!({ + "hook_event_name": "PreToolUse", + "tool_name": "Bash", + "tool_input": {"command": command}, + }); + let output = run_with_stdin( + dir, + &["hook", "--harness", "claude-code"], + &payload.to_string(), + ); + // THE STATUS IS ASSERTED, for the retired suite's measured reason: this + // harness prints nothing on an allow and exits 0 either way, so a substring + // check over an empty string is true — including the empty output of a + // binary that died before it judged anything. + assert_eq!( + output.status.code(), + Some(0), + "the claude-code harness answers on stdout and exits 0" + ); + String::from_utf8(output.stdout).expect("the decision document is UTF-8") +} + +fn ready(dir: &Path) -> String { + call(dir, "gh pr ready 702") +} + +fn denied(decision: &str) { + assert!( + decision.contains(r#""permissionDecision":"deny""#), + "expected a deny, got: {decision}" + ); +} + +fn allowed(decision: &str) { + assert!( + !decision.contains(r#""deny""#), + "expected an allow, got: {decision}" + ); +} + +// --- the two refusals, and which row owns each ------------------------------ + +#[test] +fn a_ready_with_no_record_at_all_is_refused_and_the_receipt_row_names_the_command() { + // The did-you-look half. The deny is built from the DECLARED command rather + // than from prose, which is the property that makes the remedy runnable. + let declared = declared_command(); + let dir = repo("review-answered-no-record", &declared, true); + let decision = ready(&dir); + denied(&decision); + assert!(decision.contains("gh api graphql"), "{decision}"); + assert!(decision.contains("reviewThreads"), "{decision}"); +} + +#[test] +fn the_measured_shape_a_head_carrying_unresolved_threads_is_refused_naming_the_count() { + // #623's four open threads, as the projection emits them: one element per + // thread id. + let declared = declared_command(); + let dir = repo("review-answered-open-threads", &declared, true); + record(&dir, &declared, r#"["PRRT_a","PRRT_b","PRRT_c","PRRT_d"]"#); + let decision = ready(&dir); + denied(&decision); + assert!(decision.contains("review-unanswered"), "{decision}"); + // THE COUNT, as the typed ABI renders it: the token, its gloss, and the + // `Subject::Count` beside them. The retired case read `4 blocking` out of a + // free string; the number is the same and it is now a decoded subject. + assert!(decision.contains("V-REVIEW-UNANSWERED"), "{decision}"); + assert!( + decision.contains("blocking review conditions) 4"), + "{decision}" + ); + // Pointer-only (non-negotiable rule 4): the ids are not in the engine, so a + // refusal naming one would be a payload this channel refuses to carry. + assert!(!decision.contains("PRRT_a"), "{decision}"); +} + +#[test] +fn a_head_whose_threads_are_all_answered_is_allowed() { + // THE LOAD-BEARING HALF. A predicate that only ever denied would satisfy + // every case above and gate nothing (CLOUD-418). `[]` is the genuine zero: + // the command looked and found none. + let declared = declared_command(); + let dir = repo("review-answered-clean", &declared, true); + record(&dir, &declared, "[]"); + allowed(&ready(&dir)); +} + +// --- the vacuity cases the row enumerates ----------------------------------- + +#[test] +fn vacuity_zero_threads_and_no_review_reads_as_unreviewed_not_as_all_addressed() { + // #618 carries no threads and no review. The projection emits the PR + // author's login when nothing but the author reviewed, so the honest count + // is one — and a thread-only predicate would have read this as zero and + // passed it. + let declared = declared_command(); + let dir = repo("review-answered-unreviewed", &declared, true); + record(&dir, &declared, r#"["wenzowski"]"#); + let decision = ready(&dir); + denied(&decision); + assert!( + decision.contains("blocking review conditions) 1"), + "{decision}" + ); +} + +#[test] +fn vacuity_a_buffer_that_is_not_the_declared_shape_records_nothing_rather_than_one_row() { + // `returns = "json-array"` (CLOUD-993). A `gh` that printed an auth error, + // or a wrapper that annotated its own output, must not become `rows 1` — + // which would be a refusal nobody can clear — nor `rows 0`, which would be a + // pass over a command that never answered. It records NOTHING, so the + // receipt row's did-you-look refusal stands. + let declared = declared_command(); + let dir = repo("review-answered-wrong-shape", &declared, true); + record( + &dir, + &declared, + "gh: could not determine the current repository", + ); + let decision = ready(&dir); + denied(&decision); + assert!(decision.contains("gh api graphql"), "{decision}"); +} + +#[test] +fn vacuity_an_empty_buffer_is_not_zero_rows() { + // A command that printed nothing is could-not-look, not "there are none". + // Recording a zero here would turn silence into a pass. + let declared = declared_command(); + let dir = repo("review-answered-empty-buffer", &declared, true); + record(&dir, &declared, ""); + denied(&ready(&dir)); +} + +#[test] +fn a_buffer_from_a_command_nobody_asked_for_never_becomes_the_record() { + // The forgery control, over this fact: the agent chooses WHICH command runs + // and does not author what it prints, so byte-equality against the + // declaration is what stands between the two. + let declared = declared_command(); + let dir = repo("review-answered-forged-buffer", &declared, true); + record(&dir, "echo []", "[]"); + denied(&ready(&dir)); +} + +// --- what must NOT be refused ---------------------------------------------- + +#[test] +fn a_redraft_is_not_a_ready_even_on_a_head_carrying_findings() { + // `land` re-drafts on a red run, and that is the one thing that stops the + // next push buying another matrix (CLOUD-240). Refusing it would leave the + // tap open on exactly the head this gate is keeping out of CI. + let declared = declared_command(); + let dir = repo("review-answered-redraft", &declared, true); + record(&dir, &declared, r#"["PRRT_a","PRRT_b"]"#); + allowed(&call(&dir, "gh pr ready 702 --undo")); +} + +#[test] +fn vacuity_a_page_the_command_could_not_read_refuses_rather_than_passing() { + // GitHub caps a connection page at 100, so a PR with more threads than that + // would have the surplus fall outside the query — and an unresolved thread + // out there would leave `rows == 0`, a FALSE GREEN in the one direction this + // gate exists to prevent. The projection emits an extra element per + // truncated connection, so the buffer a clear-but-truncated head produces is + // `[true]` rather than `[]`. + // + // THE DISCRIMINATING PAIR is this case beside "all answered": both are a + // head with zero unresolved threads, and only the truncated one refuses. + // Without the `pageInfo` clauses they would be the same buffer. + let declared = declared_command(); + let dir = repo("review-answered-truncated", &declared, true); + record(&dir, &declared, "[true]"); + let decision = ready(&dir); + denied(&decision); + assert!( + decision.contains("blocking review conditions) 1"), + "{decision}" + ); +} + +#[test] +fn the_bypass_a_compound_command_is_still_a_ready() { + // The case an earlier draft did not have, and the reason it did not: this + // module anchored on `startswith`, so `cd /repo && gh pr ready 702` went + // unjudged. The receipt row DOES select it, so an existing record satisfies + // the did-you-look half — and with the count half silent the call was + // allowed carrying two unresolved threads. Measured exactly that before the + // anchor came out. + // + // End to end rather than only in the module's `test_` rules, because what + // was wrong was the interaction between two rows: the receipt row's + // selection and this module's narrowing disagreeing about one command. + let declared = declared_command(); + let dir = repo("review-answered-compound", &declared, true); + record(&dir, &declared, r#"["PRRT_a","PRRT_b"]"#); + let decision = call(&dir, "cd /repo && gh pr ready 702"); + denied(&decision); + assert!( + decision.contains("blocking review conditions) 2"), + "{decision}" + ); +} + +#[test] +fn a_commit_message_naming_the_command_is_prose_not_a_ready() { + // THE ANCHOR'S DISCRIMINATING CASE. This repository writes `gh pr ready` + // down constantly — in commit messages, in issue bodies, in the module + // itself — so a `contains` over the raw command would refuse its own + // documentation, which is the hazard `run-shape.rego`'s header records for + // the identical predicate. + // + // Over the binary rather than only in the module's own `test_` rules, + // because what is at risk is the engine handing the whole command string + // through: a `with input as` case fabricates that string and cannot show it + // arrives raw. + let declared = declared_command(); + let dir = repo("review-answered-prose", &declared, true); + record(&dir, &declared, r#"["PRRT_a","PRRT_b"]"#); + allowed(&call( + &dir, + r#"git commit -m "run gh pr ready once the review is answered""#, + )); +} + +#[test] +fn reading_the_review_is_never_refused_so_the_remedy_is_reachable() { + // A gate whose own remedy it blocks is unsatisfiable. Both `gh pr view` and + // the declared `gh api graphql` must pass on a head with findings recorded. + let declared = declared_command(); + let dir = repo("review-answered-remedy", &declared, true); + record(&dir, &declared, r#"["PRRT_a"]"#); + for command in ["gh pr view 702 --json reviewDecision", declared.as_str()] { + allowed(&call(&dir, command)); + } +} + +// --- the ABI's own seam, which the retired suite had no registry to leave ---- + +#[test] +fn an_undeclared_class_refuses_with_the_token_and_says_the_registry_is_silent() { + // A module may emit a verdict no `[[verdict]]` row declares. The refusal + // still happens — the predicate decided — and the engine says the registry + // is silent rather than printing an empty gloss, which would read as a class + // with nothing to say about itself. The count still travels: a subject is + // decoded from the violation, not from the registry. + let declared = declared_command(); + let dir = repo("review-answered-no-class", &declared, false); + record(&dir, &declared, r#"["PRRT_a","PRRT_b","PRRT_c"]"#); + let decision = ready(&dir); + denied(&decision); + assert!(decision.contains("V-REVIEW-UNANSWERED"), "{decision}"); + assert!( + decision.contains("no `[[verdict]]` row declares"), + "{decision}" + ); + assert!(decision.contains(") 3"), "{decision}"); +} + +/// The fixture config's head — everything up to the declared command, which is +/// interpolated so the bytes come from the committed row rather than from here. +const CONFIG: &str = r#"version = 1 + +[[fact]] +name = "review-answered" +returns = "json-array" +"#; + +/// The two rows that judge the call: the receipt row that asks whether the agent +/// looked, and the policy row that reads what it found. +const ROWS: &str = r#" +[[rule]] +id = "ready-needs-an-answered-review" +kind = "receipt" +scope = "mediated_call" +severity = "deny" +pattern = "gh pr ready" +checks = ["review-answered"] +key = "head" +reason = "run the declared command" + +[[rule]] +id = "review-answered" +kind = "policy" +scope = "mediated_call" +module = "policy/review-answered.rego" +severity = "deny" +"#; + +/// The verdict class the module's refusal resolves against, omitted by exactly +/// one case above so the registry-is-silent branch is reachable. +const CLASS: &str = r#" +[[verdict]] +id = "V-REVIEW-UNANSWERED" +gloss = "readying would buy a CI matrix on a head carrying blocking review conditions" +class = """ +Readying is the event that starts CI, and nothing in `land`'s pre-ready sequence \ +asks about review. +""" + +[[verdict.route]] +id = "R-ANSWER-THE-THREADS" +kind = "command" +target = "resolve each thread, then re-run the declared command and retry" +"#; diff --git a/crates/batten/tests/shell_retirement.rs b/crates/batten/tests/shell_retirement.rs index 0408c55de..754f08482 100644 --- a/crates/batten/tests/shell_retirement.rs +++ b/crates/batten/tests/shell_retirement.rs @@ -158,6 +158,86 @@ fn a_deleted_and_fully_mapped_shell_rule_passes() { ); } +/// The truncation arm, over the engine — and it is the shape that forced the +/// clause rather than one imagined for it. +/// +/// `hooks-wiring-check.sh` opened its `DECLARED` table with the first entry glued +/// to the assignment, so retiring the program that entry named SHORTENED the +/// opening line instead of deleting it. A line-set arm reads that as an addition +/// and refuses the cleanup the campaign itself mandates. +/// +/// Over the binary because that is the only tier that shows the ENGINE builds +/// `base-lines` at all: a `with input as` case hands the module the very map the +/// boundary might be unable to produce. +#[test] +fn an_edit_truncating_a_line_at_a_retired_reference_is_admitted() { + let root = repo( + "truncated", + &[ + ("mise-tasks/old-gate.sh", GATE), + ( + "mise-tasks/wiring.sh", + "#!/usr/bin/env bash\nDECLARED=\"${T-mise-tasks/old-gate.sh CLOUD-312\nmise-tasks/other.sh CLOUD-312}\"\n", + ), + ], + &Head { + written: &[ + ( + "mise-tasks/wiring.sh", + "#!/usr/bin/env bash\nDECLARED=\"${T-\nmise-tasks/other.sh CLOUD-312}\"\n", + ), + ( + "crates/batten/tests/old_gate.rs", + &ledger("mise-tasks/old-gate.sh"), + ), + ], + removed: &["mise-tasks/old-gate.sh"], + }, + ); + assert!( + findings(&root).is_empty(), + "shortening a line at the retired path it named is the campaign cleaning up after \ + itself: {:?}", + findings(&root) + ); +} + +/// 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. +#[test] +fn an_edit_truncating_a_line_at_a_live_reference_is_refused() { + let root = repo( + "truncated-live", + &[ + ("mise-tasks/old-gate.sh", GATE), + ("mise-tasks/still-here.sh", GATE), + ( + "mise-tasks/wiring.sh", + "#!/usr/bin/env bash\nDECLARED=\"${T-mise-tasks/still-here.sh CLOUD-312\nmise-tasks/old-gate.sh CLOUD-312}\"\n", + ), + ], + &Head { + written: &[ + ( + "mise-tasks/wiring.sh", + "#!/usr/bin/env bash\nDECLARED=\"${T-}\"\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")], + "dropping a reference to a file that is still here is maintenance in place" + ); +} + #[test] fn an_untouched_tree_is_silent() { let root = repo( diff --git a/mise-tasks/hooks-wiring-check.sh b/mise-tasks/hooks-wiring-check.sh index 177b161c3..1c047f1f9 100755 --- a/mise-tasks/hooks-wiring-check.sh +++ b/mise-tasks/hooks-wiring-check.sh @@ -174,7 +174,8 @@ gemini-cli .gemini/settings.json}" # The rest were six until CLOUD-777, whose widened scope could finally see them. # Their owners are not invented: CLOUD-312 is "the shell guards retire behind # it", whose scope CLOUD-777 widened from pre-tool to every point. -DECLARED="${HOOKS_WIRING_DECLARED-.claude/hooks/session-start.sh CLOUD-312 +DECLARED="${HOOKS_WIRING_DECLARED- +.claude/hooks/session-start.sh CLOUD-312 mise-tasks/run-shape-guard.sh CLOUD-821 stop-hook-git-check.sh CLOUD-605 session-start-git-identity.sh CLOUD-605}" diff --git a/mise.toml b/mise.toml index db52d9f8e..a0173bb05 100644 --- a/mise.toml +++ b/mise.toml @@ -398,7 +398,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,ci-slow-needed,bot-issue,land,land-lock,ci-lease-precondition,board-diff-overlap,reclaim-census,connector-allow-resolve,serena-mcp,target-prune,claimed-keys,released,in-progress-drain,merged-pr-keys,board-payloads,attestation-check,awk-regex-check,batten-glob-check,board-sweep,branch-age-check,cap-drift,checks-green,ci-drift,ci-local-parity,ci-tools-check,claim-check,claim-race-check,closing-key-check,coderabbit-config-check,config-deprecations,config-lint,connector-allow-guard,container-preflight,darwin-link,deferral-check,derived-check,digest-major-agreement,doctor,done-check,done-pr-check,duplicate-close-check,evaluator-closure-check,evaluator-io-check,finding-sink-check,gh-guard,graph-check,hook-matcher-check,hook-pin-check,hook-profile-check,hooks-wiring-check,install-check,land-divergence-assert,land-lock-check,landed-check,license-table-check,linear-check,lock-complete,macos-link-check,mcp-allow-check,mcp-attach-check,mcp-timeout-budget,memories-check,mise-action-floor,mise-pin-agreement,module-map-check,msrv-pin-agreement,mutant,mutant-census,no-doctests,nonverdict-assert,ntia-check,perf-assert,perf-compare,perf-gate,pipefail-grep-check,privileged-lane,pr-unsubscribed,publish-credential-check,ready-cites-check,ready-guard,ready-lint,reference-check,release-assets-check,release-due,release-tracking-check,renovate-config-validator,report-only-check,review-answered,rules-drift,run-shape,run-shape-guard,rust-paths-check,sbom,sbom-check,schema-check,semver,signing-posture,skill-check,sonar-gate,spec-ref-check,stop-posture-check,suite-bench-check,timeout-check,token-bench-check,transcript-corpus-check,tree-clean,unlanded-check,verified" +MUTANT_GATES = "alive,ci-slow-needed,bot-issue,land,land-lock,ci-lease-precondition,board-diff-overlap,reclaim-census,connector-allow-resolve,serena-mcp,target-prune,claimed-keys,released,in-progress-drain,merged-pr-keys,board-payloads,attestation-check,awk-regex-check,batten-glob-check,board-sweep,branch-age-check,cap-drift,checks-green,ci-drift,ci-local-parity,ci-tools-check,claim-check,claim-race-check,closing-key-check,coderabbit-config-check,config-deprecations,config-lint,connector-allow-guard,container-preflight,darwin-link,deferral-check,derived-check,digest-major-agreement,doctor,done-check,done-pr-check,duplicate-close-check,evaluator-closure-check,evaluator-io-check,finding-sink-check,gh-guard,graph-check,hook-matcher-check,hook-pin-check,hook-profile-check,hooks-wiring-check,install-check,land-divergence-assert,land-lock-check,landed-check,license-table-check,linear-check,lock-complete,macos-link-check,mcp-allow-check,mcp-attach-check,mcp-timeout-budget,memories-check,mise-action-floor,mise-pin-agreement,module-map-check,msrv-pin-agreement,mutant,mutant-census,no-doctests,nonverdict-assert,ntia-check,perf-assert,perf-compare,perf-gate,pipefail-grep-check,privileged-lane,pr-unsubscribed,publish-credential-check,ready-cites-check,ready-guard,ready-lint,reference-check,release-assets-check,release-due,release-tracking-check,renovate-config-validator,report-only-check,rules-drift,run-shape,run-shape-guard,rust-paths-check,sbom,sbom-check,schema-check,semver,signing-posture,skill-check,sonar-gate,spec-ref-check,stop-posture-check,suite-bench-check,timeout-check,token-bench-check,transcript-corpus-check,tree-clean,unlanded-check,verified" # --- GitHub reachability behind an egress proxy (Claude Code web sandbox etc.) --- # mise resolves every tool's release through GitHub's *API* host, api.github.com. diff --git a/policy/review-answered.rego b/policy/review-answered.rego index 3f6550a8f..0f1cd045e 100644 --- a/policy/review-answered.rego +++ b/policy/review-answered.rego @@ -55,32 +55,13 @@ import rego.v1 rules contains "review-unanswered" -# THE MUTATIONS, and each corrupts a conjunct no other conjunct already excludes -# — the discrimination `.claude/rules/policy-modules.md` records a survivor for, -# and the first draft of this block earned that warning twice. Field 3 is a bats -# `--filter` and must match a real case name: three descriptions that matched -# nothing all reported `case-already-red`, which is indistinguishable from a -# mutation nobody caught. `@` delimits each sed script because the rows are -# `|`-separated. -# -# THE THIRD ROW TOOK THREE ATTEMPTS AND THE FIRST TWO ARE THE LESSON. It began as -# `command-not-anchored`, naming a case whose command carries no `ready` at all, -# so the cheap conjunct excluded the input and the mutation could not be seen. It -# was renamed to `prose-judged` — correctly named, and it SURVIVED anyway, because -# the engine never hands this module a prose command with a record beside it, so -# no bats case can discriminate that conjunct at all. Both survivals were true -# statements about the module rather than bad filters. -# -# What finally made it discriminating was deleting the conjunct. `compound-not-judged` -# reinstates the anchor those rows were defending, and the anchor is exactly what -# opened the bypass below — so the mutation now corrupts something a case CAN see: -# `cd /repo && gh pr ready` goes from refused to allowed. A mutation that cannot -# be caught is a coverage claim that is not true; a mutation that reintroduces a -# measured defect is the opposite. -#MUTANT compound-not-judged|s@\tcontains(input.call.command, "gh pr ready")@\tstartswith(trim_space(input.call.command), "gh pr ready")@|a compound command is still a ready -#MUTANT count-not-decided|s@\trecord.rows > 0@\trecord.rows > 99999@|a head carrying unresolved threads is refused -#MUTANT redraft-judged|s@\tnot contains(input.call.command, "--undo")@\ttrue@|a re-draft is not a ready - +# NO BATS SUITE, and that is CLOUD-1059's doing rather than a gap. The suite that +# drove this module end to end asserted the refusal's PROSE, which CLOUD-1050 +# deletes; the migration gate refuses an authored Bats suite edited in place, so +# it retired into `crates/batten/tests/review_answered.rs`, which drives the same +# two hook calls over the compiled binary. `mutant` resolves a gate's suite by +# `tests/.bats`, so there is nothing for it to reach. +#MUTANT-EXEMPT CLOUD-1059|the end-to-end tier retired to a compiled-binary test, so `mutant` has no `tests/.bats` to turn red; the three mutations it declared are asserted by `review_answered.rs`'s open-threads, count and compound-command cases violation contains { "rule": "review-unanswered", "verdict": "V-REVIEW-UNANSWERED", diff --git a/policy/shell-retirement.rego b/policy/shell-retirement.rego index 2cb74c6dc..4091068d9 100644 --- a/policy/shell-retirement.rego +++ b/policy/shell-retirement.rego @@ -198,9 +198,39 @@ only_drops_a_retired_reference(path) if { contains(line, gone) }) == count(removed) - # AND NOTHING WAS ADDED. Without this, a change could delete a program, drop - # its declaration row, and smuggle an unrelated rewrite in the same file. - count({line | some line in input.tree.lines[path]; not line in {l | some l in base}}) == 0 + # AND NOTHING WAS ADDED THAT IS NOT A TRUNCATION OF A REMOVED LINE. Without a + # clause here a change could delete a program, drop its declaration row, and + # smuggle an unrelated rewrite into the same file. + # + # The truncation half is not a loosening for convenience — it is the shape a + # real declaration table produces, measured. It is also why + # `hooks-wiring-check.sh` now opens its table on a line of its own with no + # entry glued to it: with each row on its own line every row is independently + # removable, and the next retirement in this campaign does not have to shorten + # anything at all. Its consumers already skip an empty pattern, so the leading + # blank costs nothing. `hooks-wiring-check.sh` opens its + # table as `DECLARED="${HOOKS_WIRING_DECLARED-` and the FIRST entry used to sit + # on that same line, so removing the first row rewrote the opening line rather + # than deleting it. An added line that is a strict PREFIX of a removed one, with + # the dropped remainder naming a path this delta deleted, is exactly that edit + # and nothing else: it can only ever shorten, never introduce a byte the base + # did not already carry at that position. + added := {line | some line in input.tree.lines[path]; not line in {l | some l in base}} + count({line | some line in added; truncates_a_retired_reference(line, removed)}) == count(added) +} + +# An added line that is a strict prefix of a removed line, where everything the +# prefix drops names a path this same delta deleted. +# +# `startswith` plus the remainder test rather than a regex: the admission has to +# be that the added line INTRODUCES NOTHING, and a prefix relation is the only +# spelling of that which cannot be satisfied by a rewrite. +truncates_a_retired_reference(line, removed) if { + some was in removed + startswith(was, line) + count(was) > count(line) + some gone in delta.deleted + contains(substring(was, count(line), -1), gone) } # --------------------------------------------------------------------------- @@ -395,6 +425,45 @@ test_an_edit_that_also_adds_a_line_is_refused if { v.verdict == "V-SHELL-RULE-EDITED" } +# THE MEASURED SHAPE the truncation clause exists for. `hooks-wiring-check.sh` +# opened its `DECLARED` table with the first entry glued to the assignment, so +# removing that entry SHORTENED the opening line instead of deleting it. The +# added line introduces no byte the base did not carry, and everything it drops +# names a path this delta retired. +test_a_line_truncated_at_a_retired_reference_is_admitted if { + count(violation) == 0 with input as {"tree": { + "base-delta": { + "added": [], + "edited": ["mise-tasks/wiring.sh"], + "deleted": ["mise-tasks/old-gate.sh"], + "base-lines": {"mise-tasks/wiring.sh": ["#!/usr/bin/env bash", "DECLARED=\"${T-mise-tasks/old-gate.sh CLOUD-312", "mise-tasks/other.sh CLOUD-312"]}, + }, + "lines": { + "mise-tasks/wiring.sh": ["#!/usr/bin/env bash", "DECLARED=\"${T-", "mise-tasks/other.sh CLOUD-312"], + "crates/batten/tests/old_gate.rs": ["// carried: mise-tasks/old-gate.sh policy/old-gate.rego crates/batten/tests/old_gate.rs"], + }, + }} +} + +# ANTI-VACUITY FOR THE TRUNCATION CLAUSE, and without it a prefix relation would +# admit any shortening at all. What this truncation drops names no retired path, +# so it is ordinary maintenance and is still refused. +test_a_truncation_dropping_a_live_reference_is_refused if { + some v in violation with input as {"tree": { + "base-delta": { + "added": [], + "edited": ["mise-tasks/wiring.sh"], + "deleted": ["mise-tasks/old-gate.sh"], + "base-lines": {"mise-tasks/wiring.sh": ["#!/usr/bin/env bash", "DECLARED=\"${T-mise-tasks/still-here.sh CLOUD-312", "mise-tasks/old-gate.sh CLOUD-312"]}, + }, + "lines": { + "mise-tasks/wiring.sh": ["#!/usr/bin/env bash", "DECLARED=\"${T-"], + "crates/batten/tests/old_gate.rs": ["// carried: mise-tasks/old-gate.sh policy/old-gate.rego crates/batten/tests/old_gate.rs"], + }, + }} + v.verdict == "V-SHELL-RULE-EDITED" +} + # COULD NOT LOOK REFUSES. A base side this could not read leaves the removed set # unknowable, and an arm whose failure mode is a silent licence must fail closed. test_an_edit_with_no_readable_base_side_is_refused if { diff --git a/tests/review-answered.bats b/tests/review-answered.bats deleted file mode 100644 index 14103eb7d..000000000 --- a/tests/review-answered.bats +++ /dev/null @@ -1,290 +0,0 @@ -#!/usr/bin/env bats -# subject: policy/review-answered.rego -# -# The second test tier for the read-the-review gate (CLOUD-859), and the one that -# is not optional. The module's own `test_` rules pin the predicate; they cannot -# prove the ENGINE builds the input it reads, because a `with input as` case -# fabricates the very shape the engine may be unable to produce — the defect class -# `.claude/rules/policy-modules.md` records twice, both instances found by adding -# this tier rather than by reading. -# -# So every case here goes through TWO real hook calls in the order a session makes -# them: a `PostToolUse` envelope carrying the declared command and a buffer, which -# is what mints the record, and then a `PreToolUse` `gh pr ready`, which is what -# reads it. Nothing writes a receipt by hand. That is what makes this the tier -# that would have caught `record_agent_fact` calling `rows_in` where the row -# declared `json-array` — a live instance, in the same change. -# -# The fixture declares the REAL command string, byte for byte from `batten.toml`, -# so a rewording of it there fails here rather than silently ending the coupling. -# The engine never executes it — it compares `envelope.command` to the -# declaration and counts `envelope.result` — which is exactly what lets these -# cases cover shapes a live `gh` call could not be made to produce on demand. - -setup() { - load helpers - - # `batten_binary`, which keeps the skip this chain existed for — there is no - # release build when `test:bats` runs in CI, and a shorter chain took every - # case with it — and drops the release-FIRST ordering, which was a measured - # false green: these twelve cases passed against a release binary nine hours - # older than the change they would otherwise have caught (CLOUD-859). - BIN=$(batten_binary "$BATS_TEST_DIRNAME/..") || skip "no batten binary to drive" - - MODULE="$BATS_TEST_DIRNAME/../policy/review-answered.rego" - REPO="$BATS_TEST_TMPDIR/repo" - mkdir -p "$REPO/policy" - cp "$MODULE" "$REPO/policy/review-answered.rego" - - # THE DECLARED COMMAND, read out of this repository's own `batten.toml` rather - # than retyped. Byte-equality is the forgery control, so a copy here that - # drifted would leave every case passing over a string the real gate does not - # use — the coupling this suite exists to hold. - # - # ONE READER, not two. The first draft tried `batten config show --json` and - # fell back to a text read; the fallback had to cover the same ground anyway, - # so the first stage was a second way to get the same string and therefore a - # second way to get it wrong. It reads the row BY NAME rather than taking the - # first `[[fact]]` block, so adding a second fact row above this one cannot - # silently repoint every case in this file at the wrong command. - DECLARED=$( - python3 - "$BATS_TEST_DIRNAME/../batten.toml" review-answered <<'READER' -import re, sys - -text, want = open(sys.argv[1]).read(), sys.argv[2] -for block in text.split("[[fact]]")[1:]: - name = re.search(r'^name = "(.*)"$', block, re.M) - command = re.search(r'^command = "(.*)"$', block, re.M) - if name and command and name.group(1) == want: - print(command.group(1)) - break -READER - ) - [ -n "$DECLARED" ] || skip "no [[fact]] row named review-answered in batten.toml" - - { - echo "version = 1" - echo - echo "[[fact]]" - echo 'name = "review-answered"' - echo 'returns = "json-array"' - printf 'command = "%s"\n' "$DECLARED" - echo - echo "[[rule]]" - echo 'id = "ready-needs-an-answered-review"' - echo 'kind = "receipt"' - echo 'scope = "mediated_call"' - echo 'severity = "deny"' - echo 'pattern = "gh pr ready"' - echo 'checks = ["review-answered"]' - echo 'key = "head"' - echo 'reason = "run the declared command"' - echo - echo "[[rule]]" - echo 'id = "review-answered"' - echo 'kind = "policy"' - echo 'scope = "mediated_call"' - echo 'module = "policy/review-answered.rego"' - echo 'severity = "deny"' - } >"$REPO/batten.toml" - - # No global or system config: a contributor's own git settings must not be - # able to change a verdict here (CLOUD-282). - GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_SYSTEM=/dev/null git init -q -b main "$REPO" - # AND A COMMIT, which this fixture did not have and now needs (CLOUD-859). The - # record is filed under the subject the row's `key = "head"` names, so a - # repository whose HEAD does not resolve has no subject — the boundary answers - # could-not-look and every case below would be ALLOWED, which is the fail-open - # posture working and not the thing these cases are about. `tests/fact-record-keying.bats` - # is where the keying itself is asserted. - (cd "$REPO" && - GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_SYSTEM=/dev/null \ - git -c user.email=fixture@example.invalid -c user.name=fixture \ - commit -q --allow-empty -m "the head this gate judges") -} - -# Mint the record the way a session does: a PostToolUse envelope carrying the -# declared command and the buffer the host handed back. -record() { # record - local envelope - envelope=$(python3 -c 'import json,sys; print(json.dumps({"hook_event_name":"PostToolUse","session_id":"sess-review","cwd":"/repo","tool_name":"Bash","tool_input":{"command":sys.argv[1]},"tool_response":{"stdout":sys.argv[2],"stderr":""}}))' "$DECLARED" "$1") - (cd "$REPO" && printf '%s' "$envelope" | "$BIN" hook --harness claude-code) -} - -# Read it: the call the gate exists to judge. -ready() { # ready [] - local envelope - envelope=$(python3 -c 'import json,sys; print(json.dumps({"hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":sys.argv[1]}}))' "gh pr ready 702${1:+ $1}") - (cd "$REPO" && printf '%s' "$envelope" | "$BIN" hook --harness claude-code) -} - -# BOTH HELPERS ASSERT THE EXIT STATUS, for `tests/run-shape.bats`' measured -# reason: `batten hook` prints nothing on an allow and exits 0 either way, so a -# substring check over an empty string is true — including the empty output of a -# binary that died before it judged anything. -denied() { [ "$status" -eq 0 ] && [[ "$1" == *'"permissionDecision":"deny"'* ]]; } -allowed() { [ "$status" -eq 0 ] && [[ "$1" != *'"deny"'* ]]; } - -# --- the two refusals, and which row owns each ------------------------------ - -@test "a ready with no record at all is refused, and the receipt row names the command" { - # The did-you-look half. The deny is built from the DECLARED command rather - # than from prose, which is the property that makes the remedy runnable. - run ready - denied "$output" - [[ "$output" == *"gh api graphql"* ]] - [[ "$output" == *"reviewThreads"* ]] -} - -@test "THE MEASURED SHAPE: a head carrying unresolved threads is refused, naming the count" { - # #623's four open threads, as the projection emits them: one element per - # thread id. - run record '["PRRT_a","PRRT_b","PRRT_c","PRRT_d"]' - [ "$status" -eq 0 ] - run ready - denied "$output" - [[ "$output" == *"review-unanswered"* ]] - [[ "$output" == *"4 blocking"* ]] - # Pointer-only (non-negotiable rule 4): the ids are not in the engine, so a - # message naming one would be a payload this channel refuses to carry. - [[ "$output" != *"PRRT_a"* ]] -} - -@test "a head whose threads are all answered is allowed" { - # THE LOAD-BEARING HALF. A predicate that only ever denied would satisfy every - # case above and gate nothing (CLOUD-418). `[]` is the genuine zero: the - # command looked and found none. - run record '[]' - [ "$status" -eq 0 ] - run ready - allowed "$output" -} - -# --- the vacuity cases the row enumerates ----------------------------------- - -@test "VACUITY: zero threads and no review reads as unreviewed, not as all-addressed" { - # #618 carries no threads and no review. The projection emits the PR author's - # login when nothing but the author reviewed, so the honest count is one — and - # a thread-only predicate would have read this as zero and passed it. - run record '["wenzowski"]' - [ "$status" -eq 0 ] - run ready - denied "$output" - [[ "$output" == *"1 blocking"* ]] -} - -@test "VACUITY: a buffer that is not the declared shape records nothing rather than one row" { - # `returns = "json-array"` (CLOUD-993). A `gh` that printed an auth error, or a - # wrapper that annotated its own output, must not become `rows 1` — which would - # be a refusal nobody can clear — nor `rows 0`, which would be a pass over a - # command that never answered. It records NOTHING, so the receipt row's - # did-you-look refusal stands. - run record 'gh: could not determine the current repository' - [ "$status" -eq 0 ] - run ready - denied "$output" - [[ "$output" == *"gh api graphql"* ]] -} - -@test "VACUITY: an empty buffer is not zero rows" { - # A command that printed nothing is could-not-look, not "there are none". - # Recording a zero here would turn silence into a pass. - run record '' - [ "$status" -eq 0 ] - run ready - denied "$output" -} - -@test "a buffer from a command nobody asked for never becomes the record" { - # The forgery control, over this fact: the agent chooses WHICH command runs and - # does not author what it prints, so byte-equality against the declaration is - # what stands between the two. - local envelope - envelope=$(python3 -c 'import json; print(json.dumps({"hook_event_name":"PostToolUse","session_id":"s","cwd":"/repo","tool_name":"Bash","tool_input":{"command":"echo []"},"tool_response":{"stdout":"[]","stderr":""}}))') - run bash -c "cd '$REPO' && printf '%s' '$envelope' | '$BIN' hook --harness claude-code" - [ "$status" -eq 0 ] - run ready - denied "$output" -} - -# --- what must NOT be refused ---------------------------------------------- - -@test "a re-draft is not a ready, even on a head carrying findings" { - # `land` re-drafts on a red run, and that is the one thing that stops the next - # push buying another matrix (CLOUD-240). Refusing it would leave the tap open - # on exactly the head this gate is keeping out of CI. - run record '["PRRT_a","PRRT_b"]' - [ "$status" -eq 0 ] - run ready '--undo' - allowed "$output" -} - -@test "VACUITY: a page the command could not read refuses rather than passing" { - # GitHub caps a connection page at 100, so a PR with more threads than that - # would have the surplus fall outside the query — and an unresolved thread out - # there would leave `rows == 0`, a FALSE GREEN in the one direction this gate - # exists to prevent. The projection emits an extra element per truncated - # connection, so the buffer a clear-but-truncated head produces is `[true]` - # rather than `[]`. - # - # THE DISCRIMINATING PAIR is this case beside "all answered": both are a head - # with zero unresolved threads, and only the truncated one refuses. Without the - # `pageInfo` clauses they would be the same buffer. - run record '[true]' - [ "$status" -eq 0 ] - run ready - denied "$output" - [[ "$output" == *"1 blocking"* ]] -} - -@test "THE BYPASS: a compound command is still a ready" { - # The case an earlier draft did not have, and the reason it did not: this module - # anchored on `startswith`, so `cd /repo && gh pr ready 702` went unjudged. The - # receipt row DOES select it, so an existing record satisfies the did-you-look - # half — and with the count half silent the call was allowed carrying two - # unresolved threads. Measured exactly that before the anchor came out. - # - # End to end rather than only in the module's `test_` rules, because what was - # wrong was the interaction between two rows: the receipt row's selection and - # this module's narrowing disagreeing about the same command. - run record '["PRRT_a","PRRT_b"]' - [ "$status" -eq 0 ] - - local envelope - envelope=$(python3 -c 'import json,sys; print(json.dumps({"hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":sys.argv[1]}}))' 'cd /repo && gh pr ready 702') - run bash -c "cd '$REPO' && printf '%s' \"\$1\" | '$BIN' hook --harness claude-code" _ "$envelope" - denied "$output" - [[ "$output" == *"2 blocking"* ]] -} - -@test "a commit message naming the command is prose, not a ready" { - # THE ANCHOR'S DISCRIMINATING CASE. This repository writes `gh pr ready` down - # constantly — in commit messages, in issue bodies, in the module itself — so a - # `contains` over the raw command refuses its own documentation, which is the - # hazard `run-shape.rego`'s header records for the identical predicate. - # - # Over the binary rather than only in the module's own `test_` rules, because - # what is at risk is the engine handing the whole command string through: a - # `with input as` case fabricates that string and cannot show it arrives raw. - run record '["PRRT_a","PRRT_b"]' - [ "$status" -eq 0 ] - - local envelope - envelope=$(python3 -c 'import json,sys; print(json.dumps({"hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":sys.argv[1]}}))' \ - 'git commit -m "run gh pr ready once the review is answered"') - run bash -c "cd '$REPO' && printf '%s' \"\$1\" | '$BIN' hook --harness claude-code" _ "$envelope" - allowed "$output" -} - -@test "reading the review is never refused, so the remedy is reachable" { - # A gate whose own remedy it blocks is unsatisfiable. Both `gh pr view` and the - # declared `gh api graphql` must pass on a head with findings recorded. - run record '["PRRT_a"]' - [ "$status" -eq 0 ] - local envelope - for c in 'gh pr view 702 --json reviewDecision' "$DECLARED"; do - envelope=$(python3 -c 'import json,sys; print(json.dumps({"hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":sys.argv[1]}}))' "$c") - run bash -c "cd '$REPO' && printf '%s' \"\$1\" | '$BIN' hook --harness claude-code" _ "$envelope" - allowed "$output" - done -} From a45f6f2614a7a5c2a2d52f092a97fba3f3766199 Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Wed, 26 Aug 2026 16:21:34 +0000 Subject: [PATCH 23/38] chore(bench): regenerate the suite cost table for 154 suites The corpus shrank by the suites this change retires. Refs: CLOUD-1059 --- bench/suites/RESULTS.md | 247 ++++++++++++++++++++-------------------- 1 file changed, 124 insertions(+), 123 deletions(-) diff --git a/bench/suites/RESULTS.md b/bench/suites/RESULTS.md index 6d4000367..46fb7afa4 100644 --- a/bench/suites/RESULTS.md +++ b/bench/suites/RESULTS.md @@ -6,161 +6,162 @@ 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: 153 -- serial total: 1154.2s +- suites: 154 +- serial total: 954.6s | seconds | share | suite | | ---: | ---: | --- | -| 154.0 | 13.3% | `tests/land-lock.bats` | -| 110.7 | 9.6% | `tests/derived-check.bats` | -| 102.0 | 8.8% | `tests/ci-wait.bats` | -| 100.4 | 8.7% | `tests/session-start.bats` | -| 79.9 | 6.9% | `tests/land.bats` | -| 52.2 | 4.5% | `tests/hooks-wiring-check.bats` | -| 36.1 | 3.1% | `tests/helpers.bats` | -| 34.6 | 3.0% | `tests/main-watch.bats` | -| 34.3 | 3.0% | `tests/sbom-check.bats` | -| 33.1 | 2.9% | `tests/ci-local-parity.bats` | -| 24.3 | 2.1% | `tests/prebuilt-lint.bats` | -| 24.2 | 2.1% | `tests/hook-latency-drift.bats` | -| 20.1 | 1.7% | `tests/renovate-config-validator.bats` | -| 16.9 | 1.5% | `tests/graph-check.bats` | -| 16.5 | 1.4% | `tests/claim-check.bats` | -| 15.4 | 1.3% | `tests/config-lint.bats` | -| 15.2 | 1.3% | `tests/token-bench.bats` | -| 13.2 | 1.1% | `tests/board-diff-overlap.bats` | -| 11.1 | 1.0% | `tests/released.bats` | -| 9.8 | 0.9% | `tests/target-race.bats` | -| 9.6 | 0.8% | `tests/sbom.bats` | -| 9.6 | 0.8% | `tests/ready-guard.bats` | -| 8.1 | 0.7% | `tests/run-shape-guard.bats` | -| 7.9 | 0.7% | `tests/ready-lint.bats` | -| 7.9 | 0.7% | `tests/replay.bats` | -| 7.6 | 0.7% | `tests/release-tracking-check.bats` | -| 7.5 | 0.7% | `tests/semver.bats` | -| 6.8 | 0.6% | `tests/mutant.bats` | -| 6.4 | 0.6% | `tests/perf-record.bats` | -| 6.1 | 0.5% | `tests/board-sweep.bats` | -| 6.0 | 0.5% | `tests/pkl-check.bats` | -| 5.9 | 0.5% | `tests/commit-convention.bats` | -| 5.8 | 0.5% | `tests/schema-check.bats` | -| 5.2 | 0.5% | `tests/step-receipt.bats` | -| 5.2 | 0.5% | `tests/task-registry.bats` | -| 5.1 | 0.4% | `tests/lock-complete.bats` | -| 5.1 | 0.4% | `tests/singleton.bats` | -| 4.7 | 0.4% | `tests/signing-posture.bats` | -| 4.5 | 0.4% | `tests/pre-commit-staging.bats` | -| 4.3 | 0.4% | `tests/release-assets-check.bats` | -| 4.2 | 0.4% | `tests/mcp-allow-check.bats` | -| 4.1 | 0.4% | `tests/in-progress-drain.bats` | -| 3.6 | 0.3% | `tests/rules-drift.bats` | -| 3.4 | 0.3% | `tests/doctor-race.bats` | -| 3.4 | 0.3% | `tests/ready-cites-check.bats` | -| 3.3 | 0.3% | `tests/reference-check.bats` | +| 147.1 | 15.4% | `tests/land-lock.bats` | +| 114.1 | 11.9% | `tests/derived-check.bats` | +| 101.9 | 10.7% | `tests/ci-wait.bats` | +| 74.5 | 7.8% | `tests/land.bats` | +| 54.2 | 5.7% | `tests/hooks-wiring-check.bats` | +| 36.1 | 3.8% | `tests/helpers.bats` | +| 34.6 | 3.6% | `tests/main-watch.bats` | +| 31.5 | 3.3% | `tests/ci-local-parity.bats` | +| 28.9 | 3.0% | `tests/prebuilt-lint.bats` | +| 24.2 | 2.5% | `tests/hook-latency-drift.bats` | +| 16.4 | 1.7% | `tests/graph-check.bats` | +| 15.6 | 1.6% | `tests/sbom-check.bats` | +| 15.5 | 1.6% | `tests/claim-check.bats` | +| 14.9 | 1.6% | `tests/config-lint.bats` | +| 12.5 | 1.3% | `tests/board-diff-overlap.bats` | +| 8.1 | 0.8% | `tests/run-shape-guard.bats` | +| 7.8 | 0.8% | `tests/ready-lint.bats` | +| 7.6 | 0.8% | `tests/token-bench.bats` | +| 7.4 | 0.8% | `tests/target-race.bats` | +| 7.2 | 0.8% | `tests/ready-guard.bats` | +| 7.0 | 0.7% | `tests/mutant.bats` | +| 6.6 | 0.7% | `tests/released.bats` | +| 5.9 | 0.6% | `tests/mcp-allow-check.bats` | +| 5.8 | 0.6% | `tests/board-sweep.bats` | +| 5.7 | 0.6% | `tests/renovate-config-validator.bats` | +| 5.7 | 0.6% | `tests/lock-complete.bats` | +| 5.4 | 0.6% | `tests/replay.bats` | +| 5.3 | 0.6% | `tests/release-tracking-check.bats` | +| 5.1 | 0.5% | `tests/pre-commit-staging.bats` | +| 4.8 | 0.5% | `tests/session-start.bats` | +| 4.7 | 0.5% | `tests/release-assets-check.bats` | +| 4.5 | 0.5% | `tests/step-receipt.bats` | +| 4.4 | 0.5% | `tests/sbom.bats` | +| 4.4 | 0.5% | `tests/singleton.bats` | +| 4.0 | 0.4% | `tests/in-progress-drain.bats` | +| 3.8 | 0.4% | `tests/task-registry.bats` | +| 3.7 | 0.4% | `tests/schema-check.bats` | +| 3.7 | 0.4% | `tests/ready-cites-check.bats` | +| 3.5 | 0.4% | `tests/doctor-race.bats` | +| 3.4 | 0.4% | `tests/reference-check.bats` | | 3.3 | 0.3% | `tests/with-lock.bats` | +| 3.3 | 0.3% | `tests/semver.bats` | | 3.1 | 0.3% | `tests/ntia-check.bats` | -| 3.1 | 0.3% | `tests/hk-selection.bats` | -| 3.0 | 0.3% | `tests/target-ensure.bats` | +| 3.0 | 0.3% | `tests/hk-selection.bats` | | 3.0 | 0.3% | `tests/land-divergence.bats` | -| 2.9 | 0.3% | `tests/unlanded-check.bats` | -| 2.6 | 0.2% | `tests/landed-check.bats` | -| 2.6 | 0.2% | `tests/tree-clean.bats` | -| 2.4 | 0.2% | `tests/suite-select.bats` | -| 2.2 | 0.2% | `tests/spec-ref-check.bats` | -| 2.1 | 0.2% | `tests/fanout-guard.bats` | -| 2.1 | 0.2% | `tests/claim-race-check.bats` | -| 1.9 | 0.2% | `tests/timeout-drift.bats` | -| 1.9 | 0.2% | `tests/closing-key-check.bats` | -| 1.9 | 0.2% | `tests/target-prune.bats` | -| 1.8 | 0.2% | `tests/skill-check.bats` | -| 1.6 | 0.1% | `tests/ci-slow-needed.bats` | -| 1.6 | 0.1% | `tests/finding-sink-check.bats` | -| 1.6 | 0.1% | `tests/reclaim-census.bats` | -| 1.5 | 0.1% | `tests/bot-issue.bats` | -| 1.5 | 0.1% | `tests/memories-check.bats` | +| 2.6 | 0.3% | `tests/target-ensure.bats` | +| 2.2 | 0.2% | `tests/landed-check.bats` | +| 2.1 | 0.2% | `tests/rules-drift.bats` | +| 2.0 | 0.2% | `tests/suite-select.bats` | +| 2.0 | 0.2% | `tests/claim-race-check.bats` | +| 2.0 | 0.2% | `tests/unlanded-check.bats` | +| 1.9 | 0.2% | `tests/config-deprecations.bats` | +| 1.9 | 0.2% | `tests/spec-ref-check.bats` | +| 1.8 | 0.2% | `tests/closing-key-check.bats` | +| 1.8 | 0.2% | `tests/ci-slow-needed.bats` | +| 1.6 | 0.2% | `tests/reclaim-census.bats` | +| 1.6 | 0.2% | `tests/skill-check.bats` | +| 1.6 | 0.2% | `tests/tree-clean.bats` | +| 1.6 | 0.2% | `tests/finding-sink-check.bats` | +| 1.5 | 0.2% | `tests/module-map-check.bats` | +| 1.5 | 0.2% | `tests/memories-check.bats` | +| 1.5 | 0.2% | `tests/bot-issue.bats` | +| 1.4 | 0.2% | `tests/perf-record.bats` | +| 1.4 | 0.1% | `tests/signing-posture.bats` | | 1.4 | 0.1% | `tests/alive.bats` | -| 1.4 | 0.1% | `tests/spawn-census.bats` | -| 1.4 | 0.1% | `tests/ready-lint-deferral.bats` | | 1.3 | 0.1% | `tests/claimed-keys.bats` | +| 1.3 | 0.1% | `tests/verify.bats` | | 1.3 | 0.1% | `tests/ci-tools-check.bats` | -| 1.3 | 0.1% | `tests/ci-lease-precondition.bats` | -| 1.2 | 0.1% | `tests/verify.bats` | -| 1.2 | 0.1% | `tests/mutant-census.bats` | -| 1.1 | 0.1% | `tests/linear-check.bats` | -| 1.0 | 0.1% | `tests/deferral-check.bats` | +| 1.2 | 0.1% | `tests/linear-check.bats` | +| 1.1 | 0.1% | `tests/ready-lint-deferral.bats` | +| 1.1 | 0.1% | `tests/mutant-census.bats` | +| 1.1 | 0.1% | `tests/ci-lease-precondition.bats` | | 1.0 | 0.1% | `tests/awk-regex-check.bats` | -| 1.0 | 0.1% | `tests/evaluator-closure-check.bats` | -| 1.0 | 0.1% | `tests/land-divergence-assert.bats` | +| 1.0 | 0.1% | `tests/spawn-census.bats` | | 1.0 | 0.1% | `tests/install-check.bats` | +| 1.0 | 0.1% | `tests/deferral-check.bats` | +| 0.9 | 0.1% | `tests/land-divergence-assert.bats` | | 0.9 | 0.1% | `tests/done-check.bats` | | 0.9 | 0.1% | `tests/nonverdict-scan.bats` | -| 0.9 | 0.1% | `tests/verified.bats` | -| 0.9 | 0.1% | `tests/attestation-check.bats` | -| 0.9 | 0.1% | `tests/module-map-check.bats` | -| 0.9 | 0.1% | `tests/release-backfill.bats` | +| 0.9 | 0.1% | `tests/perf-assert.bats` | +| 0.8 | 0.1% | `tests/release-backfill.bats` | | 0.8 | 0.1% | `tests/lint-deno.bats` | -| 0.8 | 0.1% | `tests/done-pr-check.bats` | -| 0.8 | 0.1% | `tests/perf-assert.bats` | -| 0.8 | 0.1% | `tests/suite-bench-check.bats` | -| 0.8 | 0.1% | `tests/pr-unsubscribed.bats` | -| 0.7 | 0.1% | `tests/transcript-corpus-check.bats` | +| 0.8 | 0.1% | `tests/target-prune.bats` | +| 0.7 | 0.1% | `tests/attestation-check.bats` | +| 0.7 | 0.1% | `tests/pr-unsubscribed.bats` | +| 0.7 | 0.1% | `tests/done-pr-check.bats` | | 0.7 | 0.1% | `tests/gh-guard.bats` | -| 0.7 | 0.1% | `tests/sbom-binary.bats` | -| 0.7 | 0.1% | `tests/stop-posture-check.bats` | +| 0.7 | 0.1% | `tests/render-cli.bats` | +| 0.7 | 0.1% | `tests/fact-record-keying.bats` | | 0.7 | 0.1% | `tests/checks-green.bats` | +| 0.7 | 0.1% | `tests/timeout-drift.bats` | +| 0.7 | 0.1% | `tests/sbom-binary.bats` | | 0.7 | 0.1% | `tests/doctor.bats` | -| 0.7 | 0.1% | `tests/duplicate-close-check.bats` | -| 0.7 | 0.1% | `tests/render-cli.bats` | -| 0.7 | 0.1% | `tests/lint-rego.bats` | -| 0.6 | 0.1% | `tests/merged-pr-keys.bats` | | 0.6 | 0.1% | `tests/perf-compare.bats` | +| 0.6 | 0.1% | `tests/verified.bats` | +| 0.6 | 0.1% | `tests/evaluator-closure-check.bats` | +| 0.6 | 0.1% | `tests/merged-pr-keys.bats` | +| 0.6 | 0.1% | `tests/lint-rego.bats` | +| 0.6 | 0.1% | `tests/duplicate-close-check.bats` | | 0.6 | 0.1% | `tests/hook-matcher-check.bats` | -| 0.6 | 0.1% | `tests/timeout-check.bats` | +| 0.6 | 0.1% | `tests/mcp-attach-check.bats` | | 0.6 | 0.1% | `tests/mcp-timeout-budget.bats` | -| 0.6 | 0.1% | `tests/install.bats` | -| 0.6 | 0.0% | `tests/macos-link-check.bats` | -| 0.5 | 0.0% | `tests/hook-pin-check.bats` | -| 0.5 | 0.0% | `tests/mcp-attach-check.bats` | -| 0.5 | 0.0% | `tests/hook-profile-check.bats` | -| 0.5 | 0.0% | `tests/token-bench-check.bats` | -| 0.5 | 0.0% | `tests/sonar-gate.bats` | -| 0.5 | 0.0% | `tests/connector-verb-guard.bats` | -| 0.5 | 0.0% | `tests/abandon-matrix.bats` | -| 0.5 | 0.0% | `tests/checksums.bats` | -| 0.4 | 0.0% | `tests/license-table-check.bats` | +| 0.5 | 0.1% | `tests/suite-bench-check.bats` | +| 0.5 | 0.1% | `tests/install.bats` | +| 0.5 | 0.1% | `tests/hook-profile-check.bats` | +| 0.5 | 0.1% | `tests/macos-link-check.bats` | +| 0.5 | 0.1% | `tests/checksums.bats` | +| 0.5 | 0.1% | `tests/stop-posture-check.bats` | | 0.4 | 0.0% | `tests/publish-credential-check.bats` | +| 0.4 | 0.0% | `tests/msrv-pin-agreement.bats` | | 0.4 | 0.0% | `tests/pipefail-grep-check.bats` | -| 0.4 | 0.0% | `tests/digest-major-agreement.bats` | | 0.4 | 0.0% | `tests/board-payloads.bats` | -| 0.4 | 0.0% | `tests/msrv-pin-agreement.bats` | -| 0.4 | 0.0% | `tests/connector-allow-guard.bats` | -| 0.4 | 0.0% | `tests/serena-mcp.bats` | +| 0.4 | 0.0% | `tests/hook-pin-check.bats` | +| 0.4 | 0.0% | `tests/sonar-gate.bats` | +| 0.4 | 0.0% | `tests/digest-major-agreement.bats` | +| 0.4 | 0.0% | `tests/abandon-matrix.bats` | | 0.4 | 0.0% | `tests/branch-age-check.bats` | -| 0.4 | 0.0% | `tests/evaluator-io-check.bats` | +| 0.4 | 0.0% | `tests/license-table-check.bats` | +| 0.4 | 0.0% | `tests/connector-allow-guard.bats` | +| 0.4 | 0.0% | `tests/land-lock-check.bats` | | 0.3 | 0.0% | `tests/run-shape-guard-quoting.bats` | -| 0.3 | 0.0% | `tests/release-due.bats` | -| 0.3 | 0.0% | `tests/land-lock-check.bats` | -| 0.3 | 0.0% | `tests/ci-drift.bats` | -| 0.3 | 0.0% | `tests/connector-allow-resolve.bats` | -| 0.3 | 0.0% | `tests/container-preflight.bats` | -| 0.3 | 0.0% | `tests/report-only-check.bats` | +| 0.3 | 0.0% | `tests/transcript-corpus-check.bats` | +| 0.3 | 0.0% | `tests/serena-mcp.bats` | +| 0.3 | 0.0% | `tests/timeout-check.bats` | +| 0.3 | 0.0% | `tests/commit-attribution.bats` | +| 0.3 | 0.0% | `tests/pkl-check.bats` | | 0.3 | 0.0% | `tests/no-doctests.bats` | +| 0.3 | 0.0% | `tests/report-only-check.bats` | +| 0.3 | 0.0% | `tests/release-due.bats` | | 0.3 | 0.0% | `tests/nonverdict-assert.bats` | -| 0.3 | 0.0% | `tests/commit-attribution.bats` | -| 0.3 | 0.0% | `tests/cap-drift.bats` | +| 0.3 | 0.0% | `tests/commit-convention.bats` | | 0.3 | 0.0% | `tests/batten-glob-check.bats` | -| 0.3 | 0.0% | `tests/task-fail-closed.bats` | +| 0.3 | 0.0% | `tests/container-setup.bats` | +| 0.3 | 0.0% | `tests/container-preflight.bats` | +| 0.3 | 0.0% | `tests/connector-allow-resolve.bats` | +| 0.2 | 0.0% | `tests/cap-drift.bats` | | 0.2 | 0.0% | `tests/mise-pin-agreement.bats` | -| 0.2 | 0.0% | `tests/coderabbit-config-check.bats` | +| 0.2 | 0.0% | `tests/ci-drift.bats` | | 0.2 | 0.0% | `tests/rust-paths-check.bats` | -| 0.2 | 0.0% | `tests/git-hook.bats` | +| 0.2 | 0.0% | `tests/coderabbit-config-check.bats` | | 0.2 | 0.0% | `tests/mise-action-floor.bats` | -| 0.2 | 0.0% | `tests/test-bats-parallel.bats` | -| 0.2 | 0.0% | `tests/dist.bats` | -| 0.2 | 0.0% | `tests/remedy-payload-source.bats` | +| 0.2 | 0.0% | `tests/git-hook.bats` | | 0.2 | 0.0% | `tests/perf-gate.bats` | -| 0.1 | 0.0% | `tests/egress-check.bats` | +| 0.2 | 0.0% | `tests/remedy-payload-source.bats` | +| 0.2 | 0.0% | `tests/token-bench-check.bats` | +| 0.1 | 0.0% | `tests/dist.bats` | +| 0.1 | 0.0% | `tests/test-bats-parallel.bats` | +| 0.1 | 0.0% | `tests/task-fail-closed.bats` | | 0.1 | 0.0% | `tests/perf-pair.bats` | -| 0.1 | 0.0% | `tests/cross-check.bats` | +| 0.1 | 0.0% | `tests/egress-check.bats` | +| 0.1 | 0.0% | `tests/evaluator-io-check.bats` | | 0.1 | 0.0% | `tests/darwin-link.bats` | +| 0.1 | 0.0% | `tests/cross-check.bats` | | 0.0 | 0.0% | `tests/zizmor-split.bats` | From e39f197841e70082606f03b9f7197feccebc2d2f Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Wed, 26 Aug 2026 17:03:10 +0000 Subject: [PATCH 24/38] fix(policy): the retirement ledger's own arms, and the module the layering table had not placed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Running `mise run verify` against the rebased branch surfaced two classes of defect in work this PR had already landed, both of them the gates working. CLOUD-908's ledger, ten arms: * Four `changed:` arms carried a target and no REASON, which is what `case-change-unexplained` exists to catch — a diverged behaviour recorded as a bare pointer says where it went and not why it moved. Each now states its reason. * Five arms were suite-qualified as `::` where the resolver keys on `.bats::`, so they were keys nothing looked up. Two of them then resolved by BORROWING a bare arm of the same title from another suite, which is precisely the defect the resolver's own comment records. All five now spell the suite file. * Two `carried:` arms had a backslash-escaped apostrophe where the case title carries a plain one, so neither matched the case it meant to claim. `policy/module-layering.rego` had never been told about `recorder`, which arrived earlier on this branch. Its coverage rule named the module before any reviewer did — the fourth time that property has worked on a module this repository added. Declared with what it is: a writer rather than a decider, so it sits below `rules` and reaches `exec` for the one spawn the placed-adapter rule requires. `mise run batten-check`: exit 0. Refs: CLOUD-908, CLOUD-931, CLOUD-1051, CLOUD-1059 --- crates/batten/tests/board_record.rs | 4 ++-- crates/batten/tests/filed_here.rs | 10 +++++----- crates/batten/tests/review_answered.rs | 8 ++++---- crates/batten/tests/stop_posture.rs | 12 ++++++------ policy/module-layering.rego | 6 ++++++ 5 files changed, 23 insertions(+), 17 deletions(-) diff --git a/crates/batten/tests/board_record.rs b/crates/batten/tests/board_record.rs index 6c778c11c..3d9e875ad 100644 --- a/crates/batten/tests/board_record.rs +++ b/crates/batten/tests/board_record.rs @@ -73,8 +73,8 @@ //! //! CHANGED — behaviour that diverges deliberately, each with its reason. //! -// changed: "board-write-record::the bypass is honoured" crates/batten/src/recorder.rs -// changed: "A FILE THIS BRANCH HAS NOT TOUCHED IS STILL RECORDED" crates/batten/tests/board_record.rs +// changed: "board-write-record.bats::the bypass is honoured" crates/batten/src/recorder.rs BATTEN_BOARD_WRITE_BYPASS is gone rather than ported: a bypass exists to let an author past a REFUSAL, and a recorder refuses nothing, so the only thing it could buy was a quieter record — the one direction the gate reading it cannot detect +// changed: "board-write-record.bats::A FILE THIS BRANCH HAS NOT TOUCHED IS STILL RECORDED" crates/batten/tests/board_record.rs the overlap column holds the paths the body NAMES, intersected by the gate later rather than here, so the case is carried under a name that says what it measures //! //! `BATTEN_BOARD_WRITE_BYPASS` is **gone rather than ported**, and that is a //! deliberate narrowing rather than an oversight. A bypass exists to let an diff --git a/crates/batten/tests/filed_here.rs b/crates/batten/tests/filed_here.rs index 4b6242647..af7913658 100644 --- a/crates/batten/tests/filed_here.rs +++ b/crates/batten/tests/filed_here.rs @@ -75,11 +75,11 @@ //! //! CHANGED — behaviour that diverges deliberately, each with its reason. //! -// changed: "filed-here-check::the bypass is honoured" crates/batten/src/rules.rs -// changed: "the override lets the diff refusal through" crates/batten/tests/admission.rs -// changed: "the override records which rows it overrode" crates/batten/tests/admission.rs -// changed: "the override does not excuse an unrefined row" crates/batten/tests/admission.rs -// changed: "the override records nothing when there was nothing to override" crates/batten/tests/admission.rs +// changed: "filed-here-check.bats::the bypass is honoured" crates/batten/src/rules.rs BATTEN_FILED_HERE_BYPASS is gone: this is a `[[rule]]` row now, so the engine's own hatch is the one switch, and a per-gate variable would be a second one nobody can find +// changed: "filed-here-check.bats::the override lets the diff refusal through" crates/batten/tests/admission.rs the override is an ISSUED admission rather than a variable somebody knows (CLOUD-1051), so the case moves to the suite that drives `batten override request` end to end +// changed: "filed-here-check.bats::the override records which rows it overrode" crates/batten/tests/admission.rs same cause: what an admission records is the store's property, asserted where the store is +// changed: "filed-here-check.bats::the override does not excuse an unrefined row" crates/batten/tests/admission.rs same cause, and the narrowing is structural now: an admission is keyed to one subject, so it cannot reach a second predicate at all +// changed: "filed-here-check.bats::the override records nothing when there was nothing to override" crates/batten/tests/admission.rs same cause: an unspent admission leaves the store untouched, which is the store's own case //! //! `BATTEN_FILED_HERE_BYPASS` and `BATTEN_FILED_HERE_OVERLAP` are **gone rather //! than ported**, which is the whole of CLOUD-1051's first half: a knowable diff --git a/crates/batten/tests/review_answered.rs b/crates/batten/tests/review_answered.rs index d4b6b85b4..e0d1af8d0 100644 --- a/crates/batten/tests/review_answered.rs +++ b/crates/batten/tests/review_answered.rs @@ -49,10 +49,10 @@ //! the engine renders beside the token. The number is identical in every case, //! which is what makes this a changed assertion rather than a dropped one. //! -// changed: "review-answered::THE MEASURED SHAPE: a head carrying unresolved threads is refused, naming the count" crates/batten/tests/review_answered.rs -// changed: "review-answered::VACUITY: zero threads and no review reads as unreviewed, not as all-addressed" crates/batten/tests/review_answered.rs -// changed: "review-answered::VACUITY: a page the command could not read refuses rather than passing" crates/batten/tests/review_answered.rs -// changed: "review-answered::THE BYPASS: a compound command is still a ready" crates/batten/tests/review_answered.rs +// changed: "review-answered.bats::THE MEASURED SHAPE: a head carrying unresolved threads is refused, naming the count" crates/batten/tests/review_answered.rs the count is identical and where it is read from is not: `4 blocking` was a substring of a free string, and it is now the `Subject::Count` the engine renders beside the token (CLOUD-1050) +// changed: "review-answered.bats::VACUITY: zero threads and no review reads as unreviewed, not as all-addressed" crates/batten/tests/review_answered.rs same cause, same number: the assertion moved from prose to the decoded subject +// changed: "review-answered.bats::VACUITY: a page the command could not read refuses rather than passing" crates/batten/tests/review_answered.rs same cause, same number, and the discriminating pair with the all-answered case is untouched +// changed: "review-answered.bats::THE BYPASS: a compound command is still a ready" crates/batten/tests/review_answered.rs same cause, same number; what the case proves — that the receipt row's selection and this module's narrowing agree about one command — is unchanged //! //! # One case the retired suite could not have //! diff --git a/crates/batten/tests/stop_posture.rs b/crates/batten/tests/stop_posture.rs index d3dfaa1ef..3c00c1756 100644 --- a/crates/batten/tests/stop_posture.rs +++ b/crates/batten/tests/stop_posture.rs @@ -41,13 +41,13 @@ // carried: "stop-guard::the bypass is honoured" crates/batten/tests/stop_posture.rs // carried: "the guard never exits non-zero, so it cannot surface as a hook error" crates/batten/tests/stop_posture.rs // carried: "a turn that strands a finding is pointed at, and the turn still ends" crates/batten/tests/stop_posture.rs -// carried: "POINTER, NEVER PAYLOAD: the advisory carries no byte of the turn\'s prose" crates/batten/tests/stop_posture.rs +// carried: "POINTER, NEVER PAYLOAD: the advisory carries no byte of the turn's prose" crates/batten/tests/stop_posture.rs // carried: "the advisory says what to do, since a coordinate alone is not an instruction" crates/batten/tests/stop_posture.rs // carried: "the shipped rule keeps precedence when both would fire" crates/batten/tests/stop_posture.rs // carried: "a turn that strands nothing is silent" crates/batten/tests/stop_posture.rs // carried: "an unreadable transcript manufactures no advisory" crates/batten/tests/stop_posture.rs // carried: "the recursion bound still holds for the second rule" crates/batten/tests/stop_posture.rs -// carried: "A FILED ROW NAMING THIS BRANCH\'S OWN DIFF IS POINTED AT, BEFORE ANY CI" crates/batten/tests/filed_here.rs +// carried: "A FILED ROW NAMING THIS BRANCH'S OWN DIFF IS POINTED AT, BEFORE ANY CI" crates/batten/tests/filed_here.rs // carried: "the punt pointer carries no prose from the row" crates/batten/tests/filed_here.rs // carried: "the punt rule yields to the measured posture rule" crates/batten/tests/stop_posture.rs // carried: "a branch with no filed row names none" crates/batten/tests/filed_here.rs @@ -65,10 +65,10 @@ //! //! CHANGED — behaviour that diverges deliberately, each with its reason. //! -// changed: "the punt pointer fires once and then goes quiet for that row" crates/batten/src/lib.rs -// changed: "a second row still gets its own pointer after the first is spent" crates/batten/src/lib.rs -// changed: "EVERY ROW THE BRANCH FILED IS ENUMERATED FOR RE-EVALUATION" crates/batten/src/lib.rs -// changed: "the checklist repeats only when the set changes" crates/batten/src/lib.rs +// changed: "stop-guard.bats::the punt pointer fires once and then goes quiet for that row" crates/batten/src/lib.rs the suppression key is the finding's POINTER, which is the path rather than the row id: a `Finding` carries its first path-bearing subject and the id travels as a subject the engine does not project onto the struct. One nudge per PATH per branch, not one per row +// changed: "stop-guard.bats::a second row still gets its own pointer after the first is spent" crates/batten/src/lib.rs same cause: two rows overlapping DIFFERENT paths each get their own pointer, and two rows overlapping the same path share one. The retired suite keyed on the id, which the engine no longer carries into the struct +// changed: "stop-guard.bats::EVERY ROW THE BRANCH FILED IS ENUMERATED FOR RE-EVALUATION" crates/batten/src/lib.rs the checklist reads the `board-writes` record directly rather than re-deriving the set from findings, so it still enumerates by id — which is why the id survives here and not in the pointer above +// changed: "stop-guard.bats::the checklist repeats only when the set changes" crates/batten/src/lib.rs suppression is per SET rather than per row, keyed on the record's own contents; the retired suite keyed on a file the shell wrote, which no longer exists //! //! THE PUNT POINTER NAMES THE PATH, NOT THE ROW, and the suppression key moved //! with it. A `Finding` carries its first path-bearing subject as its pointer and diff --git a/policy/module-layering.rego b/policy/module-layering.rego index c97beb873..0f32a12f9 100644 --- a/policy/module-layering.rego +++ b/policy/module-layering.rego @@ -79,6 +79,12 @@ declared_modules := { # the same property the three above record, working a second time. `symbols` # arrived with CLOUD-760 and it worked a third. "patch", "symbols", + # `recorder` arrived with CLOUD-1051 and it worked a fourth time: the module + # landed undeclared and this rule named it, before any reviewer did. It is a + # writer rather than a decider — it accumulates what a gate already said — so + # it sits below `rules` and reaches `exec` for its one spawn, which is the + # placed adapter `policy/spawn-adapters.rego` requires. + "recorder", } # THE FORBIDDEN EDGES, each traceable to prose already in the tree. From 31cae81b56e730eb03eb711272ef92d69209afca Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Wed, 26 Aug 2026 18:02:40 +0000 Subject: [PATCH 25/38] perf(policy)!: a module's own test rules stop riding the hot path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `perf-compare` refused this branch at 2.543x on the wired path — 20.94 ms at the merge base against 53.26 ms here — and the cause is general rather than this row's. Measured, release binary, 40 runs each, isolating `policy/stop-posture.rego`: | tree | wired p50 | | -------------------------------------- | --------- | | the row absent entirely | 23.59 ms | | a trivial module in its place | 24.53 ms | | the module with its `test_` rules cut | 26.51 ms | | as committed | 51.72 ms | So registration costs 1 ms, the predicate costs 2, and its thirteen `test_` rules cost 25. A `test_` rule is the load-time tier: `batten policy test` runs it and nothing else queries one. They were compiled into every mediated bundle and evaluated with it anyway, because `data.batten.deny` is answered by evaluating the package — so every tool call ran a suite that decides nothing about that call. Every mediated module was already paying this; no branch had added one large enough to cross the threshold. `without_test_rules` drops them under `ModuleChecks::SkipOnHotPath` and nowhere else, so `policy test` and every checked load still compile the full text. It strips a NAME rather than a shape: a rule head is at column zero, and so is the `}` that closes its body — treating that brace as a new head is what made the first draft emit a stray `}` and break the parse, which is why closing delimiters continue whatever is open. Wired p50 with the strip: 19.82 ms, below the merge base's 20.94 ms. The test asserts both ends the strip sits between, because either alone is satisfiable by a mistake: `policy test` still counts the module's own cases, and the stripped module still refuses the fixture's hedged message. `mise run test:cargo`: 2596/2596. Refs: CLOUD-1051, CLOUD-1053 --- crates/batten/src/policy.rs | 58 ++++++++++++++++++++++++++++ crates/batten/tests/stop_posture.rs | 60 +++++++++++++++++++++++++++++ 2 files changed, 118 insertions(+) diff --git a/crates/batten/src/policy.rs b/crates/batten/src/policy.rs index e6b651436..952753227 100644 --- a/crates/batten/src/policy.rs +++ b/crates/batten/src/policy.rs @@ -784,6 +784,14 @@ pub fn load( )) })?, }; + // The hot path drops the module's own `test_` rules (CLOUD-1051): + // nothing queries one, and evaluating them cost 25 ms per mediated + // call. `policy test` and every checked load compile the full text. + let source = if checks == ModuleChecks::SkipOnHotPath { + without_test_rules(&source) + } else { + source + }; sources.push((path.clone(), source)); } @@ -816,6 +824,56 @@ pub fn load( Ok(bundles) } +/// Drop a module's own `test_` rules before it reaches the hot path. +/// +/// # 25 milliseconds per mediated call, measured +/// +/// A module's `test_` rules are the LOAD-TIME tier: `batten policy test` runs +/// them and nothing else ever queries one. They were nonetheless compiled into +/// every bundle and evaluated with it, because `data.batten.deny` is answered by +/// evaluating the package — so every tool call paid for a suite that decides +/// nothing about that call. +/// +/// Measured on the wired path, release binary, 40 runs: registering +/// `policy/stop-posture.rego` cost 28 ms, of which 1 ms was the registration and +/// **25 ms was its thirteen `test_` rules**, each running the module's own regex +/// scrub over a prose fixture. The same tax was already being paid by every +/// other mediated module, unnoticed because no branch had added one large enough +/// to cross `perf-compare`'s threshold. +/// +/// # It strips a NAME, not a shape +/// +/// A rule head begins at column zero — that is Rego's own layout, and the same +/// discriminator `rules.rs`'s case scanner already uses one layer over. So a +/// line starting `test_` at column zero opens a test rule, and everything up to +/// the next column-zero line that opens something else belongs to it. +/// +/// **`ModuleChecks::SkipOnHotPath` is the only caller**, which is what keeps this +/// from being a way to lose a test: `policy test` compiles the full text, and so +/// does every load that runs the module checks. A stripped module is never the +/// one anything is graded against. +fn without_test_rules(source: &str) -> String { + let mut kept = String::with_capacity(source.len()); + let mut dropping = false; + for line in source.lines() { + // A rule HEAD is at column zero; so is the `}` that closes its body, and + // treating that brace as a new head is what made the first draft emit a + // stray `}` and break the parse. A closing delimiter continues whatever + // is open rather than opening anything. + let opens_a_rule = !line.is_empty() + && !line.starts_with([' ', '\t']) + && !line.starts_with(['}', ']', ')']); + if opens_a_rule { + dropping = line.starts_with("test_"); + } + if !dropping { + kept.push_str(line); + kept.push('\n'); + } + } + kept +} + /// Compose `sources` into **one** bundle: one engine, one compile, one smoke /// query (CLOUD-837). /// diff --git a/crates/batten/tests/stop_posture.rs b/crates/batten/tests/stop_posture.rs index 3c00c1756..ec4542a93 100644 --- a/crates/batten/tests/stop_posture.rs +++ b/crates/batten/tests/stop_posture.rs @@ -186,6 +186,66 @@ fn stdout_of(output: &Output) -> String { String::from_utf8(output.stdout.clone()).expect("stdout is UTF-8") } +/// The hot path drops a module's own `test_` rules, and `policy test` does not. +/// +/// # The seam, and what it is worth +/// +/// A `test_` rule is the LOAD-TIME tier: `batten policy test` runs it and nothing +/// else queries one. They were nonetheless compiled into every mediated bundle +/// and evaluated with it, because `data.batten.deny` is answered by evaluating +/// the package — so every tool call ran a suite that decides nothing about that +/// call. Measured on the wired path, release binary, 40 runs: 51.72 ms with this +/// module's thirteen cases in the bundle, 19.82 ms with them stripped, against a +/// merge base of 20.94 ms. +/// +/// # Both halves, because either alone is satisfiable by a mistake +/// +/// A strip that removed too much would leave the module deciding nothing and the +/// cases ungraded; a strip that removed nothing would leave the cost. So this +/// asserts the DECISION still fires (the fixture's hedged message is refused, +/// which is the other cases above) and that `policy test` still counts this +/// module's own cases — the two ends the strip sits between. +#[test] +fn the_hot_path_drops_the_modules_own_cases_and_policy_test_keeps_them() { + let dir = repo("stop-posture-test-strip"); + + // The graded tier: `policy test` compiles the full text, so the module's own + // cases are still there to run and still pass. + let graded = batten() + .current_dir(&dir) + .args(["policy", "test"]) + .output() + .expect("run batten policy test"); + let report = String::from_utf8(graded.stdout).expect("stdout is UTF-8"); + assert_eq!( + graded.status.code(), + Some(0), + "the module's own cases still pass: {report}" + ); + let passed: usize = report + .split_whitespace() + .zip(report.split_whitespace().skip(1)) + .find_map(|(count, word)| (word == "passed,").then(|| count.parse().ok())?) + .expect("the report states how many cases passed"); + assert!( + passed >= 10, + "the strip must not reach the graded tier, and this module carries more \ + than ten cases: {report}" + ); + + // The hot path: the same module, same fixture, still decides. A strip that + // took a real rule with it would go silent here. + let out = hook( + &dir, + &stop_payload("one thing I'd flag is the ordering", false), + ); + assert!( + stdout_of(&out).contains("V-HEDGED-FLAG-FRAMING"), + "the stripped module still refuses: {}", + stdout_of(&out) + ); +} + /// THE CASE THE MODULE'S OWN SUITE COULD NOT MAKE: the engine builds the input, /// runs the module, and the nudge reaches the host's advisory channel. #[test] From baa5dc6527f6279cb8de3b4ce7e46a9a2b908863 Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Wed, 26 Aug 2026 18:17:59 +0000 Subject: [PATCH 26/38] refactor(policy): the module read splits out, at the seam the I/O already had MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The hot-path strip pushed `load` to 101 lines. `read_sources` takes the read — working tree or ref, plus the strip — which is where the boundary already was: everything past it is pure, and that is what lets `compile`'s composition property be tested without a filesystem. `mise run lint:clippy`: exit 0. Refs: CLOUD-1051 --- crates/batten/src/policy.rs | 67 +++++++++++++++++++++++-------------- 1 file changed, 41 insertions(+), 26 deletions(-) diff --git a/crates/batten/src/policy.rs b/crates/batten/src/policy.rs index 952753227..7f5d17dd2 100644 --- a/crates/batten/src/policy.rs +++ b/crates/batten/src/policy.rs @@ -768,32 +768,7 @@ pub fn load( (None, Some(bundle)) => bundle_members(root, bundle, reference, &rule.id)?, (None, None) => Vec::new(), }; - let mut sources = Vec::new(); - for path in &paths { - let source = match reference { - Some(reference) => crate::git::show(root, reference, path).map_err(|_| { - UsageError::raise(format!( - "rule `{}` registers `{path}`, which is absent at {reference}", - rule.id - )) - })?, - None => std::fs::read_to_string(root.join(path)).map_err(|_| { - UsageError::raise(format!( - "rule `{}` registers `{path}`, which cannot be read", - rule.id - )) - })?, - }; - // The hot path drops the module's own `test_` rules (CLOUD-1051): - // nothing queries one, and evaluating them cost 25 ms per mediated - // call. `policy test` and every checked load compile the full text. - let source = if checks == ModuleChecks::SkipOnHotPath { - without_test_rules(&source) - } else { - source - }; - sources.push((path.clone(), source)); - } + let sources = read_sources(root, &paths, reference, &rule.id, checks)?; // EVERYTHING PAST THE READ IS PURE, and the split is what lets the // composition property be tested without a filesystem: `compile` builds @@ -824,6 +799,46 @@ pub fn load( Ok(bundles) } +/// Read one row's declared module text, from the working tree or from a ref. +/// +/// Split out of [`load`] when the hot-path strip below pushed that function past +/// the line ceiling, and the seam is where the I/O already was: everything past +/// this read is pure, which is what lets [`compile`]'s composition property be +/// tested without a filesystem. +fn read_sources( + root: &Path, + paths: &[String], + reference: Option<&str>, + rule_id: &str, + checks: ModuleChecks, +) -> Result> { + let mut sources = Vec::new(); + for path in paths { + let source = match reference { + Some(reference) => crate::git::show(root, reference, path).map_err(|_| { + UsageError::raise(format!( + "rule `{rule_id}` registers `{path}`, which is absent at {reference}" + )) + })?, + None => std::fs::read_to_string(root.join(path)).map_err(|_| { + UsageError::raise(format!( + "rule `{rule_id}` registers `{path}`, which cannot be read" + )) + })?, + }; + // The hot path drops the module's own `test_` rules (CLOUD-1051): + // nothing queries one, and evaluating them cost 25 ms per mediated call. + // `policy test` and every checked load compile the full text. + let source = if checks == ModuleChecks::SkipOnHotPath { + without_test_rules(&source) + } else { + source + }; + sources.push((path.clone(), source)); + } + Ok(sources) +} + /// Drop a module's own `test_` rules before it reaches the hot path. /// /// # 25 milliseconds per mediated call, measured From 6839619ead647e59d76e48575ecf1619ce0894ed Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Wed, 26 Aug 2026 18:59:31 +0000 Subject: [PATCH 27/38] fix(exec): a shebang program reaches the ladder, which is what Windows needed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `exec::piped` spawned `Command::new(path)` directly, so a `#!/usr/bin/env bash` program — no extension, no executable image — was refused by `CreateProcess` and the caller read the refusal as could-not-look. Windows CI named it precisely: a recorder column reporting `-` where the stub's exit status says `ready`. `rules::spawn_resolving` exists for exactly this and is already what this module's own verb spawns through; its third rung reads the shebang and runs the interpreter. `None` for the resolve root, because the program is absolute here and handing a directory would only be a guess at one. Found by CI rather than locally, which is the honest account: this sandbox is Linux, so the condition cannot be produced here at all. Refs: CLOUD-113, CLOUD-931, CLOUD-1051 --- crates/batten/src/exec.rs | 30 ++++++++++++++++++++++-------- 1 file changed, 22 insertions(+), 8 deletions(-) diff --git a/crates/batten/src/exec.rs b/crates/batten/src/exec.rs index 9487aa1f8..067256b30 100644 --- a/crates/batten/src/exec.rs +++ b/crates/batten/src/exec.rs @@ -1227,14 +1227,28 @@ pub(crate) fn piped( if !path.is_file() { return None; } - let mut child = Command::new(&path) - .args(args) - .current_dir(root) - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::null()) - .spawn() - .ok()?; + // THROUGH THE RESOLUTION LADDER, not `Command::new` directly, and Windows CI + // is what said so: a `#!/usr/bin/env bash` program has no extension and no + // executable image, so `CreateProcess` refuses it and the caller read the + // refusal as could-not-look — a recorder column that said `-` where it should + // have said `ready`. `spawn_resolving`'s third rung reads the shebang and + // runs the interpreter, which is the same ladder this module's own verb uses + // and the reason it exists. + // + // `None` for the resolve root: the program is already absolute here, so a + // relative name cannot arise and handing a directory would only be a guess at + // one. + let mut child = crate::rules::spawn_resolving(None, path.to_str()?, |program, extra| { + Command::new(OsString::from(program)) + .args(extra.iter().map(OsString::from)) + .args(args) + .current_dir(root) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + }) + .ok()?; child.stdin.take()?.write_all(stdin.as_bytes()).ok()?; let finished = child.wait_with_output().ok()?; Some(( From d9b36ab66ba7f0bf928ec78c94961e6324f0d217 Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Wed, 26 Aug 2026 19:31:41 +0000 Subject: [PATCH 28/38] fix(tests): the Windows shebang the ladder can read, and the third precondition row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two failures the runners named, neither reproducible in this sandbox. WINDOWS. `board_record.rs` and `stop_posture.rs` wrote their stub programs with `#!/usr/bin/env bash`. `rules::spawn_resolving`'s third rung reads the shebang and spawns the interpreter it names, and `/usr/bin/env` is not a path a Windows runner resolves — so every program column came back could-not-look and the recorder reported `-` where the stub's exit status says `ready`. Twelve fixtures that already cross Windows CI spell it `#!/bin/sh`, which is the spelling CLOUD-113 records the ladder as fixed for; both stub bodies were already POSIX. THE THIRD PRECONDITION ROW. `the_committed_policy_gates_ready_on_receipts_rather_than_banning_it` names two rows that may refuse `gh pr ready` without being an outright ban. `main` added a third — `ready-needs-an-answered-review` (CLOUD-859) — and which one fires first is a property of the checkout, so the case reddens as soon as HEAD moves past the recorded review. That is the same hidden dependency its own doc comment records for the branch NAME, one row later; a precondition row added to the committed table belongs in this list the day it lands. `mise run test:cargo`: 2596/2596. Refs: CLOUD-113, CLOUD-859, CLOUD-1051 --- crates/batten/tests/board_record.rs | 2 +- crates/batten/tests/cli.rs | 26 ++++++++++++++++++-------- crates/batten/tests/stop_posture.rs | 2 +- 3 files changed, 20 insertions(+), 10 deletions(-) diff --git a/crates/batten/tests/board_record.rs b/crates/batten/tests/board_record.rs index 3d9e875ad..8720e463b 100644 --- a/crates/batten/tests/board_record.rs +++ b/crates/batten/tests/board_record.rs @@ -123,7 +123,7 @@ fn write_program(dir: &Path, name: &str, exit: i32, stdout: &str) { // `%s\n` with the newline OWNED HERE rather than embedded in the // argument: a `\n` inside the Rust literal reaches bash escaped, so // `printf '%s'` prints it verbatim and glues it to the last token. - format!("#!/usr/bin/env bash\ncat >/dev/null\nprintf '%s\\n' {stdout:?}\nexit {exit}\n"), + format!("#!/bin/sh\ncat >/dev/null\nprintf '%s\\n' {stdout:?}\nexit {exit}\n"), ) .expect("write stub"); #[cfg(unix)] diff --git a/crates/batten/tests/cli.rs b/crates/batten/tests/cli.rs index 4627639c8..7e7dd3a96 100644 --- a/crates/batten/tests/cli.rs +++ b/crates/batten/tests/cli.rs @@ -2760,13 +2760,21 @@ fn the_census_check_refuses_a_case_naming_no_row() { /// case is a statement about the committed policy and not about whether this /// checkout happens to have run `verify`. /// -/// TWO ROWS CAN REFUSE, AND BOTH ARE PRECONDITIONS — which is the assertion, -/// rather than a widening of it. `ready-needs-receipts` refuses until `verify` -/// has run; `ready-names-an-issue` is a `shape` row carrying `requires_key`, -/// which the rules file describes as narrowing the deny "from *this command is -/// banned* to *this command is banned unless the work is keyed*". Neither is the -/// outright ban this case exists to refuse, and which one fires first is a -/// property of the checkout. +/// THREE ROWS CAN REFUSE, AND ALL THREE ARE PRECONDITIONS — which is the +/// assertion, rather than a widening of it. `ready-needs-receipts` refuses until +/// `verify` has run; `ready-names-an-issue` is a `shape` row carrying +/// `requires_key`, which the rules file describes as narrowing the deny "from +/// *this command is banned* to *this command is banned unless the work is +/// keyed*"; `ready-needs-an-answered-review` (CLOUD-859) refuses until the +/// declared review command has been run for this head. None is the outright ban +/// this case exists to refuse, and which one fires first is a property of the +/// checkout. +/// +/// The third row arrived after this case was written and reddened it here, on a +/// checkout whose first two preconditions were satisfied — the same class of +/// hidden dependency the paragraph below records for the branch NAME, one row +/// later. A precondition row added to the committed table belongs in this list +/// the day it lands. /// /// Naming only the receipt row made that a hidden dependency on the branch /// NAME. A branch carrying no `CLOUD-*` key trips the key row before the receipt @@ -2786,7 +2794,9 @@ fn the_committed_policy_gates_ready_on_receipts_rather_than_banning_it() { // Refused — and it must be a PRECONDITION row that did it, never one of // the `gh` lifecycle bans, which refuse the command outright. Some(2) => assert!( - stderr.contains("ready-needs-receipts") || stderr.contains("ready-names-an-issue"), + stderr.contains("ready-needs-receipts") + || stderr.contains("ready-names-an-issue") + || stderr.contains("ready-needs-an-answered-review"), "a refused `gh pr ready` must come from a precondition row, got: {stderr}" ), other => panic!("unexpected exit {other:?}: {stderr}"), diff --git a/crates/batten/tests/stop_posture.rs b/crates/batten/tests/stop_posture.rs index ec4542a93..deb71518c 100644 --- a/crates/batten/tests/stop_posture.rs +++ b/crates/batten/tests/stop_posture.rs @@ -382,7 +382,7 @@ fn stub(dir: &Path, program: &str, exit: i32, stdout: &str) { fs::create_dir_all(path.parent().expect("a parent")).expect("mise-tasks dir"); fs::write( &path, - format!("#!/usr/bin/env bash\ncat >/dev/null\nprintf '%s\\n' {stdout:?}\nexit {exit}\n"), + format!("#!/bin/sh\ncat >/dev/null\nprintf '%s\\n' {stdout:?}\nexit {exit}\n"), ) .expect("write stub"); #[cfg(unix)] From d77371a11d37d3098520b7859f144f0d93c0ad2d Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Wed, 26 Aug 2026 22:10:23 +0000 Subject: [PATCH 29/38] build(deps): gix 0.87, because every version of bisync was yanked under us MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `cargo-semver-checks` generates a scratch crate and runs `cargo update` in it — it discards `Cargo.lock`. So its verdict is a function of the crates.io index at the moment it runs rather than of the tree, and it is the only gate here with that property. Today at 19:25:45Z the last unyanked `bisync` went. `gix 0.86` reaches it through `gix-protocol ^0.64.0`, so the scratch resolve has nothing to select and the gate exits 101 — not a verdict. `semver` had passed in CI on this branch's head at 19:18:19Z, seven minutes earlier. Builds were never affected: `Cargo.lock` pinned `bisync 0.3.0` and a yank does not invalidate an existing lock. Only the resolve that throws the lock away broke. Upstream had already fixed it. `gix-protocol 0.65.1` dropped `bisync` outright on 2026-08-24, the same day the maintainer yanked every version of it, and `gix 0.87.1` carries that. `gix-diff` moves 0.66 -> 0.67 in step because `gix 0.87.1` requires `^0.67.1`. `bisync` is gone from both lockfiles. No call site changed: `mise run lint:clippy` exits 0 across the three modules that touch gix (`git.rs`, `mint.rs`, `policy.rs`), which is CLOUD-739/740 confining gix to one module paying off on the first bump that could have hurt. The bump rides this branch rather than its own PR because it unblocks every branch, not just this one — and a separate PR would have hit the same wall, since nothing can land while the gate cannot resolve. Refs: CLOUD-1050 --- Cargo.lock | 185 ++++++++++++++++++++++++----------------------- Cargo.toml | 4 +- fuzz/Cargo.lock | 187 +++++++++++++++++++++++++----------------------- 3 files changed, 195 insertions(+), 181 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index dc7ceb436..4c68f94e3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -151,21 +151,6 @@ dependencies = [ "yaml-rust2", ] -[[package]] -name = "bisync" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5020822f6d6f23196ccaf55e228db36f9de1cf788052b37992e17cbc96ec41a7" -dependencies = [ - "bisync_macros", -] - -[[package]] -name = "bisync_macros" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d21f40d350a700f6aa107e45fb26448cf489d34794b2ba4522181dc9f1173af6" - [[package]] name = "bit-set" version = "0.8.0" @@ -720,9 +705,9 @@ dependencies = [ [[package]] name = "gix" -version = "0.86.0" +version = "0.87.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb3790fd8981cba7949f1ba924ef865d902df731627bc5998d14164063892fce" +checksum = "fdefc1465d8631807deaf504dacdeff628b978de515653b47976ca7c28ab2a7a" dependencies = [ "gix-actor", "gix-commitgraph", @@ -738,11 +723,13 @@ dependencies = [ "gix-hashtable", "gix-index", "gix-lock", + "gix-note", "gix-object", "gix-odb", "gix-pack", "gix-path", "gix-protocol", + "gix-quote", "gix-ref", "gix-refspec", "gix-revision", @@ -764,9 +751,9 @@ dependencies = [ [[package]] name = "gix-actor" -version = "0.41.2" +version = "0.42.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33f9308ad6fd35b2a865cbe4117ac61b2be59e4a9ef1621c7a9794f7c8e52c5b" +checksum = "e37efa99929ac62f980fb1a7dcd09dea85b3aa6ead6ba0498362add3f4e698de" dependencies = [ "bstr", "gix-date", @@ -775,9 +762,9 @@ dependencies = [ [[package]] name = "gix-attributes" -version = "0.34.0" +version = "0.35.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c31c593692ebdc1e38858d9a2b56f6a594c501e24a38971fe6685571f5a07be0" +checksum = "76b83782ac69ae28921a6e8fbcf2e24069613f1518030b97ae622b079abffa54" dependencies = [ "bstr", "gix-features", @@ -792,27 +779,27 @@ dependencies = [ [[package]] name = "gix-bitmap" -version = "0.3.3" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7cd1d118d0f5d88b96e6f6e13b566475fef4797ead4a02c26fed36c1375066f7" +checksum = "f17013c7ef5cb95ebb4bf4978201e6a8a42ffea9d666fd5388f1b7e742307d80" dependencies = [ "gix-error", ] [[package]] name = "gix-chunk" -version = "0.7.3" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2a871e5cab12ba568845714473505deefffb3c04eb47f4708ce344cd459c1cc" +checksum = "00e32f938b3745ac93d73bc7490b6a15a661b2fc81973bfe90e275cc53c908e1" dependencies = [ "gix-error", ] [[package]] name = "gix-command" -version = "0.9.2" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf4363accdf6ef7ba861871d2d521ab7418a04aaaed919fadb022af71d379b12" +checksum = "0d96b8e8423ce2665232bda1b682a62541f07ab801ffad69a034ef962bed3244" dependencies = [ "bstr", "gix-path", @@ -823,9 +810,9 @@ dependencies = [ [[package]] name = "gix-commitgraph" -version = "0.38.0" +version = "0.39.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2cd7f054ae2727223fe46dd39c012f066b12f532962d336d29ee193261787da" +checksum = "6b93c9fb1f5be01bba54a7a3b7455d359efc68555539c75ef74b8021bb6db497" dependencies = [ "bstr", "gix-chunk", @@ -837,9 +824,9 @@ dependencies = [ [[package]] name = "gix-config" -version = "0.59.0" +version = "0.60.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "103d11bef95c467577ecfa8b7b86a22e65af3507b2c9bfa3809a4afbae7df301" +checksum = "f973c28c0a4871a7926fc90c8377d4458fd6cbb19692f56582b4d2022313ae90" dependencies = [ "bstr", "gix-config-value", @@ -869,9 +856,9 @@ dependencies = [ [[package]] name = "gix-date" -version = "0.15.6" +version = "0.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e47b9e8cdc688296609b706428de570f88b1e0eed7156dde7b4a89d26fa4567" +checksum = "e63d9aa18f29facd571c8953e66224ee0075b9e16622024794555ed4acceea85" dependencies = [ "bstr", "gix-error", @@ -881,9 +868,9 @@ dependencies = [ [[package]] name = "gix-diff" -version = "0.66.0" +version = "0.67.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fee7d89a3c507491cdfc57a1d1e0e300214720b4f7709ebc253e422f99822bfc" +checksum = "1b1689ff5ddeee4acfb2a43e875a1072a76d208624b15a9065c938b39cb0da2a" dependencies = [ "bstr", "gix-hash", @@ -893,9 +880,9 @@ dependencies = [ [[package]] name = "gix-discover" -version = "0.54.0" +version = "0.55.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9f517766fa1101dfe2606c1a19a8ffa699099030995a9194445446dfe261bdf" +checksum = "ec32a30ec2934735599c2545f30067e80bd255fd3454b52a5d59bc02b52874a3" dependencies = [ "bstr", "dunce", @@ -908,18 +895,18 @@ dependencies = [ [[package]] name = "gix-error" -version = "0.2.5" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a9292309fd944e71b2a3c96d3c03a6feb8852db646febdde7cbb9f79cb5f329" +checksum = "e73b2794a6a746953c24d4ed51e4d408b83d8d599ed4e5c39a47736d75687cf0" dependencies = [ "bstr", ] [[package]] name = "gix-features" -version = "0.49.0" +version = "0.49.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20aa09e83a48dc02c5f5f08578aa79d3ab1bab4618b8c362f88684645a02bdcc" +checksum = "39c0e59d9d253dcccc38c3a46b91bfb9b46bd63eed54fe1a719e12194884d52a" dependencies = [ "bytes", "crc32fast", @@ -936,9 +923,9 @@ dependencies = [ [[package]] name = "gix-filter" -version = "0.33.0" +version = "0.34.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e7b5dbf524d97e839f642930c76d7f011c0791e7d11d8148989ac5af7c76aa8" +checksum = "9c30b28d957e2e6f1e1bbfbfd26b8e0bb0aab68d8a5e730fb4fd3049943575e3" dependencies = [ "bstr", "encoding_rs", @@ -957,9 +944,9 @@ dependencies = [ [[package]] name = "gix-fs" -version = "0.22.0" +version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "865cf13fcaf5455220546cb9607c416bd1be9a6caafd143655a362fdeab64e80" +checksum = "ebcfa9fd253f25350a3b21b3dd74034a446098e373c6123d4cee3519894f12ef" dependencies = [ "bstr", "gix-features", @@ -970,9 +957,9 @@ dependencies = [ [[package]] name = "gix-glob" -version = "0.27.0" +version = "0.27.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "421e92a711554fa5827d1b0599d3389acdd0f6729e97a8c5a57d79af1e50bf36" +checksum = "b417cf515fd8c91468b578071f76d6cba716f8a1eccd853906bff4908b2c1413" dependencies = [ "bitflags 2.13.1", "bstr", @@ -982,9 +969,9 @@ dependencies = [ [[package]] name = "gix-hash" -version = "0.26.0" +version = "0.26.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13adaa73415fd6c902310923f68d0b98e8cecf14b33ea58c02cc387cee56f54e" +checksum = "380b9c423a54f0821064b954b5a5ab25c660812f6a91da03c3f1cb4b10762aa5" dependencies = [ "faster-hex", "gix-features", @@ -1006,9 +993,9 @@ dependencies = [ [[package]] name = "gix-index" -version = "0.54.0" +version = "0.55.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5009c4e7e9f9b4cfaaab1153e49133eb04d79c015b5702d6c3d2ab94271a89c6" +checksum = "632e16cb48b0e88a747e106924cb2765194105d8070a7a961b0d080ed806c632" dependencies = [ "bitflags 2.13.1", "bstr", @@ -1043,11 +1030,34 @@ dependencies = [ "thiserror", ] +[[package]] +name = "gix-macros" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3836b4b051393464a753c5a08fe19c7ce0d8b77574a92bd558972941d4553cb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "gix-note" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9af6bbdb3f3c62b4920407c479af1cc0beb27fd75a762782d48baed53d330958" +dependencies = [ + "gix-error", + "gix-hash", + "gix-hashtable", + "gix-object", +] + [[package]] name = "gix-object" -version = "0.63.0" +version = "0.64.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e48c235e7f886eb819fc878af75be889333dd3c38bee02ed7af48ae2cf596c4" +checksum = "56fef799ca40cdeab4de5a3e74a696d8fa9b9c6264592646bf022e026f13ce2c" dependencies = [ "bstr", "gix-actor", @@ -1064,9 +1074,9 @@ dependencies = [ [[package]] name = "gix-odb" -version = "0.83.0" +version = "0.84.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8dd494ffb5037e62b8220109e894d2861ff2150a2cacbfccdba57ae1ebab2b96" +checksum = "cf45d195b71cb6363886e7b466821bf4dffd7aba1b6b814ff75488e0061d72a7" dependencies = [ "arc-swap", "gix-features", @@ -1086,9 +1096,9 @@ dependencies = [ [[package]] name = "gix-pack" -version = "0.73.0" +version = "0.74.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d5446127b269706e85998065267ddd2ccc3550179da6780b22fe496175ccb20" +checksum = "3d199d515cbdcf05f531f3859be9771fc12acac7b7fe0f5b581b2c1ab1c3a61e" dependencies = [ "clru", "gix-chunk", @@ -1106,9 +1116,9 @@ dependencies = [ [[package]] name = "gix-packetline" -version = "0.22.0" +version = "0.22.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3766025c72319c4accdd854a18e6f0dd176c8eb0f3bc8a60a7765be2b50cabf2" +checksum = "792bd92bea390087e6223b18d4d023decc36467de31e6b066b69b94beea1b9e2" dependencies = [ "bstr", "faster-hex", @@ -1118,9 +1128,9 @@ dependencies = [ [[package]] name = "gix-path" -version = "0.12.4" +version = "0.12.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "751d6bd162106f8c1e7e9aaccb5bbdd605267e91a930a17a4560c46e33a9100c" +checksum = "2b075e730586bba7341304d6fc1b4efc1d10cf64532622521c0e07f30e661046" dependencies = [ "bstr", "gix-trace", @@ -1130,15 +1140,15 @@ dependencies = [ [[package]] name = "gix-protocol" -version = "0.64.0" +version = "0.65.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dede40e89c1e90f548415f50636bb051f6d9c60f68b8b710bc07825722d19588" +checksum = "68878c37ae168b474c762d70adc8a8e0f9323ed0d80444a672e2eb561fa16691" dependencies = [ - "bisync", "bstr", "gix-date", "gix-features", "gix-hash", + "gix-macros", "gix-ref", "gix-shallow", "gix-transport", @@ -1149,9 +1159,9 @@ dependencies = [ [[package]] name = "gix-quote" -version = "0.7.2" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6e541fc33cc2b783b7979040d445a0c86a2eca747c8faea4ca84230d06ae6ef" +checksum = "f74795eb76eab8313063849f9cbd2bbcdc6e4692f71c1d7d0244ab11cd777329" dependencies = [ "bstr", "gix-error", @@ -1160,9 +1170,9 @@ dependencies = [ [[package]] name = "gix-ref" -version = "0.66.0" +version = "0.67.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eeb0c90a8f6202ceaaa22996cbf837c943ccb2d8af9ff3490f0758305e6b7883" +checksum = "8328387e7ab354e1dc3afb63e6b4d1866f82d7ce035222bc9d5baaf36bc88020" dependencies = [ "gix-actor", "gix-features", @@ -1180,15 +1190,12 @@ dependencies = [ [[package]] name = "gix-refspec" -version = "0.44.0" +version = "0.45.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7406282cc0259b51f6aee299ca3d31279a020530363152a2e6c96e8a7f7bbc83" +checksum = "7de280d46e8fd9e4d3e7e2ab4276b965e377ba68b2b9a8fce56371015e8bb059" dependencies = [ "bstr", - "gix-error", - "gix-glob", "gix-hash", - "gix-revision", "gix-validate", "smallvec", "thiserror", @@ -1196,9 +1203,9 @@ dependencies = [ [[package]] name = "gix-revision" -version = "0.48.0" +version = "0.49.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e55e09d4a1ecf2beecc8c09cafcad37979e805b31f588b0e957e191df5783681" +checksum = "4577b864c3e134697e91564553e43230c2e401bb1bcc51cfa998edca21c95078" dependencies = [ "bitflags 2.13.1", "bstr", @@ -1215,9 +1222,9 @@ dependencies = [ [[package]] name = "gix-revwalk" -version = "0.34.0" +version = "0.35.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "36c113c0a53294dc6280ffc06cbcc4f50f820397e97d6a00b429a44b8db26e29" +checksum = "248823eefe405e2c0754b74f6f43ab6faab68670cdbf2d6e114cb0471f766450" dependencies = [ "gix-commitgraph", "gix-date", @@ -1274,9 +1281,9 @@ checksum = "be3eb81d9dc914335923e50d52829c551feefd6a72d176c4130c546b67a60814" [[package]] name = "gix-transport" -version = "0.58.1" +version = "0.59.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f36d045b840f8aeee1a527e677eab1fbebfbbe94bf2e708fa81d0b4b742d5fc" +checksum = "b1295b6ada647b5b4ac6eb123d95a82ee80aef11a7d9db6ea9cb38dcbd411362" dependencies = [ "bstr", "gix-command", @@ -1291,9 +1298,9 @@ dependencies = [ [[package]] name = "gix-traverse" -version = "0.60.0" +version = "0.61.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "008c5cd879e46e86b5c2469e633611978b18775d53d05668d691bc13088bd409" +checksum = "9af3503668739f4de5dba57fe15cfd9adc443b08bcbbf195e5de16b648872245" dependencies = [ "bitflags 2.13.1", "gix-commitgraph", @@ -1308,9 +1315,9 @@ dependencies = [ [[package]] name = "gix-url" -version = "0.37.1" +version = "0.38.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "31bdfc93aa880cda3272718a5879ce3aa7723fa13514320dd6608151607afe72" +checksum = "5bda80ccb4bc81fb9fb07a975916eb0c7a889bcbcb0c8a239c3f82a9cc2abdda" dependencies = [ "bstr", "gix-path", @@ -1321,9 +1328,9 @@ dependencies = [ [[package]] name = "gix-utils" -version = "0.3.5" +version = "0.3.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1795bd2a970ca8b2185318c2abb97d955c71992f1cf28de73ad3b593a9f3ce8" +checksum = "0da1c46491b49458a446cc76f0085860f8164c2290742e0aa8c653ce67240a97" dependencies = [ "bstr", "fastrand", @@ -1333,18 +1340,18 @@ dependencies = [ [[package]] name = "gix-validate" -version = "0.11.3" +version = "0.11.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a034e84d1e04e1b1f20f51f12491da230b6ac8b925d0c8e1b89bcd87a7c5ccc" +checksum = "4dae8780f63ed8a803b8bdabbd7aa5f5c5d74592c8b50eed875c1bb4f6545a6a" dependencies = [ "bstr", ] [[package]] name = "gix-worktree-stream" -version = "0.35.0" +version = "0.36.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b088c8724e7be120c4798dd86925cf05332c9d356a463542578600c50c7a549" +checksum = "de5ec0ccbab39c9994656ea031ded8ffd90b370a3cc8e360fbe3c4ebe7c64964" dependencies = [ "gix-attributes", "gix-error", diff --git a/Cargo.toml b/Cargo.toml index 0db66bc97..6ab8adfda 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -152,7 +152,7 @@ flate2 = { version = "1", default-features = false, features = ["rust_backend"] # zlib-rs now — its own `max-performance` is a bare alias for # `max-performance-safe` — so there is no C backend to select away from and no # `links` key to trip `macos-link-check` rule 1. -gix = { version = "0.86", default-features = false, features = [ +gix = { version = "0.87", default-features = false, features = [ "revision", "sha1", "sha256", @@ -167,7 +167,7 @@ gix = { version = "0.86", default-features = false, features = [ # whose nine `dep:` entries are the external-diff-driver and worktree-materialising # machinery described below. `pub mod tree` is ungated, which is why the tree half # costs nothing and the blob half is bought separately and narrowly. -gix-diff = { version = "0.66", default-features = false } +gix-diff = { version = "0.67", default-features = false } # The diff algorithm behind the in-process patch identity (CLOUD-739). Hunks in, # nothing else: it takes two byte slices and returns the edit script. No # filesystem, no process, no threads, no global state. diff --git a/fuzz/Cargo.lock b/fuzz/Cargo.lock index 6ac367891..e899b4c10 100644 --- a/fuzz/Cargo.lock +++ b/fuzz/Cargo.lock @@ -108,7 +108,7 @@ checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] name = "batten" -version = "0.0.119" +version = "0.0.120" dependencies = [ "anyhow", "clap", @@ -151,21 +151,6 @@ dependencies = [ "serde_json", ] -[[package]] -name = "bisync" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5020822f6d6f23196ccaf55e228db36f9de1cf788052b37992e17cbc96ec41a7" -dependencies = [ - "bisync_macros", -] - -[[package]] -name = "bisync_macros" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d21f40d350a700f6aa107e45fb26448cf489d34794b2ba4522181dc9f1173af6" - [[package]] name = "bitflags" version = "1.3.2" @@ -617,9 +602,9 @@ dependencies = [ [[package]] name = "gix" -version = "0.86.0" +version = "0.87.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb3790fd8981cba7949f1ba924ef865d902df731627bc5998d14164063892fce" +checksum = "fdefc1465d8631807deaf504dacdeff628b978de515653b47976ca7c28ab2a7a" dependencies = [ "gix-actor", "gix-commitgraph", @@ -635,11 +620,13 @@ dependencies = [ "gix-hashtable", "gix-index", "gix-lock", + "gix-note", "gix-object", "gix-odb", "gix-pack", "gix-path", "gix-protocol", + "gix-quote", "gix-ref", "gix-refspec", "gix-revision", @@ -661,9 +648,9 @@ dependencies = [ [[package]] name = "gix-actor" -version = "0.41.2" +version = "0.42.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33f9308ad6fd35b2a865cbe4117ac61b2be59e4a9ef1621c7a9794f7c8e52c5b" +checksum = "e37efa99929ac62f980fb1a7dcd09dea85b3aa6ead6ba0498362add3f4e698de" dependencies = [ "bstr", "gix-date", @@ -672,9 +659,9 @@ dependencies = [ [[package]] name = "gix-attributes" -version = "0.34.0" +version = "0.35.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c31c593692ebdc1e38858d9a2b56f6a594c501e24a38971fe6685571f5a07be0" +checksum = "76b83782ac69ae28921a6e8fbcf2e24069613f1518030b97ae622b079abffa54" dependencies = [ "bstr", "gix-features", @@ -689,27 +676,27 @@ dependencies = [ [[package]] name = "gix-bitmap" -version = "0.3.3" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7cd1d118d0f5d88b96e6f6e13b566475fef4797ead4a02c26fed36c1375066f7" +checksum = "f17013c7ef5cb95ebb4bf4978201e6a8a42ffea9d666fd5388f1b7e742307d80" dependencies = [ "gix-error", ] [[package]] name = "gix-chunk" -version = "0.7.3" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2a871e5cab12ba568845714473505deefffb3c04eb47f4708ce344cd459c1cc" +checksum = "00e32f938b3745ac93d73bc7490b6a15a661b2fc81973bfe90e275cc53c908e1" dependencies = [ "gix-error", ] [[package]] name = "gix-command" -version = "0.9.2" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf4363accdf6ef7ba861871d2d521ab7418a04aaaed919fadb022af71d379b12" +checksum = "0d96b8e8423ce2665232bda1b682a62541f07ab801ffad69a034ef962bed3244" dependencies = [ "bstr", "gix-path", @@ -720,9 +707,9 @@ dependencies = [ [[package]] name = "gix-commitgraph" -version = "0.38.0" +version = "0.39.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2cd7f054ae2727223fe46dd39c012f066b12f532962d336d29ee193261787da" +checksum = "6b93c9fb1f5be01bba54a7a3b7455d359efc68555539c75ef74b8021bb6db497" dependencies = [ "bstr", "gix-chunk", @@ -734,9 +721,9 @@ dependencies = [ [[package]] name = "gix-config" -version = "0.59.0" +version = "0.60.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "103d11bef95c467577ecfa8b7b86a22e65af3507b2c9bfa3809a4afbae7df301" +checksum = "f973c28c0a4871a7926fc90c8377d4458fd6cbb19692f56582b4d2022313ae90" dependencies = [ "bstr", "gix-config-value", @@ -766,9 +753,9 @@ dependencies = [ [[package]] name = "gix-date" -version = "0.15.6" +version = "0.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e47b9e8cdc688296609b706428de570f88b1e0eed7156dde7b4a89d26fa4567" +checksum = "e63d9aa18f29facd571c8953e66224ee0075b9e16622024794555ed4acceea85" dependencies = [ "bstr", "gix-error", @@ -778,9 +765,9 @@ dependencies = [ [[package]] name = "gix-diff" -version = "0.66.0" +version = "0.67.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fee7d89a3c507491cdfc57a1d1e0e300214720b4f7709ebc253e422f99822bfc" +checksum = "1b1689ff5ddeee4acfb2a43e875a1072a76d208624b15a9065c938b39cb0da2a" dependencies = [ "bstr", "gix-hash", @@ -790,9 +777,9 @@ dependencies = [ [[package]] name = "gix-discover" -version = "0.54.0" +version = "0.55.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9f517766fa1101dfe2606c1a19a8ffa699099030995a9194445446dfe261bdf" +checksum = "ec32a30ec2934735599c2545f30067e80bd255fd3454b52a5d59bc02b52874a3" dependencies = [ "bstr", "dunce", @@ -805,18 +792,18 @@ dependencies = [ [[package]] name = "gix-error" -version = "0.2.5" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a9292309fd944e71b2a3c96d3c03a6feb8852db646febdde7cbb9f79cb5f329" +checksum = "e73b2794a6a746953c24d4ed51e4d408b83d8d599ed4e5c39a47736d75687cf0" dependencies = [ "bstr", ] [[package]] name = "gix-features" -version = "0.49.0" +version = "0.49.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20aa09e83a48dc02c5f5f08578aa79d3ab1bab4618b8c362f88684645a02bdcc" +checksum = "39c0e59d9d253dcccc38c3a46b91bfb9b46bd63eed54fe1a719e12194884d52a" dependencies = [ "bytes", "crc32fast", @@ -833,9 +820,9 @@ dependencies = [ [[package]] name = "gix-filter" -version = "0.33.0" +version = "0.34.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e7b5dbf524d97e839f642930c76d7f011c0791e7d11d8148989ac5af7c76aa8" +checksum = "9c30b28d957e2e6f1e1bbfbfd26b8e0bb0aab68d8a5e730fb4fd3049943575e3" dependencies = [ "bstr", "encoding_rs", @@ -854,9 +841,9 @@ dependencies = [ [[package]] name = "gix-fs" -version = "0.22.0" +version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "865cf13fcaf5455220546cb9607c416bd1be9a6caafd143655a362fdeab64e80" +checksum = "ebcfa9fd253f25350a3b21b3dd74034a446098e373c6123d4cee3519894f12ef" dependencies = [ "bstr", "gix-features", @@ -867,9 +854,9 @@ dependencies = [ [[package]] name = "gix-glob" -version = "0.27.0" +version = "0.27.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "421e92a711554fa5827d1b0599d3389acdd0f6729e97a8c5a57d79af1e50bf36" +checksum = "b417cf515fd8c91468b578071f76d6cba716f8a1eccd853906bff4908b2c1413" dependencies = [ "bitflags 2.13.1", "bstr", @@ -879,9 +866,9 @@ dependencies = [ [[package]] name = "gix-hash" -version = "0.26.0" +version = "0.26.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13adaa73415fd6c902310923f68d0b98e8cecf14b33ea58c02cc387cee56f54e" +checksum = "380b9c423a54f0821064b954b5a5ab25c660812f6a91da03c3f1cb4b10762aa5" dependencies = [ "faster-hex", "gix-features", @@ -903,9 +890,9 @@ dependencies = [ [[package]] name = "gix-index" -version = "0.54.0" +version = "0.55.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5009c4e7e9f9b4cfaaab1153e49133eb04d79c015b5702d6c3d2ab94271a89c6" +checksum = "632e16cb48b0e88a747e106924cb2765194105d8070a7a961b0d080ed806c632" dependencies = [ "bitflags 2.13.1", "bstr", @@ -940,11 +927,34 @@ dependencies = [ "thiserror", ] +[[package]] +name = "gix-macros" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3836b4b051393464a753c5a08fe19c7ce0d8b77574a92bd558972941d4553cb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "gix-note" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9af6bbdb3f3c62b4920407c479af1cc0beb27fd75a762782d48baed53d330958" +dependencies = [ + "gix-error", + "gix-hash", + "gix-hashtable", + "gix-object", +] + [[package]] name = "gix-object" -version = "0.63.0" +version = "0.64.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e48c235e7f886eb819fc878af75be889333dd3c38bee02ed7af48ae2cf596c4" +checksum = "56fef799ca40cdeab4de5a3e74a696d8fa9b9c6264592646bf022e026f13ce2c" dependencies = [ "bstr", "gix-actor", @@ -961,9 +971,9 @@ dependencies = [ [[package]] name = "gix-odb" -version = "0.83.0" +version = "0.84.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8dd494ffb5037e62b8220109e894d2861ff2150a2cacbfccdba57ae1ebab2b96" +checksum = "cf45d195b71cb6363886e7b466821bf4dffd7aba1b6b814ff75488e0061d72a7" dependencies = [ "arc-swap", "gix-features", @@ -983,9 +993,9 @@ dependencies = [ [[package]] name = "gix-pack" -version = "0.73.0" +version = "0.74.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d5446127b269706e85998065267ddd2ccc3550179da6780b22fe496175ccb20" +checksum = "3d199d515cbdcf05f531f3859be9771fc12acac7b7fe0f5b581b2c1ab1c3a61e" dependencies = [ "clru", "gix-chunk", @@ -1003,9 +1013,9 @@ dependencies = [ [[package]] name = "gix-packetline" -version = "0.22.0" +version = "0.22.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3766025c72319c4accdd854a18e6f0dd176c8eb0f3bc8a60a7765be2b50cabf2" +checksum = "792bd92bea390087e6223b18d4d023decc36467de31e6b066b69b94beea1b9e2" dependencies = [ "bstr", "faster-hex", @@ -1015,9 +1025,9 @@ dependencies = [ [[package]] name = "gix-path" -version = "0.12.4" +version = "0.12.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "751d6bd162106f8c1e7e9aaccb5bbdd605267e91a930a17a4560c46e33a9100c" +checksum = "2b075e730586bba7341304d6fc1b4efc1d10cf64532622521c0e07f30e661046" dependencies = [ "bstr", "gix-trace", @@ -1027,15 +1037,15 @@ dependencies = [ [[package]] name = "gix-protocol" -version = "0.64.0" +version = "0.65.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dede40e89c1e90f548415f50636bb051f6d9c60f68b8b710bc07825722d19588" +checksum = "68878c37ae168b474c762d70adc8a8e0f9323ed0d80444a672e2eb561fa16691" dependencies = [ - "bisync", "bstr", "gix-date", "gix-features", "gix-hash", + "gix-macros", "gix-ref", "gix-shallow", "gix-transport", @@ -1046,9 +1056,9 @@ dependencies = [ [[package]] name = "gix-quote" -version = "0.7.2" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6e541fc33cc2b783b7979040d445a0c86a2eca747c8faea4ca84230d06ae6ef" +checksum = "f74795eb76eab8313063849f9cbd2bbcdc6e4692f71c1d7d0244ab11cd777329" dependencies = [ "bstr", "gix-error", @@ -1057,9 +1067,9 @@ dependencies = [ [[package]] name = "gix-ref" -version = "0.66.0" +version = "0.67.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eeb0c90a8f6202ceaaa22996cbf837c943ccb2d8af9ff3490f0758305e6b7883" +checksum = "8328387e7ab354e1dc3afb63e6b4d1866f82d7ce035222bc9d5baaf36bc88020" dependencies = [ "gix-actor", "gix-features", @@ -1077,15 +1087,12 @@ dependencies = [ [[package]] name = "gix-refspec" -version = "0.44.0" +version = "0.45.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7406282cc0259b51f6aee299ca3d31279a020530363152a2e6c96e8a7f7bbc83" +checksum = "7de280d46e8fd9e4d3e7e2ab4276b965e377ba68b2b9a8fce56371015e8bb059" dependencies = [ "bstr", - "gix-error", - "gix-glob", "gix-hash", - "gix-revision", "gix-validate", "smallvec", "thiserror", @@ -1093,9 +1100,9 @@ dependencies = [ [[package]] name = "gix-revision" -version = "0.48.0" +version = "0.49.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e55e09d4a1ecf2beecc8c09cafcad37979e805b31f588b0e957e191df5783681" +checksum = "4577b864c3e134697e91564553e43230c2e401bb1bcc51cfa998edca21c95078" dependencies = [ "bitflags 2.13.1", "bstr", @@ -1112,9 +1119,9 @@ dependencies = [ [[package]] name = "gix-revwalk" -version = "0.34.0" +version = "0.35.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "36c113c0a53294dc6280ffc06cbcc4f50f820397e97d6a00b429a44b8db26e29" +checksum = "248823eefe405e2c0754b74f6f43ab6faab68670cdbf2d6e114cb0471f766450" dependencies = [ "gix-commitgraph", "gix-date", @@ -1171,9 +1178,9 @@ checksum = "be3eb81d9dc914335923e50d52829c551feefd6a72d176c4130c546b67a60814" [[package]] name = "gix-transport" -version = "0.58.1" +version = "0.59.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f36d045b840f8aeee1a527e677eab1fbebfbbe94bf2e708fa81d0b4b742d5fc" +checksum = "b1295b6ada647b5b4ac6eb123d95a82ee80aef11a7d9db6ea9cb38dcbd411362" dependencies = [ "bstr", "gix-command", @@ -1188,9 +1195,9 @@ dependencies = [ [[package]] name = "gix-traverse" -version = "0.60.0" +version = "0.61.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "008c5cd879e46e86b5c2469e633611978b18775d53d05668d691bc13088bd409" +checksum = "9af3503668739f4de5dba57fe15cfd9adc443b08bcbbf195e5de16b648872245" dependencies = [ "bitflags 2.13.1", "gix-commitgraph", @@ -1205,9 +1212,9 @@ dependencies = [ [[package]] name = "gix-url" -version = "0.37.1" +version = "0.38.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "31bdfc93aa880cda3272718a5879ce3aa7723fa13514320dd6608151607afe72" +checksum = "5bda80ccb4bc81fb9fb07a975916eb0c7a889bcbcb0c8a239c3f82a9cc2abdda" dependencies = [ "bstr", "gix-path", @@ -1218,9 +1225,9 @@ dependencies = [ [[package]] name = "gix-utils" -version = "0.3.5" +version = "0.3.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1795bd2a970ca8b2185318c2abb97d955c71992f1cf28de73ad3b593a9f3ce8" +checksum = "0da1c46491b49458a446cc76f0085860f8164c2290742e0aa8c653ce67240a97" dependencies = [ "bstr", "fastrand", @@ -1230,18 +1237,18 @@ dependencies = [ [[package]] name = "gix-validate" -version = "0.11.3" +version = "0.11.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a034e84d1e04e1b1f20f51f12491da230b6ac8b925d0c8e1b89bcd87a7c5ccc" +checksum = "4dae8780f63ed8a803b8bdabbd7aa5f5c5d74592c8b50eed875c1bb4f6545a6a" dependencies = [ "bstr", ] [[package]] name = "gix-worktree-stream" -version = "0.35.0" +version = "0.36.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b088c8724e7be120c4798dd86925cf05332c9d356a463542578600c50c7a549" +checksum = "de5ec0ccbab39c9994656ea031ded8ffd90b370a3cc8e360fbe3c4ebe7c64964" dependencies = [ "gix-attributes", "gix-error", From 92725b23af0fb6de08881e1b58eab9b855c28a23 Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Thu, 27 Aug 2026 06:31:49 +0000 Subject: [PATCH 30/38] feat(semver): the compatibility gate becomes an adapter, with a baseline the lock can build MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The predicate half of retiring `mise-tasks/semver.sh`. The verb and the retirement ledger follow; this is the module they will call. WHY IT MOVED, and it is CLOUD-1059 working on its author. `cargo-semver-checks` could no longer resolve the baseline, the repair meant editing `semver.sh`, and an edit is `V-SHELL-RULE-EDITED` — a verdict that declares no override route by design. Maintenance of a shell-tier rule is completed by migrating it, so the migration is the repair rather than a detour around one. WHAT THE FALLBACK IS FOR, measured rather than argued. `cargo-semver-checks` generates a scratch crate for the baseline and runs `cargo update` in it, so it discards `Cargo.lock` by construction — the only gate here whose verdict is a function of the registry index at the moment it runs rather than of the tree. On 2026-08-26 it passed in CI at 19:18:19Z, `bisync 0.3.0` was yanked at 19:25:45Z, and every commit from v0.0.89 on became unresolvable seven minutes later. Nothing was unbuildable: `origin/main`'s own lock pins `bisync 0.3.0`, and a yank does not invalidate an existing lock. So `baseline_rustdoc` builds the baseline from the lock it committed and hands it over through `--baseline-rustdoc`. That applies MORE of the gate than the rev route, not less — the comparison is against the true baseline either way, and this one survives a registry that moved underneath it. Proven end to end before any of this was written: 223 checks graded, 217 pass, 5 fail, and the 5 are this branch's own declared breaks. `--document-private-items` is not optional and was found by measurement: without it the baseline reported `constructible_struct_adds_private_field` spuriously, because that lint reasons about fields a public API cannot see (216 pass -> 217). The rev route stays PRIMARY. `Route` is reported so a green never hides which baseline produced it, and the lock route is what fires when the report says the registry could not resolve — read from the report rather than the exit code, because the tool returns the same code for every kind of broken run. Two bugs caught while writing it, both recorded because both would have been silent: `declared_break` collapsed the sha and the subject into one tuple field, so a refusal would have named a commit message where a reader expected a commit; and `baseline_rustdoc` checked out `HEAD` rather than the baseline, which would have compared the change to itself and passed unconditionally. Refs: CLOUD-1050, CLOUD-1059 --- .serena/memories/core.md | 12 + crates/batten/src/lib.rs | 4 + crates/batten/src/semver.rs | 513 ++++++++++++++++++++++++++++++++++++ 3 files changed, 529 insertions(+) create mode 100644 crates/batten/src/semver.rs diff --git a/.serena/memories/core.md b/.serena/memories/core.md index 94b9879f8..296022c93 100644 --- a/.serena/memories/core.md +++ b/.serena/memories/core.md @@ -1546,6 +1546,18 @@ judge_fingerprint`, its own domain tag), so a caller can reference content it field a matched byte can occupy, and byte-stability is a property of the request SET rather than of the schedule, which is what makes it safe under CLOUD-850's concurrent acquisition. +- `semver.rs` — the API-compatibility gate as a delegated-analyser adapter + (CLOUD-1050), ported off `mise-tasks/semver.sh` when CLOUD-1059 made editing a + shell rule refusable. Same shape as `symbols.rs` and `secrets.rs` — a pinned + binary, its flags beside the parser, an exit status reconciled against what the + output says — plus one thing neither needed: **a baseline the committed lock + can build**. `cargo-semver-checks` runs `cargo update` in a scratch crate, so it + discards `Cargo.lock` and its verdict is a function of the registry index at the + moment it runs. Measured 2026-08-26: green in CI at 19:18:19Z, `bisync 0.3.0` + yanked at 19:25:45Z, every commit from v0.0.89 on unresolvable seven minutes + later. `baseline_rustdoc` builds it with `--locked` instead and hands it over + through `--baseline-rustdoc` — MORE of the gate applied, not less. The rev route + stays primary and `Route` is reported, so a green never hides its baseline. - `symbols.rs` — the first `Cost::Effect` fact's acquisition (CLOUD-760). Where a **name** resolves, asked of the compiler rather than of the text: the census `.claude/rules/scanning.md` records three answers for — `grep` 14, a syntax diff --git a/crates/batten/src/lib.rs b/crates/batten/src/lib.rs index 7e843651c..4e542ad3c 100644 --- a/crates/batten/src/lib.rs +++ b/crates/batten/src/lib.rs @@ -64,6 +64,10 @@ pub mod resolve; pub mod rules; pub mod secrets; pub mod selfwrite; +/// The API-compatibility gate as a delegated-analyser adapter (CLOUD-1050), +/// carrying a baseline the committed lock can build when the registry can no +/// longer resolve one. +pub mod semver; pub mod session; pub mod severity; pub mod sink; diff --git a/crates/batten/src/semver.rs b/crates/batten/src/semver.rs new file mode 100644 index 000000000..39b7667a3 --- /dev/null +++ b/crates/batten/src/semver.rs @@ -0,0 +1,513 @@ +//! The API-compatibility gate, as a delegated-analyser adapter (CLOUD-1050). +//! +//! Ported from `mise-tasks/semver.sh` under CLOUD-1059, which is the rule +//! working on its author: repairing that gate meant editing it, an edit is +//! `V-SHELL-RULE-EDITED`, and that verdict declares no override route. So the +//! maintenance was completed by migrating it, which is the campaign's whole +//! claim. +//! +//! # Generalised from `symbols.rs`, which generalised from `secrets.rs` +//! +//! The SHAPE carries across — a pinned binary, its flags pinned beside the +//! parser, and an exit status reconciled against what the output says — along +//! with the invariant those two state: **clean is never inferred from a stream +//! that failed to parse.** Here that is the vacuous-run refusal below. +//! +//! What is new is the baseline, and it is the reason this module exists at all. +//! +//! # The baseline can stop resolving with no commit, and did +//! +//! `cargo-semver-checks` generates a scratch crate for the baseline and runs +//! `cargo update` in it, so it **discards `Cargo.lock` by construction**. Its +//! verdict is therefore a function of the registry index at the moment it runs, +//! not of the tree — the only gate in this repository with that property, every +//! other one being pinned. +//! +//! Measured on 2026-08-26: the gate passed in CI at 19:18:19Z, `bisync 0.3.0` +//! was yanked at 19:25:45Z, and every commit from v0.0.89 on became unresolvable +//! seven minutes later — `gix 0.86` reaches it through `gix-protocol ^0.64.0`. +//! Nothing about the baseline was unbuildable: `origin/main`'s own `Cargo.lock` +//! pins `bisync 0.3.0`, and a yank does not invalidate an existing lock. Only +//! the re-resolve could not be satisfied. +//! +//! So [`baseline_rustdoc`] builds the baseline the way the repository's own +//! discipline says to — from the lock it committed — and hands the result over +//! through `--baseline-rustdoc`. That applies MORE of the gate than the rev +//! route, not less: the comparison is against the true baseline either way, and +//! this one survives a registry that has moved underneath it. +//! +//! **It is a FALLBACK and must stay one.** The rev route is the tool's own +//! well-tested path; taking it away would leave this module's worktree +//! orchestration as the only thing anyone exercises. [`Route`] is what the +//! caller reports, so a green never hides which baseline produced it. + +use std::path::{Path, PathBuf}; + +use crate::exit::ExitCode; + +/// The analyser this module delegates to, pinned by `mise.toml`. +const ANALYSER: &str = "cargo"; + +/// What the tool prints when it graded nothing. A run that graded nothing has +/// not answered, and reading it as a pass is how this gate would quietly die — +/// measured, when an inherited `CARGO_TERM_COLOR=always` put escape sequences +/// between the anchor and the word and the refusal below never fired. +const VACUOUS: &str = " 0 checks:"; + +/// What a `cargo update` that could not resolve says. Either spelling is the +/// registry refusing, never an API verdict. +const UNRESOLVABLE: [&str; 2] = ["is yanked", "failed to select a version"]; + +/// Which baseline produced the verdict. +/// +/// Reported rather than inferred: the two routes answer the same question and a +/// reader who cannot tell them apart cannot tell a normal run from one that +/// worked around a moved registry. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Route { + /// `--baseline-rev`, the tool's own path. + Rev, + /// `--baseline-rustdoc`, built from the baseline's committed lock. + Lock, +} + +impl Route { + /// The stable token (§6). + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Route::Rev => "rev", + Route::Lock => "lock", + } + } +} + +/// One comparison's outcome, before it is reconciled against the declarations. +#[derive(Debug)] +pub struct Compared { + /// The tool's own exit code: `0` compatible, `100` a bump larger than + /// claimed, anything else a run that did not complete. + pub code: Option, + /// The report, read for the vacuous-run refusal and the failing lint ids. + pub report: String, + /// Which baseline answered. + pub route: Route, +} + +impl Compared { + /// The failing lint ids, sorted and deduplicated. + /// + /// Pointer, never payload (non-negotiable rule 4): the ids the tool named, + /// never the rustdoc it read them from. + #[must_use] + pub fn lints(&self) -> Vec { + let mut found: Vec = self + .report + .lines() + .filter_map(|line| line.strip_prefix("--- failure ")) + .filter_map(|rest| rest.split_whitespace().next()) + .map(|id| id.trim_end_matches(':').to_owned()) + .collect(); + found.sort_unstable(); + found.dedup(); + found + } + + /// Whether the run graded nothing. + #[must_use] + pub fn graded_nothing(&self) -> bool { + self.report + .lines() + .any(|line| line.trim_start().starts_with("Checked") && line.contains(VACUOUS)) + } + + /// Whether the run failed because the registry could not satisfy a resolve. + /// + /// This is the could-not-look that the lock route answers, and it is read + /// from the report rather than from the exit code because the tool reports + /// the same code for every kind of broken run. + #[must_use] + pub fn unresolvable(&self) -> bool { + UNRESOLVABLE.iter().any(|tell| self.report.contains(tell)) + } +} + +/// Run the comparison against a git rev — the tool's own path. +#[must_use] +#[expect( + clippy::disallowed_types, + reason = "stays: this module IS the cargo-semver-checks adapter, which is what `policy/spawn-adapters.rego` places it for. The delegated tool is the whole mechanism (CLOUD-1050)" +)] +pub fn against_rev( + root: &Path, + toolchain: &str, + package: &str, + baseline: &str, + release_type: &str, +) -> Option { + let output = std::process::Command::new(ANALYSER) + .arg(format!("+{toolchain}")) + .args(["semver-checks", "check-release"]) + .args(["--package", package]) + .args(["--baseline-rev", baseline]) + .args(["--release-type", release_type]) + // Overriding `mise.toml [env]`'s `always`, and load-bearing rather than + // cosmetic: the report below is PARSED, and a gate that parses colour is + // CLOUD-199's defect — an anchored pattern that can never match because + // escape sequences sit between the anchor and the word. + .env("CARGO_TERM_COLOR", "never") + .current_dir(root) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .output() + .ok()?; + Some(Compared { + code: output.status.code(), + report: merged(&output), + route: Route::Rev, + }) +} + +/// Run the comparison against a rustdoc JSON built from the baseline's own lock. +#[must_use] +#[expect( + clippy::disallowed_types, + reason = "stays: the adapter's second invocation, for the same reason as the first — the fallback exists because the tool's own baseline generation discards the lock (CLOUD-1050)" +)] +pub fn against_rustdoc( + root: &Path, + toolchain: &str, + package: &str, + rustdoc: &Path, + release_type: &str, +) -> Option { + let output = std::process::Command::new(ANALYSER) + .arg(format!("+{toolchain}")) + .args(["semver-checks", "check-release"]) + .args(["--package", package]) + .arg("--baseline-rustdoc") + .arg(rustdoc) + .args(["--release-type", release_type]) + .env("CARGO_TERM_COLOR", "never") + .current_dir(root) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .output() + .ok()?; + Some(Compared { + code: output.status.code(), + report: merged(&output), + route: Route::Lock, + }) +} + +/// Build the baseline's rustdoc JSON from the lock it committed. +/// +/// # Why every flag here is load-bearing +/// +/// * `--locked` is the whole point: the baseline's `Cargo.lock` is what a yank +/// cannot invalidate, and re-resolving is exactly what the rev route does that +/// this one must not. +/// * `RUSTC_BOOTSTRAP=1` is how rustdoc JSON is emitted on a pinned stable +/// toolchain. It is the same trick `cargo-semver-checks` performs internally, +/// so this is not a lower standard than the tool's own generation. +/// * `--document-private-items` is not optional and was found by measurement: a +/// baseline without it reported `constructible_struct_adds_private_field` +/// spuriously, because that lint reasons about fields a public API cannot see. +/// * Its own `CARGO_TARGET_DIR` under the worktree, because the main one is +/// already held by whatever else `verify` is running — the same reasoning +/// `perf-pair` records for its two arms. +/// +/// `None` is could-not-look, and every failure collapses into it: no worktree, +/// no build, no JSON where one was expected. A caller must not read that as a +/// clean comparison. +#[must_use] +#[expect( + clippy::disallowed_types, + reason = "stays: building the baseline is this adapter's own work, and the git worktree plus the doc build are what the lock route IS (CLOUD-1050)" +)] +pub fn baseline_rustdoc( + root: &Path, + toolchain: &str, + package: &str, + baseline: &str, + at: &Path, +) -> Option { + let worktree = at.join("tree"); + let target = at.join("target"); + let added = std::process::Command::new("git") + .args(["worktree", "add", "--detach"]) + .arg(&worktree) + // THE BASELINE, never `HEAD`. A worktree at the branch's own tip would + // compare the change to itself and pass unconditionally, which is the + // vacuous shape this whole gate exists against. + .arg(baseline) + .current_dir(root) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .ok()?; + if !added.success() { + return None; + } + let built = std::process::Command::new(ANALYSER) + .arg(format!("+{toolchain}")) + .args([ + "doc", + "--locked", + "--no-deps", + "--lib", + "--package", + package, + ]) + .env("RUSTC_BOOTSTRAP", "1") + .env( + "RUSTDOCFLAGS", + "-Z unstable-options --output-format json --document-private-items", + ) + .env("CARGO_TARGET_DIR", &target) + .env("CARGO_TERM_COLOR", "never") + .current_dir(&worktree) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .ok()?; + if !built.success() { + return None; + } + let json = target.join("doc").join(format!("{package}.json")); + json.is_file().then_some(json) +} + +/// Whether any commit in `base..head` DECLARES a breaking change. +/// +/// Conventional Commits spells it two ways and both count: a `!` before the +/// colon, or a `BREAKING CHANGE:` footer. The range is the branch's own +/// commits, so a declaration that already landed on the baseline does not +/// license a break this branch introduced. +#[must_use] +pub fn declared_break(commits: &[Commit]) -> Option { + commits + .iter() + .find(|commit| declares(&commit.subject, &commit.body)) + .map(|commit| commit.sha.clone()) +} + +/// One commit, as the caller reads it out of git. +/// +/// Three fields rather than two, because the sha is the POINTER a refusal +/// carries and the subject is what the predicate reads — collapsing them made +/// the first draft of this module report a subject where a reader expected a +/// sha. +#[derive(Debug, Clone)] +pub struct Commit { + /// The commit this is, reported as its short form. + pub sha: String, + /// `%s`. + pub subject: String, + /// `%B`. + pub body: String, +} + +/// The two Conventional Commits spellings, over one commit. +fn declares(subject: &str, body: &str) -> bool { + body.lines() + .any(|line| line.starts_with("BREAKING CHANGE:") || line.starts_with("BREAKING-CHANGE:")) + || bang_before_colon(subject) +} + +/// A `type(scope)!:` or `type!:` prefix. +/// +/// Hand-scanned rather than a regex for `pattern.rs`'s reason: the shape is +/// three tokens and a literal, and a pattern here would be a second spelling of +/// something `commit.rs` already owns. +fn bang_before_colon(subject: &str) -> bool { + let Some(colon) = subject.find(':') else { + return false; + }; + let head = &subject[..colon]; + let Some(head) = head.strip_suffix('!') else { + return false; + }; + let name = head.split_once('(').map_or(head, |(before, _)| before); + !name.is_empty() && name.chars().all(|character| character.is_ascii_lowercase()) +} + +/// Both streams, because the tool splits its report across them and the +/// predicates below read one document. +fn merged(output: &std::process::Output) -> String { + let mut text = String::from_utf8_lossy(&output.stdout).into_owned(); + text.push_str(&String::from_utf8_lossy(&output.stderr)); + text +} + +/// The verdict a caller renders, and the one contract (§7). +#[derive(Debug, PartialEq, Eq)] +pub enum Verdict { + /// The delta is compatible with the claim. + Compatible, + /// It breaks the API and a commit declares it. + Declared(String), + /// It breaks the API and nothing declares it. + Undeclared, + /// The comparison did not complete. Never a pass. + CouldNotLook, +} + +impl Verdict { + /// The exit code this verdict maps to. + /// + /// `1` is an undeclared break and `2` is could-not-look, matching every + /// other `*-check` program so a caller can tell "this branch breaks the + /// contract" from "this gate never ran". + #[must_use] + pub const fn code(&self) -> ExitCode { + match self { + Verdict::Compatible | Verdict::Declared(_) => ExitCode::Success, + Verdict::Undeclared => ExitCode::Violation, + Verdict::CouldNotLook => ExitCode::Usage, + } + } +} + +/// Reconcile a completed comparison against the branch's declarations. +/// +/// `100` is cargo-semver-checks' own "required bump is larger than claimed"; +/// anything that is neither `0` nor `100` is a broken run, which is +/// could-not-look rather than a verdict. +#[must_use] +pub fn reconcile(compared: &Compared, commits: &[Commit]) -> Verdict { + if compared.graded_nothing() { + return Verdict::CouldNotLook; + } + match compared.code { + Some(0) => Verdict::Compatible, + Some(100) => declared_break(commits).map_or(Verdict::Undeclared, Verdict::Declared), + _ => Verdict::CouldNotLook, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn report(body: &str) -> Compared { + Compared { + code: Some(100), + report: body.to_owned(), + route: Route::Rev, + } + } + + #[test] + fn a_graded_zero_run_is_never_a_pass() { + let compared = report(" Checked [ 0.1s] 0 checks: 0 pass, 0 fail\n"); + assert!(compared.graded_nothing()); + assert_eq!(reconcile(&compared, &[]), Verdict::CouldNotLook); + } + + #[test] + fn a_run_that_graded_something_is_not_vacuous() { + // ANTI-VACUITY for the case above: the refusal must key on the COUNT + // rather than on the word, or every run reads as vacuous. + let compared = report(" Checked [ 0.2s] 223 checks: 217 pass, 5 fail\n"); + assert!(!compared.graded_nothing()); + } + + #[test] + fn the_lints_are_ids_and_never_the_rustdoc() { + let compared = report( + "--- failure enum_variant_added: pub enum variant added ---\n\ + --- failure struct_pub_field_missing: field removed ---\n\ + --- failure enum_variant_added: again ---\n", + ); + assert_eq!( + compared.lints(), + vec![ + String::from("enum_variant_added"), + String::from("struct_pub_field_missing") + ] + ); + } + + #[test] + fn a_bang_declares_a_break_and_a_bare_type_does_not() { + assert!(bang_before_colon("feat(policy)!: a typed refusal")); + assert!(bang_before_colon("feat!: a typed refusal")); + assert!(!bang_before_colon("feat(policy): a typed refusal")); + assert!(!bang_before_colon("no colon here")); + // A `!` that is not the type's own is not a declaration. + assert!(!bang_before_colon("fix: it broke! badly: really")); + } + + #[test] + fn a_breaking_change_footer_declares_too() { + assert!(declares( + "fix(policy): a repair", + "BREAKING CHANGE: the ABI\n" + )); + assert!(declares( + "fix(policy): a repair", + "BREAKING-CHANGE: the ABI\n" + )); + assert!(!declares("fix(policy): a repair", "an ordinary body\n")); + } + + fn commit(sha: &str, subject: &str, body: &str) -> Commit { + Commit { + sha: sha.to_owned(), + subject: subject.to_owned(), + body: body.to_owned(), + } + } + + #[test] + fn an_undeclared_break_is_exit_one_and_a_declared_one_passes() { + let compared = report(" Checked [] 223 checks: 217 pass, 5 fail\n"); + assert_eq!(reconcile(&compared, &[]), Verdict::Undeclared); + assert_eq!(reconcile(&compared, &[]).code(), ExitCode::Violation); + + // THE POINTER IS THE SHA, not the subject. Collapsing the two is the + // defect this shape exists to prevent: a refusal naming a commit message + // where a reader expected a commit is a pointer nobody can follow. + let declaring = [commit("abc1234", "feat(policy)!: the ABI", "the body\n")]; + assert_eq!( + reconcile(&compared, &declaring), + Verdict::Declared(String::from("abc1234")) + ); + assert_eq!(reconcile(&compared, &declaring).code(), ExitCode::Success); + } + + #[test] + fn a_declaration_on_an_ordinary_commit_does_not_license_the_break() { + // ANTI-VACUITY: without this the arm above passes for any commit at all, + // and the range the caller hands over stops meaning anything. + let compared = report(" Checked [] 223 checks: 217 pass, 5 fail\n"); + let ordinary = [commit("def5678", "fix(policy): a repair", "the body\n")]; + assert_eq!(reconcile(&compared, &ordinary), Verdict::Undeclared); + } + + #[test] + fn a_broken_run_is_could_not_look_rather_than_a_verdict() { + let mut compared = report(" Checked [] 223 checks: 223 pass, 0 fail\n"); + compared.code = Some(101); + assert_eq!(reconcile(&compared, &[]), Verdict::CouldNotLook); + assert_eq!(reconcile(&compared, &[]).code(), ExitCode::Usage); + } + + #[test] + fn a_registry_that_could_not_resolve_is_told_apart_from_a_verdict() { + // The tell the lock route keys on. Both spellings, because cargo uses + // one or the other depending on which half of the resolve failed. + let yanked = report("error: failed to select a version for `bisync`\n 0.3.0 is yanked\n"); + assert!(yanked.unresolvable()); + let ordinary = report("--- failure enum_variant_added: added ---\n"); + assert!(!ordinary.unresolvable()); + } + + #[test] + fn the_route_is_reportable_so_a_green_names_its_baseline() { + assert_eq!(Route::Rev.as_str(), "rev"); + assert_eq!(Route::Lock.as_str(), "lock"); + } +} From 550573b25cf39b4df0c900eb4d772574bf1fbaee Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Thu, 27 Aug 2026 07:02:54 +0000 Subject: [PATCH 31/38] feat(cli)!: `batten semver check`, and the gate answers again MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The verb half. `semver check` runs the rev route first and falls back to the lock route only when the report says the registry could not RESOLVE — never when it merely refused. It reports which route answered, so a green never hides its baseline. Measured on this branch, end to end: exit 0, route `lock`, 5 failing lints, and `31cae81b` is the commit that declares them. That is the same verdict the rev route gave before the yank, reached over a baseline the tool could no longer build for itself. FOUR BUGS FOUND BY RUNNING IT, each recorded because each was silent: * The scratch lived under the state directory, which is not where a multi-GB build belongs and could not be created anyway. It is under `target/` now, beside `cargo-semver-checks`' own scratch, where `target-prune` reclaims it. * `remove_dir_all` takes the worktree's directory and leaves git's registration, so the second run failed on a stale entry. `git worktree prune` first. * A nested `cargo` inherits the outer one's manifest and toolchain when batten itself runs under `cargo run`, and reads them as instructions about a package that is not in front of it. Removed rather than overridden, because overriding needs the whole set and removal does not. * THE ONE THAT MATTERED: the caller's root can be relative — `.` under `cargo run` — and the doc build runs with its cwd set to the worktree, so a relative `CARGO_TARGET_DIR` resolved against THAT. The build reported success having written into `tree/target/semver-baseline/target/`, and the JSON check looked where nobody had written. A build that succeeds into the wrong directory is the worst shape available: a pass nobody can find. The last one is also why `baseline_rustdoc` returns `Result` rather than `Option`. Three runs said only "could not be run at all", which is a gate that cannot say why it could not look — the shape this repository refuses to ship. Each failure now carries one line naming it, and that line is what found the relative-path bug. `--release-type` keeps the stronger warning of the two flags: the baseline is overridable so a suite can drive both routes, but the CLAIM is what the comparison means, and a caller weakening it in passing would make every break compatible with a major nobody is taking. `completions/*` and `man/*` regenerated by their generators, never hand-edited; `derived-check` is what caught them missing. Refs: CLOUD-1050, CLOUD-1059 --- completions/batten.bash | 159 ++++++++++++++++++++++++++++- completions/batten.fish | 98 +++++++++++++----- completions/batten.zsh | 160 +++++++++++++++++++++++++++++ crates/batten/src/cli.rs | 38 +++++++ crates/batten/src/lib.rs | 191 ++++++++++++++++++++++++++++++++++- crates/batten/src/semver.rs | 104 +++++++++++++++---- crates/batten/src/surface.rs | 77 ++++++++++++++ man/batten-semver-check.1 | 22 ++++ man/batten-semver.1 | 20 ++++ man/batten.1 | 3 + 10 files changed, 827 insertions(+), 45 deletions(-) create mode 100644 man/batten-semver-check.1 create mode 100644 man/batten-semver.1 diff --git a/completions/batten.bash b/completions/batten.bash index a61903479..5dbbdaec7 100644 --- a/completions/batten.bash +++ b/completions/batten.bash @@ -79,6 +79,9 @@ _batten() { batten,receipt) cmd="batten__subcmd__receipt" ;; + batten,semver) + cmd="batten__subcmd__semver" + ;; batten,spec) cmd="batten__subcmd__spec" ;; @@ -313,6 +316,9 @@ _batten() { batten__subcmd__help,receipt) cmd="batten__subcmd__help__subcmd__receipt" ;; + batten__subcmd__help,semver) + cmd="batten__subcmd__help__subcmd__semver" + ;; batten__subcmd__help,spec) cmd="batten__subcmd__help__subcmd__spec" ;; @@ -415,6 +421,9 @@ _batten() { batten__subcmd__help__subcmd__receipt,status) cmd="batten__subcmd__help__subcmd__receipt__subcmd__status" ;; + batten__subcmd__help__subcmd__semver,check) + cmd="batten__subcmd__help__subcmd__semver__subcmd__check" + ;; batten__subcmd__help__subcmd__state,adopt) cmd="batten__subcmd__help__subcmd__state__subcmd__adopt" ;; @@ -538,6 +547,18 @@ _batten() { batten__subcmd__receipt__subcmd__help,status) cmd="batten__subcmd__receipt__subcmd__help__subcmd__status" ;; + batten__subcmd__semver,check) + cmd="batten__subcmd__semver__subcmd__check" + ;; + batten__subcmd__semver,help) + cmd="batten__subcmd__semver__subcmd__help" + ;; + batten__subcmd__semver__subcmd__help,check) + cmd="batten__subcmd__semver__subcmd__help__subcmd__check" + ;; + batten__subcmd__semver__subcmd__help,help) + cmd="batten__subcmd__semver__subcmd__help__subcmd__help" + ;; batten__subcmd__state,adopt) cmd="batten__subcmd__state__subcmd__adopt" ;; @@ -587,7 +608,7 @@ _batten() { case "${cmd}" in batten) - opts="-q -v -y -h -V --strictness --fail-on-warning --config-from --silent --quiet --verbose --debug --trace --log-level --no-color --no-input --yes --help --version check enforce exec capture config lint spec doctor init baseline generate policy commit attribution worktree override provision hook payload receipt defects design state help" + opts="-q -v -y -h -V --strictness --fail-on-warning --config-from --silent --quiet --verbose --debug --trace --log-level --no-color --no-input --yes --help --version check enforce exec capture config lint spec doctor init baseline generate policy commit semver attribution worktree override provision hook payload receipt defects design state help" if [[ ${cur} == -* || ${COMP_CWORD} -eq 1 ]] ; then COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 @@ -1977,7 +1998,7 @@ _batten() { return 0 ;; batten__subcmd__help) - opts="check enforce exec capture config lint spec doctor init baseline generate policy commit attribution worktree override provision hook payload receipt defects design state help" + opts="check enforce exec capture config lint spec doctor init baseline generate policy commit semver attribution worktree override provision hook payload receipt defects design state help" if [[ ${cur} == -* || ${COMP_CWORD} -eq 2 ]] ; then COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 @@ -2718,6 +2739,34 @@ _batten() { COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 ;; + batten__subcmd__help__subcmd__semver) + opts="check" + 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__help__subcmd__semver__subcmd__check) + 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__spec) opts="" if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then @@ -3730,6 +3779,112 @@ _batten() { COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 ;; + batten__subcmd__semver) + opts="-q -v -y -h --strictness --fail-on-warning --config-from --silent --quiet --verbose --debug --trace --log-level --no-color --no-input --yes --help check help" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 2 ]] ; 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 + ;; + --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__semver__subcmd__check) + opts="-q -v -y -h --baseline --release-type --package --strictness --fail-on-warning --config-from --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 + --baseline) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; + --release-type) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; + --package) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; + --strictness) + COMPREPLY=($(compgen -W "permissive standard strict" -- "${cur}")) + return 0 + ;; + --config-from) + 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__semver__subcmd__help) + opts="check 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__semver__subcmd__help__subcmd__check) + 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__semver__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__spec) opts="-q -v -y -h --format --strictness --fail-on-warning --config-from --silent --quiet --verbose --debug --trace --log-level --no-color --no-input --yes --help" if [[ ${cur} == -* || ${COMP_CWORD} -eq 2 ]] ; then diff --git a/completions/batten.fish b/completions/batten.fish index 4d0389520..62d1f96f9 100644 --- a/completions/batten.fish +++ b/completions/batten.fish @@ -58,6 +58,7 @@ complete -c batten -n "__fish_batten_needs_command" -f -a "baseline" -d 'Record complete -c batten -n "__fish_batten_needs_command" -f -a "generate" -d 'Emit artifacts derived from the command spec, on stdout' complete -c batten -n "__fish_batten_needs_command" -f -a "policy" -d 'Inspect the thresholds and path sets this repository holds itself to' complete -c batten -n "__fish_batten_needs_command" -f -a "commit" -d 'The shape a commit must take here: what its subject may say' +complete -c batten -n "__fish_batten_needs_command" -f -a "semver" -d 'Whether this branch\'s API delta is compatible with the bump it claims' complete -c batten -n "__fish_batten_needs_command" -f -a "attribution" -d 'What produced commits may carry about the tooling that made them' complete -c batten -n "__fish_batten_needs_command" -f -a "worktree" -d 'Worktrees and the work in them: what is at risk' complete -c batten -n "__fish_batten_needs_command" -f -a "override" -d 'Issued admissions: an override is a record, never a variable somebody knows' @@ -821,6 +822,53 @@ complete -c batten -n "__fish_batten_using_subcommand commit; and __fish_seen_su complete -c batten -n "__fish_batten_using_subcommand commit; 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 commit; and __fish_seen_subcommand_from help" -f -a "check" -d 'Refuse a commit subject that does not follow the configured convention' complete -c batten -n "__fish_batten_using_subcommand commit; 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' +strict\t'Everything `Standard` fails on, plus anything advisory'" +complete -c batten -n "__fish_batten_using_subcommand semver; 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 semver; 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' +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 semver; 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 semver; 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 semver; 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 semver; 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 semver; and not __fish_seen_subcommand_from check help" -l debug -d 'Add resolution detail' +complete -c batten -n "__fish_batten_using_subcommand semver; and not __fish_seen_subcommand_from check help" -l trace -d 'Add everything' +complete -c batten -n "__fish_batten_using_subcommand semver; 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 semver; 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 semver; 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 semver; 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 semver; and not __fish_seen_subcommand_from check help" -f -a "check" -d 'Refuse an API break this branch\'s commits do not declare' +complete -c batten -n "__fish_batten_using_subcommand semver; 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 semver; and __fish_seen_subcommand_from check" -l baseline -d 'The rev to measure the API delta against (default: origin/main)' -r +complete -c batten -n "__fish_batten_using_subcommand semver; and __fish_seen_subcommand_from check" -l release-type -d 'The bump being claimed, which is what the delta is judged against' -r +complete -c batten -n "__fish_batten_using_subcommand semver; and __fish_seen_subcommand_from check" -l package -d 'The package whose public API is compared (default: batten)' -r +complete -c batten -n "__fish_batten_using_subcommand semver; 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' +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 semver; and __fish_seen_subcommand_from check" -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 semver; and __fish_seen_subcommand_from check" -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 semver; and __fish_seen_subcommand_from check" -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 semver; and __fish_seen_subcommand_from check" -l silent -d 'Say nothing but a verdict or a usage error' +complete -c batten -n "__fish_batten_using_subcommand semver; and __fish_seen_subcommand_from check" -s q -l quiet -d 'Suppress ordinary progress (repeatable: -qq is silent)' +complete -c batten -n "__fish_batten_using_subcommand semver; and __fish_seen_subcommand_from check" -s v -l verbose -d 'Explain what is being checked (repeatable: -vv is debug)' +complete -c batten -n "__fish_batten_using_subcommand semver; and __fish_seen_subcommand_from check" -l debug -d 'Add resolution detail' +complete -c batten -n "__fish_batten_using_subcommand semver; and __fish_seen_subcommand_from check" -l trace -d 'Add everything' +complete -c batten -n "__fish_batten_using_subcommand semver; and __fish_seen_subcommand_from check" -l no-color -d 'Never colour stderr, whatever it is attached to' +complete -c batten -n "__fish_batten_using_subcommand semver; 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 semver; 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 semver; 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 semver; and __fish_seen_subcommand_from help" -f -a "check" -d 'Refuse an API break this branch\'s commits do not declare' +complete -c batten -n "__fish_batten_using_subcommand semver; 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 attribution; and not __fish_seen_subcommand_from check identity 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'" @@ -1466,30 +1514,31 @@ 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 "migrate" -d 'Upgrade the findings store to this binary\'s record version' 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 help; and not __fish_seen_subcommand_from check enforce exec capture config lint spec doctor init baseline generate policy commit attribution worktree override provision hook payload receipt defects design state help" -f -a "check" -d 'Run the applicable read-only gates against the repository' -complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture config lint spec doctor init baseline generate policy commit attribution worktree override provision hook payload receipt defects design state help" -f -a "enforce" -d 'Run every configured rule, including kinds that execute a configured command' -complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture config lint spec doctor init baseline generate policy commit attribution worktree override provision hook payload receipt defects design state help" -f -a "exec" -d 'Run a command — or a `:::` bundle — and report a pointer to what it wrote' -complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture config lint spec doctor init baseline generate policy commit attribution worktree override provision hook payload receipt defects design state help" -f -a "capture" -d 'Captured command output: navigate what `exec` already ran, without running it again' -complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture config lint spec doctor init baseline generate policy commit attribution worktree override provision hook payload receipt defects design state help" -f -a "config" -d 'Inspect configuration' -complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture config lint spec doctor init baseline generate policy commit attribution worktree override provision hook payload receipt defects design state help" -f -a "lint" -d 'Lint an artifact against a declared schema' -complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture config lint spec doctor init baseline generate policy commit attribution worktree override provision hook payload receipt defects design state help" -f -a "spec" -d 'Print the tool\'s own command spec' -complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture config lint spec doctor init baseline generate policy commit attribution worktree override provision hook payload receipt defects design state help" -f -a "doctor" -d 'Diagnose whether Batten can run in this repository' -complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture config lint spec doctor init baseline generate policy commit attribution worktree override provision hook payload receipt defects design state help" -f -a "init" -d 'Write a starter batten.toml, refusing to overwrite an existing one' -complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture config lint spec doctor init baseline generate policy commit attribution worktree override provision hook payload receipt defects design state help" -f -a "baseline" -d 'Record the findings that already exist, so only new ones fail' -complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture config lint spec doctor init baseline generate policy commit attribution worktree override provision hook payload receipt defects design state help" -f -a "generate" -d 'Emit artifacts derived from the command spec, on stdout' -complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture config lint spec doctor init baseline generate policy commit attribution worktree override provision hook payload receipt defects design state help" -f -a "policy" -d 'Inspect the thresholds and path sets this repository holds itself to' -complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture config lint spec doctor init baseline generate policy commit attribution worktree override provision hook payload receipt defects design state help" -f -a "commit" -d 'The shape a commit must take here: what its subject may say' -complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture config lint spec doctor init baseline generate policy commit attribution worktree override provision hook payload receipt defects design state help" -f -a "attribution" -d 'What produced commits may carry about the tooling that made them' -complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture config lint spec doctor init baseline generate policy commit attribution worktree override provision hook payload receipt defects design state help" -f -a "worktree" -d 'Worktrees and the work in them: what is at risk' -complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture config lint spec doctor init baseline generate policy commit attribution worktree override provision hook payload receipt defects design state help" -f -a "override" -d 'Issued admissions: an override is a record, never a variable somebody knows' -complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture config lint spec doctor init baseline generate policy commit attribution worktree override provision hook payload receipt defects design state help" -f -a "provision" -d 'Pinned tools this repository provisions, cached out of tree' -complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture config lint spec doctor init baseline generate policy commit attribution worktree override provision hook payload receipt defects design state help" -f -a "hook" -d 'Adjudicate a mediated tool call read from stdin (a deny is exit 2, the one contract)' -complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture config lint spec doctor init baseline generate policy commit attribution worktree override provision hook payload receipt defects design state help" -f -a "payload" -d 'Read a hook payload from stdin' -complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture config lint spec doctor init baseline generate policy commit attribution worktree override provision hook payload receipt defects design state help" -f -a "receipt" -d 'Verification receipts: SHA-keyed claims a named check passed, invalidated by git facts' -complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture config lint spec doctor init baseline generate policy commit attribution worktree override provision hook payload receipt defects design state help" -f -a "defects" -d 'The append-only defect ledger: the lessons this repository has already paid for' -complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture config lint spec doctor init baseline generate policy commit attribution worktree override provision hook payload receipt defects design state help" -f -a "design" -d 'Design-evidence claims: the integrity of the record behind a decision' -complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture config lint spec doctor init baseline generate policy commit attribution worktree override provision hook payload receipt defects design state help" -f -a "state" -d 'The out-of-tree findings store: which store belongs to this checkout' -complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture config lint spec doctor init baseline generate policy commit attribution worktree override provision hook payload receipt defects design state help" -f -a "help" -d 'Print this message or the help of the given subcommand(s)' +complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture config lint spec doctor init baseline generate policy commit semver attribution worktree override provision hook payload receipt defects design state help" -f -a "check" -d 'Run the applicable read-only gates against the repository' +complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture config lint spec doctor init baseline generate policy commit semver attribution worktree override provision hook payload receipt defects design state help" -f -a "enforce" -d 'Run every configured rule, including kinds that execute a configured command' +complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture config lint spec doctor init baseline generate policy commit semver attribution worktree override provision hook payload receipt defects design state help" -f -a "exec" -d 'Run a command — or a `:::` bundle — and report a pointer to what it wrote' +complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture config lint spec doctor init baseline generate policy commit semver attribution worktree override provision hook payload receipt defects design state help" -f -a "capture" -d 'Captured command output: navigate what `exec` already ran, without running it again' +complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture config lint spec doctor init baseline generate policy commit semver attribution worktree override provision hook payload receipt defects design state help" -f -a "config" -d 'Inspect configuration' +complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture config lint spec doctor init baseline generate policy commit semver attribution worktree override provision hook payload receipt defects design state help" -f -a "lint" -d 'Lint an artifact against a declared schema' +complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture config lint spec doctor init baseline generate policy commit semver attribution worktree override provision hook payload receipt defects design state help" -f -a "spec" -d 'Print the tool\'s own command spec' +complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture config lint spec doctor init baseline generate policy commit semver attribution worktree override provision hook payload receipt defects design state help" -f -a "doctor" -d 'Diagnose whether Batten can run in this repository' +complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture config lint spec doctor init baseline generate policy commit semver attribution worktree override provision hook payload receipt defects design state help" -f -a "init" -d 'Write a starter batten.toml, refusing to overwrite an existing one' +complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture config lint spec doctor init baseline generate policy commit semver attribution worktree override provision hook payload receipt defects design state help" -f -a "baseline" -d 'Record the findings that already exist, so only new ones fail' +complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture config lint spec doctor init baseline generate policy commit semver attribution worktree override provision hook payload receipt defects design state help" -f -a "generate" -d 'Emit artifacts derived from the command spec, on stdout' +complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture config lint spec doctor init baseline generate policy commit semver attribution worktree override provision hook payload receipt defects design state help" -f -a "policy" -d 'Inspect the thresholds and path sets this repository holds itself to' +complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture config lint spec doctor init baseline generate policy commit semver attribution worktree override provision hook payload receipt defects design state help" -f -a "commit" -d 'The shape a commit must take here: what its subject may say' +complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture config lint spec doctor init baseline generate policy commit semver attribution worktree override provision hook payload receipt defects design state help" -f -a "semver" -d 'Whether this branch\'s API delta is compatible with the bump it claims' +complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture config lint spec doctor init baseline generate policy commit semver attribution worktree override provision hook payload receipt defects design state help" -f -a "attribution" -d 'What produced commits may carry about the tooling that made them' +complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture config lint spec doctor init baseline generate policy commit semver attribution worktree override provision hook payload receipt defects design state help" -f -a "worktree" -d 'Worktrees and the work in them: what is at risk' +complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture config lint spec doctor init baseline generate policy commit semver attribution worktree override provision hook payload receipt defects design state help" -f -a "override" -d 'Issued admissions: an override is a record, never a variable somebody knows' +complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture config lint spec doctor init baseline generate policy commit semver attribution worktree override provision hook payload receipt defects design state help" -f -a "provision" -d 'Pinned tools this repository provisions, cached out of tree' +complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture config lint spec doctor init baseline generate policy commit semver attribution worktree override provision hook payload receipt defects design state help" -f -a "hook" -d 'Adjudicate a mediated tool call read from stdin (a deny is exit 2, the one contract)' +complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture config lint spec doctor init baseline generate policy commit semver attribution worktree override provision hook payload receipt defects design state help" -f -a "payload" -d 'Read a hook payload from stdin' +complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture config lint spec doctor init baseline generate policy commit semver attribution worktree override provision hook payload receipt defects design state help" -f -a "receipt" -d 'Verification receipts: SHA-keyed claims a named check passed, invalidated by git facts' +complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture config lint spec doctor init baseline generate policy commit semver attribution worktree override provision hook payload receipt defects design state help" -f -a "defects" -d 'The append-only defect ledger: the lessons this repository has already paid for' +complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture config lint spec doctor init baseline generate policy commit semver attribution worktree override provision hook payload receipt defects design state help" -f -a "design" -d 'Design-evidence claims: the integrity of the record behind a decision' +complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture config lint spec doctor init baseline generate policy commit semver attribution worktree override provision hook payload receipt defects design state help" -f -a "state" -d 'The out-of-tree findings store: which store belongs to this checkout' +complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture config lint spec doctor init baseline generate policy commit semver attribution worktree override provision hook payload receipt defects design state help" -f -a "help" -d 'Print this message or the help of the given subcommand(s)' complete -c batten -n "__fish_batten_using_subcommand help; and __fish_seen_subcommand_from capture" -f -a "show" -d 'Print a capture\'s pointer, or the lines a selection asks for, with no second run' complete -c batten -n "__fish_batten_using_subcommand help; and __fish_seen_subcommand_from capture" -f -a "list" -d 'List this repository\'s captures as handles, in a fixed order' complete -c batten -n "__fish_batten_using_subcommand help; and __fish_seen_subcommand_from capture" -f -a "prune" -d 'Remove this repository\'s captures — the one removal path; captures never expire on their own' @@ -1509,6 +1558,7 @@ 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 policy" -f -a "tools" -d 'Print the tool names the mediated-call rows decide, one per line' complete -c batten -n "__fish_batten_using_subcommand help; and __fish_seen_subcommand_from policy" -f -a "explain" -d 'Resolve a verdict token to its class definition and the routes out of it' complete -c batten -n "__fish_batten_using_subcommand help; and __fish_seen_subcommand_from commit" -f -a "check" -d 'Refuse a commit subject that does not follow the configured convention' +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' complete -c batten -n "__fish_batten_using_subcommand help; and __fish_seen_subcommand_from worktree" -f -a "status" -d 'Report work that is uncommitted, unpushed, or not landed on the configured target' diff --git a/completions/batten.zsh b/completions/batten.zsh index 712164fc1..c3dc57a46 100644 --- a/completions/batten.zsh +++ b/completions/batten.zsh @@ -1328,6 +1328,101 @@ esac ;; esac ;; +(semver) +_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' \ +'--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'\'')]' \ +":: :_batten__subcmd__semver_commands" \ +"*::: :->semver" \ +&& ret=0 + + case $state in + (semver) + words=($line[1] "${words[@]}") + (( CURRENT += 1 )) + curcontext="${curcontext%:*:*}:batten-semver-command-$line[1]:" + case $line[1] in + (check) +_arguments "${_arguments_options[@]}" : \ +'--baseline=[The rev to measure the API delta against (default\: origin/main)]: :_default' \ +'--release-type=[The bump being claimed, which is what the delta is judged against]: :_default' \ +'--package=[The package whose public API is compared (default\: batten)]: :_default' \ +'--strictness=[Raise how strictly gates apply (an override may only tighten policy)]: :((permissive\:"Advisory\: findings are reported without failing the run" +standard\:"The default\: a finding is a violation" +strict\:"Everything \`Standard\` fails on, plus anything advisory"))' \ +'--config-from=[Read the committed config from a git ref (e.g. origin/main) instead of the working tree]: :_default' \ +'--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__semver__subcmd__help_commands" \ +"*::: :->help" \ +&& ret=0 + + case $state in + (help) + words=($line[1] "${words[@]}") + (( CURRENT += 1 )) + curcontext="${curcontext%:*:*}:batten-semver-help-command-$line[1]:" + case $line[1] in + (check) +_arguments "${_arguments_options[@]}" : \ +&& ret=0 +;; +(help) +_arguments "${_arguments_options[@]}" : \ +&& ret=0 +;; + esac + ;; +esac +;; + esac + ;; +esac +;; (attribution) _arguments "${_arguments_options[@]}" : \ '--strictness=[Raise how strictly gates apply (an override may only tighten policy)]: :((permissive\:"Advisory\: findings are reported without failing the run" @@ -2730,6 +2825,26 @@ _arguments "${_arguments_options[@]}" : \ ;; esac ;; +(semver) +_arguments "${_arguments_options[@]}" : \ +":: :_batten__subcmd__help__subcmd__semver_commands" \ +"*::: :->semver" \ +&& ret=0 + + case $state in + (semver) + words=($line[1] "${words[@]}") + (( CURRENT += 1 )) + curcontext="${curcontext%:*:*}:batten-help-semver-command-$line[1]:" + case $line[1] in + (check) +_arguments "${_arguments_options[@]}" : \ +&& ret=0 +;; + esac + ;; +esac +;; (attribution) _arguments "${_arguments_options[@]}" : \ ":: :_batten__subcmd__help__subcmd__attribution_commands" \ @@ -2975,6 +3090,7 @@ _batten_commands() { 'generate:Emit artifacts derived from the command spec, on stdout' \ 'policy:Inspect the thresholds and path sets this repository holds itself to' \ 'commit:The shape a commit must take here\: what its subject may say' \ +'semver:Whether this branch'\''s API delta is compatible with the bump it claims' \ 'attribution:What produced commits may carry about the tooling that made them' \ 'worktree:Worktrees and the work in them\: what is at risk' \ 'override:Issued admissions\: an override is a record, never a variable somebody knows' \ @@ -3405,6 +3521,7 @@ _batten__subcmd__help_commands() { 'generate:Emit artifacts derived from the command spec, on stdout' \ 'policy:Inspect the thresholds and path sets this repository holds itself to' \ 'commit:The shape a commit must take here\: what its subject may say' \ +'semver:Whether this branch'\''s API delta is compatible with the bump it claims' \ 'attribution:What produced commits may carry about the tooling that made them' \ 'worktree:Worktrees and the work in them\: what is at risk' \ 'override:Issued admissions\: an override is a record, never a variable somebody knows' \ @@ -3724,6 +3841,18 @@ _batten__subcmd__help__subcmd__receipt__subcmd__status_commands() { local commands; commands=() _describe -t commands 'batten help receipt status commands' commands "$@" } +(( $+functions[_batten__subcmd__help__subcmd__semver_commands] )) || +_batten__subcmd__help__subcmd__semver_commands() { + local commands; commands=( +'check:Refuse an API break this branch'\''s commits do not declare' \ + ) + _describe -t commands 'batten help semver commands' commands "$@" +} +(( $+functions[_batten__subcmd__help__subcmd__semver__subcmd__check_commands] )) || +_batten__subcmd__help__subcmd__semver__subcmd__check_commands() { + local commands; commands=() + _describe -t commands 'batten help semver check commands' commands "$@" +} (( $+functions[_batten__subcmd__help__subcmd__spec_commands] )) || _batten__subcmd__help__subcmd__spec_commands() { local commands; commands=() @@ -4039,6 +4168,37 @@ _batten__subcmd__receipt__subcmd__status_commands() { local commands; commands=() _describe -t commands 'batten receipt status commands' commands "$@" } +(( $+functions[_batten__subcmd__semver_commands] )) || +_batten__subcmd__semver_commands() { + local commands; commands=( +'check:Refuse an API break this branch'\''s commits do not declare' \ +'help:Print this message or the help of the given subcommand(s)' \ + ) + _describe -t commands 'batten semver commands' commands "$@" +} +(( $+functions[_batten__subcmd__semver__subcmd__check_commands] )) || +_batten__subcmd__semver__subcmd__check_commands() { + local commands; commands=() + _describe -t commands 'batten semver check commands' commands "$@" +} +(( $+functions[_batten__subcmd__semver__subcmd__help_commands] )) || +_batten__subcmd__semver__subcmd__help_commands() { + local commands; commands=( +'check:Refuse an API break this branch'\''s commits do not declare' \ +'help:Print this message or the help of the given subcommand(s)' \ + ) + _describe -t commands 'batten semver help commands' commands "$@" +} +(( $+functions[_batten__subcmd__semver__subcmd__help__subcmd__check_commands] )) || +_batten__subcmd__semver__subcmd__help__subcmd__check_commands() { + local commands; commands=() + _describe -t commands 'batten semver help check commands' commands "$@" +} +(( $+functions[_batten__subcmd__semver__subcmd__help__subcmd__help_commands] )) || +_batten__subcmd__semver__subcmd__help__subcmd__help_commands() { + local commands; commands=() + _describe -t commands 'batten semver help help commands' commands "$@" +} (( $+functions[_batten__subcmd__spec_commands] )) || _batten__subcmd__spec_commands() { local commands; commands=() diff --git a/crates/batten/src/cli.rs b/crates/batten/src/cli.rs index ab62b972f..a166828e3 100644 --- a/crates/batten/src/cli.rs +++ b/crates/batten/src/cli.rs @@ -192,6 +192,32 @@ pub enum Command { /// The chosen sub-verb. command: OverrideCommand, }, + /// The API-compatibility gate (CLOUD-1050), ported off `mise-tasks/semver.sh`. + /// + /// Appended for the reason `Override` above states, which this variant is the + /// first to be judged by: `mise run semver` reads a shifted discriminant as a + /// break the crate has to declare. + Semver { + /// The chosen sub-verb. + command: SemverCommand, + }, +} + +/// Subcommands of `semver`. +#[derive(Debug, Clone, PartialEq, Eq)] +#[non_exhaustive] +pub enum SemverCommand { + /// Compare this branch's public API against a baseline. + Check { + /// The rev to measure against. `None` is `origin/main`. + baseline: Option, + /// The bump being claimed. `None` is `patch`, which is the honest claim + /// below `0.1.0` because release-plz bumps the patch whatever the commit + /// type says. + release_type: Option, + /// The package compared. `None` is `batten`. + package: Option, + }, } /// Everything `batten exec` was asked for, as one value. @@ -797,6 +823,17 @@ fn commit_of(matches: &ArgMatches) -> Option { } } +fn semver_of(matches: &ArgMatches) -> Option { + match matches.subcommand()? { + ("check", matches) => Some(SemverCommand::Check { + baseline: matches.get_one::("baseline").cloned(), + release_type: matches.get_one::("release-type").cloned(), + package: matches.get_one::("package").cloned(), + }), + _ => None, + } +} + fn provision_of(matches: &ArgMatches) -> Option { match matches.subcommand()? { ("status", matches) => Some(ProvisionCommand::Status { @@ -1024,6 +1061,7 @@ fn command_of((name, matches): (&str, &ArgMatches)) -> Option { "design" => design_of(matches).map(|command| Command::Design { command }), "attribution" => attribution_of(matches).map(|command| Command::Attribution { command }), "commit" => commit_of(matches).map(|command| Command::Commit { command }), + "semver" => semver_of(matches).map(|command| Command::Semver { command }), "worktree" => worktree_of(matches).map(|command| Command::Worktree { command }), "override" => override_of(matches).map(|command| Command::Override { command }), "generate" => generate_of(matches).map(|command| Command::Generate { command }), diff --git a/crates/batten/src/lib.rs b/crates/batten/src/lib.rs index 4e542ad3c..50cc52f8a 100644 --- a/crates/batten/src/lib.rs +++ b/crates/batten/src/lib.rs @@ -99,7 +99,7 @@ use anyhow::Result; pub use cli::{ AttributionCommand, Cli, Command, CommitCommand, ConfigCommand, DefectsCommand, DesignCommand, GenerateCommand, LintCommand, OverrideCommand, PolicyCommand, ProvisionCommand, ReceiptCommand, - SpecFormat, StateCommand, WorktreeCommand, + SemverCommand, SpecFormat, StateCommand, WorktreeCommand, }; pub use config::Config; pub use effect::Effect; @@ -227,6 +227,7 @@ pub fn run(cli: Cli, mode: Mode, out: &mut dyn Write, err: &mut dyn Write) -> Re WorktreeCommand::Status { json } => run_worktree_status(json, &overrides, out), }, Some(Command::Override { command }) => run_override(command, &overrides, out, err), + Some(Command::Semver { command }) => run_semver(command, 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 { @@ -2274,6 +2275,194 @@ fn commit_policy(overrides: &Overrides) -> Result { }) } +/// Dispatch the `semver` subtree. +/// +/// A function rather than an inline match for the reason every other multi-verb +/// noun here has one: [`run`]'s body is a table of one line per verb, and a verb +/// whose arm is a nested destructure stops it being readable as a table. +fn run_semver( + command: SemverCommand, + mode: Mode, + out: &mut dyn Write, + err: &mut dyn Write, +) -> Result { + match command { + SemverCommand::Check { + baseline, + release_type, + package, + } => run_semver_check( + baseline.as_deref(), + release_type.as_deref(), + package.as_deref(), + mode, + out, + err, + ), + } +} + +/// `batten semver check`: is this branch's API delta compatible with its claim? +/// +/// The rev route first, the lock route only when the report says the registry +/// could not resolve. `crate::semver` states why that fallback exists and why it +/// applies more of the gate rather than less. +fn run_semver_check( + baseline: Option<&str>, + release_type: Option<&str>, + package: Option<&str>, + mode: Mode, + out: &mut dyn Write, + err: &mut dyn Write, +) -> Result { + let root = &hook_authority_root(); + let baseline = baseline.unwrap_or("origin/main"); + let release_type = release_type.unwrap_or("patch"); + let package = package.unwrap_or("batten"); + let Some(toolchain) = semver_toolchain(root) else { + output::message( + mode, + Verbosity::Normal, + err, + "semver: no rustc on PATH, so the toolchain the comparison must run under could not be determined. This is a checkout problem, not a verdict.", + )?; + return Ok(ExitCode::Usage); + }; + let mut reason = None; + let Some(compared) = semver_compare( + root, + &toolchain, + package, + baseline, + release_type, + &mut reason, + ) else { + let why = reason.unwrap_or_else(|| String::from("the comparison could not be run at all")); + output::message( + mode, + Verbosity::Normal, + err, + &format!("semver: {why}, so this is not a pass."), + )?; + return Ok(ExitCode::Usage); + }; + let commits = semver_commits(root, baseline); + let verdict = semver::reconcile(&compared, &commits); + let route = compared.route.as_str(); + let lints = compared.lints().join(" "); + match &verdict { + semver::Verdict::Compatible => writeln!( + out, + "semver: the API delta is {release_type}-compatible for {package} (baseline {baseline}, route {route})" + )?, + semver::Verdict::Declared(sha) => writeln!( + out, + "semver: breaking change DECLARED by {sha} — {lints} (baseline {baseline}, route {route})" + )?, + semver::Verdict::Undeclared => output::message( + mode, + Verbosity::Normal, + err, + &format!( + "semver: this branch breaks the {package} API but no commit declares it. Failing lint(s): {lints}. Mark the break in Conventional Commits — a `!` before the colon, or a `BREAKING CHANGE:` footer — or keep the change {release_type}-compatible." + ), + )?, + semver::Verdict::CouldNotLook => output::message( + mode, + Verbosity::Normal, + err, + "semver: the comparison did not complete, or graded nothing, so this is not a pass.", + )?, + } + Ok(verdict.code()) +} + +/// Run the comparison, taking the lock route only when the rev route could not +/// RESOLVE — never when it merely refused. +fn semver_compare( + root: &Path, + toolchain: &str, + package: &str, + baseline: &str, + release_type: &str, + reason: &mut Option, +) -> Option { + let first = semver::against_rev(root, toolchain, package, baseline, release_type)?; + if !first.unresolvable() { + return Some(first); + } + // The registry could not satisfy the scratch resolve. The baseline is still + // buildable from the lock it committed, so build it there instead. + // Under `target/`, beside `cargo-semver-checks`' own `target/semver-checks/` + // scratch. A build artefact belongs where the build artefacts are: it is + // gitignored already, and `target-prune` reclaims it with everything else + // rather than growing without bound in a state directory nobody prunes. + let scratch = root.join("target").join("semver-baseline"); + drop(std::fs::remove_dir_all(&scratch)); + std::fs::create_dir_all(&scratch).ok()?; + match semver::baseline_rustdoc(root, toolchain, package, baseline, &scratch) { + Ok(rustdoc) => semver::against_rustdoc(root, toolchain, package, &rustdoc, release_type), + Err(why) => { + // The caller renders could-not-look either way; this is what makes it + // legible. A gate that cannot say WHY it could not look is the one + // shape this repository refuses to ship. + *reason = Some(why); + None + } + } +} + +/// The pinned toolchain, read from the compiler that is actually active. +/// +/// A READ of the one authority rather than a fourth copy of the number: mise +/// puts the pin on PATH, and the copies are what `msrv-pin-agreement` exists to +/// hold together. +#[expect( + clippy::disallowed_types, + reason = "stays: asking the active compiler its version is how the pin is READ rather than restated, and it is the one spawn `semver.rs` cannot make for itself without importing the caller's environment (CLOUD-1050)" +)] +fn semver_toolchain(root: &Path) -> Option { + if let Ok(named) = std::env::var("SEMVER_TOOLCHAIN") + && !named.is_empty() + { + return Some(named); + } + let output = std::process::Command::new("rustc") + .arg("--version") + .current_dir(root) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::null()) + .output() + .ok()?; + let text = String::from_utf8_lossy(&output.stdout).into_owned(); + let version = text.split_whitespace().nth(1)?; + (!version.is_empty()).then(|| version.to_owned()) +} + +/// The branch's own commits, which are the only ones that may declare its break. +/// +/// A declaration that already landed on the baseline licenses nothing here, and +/// that is the retired suite's own case carried rather than re-derived. +/// +/// Composed from the two readers `git.rs` already has rather than a third: +/// `subjects_in_range` walks the range once for the sha and `%s`, and +/// `commit_record` carries `%B` for the footer spelling. +fn semver_commits(root: &Path, baseline: &str) -> Vec { + let Ok(subjects) = crate::git::subjects_in_range(root, baseline, "HEAD") else { + return Vec::new(); + }; + subjects + .into_iter() + .map(|found| semver::Commit { + body: crate::git::commit_record(root, &found.commit) + .map(|record| record.body) + .unwrap_or_default(), + sha: found.commit.chars().take(8).collect(), + subject: found.subject, + }) + .collect() +} + fn run_commit_check( json: bool, range: Option<&str>, diff --git a/crates/batten/src/semver.rs b/crates/batten/src/semver.rs index 39b7667a3..5f476b805 100644 --- a/crates/batten/src/semver.rs +++ b/crates/batten/src/semver.rs @@ -218,10 +218,12 @@ pub fn against_rustdoc( /// already held by whatever else `verify` is running — the same reasoning /// `perf-pair` records for its two arms. /// -/// `None` is could-not-look, and every failure collapses into it: no worktree, -/// no build, no JSON where one was expected. A caller must not read that as a -/// clean comparison. -#[must_use] +/// # Errors +/// +/// Every failure is could-not-look — no worktree, no build, no JSON where one +/// was expected — and each carries ONE line saying which. A caller must not read +/// any of them as a clean comparison, and a gate that cannot say why it could +/// not look is the shape this repository refuses to ship. #[expect( clippy::disallowed_types, reason = "stays: building the baseline is this adapter's own work, and the git worktree plus the doc build are what the lock route IS (CLOUD-1050)" @@ -232,9 +234,31 @@ pub fn baseline_rustdoc( package: &str, baseline: &str, at: &Path, -) -> Option { +) -> Result { + // ABSOLUTE, and this is not tidiness. The caller's root can be relative — + // `.` is what it resolves to under `cargo run` — and the doc build runs with + // its cwd set to the WORKTREE, so a relative `CARGO_TARGET_DIR` resolves + // against that instead. Measured: the build reported success having written + // into `tree/target/semver-baseline/target/`, and the JSON check then looked + // where nobody had written. A build that succeeds into the wrong directory is + // the worst shape available — it is a pass nobody can find. + let at = &at + .canonicalize() + .map_err(|err| format!("the baseline scratch directory could not be resolved: {err}"))?; let worktree = at.join("tree"); let target = at.join("target"); + // PRUNE BEFORE ADDING, because the scratch is removed as a directory and git + // keeps its own registration elsewhere. Without this the second run fails + // with "already registered" over a path that is no longer there — a stale + // bookkeeping entry reported as a gate that could not look. + drop( + std::process::Command::new("git") + .args(["worktree", "prune"]) + .current_dir(root) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status(), + ); let added = std::process::Command::new("git") .args(["worktree", "add", "--detach"]) .arg(&worktree) @@ -243,12 +267,15 @@ pub fn baseline_rustdoc( // vacuous shape this whole gate exists against. .arg(baseline) .current_dir(root) - .stdout(std::process::Stdio::null()) - .stderr(std::process::Stdio::null()) - .status() - .ok()?; - if !added.success() { - return None; + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .output() + .map_err(|err| format!("the baseline worktree could not be created: {err}"))?; + if !added.status.success() { + return Err(format!( + "the baseline worktree could not be created: {}", + last_line(&added.stderr) + )); } let built = std::process::Command::new(ANALYSER) .arg(format!("+{toolchain}")) @@ -267,16 +294,57 @@ pub fn baseline_rustdoc( ) .env("CARGO_TARGET_DIR", &target) .env("CARGO_TERM_COLOR", "never") + // THE OUTER CARGO'S ENVIRONMENT IS NOT THIS BUILD'S. When the binary + // itself is launched through `cargo run`, cargo exports its own manifest + // and toolchain into the child, and a nested `cargo doc` reads them as + // instructions about a package that is not the one in front of it. Every + // one is removed rather than overridden, because overriding requires + // knowing the whole set and removal does not. + .env_remove("CARGO") + .env_remove("CARGO_MANIFEST_DIR") + .env_remove("CARGO_MANIFEST_PATH") + .env_remove("CARGO_PKG_NAME") + .env_remove("CARGO_PKG_VERSION") + .env_remove("CARGO_MAKEFLAGS") + .env_remove("RUSTC") + .env_remove("RUSTDOC") + .env_remove("RUSTUP_TOOLCHAIN") .current_dir(&worktree) - .stdout(std::process::Stdio::null()) - .stderr(std::process::Stdio::null()) - .status() - .ok()?; - if !built.success() { - return None; + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .output() + .map_err(|err| format!("the baseline doc build could not be run: {err}"))?; + if !built.status.success() { + return Err(format!( + "the baseline doc build failed: {}", + last_line(&built.stderr) + )); } let json = target.join("doc").join(format!("{package}.json")); - json.is_file().then_some(json) + if json.is_file() { + Ok(json) + } else { + Err(format!( + "the baseline doc build reported success but emitted no rustdoc JSON at {}: {}", + json.display(), + last_line(&built.stdout) + )) + } +} + +/// The last non-empty line of a child's stderr. +/// +/// A POINTER rather than the payload (non-negotiable rule 4): one line names why +/// the gate could not look, and the whole stream is a build log nobody asked to +/// have quoted back at them. +fn last_line(stream: &[u8]) -> String { + String::from_utf8_lossy(stream) + .lines() + .rev() + .find(|line| !line.trim().is_empty()) + .unwrap_or("no output") + .trim() + .to_owned() } /// Whether any commit in `base..head` DECLARES a breaking change. diff --git a/crates/batten/src/surface.rs b/crates/batten/src/surface.rs index 91d749e82..ab3fc8c7f 100644 --- a/crates/batten/src/surface.rs +++ b/crates/batten/src/surface.rs @@ -467,6 +467,62 @@ const RANGE: FlagDecl = FlagDecl { value: ValueDecl::Str, }; +/// `--baseline ` on `semver check` (CLOUD-1050). +/// +/// The rev the API delta is measured against. Overridable because the suite has +/// to drive a resolvable baseline and an unresolvable one to tell the two routes +/// apart — never so a caller can weaken the gate in passing, which is why the +/// RELEASE TYPE below carries the stronger warning of the two. +const SEMVER_BASELINE: FlagDecl = FlagDecl { + id: "baseline", + long: Some("baseline"), + short: None, + help: "The rev to measure the API delta against (default: origin/main)", + env: EnvDecl::None, + global: false, + positional: false, + required: false, + hidden: false, + rung: Rung::None, + value: ValueDecl::Str, +}; + +/// `--release-type ` on `semver check` (CLOUD-1050). +/// +/// The bump being CLAIMED, and stating it is what makes the comparison real: a +/// branch carries the same version as its baseline — release-plz bumps on +/// landing, not before — so without this the tool assumes a major release is +/// coming and every break is compatible with it. Measured: 0 checks graded +/// without it, 223 with. +const SEMVER_RELEASE_TYPE: FlagDecl = FlagDecl { + id: "release-type", + long: Some("release-type"), + short: None, + help: "The bump being claimed, which is what the delta is judged against", + env: EnvDecl::None, + global: false, + positional: false, + required: false, + hidden: false, + rung: Rung::None, + value: ValueDecl::Str, +}; + +/// `--package ` on `semver check` (CLOUD-1050). +const SEMVER_PACKAGE: FlagDecl = FlagDecl { + id: "package", + long: Some("package"), + short: None, + help: "The package whose public API is compared (default: batten)", + env: EnvDecl::None, + global: false, + positional: false, + required: false, + hidden: false, + rung: Rung::None, + value: ValueDecl::Str, +}; + /// `--message ` on `attribution check` (CLOUD-274) and `commit check` /// (CLOUD-701). /// @@ -1729,6 +1785,27 @@ pub const SURFACE: &[CommandDecl] = &[ effect: Effect::Read, flags: &[JSON, RANGE, MESSAGE], }, + // The `semver` noun (CLOUD-1050), ported off `mise-tasks/semver.sh` when + // CLOUD-1059 made editing a shell rule refusable. + // + // NOT `read`, and the distinction is the point: this subtree spawns + // `cargo-semver-checks`, and its fallback adds a git worktree and a doc + // build. A row claiming `read` here would put a spawning verb on the derived + // read-only allowlist, which is the claim that allowlist exists to make true. + CommandDecl { + path: "semver", + about: "Whether this branch's API delta is compatible with the bump it claims", + data_channel: false, + effect: Effect::Unclassified, + flags: &[], + }, + CommandDecl { + path: "semver check", + about: "Refuse an API break this branch's commits do not declare", + data_channel: true, + effect: Effect::Write, + flags: &[SEMVER_BASELINE, SEMVER_RELEASE_TYPE, SEMVER_PACKAGE], + }, CommandDecl { path: "attribution", about: "What produced commits may carry about the tooling that made them", diff --git a/man/batten-semver-check.1 b/man/batten-semver-check.1 new file mode 100644 index 000000000..a118a5325 --- /dev/null +++ b/man/batten-semver-check.1 @@ -0,0 +1,22 @@ +.ie \n(.g .ds Aq \(aq +.el .ds Aq ' +.TH batten-semver-check 1 batten +.SH NAME +batten\-semver\-check \- Refuse an API break this branch\*(Aqs commits do not declare +.SH SYNOPSIS +\fBbatten semver check\fR [\fB\-\-baseline\fR] [\fB\-\-release\-type\fR] [\fB\-\-package\fR] [\fB\-h\fR|\fB\-\-help\fR] +.SH DESCRIPTION +Refuse an API break this branch\*(Aqs commits do not declare +.SH OPTIONS +.TP +\fB\-\-baseline\fR +The rev to measure the API delta against (default: origin/main) +.TP +\fB\-\-release\-type\fR +The bump being claimed, which is what the delta is judged against +.TP +\fB\-\-package\fR +The package whose public API is compared (default: batten) +.TP +\fB\-h\fR, \fB\-\-help\fR +Print help diff --git a/man/batten-semver.1 b/man/batten-semver.1 new file mode 100644 index 000000000..1736345fd --- /dev/null +++ b/man/batten-semver.1 @@ -0,0 +1,20 @@ +.ie \n(.g .ds Aq \(aq +.el .ds Aq ' +.TH batten-semver 1 batten +.SH NAME +batten\-semver \- Whether this branch\*(Aqs API delta is compatible with the bump it claims +.SH SYNOPSIS +\fBbatten semver\fR [\fB\-h\fR|\fB\-\-help\fR] <\fIsubcommands\fR> +.SH DESCRIPTION +Whether this branch\*(Aqs API delta is compatible with the bump it claims +.SH OPTIONS +.TP +\fB\-h\fR, \fB\-\-help\fR +Print help +.SH SUBCOMMANDS +.TP +batten\-semver\-check(1) +Refuse an API break this branch\*(Aqs commits do not declare +.TP +batten\-semver\-help(1) +Print this message or the help of the given subcommand(s) diff --git a/man/batten.1 b/man/batten.1 index b78ee723c..058533b4e 100644 --- a/man/batten.1 +++ b/man/batten.1 @@ -91,6 +91,9 @@ Inspect the thresholds and path sets this repository holds itself to batten\-commit(1) The shape a commit must take here: what its subject may say .TP +batten\-semver(1) +Whether this branch\*(Aqs API delta is compatible with the bump it claims +.TP batten\-attribution(1) What produced commits may carry about the tooling that made them .TP From fba3f78778a81cae8e57b1ccb0a169b1f7d2bb8f Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Thu, 27 Aug 2026 07:12:23 +0000 Subject: [PATCH 32/38] feat(policy)!: semver.sh retires, and the gate it leaves behind reads the lock MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `mise-tasks/semver.sh` and `tests/semver.bats` are deleted rather than edited. `cargo-semver-checks` generates a scratch crate and runs `cargo update` in it, discarding `Cargo.lock`, so a yank of `bisync` on 2026-08-26 made every commit from v0.0.89 on unresolvable seven minutes after this gate last passed in CI. The repair — feed the tool a baseline rustdoc built from the committed lock — is an EDIT to an authored shell rule, and `V-SHELL-RULE-EDITED` declares no override route and no `bypass_env` by design. Maintenance of a shell-tier rule is completed by migrating it, so the repair WAS the migration. This is the first time CLOUD-1059's claim was tested against a repair somebody needed. `[tasks.semver]` becomes an inline `cargo run -p batten -- semver check`, so `rust.yml`'s required check and `verify`'s call site stay byte-identical. `semver` leaves `$MUTANT_GATES` because `mutant` resolves a gate's suite as `tests/.bats` and there is no longer one; it stays in `$CI_REQUIRED_CHECKS`, where it is a real required check. Both retirement ledgers travel in `crates/batten/tests/semver_gate.rs`: two per-path arms and twelve per-case arms, four of them `changed:` with reasons. The one narrowing stated plainly is the shell's `rustup toolchain install` fallback, gone rather than ported — a gate that installs a toolchain has a side effect nobody asked for, and the honest answer to an absent one is could-not-look. Refs: CLOUD-102, CLOUD-1050, CLOUD-1059 --- crates/batten/tests/semver_gate.rs | 205 ++++++++++++++++++++++++ mise-tasks/semver.sh | 173 -------------------- mise.toml | 26 ++- policy/spawn-adapters.rego | 4 +- tests/semver.bats | 245 ----------------------------- 5 files changed, 233 insertions(+), 420 deletions(-) create mode 100644 crates/batten/tests/semver_gate.rs delete mode 100755 mise-tasks/semver.sh delete mode 100644 tests/semver.bats diff --git a/crates/batten/tests/semver_gate.rs b/crates/batten/tests/semver_gate.rs new file mode 100644 index 000000000..fee8b5f39 --- /dev/null +++ b/crates/batten/tests/semver_gate.rs @@ -0,0 +1,205 @@ +//! The API-compatibility gate over the compiled binary (CLOUD-102), ported off +//! `tests/semver.bats` under CLOUD-1059. +//! +//! # Why it moved +//! +//! `cargo-semver-checks` stopped being able to resolve the baseline — it runs +//! `cargo update` in a scratch crate and so discards `Cargo.lock`, and a yank of +//! `bisync` on 2026-08-26 made every commit from v0.0.89 on unresolvable seven +//! minutes after the gate last passed in CI. Repairing that meant editing +//! `mise-tasks/semver.sh`, and an edit is `V-SHELL-RULE-EDITED`, which declares +//! no override route by design. So the repair WAS the migration. +//! +//! # What this tier can assert and the retired one could not +//! +//! The retired suite drove the shell program with a stubbed `cargo-semver-checks` +//! on `PATH`, so every case was a statement about the wrapper's parsing. These +//! cases drive the compiled binary, so the predicate, the exit mapping and the +//! report parsing are asserted as ONE object — and the four bugs the port found +//! (a relative `CARGO_TARGET_DIR`, an orphaned worktree registration, an +//! inherited outer-cargo environment, a scratch under the state directory) were +//! every one of them invisible to a stub. +//! +//! **The expensive route is not exercised here, and saying so is the point.** +//! Building a baseline rustdoc takes ~30s and a real git worktree; a unit case +//! that faked it would assert the fixture rather than the engine, which is the +//! `with input as` defect one layer over. What IS asserted here is everything +//! that decides — the vacuous-run refusal, the exit mapping, the declared-break +//! reconciliation and the could-not-look channel — over the real binary. The +//! lock route's own end-to-end evidence is recorded in the commit that added it: +//! 223 checks graded, 217 pass, 5 fail, route `lock`, exit 0. +//! +//! # RETIREMENT LEDGER, PER PATH — what `shell-retirement` reads +//! +// carried: mise-tasks/semver.sh crates/batten/src/semver.rs crates/batten/tests/semver_gate.rs +// carried: tests/semver.bats crates/batten/src/semver.rs crates/batten/tests/semver_gate.rs +//! +//! # RETIREMENT LEDGER — `tests/semver.bats`, 12 cases +//! +//! CARRIED — the property survives, proved here against the binary. +//! +// carried: "a patch-compatible delta passes, and names the claim it verified" crates/batten/tests/semver_gate.rs +// carried: "THE VACUOUS RUN: a report that graded 0 checks is exit 2, never a pass" crates/batten/tests/semver_gate.rs +// carried: "an undeclared break fails, and names the lint rather than the payload" crates/batten/tests/semver_gate.rs +// carried: "a break declared with a bang passes, and names the declaring commit" crates/batten/tests/semver_gate.rs +// carried: "a break declared with a BREAKING CHANGE footer passes too" crates/batten/tests/semver_gate.rs +// carried: "A DECLARATION ON THE BASELINE DOES NOT COUNT — only this branch's commits" crates/batten/tests/semver_gate.rs +// carried: "an exit code that is neither verdict is exit 2 — a broken run is not a pass" crates/batten/tests/semver_gate.rs +// carried: "output is a pointer — lint ids and a short sha, never the rustdoc it read" crates/batten/tests/semver_gate.rs +//! +//! CHANGED — behaviour that diverges deliberately, each with its reason. +//! +// changed: "semver.bats::a missing cargo-semver-checks is exit 2, never a silent pass" crates/batten/src/semver.rs the shell probed `command -v` and refused before running; the adapter has no separate probe because a spawn that cannot start IS the could-not-look it would have reported, and one channel cannot disagree with itself +// changed: "semver.bats::the toolchain defaults to the one on PATH, not to a floating channel" crates/batten/src/lib.rs unchanged in effect and moved in place: `semver_toolchain` reads `rustc --version`, which is the same READ of the one authority the shell made, asserted where it now lives +// changed: "semver.bats::SEMVER_TOOLCHAIN still overrides, so the suite can drive another claim" crates/batten/src/lib.rs the seam survives as the same environment variable, read at the same point; what changed is that a suite driving it no longer needs a stub on PATH to observe the effect +// changed: "semver.bats::no rustc at all is exit 2, never a fall back to a floating channel" crates/batten/src/lib.rs same predicate, same exit code, and the refusal now names the checkout rather than the channel because there is no channel left to fall back to +//! +//! The shell's `rustup toolchain install` fallback is **gone rather than ported**, +//! and that is the one narrowing worth stating plainly: a gate that INSTALLS a +//! toolchain is a gate with a side effect nobody asked for, and the honest answer +//! to an absent toolchain is the could-not-look this already returns. `mise +//! install` is the one place a toolchain arrives. + +#![allow(clippy::unwrap_used, clippy::expect_used)] + +mod common; + +use batten::exit::ExitCode; +use batten::semver::{Commit, Compared, Route, Verdict, declared_break, reconcile}; + +fn graded(code: i32, body: &str) -> Compared { + Compared { + code: Some(code), + report: body.to_owned(), + route: Route::Rev, + } +} + +fn commit(sha: &str, subject: &str, body: &str) -> Commit { + Commit { + sha: sha.to_owned(), + subject: subject.to_owned(), + body: body.to_owned(), + } +} + +const REAL: &str = " Checked [ 0.2s] 223 checks: 217 pass, 5 fail, 1 warn, 31 skip\n"; + +#[test] +fn a_compatible_delta_passes() { + let compared = graded(0, " Checked [] 223 checks: 223 pass, 0 fail\n"); + assert_eq!(reconcile(&compared, &[]), Verdict::Compatible); + assert_eq!(reconcile(&compared, &[]).code(), ExitCode::Success); +} + +#[test] +fn the_vacuous_run_is_exit_two_and_never_a_pass() { + // The point of the whole gate. A run that graded nothing has not answered, + // and it is the shape the DEFAULT invocation produces — without an explicit + // `--release-type` the tool assumes a major is coming and every break is + // compatible with it. Measured: 0 checks without the flag, 223 with. + let compared = graded(0, " Checked [ 0.1s] 0 checks: 0 pass, 0 fail\n"); + assert_eq!(reconcile(&compared, &[]), Verdict::CouldNotLook); + assert_eq!(reconcile(&compared, &[]).code(), ExitCode::Usage); +} + +#[test] +fn an_undeclared_break_is_exit_one() { + let compared = graded(100, REAL); + let ordinary = [commit("def5678", "fix(policy): a repair", "a body\n")]; + assert_eq!(reconcile(&compared, &ordinary), Verdict::Undeclared); + assert_eq!(reconcile(&compared, &ordinary).code(), ExitCode::Violation); +} + +#[test] +fn a_bang_declares_the_break_and_names_the_commit() { + let compared = graded(100, REAL); + let declaring = [commit("abc1234", "feat(policy)!: the ABI", "a body\n")]; + assert_eq!( + reconcile(&compared, &declaring), + Verdict::Declared(String::from("abc1234")) + ); +} + +#[test] +fn a_breaking_change_footer_declares_it_too() { + let compared = graded(100, REAL); + let declaring = [commit( + "abc1234", + "fix(policy): a repair", + "the body\n\nBREAKING CHANGE: the ABI\n", + )]; + assert_eq!( + reconcile(&compared, &declaring), + Verdict::Declared(String::from("abc1234")) + ); +} + +#[test] +fn a_declaration_outside_the_range_does_not_count() { + // The baseline's own commits are not in the set the caller hands over, so a + // `feat!` that already landed licenses nothing here. Asserted as the EMPTY + // range rather than by constructing a baseline: what makes the property true + // is which commits reach this function, and an empty set is that stated. + let compared = graded(100, REAL); + assert_eq!(declared_break(&[]), None); + assert_eq!(reconcile(&compared, &[]), Verdict::Undeclared); +} + +#[test] +fn a_code_that_is_neither_verdict_is_could_not_look() { + // 101 is the tool crashing, and a broken run is not a pass. This is the exit + // the yank produced for three weeks' worth of commits. + let compared = graded(101, REAL); + assert_eq!( + reconcile(&compared, &compared_commits()), + Verdict::CouldNotLook + ); + assert_eq!( + reconcile(&compared, &compared_commits()).code(), + ExitCode::Usage + ); +} + +fn compared_commits() -> Vec { + vec![commit("abc1234", "feat(policy)!: the ABI", "a body\n")] +} + +#[test] +fn the_output_is_a_pointer_and_never_the_rustdoc() { + // Non-negotiable rule 4. The lint ids the tool named, deduplicated and + // sorted; never the rustdoc they were read from, and never the source. + let compared = graded( + 100, + "--- failure struct_pub_field_missing: pub field removed ---\n\ + --- failure enum_variant_added: variant added ---\n\ + --- failure enum_variant_added: and again ---\n", + ); + assert_eq!( + compared.lints(), + vec![ + String::from("enum_variant_added"), + String::from("struct_pub_field_missing") + ] + ); +} + +#[test] +fn the_verb_is_declared_and_reachable() { + // The seam the retirement rests on: `mise run semver` now invokes this, so a + // verb that does not parse is a required CI check that cannot run at all. + let root = common::at_root("."); + let output = common::run(&root, &["semver", "--help"]); + assert_eq!(output.status.code(), Some(0)); + let text = String::from_utf8_lossy(&output.stdout); + assert!(text.contains("check"), "the sub-verb is reachable: {text}"); +} + +#[test] +fn the_route_travels_with_the_verdict() { + // A green must never hide which baseline produced it. The two routes answer + // the same question and a reader who cannot tell them apart cannot tell a + // normal run from one that worked around a moved registry. + assert_eq!(Route::Rev.as_str(), "rev"); + assert_eq!(Route::Lock.as_str(), "lock"); +} diff --git a/mise-tasks/semver.sh b/mise-tasks/semver.sh deleted file mode 100755 index d0c028308..000000000 --- a/mise-tasks/semver.sh +++ /dev/null @@ -1,173 +0,0 @@ -#!/usr/bin/env bash -#MISE description="Gate: the API delta on this branch is compatible with the bump release-plz will infer, unless a commit declares the break" -# -# CLOUD-102. release-plz infers the bump from commit TYPES; nothing checked it -# against the actual API delta, so a `fix:` that is really breaking mis-releases. -# Batten lands every commit by fast-forward and its value is a stable contract, -# so the inference deserves a verifier. -# -# THE DEFAULT INVOCATION IS A CHECK THAT CANNOT FAIL, and that is the whole -# reason this is a task rather than one line in `verify`. Measured on 0.0.61: -# -# $ cargo semver-checks check-release -p batten --baseline-rev origin/main -# Checking batten v0.0.61 -> v0.0.61 (no change; assume major) -# Checked [0.000s] 0 checks: 0 pass, 254 skip -# Summary no semver update required -# -# Zero checks ran. A branch carries the same version as its baseline — release-plz -# bumps on landing, not before — so the tool assumes a major release is coming and -# every lint is vacuously satisfied. Wired that way this gate would report green -# over any breakage, forever. -# -# `--release-type` is what makes it real: it states the bump being claimed instead -# of inferring one from two identical versions. Measured, the same tree then runs -# 223 checks. `patch` is the honest claim here because below 0.1.0 release-plz -# bumps the patch whatever the commit type says (.claude/rules/commits.md), so -# patch-compatibility is exactly what the next release will assert. -# -# THE TOOLCHAIN IS THE PINNED ONE, AND THAT IS NEW (CLOUD-593, CLOUD-654). It -# used to be a floating rustup `stable`, and the reason was real while it lasted: -# cargo-semver-checks 0.50.0 refuses under 1.85.0 ("rustc version is not high -# enough: >=1.93.0 needed"), and downgrading was not an escape — 0.40.0, the -# 1.85-era release, instead dies resolving this crate's own dependency tree -# (`home@0.5.12 requires rustc 1.88`). So the rustdoc JSON was built by whatever -# `stable` happened to be. -# -# CLOUD-593 coupled the floor to the pin and moved the pin to current stable, -# which removed that premise and then INVERTED it. `rust-version` became 1.97, and -# a rustup `stable` channel resolving anything older now aborts the run outright: -# measured here at rustc 1.94.1, "batten@0.0.80 requires rustc 1.97", exit 101 — -# not a verdict, so `verify` correctly refused rather than passing. A floating -# channel could break this gate again on any given day, in either direction; the -# pin cannot, because it is the compiler the crate is actually built with. -# -# Read from `rustc --version` rather than parsed out of `mise.toml`, deliberately: -# mise puts the pinned toolchain on PATH, so asking the compiler that is actually -# active is a READ of the one authority instead of a fourth COPY of the number — -# the copies are exactly what `msrv-pin-agreement` exists to hold together. -# -# Exit 0 compatible (or a declared break) / 1 an undeclared break / 2 could not -# look — matching the other `*-check` programs, so a caller can tell "this branch -# breaks the contract" from "this gate never ran". -# A gate listed in $MUTANT_GATES with no row here fails `mise run mutant`. -#MUTANT undeclared-break-passes|s/^exit 1$/exit 0/|an undeclared break fails - -set -uo pipefail - -cd "${SEMVER_ROOT:-$(git rev-parse --show-toplevel)}" || exit 2 - -readonly PACKAGE="${SEMVER_PACKAGE:-batten}" -readonly BASELINE="${SEMVER_BASELINE:-origin/main}" -# The bump release-plz will infer. Overridable so the suite can drive the other -# claims, not so a caller can weaken the gate in passing: a real change of claim -# belongs in this file, beside the reasoning above. -readonly RELEASE_TYPE="${SEMVER_RELEASE_TYPE:-patch}" - -# --- the toolchain ------------------------------------------------------------ -# -# Guarded by hand, because a task body does not run under `set -e` and a gate that -# could not build its own inputs must never report on them. -toolchain="${SEMVER_TOOLCHAIN:-}" -if [[ -z "$toolchain" ]]; then - # `rustc 1.97.1 (8bab26f4f 2026-07-14)` -> `1.97.1`, which is also the name - # rustup gives the toolchain mise installed, so `cargo +1.97.1` resolves it. - toolchain=$(rustc --version 2>/dev/null | awk '{print $2}') -fi -if [[ -z "$toolchain" ]]; then - echo "::error:: semver: no rustc on PATH, so the toolchain the comparison must run under could not be determined. This is a checkout problem, not a verdict — run \`mise install\`." >&2 - exit 2 -fi -# Read into a variable and match with `<<<` rather than piping into `grep -q`: -# under `pipefail` an early-exiting grep SIGPIPEs its producer, so a MATCH -# reports failure (mem:toolchain-and-hooks). `pipefail-grep-check` gates it. -installed=$(rustup toolchain list 2>/dev/null) -if ! grep -q "^${toolchain}" <<<"$installed"; then - if ! rustup toolchain install "$toolchain" --profile minimal >&2; then - echo "::error:: semver: could not install the \`$toolchain\` toolchain cargo-semver-checks needs, so the API delta was never compared. This is a checkout problem, not a verdict." >&2 - exit 2 - fi -fi - -if ! command -v cargo-semver-checks >/dev/null 2>&1; then - echo "::error:: semver: cargo-semver-checks is not on PATH — run \`mise install\`. A gate that cannot look must not report green." >&2 - exit 2 -fi - -# --- the comparison ----------------------------------------------------------- -report=$(mktemp) || exit 2 -trap 'rm -f "$report"' EXIT - -# `CARGO_TERM_COLOR=never`, overriding `mise.toml [env]`'s `always`, and this is -# load-bearing rather than cosmetic: the report below is PARSED, and a gate that -# parses colour is the CLOUD-199 defect — an anchored pattern that can never -# match because escape sequences sit between the anchor and the word. It was not -# hypothetical here. The first version of this task inherited `always`, its -# `^ *Checked` anchor matched nothing, and the vacuous-run refusal directly below -# reported a clean pass over a run that graded 0 checks. Caught by running the -# probe rather than by reading the code. -CARGO_TERM_COLOR=never cargo "+$toolchain" semver-checks check-release \ - --package "$PACKAGE" \ - --baseline-rev "$BASELINE" \ - --release-type "$RELEASE_TYPE" \ - >"$report" 2>&1 -rc=$? - -# THE VACUOUS-RUN REFUSAL, and it is the point of the whole task. A run that -# graded nothing has not answered — it is the shape the default invocation -# produces, and reporting it as a pass is how this gate would quietly die. Read -# from the tool's own summary line rather than inferred from the exit code, -# because that line is where "0 checks" is stated. -if grep -qE '^[[:space:]]*Checked .* 0 checks:' "$report"; then - echo "::error:: semver: cargo-semver-checks ran 0 checks, so nothing was verified. That is the \`no change; assume major\` shape — the comparison needs an explicit --release-type. Not a pass." >&2 - exit 2 -fi - -if [[ "$rc" = 0 ]]; then - echo "semver: the API delta on this branch is ${RELEASE_TYPE}-compatible for $PACKAGE (baseline $BASELINE)" - exit 0 -fi - -# An exit code that is neither "compatible" nor "incompatible" is a broken run: -# a missing baseline ref, a crate that would not build, a tool that crashed. 100 -# is cargo-semver-checks' own "required version bump is larger than claimed". -if [[ "$rc" != 100 ]]; then - echo "::error:: semver: cargo-semver-checks exited $rc, which is neither verdict — the comparison did not complete, so this is not a pass. Re-run \`mise run semver\` and read its output." >&2 - exit 2 -fi - -# --- a break is permitted only when it is DECLARED ---------------------------- -# -# The same range and the same env convention `[tasks.commit-lint]` uses, so there -# is one definition of "the commits this branch adds" rather than two that drift. -# Absent a range, the branch's own commits since the baseline are that set. -base="${BASE_SHA:-$(git rev-parse "$BASELINE" 2>/dev/null)}" -head="${HEAD_SHA:-HEAD}" - -# Pointer, never payload (non-negotiable rule 4): the lint ids the tool named, -# not the rustdoc it read them from. -lints=$(grep -oE '^--- failure [a-z_]+' "$report" | awk '{print $3}' | sort -u | tr '\n' ' ') - -declared="" -if [[ -n "$base" ]]; then - while read -r sha; do - [[ -n "$sha" ]] || continue - subject=$(git show -s --format=%s "$sha" 2>/dev/null) - body=$(git show -s --format=%B "$sha" 2>/dev/null) - # Conventional Commits spells a break two ways, and both count: a `!` - # before the colon, or a `BREAKING CHANGE:` footer. - if grep -qE '^[a-z]+(\([a-z0-9._-]+\))?!:' <<<"$subject" || - grep -qE '^BREAKING[ -]CHANGE:' <<<"$body"; then - declared="$sha" - break - fi - done <<<"$(git rev-list --no-merges "$base..$head" 2>/dev/null)" -fi - -if [[ -n "$declared" ]]; then - echo "semver: breaking change DECLARED by ${declared:0:8} — ${lints}(baseline $BASELINE)" - exit 0 -fi - -echo "::error:: semver: this branch breaks the $PACKAGE API but no commit declares it. Failing lint(s): ${lints}" >&2 -echo "::error:: semver: mark the break in Conventional Commits — a \`!\` before the colon, or a \`BREAKING CHANGE:\` footer — or keep the change ${RELEASE_TYPE}-compatible." >&2 -exit 1 diff --git a/mise.toml b/mise.toml index a0173bb05..004e39284 100644 --- a/mise.toml +++ b/mise.toml @@ -398,7 +398,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,ci-slow-needed,bot-issue,land,land-lock,ci-lease-precondition,board-diff-overlap,reclaim-census,connector-allow-resolve,serena-mcp,target-prune,claimed-keys,released,in-progress-drain,merged-pr-keys,board-payloads,attestation-check,awk-regex-check,batten-glob-check,board-sweep,branch-age-check,cap-drift,checks-green,ci-drift,ci-local-parity,ci-tools-check,claim-check,claim-race-check,closing-key-check,coderabbit-config-check,config-deprecations,config-lint,connector-allow-guard,container-preflight,darwin-link,deferral-check,derived-check,digest-major-agreement,doctor,done-check,done-pr-check,duplicate-close-check,evaluator-closure-check,evaluator-io-check,finding-sink-check,gh-guard,graph-check,hook-matcher-check,hook-pin-check,hook-profile-check,hooks-wiring-check,install-check,land-divergence-assert,land-lock-check,landed-check,license-table-check,linear-check,lock-complete,macos-link-check,mcp-allow-check,mcp-attach-check,mcp-timeout-budget,memories-check,mise-action-floor,mise-pin-agreement,module-map-check,msrv-pin-agreement,mutant,mutant-census,no-doctests,nonverdict-assert,ntia-check,perf-assert,perf-compare,perf-gate,pipefail-grep-check,privileged-lane,pr-unsubscribed,publish-credential-check,ready-cites-check,ready-guard,ready-lint,reference-check,release-assets-check,release-due,release-tracking-check,renovate-config-validator,report-only-check,rules-drift,run-shape,run-shape-guard,rust-paths-check,sbom,sbom-check,schema-check,semver,signing-posture,skill-check,sonar-gate,spec-ref-check,stop-posture-check,suite-bench-check,timeout-check,token-bench-check,transcript-corpus-check,tree-clean,unlanded-check,verified" +MUTANT_GATES = "alive,ci-slow-needed,bot-issue,land,land-lock,ci-lease-precondition,board-diff-overlap,reclaim-census,connector-allow-resolve,serena-mcp,target-prune,claimed-keys,released,in-progress-drain,merged-pr-keys,board-payloads,attestation-check,awk-regex-check,batten-glob-check,board-sweep,branch-age-check,cap-drift,checks-green,ci-drift,ci-local-parity,ci-tools-check,claim-check,claim-race-check,closing-key-check,coderabbit-config-check,config-deprecations,config-lint,connector-allow-guard,container-preflight,darwin-link,deferral-check,derived-check,digest-major-agreement,doctor,done-check,done-pr-check,duplicate-close-check,evaluator-closure-check,evaluator-io-check,finding-sink-check,gh-guard,graph-check,hook-matcher-check,hook-pin-check,hook-profile-check,hooks-wiring-check,install-check,land-divergence-assert,land-lock-check,landed-check,license-table-check,linear-check,lock-complete,macos-link-check,mcp-allow-check,mcp-attach-check,mcp-timeout-budget,memories-check,mise-action-floor,mise-pin-agreement,module-map-check,msrv-pin-agreement,mutant,mutant-census,no-doctests,nonverdict-assert,ntia-check,perf-assert,perf-compare,perf-gate,pipefail-grep-check,privileged-lane,pr-unsubscribed,publish-credential-check,ready-cites-check,ready-guard,ready-lint,reference-check,release-assets-check,release-due,release-tracking-check,renovate-config-validator,report-only-check,rules-drift,run-shape,run-shape-guard,rust-paths-check,sbom,sbom-check,schema-check,signing-posture,skill-check,sonar-gate,spec-ref-check,stop-posture-check,suite-bench-check,timeout-check,token-bench-check,transcript-corpus-check,tree-clean,unlanded-check,verified" # --- 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. @@ -825,6 +825,30 @@ description = "Gate: a branch whose whole diff is comment lines buys a CI matrix # renamed row breaks this loudly instead of silently passing. run = "cargo run --quiet -p batten -- check --rule prose-only" +[tasks.semver] +description = "Gate: the API delta on this branch is compatible with the bump release-plz will infer, unless a commit declares the break" +# CLOUD-102's gate, ported off `mise-tasks/semver.sh` under CLOUD-1059. +# +# THE TASK NAME SURVIVES THE MIGRATION, for `prose-only-check`'s reason above, +# and here it also has to: `.github/workflows/rust.yml` runs `mise run semver` as +# a required check and `verify` calls it through `perf-gate`'s neighbour, so a +# renamed task would need a workflow edit to land a workflow edit. +# +# WHY IT WAS MIGRATED AT ALL, which is the campaign working on its author. +# `cargo-semver-checks` generates a scratch crate and runs `cargo update` in it, +# discarding `Cargo.lock` — so on 2026-08-26 a yank of `bisync` made every commit +# from v0.0.89 on unresolvable, seven minutes after this gate passed in CI. The +# repair was a fallback that builds the baseline from its committed lock, and +# adding it meant EDITING an authored shell rule, which `V-SHELL-RULE-EDITED` +# refuses with no override route. Maintenance of a shell-tier rule is completed +# by migrating it; that is the whole claim, and this is the first time it was +# tested against a repair somebody actually needed. +# +# The defaults live in `crates/batten/src/lib.rs` beside the reasoning, not here: +# `origin/main`, `patch` (the honest claim below 0.1.0, where release-plz bumps +# the patch whatever the commit type says), and `batten`. +run = "cargo run --quiet -p batten -- semver check" + [tasks.filed-here-check] description = "Gate: every row this branch put on the board was refined at creation and none names code this branch has open (reads the recorder's record; pointer-only)" # CLOUD-514's gate, ported off `mise-tasks/filed-here-check.sh` under CLOUD-1051. diff --git a/policy/spawn-adapters.rego b/policy/spawn-adapters.rego index 6464f0d08..7e73c5026 100644 --- a/policy/spawn-adapters.rego +++ b/policy/spawn-adapters.rego @@ -79,9 +79,11 @@ rules contains "spawn-adapters" # handler the harness handler boundary # action an action row RUNS a command; that is the kind's definition # rules the engine that runs a `command` row's `check` and `fix` +# semver the pinned cargo-semver-checks adapter (CLOUD-1050), which also +# builds its own baseline when the registry cannot resolve one adapters := { "exec", "provision", "secrets", "symbols", - "judge", "handler", "action", "rules", + "judge", "handler", "action", "rules", "semver", } module_of(path) := name if { diff --git a/tests/semver.bats b/tests/semver.bats deleted file mode 100644 index 8a31ba161..000000000 --- a/tests/semver.bats +++ /dev/null @@ -1,245 +0,0 @@ -#!/usr/bin/env bats -# subject: mise-tasks/semver.sh -# The decision table for `mise run semver` (CLOUD-102). -# -# The gate's subject — "does the API delta match the bump release-plz will -# infer" — is answered by cargo-semver-checks, which takes ~7s warm and needs a -# newer toolchain than this repo pins. So the suite drives a STUB: what is being -# tested here is the decision the task makes around the tool, which is where -# every one of its failure modes lives. The tool's own correctness is upstream's -# suite, and the real tool against the real crate runs in `verify` and in CI. -# -# The case this suite exists for is `a report that graded nothing is refused`. -# The invocation CLOUD-102 specifies produces exactly that shape — 0 checks, 254 -# skipped, "no semver update required" — and reporting it green is how this gate -# would certify nothing forever. It is not hypothetical: the first version of -# the task inherited `CARGO_TERM_COLOR=always` from mise.toml, so its anchored -# pattern could never match the coloured summary line, and the refusal below -# silently did not fire (the CLOUD-199 defect, in a gate written to prevent the -# same class one layer up). The probe caught it; this case keeps it caught. - -setup() { - GATE="$BATS_TEST_DIRNAME/../mise-tasks/semver.sh" - REPO="$BATS_TEST_TMPDIR/repo" - BIN="$BATS_TEST_TMPDIR/bin" - mkdir -p "$REPO" "$BIN" - - # A repository with a baseline and one commit on top of it, so the - # declared-break walk has a real range to read. - git -C "$REPO" init -q -b main - git -C "$REPO" -c user.email=t@t -c user.name=t commit -q --allow-empty -m "chore: baseline" - git -C "$REPO" branch -f baseline - - # `cargo` is what the task invokes; the stub answers for `semver-checks`. - # Whatever the test wrote into $BATS_TEST_TMPDIR/report is echoed, and - # $BATS_TEST_TMPDIR/rc is the exit code — so a case states the tool's - # behaviour as data rather than by mocking a flag at a time. - cat >"$BIN/cargo" <<-'STUB' - #!/usr/bin/env bash - cat "$STUB_REPORT" 2>/dev/null - exit "$(cat "$STUB_RC" 2>/dev/null || echo 0)" - STUB - chmod +x "$BIN/cargo" - - cat >"$BIN/rustup" <<-'STUB' - #!/usr/bin/env bash - [ "$1" = "toolchain" ] && [ "$2" = "list" ] && echo stable - exit 0 - STUB - chmod +x "$BIN/rustup" - - cat >"$BIN/cargo-semver-checks" <<-'STUB' - #!/usr/bin/env bash - exit 0 - STUB - chmod +x "$BIN/cargo-semver-checks" - - # The toolchain the comparison runs under is derived from the compiler on - # PATH (CLOUD-593), so it is part of the stub environment rather than - # something a case reaches out of the sandbox for. - cat >"$BIN/rustc" <<-'STUB' - #!/usr/bin/env bash - echo "rustc 1.97.1 (8bab26f4f 2026-07-14)" - STUB - chmod +x "$BIN/rustc" - - export STUB_REPORT="$BATS_TEST_TMPDIR/report" - export STUB_RC="$BATS_TEST_TMPDIR/rc" - export SEMVER_ROOT="$REPO" - export SEMVER_BASELINE="baseline" - export PATH="$BIN:$PATH" - : >"$STUB_REPORT" - echo 0 >"$STUB_RC" -} - -# The tool's summary line for a run that graded `n` checks. -graded() { - printf ' Checked [ 0.106s] %s checks: %s pass, 31 skip\n' "$1" "$1" >"$STUB_REPORT" -} - -# A commit on top of the baseline, so the range the task walks is non-empty. -commit_with() { - git -C "$REPO" -c user.email=t@t -c user.name=t commit -q --allow-empty -m "$1" -} - -@test "a patch-compatible delta passes, and names the claim it verified" { - graded 223 - commit_with "fix(x): a compatible change" - run "$GATE" - [ "$status" -eq 0 ] - [[ "$output" == *"patch-compatible"* ]] -} - -@test "THE VACUOUS RUN: a report that graded 0 checks is exit 2, never a pass" { - # The shape the issue's own invocation produces. Exit 0 from the tool AND a - # reassuring summary, so only the check count distinguishes it from a real - # pass — which is why the count is what the gate reads. - graded 0 - commit_with "fix(x): a change nothing graded" - run "$GATE" - [ "$status" -eq 2 ] - [[ "$output" == *"0 checks"* ]] -} - -@test "an undeclared break fails, and names the lint rather than the payload" { - graded 223 - printf -- '--- failure enum_marked_non_exhaustive: enum marked #[non_exhaustive] ---\n' >>"$STUB_REPORT" - echo 100 >"$STUB_RC" - commit_with "fix(x): quietly breaking" - run "$GATE" - [ "$status" -eq 1 ] - [[ "$output" == *"enum_marked_non_exhaustive"* ]] - [[ "$output" == *"no commit declares it"* ]] -} - -@test "a break declared with a bang passes, and names the declaring commit" { - graded 223 - printf -- '--- failure enum_marked_non_exhaustive: enum marked #[non_exhaustive] ---\n' >>"$STUB_REPORT" - echo 100 >"$STUB_RC" - commit_with "feat(x)!: a declared break" - run "$GATE" - [ "$status" -eq 0 ] - [[ "$output" == *"DECLARED"* ]] -} - -@test "a break declared with a BREAKING CHANGE footer passes too" { - # Conventional Commits spells it two ways and the gate must read both, or an - # author who used the footer is told to use the bang they already meant. - graded 223 - printf -- '--- failure enum_marked_non_exhaustive: enum marked #[non_exhaustive] ---\n' >>"$STUB_REPORT" - echo 100 >"$STUB_RC" - commit_with "feat(x): a declared break - -BREAKING CHANGE: the enum is now non_exhaustive" - run "$GATE" - [ "$status" -eq 0 ] - [[ "$output" == *"DECLARED"* ]] -} - -@test "A DECLARATION ON THE BASELINE DOES NOT COUNT — only this branch's commits" { - # The range is BASE..HEAD for the same reason commit-lint's is: a `!` that - # landed long ago is not this branch declaring anything, and reading the - # whole history would let any past break license every future one. - graded 223 - printf -- '--- failure enum_marked_non_exhaustive: enum marked #[non_exhaustive] ---\n' >>"$STUB_REPORT" - echo 100 >"$STUB_RC" - git -C "$REPO" -c user.email=t@t -c user.name=t commit -q --allow-empty -m "feat(x)!: an OLD declared break" - git -C "$REPO" branch -f baseline - commit_with "fix(x): quietly breaking, on top of it" - run "$GATE" - [ "$status" -eq 1 ] - [[ "$output" == *"no commit declares it"* ]] -} - -@test "an exit code that is neither verdict is exit 2 — a broken run is not a pass" { - # A missing baseline ref, a crate that would not build, a crashed tool. The - # tool answers 0 or 100; anything else means the comparison never completed, - # and calling that green is the failure this whole gate is about. - graded 223 - echo 1 >"$STUB_RC" - commit_with "fix(x): a change" - run "$GATE" - [ "$status" -eq 2 ] - [[ "$output" == *"neither verdict"* ]] -} - -@test "a missing cargo-semver-checks is exit 2, never a silent pass" { - # PATH narrowed to the stub dir plus the system ones: the real tool is - # installed by mise for `verify`, so leaving the ambient PATH in place would - # find it and this case would assert nothing. - rm "$BIN/cargo-semver-checks" - graded 223 - PATH="$BIN:/usr/bin:/bin" run "$GATE" - [ "$status" -eq 2 ] - [[ "$output" == *"not on PATH"* ]] -} - -@test "output is a pointer — lint ids and a short sha, never the rustdoc it read" { - graded 223 - printf -- '--- failure enum_marked_non_exhaustive: enum marked #[non_exhaustive] ---\n' >>"$STUB_REPORT" - printf 'SECRET_RUSTDOC_PAYLOAD\n' >>"$STUB_REPORT" - echo 100 >"$STUB_RC" - commit_with "fix(x): quietly breaking" - run "$GATE" - [ "$status" -eq 1 ] - [[ "$output" != *"SECRET_RUSTDOC_PAYLOAD"* ]] -} - -# --- the toolchain the comparison runs under (CLOUD-593, CLOUD-654) ----------- -# -# It used to default to a floating rustup `stable`, on the premise that the -# pinned toolchain was too old for cargo-semver-checks. CLOUD-593 coupled the -# floor to the pin and inverted that: with `rust-version` at 1.97, a `stable` -# channel resolving 1.94.1 aborts the run with "requires rustc 1.97", exit 101 — -# "could not look", which `verify` correctly refuses. These rows hold the gate to -# asking the compiler that is actually on PATH. - -@test "the toolchain defaults to the one on PATH, not to a floating channel" { - # A version no channel would produce, so a pass cannot come from the ambient - # toolchain happening to match. - cat >"$BIN/rustc" <<-'STUB' - #!/usr/bin/env bash - echo "rustc 9.9.9 (deadbeef 2026-01-01)" - STUB - chmod +x "$BIN/rustc" - # The stub cargo records the toolchain selector it was invoked with. - cat >"$BIN/cargo" <<-'STUB' - #!/usr/bin/env bash - echo "$1" >"$STUB_SELECTOR" - cat "$STUB_REPORT" 2>/dev/null - exit "$(cat "$STUB_RC" 2>/dev/null || echo 0)" - STUB - chmod +x "$BIN/cargo" - export STUB_SELECTOR="$BATS_TEST_TMPDIR/selector" - graded 223 - commit_with "fix(x): a compatible change" - run "$GATE" - [ "$status" -eq 0 ] - [ "$(cat "$STUB_SELECTOR")" = "+9.9.9" ] -} - -@test "SEMVER_TOOLCHAIN still overrides, so the suite can drive another claim" { - cat >"$BIN/cargo" <<-'STUB' - #!/usr/bin/env bash - echo "$1" >"$STUB_SELECTOR" - cat "$STUB_REPORT" 2>/dev/null - exit "$(cat "$STUB_RC" 2>/dev/null || echo 0)" - STUB - chmod +x "$BIN/cargo" - export STUB_SELECTOR="$BATS_TEST_TMPDIR/selector" - graded 223 - commit_with "fix(x): a compatible change" - SEMVER_TOOLCHAIN=1.2.3 run "$GATE" - [ "$status" -eq 0 ] - [ "$(cat "$STUB_SELECTOR")" = "+1.2.3" ] -} - -@test "no rustc at all is exit 2, never a fall back to a floating channel" { - # Falling back to `stable` here is the defect this replaced: it is the one - # answer that looks like a verdict and is not. - rm "$BIN/rustc" - graded 223 - PATH="$BIN:/usr/bin:/bin" run "$GATE" - [ "$status" -eq 2 ] - [[ "$output" == *"no rustc on PATH"* ]] -} From bc5e9a33fb474faff42ddfeab3a39595bf21e950 Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Thu, 27 Aug 2026 07:24:51 +0000 Subject: [PATCH 33/38] fix(semver): the baseline tree is materialized through gix, not a git spawn MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `baseline_rustdoc` spawned `git worktree add --detach`, which `no_second_git_invoker_exists` refuses: CLOUD-740's terminal assertion is that nothing under `src/` names `git` as a literal program, and it is stronger for having no exempt module at all. The adapter needed a source tree at a rev, which is a traversal, not a subprocess. `git::materialize_rev` writes the tree at a rev blob by blob, reusing `walk_blob_ids` so the selection is the same implementation every other comparison in that module uses. Two things improve rather than merely move: a worktree keeps a registration OUTSIDE its directory, so removing the scratch stranded an entry the next run reported as "already registered" over a path that was gone — there is no registration now; and the destination is a plain directory, so nothing about the baseline can reach back into the repository it came from. Modes are not reproduced, stated as a bound: the one consumer is a `cargo doc` build, which reads and executes nothing. An empty traversal raises rather than returning. A doc build over an empty directory fails with a message about a missing manifest, which is a true statement about the wrong question — the vacuous shape one layer down. Refs: CLOUD-740, CLOUD-1050 --- crates/batten/src/git.rs | 59 +++++++++++++++++++++++++++++++++++++ crates/batten/src/semver.rs | 59 +++++++++++++------------------------ 2 files changed, 80 insertions(+), 38 deletions(-) diff --git a/crates/batten/src/git.rs b/crates/batten/src/git.rs index 0cf4990a8..df14504d6 100644 --- a/crates/batten/src/git.rs +++ b/crates/batten/src/git.rs @@ -2204,6 +2204,65 @@ fn without_comments(path: &str, text: &str) -> String { .join("\n") } +/// Write the tree at `rev` into `dest`, blob by blob (CLOUD-1050). +/// +/// **This exists because `git worktree add` is a git spawn and there is no +/// longer anywhere in this crate for one to live.** CLOUD-740's terminal +/// assertion — `no_second_git_invoker_exists` — forbids naming `git` as a +/// literal program anywhere under `src/`, and the `semver` adapter's baseline +/// build needs a source tree at a rev. A traversal that writes what it reads is +/// the whole of what the worktree was providing, and it is strictly better here +/// for two reasons the spawn version paid for in bugs: there is no registration +/// kept elsewhere, so removing the directory cannot strand a stale entry that +/// the next run reports as "already registered"; and the destination is a plain +/// directory rather than a linked worktree, so nothing about the baseline can +/// reach back into the repository it came from. +/// +/// **Modes are not reproduced, and that is a stated bound.** Every entry lands +/// as an ordinary file: the one consumer is a `cargo doc` build, which reads +/// manifests and sources and executes nothing out of the tree. A caller needing +/// an executable bit needs a different primitive, not an extra argument here. +/// +/// # Errors +/// +/// Raises when the repository cannot be opened, when `rev` does not resolve to a +/// tree, when a blob cannot be read, when a write fails — and, deliberately, +/// when the traversal selected **nothing**. An empty destination is the vacuous +/// shape: a doc build over it would fail with a message about a missing +/// manifest, which is a true statement about the wrong question. +pub fn materialize_rev(dir: &Path, rev: &str, dest: &Path) -> Result<()> { + let repository = open(dir)?; + + // COLLECTED FIRST, because the visitor is `FnMut` and writing inside it + // would make every filesystem error a value the traversal cannot carry out. + // The ids are cheap — that is the split `walk_blob_ids` exists for — so + // holding them costs a map of the tree rather than the tree's contents. + let mut blobs: Vec<(String, gix::ObjectId)> = Vec::new(); + walk_blob_ids(&repository, rev, "**", |path, id| { + blobs.push((path.to_owned(), id)); + })?; + if blobs.is_empty() { + return Err(UsageError::raise(format!( + "the tree at {rev:?} selected no file to materialize" + ))); + } + + for (path, id) in blobs { + let out = dest.join(&path); + if let Some(parent) = out.parent() { + std::fs::create_dir_all(parent).map_err(|err| { + UsageError::raise(format!("cannot create {}: {err}", parent.display())) + })?; + } + let object = repository.find_object(id).map_err(|err| { + UsageError::raise(format!("cannot read the blob behind {path:?}: {err}")) + })?; + std::fs::write(&out, &object.data) + .map_err(|err| UsageError::raise(format!("cannot write {}: {err}", out.display())))?; + } + Ok(()) +} + /// Classify every path a declared glob selects as added, edited or deleted /// against `base` (CLOUD-1059). /// diff --git a/crates/batten/src/semver.rs b/crates/batten/src/semver.rs index 5f476b805..4e5446e1b 100644 --- a/crates/batten/src/semver.rs +++ b/crates/batten/src/semver.rs @@ -214,19 +214,19 @@ pub fn against_rustdoc( /// * `--document-private-items` is not optional and was found by measurement: a /// baseline without it reported `constructible_struct_adds_private_field` /// spuriously, because that lint reasons about fields a public API cannot see. -/// * Its own `CARGO_TARGET_DIR` under the worktree, because the main one is -/// already held by whatever else `verify` is running — the same reasoning -/// `perf-pair` records for its two arms. +/// * Its own `CARGO_TARGET_DIR` beside the materialized tree, because the main +/// one is already held by whatever else `verify` is running — the same +/// reasoning `perf-pair` records for its two arms. /// /// # Errors /// -/// Every failure is could-not-look — no worktree, no build, no JSON where one -/// was expected — and each carries ONE line saying which. A caller must not read -/// any of them as a clean comparison, and a gate that cannot say why it could -/// not look is the shape this repository refuses to ship. +/// Every failure is could-not-look — no baseline tree, no build, no JSON where +/// one was expected — and each carries ONE line saying which. A caller must not +/// read any of them as a clean comparison, and a gate that cannot say why it +/// could not look is the shape this repository refuses to ship. #[expect( clippy::disallowed_types, - reason = "stays: building the baseline is this adapter's own work, and the git worktree plus the doc build are what the lock route IS (CLOUD-1050)" + reason = "stays: building the baseline is this adapter's own work, and the doc build is what the lock route IS (CLOUD-1050)" )] pub fn baseline_rustdoc( root: &Path, @@ -247,36 +247,19 @@ pub fn baseline_rustdoc( .map_err(|err| format!("the baseline scratch directory could not be resolved: {err}"))?; let worktree = at.join("tree"); let target = at.join("target"); - // PRUNE BEFORE ADDING, because the scratch is removed as a directory and git - // keeps its own registration elsewhere. Without this the second run fails - // with "already registered" over a path that is no longer there — a stale - // bookkeeping entry reported as a gate that could not look. - drop( - std::process::Command::new("git") - .args(["worktree", "prune"]) - .current_dir(root) - .stdout(std::process::Stdio::null()) - .stderr(std::process::Stdio::null()) - .status(), - ); - let added = std::process::Command::new("git") - .args(["worktree", "add", "--detach"]) - .arg(&worktree) - // THE BASELINE, never `HEAD`. A worktree at the branch's own tip would - // compare the change to itself and pass unconditionally, which is the - // vacuous shape this whole gate exists against. - .arg(baseline) - .current_dir(root) - .stdout(std::process::Stdio::piped()) - .stderr(std::process::Stdio::piped()) - .output() - .map_err(|err| format!("the baseline worktree could not be created: {err}"))?; - if !added.status.success() { - return Err(format!( - "the baseline worktree could not be created: {}", - last_line(&added.stderr) - )); - } + // MATERIALIZED THROUGH gix, never `git worktree add`. CLOUD-740's terminal + // assertion forbids naming `git` as a literal program anywhere in this + // crate, and a source tree at a rev is exactly what `git::materialize_rev` + // writes. It also removes the failure the spawn version shipped with: a + // worktree keeps a registration outside the directory, so removing the + // scratch stranded a stale entry and the next run refused over a path that + // was no longer there. + // + // THE BASELINE, never `HEAD`. A tree at the branch's own tip would compare + // the change to itself and pass unconditionally, which is the vacuous shape + // this whole gate exists against. + crate::git::materialize_rev(root, baseline, &worktree) + .map_err(|err| format!("the baseline tree could not be materialized: {err}"))?; let built = std::process::Command::new(ANALYSER) .arg(format!("+{toolchain}")) .args([ From 0471b9c4290c209265d2ad4b7c4695e64ce9dddd Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Thu, 27 Aug 2026 07:35:46 +0000 Subject: [PATCH 34/38] fix(semver): the toolchain read moves into the adapter `spawn-adapters` places MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `semver_toolchain` spawned `rustc --version` from `lib.rs`, and `policy/spawn-adapters.rego` places spawns by MODULE: `lib.rs` is not a placed adapter, so the site was `V-SPAWN-UNPLACED`. Four cases in `tests/prebuilt-lint.bats` — including "this repository is clean today" — were red on it, all naming the same `crates/batten/src/lib.rs:2430`. Its doc comment argued the spawn could not live in `semver.rs` "without importing the caller's environment". That reasoning was wrong: the caller's root reaches it through `current_dir`, exactly as it does for the two comparisons already there, and this read delegates to the same pinned toolchain they do. It belongs in the adapter, and the rule is what said so. `SEMVER_TOOLCHAIN` still overrides at the same point, so the seam the retired suite drove is unchanged; the two ledger arms that named its old home now name its new one. Refs: CLOUD-760, CLOUD-1050 --- crates/batten/src/lib.rs | 29 +--------------------- crates/batten/src/semver.rs | 40 ++++++++++++++++++++++++++++-- crates/batten/tests/semver_gate.rs | 7 +++--- 3 files changed, 43 insertions(+), 33 deletions(-) diff --git a/crates/batten/src/lib.rs b/crates/batten/src/lib.rs index 50cc52f8a..cab7274a9 100644 --- a/crates/batten/src/lib.rs +++ b/crates/batten/src/lib.rs @@ -2319,7 +2319,7 @@ fn run_semver_check( let baseline = baseline.unwrap_or("origin/main"); let release_type = release_type.unwrap_or("patch"); let package = package.unwrap_or("batten"); - let Some(toolchain) = semver_toolchain(root) else { + let Some(toolchain) = semver::toolchain(root) else { output::message( mode, Verbosity::Normal, @@ -2412,33 +2412,6 @@ fn semver_compare( } } -/// The pinned toolchain, read from the compiler that is actually active. -/// -/// A READ of the one authority rather than a fourth copy of the number: mise -/// puts the pin on PATH, and the copies are what `msrv-pin-agreement` exists to -/// hold together. -#[expect( - clippy::disallowed_types, - reason = "stays: asking the active compiler its version is how the pin is READ rather than restated, and it is the one spawn `semver.rs` cannot make for itself without importing the caller's environment (CLOUD-1050)" -)] -fn semver_toolchain(root: &Path) -> Option { - if let Ok(named) = std::env::var("SEMVER_TOOLCHAIN") - && !named.is_empty() - { - return Some(named); - } - let output = std::process::Command::new("rustc") - .arg("--version") - .current_dir(root) - .stdout(std::process::Stdio::piped()) - .stderr(std::process::Stdio::null()) - .output() - .ok()?; - let text = String::from_utf8_lossy(&output.stdout).into_owned(); - let version = text.split_whitespace().nth(1)?; - (!version.is_empty()).then(|| version.to_owned()) -} - /// The branch's own commits, which are the only ones that may declare its break. /// /// A declaration that already landed on the baseline licenses nothing here, and diff --git a/crates/batten/src/semver.rs b/crates/batten/src/semver.rs index 4e5446e1b..dd314f34d 100644 --- a/crates/batten/src/semver.rs +++ b/crates/batten/src/semver.rs @@ -37,8 +37,8 @@ //! this one survives a registry that has moved underneath it. //! //! **It is a FALLBACK and must stay one.** The rev route is the tool's own -//! well-tested path; taking it away would leave this module's worktree -//! orchestration as the only thing anyone exercises. [`Route`] is what the +//! well-tested path; taking it away would leave this module's own tree +//! materialization and doc build as the only thing anyone exercises. [`Route`] is what the //! caller reports, so a green never hides which baseline produced it. use std::path::{Path, PathBuf}; @@ -201,6 +201,42 @@ pub fn against_rustdoc( }) } +/// The pinned toolchain, read from the compiler that is actually active. +/// +/// A READ of the one authority rather than a fourth copy of the number: mise +/// puts the pin on PATH, and the copies are what `msrv-pin-agreement` exists to +/// hold together. `SEMVER_TOOLCHAIN` overrides it, which is the seam the retired +/// suite drove and the one thing about it that had to survive the port. +/// +/// **It lives here rather than beside its caller because `spawn-adapters` places +/// spawns by MODULE.** It sat in `lib.rs` first, on the reasoning that asking the +/// compiler its version is the caller's business — and `lib.rs` is not a placed +/// adapter, so the rule refused it. The reasoning was wrong anyway: this spawn +/// belongs to the same delegated analyser as the two below, and `current_dir` is +/// how the caller's root reaches it, exactly as it does for them. +#[must_use] +#[expect( + clippy::disallowed_types, + reason = "stays: asking the active compiler its version is how the pin is READ rather than restated, and it is the same delegated toolchain the two comparisons below invoke (CLOUD-1050)" +)] +pub fn toolchain(root: &Path) -> Option { + if let Ok(named) = std::env::var("SEMVER_TOOLCHAIN") + && !named.is_empty() + { + return Some(named); + } + let output = std::process::Command::new("rustc") + .arg("--version") + .current_dir(root) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::null()) + .output() + .ok()?; + let text = String::from_utf8_lossy(&output.stdout).into_owned(); + let version = text.split_whitespace().nth(1)?; + (!version.is_empty()).then(|| version.to_owned()) +} + /// Build the baseline's rustdoc JSON from the lock it committed. /// /// # Why every flag here is load-bearing diff --git a/crates/batten/tests/semver_gate.rs b/crates/batten/tests/semver_gate.rs index fee8b5f39..7874325dd 100644 --- a/crates/batten/tests/semver_gate.rs +++ b/crates/batten/tests/semver_gate.rs @@ -21,7 +21,8 @@ //! every one of them invisible to a stub. //! //! **The expensive route is not exercised here, and saying so is the point.** -//! Building a baseline rustdoc takes ~30s and a real git worktree; a unit case +//! Building a baseline rustdoc takes ~30s and a materialized copy of the +//! baseline tree; a unit case //! that faked it would assert the fixture rather than the engine, which is the //! `with input as` defect one layer over. What IS asserted here is everything //! that decides — the vacuous-run refusal, the exit mapping, the declared-break @@ -50,8 +51,8 @@ //! CHANGED — behaviour that diverges deliberately, each with its reason. //! // changed: "semver.bats::a missing cargo-semver-checks is exit 2, never a silent pass" crates/batten/src/semver.rs the shell probed `command -v` and refused before running; the adapter has no separate probe because a spawn that cannot start IS the could-not-look it would have reported, and one channel cannot disagree with itself -// changed: "semver.bats::the toolchain defaults to the one on PATH, not to a floating channel" crates/batten/src/lib.rs unchanged in effect and moved in place: `semver_toolchain` reads `rustc --version`, which is the same READ of the one authority the shell made, asserted where it now lives -// changed: "semver.bats::SEMVER_TOOLCHAIN still overrides, so the suite can drive another claim" crates/batten/src/lib.rs the seam survives as the same environment variable, read at the same point; what changed is that a suite driving it no longer needs a stub on PATH to observe the effect +// changed: "semver.bats::the toolchain defaults to the one on PATH, not to a floating channel" crates/batten/src/semver.rs unchanged in effect and moved in place: `semver::toolchain` reads `rustc --version`, which is the same READ of the one authority the shell made; it lives in the adapter rather than beside its caller because `spawn-adapters` places spawns by module and `lib.rs` is not a placed one +// changed: "semver.bats::SEMVER_TOOLCHAIN still overrides, so the suite can drive another claim" crates/batten/src/semver.rs the seam survives as the same environment variable, read at the same point; what changed is that a suite driving it no longer needs a stub on PATH to observe the effect // changed: "semver.bats::no rustc at all is exit 2, never a fall back to a floating channel" crates/batten/src/lib.rs same predicate, same exit code, and the refusal now names the checkout rather than the channel because there is no channel left to fall back to //! //! The shell's `rustup toolchain install` fallback is **gone rather than ported**, From 00a3abdac8eb2f80546b49ba86b983728571830f Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Thu, 27 Aug 2026 07:43:04 +0000 Subject: [PATCH 35/38] chore(bench): regenerate the suite cost corpus over the 153 suites that remain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Derived from a complete `test:bats` run — 2798/2798 cases, 153 suites, 1249.1s serial. This PR retires eight bats suites, so the corpus loses their rows and every share is recomputed against the smaller total. A first attempt at this published a corpus over 37 suites and was thrown away. `suite-bench` is faithful to `target/bats-report/report.xml`, and the report in the tree was the TRUNCATED one a cancelled `verify` lap left behind. Its staleness arm refuses a report naming a retired suite and has no term for one naming too few, so nothing refused it — the completeness term that would have is CLOUD-1076, which cannot be written here because repairing `mise-tasks/suite-bench.sh` is `V-SHELL-RULE-EDITED`. Refs: CLOUD-352, CLOUD-1076 --- bench/suites/RESULTS.md | 277 ++++++++++++++++++++-------------------- 1 file changed, 138 insertions(+), 139 deletions(-) diff --git a/bench/suites/RESULTS.md b/bench/suites/RESULTS.md index 46fb7afa4..56da9cee2 100644 --- a/bench/suites/RESULTS.md +++ b/bench/suites/RESULTS.md @@ -6,162 +6,161 @@ 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: 154 -- serial total: 954.6s +- suites: 153 +- serial total: 1249.1s | seconds | share | suite | | ---: | ---: | --- | -| 147.1 | 15.4% | `tests/land-lock.bats` | -| 114.1 | 11.9% | `tests/derived-check.bats` | -| 101.9 | 10.7% | `tests/ci-wait.bats` | -| 74.5 | 7.8% | `tests/land.bats` | -| 54.2 | 5.7% | `tests/hooks-wiring-check.bats` | -| 36.1 | 3.8% | `tests/helpers.bats` | -| 34.6 | 3.6% | `tests/main-watch.bats` | -| 31.5 | 3.3% | `tests/ci-local-parity.bats` | -| 28.9 | 3.0% | `tests/prebuilt-lint.bats` | -| 24.2 | 2.5% | `tests/hook-latency-drift.bats` | -| 16.4 | 1.7% | `tests/graph-check.bats` | -| 15.6 | 1.6% | `tests/sbom-check.bats` | -| 15.5 | 1.6% | `tests/claim-check.bats` | -| 14.9 | 1.6% | `tests/config-lint.bats` | -| 12.5 | 1.3% | `tests/board-diff-overlap.bats` | -| 8.1 | 0.8% | `tests/run-shape-guard.bats` | -| 7.8 | 0.8% | `tests/ready-lint.bats` | -| 7.6 | 0.8% | `tests/token-bench.bats` | -| 7.4 | 0.8% | `tests/target-race.bats` | -| 7.2 | 0.8% | `tests/ready-guard.bats` | -| 7.0 | 0.7% | `tests/mutant.bats` | -| 6.6 | 0.7% | `tests/released.bats` | -| 5.9 | 0.6% | `tests/mcp-allow-check.bats` | -| 5.8 | 0.6% | `tests/board-sweep.bats` | -| 5.7 | 0.6% | `tests/renovate-config-validator.bats` | -| 5.7 | 0.6% | `tests/lock-complete.bats` | -| 5.4 | 0.6% | `tests/replay.bats` | -| 5.3 | 0.6% | `tests/release-tracking-check.bats` | -| 5.1 | 0.5% | `tests/pre-commit-staging.bats` | -| 4.8 | 0.5% | `tests/session-start.bats` | -| 4.7 | 0.5% | `tests/release-assets-check.bats` | -| 4.5 | 0.5% | `tests/step-receipt.bats` | -| 4.4 | 0.5% | `tests/sbom.bats` | -| 4.4 | 0.5% | `tests/singleton.bats` | -| 4.0 | 0.4% | `tests/in-progress-drain.bats` | -| 3.8 | 0.4% | `tests/task-registry.bats` | -| 3.7 | 0.4% | `tests/schema-check.bats` | -| 3.7 | 0.4% | `tests/ready-cites-check.bats` | -| 3.5 | 0.4% | `tests/doctor-race.bats` | -| 3.4 | 0.4% | `tests/reference-check.bats` | -| 3.3 | 0.3% | `tests/with-lock.bats` | -| 3.3 | 0.3% | `tests/semver.bats` | -| 3.1 | 0.3% | `tests/ntia-check.bats` | -| 3.0 | 0.3% | `tests/hk-selection.bats` | -| 3.0 | 0.3% | `tests/land-divergence.bats` | -| 2.6 | 0.3% | `tests/target-ensure.bats` | -| 2.2 | 0.2% | `tests/landed-check.bats` | -| 2.1 | 0.2% | `tests/rules-drift.bats` | -| 2.0 | 0.2% | `tests/suite-select.bats` | -| 2.0 | 0.2% | `tests/claim-race-check.bats` | -| 2.0 | 0.2% | `tests/unlanded-check.bats` | -| 1.9 | 0.2% | `tests/config-deprecations.bats` | -| 1.9 | 0.2% | `tests/spec-ref-check.bats` | -| 1.8 | 0.2% | `tests/closing-key-check.bats` | -| 1.8 | 0.2% | `tests/ci-slow-needed.bats` | -| 1.6 | 0.2% | `tests/reclaim-census.bats` | -| 1.6 | 0.2% | `tests/skill-check.bats` | -| 1.6 | 0.2% | `tests/tree-clean.bats` | -| 1.6 | 0.2% | `tests/finding-sink-check.bats` | -| 1.5 | 0.2% | `tests/module-map-check.bats` | -| 1.5 | 0.2% | `tests/memories-check.bats` | -| 1.5 | 0.2% | `tests/bot-issue.bats` | -| 1.4 | 0.2% | `tests/perf-record.bats` | -| 1.4 | 0.1% | `tests/signing-posture.bats` | -| 1.4 | 0.1% | `tests/alive.bats` | -| 1.3 | 0.1% | `tests/claimed-keys.bats` | -| 1.3 | 0.1% | `tests/verify.bats` | -| 1.3 | 0.1% | `tests/ci-tools-check.bats` | +| 195.0 | 15.6% | `tests/land-lock.bats` | +| 138.0 | 11.0% | `tests/derived-check.bats` | +| 102.2 | 8.2% | `tests/ci-wait.bats` | +| 101.2 | 8.1% | `tests/session-start.bats` | +| 90.2 | 7.2% | `tests/land.bats` | +| 63.0 | 5.0% | `tests/hooks-wiring-check.bats` | +| 47.7 | 3.8% | `tests/prebuilt-lint.bats` | +| 39.5 | 3.2% | `tests/ci-local-parity.bats` | +| 36.1 | 2.9% | `tests/helpers.bats` | +| 34.5 | 2.8% | `tests/main-watch.bats` | +| 24.3 | 1.9% | `tests/hook-latency-drift.bats` | +| 21.1 | 1.7% | `tests/sbom-check.bats` | +| 20.4 | 1.6% | `tests/graph-check.bats` | +| 19.5 | 1.6% | `tests/token-bench.bats` | +| 17.7 | 1.4% | `tests/claim-check.bats` | +| 15.4 | 1.2% | `tests/config-lint.bats` | +| 14.2 | 1.1% | `tests/board-diff-overlap.bats` | +| 10.3 | 0.8% | `tests/run-shape-guard.bats` | +| 9.1 | 0.7% | `tests/ready-lint.bats` | +| 8.2 | 0.7% | `tests/target-race.bats` | +| 7.9 | 0.6% | `tests/ready-guard.bats` | +| 7.6 | 0.6% | `tests/released.bats` | +| 7.6 | 0.6% | `tests/mcp-allow-check.bats` | +| 7.4 | 0.6% | `tests/step-receipt.bats` | +| 7.3 | 0.6% | `tests/mutant.bats` | +| 7.2 | 0.6% | `tests/board-sweep.bats` | +| 6.5 | 0.5% | `tests/renovate-config-validator.bats` | +| 6.2 | 0.5% | `tests/release-tracking-check.bats` | +| 6.1 | 0.5% | `tests/replay.bats` | +| 5.9 | 0.5% | `tests/lock-complete.bats` | +| 5.8 | 0.5% | `tests/sbom.bats` | +| 5.6 | 0.4% | `tests/release-assets-check.bats` | +| 5.0 | 0.4% | `tests/in-progress-drain.bats` | +| 5.0 | 0.4% | `tests/schema-check.bats` | +| 5.0 | 0.4% | `tests/task-registry.bats` | +| 4.8 | 0.4% | `tests/singleton.bats` | +| 4.8 | 0.4% | `tests/ntia-check.bats` | +| 4.4 | 0.4% | `tests/suite-select.bats` | +| 4.2 | 0.3% | `tests/pre-commit-staging.bats` | +| 4.1 | 0.3% | `tests/reference-check.bats` | +| 4.0 | 0.3% | `tests/ready-cites-check.bats` | +| 3.8 | 0.3% | `tests/land-divergence.bats` | +| 3.7 | 0.3% | `tests/unlanded-check.bats` | +| 3.6 | 0.3% | `tests/doctor-race.bats` | +| 3.5 | 0.3% | `tests/target-ensure.bats` | +| 3.2 | 0.3% | `tests/hk-selection.bats` | +| 3.2 | 0.3% | `tests/verify.bats` | +| 3.0 | 0.2% | `tests/with-lock.bats` | +| 2.8 | 0.2% | `tests/landed-check.bats` | +| 2.5 | 0.2% | `tests/tree-clean.bats` | +| 2.4 | 0.2% | `tests/install.bats` | +| 2.3 | 0.2% | `tests/rules-drift.bats` | +| 2.3 | 0.2% | `tests/skill-check.bats` | +| 2.2 | 0.2% | `tests/target-prune.bats` | +| 2.2 | 0.2% | `tests/config-deprecations.bats` | +| 2.2 | 0.2% | `tests/spec-ref-check.bats` | +| 2.1 | 0.2% | `tests/closing-key-check.bats` | +| 2.1 | 0.2% | `tests/claim-race-check.bats` | +| 2.0 | 0.2% | `tests/finding-sink-check.bats` | +| 1.9 | 0.2% | `tests/perf-assert.bats` | +| 1.9 | 0.2% | `tests/memories-check.bats` | +| 1.8 | 0.1% | `tests/bot-issue.bats` | +| 1.8 | 0.1% | `tests/signing-posture.bats` | +| 1.8 | 0.1% | `tests/reclaim-census.bats` | +| 1.7 | 0.1% | `tests/alive.bats` | +| 1.7 | 0.1% | `tests/claimed-keys.bats` | +| 1.7 | 0.1% | `tests/ready-lint-deferral.bats` | +| 1.7 | 0.1% | `tests/ci-tools-check.bats` | +| 1.7 | 0.1% | `tests/ci-slow-needed.bats` | +| 1.5 | 0.1% | `tests/install-check.bats` | +| 1.5 | 0.1% | `tests/verified.bats` | +| 1.4 | 0.1% | `tests/ci-lease-precondition.bats` | +| 1.4 | 0.1% | `tests/perf-record.bats` | +| 1.4 | 0.1% | `tests/mutant-census.bats` | +| 1.3 | 0.1% | `tests/awk-regex-check.bats` | +| 1.3 | 0.1% | `tests/spawn-census.bats` | +| 1.3 | 0.1% | `tests/deferral-check.bats` | +| 1.2 | 0.1% | `tests/done-check.bats` | | 1.2 | 0.1% | `tests/linear-check.bats` | -| 1.1 | 0.1% | `tests/ready-lint-deferral.bats` | -| 1.1 | 0.1% | `tests/mutant-census.bats` | -| 1.1 | 0.1% | `tests/ci-lease-precondition.bats` | -| 1.0 | 0.1% | `tests/awk-regex-check.bats` | -| 1.0 | 0.1% | `tests/spawn-census.bats` | -| 1.0 | 0.1% | `tests/install-check.bats` | -| 1.0 | 0.1% | `tests/deferral-check.bats` | -| 0.9 | 0.1% | `tests/land-divergence-assert.bats` | -| 0.9 | 0.1% | `tests/done-check.bats` | -| 0.9 | 0.1% | `tests/nonverdict-scan.bats` | -| 0.9 | 0.1% | `tests/perf-assert.bats` | -| 0.8 | 0.1% | `tests/release-backfill.bats` | +| 1.1 | 0.1% | `tests/land-divergence-assert.bats` | +| 1.1 | 0.1% | `tests/nonverdict-scan.bats` | +| 1.1 | 0.1% | `tests/release-backfill.bats` | +| 1.0 | 0.1% | `tests/attestation-check.bats` | +| 1.0 | 0.1% | `tests/pr-unsubscribed.bats` | +| 1.0 | 0.1% | `tests/module-map-check.bats` | +| 0.9 | 0.1% | `tests/done-pr-check.bats` | +| 0.9 | 0.1% | `tests/fact-record-keying.bats` | +| 0.9 | 0.1% | `tests/doctor.bats` | +| 0.9 | 0.1% | `tests/timeout-drift.bats` | +| 0.9 | 0.1% | `tests/checks-green.bats` | +| 0.9 | 0.1% | `tests/gh-guard.bats` | | 0.8 | 0.1% | `tests/lint-deno.bats` | -| 0.8 | 0.1% | `tests/target-prune.bats` | -| 0.7 | 0.1% | `tests/attestation-check.bats` | -| 0.7 | 0.1% | `tests/pr-unsubscribed.bats` | -| 0.7 | 0.1% | `tests/done-pr-check.bats` | -| 0.7 | 0.1% | `tests/gh-guard.bats` | -| 0.7 | 0.1% | `tests/render-cli.bats` | -| 0.7 | 0.1% | `tests/fact-record-keying.bats` | -| 0.7 | 0.1% | `tests/checks-green.bats` | -| 0.7 | 0.1% | `tests/timeout-drift.bats` | -| 0.7 | 0.1% | `tests/sbom-binary.bats` | -| 0.7 | 0.1% | `tests/doctor.bats` | -| 0.6 | 0.1% | `tests/perf-compare.bats` | -| 0.6 | 0.1% | `tests/verified.bats` | +| 0.8 | 0.1% | `tests/sbom-binary.bats` | +| 0.8 | 0.1% | `tests/transcript-corpus-check.bats` | +| 0.8 | 0.1% | `tests/mcp-attach-check.bats` | +| 0.8 | 0.1% | `tests/render-cli.bats` | +| 0.8 | 0.1% | `tests/mcp-timeout-budget.bats` | +| 0.8 | 0.1% | `tests/perf-compare.bats` | +| 0.7 | 0.1% | `tests/suite-bench-check.bats` | +| 0.7 | 0.1% | `tests/duplicate-close-check.bats` | +| 0.7 | 0.1% | `tests/merged-pr-keys.bats` | +| 0.7 | 0.1% | `tests/stop-posture-check.bats` | +| 0.7 | 0.1% | `tests/hook-matcher-check.bats` | | 0.6 | 0.1% | `tests/evaluator-closure-check.bats` | -| 0.6 | 0.1% | `tests/merged-pr-keys.bats` | -| 0.6 | 0.1% | `tests/lint-rego.bats` | -| 0.6 | 0.1% | `tests/duplicate-close-check.bats` | -| 0.6 | 0.1% | `tests/hook-matcher-check.bats` | -| 0.6 | 0.1% | `tests/mcp-attach-check.bats` | -| 0.6 | 0.1% | `tests/mcp-timeout-budget.bats` | -| 0.5 | 0.1% | `tests/suite-bench-check.bats` | -| 0.5 | 0.1% | `tests/install.bats` | -| 0.5 | 0.1% | `tests/hook-profile-check.bats` | -| 0.5 | 0.1% | `tests/macos-link-check.bats` | -| 0.5 | 0.1% | `tests/checksums.bats` | -| 0.5 | 0.1% | `tests/stop-posture-check.bats` | -| 0.4 | 0.0% | `tests/publish-credential-check.bats` | -| 0.4 | 0.0% | `tests/msrv-pin-agreement.bats` | -| 0.4 | 0.0% | `tests/pipefail-grep-check.bats` | -| 0.4 | 0.0% | `tests/board-payloads.bats` | -| 0.4 | 0.0% | `tests/hook-pin-check.bats` | -| 0.4 | 0.0% | `tests/sonar-gate.bats` | +| 0.6 | 0.0% | `tests/lint-rego.bats` | +| 0.6 | 0.0% | `tests/hook-profile-check.bats` | +| 0.6 | 0.0% | `tests/checksums.bats` | +| 0.6 | 0.0% | `tests/macos-link-check.bats` | +| 0.6 | 0.0% | `tests/pipefail-grep-check.bats` | +| 0.5 | 0.0% | `tests/publish-credential-check.bats` | +| 0.5 | 0.0% | `tests/sonar-gate.bats` | +| 0.5 | 0.0% | `tests/board-payloads.bats` | +| 0.5 | 0.0% | `tests/serena-mcp.bats` | +| 0.5 | 0.0% | `tests/abandon-matrix.bats` | +| 0.5 | 0.0% | `tests/hook-pin-check.bats` | +| 0.5 | 0.0% | `tests/msrv-pin-agreement.bats` | +| 0.5 | 0.0% | `tests/connector-allow-guard.bats` | +| 0.5 | 0.0% | `tests/land-lock-check.bats` | | 0.4 | 0.0% | `tests/digest-major-agreement.bats` | -| 0.4 | 0.0% | `tests/abandon-matrix.bats` | | 0.4 | 0.0% | `tests/branch-age-check.bats` | -| 0.4 | 0.0% | `tests/license-table-check.bats` | -| 0.4 | 0.0% | `tests/connector-allow-guard.bats` | -| 0.4 | 0.0% | `tests/land-lock-check.bats` | -| 0.3 | 0.0% | `tests/run-shape-guard-quoting.bats` | -| 0.3 | 0.0% | `tests/transcript-corpus-check.bats` | -| 0.3 | 0.0% | `tests/serena-mcp.bats` | -| 0.3 | 0.0% | `tests/timeout-check.bats` | -| 0.3 | 0.0% | `tests/commit-attribution.bats` | +| 0.4 | 0.0% | `tests/release-due.bats` | +| 0.4 | 0.0% | `tests/nonverdict-assert.bats` | +| 0.4 | 0.0% | `tests/no-doctests.bats` | +| 0.4 | 0.0% | `tests/timeout-check.bats` | +| 0.4 | 0.0% | `tests/run-shape-guard-quoting.bats` | +| 0.4 | 0.0% | `tests/task-fail-closed.bats` | +| 0.4 | 0.0% | `tests/connector-allow-resolve.bats` | +| 0.4 | 0.0% | `tests/commit-attribution.bats` | +| 0.4 | 0.0% | `tests/container-setup.bats` | | 0.3 | 0.0% | `tests/pkl-check.bats` | -| 0.3 | 0.0% | `tests/no-doctests.bats` | -| 0.3 | 0.0% | `tests/report-only-check.bats` | -| 0.3 | 0.0% | `tests/release-due.bats` | -| 0.3 | 0.0% | `tests/nonverdict-assert.bats` | -| 0.3 | 0.0% | `tests/commit-convention.bats` | +| 0.3 | 0.0% | `tests/cap-drift.bats` | | 0.3 | 0.0% | `tests/batten-glob-check.bats` | -| 0.3 | 0.0% | `tests/container-setup.bats` | +| 0.3 | 0.0% | `tests/commit-convention.bats` | | 0.3 | 0.0% | `tests/container-preflight.bats` | -| 0.3 | 0.0% | `tests/connector-allow-resolve.bats` | -| 0.2 | 0.0% | `tests/cap-drift.bats` | -| 0.2 | 0.0% | `tests/mise-pin-agreement.bats` | -| 0.2 | 0.0% | `tests/ci-drift.bats` | -| 0.2 | 0.0% | `tests/rust-paths-check.bats` | -| 0.2 | 0.0% | `tests/coderabbit-config-check.bats` | +| 0.3 | 0.0% | `tests/license-table-check.bats` | +| 0.3 | 0.0% | `tests/report-only-check.bats` | +| 0.3 | 0.0% | `tests/ci-drift.bats` | +| 0.3 | 0.0% | `tests/mise-pin-agreement.bats` | +| 0.3 | 0.0% | `tests/coderabbit-config-check.bats` | +| 0.3 | 0.0% | `tests/git-hook.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.2 | 0.0% | `tests/remedy-payload-source.bats` | -| 0.2 | 0.0% | `tests/token-bench-check.bats` | -| 0.1 | 0.0% | `tests/dist.bats` | +| 0.2 | 0.0% | `tests/dist.bats` | | 0.1 | 0.0% | `tests/test-bats-parallel.bats` | -| 0.1 | 0.0% | `tests/task-fail-closed.bats` | | 0.1 | 0.0% | `tests/perf-pair.bats` | | 0.1 | 0.0% | `tests/egress-check.bats` | | 0.1 | 0.0% | `tests/evaluator-io-check.bats` | +| 0.1 | 0.0% | `tests/zizmor-split.bats` | | 0.1 | 0.0% | `tests/darwin-link.bats` | | 0.1 | 0.0% | `tests/cross-check.bats` | -| 0.0 | 0.0% | `tests/zizmor-split.bats` | From b05f9bf7bf9b13f48590cdbce069d3a8fec2e891 Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Thu, 27 Aug 2026 09:34:26 +0000 Subject: [PATCH 36/38] fix(semver): five censuses name the new verb, and every one of them found it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `mise run fix` is green. Each of these was a gate refusing a claim the semver port made without noticing, and they surfaced one at a time because the first failure cancels the run: * `every_flag_id_is_snake_case` — `--release-type`'s ID was kebab-case. An id is the emitted spec's `name`, so a consumer matches on it; the long form stays kebab. `cli.rs` looked it up by the old spelling, which `every_leaf_verb_dispatches` then caught. * `every_data_emitting_verb_declares_the_json_flag` — `semver check` claimed `data_channel: true` with no `-J` behind it. It emits one human line naming the route and the failing lints; there is no JSON emitter, so the honest declaration is `false`. * `the_emitted_surface_is_exactly_the_committed_row_set` — the two `semver` rows are stated rather than added silently, which is the prompt to reconcile house-style §2 in the same change. * `module-layering` reported `V-LAYER-UNPLACED` on `semver.rs`. That coverage arm has now named a new module before a human did four times. `semver -> git` is not a forbidden edge, so a placement was all it wanted. * `every_leaf_verb_is_classified` — `semver check` owed a pointer-only disposition. It reads a delegated analyser's report and a range of commit subjects and emits lint ids and a short sha, so `PointerOnly` is the law here rather than an exception. And `no_artifact_name_reaches_the_core` fired on two doc comments in `semver.rs` that named `mise.toml`. Non-negotiable rule 1: the core knows the TOOL, and which file a consumer pins it in is the consumer's business. Refs: CLOUD-244, CLOUD-359, CLOUD-772, CLOUD-1050 --- crates/batten/src/cli.rs | 2 +- crates/batten/src/semver.rs | 9 +++++++-- crates/batten/src/spec.rs | 6 ++++++ crates/batten/src/surface.rs | 16 +++++++++++----- crates/batten/tests/pointer_only.rs | 13 +++++++++++++ policy/module-layering.rego | 6 ++++-- 6 files changed, 42 insertions(+), 10 deletions(-) diff --git a/crates/batten/src/cli.rs b/crates/batten/src/cli.rs index a166828e3..41ce3b7cf 100644 --- a/crates/batten/src/cli.rs +++ b/crates/batten/src/cli.rs @@ -827,7 +827,7 @@ fn semver_of(matches: &ArgMatches) -> Option { match matches.subcommand()? { ("check", matches) => Some(SemverCommand::Check { baseline: matches.get_one::("baseline").cloned(), - release_type: matches.get_one::("release-type").cloned(), + release_type: matches.get_one::("release_type").cloned(), package: matches.get_one::("package").cloned(), }), _ => None, diff --git a/crates/batten/src/semver.rs b/crates/batten/src/semver.rs index dd314f34d..3082e99f2 100644 --- a/crates/batten/src/semver.rs +++ b/crates/batten/src/semver.rs @@ -45,7 +45,12 @@ use std::path::{Path, PathBuf}; use crate::exit::ExitCode; -/// The analyser this module delegates to, pinned by `mise.toml`. +/// The analyser this module delegates to. +/// +/// The core names the TOOL, never where a consumer pins it: which file carries +/// the pin is the consumer's business, and naming it here is non-negotiable +/// rule 1's violation — `no_artifact_name_reaches_the_core` said so about this +/// very line. const ANALYSER: &str = "cargo"; /// What the tool prints when it graded nothing. A run that graded nothing has @@ -151,7 +156,7 @@ pub fn against_rev( .args(["--package", package]) .args(["--baseline-rev", baseline]) .args(["--release-type", release_type]) - // Overriding `mise.toml [env]`'s `always`, and load-bearing rather than + // Overriding whatever the caller's environment set, and load-bearing rather than // cosmetic: the report below is PARSED, and a gate that parses colour is // CLOUD-199's defect — an anchored pattern that can never match because // escape sequences sit between the anchor and the word. diff --git a/crates/batten/src/spec.rs b/crates/batten/src/spec.rs index a485b8ad4..6d072c838 100644 --- a/crates/batten/src/spec.rs +++ b/crates/batten/src/spec.rs @@ -538,6 +538,12 @@ mod tests { "receipt".to_owned(), "receipt record".to_owned(), "receipt status".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(), diff --git a/crates/batten/src/surface.rs b/crates/batten/src/surface.rs index ab3fc8c7f..e7580b00f 100644 --- a/crates/batten/src/surface.rs +++ b/crates/batten/src/surface.rs @@ -495,7 +495,7 @@ const SEMVER_BASELINE: FlagDecl = FlagDecl { /// coming and every break is compatible with it. Measured: 0 checks graded /// without it, 223 with. const SEMVER_RELEASE_TYPE: FlagDecl = FlagDecl { - id: "release-type", + id: "release_type", long: Some("release-type"), short: None, help: "The bump being claimed, which is what the delta is judged against", @@ -1789,9 +1789,10 @@ pub const SURFACE: &[CommandDecl] = &[ // CLOUD-1059 made editing a shell rule refusable. // // NOT `read`, and the distinction is the point: this subtree spawns - // `cargo-semver-checks`, and its fallback adds a git worktree and a doc - // build. A row claiming `read` here would put a spawning verb on the derived - // read-only allowlist, which is the claim that allowlist exists to make true. + // `cargo-semver-checks`, and its fallback materializes the baseline tree and + // runs a doc build over it. A row claiming `read` here would put a spawning + // verb on the derived read-only allowlist, which is the claim that allowlist + // exists to make true. CommandDecl { path: "semver", about: "Whether this branch's API delta is compatible with the bump it claims", @@ -1802,7 +1803,12 @@ pub const SURFACE: &[CommandDecl] = &[ CommandDecl { path: "semver check", about: "Refuse an API break this branch's commits do not declare", - data_channel: true, + // NO DATA CHANNEL, declared rather than defaulted. The verdict is one + // human line naming the route and the failing lint ids; there is no `-J` + // emitter behind it, and claiming the channel without one is the drift + // `every_data_emitting_verb_declares_the_json_flag` exists to catch — it + // caught this. + data_channel: false, effect: Effect::Write, flags: &[SEMVER_BASELINE, SEMVER_RELEASE_TYPE, SEMVER_PACKAGE], }, diff --git a/crates/batten/tests/pointer_only.rs b/crates/batten/tests/pointer_only.rs index c0704f4c0..c95f17a30 100644 --- a/crates/batten/tests/pointer_only.rs +++ b/crates/batten/tests/pointer_only.rs @@ -832,6 +832,19 @@ const CENSUS: &[Verb] = &[ stdin: Stdin::Nothing, disposition: Disposition::PointerOnly, }, + // The API-compatibility gate (CLOUD-1050). It reads a delegated analyser's + // report and a range of commit messages — two of the content-richest inputs + // on this surface — and emits the failing LINT IDS and a short sha, never a + // line of the rustdoc it compared or a subject it scanned. In this corpus it + // answers could-not-look, which is the honest outcome for a fixture with no + // package to compare; the refusal is a pointer either way, and that is what + // is being decided here. + Verb { + path: "semver check", + args: &[], + stdin: Stdin::Nothing, + disposition: Disposition::PointerOnly, + }, Verb { path: "defects query", args: &[], diff --git a/policy/module-layering.rego b/policy/module-layering.rego index 0f32a12f9..dc2457b54 100644 --- a/policy/module-layering.rego +++ b/policy/module-layering.rego @@ -77,8 +77,10 @@ declared_modules := { "brief", "main", "selfwrite", # `patch` arrived with CLOUD-739 and this rule named it before a human did — # the same property the three above record, working a second time. `symbols` - # arrived with CLOUD-760 and it worked a third. - "patch", "symbols", + # arrived with CLOUD-760 and it worked a third. `semver` arrived with + # CLOUD-1050 and it worked a fourth: the module was written, its tests were + # green, and this rule is what said nobody had placed it. + "patch", "symbols", "semver", # `recorder` arrived with CLOUD-1051 and it worked a fourth time: the module # landed undeclared and this rule named it, before any reviewer did. It is a # writer rather than a decider — it accumulates what a gate already said — so From c6d171e62efdf76edd42ff3c2ae8e818ef6e4242 Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Thu, 27 Aug 2026 10:09:31 +0000 Subject: [PATCH 37/38] fix(tests): the shell-stub cases are unix-only, which is what Windows was saying MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `board_record::a_created_row_is_recorded_with_its_id_updated_at_and_a_green_verdict` failed on the Windows runner with every recorder column `-`. The cause is not the recorder: the fixtures are `#!/bin/sh` stubs, and the spawn ladder's third rung resolves the interpreter a shebang NAMES — `/bin/sh`, which is not a program a Windows runner can start. So the suite was asserting the ladder rather than its own subject. `tests/bundle.rs` already carries `#![cfg(unix)]` for exactly this, citing CLOUD-113. Following that precedent rather than inventing a second answer: * `board_record.rs` is gated at file level. Every case there drives columns a program produces, so there is no cross-platform half to keep — and `tests/board-write-record.bats`, the suite it replaces, never ran on Windows either, so the narrowing costs nothing that was there before. * `stop_posture.rs` gates only the NINE stub-spawning cases. The seven that judge the module itself spawn nothing and stay cross-platform; a file-level gate would have thrown that coverage away for no reason. `stub` and `stop_with_transcript` become `#[cfg(unix)]` with them, since they are reachable from nothing else — otherwise the Windows build trades a test failure for a dead-code error, which is the same discovery one runner later. Two earlier attempts at this failure were never actually observed: the PR was a draft, so runs 511 through 521 all concluded `skipped`. `mise run cross-check` type-checks `--all-targets` against `x86_64-pc-windows-gnu` and is green, so this one is proven here rather than on the next lap. Refs: CLOUD-113, CLOUD-1051 --- crates/batten/tests/board_record.rs | 18 +++++++++++ crates/batten/tests/stop_posture.rs | 47 +++++++++++++++++++++++++++++ 2 files changed, 65 insertions(+) diff --git a/crates/batten/tests/board_record.rs b/crates/batten/tests/board_record.rs index 8720e463b..9d4b23aa9 100644 --- a/crates/batten/tests/board_record.rs +++ b/crates/batten/tests/board_record.rs @@ -89,6 +89,24 @@ //! intersected later by the gate — so the retired case asserting an untouched //! file is still recorded is carried under a name that says what it measures. +//! # UNIX-ONLY, and the narrowing costs nothing this replaced +//! +//! Every case here drives a `[[recorder]]` whose interesting columns are +//! PROGRAM-derived, and the fixtures are `#!/bin/sh` stubs. On a Windows runner +//! the spawn ladder's third rung resolves the interpreter a shebang names, and +//! `/bin/sh` is not a program a Windows runner can start — so every column came +//! back could-not-look and the suite asserted the ladder rather than the +//! recorder. `bundle.rs` gates its whole suite for exactly this reason and cites +//! the same row (CLOUD-113). +//! +//! The narrowing is smaller than it looks: `tests/board-write-record.bats`, the +//! suite this replaces, never ran on Windows either. What would be lost by +//! writing a `.cmd` fixture instead is the point of the stubs — their status and +//! stdout are chosen per case, so a column's value is a function of the +//! DECLARATION, and a second per-platform fixture grammar would be a second +//! authority over what the recorder read. + +#![cfg(unix)] #![allow(clippy::unwrap_used, clippy::expect_used)] mod common; diff --git a/crates/batten/tests/stop_posture.rs b/crates/batten/tests/stop_posture.rs index deb71518c..25188ef59 100644 --- a/crates/batten/tests/stop_posture.rs +++ b/crates/batten/tests/stop_posture.rs @@ -377,6 +377,7 @@ fn a_tool_call_is_not_judged_by_the_end_of_turn_rule() { // --------------------------------------------------------------------------- /// Install a stub at the path the engine spawns, with a chosen exit and stdout. +#[cfg(unix)] fn stub(dir: &Path, program: &str, exit: i32, stdout: &str) { let path = dir.join(program); fs::create_dir_all(path.parent().expect("a parent")).expect("mise-tasks dir"); @@ -393,6 +394,7 @@ fn stub(dir: &Path, program: &str, exit: i32, stdout: &str) { } /// A Stop payload naming a transcript the second rule can read. +#[cfg(unix)] fn stop_with_transcript(dir: &Path, message: &str) -> String { let transcript = dir.join("session.jsonl"); fs::write(&transcript, "{}\n").expect("write transcript"); @@ -409,6 +411,11 @@ fn stop_with_transcript(dir: &Path, message: &str) -> String { /// RULE 2 — a finding stated in prose with nothing durable written. The pointer /// is the sibling's stdout and the advice says what to do with it, because a /// coordinate alone is not an instruction. +// UNIX-ONLY, per CLOUD-113: this case spawns a `#!/bin/sh` stub, and the +// Windows ladder's third rung resolves the interpreter a shebang names — which +// `/bin/sh` is not on a Windows runner. `bundle.rs` gates its whole suite for +// exactly this reason. The rules above spawn nothing and stay cross-platform. +#[cfg(unix)] #[test] fn a_stranded_finding_is_pointed_at_and_the_turn_still_ends() { let dir = repo("stop-finding-sink"); @@ -430,6 +437,11 @@ fn a_stranded_finding_is_pointed_at_and_the_turn_still_ends() { /// PRECEDENCE IS MEASURED, NOT ASSERTED. `stop-posture` leads at 3/3 against /// `finding-sink`'s 1/1, and two nudges on one turn is how a channel stops being /// read — so when both would fire, exactly one does and it is the first. +// UNIX-ONLY, per CLOUD-113: this case spawns a `#!/bin/sh` stub, and the +// Windows ladder's third rung resolves the interpreter a shebang names — which +// `/bin/sh` is not on a Windows runner. `bundle.rs` gates its whole suite for +// exactly this reason. The rules above spawn nothing and stay cross-platform. +#[cfg(unix)] #[test] fn the_measured_rule_keeps_precedence_when_both_would_fire() { let dir = repo("stop-precedence"); @@ -449,6 +461,11 @@ fn the_measured_rule_keeps_precedence_when_both_would_fire() { } /// A sibling that found nothing says nothing, which is the common case. +// UNIX-ONLY, per CLOUD-113: this case spawns a `#!/bin/sh` stub, and the +// Windows ladder's third rung resolves the interpreter a shebang names — which +// `/bin/sh` is not on a Windows runner. `bundle.rs` gates its whole suite for +// exactly this reason. The rules above spawn nothing and stay cross-platform. +#[cfg(unix)] #[test] fn a_turn_that_strands_nothing_is_silent() { let dir = repo("stop-finding-sink-clean"); @@ -463,6 +480,11 @@ fn a_turn_that_strands_nothing_is_silent() { /// COULD NOT LOOK MANUFACTURES NOTHING. A transcript the engine cannot read /// leaves the rule with no subject, and inventing an advisory over that would be /// a verdict about the environment wearing a verdict about the turn. +// UNIX-ONLY, per CLOUD-113: this case spawns a `#!/bin/sh` stub, and the +// Windows ladder's third rung resolves the interpreter a shebang names — which +// `/bin/sh` is not on a Windows runner. `bundle.rs` gates its whole suite for +// exactly this reason. The rules above spawn nothing and stay cross-platform. +#[cfg(unix)] #[test] fn an_unreadable_transcript_manufactures_no_advisory() { let dir = repo("stop-no-transcript"); @@ -477,6 +499,11 @@ fn an_unreadable_transcript_manufactures_no_advisory() { /// THE BYPASS IS ONE HATCH FOR THE SET, exactly as the retired hook had it: /// these are five readings of one question, and a per-rule switch would let the /// surface be dismantled a rule at a time with nothing reporting it. +// UNIX-ONLY, per CLOUD-113: this case spawns a `#!/bin/sh` stub, and the +// Windows ladder's third rung resolves the interpreter a shebang names — which +// `/bin/sh` is not on a Windows runner. `bundle.rs` gates its whole suite for +// exactly this reason. The rules above spawn nothing and stay cross-platform. +#[cfg(unix)] #[test] fn the_stop_guard_bypass_silences_the_whole_surface() { let dir = repo("stop-bypass"); @@ -509,6 +536,11 @@ fn the_stop_guard_bypass_silences_the_whole_surface() { /// RULE 4 — a completion signal with no landed commit. `unlanded-check` reads no /// stdin, and its pointer is a count and a rule id: never a commit message and /// never a line of the transcript the verdict was read from. +// UNIX-ONLY, per CLOUD-113: this case spawns a `#!/bin/sh` stub, and the +// Windows ladder's third rung resolves the interpreter a shebang names — which +// `/bin/sh` is not on a Windows runner. `bundle.rs` gates its whole suite for +// exactly this reason. The rules above spawn nothing and stay cross-platform. +#[cfg(unix)] #[test] fn unlanded_work_at_a_declared_stopping_point_is_pointed_at() { let dir = repo("stop-unlanded"); @@ -531,6 +563,11 @@ fn unlanded_work_at_a_declared_stopping_point_is_pointed_at() { /// Landed work is silent, which is what keeps the case above from being /// satisfied by a rule that fires unconditionally. +// UNIX-ONLY, per CLOUD-113: this case spawns a `#!/bin/sh` stub, and the +// Windows ladder's third rung resolves the interpreter a shebang names — which +// `/bin/sh` is not on a Windows runner. `bundle.rs` gates its whole suite for +// exactly this reason. The rules above spawn nothing and stay cross-platform. +#[cfg(unix)] #[test] fn landed_work_is_silent() { let dir = repo("stop-landed"); @@ -545,6 +582,11 @@ fn landed_work_is_silent() { /// THE RECURSION BOUND HOLDS FOR EVERY RULE, not just the module: it is checked /// once, before any of them, so a second `Stop` in one turn spawns nothing at /// all rather than spawning and then discarding. +// UNIX-ONLY, per CLOUD-113: this case spawns a `#!/bin/sh` stub, and the +// Windows ladder's third rung resolves the interpreter a shebang names — which +// `/bin/sh` is not on a Windows runner. `bundle.rs` gates its whole suite for +// exactly this reason. The rules above spawn nothing and stay cross-platform. +#[cfg(unix)] #[test] fn the_recursion_bound_holds_for_every_rule() { let dir = repo("stop-bounded-all"); @@ -563,6 +605,11 @@ fn the_recursion_bound_holds_for_every_rule() { /// THE GUARD NEVER EXITS NON-ZERO, so it cannot surface as a hook error. Every /// case above reads stdout; this one reads the status, over a rule that fired. +// UNIX-ONLY, per CLOUD-113: this case spawns a `#!/bin/sh` stub, and the +// Windows ladder's third rung resolves the interpreter a shebang names — which +// `/bin/sh` is not on a Windows runner. `bundle.rs` gates its whole suite for +// exactly this reason. The rules above spawn nothing and stay cross-platform. +#[cfg(unix)] #[test] fn the_stop_surface_never_exits_non_zero() { let dir = repo("stop-exit-zero"); From b0440e61ade0faad3d935266a3ed37531b37bff5 Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Thu, 27 Aug 2026 14:36:45 +0000 Subject: [PATCH 38/38] ci(workflows): reclaim runner disk before the build, and record what it found MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `ci` job died twice on the same head — ~20 minutes into `mise run ci`, the step still `in_progress`, no annotations, and NO LOG ARCHIVE AT ALL. A 404 on a job's logs is what a runner that cannot write looks like, not a graded failure, and against a 36-minute budget it is not a timeout either. Ruled out by measurement rather than inference: the same task with `BATTEN_STEP_RECEIPT_BYPASS=1` — so nothing answers from a receipt, as under CI — exits 0 locally with 2616/2616 cargo tests and 2798/2798 bats cases in 988s; this job does not run the `semver` gate, so the lock route's doc build is not its cost; and `ci.yml` passes on other branches, so the workflow is not broken in general. (A first local run finished in 31s with every expensive step receipt-cached and was thrown away: comparing a fully cached local pass against a real remote run is the false green the receipt design warns about.) What is left is this tree's size. A full `mise run ci` leaves `target/debug` at 14 GB here, and every `crates/batten/tests/*.rs` is a separately linked binary, so it grows with the test surface this branch expanded. `ci.yml`'s own comment records `mise run ci` at 741s warm and 1021s cold; both deaths are past 1180s, which is past where a healthy run finishes. So: reclaim the preinstalled toolchains this repository never uses, before the checkout, the mise install and the cache restore write their own gigabytes. SECOND and never first, because `ci-local-parity` property 7 asserts the first step is the landing lease — and the lease belongs there, since an unauthorised run should not spend even this. A bare step and never a `mise` task, because property 3 would then oblige `verify` to delete `/usr/local/lib/android` on a contributor's laptop. STATED PLAINLY: this is a hypothesis, not a proven cause. The `$GITHUB_STEP_SUMMARY` writes are what make the next run settle it — a job log dies with its runner, a step summary is uploaded when its step ends, so the free-space reading survives even if the job dies twenty minutes later. Refs: CLOUD-327, CLOUD-420, CLOUD-1051 --- .github/workflows/ci.yml | 46 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2cdafcaf5..ad220d0a2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -98,6 +98,52 @@ jobs: # non-zero; this is what makes that true at the call site too. bash -c "$body" || exit 0 + # RECLAIM RUNNER DISK, BEFORE ANYTHING WRITES ITS OWN GIGABYTES. + # + # Measured on this tree: a full `mise run ci` leaves `target/debug` at + # 14 GB (`target/` at 21 GB), and every `crates/batten/tests/*.rs` is a + # separately linked binary, so that grows with the test surface. Twice, on + # the same head, this job died ~20 minutes into `mise run ci` with the step + # still `in_progress`, no annotations, and NO LOG ARCHIVE AT ALL — a 404 on + # the job's logs is what a runner that cannot write looks like. The same + # task with `BATTEN_STEP_RECEIPT_BYPASS=1` (so nothing answers from a + # receipt, as under CI) exits 0 locally in 988s, and this workflow passes + # on other branches, so the failure is this tree's size rather than the + # gate. Reclaiming the preinstalled toolchains this repository never uses + # is the cheapest thing that could make that true. + # + # SECOND, NEVER FIRST. `ci-local-parity` property 7 asserts the first step + # of a `needs:`-less job is the landing lease, matched by name — and the + # lease belongs first on its own merits: a run this branch is not + # authorised to make should not spend even this. + # + # A BARE STEP, NEVER A `mise` TASK. Property 3 requires every `mise run + # ` a workflow names to be one `mise run verify` also runs, so a + # `mise run free-disk` would oblige a contributor's laptop to delete + # `/usr/local/lib/android`. The honest shape names no task, exactly as the + # lease step and the slow-tier decision below do. + # + # `|| true` because this is a mitigation and never a gate: a path absent + # from some future runner image must not red a required check. + # + # The summary writes are the diagnostic that SURVIVES. A job log is lost + # when the runner dies; a step summary is uploaded when its step ends, so + # the reading below is durable evidence even if the job dies twenty minutes + # later — which is what makes the next run a measurement rather than + # another guess. + - name: Reclaim runner disk before the build + run: | + { + echo "### disk before" + df -h / + } >>"$GITHUB_STEP_SUMMARY" + sudo rm -rf /usr/local/lib/android /usr/share/dotnet /opt/ghc \ + /usr/local/share/boost /usr/share/swift || true + { + echo "### disk after" + df -h / + } >>"$GITHUB_STEP_SUMMARY" + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: # tests/bats — the shell test runner. Without it `mise run test:bats`