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'); }); }); 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 `. // 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'); }); });