arethetypeswrong / arethetypeswrong/arethetypeswrong.github.io

`--format json` still truncated through a pipe when the exit code is non-zero (#184)

Aperta
#279 0 commenti 0 reazioni 0 assegnatari Vedi su GitHub
Lingua principale
TypeScript
Stelle
1.6k
Fork
65
Metriche di merge delle PR
Nessuna PR unita negli ultimi 30g

Descrizione

## Summary

`--format json` output is still truncated when stdout is a pipe, on `@arethetypeswrong/cli` 0.18.4. This is #184, which was closed but remains reproducible.

The truncation is governed by the **exit code, not the payload size**: an identically-sized payload from a *passing* package traverses the same pipe intact. Only a run that reports problems (exit code ≠ 0) truncates.

That detail matters for why this was believed fixed: the streaming `write()` helper appears to have been introduced to address #184 — its comment says as much — but it doesn't achieve the flush it was written for, and verifying it against a clean package would have shown it working.

## Reproduction

Two packages, 8 entry points each, near-identical JSON payloads. The only difference: one has findings.

- passing: each entry point is valid ESM
- failing: same shape, but each entry point's `.js` body is `module.exports.value = 1;` (CJS syntax in an ESM package)

```bash
attw pkg.tgz --profile esm-only --format json | cat # through a pipe
attw pkg.tgz --profile esm-only --format json > out # to a file
```

| | exit | to file | through pipe | |
| ------- | ---- | --------- | ------------- | ----------- |
| PASSING | 0 | 152,795 B | 152,795 B | parses |
| FAILING | 1 | 157,642 B | **65,536 B** | parse fails |

65,536 is exactly the pipe buffer capacity (the reporter of #184 saw 98,304 — the figure varies by platform, the mechanism doesn't).

## Root cause

`write()` resolves on the **source** `Readable`'s `end` event, not on the destination draining:

```js
export async function write(data, out) {
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); // <- return value ignored; may merely buffer
});
stream.on("end", () => {
resolve(); // <- resolves when the SOURCE is exhausted
});
out.on("error", (err) => reject(err));
});
}
```

`out.write()` on a pipe is asynchronous — it returns `false` once past the high-water mark, leaving the data queued. `end` fires when the `Readable` has been fully consumed, which says nothing about whether **stdout** has flushed. So `write()` resolves with bytes still pending. Functionally this is equivalent to a bare `out.write(data)`; the stream indirection doesn't add a flush guarantee.

Then, in the JSON branch of `index.ts`:

```js
await write(JSON.stringify(result, undefined, 2), out);
// ...
const exitCode = getExitCode(analysis, opts);
if (exitCode) {
process.exit(exitCode); // <- discards the still-buffered tail
}
return;
```

`process.exit()` terminates without flushing pending async writes.

This accounts for every observed behavior:

- **Non-zero exit** → `process.exit()` → truncated.
- **Zero exit** → falls through to `return` → Node drains stdout on natural exit → intact.
- **Non-JSON formats** → set `process.exitCode` rather than calling `process.exit()` → also drain. Which is why only `--format json` is affected.

## Impact

Any consumer reading `attw --format json` through a pipe gets invalid JSON precisely for the packages that *have* findings and more than a handful of entry points. This includes `child_process.spawnSync`/`execSync`, which capture output via pipes — so it hits programmatic consumers, not just interactive `| jq`.

The failure mode is unkind: it strikes only when there are problems **and** the package is large, so fixtures that are small or clean will pass while real-world usage breaks.

## Suggested fix

Either resolves it; they're complementary.

**1. Don't `process.exit()` on the JSON path** — mirror what the human-readable path already does:

```js
process.exitCode = exitCode;
return;
```

Smallest change, consistent with the existing branch, and sufficient on its own.

**2. Make `write()` actually await the flush** — repairs the primitive for every caller:

```js
export function write(data, out) {
return new Promise((resolve, reject) => {
out.write(data + "\n", (err) => (err ? reject(err) : resolve()));
});
}
```

`stream.write(chunk, cb)` invokes `cb` once the chunk has been flushed, so awaiting it before exiting is sufficient.

## Environment

- `@arethetypeswrong/cli` 0.18.4
- Node v24.14.1
- macOS (Darwin 25.5.0)

Guida per i contributori

Nessuna guida per i contributori indicizzata per questo repository

Direzione di ricerca

Inizia nel ramo JSON di index.ts e individua l’helper write() descritto nell’issue. Esegui i comandi forniti per il package che fallisce attraverso una pipe e in un file, quindi verifica se il completamento dell’output viene atteso prima dell’uscita con valore diverso da zero. Il lavoro è completato quando il caso che fallisce produce JSON completo e analizzabile attraverso una pipe senza causare regressioni nel caso che passa.

Scritto dal modello di indicizzazione a partire dal testo della issue.

Valutazione

Stack tecnologico
node.js, typescript
Ambito
cli
Tipo di issue
Bug
Difficoltà
3/5
Tempo stimato
1-2 giorni
Stato di attività
Tranquilla
Chiarezza
Specificata chiaramente
Idoneità per principianti
72/100

Ricevi le nuove issue nella tua casella

Un breve riepilogo di issue GitHub adatte ai principianti.