Skip to content

Commit 4cc99bc

Browse files
claude[bot]claude
andauthored
fix(scripts): correct the pipe-shape exit-code advisory in every sibling banner (#13828)
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. Claude-Session: https://claude.ai/code/session_01Pk26oZ12t5N1hwGW1m1MgC Co-authored-by: Claude <noreply@anthropic.com>
1 parent aed92e9 commit 4cc99bc

6 files changed

Lines changed: 125 additions & 21 deletions

File tree

scripts/check-i18n-bundles.mjs

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -991,9 +991,11 @@ function reportPrerequisiteNotMet(headline, detail, options = {}) {
991991
alsoFix.map((l) => ` ${l}\n`).join('') +
992992
`\n${nothingChecked}\n` +
993993
` (Exit code 1 — capture it BEFORE any pipe: \`pnpm check:i18n > /tmp/i18n.log 2>&1; echo "EXIT=$?"\`.\n` +
994-
` Piped, \`$?\` is the pipe's status, and \`| head -N\` turns even \`\${PIPESTATUS[0]}\`/\`pipefail\` green:\n` +
995-
` \`head\` closes the read end early, so this gate takes EPIPE and exits 0 — the pipe changed the\n` +
996-
` exit code itself. \`| tail\` reads to EOF; \`\${PIPESTATUS[0]}\` after it is the true status.)`,
994+
` Piped, \`$?\` is the LAST command's status, and \`head\`/\`tail\` essentially never fail — that\n` +
995+
` is the false green, and no pipe shape repairs it. \`\${PIPESTATUS[0]}\`/\`pipefail\` do recover\n` +
996+
` this gate's own code: \`| tail\` reads to EOF and forwards it, while \`| head -N\` closes the\n` +
997+
` read end early — the gate takes EPIPE, its verdict text is TRUNCATED, and a producer that\n` +
998+
` dies on SIGPIPE reports 141 rather than what it meant to say.)`,
997999
);
9981000
process.exit(1);
9991001
}

scripts/check-i18n-coverage.mjs

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1133,8 +1133,13 @@ function reportPrerequisiteNotMet(headline, detail) {
11331133
` Nothing was measured: no config was linted and no count was compared, so this\n` +
11341134
` result says NOTHING about whether any declared label went untranslated — and\n` +
11351135
` the baseline was left exactly as committed (\`--update\` included).\n` +
1136-
` (Exit code 1 — but piping this gate reports the PIPE's status, so\n` +
1137-
` \`pnpm check:i18n-coverage | tail -4\` reads green either way. Use \`echo "EXIT=$?"\`.)`,
1136+
` (Exit code 1 — capture it BEFORE any pipe:\n` +
1137+
` \`pnpm check:i18n-coverage > /tmp/i18n-coverage.log 2>&1; echo "EXIT=$?"\`.\n` +
1138+
` Piped, \`$?\` is the LAST command's status, and \`head\`/\`tail\` essentially never fail — that\n` +
1139+
` is the false green, and no pipe shape repairs it. \`\${PIPESTATUS[0]}\`/\`pipefail\` do recover\n` +
1140+
` this gate's own code: \`| tail\` reads to EOF and forwards it, while \`| head -N\` closes the\n` +
1141+
` read end early — the gate takes EPIPE, its verdict text is TRUNCATED, and a producer that\n` +
1142+
` dies on SIGPIPE reports 141 rather than what it meant to say.)`,
11381143
);
11391144
process.exit(1);
11401145
}
@@ -1188,8 +1193,13 @@ function reportUnmeasuredConfigs(failures, measuredCount) {
11881193
` \`--update\` would freeze the survivors while silently dropping the rest. So this\n` +
11891194
` result says NOTHING about whether any declared label went untranslated, and the\n` +
11901195
` baseline was left exactly as committed (\`--update\` included).\n` +
1191-
` (Exit code 1 — but piping this gate reports the PIPE's status, so\n` +
1192-
` \`pnpm check:i18n-coverage | tail -4\` reads green either way. Use \`echo "EXIT=$?"\`.)`,
1196+
` (Exit code 1 — capture it BEFORE any pipe:\n` +
1197+
` \`pnpm check:i18n-coverage > /tmp/i18n-coverage.log 2>&1; echo "EXIT=$?"\`.\n` +
1198+
` Piped, \`$?\` is the LAST command's status, and \`head\`/\`tail\` essentially never fail — that\n` +
1199+
` is the false green, and no pipe shape repairs it. \`\${PIPESTATUS[0]}\`/\`pipefail\` do recover\n` +
1200+
` this gate's own code: \`| tail\` reads to EOF and forwards it, while \`| head -N\` closes the\n` +
1201+
` read end early — the gate takes EPIPE, its verdict text is TRUNCATED, and a producer that\n` +
1202+
` dies on SIGPIPE reports 141 rather than what it meant to say.)`,
11931203
);
11941204
process.exit(1);
11951205
}
@@ -1217,8 +1227,13 @@ function reportEmptyPopulation(verdict) {
12171227
` Nothing was measured: no config was linted and no count was compared, so this\n` +
12181228
` result says NOTHING about whether any declared label went untranslated — and\n` +
12191229
` the baseline was left exactly as committed (\`--update\` included).\n` +
1220-
` (Exit code 1 — but piping this gate reports the PIPE's status, so\n` +
1221-
` \`pnpm check:i18n-coverage | tail -4\` reads green either way. Use \`echo "EXIT=$?"\`.)`,
1230+
` (Exit code 1 — capture it BEFORE any pipe:\n` +
1231+
` \`pnpm check:i18n-coverage > /tmp/i18n-coverage.log 2>&1; echo "EXIT=$?"\`.\n` +
1232+
` Piped, \`$?\` is the LAST command's status, and \`head\`/\`tail\` essentially never fail — that\n` +
1233+
` is the false green, and no pipe shape repairs it. \`\${PIPESTATUS[0]}\`/\`pipefail\` do recover\n` +
1234+
` this gate's own code: \`| tail\` reads to EOF and forwards it, while \`| head -N\` closes the\n` +
1235+
` read end early — the gate takes EPIPE, its verdict text is TRUNCATED, and a producer that\n` +
1236+
` dies on SIGPIPE reports 141 rather than what it meant to say.)`,
12221237
);
12231238
process.exit(1);
12241239
}

scripts/check-test-completeness.mjs

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -478,9 +478,13 @@ function prerequisiteNotMet({ headline, detail, fix }) {
478478
' this result says NOTHING about whether every test vitest counted actually ran,',
479479
' nor about whether every scheduled package reported.',
480480
' ⛔ It is NOT a finding, and it is not evidence that anything in the tree is wrong.',
481-
` (Exit code ${EXIT_PREREQUISITE_NOT_MET}, distinct from a finding's ${EXIT_FINDINGS} — but a pipe reports the PIPE's`,
482-
' status, so `node scripts/check-test-completeness.mjs | tail -4` reads green either',
483-
' way. Capture `echo "EXIT=$?"` BEFORE any pipe.)',
481+
` (Exit code ${EXIT_PREREQUISITE_NOT_MET}, distinct from a finding's ${EXIT_FINDINGS} — capture it BEFORE any pipe:`,
482+
' `node scripts/check-test-completeness.mjs > /tmp/test-completeness.log 2>&1; echo "EXIT=$?"`.',
483+
" Piped, `$?` is the LAST command's status, and `head`/`tail` essentially never fail — that",
484+
' is the false green, and no pipe shape repairs it. `${PIPESTATUS[0]}`/`pipefail` do recover',
485+
" this gate's own code: `| tail` reads to EOF and forwards it, while `| head -N` closes the",
486+
' read end early — the gate takes EPIPE, its verdict text is TRUNCATED, and a producer that',
487+
' dies on SIGPIPE reports 141 rather than what it meant to say.)',
484488
],
485489
};
486490
}

scripts/import-prerequisite.mjs

Lines changed: 70 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -394,21 +394,61 @@ export async function requireDefaultExport(specifier, load, importerUrl, options
394394
* The shared frame, in `check-i18n-coverage.mjs`'s wording and order: what is
395395
* unmet, why, the command that clears it, and — load-bearing — that nothing was
396396
* measured, so the exit code says nothing about the gate's actual question.
397+
*
398+
* ── The pipe-shape advisory, and why it says what it says ──────────────────
399+
*
400+
* Every importer of this module inherits the closing paragraph verbatim, so a
401+
* wrong claim there is wrong in every gate at once. Measured here 2026-08-31 on
402+
* node 22.22.2 / bash 5, one refusing gate plus constructed producers:
403+
*
404+
* node scripts/check-test-completeness.mjs -> 3 (no pipe)
405+
* … 2>&1 | tail -4 $? = 0 ${PIPESTATUS[0]} = 3 pipefail -> 3
406+
* … 2>&1 | head -1 $? = 0 ${PIPESTATUS[0]} = 3 pipefail -> 3
407+
*
408+
* So the false green is `$?` after ANY pipe — it is the LAST command's status,
409+
* and `head`/`tail` both essentially never fail. It is NOT a property of one
410+
* shape, and choosing a different shape does not repair it.
411+
*
412+
* ⚠️ `| head -N` does close the read end early and the producer DOES take EPIPE
413+
* — proven by a producer that prints what it caught — but node ignores SIGPIPE
414+
* and swallows the stdout write error, so the gate still reaches its own exit:
415+
* a producer instrumented to exit 7 reported `${PIPESTATUS[0]}` = 7 through
416+
* `| head -1`. ⛔ Do NOT write that `| head` turns `${PIPESTATUS[0]}`/`pipefail`
417+
* green; it does not, and an earlier draft of this advisory said so. What `head`
418+
* really costs is the VERDICT TEXT (truncated), and — for a producer that does
419+
* not ignore SIGPIPE, unlike node — a real code replaced by 141: `seq 1
420+
* 100000000 | head -1` reports `${PIPESTATUS[0]}` = 141. That is a false RED,
421+
* the opposite direction.
422+
*
423+
* `selfTest` pins the four load-bearing clauses below.
397424
*/
398425
export function reportPrerequisiteNotMet(importerUrl, verdict, measures) {
426+
console.error(prerequisiteNotMetText(importerUrl, verdict, measures));
427+
process.exit(1);
428+
}
429+
430+
/**
431+
* The text `reportPrerequisiteNotMet` prints, as a value — so the self-test can
432+
* assert on the advisory without spawning a process or stubbing `process.exit`.
433+
*/
434+
function prerequisiteNotMetText(importerUrl, verdict, measures) {
399435
const gate = fileURLToPath(importerUrl).split('/').pop().replace(/\.mjs$/, '');
400436
const subject = measures ? `whether ${measures}` : `what it gates`;
401-
console.error(
437+
return (
402438
`\n${gate}: PREREQUISITE NOT MET — ${verdict.headline}\n\n` +
403439
verdict.detail.map((l) => (l ? ` ${l}` : '')).join('\n') +
404440
`\n\n Fix: ${verdict.fix}\n\n` +
405441
` Nothing was measured: this gate exited before running a single check, so this\n` +
406442
` result says NOTHING about ${subject}. It is NOT a finding, and it is not\n` +
407443
` evidence that anything in the tree is wrong.\n` +
408-
` (Exit code 1 — but piping this gate reports the PIPE's status, so\n` +
409-
` \`node scripts/${gate}.mjs | tail -4\` reads green either way. Use \`echo "EXIT=$?"\`.)`,
444+
` (Exit code 1 — capture it BEFORE any pipe:\n` +
445+
` \`node scripts/${gate}.mjs > /tmp/${gate}.log 2>&1; echo "EXIT=$?"\`.\n` +
446+
` Piped, \`$?\` is the LAST command's status, and \`head\`/\`tail\` essentially never fail — that\n` +
447+
` is the false green, and no pipe shape repairs it. \`\${PIPESTATUS[0]}\`/\`pipefail\` do recover\n` +
448+
` this gate's own code: \`| tail\` reads to EOF and forwards it, while \`| head -N\` closes the\n` +
449+
` read end early — the gate takes EPIPE, its verdict text is TRUNCATED, and a producer that\n` +
450+
` dies on SIGPIPE reports 141 rather than what it meant to say.)`
410451
);
411-
process.exit(1);
412452
}
413453

414454
// ---------------------------------------------------------------------------
@@ -586,6 +626,32 @@ export function selfTest() {
586626
rmSync(dir, { recursive: true, force: true });
587627
}
588628

629+
// ── the inherited pipe-shape advisory ───────────────────────────────────
630+
// Pinned HERE and nowhere else, because this is the one copy 45 importers
631+
// print. The clauses are the four the advisory is for; the negative one is
632+
// the load-bearing one, since the wrong claim it excludes reads perfectly
633+
// plausible and shipped once already.
634+
const advisory = prerequisiteNotMetText(
635+
new URL('file:///repo/scripts/check-fixture-gate.mjs').href,
636+
{ headline: 'h', detail: ['d'], fix: 'f' },
637+
undefined,
638+
);
639+
t('the advisory prescribes capturing the code BEFORE any pipe',
640+
advisory.includes('capture it BEFORE any pipe') && advisory.includes('> /tmp/check-fixture-gate.log 2>&1'),
641+
advisory);
642+
t('the advisory names the shape-independent false green: `$?` is the LAST command\'s status',
643+
advisory.includes("`$?` is the LAST command's status") && advisory.includes('no pipe shape repairs it'));
644+
t('the advisory keeps `| tail` as the shape that FORWARDS the true status',
645+
advisory.includes('`| tail` reads to EOF and forwards it'));
646+
t('the advisory names `| head -N` and the EPIPE mechanism, with its real cost',
647+
advisory.includes('`| head -N` closes the') && advisory.includes('EPIPE')
648+
&& advisory.includes('TRUNCATED') && advisory.includes('141'));
649+
// ⛔ The claim this gate must never make again: measured 2026-08-31, `| head`
650+
// does NOT defeat `${PIPESTATUS[0]}`/`pipefail` — node ignores SIGPIPE and
651+
// reaches its own exit. See the mechanism note on `reportPrerequisiteNotMet`.
652+
t('the advisory does NOT claim a pipe shape defeats `${PIPESTATUS[0]}`/`pipefail`',
653+
!/turns even .*PIPESTATUS.*green|reads green either way/.test(advisory));
654+
589655
const failed = cases.filter((c) => !c.ok);
590656
for (const c of failed) console.error(` ✗ ${c.name}${c.detail ? ` -- ${c.detail}` : ''}`);
591657
if (failed.length) {

scripts/pm/check-half-states.mjs

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8502,8 +8502,13 @@ function reportPrerequisiteNotMet(v, options = {}) {
85028502
` per label", because a first page is not a count and this gate could not be run to get one.\n` +
85038503
` ⇒ Treat this exit as an unread instrument, never as a quiet board: the H39 census below\n` +
85048504
` does not appear at all in a run that ends here.\n` +
8505-
` (Exit code ${EXIT_PREREQUISITE_NOT_MET}, distinct from the unclassified failure's 2 — but piping this\n` +
8506-
` reports the PIPE's status, so \`… | tail -4\` reads green either way. Use \`echo "EXIT=$?"\`.)`,
8505+
` (Exit code ${EXIT_PREREQUISITE_NOT_MET}, distinct from the unclassified failure's 2 — capture it BEFORE\n` +
8506+
` any pipe: \`node scripts/pm/check-half-states.mjs > /tmp/half-states.log 2>&1; echo "EXIT=$?"\`.\n` +
8507+
` Piped, \`$?\` is the LAST command's status, and \`head\`/\`tail\` essentially never fail — that\n` +
8508+
` is the false green, and no pipe shape repairs it. \`\${PIPESTATUS[0]}\`/\`pipefail\` do recover\n` +
8509+
` this gate's own code: \`| tail\` reads to EOF and forwards it, while \`| head -N\` closes the\n` +
8510+
` read end early — the gate takes EPIPE, its verdict text is TRUNCATED, and a producer that\n` +
8511+
` dies on SIGPIPE reports 141 rather than what it meant to say.)`,
85078512
);
85088513
process.exit(EXIT_PREREQUISITE_NOT_MET);
85098514
}

scripts/pm/ci-failure.mjs

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -167,7 +167,9 @@
167167
* environment exactly as much as one that was dead at
168168
* request 1.
169169
*
170-
* Piping hides all of it (`… | tail` reports the PIPE's status). Read `$?`.
170+
* Piping hides all of it: after ANY pipe `$?` is the LAST command's status, and `head`/`tail`
171+
* essentially never fail. Capture the exit code BEFORE any pipe; `${PIPESTATUS[0]}`/`pipefail`
172+
* recover it, `| tail` reading to EOF while `| head -N` truncates the verdict text.
171173
*
172174
* ## The transport — node's fetch does not read HTTPS_PROXY, and the failure
173175
* wears a credential fault's face
@@ -1537,7 +1539,12 @@ function render(result, target) {
15371539

15381540
say(`(Exit ${verdict.exit}: 0 green · 1 red with the assertion in hand · 2 undetermined · ` +
15391541
`${EXIT_PREREQUISITE_NOT_MET} prerequisite not met.`);
1540-
say(" Piping reports the PIPE's status, so `... | tail` reads green either way. Use `echo 'EXIT=$?'`.)");
1542+
say(" Capture it BEFORE any pipe: `node scripts/pm/ci-failure.mjs > /tmp/ci-failure.log 2>&1; echo \"EXIT=$?\"`.");
1543+
say(" Piped, `$?` is the LAST command's status, and `head`/`tail` essentially never fail — that is the");
1544+
say(" false green, and no pipe shape repairs it. `${PIPESTATUS[0]}`/`pipefail` do recover this tool's own");
1545+
say(" code: `| tail` reads to EOF and forwards it, while `| head -N` closes the read end early — the tool");
1546+
say(" takes EPIPE, its verdict text is TRUNCATED, and a producer that dies on SIGPIPE reports 141 rather");
1547+
say(" than what it meant to say.)");
15411548
return { text: out.join('\n'), exit: verdict.exit, verdict };
15421549
}
15431550

@@ -2421,7 +2428,12 @@ async function reportMidWalkFailure(error, stage) {
24212428
console.error(`\nci-failure: ${decision.verdict}${decision.headline}\n`);
24222429
for (const line of decision.detail) console.error(line ? ` ${line}` : '');
24232430
for (const line of renderFixLines(decision.fix)) console.error(line);
2424-
console.error(" Piping reports the PIPE's status, so `... | tail` reads green either way. Use `echo \"EXIT=$?\"`.");
2431+
console.error(" Capture the exit code BEFORE any pipe: `node scripts/pm/ci-failure.mjs > /tmp/ci-failure.log 2>&1; echo \"EXIT=$?\"`.");
2432+
console.error(" Piped, `$?` is the LAST command's status, and `head`/`tail` essentially never fail — that is the");
2433+
console.error(" false green, and no pipe shape repairs it. `${PIPESTATUS[0]}`/`pipefail` do recover this tool's own");
2434+
console.error(" code: `| tail` reads to EOF and forwards it, while `| head -N` closes the read end early — the tool");
2435+
console.error(" takes EPIPE, its verdict text is TRUNCATED, and a producer that dies on SIGPIPE reports 141 rather");
2436+
console.error(" than what it meant to say.");
24252437
process.exit(decision.exit);
24262438
}
24272439

0 commit comments

Comments
 (0)