Repository navigation
test(cli): write oclif.manifest.json once per integration run, so a CLI spawn reads its command table - #26256
Conversation
… run (WIP) Claude-Session: https://claude.ai/code/session_012YTKj54wfJ886NzwKz1oba Co-authored-by: Claude <noreply@anthropic.com>
…with or without an oclif manifest Claude-Session: https://claude.ai/code/session_012YTKj54wfJ886NzwKz1oba Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift Check2 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
Coarse fallback — 31 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 0c5ad00513bcfdddf69218bea1c9016d7e9136e7 && git checkout 0c5ad00513bcfdddf69218bea1c9016d7e9136e7
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 81bba6c3f5be5b6cbabf76511383940349f23b15 8e5065109d97177536dcda34469ea409520dbc80 && git checkout -B drift-repro 81bba6c3f5be5b6cbabf76511383940349f23b15 && git merge --no-ff 8e5065109d97177536dcda34469ea409520dbc80
node scripts/docs-audit/affected-docs.mjs --json 81bba6c3f5be5b6cbabf76511383940349f23b15 |
席位核对 · ACCEPT ·
|
| item | reading |
|---|---|
| Shape | Draft, base main, Part of #23181 (slice 14), skip-changeset, mergeable clean. |
| Files | 4 files, +207/−9, all on slice 14's surface. The new test/helpers/oclif-manifest.global-setup.ts, the wiring in packages/cli/vitest.config.ts, two .gitignore lines, and the A1 file published-entry-node-env-source-reroute.test.ts. |
| Fix | Seat ruling A. For a run, a test-only globalSetup writes oclif.manifest.json from source, using oclif's own root Plugin, and removes it at teardown. It strips isESM/relativePath from every command, so the built entry still loads dist/ and never source. The write is atomic (temp file then rename), ownership is tracked by pid, nested vitest children leave it alone, and the setup runs only for the queue population. The nightly e2e tier, which pins the unmanifested Config.load(), runs without it. |
| A1 | The #12271 pin now runs explain object, a real command, instead of --version, with OCLIF_COLUMNS wide. Its control leg stays red on main's shape both without a manifest and with one. No assertion is weakened. |
| Evidence | Spawn cost falls from 7.6–9.1 s to 0.5–3.9 s. The slice 11 family runs 285/280/258 s → 111/107/116 s (N=3, 91/91 tests each run). 52 of 54 output streams are byte-identical; the other 2 differ only in printed timings. The pin file passes 5/5 under both configs. 56/56 local gates exit 0. On this head, 36 check runs succeeded and 16 were expected skips, with 0 failed. |
| Carried | Slices 10 and 12 took a declared budget, ruled as A then C. The next refresh re-reads those rows against this manifest, and their budgets tighten then. |
| Landing | 「按照 pm-dispatch 规则复核过的都授权」 (Leehom, 2026-10-25T11:35Z): ready and auto-merge through the relay. |
Generated by Claude Code
Requested by Leehom · project thread
Part of #23181 (slice 14). The card is a series and stays open.
Clause-②: no
Metadata-protocol: none
Test infrastructure only:
skip-changeset.@objectstack/cli'sfiles[]isdist,README.mdandCHANGELOG.md, unchanged, and nothing in it changes. The diff touchespackages/cli/vitest.config.ts, one new module underpackages/cli/test/helpers/, one test file underpackages/cli/test/, and the root.gitignore. No build step, no new gate, no product code.What this does
Seat ruling A on slice 11's open question (report 6418998910 on #23181). A vitest
globalSetup,packages/cli/test/helpers/oclif-manifest.global-setup.ts, writespackages/cli/oclif.manifest.jsonfrom source once per run of theintegrationproject and removes it at teardown. The file is gitignored.This package ships no oclif manifest, so every CLI process builds its command table by importing all 72 command modules. Through
tsx bin/run-dev.jsthat is a floor of about 8 s per spawn. With the manifest besidepackage.json, oclif reads the table from it and imports only the command it runs. Outside the one file ruling A1 changed (below), spawn shape, thebin/run-dev.jsentry andNODE_ENVare unchanged.Why the written manifest is transparent
new Plugin({ isRoot: true, root }), loaded in atsxchild underNODE_ENV=development, asbin/run-dev.jsruns), witherrorOnManifestCreateon so that a command module that fails to load fails the generation loudly, naming the module.isESMandrelativePath. This is required. When a cached command carries both,@oclif/core5.1.2 importsroot + relativePathverbatim, and a manifest generated from source recordssrc/commands/ID.ts. Measured control: with those keys kept,node bin/run.js generate object order_line --dry-runimportspackages/cli/src/commands/generate.tsand exits 1 (ModuleLoadError). With them removed, oclif takes its no-manifest path. Its debug log reads(import) CLI/dist/commands/generate.jsunderbin/run.jsand(import) CLI/src/commands/generate.tsunderbin/run-dev.js. So the manifest supplies the command table and nothing about where a command's code comes from.tsx bin/run-dev.jsandnode bin/run.js):--version,--help,serve --help,generate object --help,generate object order_line --dry-run,generate object class(exit 1),generate object(missing argument, exit 1),validateinexamples/app-todo, and an unknown command (exit 2). stdout, stderr and exit code are identical in 52 of 54 streams. The 2 that differ arevalidate's stdout on both entries, and they differ only in the two printed elapsed-time lines (Load time: 148msagainst164ms, andValidation passed (279ms)against(278ms)).Scope: the queue population only
Under
OS_TEST_TIERS=nightlyneither project carries the setup. The e2e tier holds the suites that pin what a manifest changes, which isConfig.load()importing every command module (run-dev-unbuilt-workspace.e2e, the two*-stderr-nonblocking.e2efiles). The product ships no manifest, so that is the shape those pins keep seeing. The setup is wired per project, not at the root: withextends: truea rootglobalSetupwould run for the root project and both tier projects, three times.Ownership, nesting and cleanup (the order's three hypotheses)
globalSetupbeforepool.runTests. The file is written to a temporary sibling and renamed over the target, so a spawn reads a whole manifest or none.OS_TEST_OCLIF_MANIFEST_OWNERin its own process. Workers andchildEnv()children inherit it, and a vitest started inside the run does nothing. Measured with a throwaway integration test that spawnedvitest run --project integrationthroughchildEnv(): the worker and the child env both carried the owner pid, the manifest's owner was the same before and after (3443), and the nested run took 3.5 s, with no 9 s generation.writtenBy.pid) and removes the file only while that pid is still its own. Measured with two--project integrationruns started 4 s apart: the owner went 3691 then 3788, both runs were green (5/5 and 7/7), and after both the file was gone. The run left without a manifest continues at the old floor with the same output.unit-only run removes a file whose writer is dead. Measured: after akill -9of the owning vitest, the file stayed with a dead owner. The next--project unitrun removed it. A planted dead-owner file (pid 999999) was regenerated by the next integration run (owners seen: 999999, then 4631) and removed at its teardown. After aSIGINTmid-run the file was gone (teardown, or theexitlistener for a run that ends without one).One test file changed, by seat ruling A1
test/published-entry-node-env-source-reroute.test.ts(#12271) spawnednode bin/run.js --version. That reached the reroute only because, with no manifest,Config.load()imports every command module. With the manifest present,--versionloads no command. In the full run below, its CONTROL stopped reproducing the card, and its three positive legs passed over a fixture that armed nothing. Its spawns now runexplain object, a real command whose module imports the trappedchalk, withOCLIF_COLUMNS=100000so oclif renders the load failure on one line. With a manifest that failure is the errorhandle()prints, hard-wrapped at 80 columns on a pipe, and a wrap can split the signature or thesrc/commandspath. No assertion was changed or loosened.Legs measured both ways, against the same armed fixture (
exit/ count of the card signature / count ofpackages/cli/src/commands):NODE_ENV=developmentNODE_ENV=productionNODE_ENV=developmentNODE_ENV=testNODE_ENV=productionSo the control still goes red on the trap on main's shape, and also with the manifest. The file under vitest: 5/5 passed twice under this config (manifest written) and 5/5 twice under the base config (no manifest).
Measurements
All on one shared 4-vCPU container under
os-verify-lock. Per-file numbers are the default reporter's module line.Per spawn (the byte-comparison runs above):
tsx bin/run-dev.js7.6 to 9.1 s without, and with the manifest 0.48 to 0.55 s for--versionand help, 1.7 to 3.9 s for commands that run.node bin/run.js2.1 to 2.6 s without, 0.15 to 2.1 s with. One generation: 8.8 to 9.9 s.Slice 11's spawn family (PR #26174's ten files), interleaved A/B,
--project integration --maxWorkers=2, BASE =c7aa9cdevitest.config.ts, HEAD = this branch, N=3:test/build-callable-names.test.tstest/generate-refuses-unimportable-alias.test.tstest/build-runtime-bundle-bodyless.test.tstest/build-strict-body-count.test.tstest/generate-refuses-unparseable-name.test.tstest/generate-refuses-namespace-prefix.test.tstest/build-view-container-name.test.tstest/generate-refuses-retired-generator.test.tstest/generate-refuses-name-outside-charset.test.tstest/build-text-face-advisory-count.test.tsRun wall: base 285.3 / 279.6 / 258.2 s, head 110.8 / 106.8 / 115.7 s (generation included). Tests 91/91 every run, and no manifest was left after any run. The
build-*files keep more of their wall because their build work is real, not spawn floor.Budget headers: slice 11's
// test-budget:headers (PR #26174) are not lowered here. A later refresh re-sizes them.Proof
@objectstack/clisuite with the setup (both tiers, queue population,vitest run --maxWorkers=2, ate4b56419, before the A1 change): Test Files 1 failed, 504 passed, 1 skipped (506), Tests 1 failed, 6324 passed, 9 skipped. The one red was the finding: theos devcompile child still inherits an ambientNODE_ENV=development— the source-loader pin asserts "no write", which cannot see inheritance #12271 CONTROL described above. A watcher saw one manifest owner for the whole 37-minute run (the vitest pid), so there was no second generation, and the file was absent after teardown.d12d287fe(the branch before the merge): the finding: theos devcompile child still inherits an ambientNODE_ENV=development— the source-loader pin asserts "no write", which cannot see inheritance #12271 file 5/5 under both configs (above).vitest run --project unit --maxWorkers=2: Test Files 333 passed (333), Tests 4854 passed (4854), lock VERDICT command-exit 0.pnpm --filter @objectstack/cli typecheck: exit 0,check:test-typecheck: OK(the new module is in the test program throughtest/**).pnpm exec eslint --no-inline-config packages/cli: exit 0.8e5065109(currentmainmerged in, with no overlap with this diff):node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackderived 56 commands over the same 4 paths, each run with its exit code captured by redirect-then-$?, and all 56 exited 0.--ranwith every exit recorded: "56 derived famil(ies) accounted for — 56 run, 0 NOT-MEASURED (a DERIVED zero)". The run atd12d287febefore the merge was the same, after two prerequisites were met:check-plugin-teardown-shape --self-testneeded621a4876fetched into the shallow clone, andcheck:dual-build-cjs-loadsneeded 10 unbuilt packages built.check:cli-test-child-env(the new module spawns its generator withchildEnv()) andcheck:cross-package-test-inputsare among the 56. Repo-widepnpm lintand the full CI farm belong to CI.main: rebuilt the cli closure, then ran the 18 cli test files changed since the branch point (17 bymain, plus the finding: theos devcompile child still inherits an ambientNODE_ENV=development— the source-loader pin asserts "no write", which cannot see inheritance #12271 pin) andtest/vitest-tiers-partition.test.tswith this config. Test Files 19 passed (19), Tests 188 passed (188), lock VERDICT command-exit 0. That covers slice 11's ten files now carrying their budget headers, slice 12's migrate files and Queue-flake anchor: test/hook-timeout-override-refusal.test.ts #26047'shook-timeout-override-refusal, which spawns vitest children.Acceptance notes
unitproject's in-processConfig.load({ root })calls (for exampleexit-signal.pin,db/clean.operator-text-21418) also find the manifest, because every project'sglobalSetupruns before any test. They load commands through the same no-manifest path (norelativePath), and the full run above was green there. A unit-only run generates nothing; it only removes a file whose writer is dead.serve --port's default readsOS_PORT/PORT.os serve --helptherefore shows the generation-time value, which is the run's own environment. Parsing still uses the loaded class. No test runs--helpwithPORTset.ModuleLoadErrornaming the module), instead of every spawn test failing on "command not found".Generated by Claude Code