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
221 lines
9.4 KiB
JavaScript
221 lines
9.4 KiB
JavaScript
import { describe, it, beforeEach, afterEach } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { resolve, join } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { cp, rm, readFile, stat } from 'node:fs/promises';
|
|
import { mkdirSync, existsSync, readdirSync, readFileSync, writeFileSync, rmSync } from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import { execFileSync, spawnSync } from 'node:child_process';
|
|
import { hermeticEnv, HERMETIC_HOME } from '../helpers/hermetic-home.mjs';
|
|
|
|
const __dirname = fileURLToPath(new URL('.', import.meta.url));
|
|
const FIXTURES = resolve(__dirname, '../fixtures');
|
|
const FIXABLE = resolve(FIXTURES, 'fixable-project');
|
|
const FIX_CLI = resolve(__dirname, '../../scanners/fix-cli.mjs');
|
|
|
|
/** Create a temporary copy of the fixable-project fixture. */
|
|
async function createTmpCopy() {
|
|
const tmpDir = join(tmpdir(), `config-audit-cli-test-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`);
|
|
mkdirSync(tmpDir, { recursive: true });
|
|
await cp(FIXABLE, tmpDir, { recursive: true });
|
|
return tmpDir;
|
|
}
|
|
|
|
describe('fix-cli dry-run', () => {
|
|
it('shows planned fixes without --apply', () => {
|
|
const result = execFileSync('node', [FIX_CLI, FIXABLE, '--json'], {
|
|
encoding: 'utf-8',
|
|
timeout: 30000,
|
|
env: hermeticEnv(),
|
|
});
|
|
const output = JSON.parse(result);
|
|
assert.ok(Array.isArray(output.planned), 'Should have planned array');
|
|
assert.ok(output.planned.length > 0, 'Should have planned fixes');
|
|
assert.strictEqual(output.backupId, null, 'No backup in dry-run');
|
|
assert.ok(Array.isArray(output.manual), 'Should have manual array');
|
|
|
|
// Regression lock (HOME-leak class): a project-scoped fix run must surface only
|
|
// project-local findings, never HOME-scoped SKL/COL findings read from the
|
|
// developer's real ~/.claude. Without a hermetic HOME the spawned CLI picks up
|
|
// CA-SKL-001 from installed skills, making this test machine-dependent.
|
|
const homeScoped = output.manual.filter((m) => /^CA-(SKL|COL)-/.test(m.findingId));
|
|
assert.deepStrictEqual(
|
|
homeScoped.map((m) => m.findingId),
|
|
[],
|
|
'fix-cli leaked HOME-scoped findings — spawn is not HOME-isolated',
|
|
);
|
|
});
|
|
|
|
it('outputs valid JSON with --json flag', () => {
|
|
const result = execFileSync('node', [FIX_CLI, FIXABLE, '--json'], {
|
|
encoding: 'utf-8',
|
|
timeout: 30000,
|
|
env: hermeticEnv(),
|
|
});
|
|
assert.doesNotThrow(() => JSON.parse(result), 'Output should be valid JSON');
|
|
});
|
|
});
|
|
|
|
describe('fix-cli --apply', () => {
|
|
let tmpDir;
|
|
|
|
beforeEach(async () => {
|
|
tmpDir = await createTmpCopy();
|
|
});
|
|
|
|
afterEach(async () => {
|
|
if (tmpDir) await rm(tmpDir, { recursive: true, force: true });
|
|
});
|
|
|
|
it('applies fixes and creates backup', () => {
|
|
const result = execFileSync('node', [FIX_CLI, tmpDir, '--apply', '--json'], {
|
|
encoding: 'utf-8',
|
|
timeout: 30000,
|
|
env: hermeticEnv(),
|
|
});
|
|
const output = JSON.parse(result);
|
|
assert.ok(output.applied.length > 0, 'Should have applied fixes');
|
|
assert.ok(output.backupId, 'Should have a backup ID');
|
|
|
|
// Verify backup exists. The CLI runs with a hermetic HOME (see execFileSync env
|
|
// below), so its backups land under HERMETIC_HOME, not the developer's real home.
|
|
const backupDir = join(HERMETIC_HOME, '.claude', 'config-audit', 'backups', output.backupId);
|
|
assert.ok(existsSync(backupDir), 'Backup directory should exist');
|
|
|
|
// …and nothing may land in the pre-v2.2.0 root, which is read-only now.
|
|
const legacyDir = join(HERMETIC_HOME, '.config-audit', 'backups', output.backupId);
|
|
assert.ok(!existsSync(legacyDir), 'Nothing may be written to the legacy backup root');
|
|
});
|
|
|
|
it('actually modifies files after --apply', async () => {
|
|
execFileSync('node', [FIX_CLI, tmpDir, '--apply'], {
|
|
encoding: 'utf-8',
|
|
timeout: 30000,
|
|
env: hermeticEnv(),
|
|
});
|
|
|
|
// Check that settings.json was fixed
|
|
const content = await readFile(join(tmpDir, '.claude', 'settings.json'), 'utf-8');
|
|
const parsed = JSON.parse(content);
|
|
assert.ok(parsed.$schema, 'Should have $schema after fix');
|
|
});
|
|
|
|
it('reports verified fixes', () => {
|
|
const result = execFileSync('node', [FIX_CLI, tmpDir, '--apply', '--json'], {
|
|
encoding: 'utf-8',
|
|
timeout: 30000,
|
|
env: hermeticEnv(),
|
|
});
|
|
const output = JSON.parse(result);
|
|
assert.ok(Array.isArray(output.verified), 'Should have verified array');
|
|
assert.ok(output.verified.length > 0, 'Should have verified fixes');
|
|
});
|
|
});
|
|
|
|
// --- v5.14 hardening (økt #45, chunk `fix`) --------------------------------
|
|
// Every case below was measured against the pre-fix CLI and recorded in
|
|
// docs/fix-fasit.local.md (F1-F8 + M-BUG-29) before a line of it was fixed.
|
|
|
|
/** Run the CLI and return { status, stdout, stderr } without throwing on non-zero. */
|
|
function runCli(args, opts = {}) {
|
|
const res = spawnSync('node', [FIX_CLI, ...args], {
|
|
encoding: 'utf-8',
|
|
timeout: 30000,
|
|
env: hermeticEnv(),
|
|
...opts,
|
|
});
|
|
return { status: res.status, stdout: res.stdout || '', stderr: res.stderr || '' };
|
|
}
|
|
|
|
describe('fix-cli argument validation (F1/F3)', () => {
|
|
it('rejects an unknown flag instead of swallowing its value as the target', () => {
|
|
const outFile = join(tmpdir(), `ca-fix-argtest-${Date.now()}.json`);
|
|
const { status, stderr } = runCli([FIXABLE, '--bogus', outFile, '--json']);
|
|
assert.strictEqual(status, 3, 'Unknown flag must fail loud with exit 3');
|
|
assert.match(stderr, /--bogus/, 'Error must name the offending flag');
|
|
assert.ok(!existsSync(outFile), 'No file may be written for a rejected run');
|
|
});
|
|
|
|
it('rejects --output-file with no value', () => {
|
|
const { status, stderr } = runCli([FIXABLE, '--output-file']);
|
|
assert.strictEqual(status, 3, 'Missing flag value must fail loud');
|
|
assert.match(stderr, /--output-file/, 'Error must name the flag missing its value');
|
|
});
|
|
|
|
it('accepts --dry-run, the flag commands/fix.md documents', () => {
|
|
const { status, stdout } = runCli([FIXABLE, '--dry-run', '--json']);
|
|
assert.strictEqual(status, 0, '--dry-run must be a supported flag');
|
|
const output = JSON.parse(stdout);
|
|
assert.strictEqual(output.backupId, null, '--dry-run must not create a backup');
|
|
});
|
|
});
|
|
|
|
describe('fix-cli --output-file (F2)', () => {
|
|
it('writes the JSON payload to the file and leaves stdout byte-stable', () => {
|
|
const outFile = join(tmpdir(), `ca-fix-out-${Date.now()}.json`);
|
|
const piped = runCli([FIXABLE, '--json']);
|
|
const written = runCli([FIXABLE, '--json', '--output-file', outFile]);
|
|
assert.strictEqual(written.status, 0);
|
|
assert.ok(existsSync(outFile), '--output-file must produce a file');
|
|
const fromFile = JSON.parse(readFileSync(outFile, 'utf-8'));
|
|
assert.ok(Array.isArray(fromFile.planned), 'File must carry the fix payload');
|
|
assert.strictEqual(
|
|
written.stdout,
|
|
piped.stdout,
|
|
'--json stdout is frozen v5.0.0 — --output-file may not change it',
|
|
);
|
|
rmSync(outFile, { force: true });
|
|
});
|
|
|
|
it('writes the payload in default mode too, where stdout carries nothing', () => {
|
|
const outFile = join(tmpdir(), `ca-fix-out-default-${Date.now()}.json`);
|
|
const { status } = runCli([FIXABLE, '--output-file', outFile]);
|
|
assert.strictEqual(status, 0);
|
|
const fromFile = JSON.parse(readFileSync(outFile, 'utf-8'));
|
|
assert.ok(Array.isArray(fromFile.planned), 'Default mode must also write the payload');
|
|
rmSync(outFile, { force: true });
|
|
});
|
|
});
|
|
|
|
describe('fix-cli exit codes (F8)', () => {
|
|
it('exits 2 when a planned fix failed', async () => {
|
|
// Two fixes on one file: the rename and the frontmatter edit. Before the
|
|
// ordering fix this pair guaranteed one ENOENT failure (M-BUG-29).
|
|
const dir = await createTmpCopy();
|
|
const rulesDir = join(dir, '.claude', 'rules');
|
|
mkdirSync(rulesDir, { recursive: true });
|
|
writeFileSync(join(rulesDir, 'both.txt'), '---\nglobs: "**/*.ts"\n---\n\nBody.\n');
|
|
const { status, stdout } = runCli([dir, '--apply', '--json']);
|
|
const output = JSON.parse(stdout);
|
|
if (output.failed.length > 0) {
|
|
assert.strictEqual(status, 2, 'A failed fix must not be reported as exit 0');
|
|
} else {
|
|
assert.strictEqual(status, 0, 'A clean run stays exit 0');
|
|
}
|
|
await rm(dir, { recursive: true, force: true });
|
|
});
|
|
});
|
|
|
|
describe('fix-cli backup completeness (F4)', () => {
|
|
it('backs up a file whose only fix is a rename', async () => {
|
|
const dir = await createTmpCopy();
|
|
const rulesDir = join(dir, '.claude', 'rules');
|
|
mkdirSync(rulesDir, { recursive: true });
|
|
// Valid `paths:` frontmatter — the extension is the only defect, so
|
|
// file-rename is the only planned fix for this file.
|
|
writeFileSync(join(rulesDir, 'renameonly.txt'), '---\npaths: ["**/*.ts"]\n---\n\nBody.\n');
|
|
|
|
const { stdout } = runCli([dir, '--apply', '--json']);
|
|
const output = JSON.parse(stdout);
|
|
const renames = output.applied.filter((a) => /renameonly\.txt$/.test(a.file));
|
|
assert.strictEqual(renames.length, 1, 'The rename must have been applied');
|
|
|
|
const backupDir = join(HERMETIC_HOME, '.claude', 'config-audit', 'backups', output.backupId);
|
|
const backedUp = readdirSync(join(backupDir, 'files'));
|
|
assert.ok(
|
|
backedUp.some((f) => /renameonly\.txt$/.test(f)),
|
|
'commands/fix.md promises a mandatory backup — a renamed file must be recoverable',
|
|
);
|
|
await rm(dir, { recursive: true, force: true });
|
|
});
|
|
});
|