fix(cli): resolveConfigPath's refusals throw, so the ten --json faces emit their envelopes — and os verify gains the catch-all it never had - #17143
Conversation
…argv, not disjointness
…nfig-refusal-throws
Conflict: packages/cli/src/commands/generate.ts, import block only. #16887 (9cdffbe) added `numericColumnFor` to the `@objectstack/spec/data` import on L20; this branch added `isReportedError` to the `utils/format.js` import on L21. Adjacent lines, two different modules, no shared logic — both sides kept. Verified after resolving: the resolved file differs from origin/main's copy by exactly this branch's four edits and nothing else, and all three `isReportedError` guards still sit on the catch blocks that enclose a `loadConfig()` call (841->868, 983->1011, 2112->2149) after #16887's hunks shifted the line numbers. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015QE8qk46e5CHJxyQEUjbf8
📓 Docs Drift CheckThis PR changes 1 package(s): 42 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 10 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 23 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 ef4b9856a579dc918fe2e464ae6a89b5fd43d782 && git checkout ef4b9856a579dc918fe2e464ae6a89b5fd43d782
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin ce7bae8b4424cba3d7a4dea3ef24dcf11ba0a24c fb18504c749a3739212cc05f1ea56db09fe9c914 && git checkout -B drift-repro ce7bae8b4424cba3d7a4dea3ef24dcf11ba0a24c && git merge --no-ff fb18504c749a3739212cc05f1ea56db09fe9c914
node scripts/docs-audit/affected-docs.mjs --json ce7bae8b4424cba3d7a4dea3ef24dcf11ba0a24c
|
Fixes #15547
Implements the
domain:cliexecution PM seat's delegated ruling B' (comment 5600782550), inside the authority the maintainer granted on 2026-09-09: 「--json面本就承诺输出可解析;resolveConfigPath往 STDOUT 打人类可读文本再process.exit(1),令 9 个命令的--json面在配置文件缺失时吐出不可解析的字节。⇒ 这是拉回已声明契约,⛔ 不是扩大接受面。」What was wrong
Every
--jsonface in this CLI already declares that it answers an error path with a payload.resolveConfigPath()was the one path that bypassed that declaration: it wrote its refusal and then calledprocess.exit(1)directly, so nothing was thrown and the catch-all each command already carries — all of which sit downstream of a throw — never ran.#15692 moved the prose off stdout (that half landed,
ee79099fd38). What it left was an empty stdout on ten published faces, which is not a parseable document either.What this changes
resolveConfigPath's two refusals throwConfigRefusalErrorinstead of exiting, so the ten existing catch-alls emit their envelopes. Plus the two implementation gaps the ruling named, in the same landing:os verifygains the catch-all it never had. It wrapped nothing, so a throw walked out ofrun()and oclif rendered it. Its body moves verbatim into a privaterunVerification()rather than being re-indented under atry, so the guard is the diff instead of 120 lines of whitespace.hints, and the helper renders them to stderr on the way out — so a--jsonrun still shows its operator the refusal while the machine reads the envelope, and the text face is unchanged. The ten catch-alls skip re-rendering the sentence a second time on stdout, gated by a structuralisReportedError().ConfigRefusalErrorcarries neithercodenorhttpStatus, soerrorCodeFields()contributes nothing and each face emits its own bare{ error }.The
{ error }shape is #15549's question, and this PR does not answer itThe envelope these faces now emit has no
codeand nohttpStatus. Whether that is the right shape is #15549's open card. ⛔ This PR deliberately does not settle it, and the new pin asserts that a document arrives and that it names the refusal — never what else is in it — so whoever settles #15549 changes the payload without touching that file. #15549 is not addressed here and remains open. #14974 (--evalmode) is likewise out of scope: triage warned explicitly against collapsing the three cards.Measurements
Which head each reading was taken at.
2004e5a6c6is not the final commit — one merge commit landed after it, and the head being merged isfb18504c749a3739212cc05f1ea56db09fe9c914. Sections 1 and 4 have been re-run at that head and report its numbers below. Sections 2, 3, 5 and 6 stay attributed to2004e5a6c6, and what carries them across the merge is measured rather than asserted.git diff 2004e5a6c6 fb18504c749amoves exactly one of this PR's sixteen files:packages/cli/src/commands/generate.ts(+98 / -20), where the merge integrated #16887's generator bodies. The other fifteen are blob-identical at the two commits — every file carrying the mechanism among them:packages/cli/src/utils/config.ts(the throw)5b1c97865bpackages/cli/src/utils/format.ts(isReportedError)bdfd31948epackages/cli/src/commands/verify.ts(the new catch-all)de2b7a09efpackages/cli/test/config-miss-stdout-purity.e2e.test.ts99a7f22b7apackages/cli/test/helpers/config-miss-family.ts16475983c6packages/cli/test/json-stdout-purity.e2e.test.ts7958e0a1c3generate.tsis not one of the ten--jsonfaces — it is one of the four non---jsoncallers audited under 验收备注 — and its fourisReportedErroroccurrences (one import, three catch-all guards) survived the merge intact: 4 at2004e5a6c6, 4 at the head.Driven through the published entry
packages/cli/bin/run.js(not thebin/run-dev.jsshim),NO_COLOR=1, stdout and stderr to separate files, exit code read before any pipe.1 — Reproduction, re-run on today's tree. Ten faces (
buildcompilediffi18n checki18n extractinfolintmigrate metavalidateverify) across both branches of the helper = 19 runs.os buildisclass Build extends Compileand inherits both the flag and the reach, which is why the population is ten and not the card's nine.0/19 parsed before, 19/19 parse after; and no terminal escape byte appears in any envelope (0 of 19 files).
Re-run at the head
fb18504c749a, same method and same published entry — 19/19 parse, exit 1 on all 19:2004e5a6c6row, and that is not a behaviour change: that branch's stderr is a 163 B path-independent part plus the absolute path, so the number moves with the working directory the runs were driven from. Measured here 163 + 138 = 301; the earlier 296 implies a 133-byte path. The branch that carries no path — auto-detect — is 123 B at both commits, and that is the row that would have moved had the text face narrowed. Across all 19 head captures, 0 envelopes carrycodeorhttpStatus, so the ruling's no-minted-code fence is re-confirmed at the head being merged.2 —
os verifyproved separately. Source: itsrun()had notryat all,await loadConfig(flags.app)bare at L92. Driven: a config module that throws at evaluation, through the published entry.os verify --jsonError: exploding config module{"error":"exploding config module"}os validate --jsonos info --json3 — The text face did not narrow. All 19 non-
--jsonruns compared before vs after with full-string equality, nevertoContain: stderr 19/19 byte-identical, stdout 19/19 byte-identical, exit status 19/19 identical. Both hint lines are still there in the explicit branch, and the one hint line in the auto-detect branch.os compile's text branch ends in oclif'sthis.error(), which re-rendered the sentence as an oclif error block and raised the exit status from 1 to 2 — measured at 483 stderr bytes and exit 2 oncompileandbuild, against 296 and exit 1 everywhere else. Fixed by exiting 1 for an already-reported refusal, and pinned.4 — The purity pin's population was widened, and the widening is proved. The pre-boot discovery moves into
test/helpers/config-miss-family.ts, shared by both pins:config-miss-stdout-purity.e2e.test.tsnow demands one JSON document (its previous form accepted "empty OR JSON", which passed straight through this card's defect), compares the text face's stderr as a whole string, and asserts the population is exactly ten and containsbuildandverify.json-stdout-purity.e2e.test.ts— whose own discovery isbootSchemaStack-based and structurally cannot see a command that fails above the kernel — now reconciles against that pre-boot population, so neither half can be lost silently.Instrument-blindness measured rather than argued, against the 19 real captured stdouts from the defective runs: the old assertion passes 19/19 (blind); the new one fails 19/19.
Re-run at the head
fb18504c749a: both pin files underOS_TEST_TIERS=nightlyand--project integration— 2 files passed, 212 tests passed (212).⭐ One assumption of mine was falsified by the run and corrected in the same landing: I asserted the two families were disjoint, and
os migrate metais in both — legitimately, since it boots a kernel under--storedand refuses at the helper under--from N. The assertion now pins the shared member and requires the two pins' argv to differ, which is the property that actually matters.5 — Ablation, direction predicted in writing first. Prediction, written before the mutation was applied: MIXED, not all-red — the exit code and the stderr bytes are untouched by the defect, so every assertion about them stays green; 38 failed / 136 passed of 174.
Measured: 38 failed / 136 passed (174), and the only two failing assertion names are the two predicted, at exactly 19 each. No assertion outside the prediction moved.
Mutation:
throw error;back toprocess.exit(1);inrefuseConfig. Confirmed on disk before any result was read —throw error;1 to 0,process.exit(1)1 to 2 (one of those is prose in a doc comment), blobb7ffa1aa4dtoc9d57d0056, off the HEAD blob.dist/was deliberately not rebuilt and still carried the fix while the pin went red, which is what proves the pin resolves throughsrc/via tsx. Restore under atrapon EXIT INT TERM with absolute paths, viagit checkout HEAD -- ABSPATH(never a bare double-dash, which restores the mutation back out of the index and exits 0): blob equal to the HEAD blob, andgit diff HEADempty. An empty hash was coded as FAILURE, never as "nothing to compare".6 — Single-writer holders, re-measured at claim time from the OPEN PR LIST (never from remote branches: this repo carries hundreds of merged-but-undeleted
claude/*refs that read as phantom holders). 19 open PRs, each head diffed against its own merge-base:packages/cli/src/utils/config.ts— no holderpackages/cli/src/commands/verify.ts— no holderpackages/cli/src/commands/compile.tsis held by fix(cli, lint): gate a dangling lookup/master_detail reference, and resolve one across the artifact packages[] #17066. Its hunks are at L95-135 (a doc comment plus a helper); this PR's edit is in the catch-all at L913-930. Different regions, no conflict expected, but it is a real overlap and it is named rather than assumed away.packages/cli/: fix(types,metadata,cli): read the dialect text out ofcausefor operator-facing records #17073 (commands/db/clean.ts), feat(spec,driver-sql,cli): one physical representation for the NUMERIC column family, read by all three producers #16887 (commands/generate.ts— this PR editsgenerate.tstoo, in its three config-path catch-alls; feat(spec,driver-sql,cli): one physical representation for the NUMERIC column family, read by all three producers #16887's hunks are in the generator bodies), chore: version packages #17076 (CHANGELOG.md,package.json).fb18504c749ais the merge that brought it into this branch. Itsgenerate.tshunks and this PR's three catch-all guards in the same file were integrated there, which is the one file the head moves relative to2004e5a6c6(see the Measurements preamble). No other holder in the list has landed.Gates
Derived with
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack, never a hand-written list. The first derivation warned STALE TREE (5 commits behind, 3 deriving files changed), soorigin/mainwas merged in and the family re-derived on a current tree.61 derived, 61 run, 0 NOT-MEASURED, 0 UNRUN — reconciled with
--ran. Every exit code captured by redirect-then-dollar-question, never through a pipe.Re-derived at the head⚠️ That run also warns STALE TREE against today's
fb18504c749a— the merge changedscripts/pm/dispatch-gates.mjsitself (+558 lines), so the family was derived again rather than carried over: 61 commands, the same family, over the same 16-path change set (committed 16, working tree 0, untracked 0).origin/main(44c917a47a, 2 commits ahead,scripts/check-dispatcher-error-vocabulary.mjschanged in the range). That is a fact aboutmainmoving after this head, not about this head; closing it needs a merge, which is the PM seat's call, not a silent rebase here.Three did not answer 0 on the first pass, and none of them is counted as a pass:
check:nul-bytesexit 1 — a genuine finding, and mine: I had written a literal ESC byte in two places while writing about the escape sequence, which is the exact slip that gate's header describes. Rewritten as the escape TEXT (backslash-u-001b) in the pin and as prose in the doc comment; re-run green over 8059 files.check:dual-build-cjs-loadsexit 3 andcheck:i18n-coverageexit 3 — both printed PREREQUISITE NOT MET over an unbuilt workspace. Read as NOT MEASURED, not as red. The workspace was built (73/73 tasks) and both then returned 0.pnpm lint(eslint . --no-inline-config) was run over the whole repo, not narrowed: 6428 files, 0 errors, 0 warnings, exit 0.pnpm --filter @objectstack/cli typecheckgreen, includingcheck:test-typecheck(the 28 pre-existing ledgered test-layer errors are in three OTHER files; the count did not move). Verified withtsc --listFilesthat all three new/edited test files are genuinely in the tsc program.Tests:
config-miss-stdout-purity.e2e.test.tsplusjson-stdout-purity.e2e.test.ts, 212 passed. Both are nightly-tier integration files, so they needOS_TEST_TIERS=nightlyand--project integrationto be selected at all — running them without that reports "No test files found" and exits 1, which is not a pass. The rest of thepackages/clisuite is CI's.Docs drift — discharged
The
docs-drift-checkcomment named 42 hand-written pages from 21 anchors, plus 10 release-owned pages, and omitted the list (above its 15-row display cap). Re-derived rather than guessed, withnode scripts/docs-audit/affected-docs.mjs --json ce7bae8b4424cba3d7a4dea3ef24dcf11ba0a24c.On the bot's own corpus, proved rather than assumed. The bot read
content/docsfromef4b9856a579, the merge of this head into basece7bae8b4424. That commit'scontent/docstree is5165b92107and this head's is the same object; the two full trees are also the same object,6c44525eb3. So this is the bot's corpus, not a lookalike cut from an oldermain. It reconciles on every published figure: 52 rows = 42 hand-written + 10 release-owned, 21 anchors,os validatedropped as over-broad at 49 pages, SDK bridge 60 of 215, 23 package-mention fallback rows. The bot's "uncommitted changes" caveat therefore cost nothing measurable here.What put the pages there — command anchors, every one. 62 anchor occurrences across the 42 hand-written rows, all of kind
command: zerosymbol, zeroroute, zerosdk(same for the 10 release-owned rows). None of the twelve symbol anchors this diff raises —ConfigRefusalError,resolveConfigPath,isReportedError,refuseConfig,reportedToStderr,runVerification,I18nCheck,I18nExtract,MigrateMeta,runClientGeneration,runMigrationGeneration,runTypesGeneration— appears in a single hand-written page (0 files each; positive control on the identical grep:defineStack61 files,objectstack.config33,loadConfig4). Every row therefore reads "this page names one of the nine command ids whose file the diff touched", and this diff changes no command's flags, gates, success output or semantics — only the error path taken when no config file resolves.Judged against the three claim classes actually at risk, over the whole hand-written corpus rather than only the listed rows:
--jsonerror payload for one of the ten faces — 14 pages carry--json; all were read. Every documented payload is a success payload or belongs to a command outside the ten (os login/os cloud loginNDJSON,os migrate apply'sdatabase_busy,os migrate duplicates). No page documents these ten faces' error payload.content/docs/protocol/kernel/config-resolution.mdx, and it namesobjectstack.config.{ts,js,mjs}only to say it is not a settings layer.stderrappears in 0 hand-written pages; "no output" / "prints nothing" / "empty stdout" in 0. The refusal sentences themselves appear in no.mdor.mdxoutsidepackages/.os verifyon a bad config —os verifyis documented nowhere: 0 hits acrosscontent/docs, and it is absent from the command tables incontent/docs/deployment/cli.mdx. Population empty, so nothing to correct. Noted for the PM seat rather than fixed here: adding a reference section for an undocumented command is a docs change this PR has no mandate for.⭐ The input-vs-emitter blind spot, re-read by hand. The bot cannot list a page that states the rule by its inputs, and this diff is entirely emitter-side, so the emitter-named list was set aside and the corpus searched for the statement instead: config-not-found phrasing,
stderr, empty/absent stdout, exit-code claims, CI and parse recipes over the ten faces, and console transcripts of a failingoscommand. The page that class predicts does exist —content/docs/deployment/cli.mdx§ Config File Auto-Detection (the search order.ts,.js,.mjs, stated purely by inputs, naming none of this diff's symbols, and duly unlisted). It states the search order and stops there: it makes no claim about what happens when nothing resolves, so there is nothing for this change to falsify. The one transcript of a failing command in the corpus belongs toos login.Result: no hand-written page needs an edit. Two claims are repaired by this change rather than broken by it, and they are the ones worth spot-checking:
content/docs/deployment/cli.mdxL1535-1536 and L1620-1621 — "Every other ObjectStack command writes exactly one JSON document to stdout under--json, soJSON.parseof the entire stdout is the way to read it." Onmainthat was false for ten faces on this path, which wrote zero documents. It is true at this head.content/docs/deployment/validating-metadata.mdxL603 andcontent/docs/ui/translations.mdxL242 — "--jsonand exit non-zero on failure" / "exits non-zero on violations". True before and after; the exit code was never the defect.⛔ The 10 release-owned pages were read, not edited (
content/docs/releases/is written centrally at release time). None is wrong. The two that come closest, with the reading that makes each true:content/docs/releases/v17/17-3.mdxL573 — "os validate --json/os build --jsoncarry the computed advisory lists andconversionson every failure exit". Verified at the head:os validate --jsonon a missing config emits{"valid":false,"error":…,"warnings":[],"conversions":[],"duration":…}. The clause holds, and this change extends it to a failure exit that previously emitted no document at all. Its first half, the ADR-0112 carriers from CLI--format jsonfailure envelopes drop the ADR-0112 error code — 48 sites emit onlyerror.message, so a script has to substring-match English #13347, is scoped to errors that carry them —errorCodeFields()adds "only the ones the thrown error actually carries", and this refusal carries neither, by ruling.content/docs/releases/v17/17-0.mdxL557 — lists "the CLI's--jsonoutput contract" among the surfaces deliberately not swept into theerror: { code, message }shape. Consistent with the bare{ error }this PR emits; it is evidence for the fence, not against it.Clause-②
Clause-②: no — re-derived from the delivered diff, not carried over from the claim. No
packages/spec/src/**, no*.zod.ts, no error-code ledger, no ADR. No accept-set member and no error code is added: this path emitted no payload before, and the envelope it emits now is the one each command had already declared for every other throw. The two new exports (ConfigRefusalError,isReportedError) are package-internal —packages/clipublishes.,./consoleand./hook-body, andutils/config.js/utils/format.jsare none of those, nor re-exported fromsrc/index.ts.验收备注
packages/cli/src/commands/serve.tsimportsloadConfigat L9 and never calls it — a dead import, measured withgrep. Untouched here.--jsonfaces:generate(3 sites, guarded so the refusal is not printed twice),doctor(guarded upstream byconfigExists(), so it never reaches the refusal), andschema-migration-plugins(only entered with a pathfindHostConfigalready resolved, so unreachable by a refusal). All four non---jsoninvocations were driven before and after: byte-identical on stdout, stderr and exit status.Generated by Claude Code