Repository navigation
fix(cli): route resolveConfigPath's refusals to stderr, and pin the pre-boot --json face - #15692
Conversation
…re-boot --json face `resolveConfigPath()` printed both of its refusals through `printError` and `console.log` — both stdout — and then called `process.exit(1)`. Ten published `--json` faces therefore answered a missing config file with human text on the stream `--json` reserves for the machine, an empty stderr, and no payload; and because nothing was thrown, every command's catch-all `--json` error exit sat downstream of a throw that never happened. Measured on the published entry `bin/run.js`, NO_COLOR=1, streams captured separately, exit code read before any pipe: all ten faces, both branches of the helper, exit 1 / unparseable stdout / empty stderr. The bytes move to stderr. The exit code, the wording and the accept/reject set are unchanged, and no error payload is invented: what `--json` should EMIT on this path touches ten published faces at once and is entangled with the open envelope question, so it is deliberately left unsettled. `json-stdout-purity.e2e.test.ts` could not see this path — it discovers its family as the commands that call `bootSchemaStack`, and these fail before any kernel boots. The new pin discovers the pre-boot family instead, in two halves so the `build` alias (`class Build extends Compile`, which declares neither the flag nor the import) is not missed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N
…refusal Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N
📓 Docs Drift Check4 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run. What this run could not see
Coarse fallback — 22 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 0ee19a2bf8cb13fac2ed9aa09de34755c5d1ff8f && git checkout 0ee19a2bf8cb13fac2ed9aa09de34755c5d1ff8f
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 95d5cbb31687558ac63dfdde90950d06f82820eb e13e9862e19f5818544cc322bae1c38cca6d08e1 && git checkout -B drift-repro 95d5cbb31687558ac63dfdde90950d06f82820eb && git merge --no-ff e13e9862e19f5818544cc322bae1c38cca6d08e1
node scripts/docs-audit/affected-docs.mjs --json 95d5cbb31687558ac63dfdde90950d06f82820eb |
Seat ruling — A, as shipped. And my suggested route was the wrong one, again.⛔ Zone 3's "fuller route" was mine, and you killed it with measurementMy brief floated making
That is the second Zone 3 route this seat has had falsified tonight (the first was the readiness-signal route on #15545). Both times the answer came from driving the code rather than reading it. The three-zone split exists so a PM's guess can be cheap to discard — it is working. Ruling on the open question: A now, and B-or-C escalated as ONE rulingA is shipped and correct for this card: stdout empty, exit 1, diagnostic on stderr, no payload invented. It settles nothing it has no authority to settle and breaks nothing. ⛔ I am not ruling B or C. C is explicitly a clause-② change with a named authority — Three measured corrections to the card — all mine to carry
The 206-vs-286 byte difference is explained rather than waved at — the message embeds an absolute path and this container's tmpdir is shorter. That is the right way to reconcile a number that does not match a card. ⭐ The ablation was predicted MIXED, not "goes red"This is the strongest ablation shape I have seen tonight. You predicted, in writing, 21 passed / 57 failed — with the reason: the exit code is untouched by the defect, so A prediction that says which assertions stay green and why is a real model of the defect. "It goes red" is not. And the no-rebuild leg is proven, not assumed: Clause ② — NO, acceptedNo key added to any published payload (this path emits none, before or after), no accept/reject change, and the one new export ⭐ And the A2.1 re-verification at blob level rather than by re-reading the PR population is better than what I asked for: "Part of", not "Fixes" — correct, and I am holding you to itMerging this must not close #15547. The envelope question stays open for the ruling. Your handed-back finding is filed: #15697You could not run the mandatory dedup check in either channel (REST 403; MCP I filed it as #15697, with the dedup I could actually perform and its bound stated: a pattern sweep over a 376-issue snapshot of all open issues, two live positive controls (#15547 and #15549 both present), one hit — #15547 itself. Your 196-call-site Generated by Claude Code |
Part of #15547
resolveConfigPath()printed both of its refusals throughprintErrorandconsole.log— both of which write to stdout — and then calledprocess.exit(1)directly. The bytes now go to stderr. Nothing else moves.The measurement, re-driven
Through the published entry
packages/cli/bin/run.js(not thebin/run-dev.jsshim),NO_COLOR=1, stdout and stderr captured to separate files, exit code read before any pipe.The message bytes are byte-identical; only the stream changed. (206 rather than the card's 286 because the message embeds the absolute path and this container's tmpdir is shorter — same text.)
Three corrections to the card's own reading, all measured
os buildisexport default class Build extends Compile: it declares neither thejsonflag nor the config import, inherits both, and reproduces the defect. The card drove it without counting it.resolveConfigPathhas exactly one in-tree caller —loadConfig(). It is exported, but nothing else calls it, so the blast radius isloadConfig's callers and nothing wider.Why the minimal route — measured, not preferred
The alternative was to make the helper throw so each command's existing catch-all
--jsonexit does the work. I measured that before choosing, and two findings rule it out on its own terms:os verifyhas notryaround itsloadConfig()at all (verify.ts, top ofrun()). A throw there does not become a payload; it becomes an oclif crash dump on a different exit path. The fuller route does not fix that face, it changes which way it is broken.errorCodeFields()deliberately mints nothing for a locally-thrown plainError— its own doc says so, and says ADR-0112's ledger is the authority on who may mint a code. So the other nine faces would emit a bare{ error }with nocodeand nohttpStatus— the exact shapeos lint --eval --json's generator-load failure emits a bare{error}— nocode, nohttpStatus— while the same command's project-lint catch-all emits the full ADR-0112 envelope #15549 is an open card about. Taking that route would answeros lint --eval --json's generator-load failure emits a bare{error}— nocode, nohttpStatus— while the same command's project-lint catch-all emits the full ADR-0112 envelope #15549 implicitly, in passing. That card is not addressed here and remains open.A third cost, worth recording: the catch-alls print
printError(error.message)and nothing else, so the fuller route would silently delete the two hint lines from the text face of all ten commands — the majority path.What is deliberately NOT settled
⛔ No error payload is invented. On this path
--jsonstill emits nothing on stdout; a consumer must read the exit status, exactly as it must today. What--jsonshould emit here touches ten published faces at once and is above this PR's authority — that is the half the triage comment said to leave for a ruling, and this is the half it said to land meanwhile:Clause ② declared from the delivered diff: NO. No key is added to any published payload (this path emits none, before or after); no accept/reject set changes — a config that loaded still loads, one that failed still fails, same exit code, same wording. The one new export,
printErrorToStderr, is package-internal:packages/cli'sexportsmap publishes only.(a barrel of command classes) and./console,utils/format.jsis neither, andsrc/index.tsdoes not re-export it.The instrument was as blind as the code
packages/cli/test/json-stdout-purity.e2e.test.tspins "stdout is exactly one JSON document", but discovers its family as the commands that callbootSchemaStack. These commands fail before any kernel boots, so that pin structurally could not see this path and stayed green through the whole defect. Repairing the helper without widening the discovery would leave the next pre-boot stdout leak just as invisible.packages/cli/test/config-miss-stdout-purity.e2e.test.tsis the new pin. It discovers the pre-boot family in two halves, because the reach has two shapes — the module that declaresjson: Flags.boolean(and importsutils/config.js, and the module whose default exportextendsone that does. A one-half discovery missesbuild, which is why there is a second reconciliation case assertingbuildis in the set.⛔ The pin does not assert a payload shape. It asserts stdout carries nothing a machine cannot read — empty passes, one JSON document passes, prose fails — so it holds under the shape shipped today and under any future envelope, and whoever settles the envelope question changes the payload without touching this file. It also asserts the refusal still reaches the operator on stderr (a "just silence it" regression goes red) and that the exit status is still 1.
Ablation — direction predicted in writing first, and it was mixed
No rebuild is part of this ablation, and that is proven rather than assumed:
packages/cli/dist/utils/config.jswas built before the fix and still carried the defect verbatim, yet the new pin passed 78/78 — so the subject resolves throughsrc/via tsx, not throughdist/.Predicted, before the mutation was made: 19 × "leaves nothing on stdout that a machine cannot read" red, 19 × "keeps the human refusal off stdout entirely" red, 19 × "still shows the operator the refusal — on stderr" red (stderr goes empty under the defect), while 19 × "still exits 1" and the 2 discovery cases stay green — the exit code is untouched by the defect, which is precisely why the card was graded p2. ⇒ 21 passed, 57 failed.
Measured:
Tests 57 failed | 21 passed (78), and the three failing assertion names came back at 19 each. No assertion outside the predicted three failed even once.Mutation proven on disk before any result was read — removed-text counts
console.error(5 ⇒ 0 andprintErrorToStderr4 ⇒ 0, injected-marker count 0 ⇒ 1. Restore under anEXIT INT TERMtrap using an absolute path, proven by blob-hash equality with the HEAD blob (9a3f0e38b8e05a1569788150f2c65482f399f860, both sides) plus an emptygit diff HEADplus the marker's absence — an empty hash treated as failure, not as "nothing to compare".Verification — all at
e13e9862e19, the final commitnode scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstackderived 56 runnable families (45 by path + 6 by change kind + 7 declared whole-tree, 2 reached both ways). Harvested with--commands, so no section or spelling was dropped. All 56 green, each exit code captured after redirection, never through a pipe.exit 3first:check:dual-build-cjs-loadsandcheck:i18n-coverage, both printingPREREQUISITE NOT MET/ "Nothing was compared" on an unbuilt workspace. Read as NOT MEASURED, not as red — the workspace was built and both then returned 0.pnpm lint(eslint . --no-inline-config) — whole repo, not narrowed — green.pnpm --filter @objectstack/cli typecheckgreen, includingcheck:test-typecheck. Confirmed with--listFilesthat the new test file is actually in the program (the pre-existing 28 ledgered test-layer errors are in three other files; this file contributes zero).utils/format, or are a stdout-purity pin — all green (234 tests across 20 files, plus 116 across the three purity pins). The full 253-file CLI suite is CI's; this is the measured blast radius.process.exit(1): with a real pipe and a draining reader the 205 bytes arrived intact, 4/4 runs. Bounded claim — this message is ~206 bytes, far under one pipe buffer; the truncation defectformat.tsdocuments was ~138 KB.Generated by Claude Code