From 48dbc8568de5e6bba041dd969924b5ff9cb75641 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 31 Aug 2026 13:24:07 +0000 Subject: [PATCH] fix(scripts): correct the pipe-shape exit-code advisory in every sibling banner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The advisory printed by `import-prerequisite.mjs` and copied literally into four more gates named `| tail -4` as the shape that "reads green either way" and left `| head` unnamed. Measured, that is backwards for anyone reading `${PIPESTATUS[0]}` or running under `pipefail`: `| tail` forwards the true status. All nine banner sites now carry one identical mechanism paragraph, so a single grep finds every copy — the property whose absence made this change necessary. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Pk26oZ12t5N1hwGW1m1MgC --- scripts/check-i18n-bundles.mjs | 8 ++-- scripts/check-i18n-coverage.mjs | 27 ++++++++--- scripts/check-test-completeness.mjs | 10 ++-- scripts/import-prerequisite.mjs | 74 +++++++++++++++++++++++++++-- scripts/pm/check-half-states.mjs | 9 +++- scripts/pm/ci-failure.mjs | 18 +++++-- 6 files changed, 125 insertions(+), 21 deletions(-) diff --git a/scripts/check-i18n-bundles.mjs b/scripts/check-i18n-bundles.mjs index 11982f7442..b185716a0f 100644 --- a/scripts/check-i18n-bundles.mjs +++ b/scripts/check-i18n-bundles.mjs @@ -991,9 +991,11 @@ function reportPrerequisiteNotMet(headline, detail, options = {}) { alsoFix.map((l) => ` ${l}\n`).join('') + `\n${nothingChecked}\n` + ` (Exit code 1 — capture it BEFORE any pipe: \`pnpm check:i18n > /tmp/i18n.log 2>&1; echo "EXIT=$?"\`.\n` + - ` Piped, \`$?\` is the pipe's status, and \`| head -N\` turns even \`\${PIPESTATUS[0]}\`/\`pipefail\` green:\n` + - ` \`head\` closes the read end early, so this gate takes EPIPE and exits 0 — the pipe changed the\n` + - ` exit code itself. \`| tail\` reads to EOF; \`\${PIPESTATUS[0]}\` after it is the true status.)`, + ` Piped, \`$?\` is the LAST command's status, and \`head\`/\`tail\` essentially never fail — that\n` + + ` is the false green, and no pipe shape repairs it. \`\${PIPESTATUS[0]}\`/\`pipefail\` do recover\n` + + ` this gate's own code: \`| tail\` reads to EOF and forwards it, while \`| head -N\` closes the\n` + + ` read end early — the gate takes EPIPE, its verdict text is TRUNCATED, and a producer that\n` + + ` dies on SIGPIPE reports 141 rather than what it meant to say.)`, ); process.exit(1); } diff --git a/scripts/check-i18n-coverage.mjs b/scripts/check-i18n-coverage.mjs index 43f2278a14..5edb0e2596 100644 --- a/scripts/check-i18n-coverage.mjs +++ b/scripts/check-i18n-coverage.mjs @@ -1133,8 +1133,13 @@ function reportPrerequisiteNotMet(headline, detail) { ` Nothing was measured: no config was linted and no count was compared, so this\n` + ` result says NOTHING about whether any declared label went untranslated — and\n` + ` the baseline was left exactly as committed (\`--update\` included).\n` + - ` (Exit code 1 — but piping this gate reports the PIPE's status, so\n` + - ` \`pnpm check:i18n-coverage | tail -4\` reads green either way. Use \`echo "EXIT=$?"\`.)`, + ` (Exit code 1 — capture it BEFORE any pipe:\n` + + ` \`pnpm check:i18n-coverage > /tmp/i18n-coverage.log 2>&1; echo "EXIT=$?"\`.\n` + + ` Piped, \`$?\` is the LAST command's status, and \`head\`/\`tail\` essentially never fail — that\n` + + ` is the false green, and no pipe shape repairs it. \`\${PIPESTATUS[0]}\`/\`pipefail\` do recover\n` + + ` this gate's own code: \`| tail\` reads to EOF and forwards it, while \`| head -N\` closes the\n` + + ` read end early — the gate takes EPIPE, its verdict text is TRUNCATED, and a producer that\n` + + ` dies on SIGPIPE reports 141 rather than what it meant to say.)`, ); process.exit(1); } @@ -1188,8 +1193,13 @@ function reportUnmeasuredConfigs(failures, measuredCount) { ` \`--update\` would freeze the survivors while silently dropping the rest. So this\n` + ` result says NOTHING about whether any declared label went untranslated, and the\n` + ` baseline was left exactly as committed (\`--update\` included).\n` + - ` (Exit code 1 — but piping this gate reports the PIPE's status, so\n` + - ` \`pnpm check:i18n-coverage | tail -4\` reads green either way. Use \`echo "EXIT=$?"\`.)`, + ` (Exit code 1 — capture it BEFORE any pipe:\n` + + ` \`pnpm check:i18n-coverage > /tmp/i18n-coverage.log 2>&1; echo "EXIT=$?"\`.\n` + + ` Piped, \`$?\` is the LAST command's status, and \`head\`/\`tail\` essentially never fail — that\n` + + ` is the false green, and no pipe shape repairs it. \`\${PIPESTATUS[0]}\`/\`pipefail\` do recover\n` + + ` this gate's own code: \`| tail\` reads to EOF and forwards it, while \`| head -N\` closes the\n` + + ` read end early — the gate takes EPIPE, its verdict text is TRUNCATED, and a producer that\n` + + ` dies on SIGPIPE reports 141 rather than what it meant to say.)`, ); process.exit(1); } @@ -1217,8 +1227,13 @@ function reportEmptyPopulation(verdict) { ` Nothing was measured: no config was linted and no count was compared, so this\n` + ` result says NOTHING about whether any declared label went untranslated — and\n` + ` the baseline was left exactly as committed (\`--update\` included).\n` + - ` (Exit code 1 — but piping this gate reports the PIPE's status, so\n` + - ` \`pnpm check:i18n-coverage | tail -4\` reads green either way. Use \`echo "EXIT=$?"\`.)`, + ` (Exit code 1 — capture it BEFORE any pipe:\n` + + ` \`pnpm check:i18n-coverage > /tmp/i18n-coverage.log 2>&1; echo "EXIT=$?"\`.\n` + + ` Piped, \`$?\` is the LAST command's status, and \`head\`/\`tail\` essentially never fail — that\n` + + ` is the false green, and no pipe shape repairs it. \`\${PIPESTATUS[0]}\`/\`pipefail\` do recover\n` + + ` this gate's own code: \`| tail\` reads to EOF and forwards it, while \`| head -N\` closes the\n` + + ` read end early — the gate takes EPIPE, its verdict text is TRUNCATED, and a producer that\n` + + ` dies on SIGPIPE reports 141 rather than what it meant to say.)`, ); process.exit(1); } diff --git a/scripts/check-test-completeness.mjs b/scripts/check-test-completeness.mjs index 5078fd80ce..fd164cedf7 100644 --- a/scripts/check-test-completeness.mjs +++ b/scripts/check-test-completeness.mjs @@ -478,9 +478,13 @@ function prerequisiteNotMet({ headline, detail, fix }) { ' this result says NOTHING about whether every test vitest counted actually ran,', ' nor about whether every scheduled package reported.', ' ⛔ It is NOT a finding, and it is not evidence that anything in the tree is wrong.', - ` (Exit code ${EXIT_PREREQUISITE_NOT_MET}, distinct from a finding's ${EXIT_FINDINGS} — but a pipe reports the PIPE's`, - ' status, so `node scripts/check-test-completeness.mjs | tail -4` reads green either', - ' way. Capture `echo "EXIT=$?"` BEFORE any pipe.)', + ` (Exit code ${EXIT_PREREQUISITE_NOT_MET}, distinct from a finding's ${EXIT_FINDINGS} — capture it BEFORE any pipe:`, + ' `node scripts/check-test-completeness.mjs > /tmp/test-completeness.log 2>&1; echo "EXIT=$?"`.', + " Piped, `$?` is the LAST command's status, and `head`/`tail` essentially never fail — that", + ' is the false green, and no pipe shape repairs it. `${PIPESTATUS[0]}`/`pipefail` do recover', + " this gate's own code: `| tail` reads to EOF and forwards it, while `| head -N` closes the", + ' read end early — the gate takes EPIPE, its verdict text is TRUNCATED, and a producer that', + ' dies on SIGPIPE reports 141 rather than what it meant to say.)', ], }; } diff --git a/scripts/import-prerequisite.mjs b/scripts/import-prerequisite.mjs index e02b688ec7..55b7512b99 100644 --- a/scripts/import-prerequisite.mjs +++ b/scripts/import-prerequisite.mjs @@ -394,21 +394,61 @@ export async function requireDefaultExport(specifier, load, importerUrl, options * The shared frame, in `check-i18n-coverage.mjs`'s wording and order: what is * unmet, why, the command that clears it, and — load-bearing — that nothing was * measured, so the exit code says nothing about the gate's actual question. + * + * ── The pipe-shape advisory, and why it says what it says ────────────────── + * + * Every importer of this module inherits the closing paragraph verbatim, so a + * wrong claim there is wrong in every gate at once. Measured here 2026-08-31 on + * node 22.22.2 / bash 5, one refusing gate plus constructed producers: + * + * node scripts/check-test-completeness.mjs -> 3 (no pipe) + * … 2>&1 | tail -4 $? = 0 ${PIPESTATUS[0]} = 3 pipefail -> 3 + * … 2>&1 | head -1 $? = 0 ${PIPESTATUS[0]} = 3 pipefail -> 3 + * + * So the false green is `$?` after ANY pipe — it is the LAST command's status, + * and `head`/`tail` both essentially never fail. It is NOT a property of one + * shape, and choosing a different shape does not repair it. + * + * ⚠️ `| head -N` does close the read end early and the producer DOES take EPIPE + * — proven by a producer that prints what it caught — but node ignores SIGPIPE + * and swallows the stdout write error, so the gate still reaches its own exit: + * a producer instrumented to exit 7 reported `${PIPESTATUS[0]}` = 7 through + * `| head -1`. ⛔ Do NOT write that `| head` turns `${PIPESTATUS[0]}`/`pipefail` + * green; it does not, and an earlier draft of this advisory said so. What `head` + * really costs is the VERDICT TEXT (truncated), and — for a producer that does + * not ignore SIGPIPE, unlike node — a real code replaced by 141: `seq 1 + * 100000000 | head -1` reports `${PIPESTATUS[0]}` = 141. That is a false RED, + * the opposite direction. + * + * `selfTest` pins the four load-bearing clauses below. */ export function reportPrerequisiteNotMet(importerUrl, verdict, measures) { + console.error(prerequisiteNotMetText(importerUrl, verdict, measures)); + process.exit(1); +} + +/** + * The text `reportPrerequisiteNotMet` prints, as a value — so the self-test can + * assert on the advisory without spawning a process or stubbing `process.exit`. + */ +function prerequisiteNotMetText(importerUrl, verdict, measures) { const gate = fileURLToPath(importerUrl).split('/').pop().replace(/\.mjs$/, ''); const subject = measures ? `whether ${measures}` : `what it gates`; - console.error( + return ( `\n${gate}: PREREQUISITE NOT MET — ${verdict.headline}\n\n` + verdict.detail.map((l) => (l ? ` ${l}` : '')).join('\n') + `\n\n Fix: ${verdict.fix}\n\n` + ` Nothing was measured: this gate exited before running a single check, so this\n` + ` result says NOTHING about ${subject}. It is NOT a finding, and it is not\n` + ` evidence that anything in the tree is wrong.\n` + - ` (Exit code 1 — but piping this gate reports the PIPE's status, so\n` + - ` \`node scripts/${gate}.mjs | tail -4\` reads green either way. Use \`echo "EXIT=$?"\`.)`, + ` (Exit code 1 — capture it BEFORE any pipe:\n` + + ` \`node scripts/${gate}.mjs > /tmp/${gate}.log 2>&1; echo "EXIT=$?"\`.\n` + + ` Piped, \`$?\` is the LAST command's status, and \`head\`/\`tail\` essentially never fail — that\n` + + ` is the false green, and no pipe shape repairs it. \`\${PIPESTATUS[0]}\`/\`pipefail\` do recover\n` + + ` this gate's own code: \`| tail\` reads to EOF and forwards it, while \`| head -N\` closes the\n` + + ` read end early — the gate takes EPIPE, its verdict text is TRUNCATED, and a producer that\n` + + ` dies on SIGPIPE reports 141 rather than what it meant to say.)` ); - process.exit(1); } // --------------------------------------------------------------------------- @@ -586,6 +626,32 @@ export function selfTest() { rmSync(dir, { recursive: true, force: true }); } + // ── the inherited pipe-shape advisory ─────────────────────────────────── + // Pinned HERE and nowhere else, because this is the one copy 45 importers + // print. The clauses are the four the advisory is for; the negative one is + // the load-bearing one, since the wrong claim it excludes reads perfectly + // plausible and shipped once already. + const advisory = prerequisiteNotMetText( + new URL('file:///repo/scripts/check-fixture-gate.mjs').href, + { headline: 'h', detail: ['d'], fix: 'f' }, + undefined, + ); + t('the advisory prescribes capturing the code BEFORE any pipe', + advisory.includes('capture it BEFORE any pipe') && advisory.includes('> /tmp/check-fixture-gate.log 2>&1'), + advisory); + t('the advisory names the shape-independent false green: `$?` is the LAST command\'s status', + advisory.includes("`$?` is the LAST command's status") && advisory.includes('no pipe shape repairs it')); + t('the advisory keeps `| tail` as the shape that FORWARDS the true status', + advisory.includes('`| tail` reads to EOF and forwards it')); + t('the advisory names `| head -N` and the EPIPE mechanism, with its real cost', + advisory.includes('`| head -N` closes the') && advisory.includes('EPIPE') + && advisory.includes('TRUNCATED') && advisory.includes('141')); + // ⛔ The claim this gate must never make again: measured 2026-08-31, `| head` + // does NOT defeat `${PIPESTATUS[0]}`/`pipefail` — node ignores SIGPIPE and + // reaches its own exit. See the mechanism note on `reportPrerequisiteNotMet`. + t('the advisory does NOT claim a pipe shape defeats `${PIPESTATUS[0]}`/`pipefail`', + !/turns even .*PIPESTATUS.*green|reads green either way/.test(advisory)); + const failed = cases.filter((c) => !c.ok); for (const c of failed) console.error(` ✗ ${c.name}${c.detail ? ` -- ${c.detail}` : ''}`); if (failed.length) { diff --git a/scripts/pm/check-half-states.mjs b/scripts/pm/check-half-states.mjs index 7d5c908258..3f1006a455 100644 --- a/scripts/pm/check-half-states.mjs +++ b/scripts/pm/check-half-states.mjs @@ -8502,8 +8502,13 @@ function reportPrerequisiteNotMet(v, options = {}) { ` per label", because a first page is not a count and this gate could not be run to get one.\n` + ` ⇒ Treat this exit as an unread instrument, never as a quiet board: the H39 census below\n` + ` does not appear at all in a run that ends here.\n` + - ` (Exit code ${EXIT_PREREQUISITE_NOT_MET}, distinct from the unclassified failure's 2 — but piping this\n` + - ` reports the PIPE's status, so \`… | tail -4\` reads green either way. Use \`echo "EXIT=$?"\`.)`, + ` (Exit code ${EXIT_PREREQUISITE_NOT_MET}, distinct from the unclassified failure's 2 — capture it BEFORE\n` + + ` any pipe: \`node scripts/pm/check-half-states.mjs > /tmp/half-states.log 2>&1; echo "EXIT=$?"\`.\n` + + ` Piped, \`$?\` is the LAST command's status, and \`head\`/\`tail\` essentially never fail — that\n` + + ` is the false green, and no pipe shape repairs it. \`\${PIPESTATUS[0]}\`/\`pipefail\` do recover\n` + + ` this gate's own code: \`| tail\` reads to EOF and forwards it, while \`| head -N\` closes the\n` + + ` read end early — the gate takes EPIPE, its verdict text is TRUNCATED, and a producer that\n` + + ` dies on SIGPIPE reports 141 rather than what it meant to say.)`, ); process.exit(EXIT_PREREQUISITE_NOT_MET); } diff --git a/scripts/pm/ci-failure.mjs b/scripts/pm/ci-failure.mjs index ccff7f5922..40e79a9d2a 100644 --- a/scripts/pm/ci-failure.mjs +++ b/scripts/pm/ci-failure.mjs @@ -167,7 +167,9 @@ * environment exactly as much as one that was dead at * request 1. * - * Piping hides all of it (`… | tail` reports the PIPE's status). Read `$?`. + * Piping hides all of it: after ANY pipe `$?` is the LAST command's status, and `head`/`tail` + * essentially never fail. Capture the exit code BEFORE any pipe; `${PIPESTATUS[0]}`/`pipefail` + * recover it, `| tail` reading to EOF while `| head -N` truncates the verdict text. * * ## The transport — node's fetch does not read HTTPS_PROXY, and the failure * wears a credential fault's face @@ -1537,7 +1539,12 @@ function render(result, target) { say(`(Exit ${verdict.exit}: 0 green · 1 red with the assertion in hand · 2 undetermined · ` + `${EXIT_PREREQUISITE_NOT_MET} prerequisite not met.`); - say(" Piping reports the PIPE's status, so `... | tail` reads green either way. Use `echo 'EXIT=$?'`.)"); + say(" Capture it BEFORE any pipe: `node scripts/pm/ci-failure.mjs > /tmp/ci-failure.log 2>&1; echo \"EXIT=$?\"`."); + say(" Piped, `$?` is the LAST command's status, and `head`/`tail` essentially never fail — that is the"); + say(" false green, and no pipe shape repairs it. `${PIPESTATUS[0]}`/`pipefail` do recover this tool's own"); + say(" code: `| tail` reads to EOF and forwards it, while `| head -N` closes the read end early — the tool"); + say(" takes EPIPE, its verdict text is TRUNCATED, and a producer that dies on SIGPIPE reports 141 rather"); + say(" than what it meant to say.)"); return { text: out.join('\n'), exit: verdict.exit, verdict }; } @@ -2421,7 +2428,12 @@ async function reportMidWalkFailure(error, stage) { console.error(`\nci-failure: ${decision.verdict} — ${decision.headline}\n`); for (const line of decision.detail) console.error(line ? ` ${line}` : ''); for (const line of renderFixLines(decision.fix)) console.error(line); - console.error(" Piping reports the PIPE's status, so `... | tail` reads green either way. Use `echo \"EXIT=$?\"`."); + console.error(" Capture the exit code BEFORE any pipe: `node scripts/pm/ci-failure.mjs > /tmp/ci-failure.log 2>&1; echo \"EXIT=$?\"`."); + console.error(" Piped, `$?` is the LAST command's status, and `head`/`tail` essentially never fail — that is the"); + console.error(" false green, and no pipe shape repairs it. `${PIPESTATUS[0]}`/`pipefail` do recover this tool's own"); + console.error(" code: `| tail` reads to EOF and forwards it, while `| head -N` closes the read end early — the tool"); + console.error(" takes EPIPE, its verdict text is TRUNCATED, and a producer that dies on SIGPIPE reports 141 rather"); + console.error(" than what it meant to say."); process.exit(decision.exit); }