arethetypeswrong / arethetypeswrong/arethetypeswrong.github.io
`--format json` still truncated through a pipe when the exit code is non-zero (#184)
- Lenguaje dominante
- TypeScript
- Estrellas
- 1.6k
- Forks
- 65
- Métricas de merge de PR
- Sin PR fusionados en 30 d
Descripción
## 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)
Guía de contribución
No hay ninguna guía de contribución indexada para este repositorio
Línea de trabajo
Comienza en la rama JSON de index.ts y localiza el helper write() descrito en el issue. Ejecuta los comandos proporcionados para el paquete que falla a través de una pipe y hacia un archivo, y después comprueba si se espera a que finalice la salida antes de la salida con un valor distinto de cero. Está terminado cuando el caso que falla produce JSON completo y analizable a través de una pipe sin provocar regresiones en el caso que pasa.
Escrito por el modelo de indexación a partir del texto del issue.
Evaluación
- Stack tecnológico
- node.js, typescript
- Área
- cli
- Tipo de issue
- Error
- Dificultad
- 3/5
- Tiempo estimado
- 1-2 días
- Estado de actividad
- Tranquilo
- Claridad
- Bien especificado
- Aptitud para principiantes
- 72/100