config-audit/scanners/posture.mjs
Kjell Tore Guttormsen 182a37c1af fix(scanners): close the CLI argument class across all fourteen CLIs
`KNOWN_OPEN` in cli-unknown-flag-rejection.test.mjs named two CLIs as still
carrying the argument-swallow defect. That number was the previous session's
field of view, not a measurement. Measuring all fourteen found **7** open on
the unknown-flag arm and **10** on a second arm the deferral note never
described.

Arm 1 — unknown flag: with no `else` branch, `--zzz` leaves no trace. exit 0,
full payload, a confident answer to a question the caller did not ask.

Arm 2 — the sharper one: `a === '--output-file' && args[i + 1]` asks only
whether a next token EXISTS, never whether it is a value. `manifest`,
`campaign-cli` and `knowledge-refresh-cli` each wrote a file literally named
`--json` into the caller's working directory when handed `--output-file
--json`, exit 0, with `--json` mode silently dropped. A wrong answer is bad;
an unintended file on disk is worse.

Two of the CLIs this catches were already in GUARDED and green on arm 1 while
arm 2 stood open a few lines away — the guard asserted one relation instead of
the invariant.

Fixed with a shared gate (`lib/cli-args.mjs`) that runs BEFORE each CLI's own
parse loop rather than replacing it: valid argv reaches the existing parser
byte-for-byte unchanged, so the byte-stability argument is structural rather
than empirical. `drift-cli`, `fix-cli` and `plugin-health-scanner` were
already correct on both arms and were moved into GUARDED instead of rewritten.
The three CLIs with a bespoke unknown-flag branch had it removed once the gate
made it unreachable.

Suite 1488 → 1531. Frozen v5.0.0 snapshots untouched; `self-audit
--check-readme` passed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014P6Rh59Mtj4uYrdYMCYZJE
2026-08-09 21:42:00 +02:00

156 lines
5.2 KiB
JavaScript

#!/usr/bin/env node
/**
* Config-Audit Posture Assessment CLI
* Runs all scanners + scoring in a single Node.js process.
* Usage: node posture.mjs <target-path> [--json] [--global] [--output-file path]
* Zero external dependencies.
*/
import { resolve } from 'node:path';
import { writeOutputFile } from './lib/write-output.mjs';
import { requireTargetDir } from './lib/require-target-dir.mjs';
import { runAllScanners } from './scan-orchestrator.mjs';
import { humanizeEnvelope } from './lib/humanizer.mjs';
import { requireValidArgs } from './lib/cli-args.mjs';
import {
calculateUtilization,
determineMaturityLevel,
determineSegment,
scoreByArea,
topActions,
generateScorecard,
generateHealthScorecard,
} from './lib/scoring.mjs';
/** Flag surface, measured 2026-08-09. Anything else is exit 3. */
const ARG_SPEC = {
boolean: ['--json', '--raw', '--global', '--full-machine', '--include-fixtures'],
value: ['--output-file', '--context-window'],
};
/**
* Run posture assessment and return structured result.
* @param {string} targetPath
* @param {object} [opts]
* @param {boolean} [opts.includeGlobal=false]
* @param {boolean} [opts.fullMachine=false] - Scan all known locations across the machine
* @returns {Promise<object>}
*/
export async function runPosture(targetPath, opts = {}) {
const envelope = await runAllScanners(targetPath, opts);
// Extract GAP scanner results
const gapScanner = envelope.scanners.find(s => s.scanner === 'GAP');
const gapFindings = gapScanner ? gapScanner.findings : [];
// Calculate scores
const utilization = calculateUtilization(gapFindings);
const maturity = determineMaturityLevel(gapFindings, { files: [] });
const segment = determineSegment(utilization.score);
const areaScores = scoreByArea(envelope.scanners);
const actions = topActions(gapFindings);
return {
utilization,
maturity,
segment,
areas: areaScores.areas,
overallGrade: areaScores.overallGrade,
topActions: actions,
opportunityCount: gapFindings.length,
scannerEnvelope: envelope,
};
}
// --- CLI entry point ---
async function main() {
const args = process.argv.slice(2);
if (!requireValidArgs(args, ARG_SPEC)) return;
let targetPath = '.';
let outputFile = null;
let jsonMode = false;
let rawMode = false;
let includeGlobal = false;
let fullMachine = false;
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] === '--json') {
jsonMode = true;
} else if (args[i] === '--raw') {
rawMode = true;
} else if (args[i] === '--global') {
includeGlobal = true;
} else if (args[i] === '--full-machine') {
fullMachine = true;
} else if (args[i] === '--include-fixtures') {
// handled below
} else if (!args[i].startsWith('-')) {
targetPath = args[i];
}
}
if (!(await requireTargetDir(resolve(targetPath)))) {
process.exitCode = 3;
return;
}
const filterFixtures = !args.includes('--include-fixtures');
const humanizedProgress = !jsonMode && !rawMode;
const result = await runPosture(targetPath, {
includeGlobal,
fullMachine,
filterFixtures,
humanizedProgress,
contextWindow,
});
// stdout JSON path: --json and --raw both write the v5.0.0-shape result
// (byte-identical). Default mode writes nothing to stdout.
if (jsonMode || rawMode) {
const json = JSON.stringify(result, null, 2);
process.stdout.write(json + '\n');
}
// stderr scorecard path: --json suppresses; --raw renders v5.0.0 verbatim
// (humanized=false); default renders humanized scorecard.
if (!jsonMode) {
const scorecard = generateHealthScorecard(
{ areas: result.areas, overallGrade: result.overallGrade },
result.opportunityCount,
{ humanized: !rawMode },
);
process.stderr.write('\n' + scorecard + '\n');
}
if (outputFile) {
// Consumers (feature-gap.md, posture.md) read scannerEnvelope.scanners[].findings
// and group on humanizer fields. posture's result nests the envelope under
// `scannerEnvelope`, so humanize THAT (not `result`, which has no top-level
// `scanners` array — humanizeEnvelope would no-op). --json/--raw stay raw.
const fileEnv = (jsonMode || rawMode)
? result
: { ...result, scannerEnvelope: humanizeEnvelope(result.scannerEnvelope) };
const json = JSON.stringify(fileEnv, null, 2);
await writeOutputFile(outputFile, json, 'utf-8');
process.stderr.write(`\nResults written to ${outputFile}\n`);
}
}
// 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`);
// 3, not 1. Every command in this plugin is told that 0/1/2 are normal grades
// (PASS/WARNING/FAIL) and only 3 is a real error, so exiting 1 here made a crash
// indistinguishable from a WARNING — and the command went on to Read a payload file
// that was never written.
process.exitCode = 3;
});
}