fix(scanners): stop discarding our own stdout when it is a pipe

process.exit() terminates immediately, but Node writes stdout asynchronously
when stdout is a pipe — everything still buffered is dropped. scan-orchestrator
measured 246 854 bytes to a file against 65 536 to a pipe (131 072 on another
run; the cut point is a flush race), so every machine consumer that pipes the
envelope got truncated, unparseable JSON. The failure reads like a corrupt file,
not like a cut-off, which is what made it survive this long. Reported by
org-ops, whose census pipes our output.

Closes the class rather than the one CLI where it was visible. campaign-cli,
campaign-export-cli, campaign-write-cli, knowledge-refresh-cli, drift-cli and
fix-cli all exited the same way on their success paths and were green only
because their payloads fit the pipe buffer today; size is not correctness. All
38 sites across 14 files now set process.exitCode and return, which is the
pattern self-audit.mjs already used.

Two contracts needed care rather than substitution: fail() is a never-returns
guard at ~25 call sites, so it throws a CliUsageError the top-level catch
renders with the identical "Error: " prefix and exit code 3; the path guards
needed an explicit return so main() stops instead of running on. Exit codes and
stderr text are unchanged, and the frozen v5.0.0 snapshots are untouched.

The class sweep is landed as a test, not as fourteen edits — it caught one site
this commit had missed. Suite 1443/0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B8sS1DuDV6bUJcyumLwbvj
This commit is contained in:
Kjell Tore Guttormsen 2026-07-31 21:40:15 +02:00
commit de8a7b5d51
16 changed files with 218 additions and 48 deletions

View file

@ -8,6 +8,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Fixed
- **`M-BUG-39` — every scanner CLI could truncate its own output when piped.** `process.exit()`
terminates immediately, but Node writes stdout **asynchronously** when stdout is a pipe, so whatever
is still buffered is discarded. `scan-orchestrator.mjs` measured **246 854 bytes to a file vs
65 536 to a pipe** (and 131 072 on another run — the cut point is a nondeterministic flush race),
handing any machine consumer truncated, unparseable JSON that reads like a corrupt file rather than
a cut-off. Reported by `org-ops`, whose census pipes our envelope. The whole class is closed, not
just the CLI where it was observable: `campaign-cli`, `campaign-export-cli`, `campaign-write-cli`,
`knowledge-refresh-cli`, `drift-cli` and `fix-cli` all exited the same way on their success paths
and were green only because their payloads happen to fit the pipe buffer today. All 38 sites across
14 files now set `process.exitCode` and return, letting Node exit once stdout drains — the pattern
`self-audit.mjs` and llm-security's orchestrator already used. `fail()` throws instead of exiting so
it keeps its never-returns contract; exit codes and `Error:`/`Fatal:` stderr text are unchanged.
Guarded by `tests/scanners/cli-pipe-integrity.test.mjs`: one behavioural test that pipes a >128 KB
envelope, one class sweep over `scanners/*.mjs`.
- **`M-BUG-36``/config-audit drift --list` showed nothing.** `drift-cli.mjs` accepted
`--output-file` but list mode ignored it, and the listing itself goes to **stderr**, which
`commands/drift.md` discards with `2>/dev/null` (ux-rules rule 2). The command received **0 bytes**