Skip to content

test(cli): reach the built CLI in the three serve e2e spawners - #12459

Merged
os-litant merged 3 commits into
mainfrom
claude/issue-11707-e2e-spawn-shape
Aug 26, 2026
Merged

test(cli): reach the built CLI in the three serve e2e spawners#12459
os-litant merged 3 commits into
mainfrom
claude/issue-11707-e2e-spawn-shape

Conversation

@os-litant

@os-litantos-litant commented Aug 26, 2026

Copy link
Copy Markdown
Collaborator

Fixes#11707

Item 1 only, per triage's scope ruling on the card. The guard (item 2) belongs to #11464 and nothing here implements it.

What changed

The three packages/cli serve e2e spawners now use the honest-and-fast shape measured in #11462:

spawnchild NODE_ENVcommand tree reached
beforetsx bin/run-dev.jsdevelopmentsrc/commands
afternode bin/run.jsunset (undefined, which Node's spawn() omits)dist/commands

Files: serve-mcp-stdio-answers.e2e.test.ts, serve-mcp-capability-collision.e2e.test.ts, serve-stdio-stdout-purity.e2e.test.ts.

A fourth file, serve-node-env-production-default.e2e.test.ts, carries a comment-only correction — see "The paragraph this change falsified" below. That file was added to this task's declared surface by the dispatching PM after the first review round, explicitly granted rather than taken; nothing in it changes behaviour and no other file is touched.

The two enabling premises, re-measured on this tree — both hold

(a) the build is declared.turbo.json still reads "@objectstack/cli#test": { "dependsOn": ["build"] } (its own build, not ^build), so packages/cli/dist really is built for these tests in CI.

(b) an unset NODE_ENV still keeps the dev-admin seed.packages/cli/src/commands/serve.ts:1298 still does the in-process assignment for --dev:

if(flags.dev){if(!process.env.NODE_ENV)process.env.NODE_ENV='development';}elseif(!process.env.NODE_ENV){process.env.NODE_ENV='production';}

and the seed's gate still reads the variable at CALL time, inside the kernel:ready hook that fires after that assignment — plugin-auth's maybeSeedDevAdmin delegates to isDevAdminSeedArmed() in walled-owner-verification-path.ts, whose env() helper reads globalThis.process.env freshly on every call. All five tests boot, sign in as the seeded admin, and mint a real osk_ key: the seed runs.

The line moved (the card cited serve.ts:1272, it is 1298 today) but the shape is intact.

Proof the suites now reach dist/commands — an instrument that can say no

A distinct marker was planted in each tree at once and the spawn recipe read back which one executed. Same fixture, same env, same argv:

probespawnchild NODE_ENVdist markersrc markerboot
A — this PR's shapenode bin/run.jsunset10ready
B — the self-cancelling pairnode bin/run.jsdevelopment01ready
C — the shape that landedtsx bin/run-dev.js(shim pins development)01ready

Rows B and C are the negative controls: the same marker pair, the same probe, the opposite answer. B is exactly the pair #11317 found in these files — it promises the built artifact and delivers source, with a green boot and nothing in the output saying so.

Suite-level ablation. Planting process.stdout.write('OS11707_NOISE') into packages/cli/dist/commands/serve.js turns serve-stdio-stdout-purity.e2e.test.ts RED, on the planted byte:

AssertionError: stdout carries 1 line(s) a JSON-RPC client would fail to parse (#7915)
OS11707_NOISE
: expected [ 'OS11707_NOISE' ] to deeply equal []

dist/ was mutated directly, not through a source edit plus a rebuild: the question is which tree the spawn reads, and a rebuild would land the marker in both. Both mutation legs were confirmed on disk before the run (grep -c on the planted literal, plus node scripts/ablation-dist-preflight.mjs @objectstack/cli 'OS11707_NOISE' reporting marker present in 1 built file), and both restore legs were proved after: sha256sum of dist/commands/serve.js identical to the pre-mutation value, git diff HEAD empty for the source leg, and ablation-dist-preflight ... --absent reporting marker absent from all 432 built files.

The build prerequisite is now legible instead of cryptic

Turbo guarantees the build in CI and guarantees nothing for a developer running vitest directly. Each of the three files gains a requireBuiltCli() preflight, called first in beforeAll. With dist/commands/serve.js removed, the suite now says:

Error: packages/cli is not built: .../packages/cli/dist/commands/serve.js does not exist.
This file spawns bin/run.js with NODE_ENV unset, which is what makes oclif resolve the command
from dist/ instead of transpiling src/ — so on an unbuilt tree the child answers
"command serve not found" and every boot below times out.
CI declares the build (turbo: @objectstack/cli#test dependsOn build); a direct vitest run does not.
Run: pnpm exec turbo run build --filter=@objectstack/cli

It fails in seconds with three tests skipped, rather than through three 150-second boot timeouts carrying oclif's wrapped command serve not found. The command target is read from the CLI's own oclif.commands.target rather than restated, for the reason scripts/cli-build-prerequisite.mjs gives; an unreadable or differently-shaped declaration defers rather than failing, so a checkout it cannot understand never turns red here.

It does not catch a dist/ that is merely BEHIND its source. That residual is real and is stated in each file's header. serve-node-env-production-default.e2e.test.ts has carried the identical residual since #11113.

The paragraph this change falsified, corrected in the same PR

serve-node-env-production-default.e2e.test.ts's section headed "WHY THIS FILE IS THE REASON @objectstack/cli#test DECLARES build" is the repo's only written account of why that turbo dependency exists — which is the premise this PR's safety rests on. This branch made three of its statements false:

  • "This is the only file in packages/cli that genuinely consumes packages/cli/dist, and it is the only one that can be" — there are four now.
  • the three spawners "pass NODE_ENV: 'development' to the child" — present tense, no longer true.
  • "Those three therefore never touch dist/ at all" — same.

Corrected to what is measured, and no further: the count is stated (four: this file plus the three below), the historical account of why the missing build stayed invisible under dependsOn: ["^build"] is kept but tensed to the past, and one new paragraph names why the new state is safe — the dependsOn: ["build"] declaration, not this file. The surrounding prose (the origin-check rationale, the anti-vacuity section, the closing refusal to switch this file to bin/run-dev.js) is untouched. 20 insertions, 6 deletions.

Measured before writing it, not recalled: of the five other files naming bin/run.js, plugin-commands.test.ts and invocation-loudness.e2e.test.ts only assert the path as a string (invocation-loudness spawns tsx bin/run-dev.js and uses BIN solely as an expected substring), and the three that spawn it are the three this PR moved. So the original sentence's structure survives; only its outcome changed.

Speed, re-measured on this box — the card's ratio holds, its absolute numbers do not

Three files, one vitest run --maxWorkers=2, warm build, under this container's shared verify lock so neither run competed with a parallel build:

shapewallvitest Durationtests
before (tsx bin/run-dev.js)27.31s26.15s44.93s
after (node bin/run.js)13.72s12.65s21.76s
after, re-run on the merged head13.75s12.60s21.68s

2.06x in test time (44.93s to 21.76s). The card's table claimed 2.2x (68.01s to 30.26s) from #11462 on a different box; its absolute numbers do not reproduce here — this box is roughly 1.5x faster on both rows — but the gap the card is arguing from has not narrowed. The case for this change survives on the ratio, not on the numbers.

Where this diff is bigger than the card predicted

The card said option 2 is "three lines in the same three files". The behavioural core is five lines per file, not three: const CLI, the spawn() argv0, the NODE_ENV value, plus two import lines (TSX becomes unused, and existsSync/readFileSync are needed by the preflight). The rest is the requireBuiltCli() preflight and the header comments, which asserted the opposite of the new code and could not be left standing.

Verification

Run on 69f95990 (branch head; origin/main68c5dbaab merged in), after turbo run build --filter=@objectstack/cli (Tasks: 56 successful, 56 total):

  • all four changed files together: Test Files 4 passed (4), Tests 8 passed (8), Duration 22.00s
  • the newly added file alone: Test Files 1 passed (1), Tests 3 passed (3)
  • pnpm lint (eslint . --no-inline-config, whole repo, not narrowed): exit 0, no output. Re-run over the four changed files after the last edit: exit 0, no output.
  • pnpm --filter @objectstack/cli typecheck: tsc --noEmit exit 0 — but packages/cli/tsconfig.json is "include": ["src"], so that program does not read test/** at all (--listFiles: 0 hits for these files). That layer is the package's recorded TEST_DEBT entry (146 errors / 65 hidden files), which CI re-measures. Measured directly instead, same location, same synthesized config extending the package tsconfig, TS6059 dropped exactly as measureTestDebt does, both tree states: 0 errors before, 0 errors after. This diff cannot move that ratchet up.
  • check:cli-test-child-env35 spawner source(s) among 95 under packages/cli/test/**; no new bulk process.env copy reaches a spawned child
  • check:cross-package-test-inputsOK: 18 package(s) read outside themselves, all declared, and turbo.json hashes every declared glob (the preflight reads packages/cli/package.json, which is inside the package and escapes nothing)
  • check:test-source-aliasOK — 72 packages with tests scanned; 61 registered as still resolving a workspace dep through dist/
  • check:nul-bytesOK (scanned 6892 text file(s) ...; no raw ASCII control bytes)
  • check:comment-mask-adoptionOK — 23 private comment-stripper(s) ... all 23 recorded
  • also green in the first round: check:type-check-coverage, check:engine-double-contract, check:where-matcher, check:query-options-erasure, check:published-files, check:slot-lookup, check:page-declaration-shape, check:type-source-resolution, check-ci-filter-parity, check-plugin-teardown-shape

Gate families re-derived in this worktree with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack after the fourth file was added: change set of exactly these four paths, same family set, no new family.

No changeset: test-only, nothing published changes behaviour.

One prose site left untouched on purpose

packages/cli/vitest.config.ts's cost header states the opposite policy: "Swapping the spawns to the built entry would halve per-spawn boot and is exactly the source-vs-dist trade scripts/check-test-source-alias.mjs exists to refuse." The gate itself does not contradict the ruling and is green here — check-test-source-alias judges in-process import resolution of test files and says nothing about what a spawned child resolves — but the header's prose does.

Unlike the paragraph corrected above, this one is not falsified by this change: it is a standing policy statement that triage's ruling contradicts, and it predates this branch. The dispatching PM ruled it out of this PR and filed it separately.


Generated by Claude Code

Adopt the honest-and-fast spawn shape measured in #11462: spawn
`bin/run.js` with plain `node` and leave the child's `NODE_ENV` UNSET,
which is the value that keeps @oclif/core's TypeScript path lookup
disabled so the command resolves from `packages/cli/dist/commands`.
The `--dev` admin seed these fixtures sign in as survives an unset
`NODE_ENV`: `serve.ts` assigns `process.env.NODE_ENV = 'development'`
in-process for `--dev` before `runtime.start()`, and plugin-auth's
`isDevAdminSeedArmed()` reads it at call time inside the `kernel:ready`
hook, after that assignment.
Each file gains a `requireBuiltCli()` preflight so an unbuilt tree fails
with a sentence and a build command instead of oclif's cryptic
"command serve not found".
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UjujZN219uFzBhSYfMykCd
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

Nothing in this diff resolved to a documentable surface (no symbol, route or SDK anchor derived from 0 changed package(s)), so this run has no opinion about the docs.

What this run could not see
  • 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 — 0 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 68c5dbaab2ecbc031e7342c9f2fc9efd6ad8bd08packageMentionDocs.

`serve-node-env-production-default.e2e.test.ts`'s "WHY THIS FILE IS THE
REASON @objectstack/cli#test DECLARES build" section claimed to be the only
file that consumes `packages/cli/dist` "and the only one that can be", and
described the three MCP/stdio spawners in the present tense as pinning
`NODE_ENV: 'development'` and therefore never touching `dist/`. This branch
makes all three of those statements false.
Corrected to what is measured: four files consume `dist/` today, the
historical account of why the missing build stayed invisible is kept but
tensed to the past, and the reason the new state is safe is named — the
`dependsOn: ["build"]` declaration, not this file.
Surface extension granted by the dispatching PM after the first report;
nothing else in the file is rewritten.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UjujZN219uFzBhSYfMykCd
@os-litant
os-litant marked this pull request as ready for review August 26, 2026 04:50
@os-litant
os-litant added this pull request to the merge queueAug 26, 2026
Merged via the queue into main with commit 262145bAug 26, 2026
37 checks passed
@os-litant
os-litant deleted the claude/issue-11707-e2e-spawn-shape branch August 26, 2026 05:08
This was referenced Aug 26, 2026
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/mskip-changesetPR has no user-facing published change; bypasses the changeset gatetests

Projects

None yet

2 participants

@os-litant@claude