diff --git a/.serena/memories/workflow/agent-fanout.md b/.serena/memories/workflow/agent-fanout.md index f6ad68cca..bbd22410c 100644 --- a/.serena/memories/workflow/agent-fanout.md +++ b/.serena/memories/workflow/agent-fanout.md @@ -152,6 +152,21 @@ minutes becoming ~7.5 against the same 7.3-minute gap. So the odds a re-invoked is not the same bet as raising `LAND_MAX_LAPS`. Neither is a substitute for the lever; both beat engineering around the loop. +**What the backstop is asking when it stops you.** `still not linear after 8 +laps; main is moving faster than a lap takes. Look before lapping again` — the +thing to look at is whether the laps lost for a REASON (rebase conflict, red CI, +failed `verify`) or purely because `main` moved. Only the second is contention, +and only the second is safe to re-run unchanged; the first is a defect the loop +is correctly refusing to paper over. Measured again on CLOUD-282, which spent a +full 8 laps to the backstop with every `verify` green and CI green three separate +times, then lost the next invocation to a genuine rebase conflict — two different +answers to that question, one invocation apart. + +A land that stops on the backstop is **re-run, not re-engineered**: wrapping it +in bespoke retry or pre-check logic is forbidden (AGENTS.md), and re-invoking +`mise run land` after confirming the laps lost only to movement is the sanctioned +move. + ## Planners fan out freely; implementers do not The cap above is a **build** cap, and reading it as a cap on sessions is the diff --git a/batten.toml b/batten.toml index 962215298..10dee5b4d 100644 --- a/batten.toml +++ b/batten.toml @@ -419,6 +419,121 @@ severity = "deny" scope = "tree" no_fix_reason = "pin the tool in mise.toml so CI installs exactly what the lockfile names" +# Portability: the shell layer must run on a machine that is not the CI runner +# (CLOUD-282). +# +# `mise run verify` is the authorization the whole workflow contract rests on — +# "verify exhaustively before CI", "the gates ARE your authorization". On macOS +# it could not pass, for reasons no branch caused: two gates used sed's +# NUL-separated mode (GNU only, BSD exits `illegal option -- z`), two used +# `mapfile` (bash 4; macOS ships 3.2), one used xargs' short +# `--no-run-if-empty`, and three fixture repos force-created a branch that git +# refuses to force while it is checked out. An agent there either stopped on a +# red `verify` it did not break, or landed on CI's word alone — the exact +# failure the contract exists to prevent. CI runs ubuntu, so CI is +# STRUCTURALLY BLIND to every one of these: the same "verdict is a property of +# the machine" shape as CLOUD-227. +# +# These are `forbid` rows and not a 50th `mise-tasks/*-check` program, per the +# 2026-08-07 engine-first amendment: `batten check` already runs in the shared hk +# gate with no glob, so the whole construct set is config a reviewer can read +# (§9) rather than a second copy inside a shell script. That amendment exists +# because the older wording made a bash task the cheapest satisfying artifact, +# and CLOUD-202 is in flight moving the existing guards OUT of that layer. +# +# WHY THE GLOBS ARE `mise-tasks/**` AND `tests/**/*.bats`: +# +# * two or more segments, so neither can match the root `batten.toml` and make +# the rule fire on its own definition — the walk is `fs::read_dir` and skips +# only `.git`. +# * recursive, matching `no-bash4-wait-n` and `bats-tests-not-deleted` rather +# than inventing a third depth convention. Both were narrowed to one segment +# once, only to dodge the vendored `tests/bats` submodule the walker used to +# descend into; since CLOUD-328 the walk stops at a nested repository, so +# depth is free again and hk.pkl's `batten-check` glob spans it too. That +# coupling is not cosmetic: `batten-glob-check` subsumes a required glob only +# by verbatim match or a `P/**` prefix, so `tests/*.bats` would be uncovered +# by hk's `tests/**/*.bats` and the step would stop running for exactly the +# commits this rule judges. +# +# WHAT IS DELIBERATELY NOT HERE, and why it is a gap rather than an oversight: +# the `tests/**/*.bats` half of the sed and flock rows. `tests/memory-guard.bats` +# carries the in-place form five times as FIXTURE STRINGS — it is the suite +# proving `memory-guard` denies that shape — and `tests/target-ensure.bats` names +# the util-linux lock in a test title. A `forbid` pattern is a literal substring +# with no `exclude` column, so those rows cannot be written today without firing +# on the tests that prove the guards work. That is exactly CLOUD-283. The +# `mise-tasks/**` half — the layer that actually could not run — lands now. +# +# The rationale comments in the guarded files deliberately do NOT spell the +# literals they explain, for the same reason the agnosticism rules above do not +# embed theirs: a substring rule over a directory fires on its own explanation. +[[rule]] +id = "no-gnu-sed-z" +kind = "forbid" +glob = "mise-tasks/**" +pattern = "sed -zE" +severity = "deny" +scope = "tree" + +# The trailing space is load-bearing: it is what distinguishes the GNU-only +# no-suffix spelling from `sed -i.bak`, which BSD accepts and which +# `tests/helpers.bash` uses. +[[rule]] +id = "no-gnu-sed-in-place" +kind = "forbid" +glob = "mise-tasks/**" +pattern = "sed -i " +severity = "deny" +scope = "tree" + +[[rule]] +id = "no-bash4-mapfile" +kind = "forbid" +glob = "mise-tasks/**" +pattern = "mapfile " +severity = "deny" +scope = "tree" + +[[rule]] +id = "no-gnu-xargs-r" +kind = "forbid" +glob = "mise-tasks/**" +pattern = "xargs -r" +severity = "deny" +scope = "tree" + +# CLOUD-286 replaced flock(1) with a `mkdir` mutex and named this row as its own +# standing half: the refusal that fired before every other gate on a Mac was +# correct behaviour over a Linux-only primitive. +[[rule]] +id = "no-util-linux-flock" +kind = "forbid" +glob = "mise-tasks/**" +pattern = "flock " +severity = "deny" +scope = "tree" + +# git refuses `branch -f` on the branch that is currently checked out. Three +# fixture setups did it anyway, and passed only because the runner's git still +# defaults to `master`, which made `main` a different branch — an accident of the +# runner's configuration, not a property of the test. It is a `setup()` failure, +# so every case in the suite errors, not one. +# +# The replacement is `git init -b ` plus a plain `git branch main`, NOT +# `branch -M main`: `-M` renames the current branch, and these fixtures want a +# SECOND branch marking the trunk while HEAD stays on the feature branch. That +# distinction is measured, not assumed — rewriting them to `-M` first made +# `tests/verified.bats`'s moved-main case fail, because it checks that other +# branch out. +[[rule]] +id = "no-branch-f-main" +kind = "forbid" +glob = "tests/**/*.bats" +pattern = "branch -f main" +severity = "deny" +scope = "tree" + # Which programs change the world, in the one §5 effect vocabulary, each with the # sanctioned mutation to point at when it is refused. # diff --git a/crates/batten/tests/cli.rs b/crates/batten/tests/cli.rs index 697cf52ca..c62ffde77 100644 --- a/crates/batten/tests/cli.rs +++ b/crates/batten/tests/cli.rs @@ -3788,6 +3788,126 @@ fn the_committed_repo_agnosticism_rules_fire_on_every_banned_shape() { ); } +#[test] +fn the_committed_portability_rules_fire_on_every_banned_shape() { + // CLOUD-282. `mise run verify` is the authorization the workflow contract + // rests on, and on macOS it could not pass: two gates used sed's GNU-only + // NUL-separated mode, two used bash 4's `mapfile` against a 3.2 /bin/bash, + // one used xargs' short --no-run-if-empty, and three fixture repos + // force-created a checked-out branch. CI runs ubuntu, so CI is structurally + // blind to every one of them — which is exactly why the standing gate is a + // property of the COMMITTED TREE rather than a job. + // + // Same shape as `the_committed_repo_agnosticism_rules_fire_on_every_banned_ + // shape` above, and full stdout equality is the load-bearing part for the + // same reason: findings sort by the `(path, line, rule)` pointer tuple, so + // the expected bytes are fixed, and a rule that is deleted, renamed, + // mis-globbed or set to `severity = "allow"` changes them. A `contains` + // assertion would pass with five of the six rules gone. + // + // Unlike that test, the banned literals CAN be written as source text here: + // both globs are anchored at a first segment (`mise-tasks/`, `tests/`) that + // this file, at `crates/batten/tests/cli.rs`, does not sit under. + let committed = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../batten.toml"); + let contents = fs::read_to_string(&committed).expect("read batten.toml"); + + let _ = fs::remove_dir_all( + PathBuf::from(env!("CARGO_TARGET_TMPDIR")).join("config-portability-dirty"), + ); + let dirty = repo_with_config("config-portability-dirty", &contents); + committed_budget_surfaces(&dirty); + committed_config_fixture_git(&dirty); + + // One file per glob, each carrying every literal its rules ban, so a rule + // scoped to the wrong directory shows up as a missing line rather than as a + // silent pass. The task file's five are written in the order they sort by + // line, and the sed rows are on separate lines because a single line can + // only ever produce one finding per rule. + fs::create_dir_all(dirty.join("mise-tasks")).expect("create fixture task dir"); + fs::write( + dirty.join("mise-tasks/seed"), + "printf x | sed -zE 's/a/b/'\n\ + sed -i 's/a/b/' file\n\ + mapfile -t lines < <(printf 'x')\n\ + git ls-files | xargs -r grep x\n\ + flock /tmp/lock true\n", + ) + .expect("write fixture task"); + fs::create_dir_all(dirty.join("tests")).expect("create fixture test dir"); + fs::write(dirty.join("tests/seed.bats"), "\tgit branch -f main\n") + .expect("write fixture suite"); + + // `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 + // `the_committed_config_refuses_to_run_a_spawning_kind_under_check`). Every + // sibling test over the committed bytes takes the same verb for the same + // reason. + let output = batten() + .arg("enforce") + .current_dir(&dirty) + .env_remove("BATTEN_STRICTNESS") + .env_remove("BATTEN_FAIL_ON_WARNING") + .output() + .expect("run batten enforce"); + assert_eq!( + output.status.code(), + Some(2), + "a construct that cannot run on macOS is a policy violation" + ); + assert_eq!( + String::from_utf8_lossy(&output.stdout), + "mise-tasks/seed:1 no-gnu-sed-z\n\ + mise-tasks/seed:2 no-gnu-sed-in-place\n\ + mise-tasks/seed:3 no-bash4-mapfile\n\ + mise-tasks/seed:4 no-gnu-xargs-r\n\ + mise-tasks/seed:5 no-util-linux-flock\n\ + tests/seed.bats:1 no-branch-f-main\n", + "one sorted pointer per banned construct, and nothing else" + ); + + // The discriminator: the portable spellings this change adopted must be + // SILENT, or the rows would forbid their own replacements. `-i.bak` is the + // one in-place form BSD accepts, a `read` loop replaces `mapfile`, bare + // `xargs` replaces the short flag, and a plain `branch main` off an explicitly + // named branch replaces the force. + let _ = fs::remove_dir_all( + PathBuf::from(env!("CARGO_TARGET_TMPDIR")).join("config-portability-clean"), + ); + let clean = repo_with_config("config-portability-clean", &contents); + committed_budget_surfaces(&clean); + committed_config_fixture_git(&clean); + fs::create_dir_all(clean.join("mise-tasks")).expect("create clean task dir"); + fs::write( + clean.join("mise-tasks/seed"), + "printf x | perl -0777 -pe 's/a/b/'\n\ + sed -i.bak 's/a/b/' file\n\ + while IFS= read -r l; do :; done < <(printf 'x\\n')\n\ + git ls-files | xargs grep x\n", + ) + .expect("write clean task"); + fs::create_dir_all(clean.join("tests")).expect("create clean test dir"); + fs::write(clean.join("tests/seed.bats"), "\tgit branch main\n").expect("write clean suite"); + + let output = batten() + .arg("enforce") + .current_dir(&clean) + .env_remove("BATTEN_STRICTNESS") + .env_remove("BATTEN_FAIL_ON_WARNING") + .output() + .expect("run batten enforce"); + assert_eq!( + output.status.code(), + Some(0), + "the portable spellings are not violations" + ); + assert!( + output.stdout.is_empty(), + "a portable tree prints nothing, got: {}", + String::from_utf8_lossy(&output.stdout) + ); +} + #[test] fn the_committed_example_config_loads_over_the_binary() { // DoD: `batten.example.toml` loads and round-trips — asserted against the diff --git a/mise-tasks/checksums b/mise-tasks/checksums index 742261cee..d075a8069 100755 --- a/mise-tasks/checksums +++ b/mise-tasks/checksums @@ -79,7 +79,15 @@ rm -f "$scratch/$MANIFEST" # LC_ALL=C so the order is byte order on every machine: two runs over one release # produce identical bytes, which is what makes the published artifact a function # of the release rather than of who cut it. -mapfile -t assets < <(cd "$scratch" && shopt -s nullglob && for f in *; do printf '%s\n' "$f"; done | LC_ALL=C sort) +# +# A `read` loop rather than `mapfile` (CLOUD-282): `mapfile` is bash 4 and macOS +# ships 3.2, so it would die here on the one platform whose release artifacts +# this manifest covers. `no-bash4-mapfile` in batten.toml is the standing gate — +# it caught this line the day the file landed. +assets=() +while IFS= read -r asset; do + assets+=("$asset") +done < <(cd "$scratch" && shopt -s nullglob && for f in *; do printf '%s\n' "$f"; done | LC_ALL=C sort) if [ "${#assets[@]}" -eq 0 ]; then echo "::error:: checksums: release $tag carries no assets to hash. A manifest that covers nothing is indistinguishable from no manifest, so none is written." >&2 diff --git a/mise-tasks/gh-guard-check b/mise-tasks/gh-guard-check index d77bda1f7..550c7e8e6 100755 --- a/mise-tasks/gh-guard-check +++ b/mise-tasks/gh-guard-check @@ -23,7 +23,19 @@ cmd="${1:-}" # Neutralise quoted spans, then split on shell separators. scrubbed=$(printf '%s' "$cmd" | sed -E "s/'[^']*'/QUOTED/g; s/\"[^\"]*\"/QUOTED/g") -mapfile -t segments < <(printf '%s' "$scrubbed" | sed -E 's/(\&\&|\|\||[;|&])/\n/g') + +# A `read` loop and not `mapfile` (CLOUD-282): `mapfile` is bash 4 and macOS +# ships 3.2 as /bin/bash, so the guard would die on its own first statement. +# Same idiom as `issue-guard` and `ready-guard`, which never used mapfile. +# +# `printf '%s\n'`, WITH the trailing newline, is the load-bearing half. `read` +# returns false on a final segment that carries no terminator, so without it the +# last — and for a single-segment command like `gh pr merge` the ONLY — segment +# is dropped and the guard silently allows everything it exists to deny. +segments=() +while IFS= read -r seg; do + segments+=("$seg") +done < <(printf '%s\n' "$scrubbed" | sed -E 's/(\&\&|\|\||[;|&])/\n/g') for seg in "${segments[@]}"; do read -r -a toks <<<"$seg" diff --git a/mise-tasks/memories-check b/mise-tasks/memories-check index 46a98f201..f529e9d1b 100755 --- a/mise-tasks/memories-check +++ b/mise-tasks/memories-check @@ -60,9 +60,17 @@ while IFS=: read -r file line ref; do [ -f "$memories/$name.md" ] || report "$file:$line" "mem-ref-stale ($name)" # CHANGELOG.md is excluded with the template: release-plz owns it, so a stale # reference there could only be fixed by hand-editing a generated file. + # + # Bare `xargs` below, with no `--no-run-if-empty` (CLOUD-282): the short form + # of that flag is a GNU extension BSD xargs did not carry until recently. It + # bought nothing here — the `/dev/null` sentinel argument means a zero-file + # invocation still runs `grep` against one unmatchable file, which prints + # nothing and exits 1, which is exactly what the flag was suppressing. + # `no-gnu-xargs-r` in batten.toml keeps it out; the literal is not spelled + # here because that row is a substring rule over this directory. done < <(git ls-files '*.md' ':!:tests/bats' ':!:CHANGELOG.md' | grep -vxF "$memories/memory_maintenance.md" | - xargs -r grep -noE 'mem:[A-Za-z0-9_/-]+' /dev/null 2>/dev/null | sort -u) + xargs grep -noE 'mem:[A-Za-z0-9_/-]+' /dev/null 2>/dev/null | sort -u) if [ "$violations" -ne 0 ]; then echo "::error:: memories-check: $violations violation(s) — the memory graph has dangling edges" >&2 diff --git a/mise-tasks/memory-guard-check b/mise-tasks/memory-guard-check index 1fa113c38..a962c37fb 100755 --- a/mise-tasks/memory-guard-check +++ b/mise-tasks/memory-guard-check @@ -90,9 +90,16 @@ body=$(awk ' inheredoc { if ($0 ~ "^[[:space:]]*" term "[[:space:]]*$") inheredoc = 0; next } { print } ' <<<"$value") -# -z, so a quoted span is neutralised across NEWLINES — a multi-line commit -# message is the single most likely place for someone to write these shapes down. -scrubbed=$(printf '%s' "$body" | sed -zE "s/'[^']*'/QUOTED/g; s/\"[^\"]*\"/QUOTED/g") +# Slurped whole, so a quoted span is neutralised across NEWLINES — a multi-line +# commit message is the single most likely place for someone to write these +# shapes down. +# +# `perl -0777` and not sed's NUL-separated mode (CLOUD-282): `-z` is a GNU +# extension, BSD sed exits `illegal option -- z`, and this is a GATE — a macOS +# checkout could not run it at all. The literal is deliberately not spelled here: +# `no-gnu-sed-z` in batten.toml is a substring rule over this very directory, so +# a comment quoting the banned form would make the row fire on its own rationale. +scrubbed=$(printf '%s' "$body" | perl -0777 -pe "s/'[^']*'/QUOTED/g; s/\"[^\"]*\"/QUOTED/g") # Judged per SEGMENT, not per command string. A read in one segment must not be # condemned by a write in another, and — the direction that actually matters — diff --git a/mise-tasks/mutant b/mise-tasks/mutant index 8176bb64c..18f72635b 100755 --- a/mise-tasks/mutant +++ b/mise-tasks/mutant @@ -134,11 +134,16 @@ for gate in ${gates//,/ }; do continue fi - if ! sed -i "$script" "$work/$src"; then + # `-i.bak` and not the bare in-place flag (CLOUD-282): BSD sed reads the + # next argument as the suffix, so the no-suffix spelling consumes the + # script on a Mac. The backup is removed rather than kept — it exists + # only to satisfy the one form both seds accept. + if ! sed -i.bak "$script" "$work/$src"; then echo "$gate/$slug unappliable-mutation" failures=$((failures + 1)) continue fi + rm -f "$work/$src.bak" # A mutation that changed nothing proves nothing, and would otherwise be # reported as caught or missed on the strength of an unrelated case. if cmp -s "$src" "$work/$src"; then diff --git a/mise-tasks/run-shape-guard b/mise-tasks/run-shape-guard index 1f1ac4f28..16cfb2ca4 100755 --- a/mise-tasks/run-shape-guard +++ b/mise-tasks/run-shape-guard @@ -120,11 +120,18 @@ body=$(awk ' inheredoc { if ($0 ~ "^[[:space:]]*" term "[[:space:]]*$") inheredoc = 0; next } { print } ' <<<"$cmd") -# -z, so a quoted span is neutralised across NEWLINES. sed is line-based by -# default, which left the interior of every multi-line `git commit -m "…"` -# exposed — and a commit message documenting these shapes is the single most -# likely place to write them down. The guard denied exactly that. -scrubbed=$(printf '%s' "$body" | sed -zE "s/'[^']*'/QUOTED/g; s/\"[^\"]*\"/QUOTED/g") +# Slurped whole, so a quoted span is neutralised across NEWLINES. A line-at-a- +# time reader left the interior of every multi-line `git commit -m "…"` exposed +# — and a commit message documenting these shapes is the single most likely +# place to write them down. The guard denied exactly that. +# +# `perl -0777` and not sed's NUL-separated mode (CLOUD-282): `-z` is a GNU +# extension, BSD sed exits `illegal option -- z`, so a macOS checkout could not +# run this guard at all. Byte-identical for both substitutions, differentially +# verified against the real fixtures. The banned literal is deliberately not +# spelled here — `no-gnu-sed-z` in batten.toml is a substring rule over this +# directory, so quoting it would make the row fire on its own rationale. +scrubbed=$(printf '%s' "$body" | perl -0777 -pe "s/'[^']*'/QUOTED/g; s/\"[^\"]*\"/QUOTED/g") # --- resolve a pipeline stage to its EFFECTIVE program ------------------------ # @@ -283,9 +290,15 @@ done < <(printf '%s\n' "$list") # is caught rather than only a leading one. if [ "$(printf '%s' "$raw" | jq -r '.tool_input.run_in_background // false' 2>/dev/null)" != true ]; then for element in "${elements[@]}"; do - # `mapfile`, not `while read`: the last stage carries no trailing newline, - # so a read-loop drops it — which for a one-stage element is every stage. - mapfile -t sleep_stages < <(printf '%s' "$element" | tr '|' '\n') + # A `read` loop, with the producer terminating its last line (CLOUD-282): + # bash 4's array-read builtin is unavailable on macOS's 3.2. The trailing + # newline is what makes the two equivalent — `read` returns false on an + # unterminated final segment, and for a one-stage element that segment is + # every stage, so `printf '%s'` here would empty the loop entirely. + sleep_stages=() + while IFS= read -r sleep_stage_line; do + sleep_stages+=("$sleep_stage_line") + done < <(printf '%s\n' "$element" | tr '|' '\n') for stage in "${sleep_stages[@]}"; do read -r prog _ _ <<<"$(resolve "$stage")" [ "$prog" = sleep ] || continue @@ -343,7 +356,11 @@ Bypass with BATTEN_RUN_SHAPE_BYPASS=1.' for element in "${elements[@]}"; do # Per stage, like the sleep rule: `git commit` is not verdict-bearing, so the # `last_verdict` early exit below would never reach it. - mapfile -t commit_stages < <(printf '%s' "$element" | tr '|' '\n') + # Same read-loop-with-terminated-producer as the sleep rule above (CLOUD-282). + commit_stages=() + while IFS= read -r commit_stage_line; do + commit_stages+=("$commit_stage_line") + done < <(printf '%s\n' "$element" | tr '|' '\n') for stage in "${commit_stages[@]}"; do read -r prog sub1 _ <<<"$(resolve "$stage")" if [ "$prog" != git ] || [ "$sub1" != commit ]; then continue; fi @@ -378,7 +395,15 @@ for element in "${elements[@]}"; do index=$((index + 1)) continue } - mapfile -t stages < <(printf '%s' "$element" | tr '|' '\n') + # A `read` loop and not `mapfile` (CLOUD-282): `mapfile` is bash 4 and macOS + # ships 3.2, so this guard would die on a Mac. `printf '%s\n'` with the + # trailing newline is load-bearing — `read` returns false on an unterminated + # final segment, and an unpiped command is exactly one stage, so dropping it + # would leave `stages` empty and wave every verdict-bearing command through. + stages=() + while IFS= read -r stage_line; do + stages+=("$stage_line") + done < <(printf '%s\n' "$element" | tr '|' '\n') if verdict_bearing "${stages[0]}"; then last_verdict=$index for stage in "${stages[@]:1}"; do diff --git a/mise-tasks/stop-posture-check b/mise-tasks/stop-posture-check index a8772beb6..bb34e0fda 100755 --- a/mise-tasks/stop-posture-check +++ b/mise-tasks/stop-posture-check @@ -57,17 +57,27 @@ msg=$(cat) || exit 2 # that documented it, twice, before its scrubber covered quoted spans. Code spans, # fenced blocks, block quotes and double-quoted spans all come out first. # -# `-z` throughout, because sed is line-based by default and every one of these -# spans is routinely line-wrapped in real prose — a line-based scrub leaves the -# interior of a wrapped quotation exposed, which is the same defect -# `run-shape-guard` fixed at its own scrubber. +# WHOLE-INPUT throughout, because the default line-at-a-time model leaves the +# interior of a wrapped quotation exposed and every one of these spans is +# routinely line-wrapped in real prose — the same defect `run-shape-guard` fixed +# at its own scrubber. The last substitution needs it twice over: it matches `\n` +# inside the pattern space, which a line-based reader never has. +# +# `perl -0777` and not sed's NUL-separated mode (CLOUD-282): `-z` is a GNU +# extension, BSD sed exits `illegal option -- z`, and these are GATES — a macOS +# checkout could not run them at all. Byte-identical for all four substitutions, +# differentially verified against the real bats fixtures and both edges (a +# leading `\n`, a blank line before `>`); only `\1` becomes `$1`. Not awk: +# `awk-regex-check` forbids `-v`-passed regexes, and this repo's dev boxes are +# mawk against a gawk runner. The banned literal is deliberately not spelled +# here — `no-gnu-sed-z` in batten.toml is a substring rule over this directory. scrubbed=$( # shellcheck disable=SC2016 # the backticks are literal markdown, not a subshell printf '%s' "$msg" | - sed -zE 's/```[^`]*```/FENCED/g' | - sed -zE 's/`[^`]*`/CODE/g' | - sed -zE 's/"[^"]*"/QUOTED/g' | - sed -zE 's/(^|\n)[[:space:]]*>[^\n]*/\1QUOTED/g' + perl -0777 -pe 's/```[^`]*```/FENCED/g' | + perl -0777 -pe 's/`[^`]*`/CODE/g' | + perl -0777 -pe 's/"[^"]*"/QUOTED/g' | + perl -0777 -pe 's/(^|\n)[[:space:]]*>[^\n]*/$1QUOTED/g' ) # The literal set is AGENTS.md's own two examples plus their direct inflections. diff --git a/mise-tasks/with-lock b/mise-tasks/with-lock index c4fba60ba..55bc1138d 100755 --- a/mise-tasks/with-lock +++ b/mise-tasks/with-lock @@ -50,7 +50,7 @@ if [ -e "$lock" ] && [ ! -d "$lock" ]; then rm -f "$lock" fi -# What the kernel used to do for free — drop the flock when the holder dies — is +# What the kernel used to do for free — drop the lock when the holder dies — is # explicit now. The trap covers every ordinary exit including a signal, and a # holder that took SIGKILL is reclaimed by the stale check in the wait loop, so # an abandoned lock is a delay measured in one poll rather than in the timeout. diff --git a/tests/ci-local-parity.bats b/tests/ci-local-parity.bats index f6ae36503..4885ffbf6 100644 --- a/tests/ci-local-parity.bats +++ b/tests/ci-local-parity.bats @@ -7,6 +7,9 @@ # catch the gate drifting away from what it guards. setup() { + # tests/helpers.bash: `sed_i` / `run_timeout`, standing in for GNU + # tools a stock macOS does not ship (CLOUD-282). + load helpers GATE="$BATS_TEST_DIRNAME/../mise-tasks/ci-local-parity" WF="$BATS_TEST_TMPDIR/workflows" MANIFEST="$BATS_TEST_TMPDIR/mise.toml" @@ -208,7 +211,7 @@ scheduled() { # The other direction, and it fails differently: `ci-wait` waits forever for # a run nothing will ever create. workflow ci - sed -i 's/^CI_REQUIRED_CHECKS = .*/CI_REQUIRED_CHECKS = "ci,gone"/' "$MANIFEST" + sed_i 's/^CI_REQUIRED_CHECKS = .*/CI_REQUIRED_CHECKS = "ci,gone"/' "$MANIFEST" run "$GATE" [ "$status" -eq 1 ] [[ "$output" == *"names 'gone', which is no job"* ]] @@ -218,8 +221,8 @@ scheduled() { # A check-run's name carries the leg in parentheses and no committed text # can expand the template, so the comparison is over the base name. workflow ci - sed -i 's/^ name: ci$/ name: ci (${{ matrix.target }})/' "$WF/ci.yml" - sed -i 's/^CI_REQUIRED_CHECKS = .*/CI_REQUIRED_CHECKS = "ci (aarch64-apple-darwin)"/' "$MANIFEST" + sed_i 's/^ name: ci$/ name: ci (${{ matrix.target }})/' "$WF/ci.yml" + sed_i 's/^CI_REQUIRED_CHECKS = .*/CI_REQUIRED_CHECKS = "ci (aarch64-apple-darwin)"/' "$MANIFEST" run "$GATE" [ "$status" -eq 0 ] } @@ -228,7 +231,7 @@ scheduled() { # An empty required set makes every check unrequired — the false green # stated as a default rather than a bug. workflow ci - sed -i '/^CI_REQUIRED_CHECKS = /d' "$MANIFEST" + sed_i '/^CI_REQUIRED_CHECKS = /d' "$MANIFEST" run "$GATE" [ "$status" -eq 1 ] [[ "$output" == *"no CI_REQUIRED_CHECKS"* ]] @@ -276,7 +279,7 @@ scheduled() { fanin() { workflow ci workflow cross " if: \${{ github.event.pull_request.draft == false }}" "cancel-in-progress: true" "cross-check" - sed -i 's/^CI_REQUIRED_CHECKS = .*/CI_REQUIRED_CHECKS = "ci,cross,final"/' "$MANIFEST" + sed_i 's/^CI_REQUIRED_CHECKS = .*/CI_REQUIRED_CHECKS = "ci,cross,final"/' "$MANIFEST" cat >"$WF/final.yml" <<-EOF name: final @@ -317,8 +320,8 @@ fanin() { [ "$status" -eq 0 ] workflow msrv " if: \${{ github.event.pull_request.draft == false }}" "cancel-in-progress: true" "ci" - sed -i 's/^CI_REQUIRED_CHECKS = .*/CI_REQUIRED_CHECKS = "ci,cross,final,msrv"/' "$MANIFEST" - sed -i 's/^ needs: \[ci, cross\]$/ needs: [ci, cross, msrv]/' "$WF/final.yml" + sed_i 's/^CI_REQUIRED_CHECKS = .*/CI_REQUIRED_CHECKS = "ci,cross,final,msrv"/' "$MANIFEST" + sed_i 's/^ needs: \[ci, cross\]$/ needs: [ci, cross, msrv]/' "$WF/final.yml" run "$GATE" [ "$status" -eq 0 ] } diff --git a/tests/ci-tools-check.bats b/tests/ci-tools-check.bats index 2f1136a1a..15f60a308 100644 --- a/tests/ci-tools-check.bats +++ b/tests/ci-tools-check.bats @@ -15,6 +15,9 @@ # too — that one is the regression test for the workflow itself. setup() { + # tests/helpers.bash: `sed_i` / `run_timeout`, standing in for GNU + # tools a stock macOS does not ship (CLOUD-282). + load helpers CHECK="$BATS_TEST_DIRNAME/../mise-tasks/ci-tools-check" WORKFLOW="$BATS_TEST_TMPDIR/ci.yml" CONFIG="$BATS_TEST_TMPDIR/mise.toml" @@ -82,7 +85,7 @@ setup() { # --- fail: the drift this gate exists for ------------------------------------ @test "a renamed tool leaves install_args naming something undeclared" { - sed -i 's/^zig = .*/zig-lang = "0.16"/' "$CONFIG" + sed_i 's/^zig = .*/zig-lang = "0.16"/' "$CONFIG" run "$CHECK" "$WORKFLOW" "$CONFIG" [ "$status" -eq 1 ] [[ "$output" == *"::error::"* ]] @@ -91,7 +94,7 @@ setup() { @test "the error names the tool and both files, not the file contents" { # Output is a pointer, never a payload (non-negotiable 4). - sed -i 's|^"npm:prettier" = .*||' "$CONFIG" + sed_i 's|^"npm:prettier" = .*||' "$CONFIG" run "$CHECK" "$WORKFLOW" "$CONFIG" [ "$status" -eq 1 ] [[ "$output" == *"npm:prettier"* ]] @@ -102,7 +105,7 @@ setup() { @test "a key from another table does not count as declared" { # `zig` appears under [env] too; only the [tools] table declares tools. - sed -i 's/^zig = "0.16"$//' "$CONFIG" + sed_i 's/^zig = "0.16"$//' "$CONFIG" run "$CHECK" "$WORKFLOW" "$CONFIG" [ "$status" -eq 1 ] [[ "$output" == *"zig"* ]] diff --git a/tests/release-assets-check.bats b/tests/release-assets-check.bats index a2c29c232..8c19b127e 100644 --- a/tests/release-assets-check.bats +++ b/tests/release-assets-check.bats @@ -8,6 +8,9 @@ # blip is the failure mode that gets a scheduled gate switched off. setup() { + # tests/helpers.bash: `sed_i` / `run_timeout`, standing in for GNU + # tools a stock macOS does not ship (CLOUD-282). + load helpers CHECK="$BATS_TEST_DIRNAME/../mise-tasks/release-assets-check" STUB="$BATS_TEST_TMPDIR/bin" mkdir -p "$STUB" @@ -156,7 +159,7 @@ complete() { # The half that can silently go to zero. Reformat the upload line and the # schema stops being demanded, with a green result to say everything is fine. complete - sed -i '/gh release upload/d' "$BATTEN_RELEASE_WORKFLOW" + sed_i '/gh release upload/d' "$BATTEN_RELEASE_WORKFLOW" run "$CHECK" v9.9.9 [ "$status" -eq 2 ] [[ "$output" == *"must not report green"* ]] diff --git a/tests/verified.bats b/tests/verified.bats index 367c3f20c..c4e369c7e 100644 --- a/tests/verified.bats +++ b/tests/verified.bats @@ -11,12 +11,30 @@ setup() { GATE="$BATS_TEST_DIRNAME/../mise-tasks/verified" REPO="$BATS_TEST_TMPDIR/repo-$BATS_TEST_NUMBER" - git init -q "$REPO" + # The developer's global git config must not reach a fixture repo + # (CLOUD-282). `init.defaultBranch=main` is the leak this suite tripped on — + # git refuses `branch -f` on the CHECKED-OUT branch, so a machine configured + # the modern way failed every test in the file at setup, while CI passed only + # because the runner's git still defaults to `master`. `commit.gpgsign` is + # the same shape. crates/batten/tests/common/mod.rs:184-185 already does this. + export GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_SYSTEM=/dev/null + # `-b work`, so the checked-out branch is NAMED rather than inherited. The + # `main` created below is a second branch marking the trunk while HEAD stays + # on the feature branch — that topology is what these cases exercise — and + # the force-create this replaces could only ever build it by accident: it + # works while git's default is `master`, and git REFUSES to force the branch + # that is currently checked out, so the same line failed outright the moment + # a developer's default was the trunk's own name. Naming the branch makes the + # topology explicit instead of inheriting it, and `main` is then a fresh name + # needing no force at all. `no-branch-f-main` in batten.toml keeps the old + # form out; the literal is not spelled here, because that row is a substring + # rule over this directory and would fire on its own explanation. + git init -q -b work "$REPO" cd "$REPO" || return 1 git config user.email t@t git config user.name t git commit -q --allow-empty -m "chore: init" - git branch -f main + git branch main git update-ref refs/remotes/origin/main main git commit -q --allow-empty -m "feat: work" HEAD_SHA=$(git rev-parse HEAD)