fix(cli): os lint --eval --json stops leaking esbuild's diagnostics to stderr, and the pin now covers every door - #16855
Conversation
…cs to stderr
The `--generator` load calls `bundleRequire`, and esbuild's own logger writes
to stderr from inside that call — before anything throws, so the `catch` that
builds the one-key `{error}` document never gets a chance to suppress it.
Pass `esbuildOptions: { logLevel: 'silent' }` to that one call site, and only
when `--json` is set. esbuild still THROWS its `BuildFailure`, so the refusal
is unchanged; the human face passes no new option at all and is byte-identical.
Extends `lint-eval-generator-load-envelope.e2e.test.ts` to the two doors its
`expect(run.stderr).toBe('')` did not drive: the unresolvable path, and a
generator that loads fine but makes esbuild warn. Each carries its own
negative control on the same run.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015QE8qk46e5CHJxyQEUjbf8
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015QE8qk46e5CHJxyQEUjbf8
📓 Docs Drift CheckThis PR changes 1 package(s): 20 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 4 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 22 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 d447c6300789f48d2311e1a0909afcc210afeae0 && git checkout d447c6300789f48d2311e1a0909afcc210afeae0
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin a814bdb859dfe707346bcb7df9a2c153c00b6404 9d220b69c23366b104b7e00c3b2b1fb7cb1a4094 && git checkout -B drift-repro a814bdb859dfe707346bcb7df9a2c153c00b6404 && git merge --no-ff 9d220b69c23366b104b7e00c3b2b1fb7cb1a4094
node scripts/docs-audit/affected-docs.mjs --json a814bdb859dfe707346bcb7df9a2c153c00b6404
|
Accepted by the Gates
⭐
|
Fixes #16358
Clause-②: no
os lint --eval --json --generator UNRESOLVABLE_PATHwrote esbuild's own[ERROR] Could not resolve …line to stderr while its--jsondocument on stdout was already well formed. The emission comes from esbuild's logger inside thebundleRequirecall that loads the generator — before anything throws — so the surroundingcatch, which does produce the correct one-key{error}document, never gets a chance to suppress it.⭐ The bytes are not the point. One file over,
packages/cli/test/lint-eval-generator-load-envelope.e2e.test.ts:164pins the opposite property —expect(run.stderr).toBe('')under the comment "A --json run leaks nothing to the human channel" — and it is green. It is honest about the one door it drives (a module that exists and throws at import, where esbuild bundles cleanly and prints nothing); the other doors into the samecatchwere uncovered, and they leak. Same command, same face, two answers, with a green pin asserting the one that holds. Closing that coverage gap is as much of this PR as the silence is.The two legs, re-driven before editing
4a1a3b0c254/26bc91fc8e7, not independently re-driven by the filing seat. Both were re-driven here at the branch point,origin/main7f96e1417e, throughpackages/cli/bin/run-dev.jswithNO_COLOR=1— the same source entry the pin file uses.Leg (a) — the stderr emission is real.
The emission is
✘ [ERROR] Could not resolve "PATH"followed by two newlines — 34 fixed bytes plus the path, so the card's 54 is this same line with a 20-character path and the 55 here is a 21-character one. The count tracks the path, the line does not change.Leg (b) — the sibling pin is green at the same head, while (a) holds.
⭐ The two together are the finding.
*.e2e.test.tsis the nightly tier (scripts/nightly-tiers.mjs), so the file needsOS_TEST_TIERS=nightlyto be collected at all; under the defaultqueuesetting it is excluded from both projects and a plain run reportsNo test files found.A third door the re-run found — the leak is not confined to the error branch
Driven at the same head with a generator that bundles and loads successfully and merely makes esbuild warn:
Nothing throws on this path at all, so no
catchwas ever involved and no error handling could have been blamed. It is the same defect — the machine face carrying human-channel output from the same call — so it is repaired and pinned here rather than filed as a separate card.The repair, and why this form
The card left the form open — a
logLevel, a custom logger, or captured stderr, "not settled by existing evidence in this repo". This PR takeslogLevel, passed throughbundleRequire'sesbuildOptions, at that one call site, and only when--jsonis set:logLevelplus theerrors/warningsarrays on the result (or on the thrownBuildFailure). There is nothing to hand it.process.stderr.writearound anawaitis a process-global monkey-patch that swallows any concurrent write, not only esbuild's, and has to be unwound on every exit path from thetry. It trades a 55-byte leak for a global mutation.esbuildOptionssurvives.bundle-require@5.1.0spreads the caller'sesbuildOptionsfirst and then overridesentryPoints,format,bundle,pluginsand friends;logLevelis in none of the overridden keys, so it reaches esbuild intact.What this does and does not suppress — stated, not shipped quietly
logLevelgoverns whether esbuild prints, not whether it throws. TheBuildFailurestill arrives witherrorspopulated, and that text is already the tail of the{error}string thecatchbuilds. Both stdout documents above are byte-identical before and after.--jsonnoesbuildOptionsis passed at all, so bundle-require's own esbuild defaults apply exactly as before — rather than me writing out a default I would then own. Measured:os lint --eval --generator /tmp/os16358/nope.mjsis byte-identical on both channels before and after (diffreports no difference on stdout or stderr).--json, an esbuild warning on a generator that loads fine (the third door above) reached stderr before and now reaches nothing. A warning is not thrown, so no handler carries it onto stdout. That is inside the defect rather than beyond it — the property the sibling pin's comment states is about the--jsonface as a whole — but a--jsonconsumer that was reading stderr for bundler warnings will no longer see them. It is called out in the changeset as well.bundleRequireappears at four sites inpackages/cli/src; this one was located by symbol, not assumed.utils/config.ts:271,utils/scaffold-validate.ts:123andcommands/serve.ts:2283are untouched and keep their diagnostics. A global esbuild silence would trade one under-read for a larger one.The pins
Two cases added to the existing file, in the same shape the existing pin uses. ⛔ The existing pin is not weakened, rewritten or moved — the ablation below shows all 7 pre-existing assertions staying green while only the 2 new ones go red.
expect(run.stderr).toBe(''), and on the same run the negative control: exit 1,Object.keys(payload)exactly['error'], the message naming bothFailed to load generatorandCould not resolve. A repair that swallowed the throw along with the logger satisfies the first assertion and fails every one after it.expect(machine.stderr).toBe('')pluspayload.mode === 'live'(proof the module really was loaded, so the empty stderr is not measuring a run that never bundled), and then the scope control: the same fixture on the human face must still show[WARNING]. That second leg is what stops the first from going vacuous — if a future esbuild stopped emittingimpossible-typeof, anstderr === ''assertion alone would stay green while measuring nothing, and the human-face leg reddens instead of hiding it. It also pins that the silence does not reach the face that asked for human output.Verification
All at
9d220b69c2unless noted. Exit codes captured before any pipe.pnpm --filter '@objectstack/cli^...' build(dependency closure)OS_TEST_TIERS=nightly … vitest run test/lint-eval-generator-load-envelope.e2e.test.tspnpm --filter @objectstack/cli typechecktsc --noEmit+check:test-typecheck; the edited test file is intsconfig.test.json's program — confirmed with--listFiles, 1 hit, 0 errors in it)pnpm --filter @objectstack/cli exec vitest run --project unit --maxWorkers=2 lintnode scripts/pm/dispatch-gates.mjs --commands→ 58 families, all run--ran: 58 derived, 58 run, 0 NOT-MEASURED, 0 UNRUNpnpm lint(eslint . --no-inline-config, whole repo — not narrowed)pnpm check:nul-bytes+grep -naP '[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]'on every edited fileFour of the 58 first returned
PREREQUISITE NOT MET(exit 3 — "nothing was measured", not a finding):check:dual-build-cjs-loads,check:i18n,check:i18n-coverage,check:i18n-walk-parity, all of which read built output that did not exist yet. They were re-run to exit 0 afterturbo run buildover the closure those gates name.check:type-check-debtfirst died of a V8 OOM at a 4 GB heap on this shared box — alsoNOT MEASURED, not red — and returnedOK — 5 ledger entries re-measured, 55 raw tsc errors, none above its recorded numberat 8 GB.dispatch-gatesreports this branch as a STALE TREE (behindorigin/main, with four files it derives from changed upstream). Re-derived aftergit fetch: the 58-family list is identical. CI evaluates the newer copies on the merge result.Ablation — the new pins can fail
Committed first, then mutated, so the restore leg points at a
HEADthat already carries the implementation.Exactly the 2 new cases go red and the 7 pre-existing ones stay green. The restore is proven by blob hash and an empty
git diff HEAD, not by an exit code, and the script carried atrap … EXIT INT TERMrestoring by absolute path.pnpm --filterruns from the package root, so vitest printedNo test files foundand exited 1 — an exit code that reads exactly like a successful ablation. The reading was discarded, a vacuity guard (No test files found/ noTest Filessummary ⇒ hard failure) was added to the script, and the run above is the re-run.No
dist/sits on any measured path: the pin file spawnsbin/run-dev.jsthroughtsx, so the child loadscommands/lint.tsfrom source.packages/cli/distdid not even exist while the before/after CLI runs above were taken — which is what the fourPREREQUISITE NOT METgates independently reported.验收备注
7f96e1417e, transcript above. (a) 55 bytes on stderr for a 21-character path, stdout already a well-formed one-key{error}; (b) the sibling pin green at that same head, 7 passed, while (a) held. Neither was inherited from the card.expect(run.stderr).toBe('')on the unresolvable-path door, in the same shape the existing pin uses, in the same file. The existing pin at:164is unchanged; the ablation shows it green against the mutated source, so it does not depend on this fix.Object.keys(payload)exactly['error'], the message naming bothFailed to load generatorandCould not resolve. Measured stdout is byte-identical before and after (143 B both times).logLevel: 'silent'viaesbuildOptions, gated onflags.json, at this one call site; the two alternatives are addressed above (no logger hook exists in esbuild's JS API; capturing stderr is a process-global monkey-patch).--jsonface — is stated here, in the code comment and in the changeset rather than shipped quietly.Out of scope, noted, not filed:
os lint --eval --generatorscores a generator returning{ objects: [] }at 100/100,mode: live, 5/5 passed. That is the documented consequence of an already-pinned property —packages/cli/src/lint/metadata-eval.ts:100states "The empty stack scores 100 / A /valid: true(pinned inscore.test.ts)" — and the eval harness deliberately routes around it for unscorable cases. Recorded because the fixture in the new warning-only pin relies on it; nothing here changes it.Clause-②: no— suppressing a stray stderr emission removes output. It relaxes no accept set, widens no published surface, and adds no schema key, closed-set member, published export or registry entry. The rebuttal condition the dispatch named does not fire: the--jsonstdout document is byte-identical before and after on every path measured.Docs drift advisory — verified, nothing falsified, PR not widened
The Docs Drift Check bot listed hand-written pages against this diff and truncated its own list above 15 rows, so the list was re-derived rather than read off the comment:
node scripts/docs-audit/affected-docs.mjs --json a814bdb859dfe707346bcb7df9a2c153c00b6404→ 24 pages, 4 of them release-owned. ⛔ Nothing was edited. The diff is still+179/-0across 3 files, and 0 of its paths are undercontent/.One anchor produced all 24. Of the two anchors the run found —
runEval(symbol) andos lint(command) — the symbol matched zero pages; every one of the 24 arrived through the bare command tokenos lint, which is named across automation, data-modeling, deployment, getting-started, permissions, protocol, releases and ui. The count is a measure of how widely the command is named, not of what this diff changes.The prior, tested rather than assumed. This change silences an internal bundler's diagnostic on stderr; it alters no
--jsonstdout document, no exit code and no command contract. Tested by scanning all 24 pages for the tokens that would have to appear for a page to state anything about the path touched:--eval--generatorstderrreleases/v16.mdx, and it is about flow-trigger failures logging at ERROR on stderr fromos serve, not the CLI's--jsonfaceesbuild/bundle-requireautomation/hook-bodies.mdx,deployment/cli.mdx,releases/v17.mdx— every one aboutloadConfig's bundler oros serve --prebuilt, i.e. theutils/config.tsandserve.tscall sites this diff does not touch--jsondeployment/cli.mdx(42),releases/v17.mdx(5), and four pages with one each — all of them documenting that the flag exists or what the stdout payload carries; none says anything about the human channelThe most exposed page,
content/docs/deployment/cli.mdx, documentsos lintat:1371-1399withos lint --json # JSON output for CIand, at:1943, "All commands that produce output support--jsonfor machine-readable output". Neither mentions--eval,--generatoror stderr, and both stay true — the change moves the command toward that sentence, not away from it.The 4 release-owned pages were read, not edited.
content/docs/releases/is written centrally at release time; ⛔ a code PR does not touch it. Read anyway, as required:v13.mdxandv15.mdxcarry none of the tokens at all;v16.mdx's singlestderris the flow-trigger line above;v17.mdx's hits areloadConfig's esbuild at:4199,--jsonpayload/codework at:770,:4690and:4784, and anasync generatorat:990that is the JavaScript noun, not--generator. ⇒ No release-owned page describes the stderr behaviour changed here, so there is no fact to hand back for separate filing.The bot's declared blind spot, hand-read. A page can state a rule by its inputs and share no identifier with the emitter, so the rule this change carries — a
--jsonrun emits nothing on the human channel — was searched for across all ofcontent/in short wrap-immune tokens rather than sentences:--eval→ 0 occurrences incontent/, anywhere.--generator→ 0 occurrences incontent/, anywhere.stderr→ 4 occurrences in all ofcontent/: three inreferences/system/logging.mdx(the runtime logger's console sink config,stream: 'stdout' | 'stderr') and one inreleases/v16.mdx. None is about a CLI machine face.esbuild/bundle-require→ 7 occurrences, all on the two untouched call sites, none namingos lint.--jsonandstderr: none.⇒ The rule is not written down anywhere in
content/, in any spelling, by the emitter or by its inputs. "Nothing falsified" is reported here as a searched result, not an assumption.Provenance check — the caveat does apply, and was followed through.
git diff --stat 7f96e1417e d447c6300789 -- content/docsis NOT empty: the bot's tree (d447c630= this head merged ontoa814bdb8) differs from the branch point on three pages —api/data-api.mdx,plugins/anatomy.mdx,references/api/protocol.mdx. All three were read. None appears in the affected list, and none carries--eval,--generator,stderr,esbuildorbundle-require.references/api/protocol.mdxnamesos linttwice, both times as one of the shared authoring rules behind an HTTPadvisorieskey — and it says the CLI surfaces those findings "on its own stdout", which this change leaves byte-identical.🤖 Generated with Claude Code
https://claude.ai/code/session_015QE8qk46e5CHJxyQEUjbf8
Generated by Claude Code