Skip to content

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

Merged
os-project-manager merged 6 commits into
mainfrom
claude/issue-15547-config-refusal-throws
Sep 9, 2026
Merged

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
os-project-manager merged 6 commits into
mainfrom
claude/issue-15547-config-refusal-throws

Conversation

@claude

@claude claude Bot commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

Fixes #15547

Implements the domain:cli execution 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 --json face 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 called process.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 throw ConfigRefusalError instead of exiting, so the ten existing catch-alls emit their envelopes. Plus the two implementation gaps the ruling named, in the same landing:

  1. os verify gains the catch-all it never had. It wrapped nothing, so a throw walked out of run() and oclif rendered it. Its body moves verbatim into a private runVerification() rather than being re-indented under a try, so the guard is the diff instead of 120 lines of whitespace.
  2. The hint lines survive in the text face, byte for byte. The thrown error carries hints, and the helper renders them to stderr on the way out — so a --json run 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 structural isReportedError().
  3. No error code is minted. ConfigRefusalError carries neither code nor httpStatus, so errorCodeFields() contributes nothing and each face emits its own bare { error }.

The { error } shape is #15549's question, and this PR does not answer it

The envelope these faces now emit has no code and no httpStatus. 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 (--eval mode) is likewise out of scope: triage warned explicitly against collapsing the three cards.

Measurements

Which head each reading was taken at. 2004e5a6c6 is not the final commit — one merge commit landed after it, and the head being merged is fb18504c749a3739212cc05f1ea56db09fe9c914. Sections 1 and 4 have been re-run at that head and report its numbers below. Sections 2, 3, 5 and 6 stay attributed to 2004e5a6c6, and what carries them across the merge is measured rather than asserted.

git diff 2004e5a6c6 fb18504c749a moves 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:

file blob, identical at both commits
packages/cli/src/utils/config.ts (the throw) 5b1c97865b
packages/cli/src/utils/format.ts (isReportedError) bdfd31948e
packages/cli/src/commands/verify.ts (the new catch-all) de2b7a09ef
packages/cli/test/config-miss-stdout-purity.e2e.test.ts 99a7f22b7a
packages/cli/test/helpers/config-miss-family.ts 16475983c6
packages/cli/test/json-stdout-purity.e2e.test.ts 7958e0a1c3

generate.ts is not one of the ten --json faces — it is one of the four non---json callers audited under 验收备注 — and its four isReportedError occurrences (one import, three catch-all guards) survived the merge intact: 4 at 2004e5a6c6, 4 at the head.

Driven through the published entry packages/cli/bin/run.js (not the bin/run-dev.js shim), 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 (build compile diff i18n check i18n extract info lint migrate meta validate verify) across both branches of the helper = 19 runs. os build is class Build extends Compile and inherits both the flag and the reach, which is why the population is ten and not the card's nine.

exit stdout stderr JSON.parse(stdout)
before, explicit path 1 0 B 296 B throws, 10/10
before, auto-detect 1 0 B 123 B throws, 9/9
after, explicit path 1 169-248 B 296 B parses, 10/10
after, auto-detect 1 73-152 B 123 B parses, 9/9

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:

exit stdout stderr JSON.parse(stdout)
head, explicit path 1 174-253 B 301 B parses, 10/10
head, auto-detect 1 73-152 B 123 B parses, 9/9

⚠️ The explicit-branch counts sit 5 B above the 2004e5a6c6 row, 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 carry code or httpStatus, so the ruling's no-minted-code fence is re-confirmed at the head being merged.

2 — os verify proved separately. Source: its run() had no try at all, await loadConfig(flags.app) bare at L92. Driven: a config module that throws at evaluation, through the published entry.

before after
os verify --json exit 1, stdout 0 B, stderr Error: exploding config module exit 1, stdout {"error":"exploding config module"}
os validate --json exit 1, stdout 116 B envelope unchanged
os info --json exit 1, stdout 36 B envelope unchanged

3 — The text face did not narrow. All 19 non---json runs compared before vs after with full-string equality, never toContain: 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.

⚠️ That comparison caught a real regression before it shipped: os compile's text branch ends in oclif's this.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 on compile and build, 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.ts now 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 contains build and verify.
  • json-stdout-purity.e2e.test.ts — whose own discovery is bootSchemaStack-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 under OS_TEST_TIERS=nightly and --project integration2 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 meta is in both — legitimately, since it boots a kernel under --stored and 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 to process.exit(1); in refuseConfig. 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), blob b7ffa1aa4d to c9d57d0056, 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 through src/ via tsx. Restore under a trap on EXIT INT TERM with absolute paths, via git 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, and git diff HEAD empty. 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:

⚠️ That reading is claim-time and has since moved: #16887 has merged, and the head fb18504c749a is the merge that brought it into this branch. Its generate.ts hunks and this PR's three catch-all guards in the same file were integrated there, which is the one file the head moves relative to 2004e5a6c6 (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), so origin/main was 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 fb18504c749a — the merge changed scripts/pm/dispatch-gates.mjs itself (+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). ⚠️ That run also warns STALE TREE against today's origin/main (44c917a47a, 2 commits ahead, scripts/check-dispatcher-error-vocabulary.mjs changed in the range). That is a fact about main moving 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-bytes exit 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-loads exit 3 and check:i18n-coverage exit 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 typecheck green, including check:test-typecheck (the 28 pre-existing ledgered test-layer errors are in three OTHER files; the count did not move). Verified with tsc --listFiles that all three new/edited test files are genuinely in the tsc program.

Tests: config-miss-stdout-purity.e2e.test.ts plus json-stdout-purity.e2e.test.ts, 212 passed. Both are nightly-tier integration files, so they need OS_TEST_TIERS=nightly and --project integration to be selected at all — running them without that reports "No test files found" and exits 1, which is not a pass. The rest of the packages/cli suite is CI's.

Docs drift — discharged

The docs-drift-check comment 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, with node scripts/docs-audit/affected-docs.mjs --json ce7bae8b4424cba3d7a4dea3ef24dcf11ba0a24c.

On the bot's own corpus, proved rather than assumed. The bot read content/docs from ef4b9856a579, the merge of this head into base ce7bae8b4424. That commit's content/docs tree is 5165b92107 and 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 older main. It reconciles on every published figure: 52 rows = 42 hand-written + 10 release-owned, 21 anchors, os validate dropped 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: zero symbol, zero route, zero sdk (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: defineStack 61 files, objectstack.config 33, loadConfig 4). 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:

  • a --json error 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 login NDJSON, os migrate apply's database_busy, os migrate duplicates). No page documents these ten faces' error payload.
  • what a missing config produces — one hit corpus-wide, content/docs/protocol/kernel/config-resolution.mdx, and it names objectstack.config.{ts,js,mjs} only to say it is not a settings layer. stderr appears in 0 hand-written pages; "no output" / "prints nothing" / "empty stdout" in 0. The refusal sentences themselves appear in no .md or .mdx outside packages/.
  • os verify on a bad configos verify is documented nowhere: 0 hits across content/docs, and it is absent from the command tables in content/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 failing os command. 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 to os 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.mdx L1535-1536 and L1620-1621 — "Every other ObjectStack command writes exactly one JSON document to stdout under --json, so JSON.parse of the entire stdout is the way to read it." On main that was false for ten faces on this path, which wrote zero documents. It is true at this head.
  • content/docs/deployment/validating-metadata.mdx L603 and content/docs/ui/translations.mdx L242 — "--json and 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.mdx L573 — "os validate --json / os build --json carry the computed advisory lists and conversions on every failure exit". Verified at the head: os validate --json on 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 json failure envelopes drop the ADR-0112 error code — 48 sites emit only error.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.mdx L557 — lists "the CLI's --json output contract" among the surfaces deliberately not swept into the error: { 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/cli publishes ., ./console and ./hook-body, and utils/config.js / utils/format.js are none of those, nor re-exported from src/index.ts.

验收备注

  • Observation, no card proposed. packages/cli/src/commands/serve.ts imports loadConfig at L9 and never calls it — a dead import, measured with grep. Untouched here.
  • Every other caller of the helper was audited for the throw, not just the ten --json faces: generate (3 sites, guarded so the refusal is not printed twice), doctor (guarded upstream by configExists(), so it never reaches the refusal), and schema-migration-plugins (only entered with a path findHostConfig already resolved, so unreachable by a refusal). All four non---json invocations were driven before and after: byte-identical on stdout, stderr and exit status.
  • Opened as a draft, not armed. Flipping it ready and arming auto-merge are the PM seat's acts.

Generated by Claude Code

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
@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation tests tooling labels Sep 9, 2026
@github-actions

github-actions Bot commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/cli, touching 21 documentable anchor(s).

42 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json ce7bae8b4424cba3d7a4dea3ef24dcf11ba0a24c.

10 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 1 anchor(s) matched too much of the corpus to be a work list: os validate (command, 49 pages)
  • 9 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 60 of 215 client-bound route-ledger rows — the other 155 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 155: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 100 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 — 23 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 ce7bae8b4424cba3d7a4dea3ef24dcf11ba0a24cpackageMentionDocs.

Which tree this was computed on

This run read content/docs from ef4b9856a579dc918fe2e464ae6a89b5fd43d782 — the merge of head fb18504c749a3739212cc05f1ea56db09fe9c914 into base ce7bae8b4424cba3d7a4dea3ef24dcf11ba0a24c, 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 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

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

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs ce7bae8b4424cba3d7a4dea3ef24dcf11ba0a24c → pass the list as
args.docs, on the commit named under Which tree this was computed on.

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/l tests tooling

Projects

None yet

2 participants