Skip to content

fix(cli): os verify --json writes exactly one JSON document to stdout; boot diagnostics move to stderr - #21381

Merged
objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-21324-verify-json-stdout
Oct 2, 2026
Merged

objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-21324-verify-json-stdout

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #21324
Clause-②: no

os verify --json > report.json now leaves a file JSON.parse reads whole. Under --json the command takes the CLI's existing stdout reservation (reserveStdoutForJson, the route bootSchemaStack took for the same defect in commit 2b641dd) before it loads the config. Every write to process.stdout, whoever makes it, is forwarded to stderr, and the report leaves through emitJson on the real stdout. Nothing is silenced, and the text face is unchanged.

Measured first: where the stdout lines came from

Measured at f39760864c (the claim's base) on a two-object defineStack stack that passes the author-time rules and reaches the runtime stage. The command was objectstack verify --json > report.json, run through this checkout's source entry (bin/run-dev.js):

before (f39760864c) after (0c2c904fea)
verify exit 0 0
JSON.parse(report.json) exit 1, Unexpected non-whitespace character after JSON at position 4 0
stdout lines 349; the document starts at line 319 31; the document only
stderr lines 3 321; the same 3 plus the 318 that moved

A stdout-write probe attributed the 318 lines ahead of the document. The probe is a --import preload in the scratchpad that tags each write by its writer, and it touches no repo file.

writer lines passes through the kernel logger?
the kernel's ObjectLogger (169 INFO, 5 WARN) 174 yes
the ObjectQL registry's console.log ([Registry] ...) 143 no
HonoServerPlugin's console.log on stop 1 no

After the fix, the same probe sees exactly one write on the real stdout: the document, from writeStdoutDirect. A sorted diff of the after-run's stderr against the before-run's stdout lines differs only in timestamps, ids, ports and durations. So nothing was dropped. With --rls (two boots; measured at 866de1d7c8, whose verify.ts is byte-identical) the after-run gives exit 0, a parseable stdout of 80 lines (the document) and 653 lines on stderr.

The route, and why not the one the claim expected

The claim expected a new optional kernel-logger member on @objectstack/verify's BootOptions, which the CLI would pass under --json. The measurement rules that out as the fix:

  • A kernel logger setting reaches 174 of the 318 lines. The registry's and Hono's console.log calls (144 lines) never go through it, so stdout would still not parse.
  • silent would also throw away the 5 WARN records: the missing job service, the analytics read-grant notice, OAuth served unencrypted on loopback, and the port fallback.
  • Sending info/warn to stderr from inside the logger would need a change in packages/core (stop clause 1). It is not needed either: the CLI already owns a seam that moves every stdout write to stderr for a --json run (packages/cli/src/utils/json-stdout.ts). That module's header records why it rejected the silent route, for the same two reasons.

The reservation redirects each write as it happens. Nothing is captured and filtered afterwards, so stop clause 2 does not apply. @objectstack/verify is untouched and its published BootOptions does not move. That is why the declaration line reads no: this PR adds no new key to a published payload and widens no accept set.

What changed

  • packages/cli/src/commands/verify.ts

    • run(): under --json, calls reserveStdoutForJson() before loadConfig. The reservation is never released, because the run is one-shot and its last act is the payload and the exit. That way a booted stack's late timers cannot land under the document.
    • The runtime report now goes out through emitJson(...) instead of this.log(JSON.stringify(..., null, 2)). The bytes are the same (pretty, with a trailing newline), but they now reach the real stdout and are drained before this.exit. The old this.log followed by an exit could truncate a report larger than one pipe buffer. The other two --json documents (the stage-1 refusal and the could-not-run envelope) already used emitJson.
  • packages/cli/test/verify-json-stdout.test.ts (new; integration tier, because it spawns the CLI; not an .e2e name, so it runs on every PR) spawns verify --json and verify on a clean stack that reaches the runtime stage, and pins four things:

    • stdout is byte-equal to the re-serialized document, and that document is the runtime report (crud, hardFailures: 0);
    • stdout carries no kernel-logger record and no registry line;
    • stderr carries the kernel logger's records, WARN among them, and the registry's console.log lines (moved, never destroyed);
    • the text face still writes the kernel logger's records and the registry lines to stdout, next to verify passed.

    The child is spawned with OS_REGISTRY_LOG: 'info', the shipped default, written out at the call site. This package's vitest config sets warn for its own workers, and at warn the registry's writer stays quiet.

  • content/docs/deployment/cli.mdx, the #### os verify entry only:

  • .changeset/21324-verify-json-stdout.md: @objectstack/cli at patch.

Tests

All runs are at f9ee0ac5e8 (after origin/main was merged in) unless a row says otherwise.

run result
pnpm --filter @objectstack/cli exec vitest run test/verify-json-stdout.test.ts test/verify-author-time-stage.test.ts test/published-subpath-console.pin.test.ts test/published-subpath-hook-body.pin.test.ts 4 files, 36 tests passed
pnpm --filter @objectstack/cli exec vitest run --project unit 243 of 245 files passed. The other 2 (published-subpath-*) refused with "packages/cli is not built". After pnpm --filter @objectstack/cli build, both pass (row above).
pnpm --filter @objectstack/cli typecheck exit 0 (tsc --noEmit, and check:test-typecheck OK)
stage-1 refusal stays one document (verify-author-time-stage.test.ts, the existing pin) green, in the first row
OS_TEST_TIERS=nightly test/config-miss-stdout-purity.e2e.test.ts (the verify face is a member, and the reservation is taken before loadConfig) 174 of 174 passed, including both verify branches on both faces
OS_TEST_TIERS=nightly test/json-stdout-purity.e2e.test.ts 1 failed, 40 passed, the same case that fails on the base (see Acceptance notes)

The integration layer is the spawn tier. The two spawn files this diff touches or depends on ran locally (rows above). The rest of the integration tier is declared to CI.

Ablations (fix committed first; each leg goes through scripts/ablation-replace.mjs, which restores and proves blob == HEAD with an empty git diff HEAD)

packages/cli/dist was absent during these legs, so the spawned CLI ran src/ through tsx, and the mutation reached the run with no rebuild.

leg mutation result
A delete if (flags.json) reserveStdoutForJson(); 3 failed, 1 passed (the text face stays green, as predicted)
B report back through this.log(JSON.stringify(..., null, 2)) 1 failed (Unexpected end of JSON input: the report went to stderr), 3 passed
C both, which is the base behaviour 3 failed with the card's own signature, Unexpected non-whitespace character after JSON at position 4; 1 passed
D reserve on the text face too (if (flags.json || !flags.json)) 1 failed (the text-face pin), 3 passed

Gates

node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands (no paths) derived 94 commands at f9ee0ac5e8. All 94 were run with their exit codes recorded, and --ran reports 94 run, 0 NOT-MEASURED, 0 UNRUN. Five of them first refused with exit 3 (PREREQUISITE NOT MET: unbuilt CLI and client-react). They were re-run after turbo run build for exactly the closure each gate named, and all five exited 0. Those five are check:skill-examples, check:i18n, check:i18n-coverage, check:i18n-walk-parity and check:dual-build-cjs-loads.

pnpm lint (repo-wide) is CI's. The narrowed run below counts as a measurement because all three of these hold:

  1. The population comes from eslint's own config. Of the 4 touched paths, the config matches the 2 .ts files and answers "File ignored because no matching configuration was supplied" for the .mdx and the .md.
  2. --format json returned 4 results: verify.ts and verify-json-stdout.test.ts with 0 errors and 0 warnings, at f9ee0ac5e8.
  3. The diff cannot change any untouched file's verdict, because eslint.config.mjs enables no type-aware linting (no parserOptions.project and no typed rules, as its own header states).

Acceptance notes

  • nightly-tiers: red on main #21347 remains open; this PR does not carry its nightly red. The red was reproduced at f39760864c with OS_TEST_TIERS=nightly on packages/cli/test/json-stdout-purity.e2e.test.ts: 1 failed, 40 passed. The failing case is the family this contract has to hold across > is exactly the set listed here — a new member goes red until it is driven too, with expected [ 'meta resync', …(13) ] to deeply equal [ 'meta resync', …(12) ]. The source-discovered family now includes migrate audit-metadata-bodies (it calls bootSchemaStack( and declares --json), and the file's FAMILY map does not list it. os verify is not a member of that family at all, because it boots through @objectstack/verify's bootStack, not bootSchemaStack. So no case there is verify --json. All 13 driven members pass their three cases. Per the dispatch, that test file is not touched here.
  • That purity family is discovered from bootSchemaStack(, so it cannot see os verify. The new pin is what holds verify --json to one document. A grep over packages/cli/src/commands for a --json flag next to a kernel boot finds 15 commands: 14 reserve through bootSchemaStack({ jsonOutput }), and the 15th is verify, which now reserves too.
  • The ObjectQL registry prints its [Registry] ... housekeeping through console.log, at its own OS_REGISTRY_LOG level, outside the kernel logger. That is why a logger-level route could not have fixed this. This is an observation, not a defect, and nothing is filed.

Generated by Claude Code

claude added 4 commits October 2, 2026 08:49
…ostics move to stderr

Under --json the run now takes the CLI's stdout reservation before loading the
config, so every write to process.stdout (the kernel logger's INFO/WARN records,
the ObjectQL registry's console.log lines, HonoServerPlugin's stop line) is
forwarded to stderr, and the report leaves through emitJson on the real stdout.
The text face takes no reservation and is unchanged.

Claude-Session: https://claude.ai/code/session_01VvcEokUG1tvVxkceYfR5XB
Co-authored-by: Claude <noreply@anthropic.com>
…riter at its shipped level

Claude-Session: https://claude.ai/code/session_01VvcEokUG1tvVxkceYfR5XB
Co-authored-by: Claude <noreply@anthropic.com>
…the logs go; the stage-1 boundary names the defineStack provenance refusal

Claude-Session: https://claude.ai/code/session_01VvcEokUG1tvVxkceYfR5XB
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added the size/m label Oct 2, 2026
@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Oct 2, 2026
@github-actions

github-actions Bot commented Oct 2, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/deployment/cli.mdx (via os verify (command, read off packages/cli/src/commands/verify.ts))

⛔ 2 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v17/17-4.mdx (via os verify (command, read off packages/cli/src/commands/verify.ts))
  • content/docs/releases/v17/17-6.mdx (via os verify (command, read off packages/cli/src/commands/verify.ts))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 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; 97 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.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 26 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 43e928dd4c0be18b5a10430239371332f2ee47f4 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from bcaae368a0be9f22df7497ebadd61506395ed3da — the merge of head f9ee0ac5e8182cecba6ae2b083867e07b9e803fa into base 43e928dd4c0be18b5a10430239371332f2ee47f4, 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 bcaae368a0be9f22df7497ebadd61506395ed3da && git checkout bcaae368a0be9f22df7497ebadd61506395ed3da
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 43e928dd4c0be18b5a10430239371332f2ee47f4 f9ee0ac5e8182cecba6ae2b083867e07b9e803fa && git checkout -B drift-repro 43e928dd4c0be18b5a10430239371332f2ee47f4 && git merge --no-ff f9ee0ac5e8182cecba6ae2b083867e07b9e803fa

node scripts/docs-audit/affected-docs.mjs --json 43e928dd4c0be18b5a10430239371332f2ee47f4

⚠️ 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 43e928dd4c0be18b5a10430239371332f2ee47f4 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 2, 2026 09:56
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 2, 2026 09:56
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 2, 2026
Merged via the queue into main with commit 5155093 Oct 2, 2026
37 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-21324-verify-json-stdout branch October 2, 2026 10:14
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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

objectstack verify --json writes hundreds of INFO log lines to stdout ahead of the JSON payload, so verify --json > report.json is not parseable

2 participants