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
110 lines
3.9 KiB
JavaScript
110 lines
3.9 KiB
JavaScript
#!/usr/bin/env node
|
|
|
|
/**
|
|
* knowledge-refresh CLI — feeds the v5.7 `/config-audit knowledge-refresh` command
|
|
* (Chunk 3: the "living" half of the living knowledge base).
|
|
*
|
|
* This is the DETERMINISTIC half of the hybrid motor: it loads the best-practices
|
|
* register and classifies every entry as `fresh` or `stale` by the age of its
|
|
* `source.verified` stamp (via the pure `assessFreshness` core). It is READ-ONLY —
|
|
* it NEVER writes the register and NEVER touches the network. Candidate discovery
|
|
* (polling the CC changelog + Anthropic blog) and the human-approved writes live in
|
|
* the command layer (Verifiseringsplikt). `--dry-run` is implicit and the only mode;
|
|
* the flag is accepted for explicitness and echoed back.
|
|
*
|
|
* Naming: `-cli` suffix → NOT an orchestrated scanner (the scan-orchestrator only
|
|
* loads scanner modules), so the scanner count is unchanged and the snapshot suite
|
|
* stays byte-stable.
|
|
*
|
|
* Usage:
|
|
* node knowledge-refresh-cli.mjs [--output-file <path>] [--stale-after <N>]
|
|
* [--reference-date <YYYY-MM-DD>] [--dry-run]
|
|
*
|
|
* Exit codes: 0 = every entry fresh, 1 = one or more stale (advisory), 3 = error.
|
|
*/
|
|
|
|
import { resolve } from 'node:path';
|
|
import { writeFile } from 'node:fs/promises';
|
|
import { loadRegister, REGISTER_PATH } from './lib/best-practices-register.mjs';
|
|
import { assessFreshness, STALE_AFTER_DAYS_DEFAULT } from './lib/knowledge-refresh.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) {
|
|
throw new CliUsageError(message);
|
|
}
|
|
|
|
async function main() {
|
|
const args = process.argv.slice(2);
|
|
let outputFile = null;
|
|
let staleAfterDays = STALE_AFTER_DAYS_DEFAULT;
|
|
let referenceDate = null; // null → today
|
|
let dryRun = false;
|
|
|
|
for (let i = 0; i < args.length; i++) {
|
|
const a = args[i];
|
|
if (a === '--dry-run') dryRun = true;
|
|
else if (a === '--output-file' && args[i + 1]) outputFile = args[++i];
|
|
else if (a === '--stale-after' && args[i + 1] !== undefined) {
|
|
const n = Number.parseInt(args[++i], 10);
|
|
if (!Number.isInteger(n) || n < 0) fail('--stale-after must be a non-negative integer (days)');
|
|
staleAfterDays = n;
|
|
} else if (a === '--reference-date' && args[i + 1]) {
|
|
referenceDate = args[++i];
|
|
if (!DATE_RE.test(referenceDate)) fail('--reference-date must be YYYY-MM-DD');
|
|
}
|
|
}
|
|
|
|
// The clock is read here ONLY — the core takes an injected date and stays pure.
|
|
const ref = referenceDate || new Date();
|
|
|
|
let register;
|
|
try {
|
|
register = loadRegister();
|
|
} catch (err) {
|
|
fail(`could not load register at ${REGISTER_PATH}: ${err.message}`);
|
|
}
|
|
|
|
let assessment;
|
|
try {
|
|
assessment = assessFreshness(register, { referenceDate: ref, staleAfterDays });
|
|
} catch (err) {
|
|
fail(err.message);
|
|
}
|
|
|
|
const payload = {
|
|
status: 'ok',
|
|
registerPath: REGISTER_PATH,
|
|
version: register.version,
|
|
dryRun: true, // this CLI never writes; the flag is informational
|
|
requestedDryRun: dryRun,
|
|
referenceDate: assessment.referenceDate,
|
|
staleAfterDays: assessment.staleAfterDays,
|
|
counts: assessment.counts,
|
|
stale: assessment.stale,
|
|
fresh: assessment.fresh,
|
|
};
|
|
|
|
const json = JSON.stringify(payload, null, 2);
|
|
if (outputFile) await writeFile(outputFile, json, 'utf-8');
|
|
else process.stdout.write(json + '\n');
|
|
|
|
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) => {
|
|
const prefix = err instanceof CliUsageError ? 'Error' : 'Fatal';
|
|
process.stderr.write(`${prefix}: ${err.message}\n`);
|
|
process.exitCode = 3;
|
|
});
|
|
}
|