config-audit/scanners/fix-cli.mjs
Kjell Tore Guttormsen caea8aca23 fix(commands): stop answering questions the caller did not ask
Dogfooding `campaign` + `knowledge-refresh` against a throwaway ledger. Seven
defects, all found by running the commands as written and measuring, not by
reading them.

The headline pair only existed together. `knowledge-refresh` built
`STALE_AFTER="--stale-after 30"` and expanded it unquoted, trusting the shell to
split it in two. bash does; zsh — the macOS default, and what the Bash tool runs
here — does not. The CLI got one argv entry, matched no flag, and because it had
no unknown-flag branch, silently kept the 90-day default and reported "✓ All 14
register entries were re-verified within the last 90 days": a true-sounding
sentence about a threshold the user had just overridden. Fixing either half alone
leaves a silent wrong answer or a loud one; both are fixed, and a guard now
rejects any template that packs a flag and its value into one variable.

`knowledge-refresh` also read one register and wrote another: step 6 named an
unanchored `knowledge/best-practices.json` while the CLI reads
`${CLAUDE_PLUGIN_ROOT}/…`, which for an installed plugin is the cache. The
validation gate then ran the cached test against the cached register — green no
matter what was written. The two copies were byte-identical that day, which is
exactly why it was invisible.

`campaign` vouched for repos it could not read. `add /finnes/ikke` returned
`added` + exit 0; `refresh-tokens` then put the phantom in `swept[]` with a
0-token delta and left `skipped[]` empty, so the machine-wide bill claimed
coverage of three repos on a machine with two. Paths stay tracked — an unmounted
volume is a legitimate absence — but are reported as `addedUnverified`, and the
command names them.

Two class sweeps, both measured rather than assumed. `posture` was the single
scanner (1 of 14) whose fatal catch exited 1, which ux-rules defines as a normal
WARNING grade — a crash indistinguishable from a result. And all 13 payload
writers failed on a `--output-file` whose parent did not exist, which on a fresh
machine turned `campaign`'s first run into "the ledger may be corrupt"; they now
share `scanners/lib/write-output.mjs`.

Predicted breadth was too wide for the first time in five sessions: 6 of 8 CLIs
predicted to lack unknown-flag rejection, 4 measured. `drift` and `fix` already
reject them, via a construct the grep did not recognise — a grep matches an
implementation, the invariant is a behaviour. The sweep was rewritten to run each
CLI with a bogus flag and read the exit code.

Suite 1453 → 1469/0. Frozen snapshots untouched. `optimize-lens-cli` and
`token-hotspots-cli` share the unknown-flag defect and are deferred to the v5.14
argument-handling chunk with their positional-swallow arm; the count is recorded
in the guard rather than rounded down to zero.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012NHWjN8EnoxSqRvMTLK2NE
2026-08-01 21:26:39 +02:00

254 lines
9.4 KiB
JavaScript

#!/usr/bin/env node
/**
* Config-Audit Fix CLI
* Standalone entry point for running fixes without the command.
* Usage: node fix-cli.mjs <path> [--apply] [--global] [--json]
* Dry-run by default — must pass --apply to write changes.
* Zero external dependencies.
*/
import { resolve } from 'node:path';
import { writeOutputFile } from './lib/write-output.mjs';
import { runAllScanners } from './scan-orchestrator.mjs';
import { planFixes, applyFixes, verifyFixes } from './fix-engine.mjs';
import { createBackup } from './lib/backup.mjs';
import { humanizeFinding } from './lib/humanizer.mjs';
// `--dry-run` is a no-op alias: dry-run is already the default. It exists because
// commands/fix.md documents it in argument-hint, and a documented flag that the
// CLI silently drops is the same fail-silent class as the unknown-flag sink below.
const BOOL_FLAGS = ['--apply', '--dry-run', '--json', '--raw', '--global'];
const VALUE_FLAGS = ['--output-file'];
async function main() {
const args = process.argv.slice(2);
let targetPath = '.';
let apply = false;
let jsonMode = false;
let rawMode = false;
let includeGlobal = false;
let outputFile = null;
// Same defect class as M-BUG-21 in drift-cli: this loop used to end in
// `else if (!args[i].startsWith('-')) targetPath = args[i]` with no
// unknown-flag branch, so an unrecognised flag was dropped silently and its
// VALUE became the scan target. Here that is worse than in drift: combined
// with --apply it silently moves the WRITE target to another tree.
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (BOOL_FLAGS.includes(arg)) {
if (arg === '--apply') apply = true;
else if (arg === '--json') jsonMode = true;
else if (arg === '--raw') rawMode = true;
else if (arg === '--global') includeGlobal = true;
// --dry-run: default behaviour, accepted so it is not silently dropped.
} else if (VALUE_FLAGS.includes(arg)) {
const value = args[i + 1];
if (value === undefined || value.startsWith('-')) {
throw new Error(`Option ${arg} requires a value.`);
}
outputFile = value;
i++;
} else if (arg.startsWith('-')) {
throw new Error(
`Unknown option: ${arg}\n` +
`Valid options: ${[...BOOL_FLAGS, ...VALUE_FLAGS].join(' ')}`
);
} else {
targetPath = arg;
}
}
// Whether to suppress prose stderr (true for both --json and --raw machine paths).
const machineMode = jsonMode || rawMode;
const resolvedPath = resolve(targetPath);
if (!machineMode) {
process.stderr.write(`Config-Audit Fix CLI v2.1.0\n`);
process.stderr.write(`Target: ${resolvedPath}\n`);
process.stderr.write(`Mode: ${apply ? 'APPLY' : 'DRY-RUN'}\n\n`);
process.stderr.write(`Scanning...\n`);
}
// 1. Run all scanners
const envelope = await runAllScanners(targetPath, {
includeGlobal,
humanizedProgress: !machineMode,
});
// 2. Plan fixes
const { fixes, skipped, manual } = planFixes(envelope);
if (!machineMode) {
process.stderr.write(`\n`);
process.stderr.write(`━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n`);
process.stderr.write(` Config-Audit Fix Plan\n`);
process.stderr.write(`━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n\n`);
if (fixes.length > 0) {
process.stderr.write(` Auto-fixable (${fixes.length}):\n`);
for (let i = 0; i < fixes.length; i++) {
process.stderr.write(` ${i + 1}. [${fixes[i].findingId}] ${fixes[i].description}\n`);
}
} else {
process.stderr.write(` No auto-fixable issues found.\n`);
}
if (manual.length > 0) {
// Default mode humanizes the manual-finding titles for the prose render.
// The JSON `manual` array (later in this function) keeps v5.0.0 verbatim.
process.stderr.write(`\n Manual (${manual.length}):\n`);
for (let i = 0; i < manual.length; i++) {
const m = manual[i];
const title = humanizeFinding({
id: m.findingId,
scanner: typeof m.findingId === 'string' ? m.findingId.split('-')[1] || '' : '',
severity: m.severity || 'info',
title: m.title,
description: m.description || '',
recommendation: m.recommendation || '',
}).title;
process.stderr.write(` ${fixes.length + i + 1}. [${m.findingId}] ${title}\n`);
}
}
if (skipped.length > 0) {
process.stderr.write(`\n Skipped (${skipped.length}): could not generate fix plan\n`);
}
process.stderr.write(`\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n`);
}
// 3. Apply or dry-run
let applied = [];
let failed = [];
let verified = [];
let regressions = [];
let backupId = null;
if (fixes.length === 0) {
const output = { planned: [], applied: [], failed: [], verified: [], regressions: [], manual, backupId: null };
if (machineMode) {
process.stdout.write(JSON.stringify(output, null, 2) + '\n');
}
if (outputFile) await writeOutputFile(outputFile, JSON.stringify(output, null, 2) + '\n', 'utf-8');
return;
}
if (apply) {
// Create backup first. file-rename used to be excluded here, so a rule file
// whose only defect was its extension was renamed with NO backup entry —
// while commands/fix.md promised "every fix creates a backup first" and
// handed the user a backupId that could not restore it. The source file is
// backed up like any other; rollback recreates it at its original path.
const filesToBackup = [...new Set(fixes.map(f => f.file))];
const backup = createBackup(filesToBackup);
backupId = backup.backupId;
if (!machineMode) {
process.stderr.write(`\n Backup created: ${backup.backupPath}\n`);
process.stderr.write(` Applying ${fixes.length} fixes...\n\n`);
}
const result = await applyFixes(fixes, { dryRun: false, backupDir: backup.backupPath });
applied = result.applied;
failed = result.failed;
if (!machineMode) {
process.stderr.write(` Results: ${applied.length} applied, ${failed.length} failed\n`);
if (failed.length > 0) {
for (const f of failed) {
process.stderr.write(` FAILED: [${f.findingId}] ${f.error}\n`);
}
}
}
// 4. Verify
if (applied.length > 0) {
if (!machineMode) {
process.stderr.write(`\n Verifying...\n`);
}
// Verification must re-scan the scope the fix run used. It hardcoded
// includeGlobal:false, so with --global every untouched global-scope
// finding fell out of the re-scan and was reported as verified.
const verification = await verifyFixes(envelope, applied, { includeGlobal });
verified = verification.verified;
regressions = verification.regressions;
if (!machineMode) {
process.stderr.write(` Verified: ${verified.length}/${applied.length}\n`);
if (regressions.length > 0) {
process.stderr.write(` Regressions: ${regressions.join(', ')}\n`);
}
// There is no rollback-cli.mjs — the restore path is the command, which
// drives rollback-engine.mjs. Pointing at a nonexistent script in the
// one message a user reaches for after a bad fix is the worst place for
// a dead reference.
process.stderr.write(`\n Rollback: /config-audit rollback ${backupId}\n`);
}
}
} else {
// Dry-run mode
const result = await applyFixes(fixes, { dryRun: true });
applied = result.applied;
if (!machineMode) {
process.stderr.write(`\n Dry-run complete. Pass --apply to execute.\n`);
}
}
// JSON output (both --json and --raw write byte-equal v5.0.0-shape stdout)
{
const output = {
planned: fixes.map(f => ({
findingId: f.findingId,
file: f.file,
type: f.type,
description: f.description,
})),
applied: applied.map(a => ({
findingId: a.findingId,
file: a.file,
status: a.status,
})),
failed: failed.map(f => ({
findingId: f.findingId,
file: f.file,
status: f.status,
error: f.error,
})),
verified,
regressions,
manual: manual.map(m => ({
findingId: m.findingId,
title: m.title,
recommendation: m.recommendation,
})),
backupId,
};
const serialized = JSON.stringify(output, null, 2) + '\n';
if (machineMode) process.stdout.write(serialized);
// --output-file carries the same payload to disk. ux-rules rule 2 requires
// it: commands run scanners with `2>/dev/null`, so anything the command has
// to act on must ride in a file, not in stdout or stderr.
if (outputFile) await writeOutputFile(outputFile, serialized, 'utf-8');
// Exit code follows the convention the other scanners use: 0 PASS,
// 2 FAIL, 3 tool error. A failed fix used to exit 0, so a caller could not
// tell a clean run from one that silently lost a fix.
if (failed.length > 0) process.exitCode = 2;
}
}
// 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;
});
}