DEL B chunk `interview` (+ discover/status/cleanup/help). Fasit written before the run predicted 8 defects and refuted 4 candidates; all 8 confirmed, all 4 refutations held, and three predictions turned out too narrow. - M-BUG-36: `drift --list` reached the command as 0 bytes. drift-cli accepted --output-file but list mode ignored it, and the listing goes to stderr, which the command discards per ux-rules rule 2. Fixing the caller alone would not have helped. - M-BUG-37: feature-gap's "Create backup" step ran fix-cli without --apply. Dry-run is the default, so no backup existed (backupId: null) while the command went on to edit config believing it could roll back. - M-BUG-38: fix-cli told users to recover with scanners/rollback-cli.mjs, which does not exist. Dead reference in the one message read after a bad fix. - M-BUG-21 fourth arm: five templates carried literal [--global]/[--full-machine] inside executable bash blocks. A bracketed placeholder does not start with a dash, so every scanner's arg loop takes it as the scan target. - interview and analyze never said which session they act on; interview could rewind a finished session; cleanup interpolated an unvalidated id into rm -rf (an empty id deletes every session); status advertised a `resume` command that does not exist and documented an `all` argument it never parsed. TDD: 9 red tests first, including a machine sweep for dead /config-audit references and for bracketed flags in bash blocks. Suite 1432 -> 1441/0. Frozen v5.0.0 snapshots untouched; --raw/--json contracts unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UGvA1uUQn2hPBPMaCKK6x3
268 lines
12 KiB
JavaScript
268 lines
12 KiB
JavaScript
import { describe, it, afterEach } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { resolve, join } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { execFileSync, spawnSync } from 'node:child_process';
|
|
import { mkdtempSync, readFileSync, existsSync } from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import { deleteBaseline } from '../../scanners/lib/baseline.mjs';
|
|
import { hermeticEnv, withHermeticHome, HERMETIC_HOME } from '../helpers/hermetic-home.mjs';
|
|
|
|
const __dirname = fileURLToPath(new URL('.', import.meta.url));
|
|
const FIXTURES = resolve(__dirname, '../fixtures');
|
|
const HEALTHY = resolve(FIXTURES, 'healthy-project');
|
|
const DRIFT_CLI = resolve(__dirname, '../../scanners/drift-cli.mjs');
|
|
|
|
// Isolate HOME: drift runs a full scan (HOME-scoped SKL/COL) AND writes its
|
|
// baselines under ~/.claude. Without this, the CLI would pollute the real
|
|
// ~/.claude during the run. See tests/helpers/hermetic-home.mjs.
|
|
const RUN = { encoding: 'utf-8', timeout: 30000, env: hermeticEnv() };
|
|
|
|
const TEST_BASELINE = `_drift_test_${Date.now()}`;
|
|
|
|
afterEach(async () => {
|
|
// Cleanup must look in the SAME hermetic HOME the CLI wrote to.
|
|
await withHermeticHome(() => deleteBaseline(TEST_BASELINE));
|
|
});
|
|
|
|
describe('drift-cli --save', () => {
|
|
it('saves a baseline and confirms', () => {
|
|
const result = execFileSync('node', [DRIFT_CLI, HEALTHY, '--save', '--name', TEST_BASELINE, '--json'], RUN);
|
|
const output = JSON.parse(result);
|
|
assert.equal(output.saved, true);
|
|
assert.equal(output.name, TEST_BASELINE);
|
|
assert.ok(output.path);
|
|
});
|
|
});
|
|
|
|
describe('drift-cli --list', () => {
|
|
it('lists baselines including saved one', async () => {
|
|
// Save first
|
|
execFileSync('node', [DRIFT_CLI, HEALTHY, '--save', '--name', TEST_BASELINE], RUN);
|
|
|
|
const result = execFileSync('node', [DRIFT_CLI, '--list', '--json'], RUN);
|
|
const output = JSON.parse(result);
|
|
assert.ok(Array.isArray(output.baselines));
|
|
const found = output.baselines.find(b => b.name === TEST_BASELINE);
|
|
assert.ok(found, 'Should find test baseline in list');
|
|
});
|
|
|
|
// commands/drift.md runs `--list` with `2>/dev/null` (ux-rules rule 2), so a
|
|
// stderr-only listing reaches the command as 0 bytes and it can show nothing.
|
|
// --output-file was accepted by the arg parser but ignored by list mode.
|
|
it('writes the listing to --output-file so the command can read it', () => {
|
|
execFileSync('node', [DRIFT_CLI, HEALTHY, '--save', '--name', TEST_BASELINE], RUN);
|
|
|
|
const outFile = join(mkdtempSync(join(tmpdir(), 'drift-list-')), 'list.json');
|
|
execFileSync('node', [DRIFT_CLI, '--list', '--output-file', outFile], RUN);
|
|
|
|
assert.ok(existsSync(outFile), '--list must honour --output-file');
|
|
const payload = JSON.parse(readFileSync(outFile, 'utf-8'));
|
|
assert.ok(Array.isArray(payload.baselines));
|
|
assert.ok(payload.baselines.find(b => b.name === TEST_BASELINE));
|
|
});
|
|
});
|
|
|
|
describe('drift-cli compare', () => {
|
|
it('outputs valid JSON with --json flag', () => {
|
|
// Save baseline first
|
|
execFileSync('node', [DRIFT_CLI, HEALTHY, '--save', '--name', TEST_BASELINE], RUN);
|
|
|
|
// Compare same fixture against itself
|
|
const result = execFileSync('node', [DRIFT_CLI, HEALTHY, '--baseline', TEST_BASELINE, '--json'], RUN);
|
|
const diff = JSON.parse(result);
|
|
assert.ok('newFindings' in diff);
|
|
assert.ok('resolvedFindings' in diff);
|
|
assert.ok('unchangedFindings' in diff);
|
|
assert.ok('movedFindings' in diff);
|
|
assert.ok('scoreChange' in diff);
|
|
assert.ok('summary' in diff);
|
|
});
|
|
|
|
it('shows stable trend when comparing same fixture', () => {
|
|
execFileSync('node', [DRIFT_CLI, HEALTHY, '--save', '--name', TEST_BASELINE], RUN);
|
|
|
|
const result = execFileSync('node', [DRIFT_CLI, HEALTHY, '--baseline', TEST_BASELINE, '--json'], RUN);
|
|
const diff = JSON.parse(result);
|
|
assert.equal(diff.summary.trend, 'stable');
|
|
assert.equal(diff.summary.newCount, 0);
|
|
assert.equal(diff.summary.resolvedCount, 0);
|
|
});
|
|
|
|
it('exits with code 1 when baseline not found', () => {
|
|
assert.throws(() => {
|
|
execFileSync('node', [DRIFT_CLI, HEALTHY, '--baseline', `nonexistent_${Date.now()}`, '--json'], RUN);
|
|
}, (err) => {
|
|
assert.equal(err.status, 1);
|
|
return true;
|
|
});
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// M-BUG-21 — argument validation.
|
|
//
|
|
// The arg loop ended in `else if (!args[i].startsWith('-')) targetPath = args[i]`,
|
|
// so an UNKNOWN flag was dropped silently and its VALUE became the scan target.
|
|
// `drift-cli.mjs . --output-file /tmp/x.json` scanned /tmp/x.json — a path that
|
|
// does not exist — producing a near-empty scan and therefore phantom drift
|
|
// against the baseline, permanently and without a single warning.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/** Run the CLI capturing both streams regardless of exit code. */
|
|
function spawnCapture(args) {
|
|
const res = spawnSync('node', [DRIFT_CLI, ...args], RUN);
|
|
return { status: res.status, stdout: String(res.stdout || ''), stderr: String(res.stderr || '') };
|
|
}
|
|
|
|
/** Run the CLI expecting a non-zero exit; returns { status, stderr }. */
|
|
function runExpectingFailure(args) {
|
|
try {
|
|
execFileSync('node', [DRIFT_CLI, ...args], RUN);
|
|
return { status: 0, stderr: '' };
|
|
} catch (err) {
|
|
return { status: err.status, stderr: String(err.stderr || '') };
|
|
}
|
|
}
|
|
|
|
describe('drift-cli argument validation (M-BUG-21)', () => {
|
|
it('rejects an unknown flag instead of swallowing its value as the scan target', () => {
|
|
const { status, stderr } = runExpectingFailure([HEALTHY, '--bogus', 'some-value', '--json']);
|
|
assert.equal(status, 3, 'unknown flag must fail loudly, not scan "some-value"');
|
|
assert.match(stderr, /unknown option/i);
|
|
assert.match(stderr, /--bogus/);
|
|
});
|
|
|
|
it('rejects --name without a value instead of silently writing the default baseline', () => {
|
|
// The destructive case: `--save --name` (value missing) fell through to the
|
|
// default baseline name and OVERWROTE an existing baseline without warning.
|
|
const { status, stderr } = runExpectingFailure([HEALTHY, '--save', '--name']);
|
|
assert.equal(status, 3);
|
|
assert.match(stderr, /--name/);
|
|
assert.match(stderr, /requires a value/i);
|
|
});
|
|
|
|
it('rejects --baseline without a value', () => {
|
|
const { status, stderr } = runExpectingFailure([HEALTHY, '--baseline']);
|
|
assert.equal(status, 3);
|
|
assert.match(stderr, /--baseline/);
|
|
assert.match(stderr, /requires a value/i);
|
|
});
|
|
|
|
it('still accepts a bare path as the scan target', () => {
|
|
execFileSync('node', [DRIFT_CLI, HEALTHY, '--save', '--name', TEST_BASELINE, '--json'], RUN);
|
|
const out = JSON.parse(
|
|
execFileSync('node', [DRIFT_CLI, HEALTHY, '--baseline', TEST_BASELINE, '--json'], RUN)
|
|
);
|
|
assert.equal(out.summary.trend, 'stable');
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// ux-rules rule 2 — every scanner Bash call uses `--output-file <path>`.
|
|
// drift-cli had no such flag, and its default-mode report goes to STDERR, so
|
|
// commands/drift.md ("... 2>/dev/null" + "Read stdout") captured NOTHING.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('drift-cli --output-file (ux-rules rule 2)', () => {
|
|
const outDir = mkdtempSync(join(tmpdir(), 'drift-outfile-'));
|
|
|
|
it('writes the diff to the given path in --json mode', () => {
|
|
execFileSync('node', [DRIFT_CLI, HEALTHY, '--save', '--name', TEST_BASELINE], RUN);
|
|
const target = join(outDir, 'diff-json.json');
|
|
|
|
execFileSync('node', [DRIFT_CLI, HEALTHY, '--baseline', TEST_BASELINE, '--json', '--output-file', target], RUN);
|
|
|
|
assert.ok(existsSync(target), '--output-file must create the file');
|
|
const written = JSON.parse(readFileSync(target, 'utf-8'));
|
|
assert.ok('summary' in written);
|
|
assert.ok('newFindings' in written);
|
|
});
|
|
|
|
it('writes the diff to the given path in default mode', () => {
|
|
execFileSync('node', [DRIFT_CLI, HEALTHY, '--save', '--name', TEST_BASELINE], RUN);
|
|
const target = join(outDir, 'diff-default.json');
|
|
|
|
execFileSync('node', [DRIFT_CLI, HEALTHY, '--baseline', TEST_BASELINE, '--output-file', target], RUN);
|
|
|
|
assert.ok(existsSync(target), 'default mode must honour --output-file too');
|
|
const written = JSON.parse(readFileSync(target, 'utf-8'));
|
|
assert.ok('summary' in written);
|
|
});
|
|
|
|
it('does not treat the --output-file value as the scan target', () => {
|
|
execFileSync('node', [DRIFT_CLI, HEALTHY, '--save', '--name', TEST_BASELINE], RUN);
|
|
const target = join(outDir, 'not-a-scan-target.json');
|
|
|
|
const stdout = execFileSync(
|
|
'node', [DRIFT_CLI, HEALTHY, '--baseline', TEST_BASELINE, '--json', '--output-file', target], RUN
|
|
);
|
|
|
|
// Scanning HEALTHY against a baseline of HEALTHY is stable. If the output
|
|
// path had been swallowed as the target, this would be phantom drift.
|
|
assert.equal(JSON.parse(stdout).summary.trend, 'stable');
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// M-BUG-27 — baseline anchor guard.
|
|
//
|
|
// diff-engine never compared the baseline's target_path against the current
|
|
// scan target. Comparing a repo against a baseline saved from an unrelated
|
|
// directory yielded 100% phantom drift reported as trend "improving" — a
|
|
// reassuring, entirely false signal, and the DEFAULT behaviour (--baseline
|
|
// default). The warning goes to stderr in every mode; --raw stdout stays
|
|
// byte-identical to the frozen v5.0.0 snapshot.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('drift-cli baseline anchor guard (M-BUG-27)', () => {
|
|
const OTHER = resolve(FIXTURES, 'conflict-project');
|
|
|
|
it('warns when the baseline was anchored to a different target path', () => {
|
|
execFileSync('node', [DRIFT_CLI, HEALTHY, '--save', '--name', TEST_BASELINE], RUN);
|
|
|
|
const res = spawnCapture([OTHER, '--baseline', TEST_BASELINE, '--json']);
|
|
assert.match(res.stderr, /different (target|path)/i, 'must warn about the anchor mismatch');
|
|
assert.match(res.stderr, /healthy-project/, 'warning should name the baseline target');
|
|
});
|
|
|
|
it('does not warn when the target paths match', () => {
|
|
execFileSync('node', [DRIFT_CLI, HEALTHY, '--save', '--name', TEST_BASELINE], RUN);
|
|
const res = spawnCapture([HEALTHY, '--baseline', TEST_BASELINE, '--json']);
|
|
assert.doesNotMatch(res.stderr, /different (target|path)/i);
|
|
});
|
|
|
|
it('records the anchor mismatch in the --output-file payload (default mode)', () => {
|
|
// The stderr warning alone is not enough: commands/drift.md runs the CLI
|
|
// under `2>/dev/null`, so a stderr-only warning is invisible to the very
|
|
// caller that needs it. The machine-readable flag must ride in the file.
|
|
execFileSync('node', [DRIFT_CLI, HEALTHY, '--save', '--name', TEST_BASELINE], RUN);
|
|
const target = join(mkdtempSync(join(tmpdir(), 'drift-anchor-')), 'diff.json');
|
|
|
|
spawnCapture([OTHER, '--baseline', TEST_BASELINE, '--output-file', target]);
|
|
|
|
const written = JSON.parse(readFileSync(target, 'utf-8'));
|
|
assert.ok(written._baselineAnchor, '_baselineAnchor must be present');
|
|
assert.equal(written._baselineAnchor.matches, false);
|
|
assert.match(written._baselineAnchor.baselineTarget, /healthy-project/);
|
|
assert.match(written._baselineAnchor.currentTarget, /conflict-project/);
|
|
});
|
|
|
|
it('marks the anchor as matching when targets agree', () => {
|
|
execFileSync('node', [DRIFT_CLI, HEALTHY, '--save', '--name', TEST_BASELINE], RUN);
|
|
const target = join(mkdtempSync(join(tmpdir(), 'drift-anchor-ok-')), 'diff.json');
|
|
|
|
execFileSync('node', [DRIFT_CLI, HEALTHY, '--baseline', TEST_BASELINE, '--output-file', target], RUN);
|
|
|
|
const written = JSON.parse(readFileSync(target, 'utf-8'));
|
|
assert.equal(written._baselineAnchor.matches, true);
|
|
});
|
|
|
|
it('keeps --raw stdout free of the warning (frozen v5.0.0 contract)', () => {
|
|
execFileSync('node', [DRIFT_CLI, HEALTHY, '--save', '--name', TEST_BASELINE], RUN);
|
|
const res = spawnCapture([OTHER, '--baseline', TEST_BASELINE, '--raw']);
|
|
const parsed = JSON.parse(res.stdout);
|
|
assert.ok(!('baselineAnchor' in parsed), '--raw stdout must stay v5.0.0-shaped');
|
|
assert.ok(!('targetMismatch' in parsed), '--raw stdout must stay v5.0.0-shaped');
|
|
});
|
|
});
|