Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .changeset/lint-eval-json-stderr-silence.md
Original file line number Diff line number Diff line change
@@ -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 "<path>"` 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 <bad>` 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.
55 changes: 55 additions & 0 deletions packages/cli/src/commands/lint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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') {
Expand Down
105 changes: 105 additions & 0 deletions packages/cli/test/lint-eval-generator-load-envelope.e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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-'));
});
Expand Down Expand Up @@ -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 "<path>"`, 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')]);
Expand Down
Loading