`write-scope.mjs` has existed since M-BUG-41, but only one writer ever called it. Measured 2026-08-12: 9 files under `scanners/` write to disk, 1 imported the gate; 21 command templates, 17 mention a write, 5 call `write-scope-cli`. Five templates paraphrasing one policy is the shape that put the lever table in five copies (#61) — one level up. The defect was never "8 ungated writers = 8 bugs". Four of them write the plugin's own bookkeeping and must STAY ungated: a gate that fires on every run gets switched off, and then it guards nothing. The defect is that nothing declared WHICH, so the question was answered by reading, and answered differently each time it was asked. `tests/lib/write-gate-coverage.test.mjs` makes the answer structural: every writer either imports the gate or holds an EXEMPT entry naming where the bytes land. Seen RED against today's tree before the fix (4 ungated writers), and each of its four assertions was separately seen red against its own defect. Two premises in the plan text were falsified by measuring them first: - `scan-orchestrator` was carried as "plugin-managed, legitimately exempt". `--save-baseline` derives its path from the SCAN TARGET, so `--global` lands `~/.claude/.config-audit-baseline.json` — user-scope, require-ok. It is gated. `lib/baseline.mjs` is the genuinely exempt one. - the first sweep scored 9 writers with a regex that could not match `writeFileSync(`, so `lib/backup.mjs` — a real writer — read as clean. The guard covers sync and async forms, strips comments before matching, and asserts non-emptiness so a regex that stops matching cannot make every other assertion vacuously green (#63, #64). Gated: fix-engine, rollback-engine, campaign-export-cli, scan-orchestrator. All five call sites share ONE reduction, `evaluateWriteTargets` — four copies of classify/strongestGate/dedup is the drift this exists to prevent. `campaign export` still DISCLOSES rather than refuses: cross-repo is by design there, and tightening it into a refusal would break the feature. A dry run is still not a write, so it is never gated (#63). A refusal is a verdict about a config that WAS examined, so it rides in the payload and keeps the 0/1/2 exit contract (#62) — and the verdict now reaches the success payload too, since stderr is discarded by `2>/dev/null` (F3's class). commands/fix.md carries `--approve-scope` from the answer the user gives, with the rule stated where it can be read: classifying is not approving. Dogfooded end to end: a target outside the session root refuses with zero bytes written, then applies under `--approve-scope`. Suite 1703 -> 1707/0. Frozen v5.0.0 + default-output snapshots: 0 changed files. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Pkn22uGCgk6QZA738zNmHL
233 lines
10 KiB
JavaScript
233 lines
10 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', '--repo', tmpDir], {
|
|
encoding: 'utf-8',
|
|
timeout: 30000,
|
|
env: hermeticEnv(),
|
|
// `--repo` above names the session root the Q1 gate classifies against,
|
|
// the same job `--repo "$PWD"` does in commands/fix.md. It is passed
|
|
// explicitly rather than via `cwd` because on macOS the tmp path is a
|
|
// symlink (/var -> /private/var) and `process.cwd()` reports the resolved
|
|
// form — root and target would then disagree as strings while naming one
|
|
// directory, and the gate would withhold an in-repo write.
|
|
});
|
|
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', '--repo', tmpDir], {
|
|
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', '--repo', tmpDir], {
|
|
encoding: 'utf-8',
|
|
timeout: 30000,
|
|
env: hermeticEnv(),
|
|
// `--repo` above names the session root the Q1 gate classifies against,
|
|
// the same job `--repo "$PWD"` does in commands/fix.md. It is passed
|
|
// explicitly rather than via `cwd` because on macOS the tmp path is a
|
|
// symlink (/var -> /private/var) and `process.cwd()` reports the resolved
|
|
// form — root and target would then disagree as strings while naming one
|
|
// directory, and the gate would withhold an in-repo write.
|
|
});
|
|
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', '--repo', dir]);
|
|
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', '--repo', dir]);
|
|
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 });
|
|
});
|
|
});
|