From eb6f7178c6d714c37d1f44ab0c98b642bbda55f4 Mon Sep 17 00:00:00 2001 From: Stella Test Date: Thu, 6 Aug 2026 03:52:47 -0700 Subject: [PATCH 1/3] fix(gate): decide guard verdicts before printing so a closed pipe cannot forge a failure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Piping a guard's output is the normal way to read it — `| tail`, `| rg FAIL`, `| head` — and check-god-files.sh reported a FALSE failure naming a different crate on each such run. Two mechanisms, both SIGPIPE: * Its crate-membership test was `printf ... | grep -qx`. `grep -q` exits the moment it matches, printf then dies writing into the closed pipe, and `set -o pipefail` reports the pipeline as failed — so a crate that IS present intermittently read as absent, and the guard blamed whichever crate the race landed on. Replaced with the pure-shell case membership check check-command-docs.sh already uses. * Guards printed their report incrementally while still deciding it. When stdout (or stderr under `2>&1`) is a pipe whose reader has exited, the next write dies of SIGPIPE — 141 outright, or EPIPE plus `set -e` = exit 1 where SIGPIPE is inherited ignored — and whatever partial state the scan had reached becomes the verdict. Every swept guard now decides its verdict fully before printing: failure lines are buffered into a variable and emitted in one final write, and that write runs under `trap '' PIPE` with its failure discarded, so an early-exiting reader can change neither the verdict nor the exit code. check-file-size.sh and check-left-behind.sh already buffered their reports and needed only the hardened final write. Swept: check-god-files, check-invariants, check-left-behind, check-file-size, check-command-docs, check-gate-parity, check-role-names, check-cargo-install-pins, check-repro-wiring. Output text is byte-identical in every case. check-role-names' subshell marker file is gone: the `sed | while` loop is now redirected from process substitution, so `fail=1` and the buffered report survive. Witness: scripts/test-guard-sigpipe.sh (make guard-sigpipe-test) pipes each guard into `head -1` and `true` and asserts exit 0. On the old scripts all nine `| true` cases die with exit 141 (18 of 27 cases pass); on the new ones all 27 pass. Closes #1815 --- Makefile | 4 ++ scripts/check-cargo-install-pins.sh | 32 ++++++++-- scripts/check-command-docs.sh | 73 ++++++++++++++--------- scripts/check-file-size.sh | 7 ++- scripts/check-gate-parity.sh | 25 +++++++- scripts/check-god-files.sh | 44 +++++++++++++- scripts/check-invariants.sh | 42 +++++++++---- scripts/check-left-behind.sh | 6 +- scripts/check-repro-wiring.sh | 35 ++++++++--- scripts/check-role-names.sh | 60 +++++++++++-------- scripts/test-guard-sigpipe.sh | 91 +++++++++++++++++++++++++++++ 11 files changed, 339 insertions(+), 80 deletions(-) create mode 100755 scripts/test-guard-sigpipe.sh diff --git a/Makefile b/Makefile index 2c4f58077..d8c7f538d 100644 --- a/Makefile +++ b/Makefile @@ -340,6 +340,10 @@ releases-baseline-update: ## Grandfather the tags that shipped nothing and never releases-published-test: ## Test the tag/release reconciliation rule (hermetic; not part of `gate`) ./scripts/test-releases-published.sh +.PHONY: guard-sigpipe-test +guard-sigpipe-test: ## Test that the gate guards survive a reader that closes their pipe early (#1815; hermetic; not part of `gate`) + ./scripts/test-guard-sigpipe.sh + .PHONY: hooks hooks: ## Install the pre-push gate hook (runs `make gate`, scoped to the diff, on every push) git config core.hooksPath .githooks diff --git a/scripts/check-cargo-install-pins.sh b/scripts/check-cargo-install-pins.sh index a2bbd3969..d29b08a73 100755 --- a/scripts/check-cargo-install-pins.sh +++ b/scripts/check-cargo-install-pins.sh @@ -57,6 +57,24 @@ if [ -z "$install_lines" ]; then fi fail=0 + +# The verdict is decided before anything is written (#1815). Failure lines are +# buffered while the scan runs and emitted in one final write: a guard that +# prints as it scans dies mid-report when its reader exits early, and under +# `set -euo pipefail` whatever partial state it had reached becomes the exit +# status. scripts/check-file-size.sh is the shape being copied. +report="" +note() { report="${report}$1"$'\n'; } + +# Emission is best-effort: the verdict is already decided, so a reader that +# closed the pipe (`| head -1`, `| true`) must be able to change neither the +# report nor the exit code. SIGPIPE is ignored so a failed write surfaces as a +# discarded error instead of killing the script (#1815). +emit() { + trap '' PIPE + printf '%s' "$report" >&2 || true +} + while IFS= read -r line; do [ -z "$line" ] && continue file_and_rest="${line%%:*}" @@ -71,7 +89,7 @@ while IFS= read -r line; do \\) continue ;; *@*) continue ;; *) - echo "check-cargo-install-pins: $file_and_rest:$lineno installs '$tok' with no @version pin." >&2 + note "check-cargo-install-pins: $file_and_rest:$lineno installs '$tok' with no @version pin." fail=1 ;; esac @@ -81,11 +99,13 @@ $install_lines EOF if [ "$fail" -ne 0 ]; then - echo >&2 - echo "Pin each crate to an exact version, e.g.:" >&2 - echo >&2 - echo " run: cargo install --locked cargo-deny@0.20.2" >&2 + note "" + note "Pin each crate to an exact version, e.g.:" + note "" + note " run: cargo install --locked cargo-deny@0.20.2" + emit exit 1 fi -echo "check-cargo-install-pins: OK — every 'cargo install' crate is pinned to a version." +emit +echo "check-cargo-install-pins: OK — every 'cargo install' crate is pinned to a version." || true diff --git a/scripts/check-command-docs.sh b/scripts/check-command-docs.sh index 40e7c0fef..463277957 100755 --- a/scripts/check-command-docs.sh +++ b/scripts/check-command-docs.sh @@ -46,6 +46,23 @@ exempt_file="scripts/command-docs-exempt.txt" status=0 +# The verdict is decided before anything is written (#1815). Failure lines are +# buffered while the checks run and emitted in one final write: a guard that +# prints as it scans dies mid-report when its reader exits early, and under +# `set -euo pipefail` whatever partial state it had reached becomes the exit +# status. scripts/check-file-size.sh is the shape being copied. +report="" +note() { report="${report}$1"$'\n'; } + +# Emission is best-effort: the verdict is already decided, so a reader that +# closed the pipe (`| head -1`, `| true`) must be able to change neither the +# report nor the exit code. SIGPIPE is ignored so a failed write surfaces as a +# discarded error instead of killing the script (#1815). +emit() { + trap '' PIPE + printf '%s' "$report" >&2 || true +} + # Membership over a newline-delimited list, in pure shell. # # Deliberately NOT `printf ... | grep -qx`: under `set -o pipefail` that is a @@ -66,16 +83,18 @@ $2 } if [ ! -f "$enum_file" ]; then - echo "FAIL $enum_file not found." >&2 - echo " This guard reads the top-level subcommand list from that file. If" >&2 - echo " it moved, update \$enum_file in scripts/check-command-docs.sh in" >&2 - echo " the same PR." >&2 + note "FAIL $enum_file not found." + note " This guard reads the top-level subcommand list from that file. If" + note " it moved, update \$enum_file in scripts/check-command-docs.sh in" + note " the same PR." + emit exit 1 fi if [ ! -d "$docs_dir" ]; then - echo "FAIL $docs_dir not found." >&2 - echo " This guard expects the command reference pages to live there." >&2 + note "FAIL $docs_dir not found." + note " This guard expects the command reference pages to live there." + emit exit 1 fi @@ -96,10 +115,11 @@ variants="$(awk -v decl="$enum_decl" ' ' "$enum_file")" if [ -z "$variants" ]; then - echo "FAIL parsed zero variants from $enum_file." >&2 - echo " Expected a block opening with: $enum_decl" >&2 - echo " If the declaration changed, update \$enum_decl in" >&2 - echo " scripts/check-command-docs.sh in the same PR." >&2 + note "FAIL parsed zero variants from $enum_file." + note " Expected a block opening with: $enum_decl" + note " If the declaration changed, update \$enum_decl in" + note " scripts/check-command-docs.sh in the same PR." + emit exit 1 fi @@ -145,7 +165,7 @@ while IFS= read -r slug; do [ -n "$slug" ] || continue is_exempt "$slug" && continue if [ ! -f "$docs_dir/$slug.mdx" ]; then - echo "FAIL \`stella $slug\` has no reference page: $docs_dir/$slug.mdx" >&2 + note "FAIL \`stella $slug\` has no reference page: $docs_dir/$slug.mdx" missing=$((missing + 1)) status=1 fi @@ -154,9 +174,9 @@ $slugs EOF if [ "$missing" -ne 0 ]; then - echo "" >&2 - echo " Write the page, or add the slug to $exempt_file with a reason." >&2 - echo " An exemption is a decision on the record; a missing page is not." >&2 + note "" + note " Write the page, or add the slug to $exempt_file with a reason." + note " An exemption is a decision on the record; a missing page is not." fi # --- 2. Every page is reachable from the sidebar ---------------------------- @@ -167,7 +187,7 @@ if [ -n "$sidebar" ]; then is_exempt "$slug" && continue [ -f "$docs_dir/$slug.mdx" ] || continue if ! contains "$sidebar" "$slug"; then - echo "FAIL $docs_dir/$slug.mdx is not listed in $meta_file." >&2 + note "FAIL $docs_dir/$slug.mdx is not listed in $meta_file." unlisted=$((unlisted + 1)) status=1 fi @@ -175,9 +195,9 @@ if [ -n "$sidebar" ]; then $slugs EOF if [ "$unlisted" -ne 0 ]; then - echo "" >&2 - echo " A page no sidebar lists is undocumented to anyone browsing." >&2 - echo " Add the slug to the \"pages\" array under the right group." >&2 + note "" + note " A page no sidebar lists is undocumented to anyone browsing." + note " Add the slug to the \"pages\" array under the right group." fi fi @@ -186,7 +206,7 @@ orphans=0 while IFS= read -r page; do [ -n "$page" ] || continue if ! contains "$slugs" "$page"; then - echo "FAIL $docs_dir/$page.mdx documents no \`Command\` variant." >&2 + note "FAIL $docs_dir/$page.mdx documents no \`Command\` variant." orphans=$((orphans + 1)) status=1 fi @@ -195,9 +215,9 @@ $pages EOF if [ "$orphans" -ne 0 ]; then - echo "" >&2 - echo " The command was renamed or removed and its page outlived it." >&2 - echo " Delete the page, or repoint it at the surface that replaced it." >&2 + note "" + note " The command was renamed or removed and its page outlived it." + note " Delete the page, or repoint it at the surface that replaced it." fi # --- 4. No stale exemptions ------------------------------------------------- @@ -205,9 +225,9 @@ if [ -n "$exempt" ]; then while IFS= read -r slug; do [ -n "$slug" ] || continue if ! contains "$slugs" "$slug"; then - echo "FAIL $exempt_file exempts \"$slug\", which is not a subcommand." >&2 - echo " Remove the line: a stale exemption silently covers a future" >&2 - echo " command that happens to reuse the name." >&2 + note "FAIL $exempt_file exempts \"$slug\", which is not a subcommand." + note " Remove the line: a stale exemption silently covers a future" + note " command that happens to reuse the name." status=1 fi done <&2; } + +# The verdict is decided before anything is written (#1815). Failure lines are +# buffered while the checks run and emitted in one final write: a guard that +# prints as it scans dies mid-report when its reader exits early, and under +# `set -euo pipefail` whatever partial state it had reached becomes the exit +# status. scripts/check-file-size.sh is the shape being copied. +report="" +note() { report="${report}check-gate-parity: $1"$'\n'; } + +# Emission is best-effort: the verdict is already decided, so a reader that +# closed the pipe (`| head -1`, `| true`) must be able to change neither the +# report nor the exit code. SIGPIPE is ignored so a failed write surfaces as a +# discarded error instead of killing the script (#1815). +emit() { + trap '' PIPE + printf '%s' "$report" >&2 || true +} # ── The truth ──────────────────────────────────────────────────────────────── @@ -57,12 +73,14 @@ if ! steps="$(make -s print-gate-steps 2>/dev/null)"; then note "FAIL — could not read GATE_STEPS (\`make -s print-gate-steps\`)." note " That target is what makes this guard derived rather than a" note " second copy of the list. Restore it in the Makefile." + emit exit 1 fi count="$(printf '%s\n' "$steps" | wc -w | tr -d ' ')" if [ "$count" -eq 0 ]; then note "FAIL — GATE_STEPS is empty." + emit exit 1 fi @@ -84,6 +102,7 @@ word="$(number_word "$count")" if [ -z "$word" ]; then note "FAIL — $count gate steps is outside the range number_word() spells." note " Extend the table in this script; the documents spell the count." + emit exit 1 fi @@ -173,8 +192,10 @@ if [ "$fail" -ne 0 ]; then note "The gate's composition lives in GATE_STEPS in the Makefile, and these" note "two documents restate it for readers. When you add or remove a guard," note "update all three in the same PR — that is what this guard is for." + emit exit 1 fi +emit printf 'check-gate-parity: OK — %s (%s) gate steps, named in %s and %s.\n' \ - "$count" "$word" "$agents" "$contributing" + "$count" "$word" "$agents" "$contributing" || true diff --git a/scripts/check-god-files.sh b/scripts/check-god-files.sh index 4267484f3..a99152329 100755 --- a/scripts/check-god-files.sh +++ b/scripts/check-god-files.sh @@ -49,11 +49,30 @@ baseline="scripts/file-size-baseline.txt" agents="AGENTS.md" fail=0 -note() { printf 'check-god-files: %s\n' "$1" >&2; } + +# The verdict is decided before anything is written (#1815). Every line is +# buffered while the comparisons run and emitted in one final write, because a +# guard that prints as it scans dies mid-report the moment its reader exits +# early (`| head`, a `tail` that has seen enough) — and under `set -euo +# pipefail` whatever partial comparison state it had reached becomes the exit +# status: a false failure naming whichever crate the race landed on. +# scripts/check-file-size.sh is the shape being copied. +report="" +note() { report="${report}check-god-files: $1"$'\n'; } + +# Emission is best-effort: the verdict is already decided, so a reader that +# closed the pipe (`| head -1`, `| true`) must be able to change neither the +# report nor the exit code. SIGPIPE is ignored so a failed write surfaces as a +# discarded error instead of killing the script (#1815). +emit() { + trap '' PIPE + printf '%s' "$report" >&2 || true +} if [ ! -f "$baseline" ]; then note "FAIL — $baseline does not exist. It is the only correct copy of the" note " god-file set; this guard has nothing to compare prose against." + emit exit 1 fi @@ -73,6 +92,7 @@ grep -v '^#' "$baseline" | if [ ! -s "$work/truth" ]; then note "FAIL — no crates/ entries in $baseline; the extraction must have broken." + emit exit 1 fi @@ -84,8 +104,23 @@ all_crates="$( LC_ALL=C sort )" +# Membership over the newline-delimited crate list, in pure shell. +# +# Deliberately NOT `printf ... | grep -qx`: under `set -o pipefail` that is a +# race, not a test — `grep -q` exits the moment it matches, `printf` then dies +# of SIGPIPE, and pipefail reports the pipeline as failed, so a crate that IS +# present intermittently reads as absent. That race is #1815: it made this +# guard blame a different innocent crate on every piped run. Same shape as +# contains() in scripts/check-command-docs.sh. has_god_files() { - printf '%s\n' "$crates_with_god_files" | grep -qx "$1" + case " +$crates_with_god_files +" in + *" +$1 +"*) return 0 ;; + *) return 1 ;; + esac } # Backticked crate-relative source paths on stdin, one per line, sorted. @@ -134,6 +169,7 @@ compare() { if [ ! -f "$agents" ]; then note "FAIL — $agents does not exist." + emit exit 1 fi @@ -201,10 +237,12 @@ if [ "$fail" -ne 0 ]; then note "set that can stay correct, so the prose follows it, never the reverse." note "After \`make file-size-update\`, update AGENTS.md's table and the affected" note "crate README in the same PR." + emit exit 1 fi +emit printf 'check-god-files: OK — %d god file(s) across %d crate(s), named identically in %s and every crate README.\n' \ "$(wc -l <"$work/truth" | tr -d ' ')" \ "$(printf '%s\n' "$crates_with_god_files" | wc -l | tr -d ' ')" \ - "$agents" + "$agents" || true diff --git a/scripts/check-invariants.sh b/scripts/check-invariants.sh index e16a92c77..b027813a2 100755 --- a/scripts/check-invariants.sh +++ b/scripts/check-invariants.sh @@ -34,6 +34,23 @@ fi home="AGENTS.md" status=0 +# The verdict is decided before anything is written (#1815). Failure lines are +# buffered while the scans run and emitted in one final write: a guard that +# prints as it scans dies mid-report when its reader exits early, and under +# `set -euo pipefail` whatever partial scan state it had reached becomes the +# exit status. scripts/check-file-size.sh is the shape being copied. +report="" +note() { report="${report}$1"$'\n'; } + +# Emission is best-effort: the verdict is already decided, so a reader that +# closed the pipe (`| head -1`, `| true`) must be able to change neither the +# report nor the exit code. SIGPIPE is ignored so a failed write surfaces as a +# discarded error instead of killing the script (#1815). +emit() { + trap '' PIPE + printf '%s' "$report" >&2 || true +} + if [ ! -f "$home" ]; then echo "check-invariants: $home not found; skipping." exit 0 @@ -48,9 +65,10 @@ invariant_re='^[0-9]+\. \*\*[^*]+\*\*' home_count="$(grep -cE "$invariant_re" "$home" || true)" if [ "$home_count" -eq 0 ]; then - echo "FAIL $home carries no numbered invariant list." >&2 - echo " This guard expects the normative list to live here. If it moved," >&2 - echo " update \$home in scripts/check-invariants.sh in the same PR." >&2 + note "FAIL $home carries no numbered invariant list." + note " This guard expects the normative list to live here. If it moved," + note " update \$home in scripts/check-invariants.sh in the same PR." + emit exit 1 fi @@ -75,7 +93,7 @@ while IFS= read -r file; do # Strip a trailing period so "Serde-first." matches "Serde-first". bare="$(printf '%s\n' "$name" | sed 's/\.$//')" if grep -qF "**$bare" "$home"; then - echo "FAIL $file:$line restates invariant \"$bare\", which is normative in $home." >&2 + note "FAIL $file:$line restates invariant \"$bare\", which is normative in $home." status=1 fi done <&2 - echo " The invariants have one home: $home. Replace the copy with a" >&2 - echo " pointer to it. A second copy drifts, and when it does there is no" >&2 - echo " way to tell which one governs." >&2 + note "" + note " The invariants have one home: $home. Replace the copy with a" + note " pointer to it. A second copy drifts, and when it does there is no" + note " way to tell which one governs." + emit exit 1 fi @@ -112,15 +131,16 @@ while IFS= read -r hit; do [ -n "$n" ] || continue checked=$((checked + 1)) if ! grep -qE "^${n}\. \*\*" "$home"; then - echo "FAIL $file:$line cites $home invariant #${n}, which does not exist." >&2 - echo " $home defines 1..$home_count." >&2 + note "FAIL $file:$line cites $home invariant #${n}, which does not exist." + note " $home defines 1..$home_count." status=1 fi done <&2 + report="${report}check-repro-wiring: $1"$'\n' fail=1 } +# A raw report line, no prefix and no verdict change — continuation output. +plain() { report="${report}$1"$'\n'; } + +# Emission is best-effort: the verdict is already decided, so a reader that +# closed the pipe (`| head -1`, `| true`) must be able to change neither the +# report nor the exit code. SIGPIPE is ignored so a failed write surfaces as a +# discarded error instead of killing the script (#1815). +emit() { + trap '' PIPE + printf '%s' "$report" >&2 || true +} for f in "$workflow" "$local_release" "$builder" "$packer"; do if [ ! -f "$f" ]; then @@ -39,6 +57,7 @@ for f in "$workflow" "$local_release" "$builder" "$packer"; do fi done if [ "$fail" -ne 0 ]; then + emit exit 1 fi @@ -58,7 +77,7 @@ for f in "$workflow" "$local_release"; do bare="$(sed 's/^[[:space:]]*#.*$//' "$f" | grep -nE 'cargo (zig)?build .*--release' || true)" if [ -n "$bare" ]; then note "$f builds a release binary directly instead of through ${builder}:" - printf '%s\n' "$bare" >&2 + plain "$bare" fi done @@ -102,11 +121,13 @@ if [ "${published:-0}" -lt 2 ]; then fi if [ "$fail" -ne 0 ]; then - echo >&2 - echo "Release builds must go through ${builder} (see #910). It is the single" >&2 - echo "place that remaps \$CARGO_HOME and the rustup sysroot out of the binary," >&2 - echo "asserts the rust-toolchain.toml pin, and emits the per-target checksum." >&2 + plain "" + plain "Release builds must go through ${builder} (see #910). It is the single" + plain "place that remaps \$CARGO_HOME and the rustup sysroot out of the binary," + plain "asserts the rust-toolchain.toml pin, and emits the per-target checksum." + emit exit 1 fi -echo "check-repro-wiring: OK — both release paths build through ${builder}, and publication waits on verify-reproducible." +emit +echo "check-repro-wiring: OK — both release paths build through ${builder}, and publication waits on verify-reproducible." || true diff --git a/scripts/check-role-names.sh b/scripts/check-role-names.sh index 9df6dc1b8..6d188e71c 100755 --- a/scripts/check-role-names.sh +++ b/scripts/check-role-names.sh @@ -52,7 +52,23 @@ repo_root="$(cd "$(dirname "$0")/.." && pwd)" cd "$repo_root" fail=0 -note() { printf 'check-role-names: %s\n' "$1" >&2; } + +# The verdict is decided before anything is written (#1815). Failure lines are +# buffered while the checks run and emitted in one final write: a guard that +# prints as it scans dies mid-report when its reader exits early, and under +# `set -euo pipefail` whatever partial state it had reached becomes the exit +# status. scripts/check-file-size.sh is the shape being copied. +report="" +note() { report="${report}check-role-names: $1"$'\n'; } + +# Emission is best-effort: the verdict is already decided, so a reader that +# closed the pipe (`| head -1`, `| true`) must be able to change neither the +# report nor the exit code. SIGPIPE is ignored so a failed write surfaces as a +# discarded error instead of killing the script (#1815). +emit() { + trap '' PIPE + printf '%s' "$report" >&2 || true +} # Retired spellings, and the modern role each maps to. A producer may mention # one of these only as an alias — never as a member of its own role set. @@ -64,6 +80,7 @@ rust_home="crates/stella-cli/src/config_wiring.rs" if [ ! -f "$rust_home" ]; then note "FAIL — $rust_home does not exist; role_key() is the normative home." + emit exit 1 fi @@ -84,6 +101,7 @@ if [ -z "$roles" ]; then note " If that function moved or changed shape, repoint this guard;" note " do not delete it. It is the only thing holding four languages" note " to one spelling." + emit exit 1 fi @@ -134,28 +152,26 @@ expect_exact_set() { # Every role named in a `pipeline__model` key anywhere in a file must be a # real role. A subset check, not an exact one: `default` has no flat key by # design (`default_model` is its key), so the set is legitimately smaller. +# +# Redirected rather than piped, like producer 5 below: a `while read` on the +# right of a pipe runs in a subshell, where `fail=1` and the buffered note +# would be set and then thrown away. expect_flat_keys_known() { path="$1" [ -f "$path" ] || return 0 - sed -n 's/.*pipeline_\([a-z_][a-z_]*\)_model.*/\1/p' "$path" | LC_ALL=C sort -u | - while IFS= read -r r; do - [ -n "$r" ] || continue - # Not a role name — these are the pipeline's own numeric settings. - case "$r" in - max_revisions | candidates) continue ;; - esac - if ! is_role "$r"; then - note "FAIL — $path writes pipeline_${r}_model, and '$r' is not a role." - printf 'x' >>"$flagfile" - fi - done + while IFS= read -r r; do + [ -n "$r" ] || continue + # Not a role name — these are the pipeline's own numeric settings. + case "$r" in + max_revisions | candidates) continue ;; + esac + if ! is_role "$r"; then + note "FAIL — $path writes pipeline_${r}_model, and '$r' is not a role." + fail=1 + fi + done < <(sed -n 's/.*pipeline_\([a-z_][a-z_]*\)_model.*/\1/p' "$path" | LC_ALL=C sort -u) } -# `while read` in a pipeline runs in a subshell, so `fail=1` set inside one -# would not survive. A marker file is the portable way to carry it back. -flagfile="$(mktemp "${TMPDIR:-/tmp}/stella-role-names.XXXXXX")" -trap 'rm -f "$flagfile"' EXIT - # ── The producers ──────────────────────────────────────────────────────────── # # Each entry states where the set lives and how it is spelled. Finding these @@ -232,18 +248,16 @@ for old in $retired_spellings; do fi done -if [ -s "$flagfile" ]; then - fail=1 -fi - if [ "$fail" -ne 0 ]; then note "" note "The role names live in role_key() in $rust_home." note "Renaming one is a cross-language change: the Rust compiler will not" note "find the Python dict or the JavaScript literal, and neither will your" note "tests. Update every producer named above in the same PR." + emit exit 1 fi +emit printf 'check-role-names: OK — %d role(s) [%s] consistent across every producer.\n' \ - "$(printf '%s\n' "$roles" | wc -l | tr -d ' ')" "$(printf '%s' "$roles_flat" | sed 's/ $//')" + "$(printf '%s\n' "$roles" | wc -l | tr -d ' ')" "$(printf '%s' "$roles_flat" | sed 's/ $//')" || true diff --git a/scripts/test-guard-sigpipe.sh b/scripts/test-guard-sigpipe.sh new file mode 100755 index 000000000..544c05e36 --- /dev/null +++ b/scripts/test-guard-sigpipe.sh @@ -0,0 +1,91 @@ +#!/usr/bin/env bash +# +# Tests that the gate guards survive a reader that closes their pipe (#1815). +# +# ./scripts/test-guard-sigpipe.sh +# +# Piping a guard's output is the normal way to read it — `| tail`, `| rg FAIL`, +# `| head` — and check-god-files.sh used to report a FALSE failure, naming a +# different crate each run, whenever a pipe of its closed early: under +# `set -euo pipefail` the failed write became the script's exit status, and +# whatever partial comparison state the scan had reached became the verdict. +# Worse, its crate-membership test was itself a `printf | grep -q` pipeline, +# which `grep -q`'s early exit could flip from "present" to "absent" mid-scan. +# +# So each case here runs a real guard against this repository with its stdout +# going to a reader that exits early, and asserts the exit code is the same 0 +# the unpiped run produces on a green tree. `| true` is the harshest reader — +# it closes the pipe before the guard writes anything — and it is +# deterministic: against the pre-#1815 scripts every `| true` case below dies +# on the final OK write with exit 141 (SIGPIPE) or 1 (EPIPE under `set -e`). +# `| head -1` is the repro the issue was filed with; the membership race it +# tickled was intermittent, so that case runs repeatedly. +# +# The guards are expected green here: `make gate` holds them green on every +# push, and a genuinely red tree fails loudly below with the guard's own +# report shown — it does not read as a SIGPIPE regression. +# +# Not part of `make gate` — run it after touching any scripts/check-*.sh, the +# same posture as scripts/test-file-size.sh. +# +# bash 3.2 compatible. + +set -uo pipefail + +repo_root="$(cd "$(dirname "$0")/.." && pwd -P)" +cd "$repo_root" || exit 1 + +pass=0 +fail=0 +errlog="$(mktemp "${TMPDIR:-/tmp}/stella-guard-sigpipe.XXXXXX")" +trap 'rm -f "$errlog"' EXIT INT TERM + +# want — run scripts/ with stdout piped into the +# reader, and expect the guard's exit code to be 0. PIPESTATUS[0] is the +# guard's own status, unpolluted by the reader's. +want() { + local guard="$1" + shift + local name="$guard | $*" + "scripts/$guard" 2>"$errlog" | "$@" >/dev/null 2>&1 + local rc="${PIPESTATUS[0]}" + if [ "$rc" -eq 0 ]; then + pass=$((pass + 1)) + echo "ok $name rc=0" + else + fail=$((fail + 1)) + echo "FAIL $name — rc=$rc (a closed pipe must not change the verdict)" + sed 's/^/ /' "$errlog" + fi +} + +# ── Every swept guard, against both early-exiting readers ──────────────────── +for guard in \ + check-god-files.sh \ + check-invariants.sh \ + check-left-behind.sh \ + check-command-docs.sh \ + check-gate-parity.sh \ + check-role-names.sh \ + check-cargo-install-pins.sh \ + check-repro-wiring.sh; do + want "$guard" true + want "$guard" head -1 +done + +# ── The original repro (#1815) ─────────────────────────────────────────────── +# The `printf | grep -qx` membership race fired only intermittently — a +# different innocent crate blamed on each piped run — so this case gets room +# to show itself: ten runs, all of which must agree with the unpiped verdict. +for _ in 1 2 3 4 5 6 7 8 9 10; do + want check-god-files.sh head -1 +done + +# ── The slow one ───────────────────────────────────────────────────────────── +# check-file-size.sh forks a `wc` per tracked file, which costs about a minute +# on a laptop, so it gets the one deterministic reader rather than the pair. +want check-file-size.sh true + +echo +echo "passed ${pass}, failed ${fail}" +[ "$fail" -eq 0 ] From a3268310e7a287326ed57ce857c0ef1669594a97 Mon Sep 17 00:00:00 2001 From: Stella Test Date: Thu, 6 Aug 2026 04:10:53 -0700 Subject: [PATCH 2/3] docs(gate): spell the gate step count as twenty-five after the parallel step additions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two gate steps landed on main in parallel — module-reachability (#1833) and self-driving-test in GATE_STEPS (#1821) — and each PR bumped the spelled-out count from twenty-three to twenty-four, so their merge left both AGENTS.md and CONTRIBUTING.md claiming twenty-four steps while GATE_STEPS holds twenty-five. check-gate-parity.sh catches exactly this, which is why docs-guards went red on every PR based on current main. The step lists themselves already name every step; only the two counts were stale. Same fix as the parallel unbreak PRs #1845/#1863 — the edits are identical, so whichever lands first the others still merge clean. Refs #1815 --- AGENTS.md | 2 +- CONTRIBUTING.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index cc171a764..d324ea1e3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -73,7 +73,7 @@ make gate # = no-scratch + no-secrets + design-refs # + self-driving-test (the shell harness) ``` -That is twenty-four steps, and the list is not maintained by hand: it is +That is twenty-five steps, and the list is not maintained by hand: it is `GATE_STEPS` in the `Makefile`, and `gate-parity` (`scripts/check-gate-parity.sh`) fails if this block or CONTRIBUTING.md's stops matching it. The block had already drifted twice before that guard existed, both times by under-reporting diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c9ed700cd..1403e2f14 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -95,7 +95,7 @@ cargo build -p stella-cli --bin stella && \ STELLA_BIN="$PWD/target/debug/stella" ./scripts/test-self-driving.sh ``` -Or just `make gate`, which is the twenty-four of them in order. +Or just `make gate`, which is the twenty-five of them in order. Do not maintain that list by hand. It is `GATE_STEPS` in the `Makefile`, and `./scripts/check-gate-parity.sh` — itself one of the steps — fails if this fence From 3167dbcf7c5958946ca14e93a7a48499ddb30f58 Mon Sep 17 00:00:00 2001 From: Mac Anderson Date: Thu, 6 Aug 2026 11:34:04 -0700 Subject: [PATCH 3/3] Update scripts/check-gate-parity.sh Co-authored-by: vercel[bot] <35613825+vercel[bot]@users.noreply.github.com> --- scripts/check-gate-parity.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/check-gate-parity.sh b/scripts/check-gate-parity.sh index e413bc607..de0963c80 100755 --- a/scripts/check-gate-parity.sh +++ b/scripts/check-gate-parity.sh @@ -190,5 +190,5 @@ if [ "$fail" -ne 0 ]; then fi emit -printf 'check-gate-parity: OK — %s (%s) gate steps, named in %s and %s.\n' \ - "$count" "$word" "$agents" "$contributing" || true +printf 'check-gate-parity: OK — %s gate steps, named in %s and %s.\n' \ + "$count" "$agents" "$contributing" || true