diff --git a/.changeset/fix-json-pipe-truncation.md b/.changeset/fix-json-pipe-truncation.md new file mode 100644 index 0000000..fbf2f6c --- /dev/null +++ b/.changeset/fix-json-pipe-truncation.md @@ -0,0 +1,5 @@ +--- +"@arethetypeswrong/cli": patch +--- + +Fix `--format json` output being truncated when piped and the exit code is non-zero. `write` now waits for stdout to actually flush, and the JSON branch sets `process.exitCode` instead of calling `process.exit`, which could terminate before a buffered pipe drained. diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index d62212e..d5a00ce 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -236,10 +236,9 @@ particularly ESM-related module resolution issues.`, await unlink(deleteTgz); } - const exitCode = getExitCode(analysis, opts); - if (exitCode) { - process.exit(exitCode); - } + // Set the exit code and return rather than process.exit(), which would + // terminate before a buffered stdout pipe finishes flushing (see #279). + process.exitCode = getExitCode(analysis, opts); return; } diff --git a/packages/cli/src/write.ts b/packages/cli/src/write.ts index 9e00a8f..9e233b0 100644 --- a/packages/cli/src/write.ts +++ b/packages/cli/src/write.ts @@ -1,27 +1,13 @@ -import { Readable, Writable } from "node:stream"; +import type { Writable } from "node:stream"; -// JSON output is often longer than 64 kb, so we need to use streams to write it to stdout -// in order to avoid truncation when piping to other commands. +// JSON output is often longer than 64 kb. Wait for the write to actually flush +// (the callback fires once the chunk has been handled, not merely buffered) so +// the output isn't truncated when stdout is a pipe. export async function write(data: string, out: Writable): Promise { return new Promise((resolve, reject) => { - const stream = new Readable({ - read() { - this.push(data); - this.push("\n"); - this.push(null); - }, - }); - - stream.on("data", (chunk) => { - out.write(chunk); - }); - - stream.on("end", () => { - resolve(); - }); - - out.on("error", (err) => { - reject(err); + out.write(data + "\n", (err) => { + if (err) reject(err); + else resolve(); }); }); } diff --git a/packages/cli/test/write.test.ts b/packages/cli/test/write.test.ts new file mode 100644 index 0000000..eb41533 --- /dev/null +++ b/packages/cli/test/write.test.ts @@ -0,0 +1,37 @@ +import assert from "node:assert"; +import path from "node:path"; +import { Writable } from "node:stream"; +import { test } from "node:test"; +import { fileURLToPath } from "node:url"; + +const directoryPath = path.dirname(fileURLToPath(import.meta.url)); + +// Regression test for #279: `write` must resolve only once the writable has +// actually handled the data, not merely when the source string is exhausted — +// otherwise a buffered stdout pipe can be truncated on exit. +test("write resolves only after the writable has flushed the data", async () => { + const { write } = await import(path.resolve(directoryPath, "../../dist/write.js")); + + const chunks: string[] = []; + let release: (() => void) | undefined; + const out = new Writable({ + write(chunk, _encoding, callback) { + chunks.push(chunk.toString()); + // Defer completion to simulate a pipe that hasn't drained yet. + release = () => callback(); + }, + }); + + let resolved = false; + const promise = write("hello", out).then(() => { + resolved = true; + }); + + await new Promise((r) => setImmediate(r)); + assert.equal(resolved, false, "write() resolved before the writable flushed"); + + release!(); + await promise; + assert.equal(resolved, true); + assert.equal(chunks.join(""), "hello\n"); +});