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:
parent
b85919f2ec
commit
de8a7b5d51
16 changed files with 218 additions and 48 deletions
14
CHANGELOG.md
14
CHANGELOG.md
|
|
@ -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**
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
});
|
||||
}
|
||||
|
|
|
|||
118
tests/scanners/cli-pipe-integrity.test.mjs
Normal file
118
tests/scanners/cli-pipe-integrity.test.mjs
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
/**
|
||||
* CLI pipe integrity (session #48) — reported from org-ops, then MEASURED here.
|
||||
*
|
||||
* `process.exit()` terminates the process immediately. When stdout is a PIPE,
|
||||
* Node writes it ASYNCHRONOUSLY, so whatever is still sitting in the buffer is
|
||||
* discarded. Any machine consumer that pipes our output gets truncated,
|
||||
* unparseable JSON — and the failure looks like a corrupt file, not a cut-off.
|
||||
*
|
||||
* Measured before the fix, `scan-orchestrator.mjs` against this repo:
|
||||
* to file: 246 854 bytes (complete)
|
||||
* to pipe: 65 536 bytes (cut mid-string) — and 131 072 on another run,
|
||||
* i.e. the cut point is a nondeterministic flush race.
|
||||
*
|
||||
* The other CLIs in the class were green only because their payloads happen to
|
||||
* fit inside the pipe buffer today: campaign-cli (926 B), campaign-export-cli,
|
||||
* campaign-write-cli, knowledge-refresh-cli (1 630 B), drift-cli and fix-cli
|
||||
* all called `process.exit()` on the SUCCESS path after writing stdout. Size is
|
||||
* not correctness, so the class test below holds the whole directory, not just
|
||||
* the one CLI where the bug was observable.
|
||||
*
|
||||
* Fix pattern (same one llm-security's orchestrator already uses): set
|
||||
* `process.exitCode` and return, letting Node exit naturally once stdout drains.
|
||||
*/
|
||||
|
||||
import { test } from 'node:test';
|
||||
import { strict as assert } from 'node:assert';
|
||||
import { execFile } from 'node:child_process';
|
||||
import { readFileSync, readdirSync, mkdtempSync, rmSync } from 'node:fs';
|
||||
import { resolve, dirname, join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = resolve(__dirname, '..', '..');
|
||||
const SCANNERS_DIR = resolve(ROOT, 'scanners');
|
||||
|
||||
/** Run a CLI, capturing stdout through a pipe. Exit codes 0-2 are normal. */
|
||||
function runPiped(args, maxBuffer = 64 * 1024 * 1024) {
|
||||
return new Promise((res, rej) => {
|
||||
execFile(process.execPath, args, { cwd: ROOT, maxBuffer }, (err, stdout) => {
|
||||
// Non-zero exit is expected (verdict codes); only spawn failures are real.
|
||||
if (err && err.code === undefined) return rej(err);
|
||||
res(stdout);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Two separate scans differ in `timestamp` and in every `duration_ms` — and a
|
||||
* duration changing digit width shifts the byte count. Comparing raw lengths
|
||||
* would be measuring the clock, not the flush, so normalise the volatile fields
|
||||
* and compare the payload structurally instead.
|
||||
*/
|
||||
function stable(json) {
|
||||
const walk = (node) => {
|
||||
if (Array.isArray(node)) return node.map(walk);
|
||||
if (node && typeof node === 'object') {
|
||||
return Object.fromEntries(
|
||||
Object.entries(node).map(([k, v]) =>
|
||||
k === 'duration_ms' || k === 'durationMs' || k === 'timestamp' || k === 'generatedAt'
|
||||
? [k, '<volatile>']
|
||||
: [k, walk(v)],
|
||||
),
|
||||
);
|
||||
}
|
||||
return node;
|
||||
};
|
||||
return JSON.stringify(walk(JSON.parse(json)));
|
||||
}
|
||||
|
||||
test('scan-orchestrator emits a complete envelope to a pipe, not just to a file', async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'ca-pipe-'));
|
||||
try {
|
||||
const outFile = join(dir, 'envelope.json');
|
||||
await runPiped([resolve(SCANNERS_DIR, 'scan-orchestrator.mjs'), ROOT, '--output-file', outFile]);
|
||||
const viaFile = readFileSync(outFile, 'utf-8');
|
||||
const viaPipe = await runPiped([resolve(SCANNERS_DIR, 'scan-orchestrator.mjs'), ROOT]);
|
||||
|
||||
// The payload must be large enough that a lost buffer WOULD show up;
|
||||
// otherwise this test could pass without exercising the defect at all.
|
||||
assert.ok(
|
||||
viaFile.length > 128 * 1024,
|
||||
`guard: payload only ${viaFile.length} bytes — too small to detect truncation`,
|
||||
);
|
||||
// Parse first: a truncated envelope fails here, with the clearest signal.
|
||||
assert.doesNotThrow(
|
||||
() => JSON.parse(viaPipe),
|
||||
'piped stdout is not parseable JSON — process.exit() dropped the buffer',
|
||||
);
|
||||
assert.equal(
|
||||
stable(viaPipe),
|
||||
stable(viaFile),
|
||||
'piped stdout does not carry the same envelope the file does',
|
||||
);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('no scanner CLI terminates with process.exit() — the whole class, not just the one that showed it', () => {
|
||||
const offenders = [];
|
||||
for (const file of readdirSync(SCANNERS_DIR).filter((f) => f.endsWith('.mjs'))) {
|
||||
const text = readFileSync(resolve(SCANNERS_DIR, file), 'utf-8');
|
||||
text.split('\n').forEach((line, i) => {
|
||||
// Prose may legitimately name the banned call — this very rule is
|
||||
// documented in comments right above the code it replaced. Assert on the
|
||||
// plumbing, not the prose.
|
||||
const code = line.replace(/\/\/.*$/, '').replace(/^\s*\*.*$/, '');
|
||||
if (/(?<!\.)\bprocess\.exit\s*\(/.test(code)) offenders.push(`${file}:${i + 1}`);
|
||||
});
|
||||
}
|
||||
assert.deepEqual(
|
||||
offenders,
|
||||
[],
|
||||
`process.exit() discards unflushed stdout when stdout is a pipe; ` +
|
||||
`use "process.exitCode = N" and return instead. Offenders: ${offenders.join(', ')}`,
|
||||
);
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue