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

@ -32,9 +32,15 @@ import {
defaultLedgerPath,
} from './lib/campaign-ledger.mjs';
/**
* Usage error. Throws rather than calling process.exit(): exit() discards
* unflushed stdout when stdout is a pipe. The top-level catch prints the same
* `Error: ` text and sets the same exit code 3, so callers see no difference.
*/
class CliUsageError extends Error {}
function fail(message) {
process.stderr.write(`Error: ${message}\n`);
process.exit(3);
throw new CliUsageError(message);
}
async function main() {
@ -99,14 +105,15 @@ async function main() {
if (outputFile) await writeFile(outputFile, json, 'utf-8');
else process.stdout.write(json + '\n');
process.exit(exitCode);
process.exitCode = exitCode;
}
const isDirectRun =
process.argv[1] && resolve(process.argv[1]) === resolve(new URL(import.meta.url).pathname);
if (isDirectRun) {
main().catch((err) => {
process.stderr.write(`Fatal: ${err.message}\n`);
process.exit(3);
const prefix = err instanceof CliUsageError ? 'Error' : 'Fatal';
process.stderr.write(`${prefix}: ${err.message}\n`);
process.exitCode = 3;
});
}

View file

@ -44,9 +44,15 @@ import { planExportPath, buildPlanExportDocument } from './lib/campaign-export.m
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
/**
* Usage error. Throws rather than calling process.exit(): exit() discards
* unflushed stdout when stdout is a pipe. The top-level catch prints the same
* `Error: ` text and sets the same exit code 3, so callers see no difference.
*/
class CliUsageError extends Error {}
function fail(message) {
process.stderr.write(`Error: ${message}\n`);
process.exit(3);
throw new CliUsageError(message);
}
/** Default session store: next to the ledger, OUTSIDE the plugin dir. */
@ -74,7 +80,7 @@ async function emit(payload, outputFile, exitCode) {
const json = JSON.stringify(payload, null, 2);
if (outputFile) await writeFile(outputFile, json, 'utf-8');
else process.stdout.write(json + '\n');
process.exit(exitCode);
process.exitCode = exitCode;
}
async function main() {
@ -159,7 +165,8 @@ const isDirectRun =
process.argv[1] && resolve(process.argv[1]) === resolve(new URL(import.meta.url).pathname);
if (isDirectRun) {
main().catch((err) => {
process.stderr.write(`Fatal: ${err.message}\n`);
process.exit(3);
const prefix = err instanceof CliUsageError ? 'Error' : 'Fatal';
process.stderr.write(`${prefix}: ${err.message}\n`);
process.exitCode = 3;
});
}

View file

@ -60,9 +60,15 @@ import { buildManifest, splitManifestByOwnership } from './manifest.mjs';
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
/**
* Usage error. Throws rather than calling process.exit(): exit() discards
* unflushed stdout when stdout is a pipe. The top-level catch prints the same
* `Error: ` text and sets the same exit code 3, so callers see no difference.
*/
class CliUsageError extends Error {}
function fail(message) {
process.stderr.write(`Error: ${message}\n`);
process.exit(3);
throw new CliUsageError(message);
}
/** Parse argv into a subcommand, positional args, and the flag map. */
@ -96,7 +102,7 @@ async function emit(payload, outputFile, exitCode) {
const json = JSON.stringify(payload, null, 2);
if (outputFile) await writeFile(outputFile, json, 'utf-8');
else process.stdout.write(json + '\n');
process.exit(exitCode);
process.exitCode = exitCode;
}
async function main() {
@ -246,7 +252,8 @@ const isDirectRun =
process.argv[1] && resolve(process.argv[1]) === resolve(new URL(import.meta.url).pathname);
if (isDirectRun) {
main().catch((err) => {
process.stderr.write(`Fatal: ${err.message}\n`);
process.exit(3);
const prefix = err instanceof CliUsageError ? 'Error' : 'Fatal';
process.stderr.write(`${prefix}: ${err.message}\n`);
process.exitCode = 3;
});
}

View file

@ -90,7 +90,7 @@ async function main() {
process.stderr.write('\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n');
}
}
process.exit(0);
return;
}
// --- Save mode ---
@ -109,7 +109,7 @@ async function main() {
process.stderr.write(`\nBaseline "${result.name}" saved to ${result.path}\n`);
process.stderr.write(`Findings: ${envelope.aggregate.total_findings}\n`);
}
process.exit(0);
return;
}
// --- Drift mode (default) ---
@ -128,7 +128,8 @@ async function main() {
process.stderr.write(`Baseline "${baselineName}" not found.\n`);
process.stderr.write(`Save one first: node drift-cli.mjs <path> --save\n`);
}
process.exit(1);
process.exitCode = 1;
return;
}
// M-BUG-27: a baseline carries the path it was saved from, but nothing ever
@ -194,8 +195,7 @@ async function main() {
}
// Exit code: 0=stable/improving, 1=degrading
if (diff.summary.trend === 'degrading') process.exit(1);
process.exit(0);
process.exitCode = diff.summary.trend === 'degrading' ? 1 : 0;
}
// Only run CLI if invoked directly
@ -203,6 +203,6 @@ const isDirectRun = process.argv[1] && resolve(process.argv[1]) === resolve(new
if (isDirectRun) {
main().catch(err => {
process.stderr.write(`Fatal: ${err.message}\n`);
process.exit(3);
process.exitCode = 3;
});
}

View file

@ -135,7 +135,7 @@ async function main() {
process.stdout.write(JSON.stringify(output, null, 2) + '\n');
}
if (outputFile) await writeFile(outputFile, JSON.stringify(output, null, 2) + '\n', 'utf-8');
process.exit(0);
return;
}
if (apply) {
@ -249,6 +249,6 @@ const isDirectRun = process.argv[1] && resolve(process.argv[1]) === resolve(new
if (isDirectRun) {
main().catch(err => {
process.stderr.write(`Fatal: ${err.message}\n`);
process.exit(3);
process.exitCode = 3;
});
}

View file

@ -30,9 +30,15 @@ import { assessFreshness, STALE_AFTER_DAYS_DEFAULT } from './lib/knowledge-refre
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
/**
* Usage error. Throws rather than calling process.exit(): exit() discards
* unflushed stdout when stdout is a pipe. The top-level catch prints the same
* `Error: ` text and sets the same exit code 3, so callers see no difference.
*/
class CliUsageError extends Error {}
function fail(message) {
process.stderr.write(`Error: ${message}\n`);
process.exit(3);
throw new CliUsageError(message);
}
async function main() {
@ -90,14 +96,15 @@ async function main() {
if (outputFile) await writeFile(outputFile, json, 'utf-8');
else process.stdout.write(json + '\n');
process.exit(assessment.counts.stale > 0 ? 1 : 0);
process.exitCode = assessment.counts.stale > 0 ? 1 : 0;
}
const isDirectRun =
process.argv[1] && resolve(process.argv[1]) === resolve(new URL(import.meta.url).pathname);
if (isDirectRun) {
main().catch((err) => {
process.stderr.write(`Fatal: ${err.message}\n`);
process.exit(3);
const prefix = err instanceof CliUsageError ? 'Error' : 'Fatal';
process.stderr.write(`${prefix}: ${err.message}\n`);
process.exitCode = 3;
});
}

View file

@ -259,11 +259,13 @@ async function main() {
const s = await stat(absPath);
if (!s.isDirectory()) {
process.stderr.write(`Error: ${absPath} is not a directory\n`);
process.exit(3);
process.exitCode = 3;
return;
}
} catch {
process.stderr.write(`Error: path does not exist: ${absPath}\n`);
process.exit(3);
process.exitCode = 3;
return;
}
const start = Date.now();
@ -297,6 +299,6 @@ const isDirectRun = process.argv[1] && resolve(process.argv[1]) === resolve(new
if (isDirectRun) {
main().catch(err => {
process.stderr.write(`Fatal: ${err.message}\n`);
process.exit(3);
process.exitCode = 3;
});
}

View file

@ -68,11 +68,13 @@ async function main() {
const s = await stat(absPath);
if (!s.isDirectory()) {
process.stderr.write(`Error: ${absPath} is not a directory\n`);
process.exit(3);
process.exitCode = 3;
return;
}
} catch {
process.stderr.write(`Error: path does not exist: ${absPath}\n`);
process.exit(3);
process.exitCode = 3;
return;
}
// Load the register once; tolerate its absence (deterministic half still runs).
@ -227,6 +229,6 @@ const isDirectRun = process.argv[1] && resolve(process.argv[1]) === resolve(new
if (isDirectRun) {
main().catch((err) => {
process.stderr.write(`Fatal: ${err.message}\n`);
process.exit(3);
process.exitCode = 3;
});
}

View file

@ -813,6 +813,6 @@ const isDirectRun = process.argv[1] && resolve(process.argv[1]) === resolve(new
if (isDirectRun) {
main().catch(err => {
process.stderr.write(`Fatal: ${err.message}\n`);
process.exit(3);
process.exitCode = 3;
});
}

View file

@ -133,6 +133,6 @@ const isDirectRun = process.argv[1] && resolve(process.argv[1]) === resolve(new
if (isDirectRun) {
main().catch(err => {
process.stderr.write(`Fatal: ${err.message}\n`);
process.exit(1);
process.exitCode = 1;
});
}

View file

@ -299,10 +299,12 @@ async function main() {
process.stderr.write(`Risk: ${agg.risk_score}/100 (${agg.risk_band})\n`);
process.stderr.write(`Verdict: ${agg.verdict}\n`);
// Exit code
if (agg.verdict === 'FAIL') process.exit(2);
if (agg.verdict === 'WARNING') process.exit(1);
process.exit(0);
// Exit code. Set, never process.exit(): stdout is written ASYNCHRONOUSLY when
// it is a pipe, and process.exit() discards whatever is still buffered. Piping
// this envelope used to yield truncated, unparseable JSON (246 854 bytes to a
// file vs 65 536 to a pipe) — a corruption that looks like a bad file, not a
// cut-off. Letting Node exit naturally drains stdout first.
process.exitCode = agg.verdict === 'FAIL' ? 2 : agg.verdict === 'WARNING' ? 1 : 0;
}
// Only run CLI if invoked directly
@ -310,6 +312,6 @@ const isDirectRun = process.argv[1] && resolve(process.argv[1]) === resolve(new
if (isDirectRun) {
main().catch(err => {
process.stderr.write(`Fatal: ${err.message}\n`);
process.exit(3);
process.exitCode = 3;
});
}

View file

@ -350,6 +350,6 @@ const isDirectRun = process.argv[1] && resolve(process.argv[1]) === resolve(file
if (isDirectRun) {
main().catch(err => {
process.stderr.write(`Fatal: ${err.message}\n`);
process.exit(3);
process.exitCode = 3;
});
}

View file

@ -78,11 +78,13 @@ async function main() {
const s = await stat(absPath);
if (!s.isDirectory()) {
process.stderr.write(`Error: ${absPath} is not a directory\n`);
process.exit(3);
process.exitCode = 3;
return;
}
} catch {
process.stderr.write(`Error: path does not exist: ${absPath}\n`);
process.exit(3);
process.exitCode = 3;
return;
}
resetCounter();
@ -141,6 +143,6 @@ const isDirectRun = process.argv[1] && resolve(process.argv[1]) === resolve(new
if (isDirectRun) {
main().catch(err => {
process.stderr.write(`Fatal: ${err.message}\n`);
process.exit(3);
process.exitCode = 3;
});
}

View file

@ -41,11 +41,13 @@ async function main() {
const s = await stat(absPath);
if (!s.isDirectory()) {
process.stderr.write(`Error: ${absPath} is not a directory\n`);
process.exit(3);
process.exitCode = 3;
return;
}
} catch {
process.stderr.write(`Error: path does not exist: ${absPath}\n`);
process.exit(3);
process.exitCode = 3;
return;
}
const result = await readActiveConfig(absPath, { verbose, suggestDisables });
@ -64,6 +66,6 @@ const isDirectRun = process.argv[1] && resolve(process.argv[1]) === resolve(new
if (isDirectRun) {
main().catch(err => {
process.stderr.write(`Fatal: ${err.message}\n`);
process.exit(3);
process.exitCode = 3;
});
}