From 98e410789dfade1f0d9a1e9b3107ccd37af036d2 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 8 Sep 2026 09:43:25 +0000 Subject: [PATCH 1/2] fix(cli): `os lint --eval --json` no longer leaks esbuild's diagnostics to stderr MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_015QE8qk46e5CHJxyQEUjbf8 --- packages/cli/src/commands/lint.ts | 55 +++++++++ ...t-eval-generator-load-envelope.e2e.test.ts | 105 ++++++++++++++++++ 2 files changed, 160 insertions(+) diff --git a/packages/cli/src/commands/lint.ts b/packages/cli/src/commands/lint.ts index 65828944cc..94079798a0 100644 --- a/packages/cli/src/commands/lint.ts +++ b/packages/cli/src/commands/lint.ts @@ -960,6 +960,61 @@ export default class Lint extends Command { const { mod } = await bundleRequire({ filepath: flags.generator, external: BUNDLE_REQUIRE_EXTERNALS, + // [#16358] Under `--json` this call site had TWO channels, and only + // one of them was ours. esbuild's own logger writes straight to + // stderr from inside the bundle, BEFORE anything throws, so the + // `catch` below — which does produce a correct one-key `{error}` + // document on stdout — never gets the chance to suppress it. + // Measured on this entry at 7f96e1417e, both doors: + // + // os lint --eval --json --generator /tmp/os16358/nope.mjs + // exit 1 · stdout 143 B (well-formed `{error}`) · stderr 55 B + // `✘ [ERROR] Could not resolve "/tmp/os16358/nope.mjs"` + // os lint --eval --json --generator ./warns-but-loads.mjs + // exit 0 · stdout 3158 B (the eval report) · stderr 340 B + // `▲ [WARNING] The "typeof" operator will never evaluate to …` + // + // ⇒ the leak is NOT confined to the failure branch. `--json` is a + // machine face; anything on stderr is a human-channel emission the + // caller did not ask for, and both of those are an internal + // bundler's diagnostic rather than an ObjectStack refusal. + // + // ⛔ NOTHING is lost from the refusal. esbuild still THROWS its + // `BuildFailure` with `errors` populated — `logLevel` governs only + // whether esbuild PRINTS — and that message is already the tail of + // the `{error}` string the `catch` builds: + // `… Build failed with 1 error:\nerror: Could not resolve "…"`. + // Silencing the logger must not silence the refusal, and it does + // not; `test/lint-eval-generator-load-envelope.e2e.test.ts` pins + // both halves on the same run. + // + // ⚠️ WHAT THIS DOES SUPPRESS, stated rather than shipped quietly: + // under `--json`, an esbuild WARNING on a generator that loads fine + // (the second run above) reached stderr before and now reaches + // nothing — a warning is not thrown, so no `catch` carries it onto + // stdout. That is inside the defect, not beyond it: the property + // the sibling pin's comment states is about the `--json` face as a + // whole, not about its error branch. + // + // ⛔ The human face is NOT touched, and is not touched BY + // CONSTRUCTION rather than by restating a default: when `--json` is + // absent this passes no `esbuildOptions` at all, so bundle-require's + // own esbuild defaults apply exactly as before. A `logLevel: + // 'warning'` written out here would be me copying a default I would + // then own. + // + // ⛔ Scope is this ONE call site. The other `bundleRequire` callers + // in this package (`utils/config.ts`, `utils/scaffold-validate.ts`, + // `commands/serve.ts`) keep their diagnostics; a global esbuild + // silence would trade one under-read for a larger one. + // + // Why `logLevel` and not the two alternatives the card left open: + // esbuild's JS API exposes no logger hook to install (its whole + // logging surface is `logLevel` plus the `errors`/`warnings` arrays + // on the result), and capturing `process.stderr.write` around an + // await is a process-global monkey-patch that would swallow + // concurrent writes that are not esbuild's. + ...(flags.json ? { esbuildOptions: { logLevel: 'silent' as const } } : {}), }); const fn = (mod as any).default ?? (mod as any).generate; if (typeof fn !== 'function') { diff --git a/packages/cli/test/lint-eval-generator-load-envelope.e2e.test.ts b/packages/cli/test/lint-eval-generator-load-envelope.e2e.test.ts index 02e1ba0e4a..a0c93ac44c 100644 --- a/packages/cli/test/lint-eval-generator-load-envelope.e2e.test.ts +++ b/packages/cli/test/lint-eval-generator-load-envelope.e2e.test.ts @@ -42,6 +42,44 @@ * #14015 with its own review gate. `the key set is exactly the carriers` pins * that fence from this side, so a well-meaning widening goes red here. * + * ## [#16358] The second property this file pins: the human channel stays EMPTY + * + * `a coded failure at import surfaces BOTH carriers` below ends with + * `expect(run.stderr).toBe('')` under the comment *"A --json run leaks nothing + * to the human channel"*. That comment states a property of the `--json` face + * AS A WHOLE, and it was honest about the one door it drove — a module that + * EXISTS AND THROWS AT IMPORT, where esbuild bundles cleanly and prints + * nothing. The other doors into the same `catch` were uncovered, and they + * leaked: esbuild's own logger writes straight to stderr from inside + * `bundleRequire`, BEFORE anything throws, so the `catch` that builds the + * one-key `{error}` document never gets a chance to suppress it. + * + * Re-driven at `7f96e1417e` before the repair, `bin/run-dev.js`, `NO_COLOR=1`: + * + * os lint --eval --json --generator /tmp/os16358/nope.mjs + * exit 1 · stdout 143 B (well-formed `{error}`) · stderr 55 B + * `✘ [ERROR] Could not resolve "/tmp/os16358/nope.mjs"` + * os lint --eval --json --generator /tmp/os16358/warn.mjs (LOADS FINE) + * exit 0 · stdout 3158 B (the live eval report) · stderr 340 B + * `▲ [WARNING] The "typeof" operator will never evaluate to "null"` + * + * ⇒ same command, same face, three answers — with a green pin asserting the + * one that held. The repair passes `esbuildOptions: { logLevel: 'silent' }` + * to that ONE `bundleRequire` call and ONLY when `--json` is set; the two + * cases below drive the two uncovered doors, and each carries its own + * negative control so a fix that silenced the REFUSAL along with the logger + * goes red here rather than reading green: + * + * - unresolvable path — stderr empty AND the exit is still 1 with the + * well-formed one-key `{error}` naming the unresolved path; + * - warning-only — stderr empty on the `--json` face AND the SAME fixture + * still shows the warning on the HUMAN face. That second leg is what + * keeps the first from going vacuous: if a future esbuild stopped + * emitting `impossible-typeof`, an `stderr === ''` assertion alone would + * stay green while measuring nothing, and the human-face leg reddens + * instead of hiding it. It also pins the scope of the silence — ⛔ the + * repair must not reach the face that asked for human output. + * * ## Why no `dist/` sits on the measured path * * These run the CLI through `bin/run-dev.js`, the SOURCE entry — same CLI, run @@ -139,6 +177,21 @@ export default function () { return {}; } const NOT_A_FUNCTION = `export default { nope: true }; `; +/** + * [#16358] Bundles and LOADS successfully, and makes esbuild emit a warning + * while doing it (`impossible-typeof`). Nothing throws here, so no `catch` + * ever sees this diagnostic and nothing carries it onto stdout — before the + * repair it reached stderr on both faces, including the machine one. + * + * The generated stack is deliberately trivial: this fixture measures the + * CHANNEL, not the rubric. `mode: 'live'` in the payload is what proves the + * module was actually loaded and called. + */ +const WARNS_BUT_LOADS = `const probe = 1; +if (typeof probe === 'null') { throw new Error('unreachable'); } +export default function () { return { objects: [] }; } +`; + beforeAll(() => { dir = mkdtempSync(join(tmpdir(), 'os-lint-eval-envelope-')); }); @@ -211,6 +264,58 @@ describe('os lint --eval --json — nothing is minted', () => { }, 120_000); }); +describe('os lint --eval --json — the human channel stays empty on EVERY door [#16358]', () => { + it('the unresolvable path leaks nothing to stderr — and still refuses on stdout', async () => { + // The door the sibling pin above does NOT drive. esbuild never reaches + // module evaluation: it throws a `BuildFailure`, and its logger had + // already written `✘ [ERROR] Could not resolve "…"` to stderr from inside + // `bundleRequire` — 55 bytes measured at 7f96e1417e for this path length + // (the emission is `✘ [ERROR] Could not resolve ""`, so the byte + // count tracks the path; the card's 54 was a 20-character path). + const run = await runJson(join(dir, 'does-not-exist.mjs')); + + // The property the card exists for, in the same shape the existing pin + // uses one describe up. + expect(run.stderr).toBe(''); + + // ⛔ NEGATIVE CONTROL, on the SAME run: silencing esbuild's logger must + // not also silence the refusal. A repair that swallowed the throw would + // satisfy the line above and fail every line below. + const payload = payloadOf(run, 'unresolvable path — stderr pin'); + expect(run.code).toBe(1); + expect(Object.keys(payload)).toEqual(['error']); + expect(payload.error).toContain('Failed to load generator'); + expect(payload.error).toContain('Could not resolve'); + }, 120_000); + + it('a generator that only WARNS leaks nothing either — and the human face still shows it', async () => { + const file = generator('warns-but-loads', WARNS_BUT_LOADS); + + // The third door: nothing throws at all, so the `catch` is never entered + // and there is no error path to blame. 340 bytes of esbuild warning + // reached stderr here before the repair, on a run that exits 0. + const machine = await runJson(file); + expect(machine.stderr).toBe(''); + + const payload = payloadOf(machine, 'warning generator — machine face') as unknown as { + mode?: string; + error?: unknown; + }; + // The module really was loaded and called — otherwise `stderr === ''` + // above would be measuring a run that never bundled anything. + expect(payload.mode).toBe('live'); + expect(payload.error).toBeUndefined(); + + // ⛔ SCOPE CONTROL: the silence is the machine face's, not the command's. + // The same fixture on the HUMAN face must still show esbuild's warning — + // which also keeps the assertion above from going vacuous if a future + // esbuild stops emitting this diagnostic. + const human = await runLint(['--generator', file]); + expect(human.stderr).toContain('[WARNING]'); + expect(human.stderr).toContain('typeof'); + }, 120_000); +}); + describe('os lint --eval — the untouched controls', () => { it('the human path is unchanged: still exit 1, still no JSON document', async () => { const run = await runLint(['--generator', join(dir, 'does-not-exist.mjs')]); From 9d220b69c23366b104b7e00c3b2b1fb7cb1a4094 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 8 Sep 2026 10:37:54 +0000 Subject: [PATCH 2/2] chore(changeset): declare the `os lint --eval --json` stderr silence Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_015QE8qk46e5CHJxyQEUjbf8 --- .changeset/lint-eval-json-stderr-silence.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 .changeset/lint-eval-json-stderr-silence.md diff --git a/.changeset/lint-eval-json-stderr-silence.md b/.changeset/lint-eval-json-stderr-silence.md new file mode 100644 index 0000000000..3b5b6835cd --- /dev/null +++ b/.changeset/lint-eval-json-stderr-silence.md @@ -0,0 +1,19 @@ +--- +"@objectstack/cli": patch +--- + +`os lint --eval --json` no longer leaks esbuild's own diagnostics to stderr while loading a `--generator` module. + +A `--json` invocation is a machine face, and its stdout document was already well-formed — but the `--generator` load runs through `bundleRequire`, and esbuild's logger writes straight to stderr from inside that call, before anything throws. The `catch` that builds the one-key `{error}` document therefore never got a chance to suppress it, and a caller who asked for JSON got an internal bundler's diagnostic on the human channel alongside it. + +Measured on `bin/run-dev.js` with `NO_COLOR=1`, two runs that both leaked: + +- an unresolvable `--generator` path: exit 1, a well-formed `{error}` on stdout, and `✘ [ERROR] Could not resolve ""` on stderr; +- a generator that bundles and loads *successfully* but makes esbuild warn: exit 0, the full live eval report on stdout, and 340 bytes of `▲ [WARNING] …` on stderr. Nothing throws on this path at all, so no error handling was ever involved. + +The load now passes `esbuildOptions: { logLevel: 'silent' }`, scoped to that one call site and applied only when `--json` is set. + +- **The refusal is unchanged.** `logLevel` governs whether esbuild *prints*; it still throws its `BuildFailure` with `errors` populated, and that text already forms the tail of the `{error}` string on stdout. Both stdout documents above are byte-identical before and after. +- **The human face is untouched**, by construction rather than by restating a default: without `--json` no `esbuildOptions` is passed at all. `os lint --eval --generator ` still prints esbuild's line on stderr exactly as before. +- **What is suppressed beyond the leak itself:** under `--json`, an esbuild *warning* on a generator that loads fine now reaches nothing. A warning is not thrown, so no handler carries it onto stdout. This is inside the defect rather than beyond it — the machine face is not a place for human-channel output — but a `--json` consumer that was reading stderr for bundler warnings will no longer see them. +- The other `bundleRequire` callers in the CLI (`os serve` / `os dev`, config loading, scaffold validation) are not affected and keep their diagnostics.