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:
parent
1182f85767
commit
05f1e954d0
7 changed files with 334 additions and 36 deletions
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue