test(cli): refuse a built-entrypoint spawn whose child resolves commands from src/ - #12558
Merged
Merged
Conversation
…nds from src/ Adds a third, NEGATIVE rule to check-cli-test-child-env: a packages/cli/test spawn of bin/run.js may not hand its child a NODE_ENV of development or test, which is what makes @oclif/core reroute the command lookup from dist/ to src/. Silence reds too -- vitest exports NODE_ENV=test on its worker and childEnv() deliberately does not strip it, so the pair forms by inheritance. Self-test 92 -> 132 cases; no existing case weakened. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UjujZN219uFzBhSYfMykCd
This was referenced Aug 26, 2026
os-litant
marked this pull request as ready for review
August 26, 2026 10:49
This was referenced Aug 26, 2026
Open
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #11464
A third, negative rule in
scripts/check-cli-test-child-env.mjs: apackages/cli/testspawn of the built entrypointbin/run.jsmay not hand its child aNODE_ENVthat sends@oclif/core's command lookup back tosrc/. Guard only — no baseline, no repairs, and no test file is touched.The census, re-derived — not taken from the card and not from the claim
The card body says the only remaining
bin/run.jsspawners areinvocation-loudness.e2e.test.tsandserve-node-env-production-default.e2e.test.ts. The claim comment corrects that to six, naminghelpers/serve-process.tsandlogin-json-noninteractive.e2e.test.ts. Both figures are wrong, in different directions. Re-derived AST-first againstorigin/main@1a6855226, by resolving every argv binding rather than grepping for the path:bin/run.jsserve-mcp-capability-collision.e2e.test.tsserve-mcp-stdio-answers.e2e.test.tsserve-node-env-production-default.e2e.test.tsserve-stdio-stdout-purity.e2e.test.tshelpers/serve-process.tsCLIis../../bin/run-dev.js, spawned throughtsxlogin-json-noninteractive.e2e.test.tsCLIis../bin/run-dev.js, spawned throughtsxinvocation-loudness.e2e.test.tsBINbound at 52expect(...).toContain(BIN)(133)plugin-commands.test.tspkg.binserve-organizations-host-resolution.e2e.test.tsFour spawners, not six and not two. The gate reports the same four independently, by different code over the same tree:
4 spawn(s) of the built CLI.Two consequences worth stating, because both were load-bearing in the dispatch:
helpers/serve-process.ts(held by test(cli): read back the port the e2e serve child actually bound, and fail loudly on a silent drift #12546) is not a built-entrypoint spawner at all, so nothing here concerns it. The threeserve-mcp-*/serve-stdio-*files (held by Threeserve-mcp-*/serve-stdio-*spawners still fail port contention with the generic "serve exited 1" — they get #12441's probe but not its named error #12526) are members and are clean — no repair is needed in any fenced file, so this is not a serial.The live violation count: 2, in one unfenced file
All four spawns are written
childEnv({ ..., NODE_ENV: undefined, ...env }), whereenvis the enclosing helper's parameter. Resolved one hop to what those helpers are actually called with:⭐ Both are irreducible, which is why they are declared rather than repaired. That file varies the child's
NODE_ENVas its independent variable and asserts whatserve's trusted-origin gate does under each value. The two set legs cannot be written any other way: keepingbin/run.jsreroutes tosrc/by the very mechanism this rule refuses; switching them tobin/run-dev.jspinsdevelopmentbefore argv is parsed, which makes thetestleg unreachable and which that file's own header forbids for the unset leg; and settingNODE_ENVafter oclif has resolved the command is not reachable from outside the child. So they are twoDELIBERATE_REROUTEentries carrying that reason, keyed perit()block and pinned in both directions — a stale entry fails.src/commands. That is comment truthfulness, the same class as #12498 one file over — not something a registry entry fixes, and not this card's declared surface.Why the rule is written negative
Per the claim's ruling, and following the shape of the rule #12530 landed in this same file: the rule states the property that must hold, never the helper that must be called. A built-entrypoint spawn's child must be readably outside
development/test.NODE_ENV: undefinedis what the population happens to write;productionis equally green; a plain object literal that says so is green with no helper involved at all — pinned by a case. A rule spelled "must callchildEnv({ NODE_ENV: undefined })" would stop meaning anything the day someone wraps the choke point.⭐ Silence reds too, and that is the card's headline. vitest exports
NODE_ENV=teston its worker andchildEnv()deliberately does not strip it, so a spawner that namesbin/run.jsand simply says nothing aboutNODE_ENVlands in the rerouted leg by inheritance. The failure does not require anyone to set the variable on purpose. Rule 2 is already satisfied by a declaredenvthere, so this had to be its own rule.The mechanism, from
@oclif/core@4.13.3—lib/util/util.js:66definesisProd()as a negated membership test over['development', 'test'], and the TypeScript path lookup is skipped only when that is true:NODE_ENVcommandsDirpackages/cli/dist/commandsproductionpackages/cli/dist/commandsdevelopmentpackages/cli/src/commandstestpackages/cli/src/commandsWhy this rule follows one hop where rule 1 refuses to
Rule 1 is file-anchored because it cannot chase a value built three functions away, and its header says so. Rule 3 cannot make that trade: every built-entrypoint spawn in the population puts the overriding spread after the declaration, so without the hop all four are unreadable and the rule reports four findings that say only "this scan gave up" — a gate resolving to zero readable sites inside its own population, which is the vacuity this file refuses everywhere else. So the spread resolves one hop, same file, and the finding is reported at the caller, where the value was chosen.
One hop, with every limit named in the header rather than left to be discovered: a caller that forwards its own parameter, a caller argument that is not an object literal, a caller literal carrying its own spread, and a helper with no same-file call sites are each reported as unreadable — never as green.
One correction the battery forced, worth naming: a helper call with no readable overrides (
childEnv()bare) was first classified as "inherits". That is an assumption about how a specific helper behaves — knowledge this rule deliberately refuses to have — so it now reports as unreadable instead. Both are findings; only one of them is honest about what was measured.Proving the guard can say no
Two ablation legs on
serve-node-env-production-default.e2e.test.ts, each with a restore trap on absolute paths, each confirmed on disk before the gate was run, each restored and proven by blob hash rather than by an exit code. No build and nodist/is involved — this gate reads source — so there is no rebuild leg to report.HEADblob1f0d3957e34c608ab1293b53045d684b5af84b42.Leg A — plant the explicit pair. The unset leg rewritten to
probeOriginCheck({ NODE_ENV: 'development' }). Mutation confirmed on disk by counting both texts: injected 2,probeOriginCheck({})0. Blob moved toadfee3d0. Gate exits 1:Leg B — the inheritance case, which is the one that matters. The explicit
NODE_ENV: undefineddeleted. Mutation confirmed on disk: occurrence count 1 to 0. Blob moved to3881b036. Gate exits 1, and names the inherited reason:Note that leg B reports one finding and not three: the two declared legs stay declared, which is the site-scoped registry working rather than a file-wide mute.
Restore, proven both times: working blob back to
1f0d3957e34c608ab1293b53045d684b5af84b42— non-empty on both sides and equal — plusgit diff HEADover the target empty, and the gate green again through the same entry point.The durable version of both legs is in the self-test, out of process, where an exit status can actually be observed.
Site keying, re-read against current
mainPer the claim's last ruling:
enclosingFunctionName/callbackSiteNamewere re-read atf0c9ffabe. Both violation sites are plainit('title', async () => ...)— an identifier callee with a string-literal title — which is exactly the shape #12531 made work, so both key distinctly and neither reads(top-level).DELIBERATE_REROUTEkeys are produced by that same helper, so they contain theit()titles verbatim. The open item on #12545 concernsit.skip/it.each/describe.each, whose callees are property accesses or calls — a different branch of the walk, which neither site touches. Should that work nonetheless change how a plainit()is named, these two keys move and the gate says so loudly (a stale entry fails) rather than going quiet. No rule message here depends on the part #12545 is about to change.Self-test: 92 to 132 cases, none weakened
Ruling 3 was extend-never-weaken. Every removed line in the diff is a signature or a destructuring plumbing line; no pre-existing case was deleted, relaxed, or re-scoped. The 40 added cases split into membership, the rule, the hop, the registry, independence, and two out-of-process legs — including, as its own case, that a file which merely names
bin/run.jsstays out of the population, which is the census error this card was dispatched with.Live-tree anti-vacuity pins were added to match rule 2's: the population count is asserted rather than merely printed (an empty finding list reads identically whether every spawn is sound or the entrypoint resolution stopped resolving anything), and the four-file population is pinned as a set so a file joining or leaving has to be seen by a PR.
Verification — all at
deba323a4, the final commit, on a clean treeTREE_AT=deba323a4 dirty=[]pnpm lint(eslint . --no-inline-config, whole repo, not narrowed)VERDICT command-exit 0 · held 57s · waited 177spnpm check:cli-test-child-envcheck:agent-test-spelling·check:bash32-floor·check:cli-command-ids·check:cross-package-test-inputs·check:entry-guard·check:parse-guard·check:pnpm-filter-targets·check:nul-bytes·check-ci-filter-parity.mjs·check-cross-package-test-inputs.mjsscripts/pm/bare-root-worklist.mjs --self-test·pnpm check:pm-dispatch-gatesGate list derived with
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstackagainst the real change set (1 path), not from a hand-written list. Exit codes were captured before any pipe.typecheckis reported as NOT APPLICABLE rather than as a green. The only edited file isscripts/check-cli-test-child-env.mjs, a.mjsmodule. The roottsconfig.jsonsets noallowJs, andtsc --listFilesover the root program returns 0 hits for that filename — so no tsc program reads it, and any "typecheck passed" line would be true and empty.No changeset
One repo gate script. No test file and no published source is touched, so this PR releases nothing —
skip-changeset.Generated by Claude Code