fix(fix): validate the arguments, back up renames, and verify the scope it fixed

Dogfooding `/config-audit fix` against a throwaway repo copy. All eight
predictions registered in the fasit before the run were confirmed, and three
further defects surfaced that were not predicted.

- M-BUG-21, third arm: the argument loop ended in `!arg.startsWith('-') =>
  targetPath`, so an unknown flag was dropped and its value became the target.
  In `fix` that is the WRITE target under `--apply`. Unknown options and a
  value-less `--output-file` now exit 3.
- `--dry-run` was documented in the command's argument-hint and never
  implemented; `--output-file` did not exist, so `commands/fix.md` told the
  agent to Read a file nothing produced. Both now exist.
- M-BUG-31: `file-rename` was excluded from the backup set, so a renamed rule
  file had no backup entry while the command promised one and returned a
  backupId that could not restore it.
- M-BUG-32: `verifyFixes` hardcoded `includeGlobal: false`, so after a
  `--global` run every untouched user-scope finding was reported as verified.
  Reproduced against an unmodified ~/.claude/CLAUDE.md.
- M-BUG-29: a rename was applied before other fixes on the same file, which
  then failed with ENOENT while the run still exited 0. Renames sort last.
- M-BUG-30: `severityOrder[s] || 4` maps critical (0) to 4, so critical fixes
  sorted last. The old test used the same falsy fallback and agreed with the
  bug. Now `?? 4`.
- A failed fix exits 2 instead of 0, matching the other scanners' convention.

Frozen tests/snapshots/v5.0.0/ untouched; --json/--raw stdout byte-identical.
Suite 1420/0 (+10).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YJ3MCDCnyw7wZSPnUXVhYS
This commit is contained in:
Kjell Tore Guttormsen 2026-07-31 18:41:27 +02:00
commit 05f1e954d0
7 changed files with 334 additions and 36 deletions

View file

@ -9,11 +9,18 @@
*/
import { resolve } from 'node:path';
import { writeFile } from 'node:fs/promises';
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 = '.';
@ -21,18 +28,36 @@ async function main() {
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++) {
if (args[i] === '--apply') {
apply = true;
} 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].startsWith('-')) {
targetPath = args[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;
}
}
@ -105,16 +130,21 @@ async function main() {
let backupId = null;
if (fixes.length === 0) {
const output = { planned: [], applied: [], failed: [], verified: [], regressions: [], manual, backupId: null };
if (machineMode) {
const output = { planned: [], applied: [], failed: [], verified: [], regressions: [], manual, backupId: null };
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);
}
if (apply) {
// Create backup first
const filesToBackup = [...new Set(fixes.filter(f => f.type !== 'file-rename').map(f => f.file))];
// 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;
@ -142,7 +172,10 @@ async function main() {
process.stderr.write(`\n Verifying...\n`);
}
const verification = await verifyFixes(envelope, applied);
// 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;
@ -165,7 +198,7 @@ async function main() {
}
// JSON output (both --json and --raw write byte-equal v5.0.0-shape stdout)
if (machineMode) {
{
const output = {
planned: fixes.map(f => ({
findingId: f.findingId,
@ -193,7 +226,17 @@ async function main() {
})),
backupId,
};
process.stdout.write(JSON.stringify(output, null, 2) + '\n');
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 writeFile(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;
}
}