Skip to content

fix(scripts): correct the pipe-shape exit-code advisory in every sibling banner - #13828

Merged
os-project-manager merged 2 commits into
mainfrom
claude/issue-13429-pipe-shape-advisory-siblings
Aug 31, 2026
Merged

fix(scripts): correct the pipe-shape exit-code advisory in every sibling banner#13828
os-project-manager merged 2 commits into
mainfrom
claude/issue-13429-pipe-shape-advisory-siblings

Conversation

@claude

@claude claude Bot commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

Fixes #13429

Nine banner sites across six files now carry one identical pipe-shape paragraph, so a single grep finds every copy — the property whose absence made this card necessary.

⚠️ Read this first: the prescribed mechanism is factually wrong, and this PR does not propagate it

Zone 1 binds me to name | head -N and the EPIPE mechanism, to name the safe capture, and to keep | tail identified as the shape that forwards the true status. All three are honoured. What I could not do is repeat PR #13427's specific sentence:

Piped, $? is the pipe's status, and | head -N turns even ${PIPESTATUS[0]}/pipefail green: head closes the read end early, so this gate takes EPIPE and exits 0 — the pipe changed the exit code itself.

Measured 2026-08-31, node 22.22.2 / bash 5, on a real refusing gate:

node scripts/check-test-completeness.mjs                  -> 3   (no pipe)
node ... 2>&1 | tail -4    $? = 0   ${PIPESTATUS[0]} = 3   pipefail -> 3
node ... 2>&1 | head -1    $? = 0   ${PIPESTATUS[0]} = 3   pipefail -> 3

| head did not turn ${PIPESTATUS[0]} or pipefail green. It is not a small-output artifact — a 5 MB producer behaves the same, and so do four other constructed shapes (process.exitCode, backpressured async writes, pnpm exec in front, a bash wrapper): every one preserved its non-zero code through | head -1.

And EPIPE genuinely happens — this is not "head read everything":

node -e 'process.stdout.on("error", e => process.stderr.write("PRODUCER-SAW: "+e.code+"\n")); ...exit(7)' | head -1
  PRODUCER-SAW: EPIPE
  ${PIPESTATUS[0]} = 7

The producer takes EPIPE and still reaches its own exit code. node ignores SIGPIPE and swallows the stdout write error, so a node gate's status survives | head intact.

What is actually true, and what all nine sites now say:

  • The false green is bare $? 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 no choice of shape repairs it.
  • ${PIPESTATUS[0]}/pipefail do recover the gate's own code. | tail reads to EOF and forwards it.
  • | head -N closes the read end early, the gate takes EPIPE, and what that really costs is the verdict text (truncated) plus — 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 from the one claimed.

Because the reference site would otherwise diverge from the other eight and keep the false claim, scripts/check-i18n-bundles.mjs is corrected here too. That is the one file the card explicitly says is already fixed; the diff is one paragraph, same defect class, same gate family, and it is what makes the single-grep census property actually hold. Flagging it for the maintainer rather than burying it.

The measurement is anchored where the next author will look: a block comment on reportPrerequisiteNotMet, with an explicit "do NOT write that | head turns PIPESTATUS/pipefail green" and why.

Census instrument

Verbatim, the command used to find the sites:

git grep -n "reads green either way"
git grep -n "the PIPE's status" -- 'scripts/**' 'packages/**'

The first alone is insufficient and the card says so; the second is what surfaced two sites the card's list does not carry (both named below). Going forward the instrument is one grep on the corrected mechanism:

git grep -n "no pipe shape repairs it"

which now returns all nine sites across the six files.

Inheritor count: measured 45, not 44 and not 43

Derivation, verbatim:

git grep -lE "^import .*from '.*import-prerequisite\.mjs'"

45 files. Both circulating numbers are wrong, in opposite directions:

  • Triage's 44 is git grep -l "import-prerequisite" -- 'scripts/**'. Of those 44, one is the module itself and one — scripts/pm/dispatch-gates.mjs — only mentions the filename in a comment and imports nothing. So under scripts/** the real importer count is 42.
  • The dispatch order's corrected 43 fixes the self-reference but keeps the scripts/** pathspec, which is itself too narrow: three real importers live in packages/lint/scripts/ and no scripts/** glob can see them.

42 + 3 = 45. Verified live: the corrected banner rendered from packages/lint/scripts/check-doc-formula-expressions.mjs during this PR's gate run.

A2.4 inherit-vs-shadow split: the sets are disjoint

# importers.txt : the 45 paths from the derivation above, sorted
# shadows.txt   : git grep -ln "PREREQUISITE NOT MET" , sorted
comm -12 importers.txt shadows.txt      ->  empty

Zero of the 45 inheritors carries its own literal copy of the banner. All four literal-copy files named by the card (check-i18n-coverage.mjs, check-half-states.mjs, ci-failure.mjs, check-test-completeness.mjs) import nothing from the module — they hard-code instead of inheriting. So the one-line edit corrects all 45 with no shadowing, and the remaining per-file work is exactly the four files the card lists. The dispatch's worry that the card's file list might be incomplete does not materialise on this axis.

(scripts/check-dual-build-cjs-loads.mjs also carries a local PREREQUISITE NOT MET banner, but it contains no pipe advisory at all — an omission, not a copy of the wrong fact. Left alone.)

Sites the card's instrument missed

Two, both surfaced by the mechanism census:

  1. scripts/pm/ci-failure.mjs:170 — a module-header comment, Piping hides all of it (\… | tail` reports the PIPE's status). Read `$?`.Same file already in scope, same wrong fact (names| tail` as the thing that hides), missed because the card grepped the old sentence. Corrected here as a bounded in-place fix: same defect class, mechanical, one line, no new verification surface.
  2. packages/cli/scripts/check-app-nav-i18n.mjs:620 — true but incomplete (prescribes echo "EXIT=$?" without saying before any pipe, names no shape). Different package, different gate family, so not touched: filed as [finding] packages/cli/scripts/check-app-nav-i18n.mjs prerequisite banner says echo "EXIT=$?" but never says BEFORE any pipe, and names no shape #13825.

Judged out of class and deliberately untouched: three .github/workflows/*.yml comments that say cmd | tail reports the pipe's status. Each is scoped to bare $?, where it is true, and each sits beside code that already does redirect-then-capture.

check:ratchet-remedy-authority (A2.5)

The dispatch was right that no path derivation names it. node scripts/pm/dispatch-gates.mjs derived 23 families for this diff and check:ratchet-remedy-authority is not among them. Run explicitly anyway — its own verdict line:

OK  check-ratchet-remedy-authority: 176 scripts swept (scripts/*.{mjs,mts}); 11 mark the
    expanding remedy ⛔ MAINTAINER-ONLY, 5 turn it down outright, 160 hand out no
    ratchet-expanding remedy.

Green. This diff adds no remedy prose and no ratchet vocabulary, so that is the expected direction — recorded as the data point the dispatch asked for either way.

Tests

All gate results below are from 8f739a646, the final commit, and exit codes were captured by redirect-then-capture, never through a pipe.

Self-tests — the constraint the card names (each must stay green, no exit code may change):

gate --self-test
scripts/import-prerequisite.mjs 0 — 41 cases (was 36; 5 added)
scripts/check-i18n-coverage.mjs 0
scripts/check-i18n-bundles.mjs 0
scripts/check-test-completeness.mjs 0
scripts/pm/check-half-states.mjs 0 — 1826 cases
scripts/pm/ci-failure.mjs 0

Exit codes unchanged, spot-checked on the live refusal path: node scripts/check-test-completeness.mjs answered 3 before the edit and 3 after.

New pins. reportPrerequisiteNotMet is split into a pure text builder plus a thin printer (no new export, no caller change, process.exit(1) untouched) so the self-test can assert the advisory as a value. Five pins: capture-before-any-pipe; the shape-independent false green; | tail kept as the forwarding shape; | head -N plus EPIPE plus its real cost; and a negative pin refusing the claim that any pipe shape defeats ${PIPESTATUS[0]}/pipefail.

Ablation — the pins bite. Committed first, then the banner was reverted to the old wrong text with a restore trap on absolute paths. Mutation confirmed on disk by counting both the injected and the deleted text plus a git hash-object move (55b7512b -> b511968f); no build/dist leg exists here, these .mjs files are executed directly. Result: 4 of the 5 new pins went red, self-test exit 1. The fifth (capture-before-any-pipe) stayed green because the mutation replaced only the mechanism paragraph — reported rather than smoothed over. Restoration proven byte-for-byte: on-disk hash back to 55b7512b, git diff HEAD empty, git status --porcelain empty, self-test back to 41/41 green.

Derived gate union — 25 commands, all green. node scripts/pm/dispatch-gates.mjs (no paths passed; it derives its own changeset from the merge base) named 23 families plus 2 convention-triggered ones. Every one run to exit 0, plus check:ratchet-remedy-authority and check:nul-bytes.

Two of them are not locally runnable in their bare form and were run in the shape CI uses: check:partof-closing-keyword needs PR_BODY (its package.json script is --self-test only, which is what CI runs), and node scripts/check-test-completeness.mjs needs a real CI log (CI runs it inside Test Core). Three gates first exited 1 with a PREREQUISITE NOT MET refusal because this worktree had no node_modules yet — read as NOT MEASURED, not red; all three went green after pnpm install. check:i18n and check:i18n-coverage each refused first for an unbuilt closure and both went green once the closure they named was built.

ESLint — narrowed, with the three things a narrowing owes. Instead of repo-wide pnpm lint:

pnpm exec eslint --no-inline-config --format json scripts/
  -> exit 0 · 204 files · 0 errors · 0 warnings
  1. The population is eslint's own — it resolved 204 files under scripts/ from its config, not from my guess, and that is every file in the tree my diff touches plus 198 more.
  2. The count is read from --format json, not from console text.
  3. Config invariance: this repo runs one eslint.config.mjs which never enables type-aware linting (no parserOptions.project, no typed @typescript-eslint rules) for any file — stated at eslint.config.mjs:327-329 and measured there with a positive control. So no verdict on an untouched file can move because of this diff.

Control bytes: check:nul-bytes exit 0 (7594 files scanned), plus a direct grep -naP control-character scan over the six changed files — clean.

Changeset: skip-changeset, and why this contradicts the dispatch order

The dispatch says a changeset is required. Measured, this repo offers no legal changeset for this diff:

  • Nothing here ships. The root package is private: true with no files, and @objectstack/lint — the only package with importers in this diff's blast radius — publishes ["dist","README.md","CHANGELOG.md"], so packages/lint/scripts/ is not in its tarball either. A changeset naming a package would manufacture a release whose tarball is byte-identical.
  • An empty-frontmatter changeset is mechanically rejected: scripts/check-empty-changeset.mjs refuses a PR that newly adds one, and its header carries the measurement (an empty changeset is a real input to changesets/action and can stall a release; the label produces no input at all).

So skip-changeset is the route, applied to this PR. Noted rather than done silently, because the diff is not a clean fit for that label's own closed path list either: four of the six files are root scripts/*.mjs rather than scripts/pm/**. The label's criterion — publishes nothing from any package — is satisfied outright. One label removal plus one file reverses this if the PM reads it differently.


Generated by Claude Code

claude added 2 commits August 31, 2026 13:24
…ing banner

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pk26oZ12t5N1hwGW1m1MgC
@github-actions

Copy link
Copy Markdown
Contributor

⛔ merge queue 构建失败 — 先分诊,再决定要不要重排

队列构建 33404373114 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集),
所以失败的测试可能在本 PR 没碰过的包里 —— 那不是重排能修的。每次盲目重排都会让排在后面的所有 PR 重建一轮。

失败的 job(日志抽取,best effort):

  • Test Core (1/6) — 失败步骤: Run this shard's tests

    @objectstack/client:test:  FAIL  src/envelope-caller-census.test.ts > #13079 §3 — every call site is classified > the mechanical enumeration and the hand ledger agree, site for site
      ↳ 失败原因: @objectstack/client:test: AssertionError: expected { …(13) } to deeply equal { …(12) }
    @objectstack/client:test:  FAIL  src/envelope-caller-census.test.ts > #13079 §3 — every call site is classified > ⭐ THE NUMBER: zero call sites in this repo would break SILENTLY
      ↳ 失败原因: @objectstack/client:test: AssertionError: expected [ { …(5) }, { …(5) } ] to deeply equal []
    @objectstack/client:test:  FAIL  src/envelope-caller-census.test.ts > #13079 §3 — every call site is classified > records the split: 13 loud pin sites, 6 result-insensitive, 1 not-SDK
      ↳ 失败原因: @objectstack/client:test: AssertionError: expected 21 to be 19 // Object.is equality
    

↳ 失败原因 是判读的关键:超时Test timed out in … / Hook timed out in …)多半是负载/时序,不是本 PR 的回归;
断言AssertionError: …)才指向真实的行为改变。两者的 FAIL 行长得一模一样,只有这一行能区分。

跨 PR 相同签名(24h,按失败测试文件聚合):

历史信号:

  • 本 PR 过去 24h 无队列失败记录(首次)。
  • 过去 24h 队列共有 9 个失败构建(不含本次)。

分诊清单:

  1. 失败测试在本 PR 改动的包里 → 真回归,修 PR。
  2. 失败测试与本 PR 无关 → 看上面的「跨 PR 相同签名」;已有汇总 issue ⇒ flaky/环境问题实锤,去那张 issue 上谈,修好前重排只会再烧一轮全队列。
  3. 两者都不是 → 可能与同组 PR 语义冲突;等前面的 PR 落地或失败出队后再重排一次即可,不要连续重排。

Generated by Claude Code · merge-queue-triage workflow (#4859)

Merged via the queue into main with commit 4cc99bc Aug 31, 2026
35 checks passed
@os-project-manager
os-project-manager deleted the claude/issue-13429-pipe-shape-advisory-siblings branch August 31, 2026 15:06
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/m skip-changeset PR has no user-facing published change; bypasses the changeset gate

Projects

None yet

2 participants