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
317 lines
13 KiB
JavaScript
317 lines
13 KiB
JavaScript
#!/usr/bin/env node
|
|
|
|
/**
|
|
* Config-Audit Scan Orchestrator
|
|
* Runs all registered scanners sequentially, collects findings, outputs JSON envelope.
|
|
* Usage: node scan-orchestrator.mjs <target-path> [--output-file path] [--save-baseline] [--baseline path]
|
|
* Zero external dependencies.
|
|
*/
|
|
|
|
import { resolve, sep } from 'node:path';
|
|
import { readFile, writeFile } from 'node:fs/promises';
|
|
import { resetCounter } from './lib/output.mjs';
|
|
import { envelope } from './lib/output.mjs';
|
|
import { discoverConfigFiles, discoverConfigFilesMulti, discoverFullMachinePaths } from './lib/file-discovery.mjs';
|
|
import { loadSuppressions, applySuppressions, formatSuppressionSummary } from './lib/suppression.mjs';
|
|
import { humanizeEnvelope } from './lib/humanizer.mjs';
|
|
import { resolveContextWindow } from './lib/context-window.mjs';
|
|
import { resolveActiveModel } from './lib/active-model.mjs';
|
|
|
|
// Scanner registry — import order determines execution order
|
|
import { scan as scanClaudeMd } from './claude-md-linter.mjs';
|
|
import { scan as scanSettings } from './settings-validator.mjs';
|
|
import { scan as scanHooks } from './hook-validator.mjs';
|
|
import { scan as scanRules } from './rules-validator.mjs';
|
|
import { scan as scanMcp } from './mcp-config-validator.mjs';
|
|
import { scan as scanImports } from './import-resolver.mjs';
|
|
import { scan as scanConflicts } from './conflict-detector.mjs';
|
|
import { scan as scanGap } from './feature-gap-scanner.mjs';
|
|
import { scan as scanTokenHotspots } from './token-hotspots.mjs';
|
|
import { scan as scanCachePrefix } from './cache-prefix-scanner.mjs';
|
|
import { scan as scanDisabledInSchema } from './disabled-in-schema-scanner.mjs';
|
|
import { scan as scanCollision } from './collision-scanner.mjs';
|
|
import { scan as scanSkillListing } from './skill-listing-scanner.mjs';
|
|
import { scan as scanAgentListing } from './agent-listing-scanner.mjs';
|
|
import { scan as scanOutputStyle } from './output-style-scanner.mjs';
|
|
import { scan as scanOptimizationLens } from './optimization-lens-scanner.mjs';
|
|
|
|
// Directory names that identify test fixture / example directories
|
|
const FIXTURE_DIR_NAMES = ['tests', 'examples', '__tests__', 'test-fixtures'];
|
|
|
|
/**
|
|
* Check if a finding originates from a test fixture or example directory
|
|
* relative to the scan target. Only filters when the finding's path extends
|
|
* beyond the target into a fixture subdirectory — if the target itself is
|
|
* a fixture directory, findings are NOT filtered.
|
|
* @param {object} f - Finding object
|
|
* @param {string} targetPath - Resolved scan target path
|
|
* @returns {boolean}
|
|
*/
|
|
function isFixturePath(f, targetPath) {
|
|
const p = f.file || f.path || f.location || '';
|
|
if (!p || !p.startsWith(targetPath)) return false;
|
|
// Get the path relative to target, then check if it passes through a fixture dir
|
|
const rel = p.slice(targetPath.length);
|
|
return FIXTURE_DIR_NAMES.some(dir => rel.includes(sep + dir + sep));
|
|
}
|
|
|
|
const SCANNERS = [
|
|
{ name: 'CML', fn: scanClaudeMd, label: 'CLAUDE.md Linter' },
|
|
{ name: 'SET', fn: scanSettings, label: 'Settings Validator' },
|
|
{ name: 'HKV', fn: scanHooks, label: 'Hook Validator' },
|
|
{ name: 'RUL', fn: scanRules, label: 'Rules Validator' },
|
|
{ name: 'MCP', fn: scanMcp, label: 'MCP Config Validator' },
|
|
{ name: 'IMP', fn: scanImports, label: 'Import Resolver' },
|
|
{ name: 'CNF', fn: scanConflicts, label: 'Conflict Detector' },
|
|
{ name: 'GAP', fn: scanGap, label: 'Feature Gap Scanner' },
|
|
{ name: 'TOK', fn: scanTokenHotspots, label: 'Token Hotspots' },
|
|
{ name: 'CPS', fn: scanCachePrefix, label: 'Cache-Prefix Stability' },
|
|
{ name: 'DIS', fn: scanDisabledInSchema, label: 'Disabled-In-Schema' },
|
|
{ name: 'COL', fn: scanCollision, label: 'Plugin Skill Collision' },
|
|
{ name: 'SKL', fn: scanSkillListing, label: 'Skill-Listing Budget' },
|
|
{ name: 'AGT', fn: scanAgentListing, label: 'Agent-Listing Budget' },
|
|
{ name: 'OST', fn: scanOutputStyle, label: 'Output-Style Validation' },
|
|
{ name: 'OPT', fn: scanOptimizationLens, label: 'Optimization Lens' },
|
|
];
|
|
|
|
/**
|
|
* Run all scanners against target path.
|
|
* @param {string} targetPath
|
|
* @param {object} [opts]
|
|
* @param {boolean} [opts.includeGlobal=false]
|
|
* @param {boolean} [opts.fullMachine=false] - Scan all known locations across the machine
|
|
* @param {boolean} [opts.suppress=true] - Apply suppressions from .config-audit-ignore
|
|
* @param {boolean} [opts.filterFixtures=true] - Exclude findings from test/example paths
|
|
* @param {boolean} [opts.excludeCache=true] - Drop stale ~/.claude/plugins/cache versions so findings reflect live config (B3)
|
|
* @returns {Promise<object>} Full envelope with all results
|
|
*/
|
|
// Exported for testing
|
|
export { isFixturePath, FIXTURE_DIR_NAMES };
|
|
|
|
export async function runAllScanners(targetPath, opts = {}) {
|
|
const start = Date.now();
|
|
const resolvedPath = resolve(targetPath);
|
|
|
|
// Default ON: stale cached plugin versions otherwise inflate token hotspots
|
|
// and CNF duplicate-hook findings with config that loads on zero turns. (B3)
|
|
const excludeCache = opts.excludeCache !== false;
|
|
|
|
// B8 — resolve the context window once and thread it to budget-aware scanners
|
|
// (SKL, CML). Undefined opts.contextWindow → conservative 200k anchor, which is
|
|
// byte-identical to the pre-B8 default; other scanners ignore the third arg.
|
|
// B8b — `--context-window auto` probes the configured model (settings cascade /
|
|
// ANTHROPIC_MODEL) so a 1M-tier host self-calibrates; unknown/unpinned → advisory.
|
|
let probedModel = null;
|
|
if (String(opts.contextWindow ?? '').trim().toLowerCase() === 'auto') {
|
|
probedModel = await resolveActiveModel(resolvedPath, { env: process.env });
|
|
}
|
|
const contextWindow = resolveContextWindow(opts.contextWindow, { model: probedModel });
|
|
|
|
// Shared file discovery — scanners reuse this
|
|
let discovery;
|
|
if (opts.fullMachine) {
|
|
const roots = await discoverFullMachinePaths();
|
|
discovery = await discoverConfigFilesMulti(roots, { excludeCache });
|
|
} else {
|
|
discovery = await discoverConfigFiles(resolvedPath, {
|
|
includeGlobal: opts.includeGlobal || false,
|
|
excludeCache,
|
|
});
|
|
}
|
|
|
|
const results = [];
|
|
|
|
for (const scanner of SCANNERS) {
|
|
resetCounter();
|
|
const scanStart = Date.now();
|
|
try {
|
|
const result = await scanner.fn(resolvedPath, discovery, { contextWindow });
|
|
results.push(result);
|
|
const count = result.findings.length;
|
|
const label = opts.humanizedProgress
|
|
? `\`[${scanner.name}] ${scanner.label}\``
|
|
: `[${scanner.name}] ${scanner.label}`;
|
|
process.stderr.write(` ${label}: ${count} finding(s) (${Date.now() - scanStart}ms)\n`);
|
|
} catch (err) {
|
|
results.push({
|
|
scanner: scanner.name,
|
|
status: 'error',
|
|
files_scanned: 0,
|
|
duration_ms: Date.now() - scanStart,
|
|
findings: [],
|
|
counts: { critical: 0, high: 0, medium: 0, low: 0, info: 0 },
|
|
error: err.message,
|
|
});
|
|
const label = opts.humanizedProgress
|
|
? `\`[${scanner.name}] ${scanner.label}\``
|
|
: `[${scanner.name}] ${scanner.label}`;
|
|
process.stderr.write(` ${label}: ERROR — ${err.message}\n`);
|
|
}
|
|
}
|
|
|
|
// Filter findings from test fixtures / examples (unless disabled)
|
|
const shouldFilterFixtures = opts.filterFixtures !== false;
|
|
let fixtureFindings = [];
|
|
|
|
if (shouldFilterFixtures) {
|
|
for (const result of results) {
|
|
const active = [];
|
|
const fixture = [];
|
|
for (const f of result.findings) {
|
|
if (isFixturePath(f, resolvedPath)) {
|
|
fixture.push(f);
|
|
} else {
|
|
active.push(f);
|
|
}
|
|
}
|
|
if (fixture.length > 0) {
|
|
fixtureFindings.push(...fixture);
|
|
result.findings = active;
|
|
result.counts = { critical: 0, high: 0, medium: 0, low: 0, info: 0 };
|
|
for (const f of active) {
|
|
if (result.counts[f.severity] !== undefined) result.counts[f.severity]++;
|
|
}
|
|
}
|
|
}
|
|
if (fixtureFindings.length > 0) {
|
|
process.stderr.write(` ${fixtureFindings.length} finding(s) from test fixtures excluded\n`);
|
|
}
|
|
}
|
|
|
|
// Apply suppressions (unless disabled)
|
|
const shouldSuppress = opts.suppress !== false;
|
|
let suppressedFindings = [];
|
|
|
|
if (shouldSuppress) {
|
|
const { suppressions } = await loadSuppressions(resolvedPath);
|
|
if (suppressions.length > 0) {
|
|
for (const result of results) {
|
|
const { active, suppressed } = applySuppressions(result.findings, suppressions);
|
|
suppressedFindings.push(...suppressed);
|
|
result.findings = active;
|
|
// Recalculate counts
|
|
result.counts = { critical: 0, high: 0, medium: 0, low: 0, info: 0 };
|
|
for (const f of active) {
|
|
if (result.counts[f.severity] !== undefined) result.counts[f.severity]++;
|
|
}
|
|
}
|
|
if (suppressedFindings.length > 0) {
|
|
process.stderr.write(` ${formatSuppressionSummary(suppressedFindings)}\n`);
|
|
}
|
|
}
|
|
}
|
|
|
|
const totalMs = Date.now() - start;
|
|
const env = envelope(resolvedPath, results, totalMs);
|
|
if (fixtureFindings.length > 0) {
|
|
env.fixture_findings = fixtureFindings;
|
|
}
|
|
if (suppressedFindings.length > 0) {
|
|
env.suppressed_findings = suppressedFindings;
|
|
}
|
|
return env;
|
|
}
|
|
|
|
// --- CLI entry point ---
|
|
async function main() {
|
|
const args = process.argv.slice(2);
|
|
let targetPath = '.';
|
|
let outputFile = null;
|
|
let saveBaseline = false;
|
|
let baselinePath = null;
|
|
let contextWindow = null;
|
|
|
|
for (let i = 0; i < args.length; i++) {
|
|
if (args[i] === '--output-file' && args[i + 1]) {
|
|
outputFile = args[++i];
|
|
} else if (args[i] === '--context-window' && args[i + 1]) {
|
|
contextWindow = args[++i];
|
|
} else if (args[i] === '--save-baseline') {
|
|
saveBaseline = true;
|
|
} else if (args[i] === '--baseline' && args[i + 1]) {
|
|
baselinePath = args[++i];
|
|
} else if (args[i] === '--global') {
|
|
// handled below
|
|
} else if (args[i] === '--full-machine') {
|
|
// handled below
|
|
} else if (args[i] === '--no-suppress') {
|
|
// handled below
|
|
} else if (args[i] === '--include-fixtures') {
|
|
// handled below
|
|
} else if (args[i] === '--exclude-cache' || args[i] === '--no-exclude-cache') {
|
|
// handled below
|
|
} else if (args[i] === '--json') {
|
|
// handled below — explicit machine-readable mode (bypass humanizer)
|
|
} else if (args[i] === '--raw') {
|
|
// handled below — v5.0.0 verbatim mode (bypass humanizer)
|
|
} else if (!args[i].startsWith('-')) {
|
|
targetPath = args[i];
|
|
}
|
|
}
|
|
|
|
const includeGlobal = args.includes('--global');
|
|
const fullMachine = args.includes('--full-machine');
|
|
const suppress = !args.includes('--no-suppress');
|
|
const filterFixtures = !args.includes('--include-fixtures');
|
|
const excludeCache = !args.includes('--no-exclude-cache');
|
|
const jsonMode = args.includes('--json');
|
|
const rawMode = args.includes('--raw');
|
|
|
|
const humanizedProgress = !jsonMode && !rawMode;
|
|
process.stderr.write(humanizedProgress ? `Config-Audit v2.2.0\n` : `Config-Audit Scanner v2.2.0\n`);
|
|
process.stderr.write(`Target: ${resolve(targetPath)}\n`);
|
|
process.stderr.write(`Scope: ${fullMachine ? 'full-machine' : includeGlobal ? 'global' : 'project'}\n`);
|
|
process.stderr.write(`Fixtures: ${filterFixtures ? 'excluded' : 'included'}\n\n`);
|
|
|
|
const result = await runAllScanners(targetPath, {
|
|
includeGlobal,
|
|
fullMachine,
|
|
suppress,
|
|
filterFixtures,
|
|
excludeCache,
|
|
humanizedProgress,
|
|
contextWindow,
|
|
});
|
|
|
|
// Default mode runs the humanizer; --json and --raw bypass for v5.0.0 byte-equal output.
|
|
const output = (jsonMode || rawMode) ? result : humanizeEnvelope(result);
|
|
const json = JSON.stringify(output, null, 2);
|
|
|
|
if (outputFile) {
|
|
await writeFile(outputFile, json, 'utf-8');
|
|
process.stderr.write(`\nResults written to ${outputFile}\n`);
|
|
} else {
|
|
process.stdout.write(json + '\n');
|
|
}
|
|
|
|
if (saveBaseline) {
|
|
const bPath = baselinePath || resolve(targetPath, '.config-audit-baseline.json');
|
|
// Always save baselines as raw v5.0.0-shape envelope so future humanizer
|
|
// changes don't trigger false-positive drift findings.
|
|
await writeFile(bPath, JSON.stringify(result, null, 2), 'utf-8');
|
|
process.stderr.write(`Baseline saved to ${bPath}\n`);
|
|
}
|
|
|
|
// Summary
|
|
const agg = result.aggregate;
|
|
process.stderr.write(`\n--- Summary ---\n`);
|
|
process.stderr.write(`Findings: ${agg.total_findings} (C:${agg.counts.critical} H:${agg.counts.high} M:${agg.counts.medium} L:${agg.counts.low} I:${agg.counts.info})\n`);
|
|
process.stderr.write(`Risk: ${agg.risk_score}/100 (${agg.risk_band})\n`);
|
|
process.stderr.write(`Verdict: ${agg.verdict}\n`);
|
|
|
|
// 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
|
|
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.exitCode = 3;
|
|
});
|
|
}
|