Skip to content

fix(cli): route resolveConfigPath's refusals to stderr, and pin the pre-boot --json face - #15692

Merged
os-litant merged 2 commits into
mainfrom
claude/issue-15547-config-path-stderr
Sep 5, 2026
Merged

os-litant merged 2 commits into
mainfrom
claude/issue-15547-config-path-stderr

Conversation

@os-litant

Copy link
Copy Markdown
Contributor

Part of #15547

resolveConfigPath() printed both of its refusals through printError and console.log — both of which write to stdout — and then called process.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 the bin/run-dev.js shim), NO_COLOR=1, stdout and stderr captured to separate files, exit code read before any pipe.

before after
explicit-path miss, all 10 faces exit 1 · stdout 206 B · stderr 0 B exit 1 · stdout 0 B · stderr 206 B
auto-detect miss, all 9 faces exit 1 · stdout 123 B · stderr 0 B exit 1 · stdout 0 B · stderr 123 B

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

  1. The population is 10 faces, not 9. The card's nine modules are exactly right as a list of modules — I re-derived it independently and got the same nine. But os build is export default class Build extends Compile: it declares neither the json flag nor the config import, inherits both, and reproduces the defect. The card drove it without counting it.
  2. Both branches reproduce on all of them. The card names the auto-detect site but drives only the explicit-path one. Both are in the table above.
  3. resolveConfigPath has exactly one in-tree caller — loadConfig(). It is exported, but nothing else calls it, so the blast radius is loadConfig'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 --json exit does the work. I measured that before choosing, and two findings rule it out on its own terms:

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 --json still emits nothing on stdout; a consumer must read the exit status, exactly as it must today. What --json should 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:

Whichever route: the human text must move to stderr regardless. […] If the ruling stalls, land that half.

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's exports map publishes only . (a barrel of command classes) and ./console, utils/format.js is neither, and src/index.ts does not re-export it.

⚠️ One residual behaviour change for a reviewer to weigh: a wrapper that showed a user only the child's stdout now shows nothing on this path. Exit 1 is unchanged, and stderr was empty before, so nothing could have been parsing it.

The instrument was as blind as the code

packages/cli/test/json-stdout-purity.e2e.test.ts pins "stdout is exactly one JSON document", but discovers its family as the commands that call bootSchemaStack. 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.ts is the new pin. It discovers the pre-boot family in two halves, because the reach has two shapes — the module that declares json: Flags.boolean( and imports utils/config.js, and the module whose default export extends one that does. A one-half discovery misses build, which is why there is a second reconciliation case asserting build is 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.js was built before the fix and still carried the defect verbatim, yet the new pin passed 78/78 — so the subject resolves through src/ via tsx, not through dist/.

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 and printErrorToStderr 4 ⇒ 0, injected-marker count 0 ⇒ 1. Restore under an EXIT INT TERM trap using an absolute path, proven by blob-hash equality with the HEAD blob (9a3f0e38b8e05a1569788150f2c65482f399f860, both sides) plus an empty git diff HEAD plus the marker's absence — an empty hash treated as failure, not as "nothing to compare".

Verification — all at e13e9862e19, the final commit

  • node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack derived 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.
    • Two came back exit 3 first: check:dual-build-cjs-loads and check:i18n-coverage, both printing PREREQUISITE 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 typecheck green, including check:test-typecheck. Confirmed with --listFiles that 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).
  • CLI tests, narrowed and declared: the 23 files that name the changed behaviour, import 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.
  • Pipe check, because the diagnostic is now a stderr write followed immediately by 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 defect format.ts documents was ~138 KB.

Generated by Claude Code

…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
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling Fix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface labels Sep 5, 2026
@github-actions

github-actions Bot commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

4 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
  • the SDK route bridge reached 61 of 219 client-bound route-ledger rows — the other 158 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 158: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 22 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 95d5cbb31687558ac63dfdde90950d06f82820eb → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 0ee19a2bf8cb13fac2ed9aa09de34755c5d1ff8f — the merge of head e13e9862e19f5818544cc322bae1c38cca6d08e1 into base 95d5cbb31687558ac63dfdde90950d06f82820eb, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Copy link
Copy Markdown
Contributor Author

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 measurement

My brief floated making resolveConfigPath throw so the existing per-command --json catch-alls would emit their envelopes, and called it "the route I would look at first". I also wrote ⛔ do not pick it just because I mentioned it first. You didn't, and the reasons are decisive:

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 ruling

A 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 — errorCodeFields()'s own doc says the ADR-0112 ledger is the authority on who may mint a code — so it wants the ruling before the code. And your framing is right that this must go up as one ruling covering this card and #15549 together: two faces of one envelope question, answered separately, is how a vocabulary fragments.

Three measured corrections to the card — all mine to carry

  1. ⭐ The population is TEN faces, not nine. os build is class Build extends Compile: it declares neither the json flag nor the config import, inherits both, and reproduces. A card that enumerates by declaration misses inheritance — the card did, and so would I have.
  2. Both branches reproduce on all of them. The card named the auto-detect site but drove only the explicit-path one; you drove 19 runs across both.
  3. resolveConfigPath has exactly one in-tree caller, loadConfig().

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 still exits 1 and the two discovery cases stay green, while the other three assertions go red at 19 each. Measured: Tests 57 failed | 21 passed (78), the three failing assertion names at exactly 19 each, no assertion outside the predicted three failing once.

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: dist/utils/config.js was built before the fix, still carried the defect verbatim, and the pin passed 78/78 ⇒ the subject resolves through src/ via tsx. That is exactly where a CLI-package ablation goes wrong.

Clause ② — NO, accepted

No key added to any published payload (this path emits none, before or after), no accept/reject change, and the one new export printErrorToStderr is package-internal: packages/cli publishes only . and ./console, utils/format.js is neither, and src/index.ts does not re-export it. ⛔ No label pre-attached. Correct.

⭐ And the A2.1 re-verification at blob level rather than by re-reading the PR population is better than what I asked for: config.ts and json-stdout-purity.e2e.test.ts are the identical blob at my ref b4b37e599d9 and at your base 791a0cbe6e3, so my free-surface measurement transfers exactly instead of approximately.

"Part of", not "Fixes" — correct, and I am holding you to it

Merging this must not close #15547. The envelope question stays open for the ruling.

Your handed-back finding is filed: #15697

You could not run the mandatory dedup check in either channel (REST 403; MCP search_issues rate-limited) and handed it back rather than filing blind. ⛔ Filing without a dedup check is worse than not filing — that is right, and I want it on the record as the correct call rather than a shortfall.

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. ⚠️ The live search channel is rate-limited for me too, so the snapshot's age is declared on the card rather than papered over.

Your 196-call-site printError observation is recorded there too, with your own bound intact: you did not sweep all 196 and claim nothing beyond what you read.


Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling Fix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants