// tests/lib/plan-scope-sweep.test.mjs // K6 — the sweep for files the plan does not name. // `plan-scope-sweep.mjs --plan --base `: changed files since the // merge base, minus every step's `Files:` / `Test first:` file / manifest // `expected_paths`, minus a named process-file list, must be empty. // 0 = nothing outside the plan, 1 = deviation (listed), 2 = could not run. import { test } from 'node:test'; import { strict as assert } from 'node:assert'; import { spawnSync, execFileSync } from 'node:child_process'; import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs'; import { join, dirname } from 'node:path'; import { tmpdir } from 'node:os'; import { fileURLToPath } from 'node:url'; import { plannedPaths, PROCESS_FILES } from '../../lib/verification/plan-scope-sweep.mjs'; import { buildPlan } from '../helpers/plan-v18-builder.mjs'; const HERE = dirname(fileURLToPath(import.meta.url)); const SWEEP = join(HERE, '..', '..', 'lib', 'verification', 'plan-scope-sweep.mjs'); const git = (dir, ...args) => execFileSync('git', ['-C', dir, ...args], { encoding: 'utf-8', stdio: 'pipe' }); function repo() { const dir = mkdtempSync(join(tmpdir(), 'scope-sweep-')); git(dir, 'init', '-q', '-b', 'main'); git(dir, 'config', 'user.email', 't@example.invalid'); git(dir, 'config', 'user.name', 't'); git(dir, 'config', 'commit.gpgsign', 'false'); writeFileSync(join(dir, 'README.md'), '# r\n'); git(dir, 'add', 'README.md'); git(dir, 'commit', '-q', '-m', 'base'); mkdirSync(join(dir, '.claude', 'projects', 'p1'), { recursive: true }); writeFileSync(join(dir, '.claude', 'projects', 'p1', 'plan.md'), buildPlan()); mkdirSync(join(dir, 'src')); mkdirSync(join(dir, 'tests')); return dir; } function put(dir, rel, body = 'x\n') { mkdirSync(dirname(join(dir, rel)), { recursive: true }); writeFileSync(join(dir, rel), body); } function run(dir, extra = []) { const r = spawnSync('node', [SWEEP, '--plan', '.claude/projects/p1/plan.md', '--base', 'main', ...extra], { cwd: dir, encoding: 'utf-8' }); let json = null; try { json = JSON.parse(r.stdout); } catch { /* asserted by caller */ } return { code: r.status, json, out: r.stdout, err: r.stderr }; } // ---- the test that failed first ---- test('K6: one changed file outside the plan → 1, and it is listed', () => { const dir = repo(); try { git(dir, 'checkout', '-q', '-b', 'work'); put(dir, 'src/mod1.mjs'); put(dir, 'tests/mod1.test.mjs'); put(dir, 'src/unplanned.mjs'); git(dir, 'add', 'src', 'tests', '.claude'); git(dir, 'commit', '-q', '-m', 'work'); const r = run(dir); assert.equal(r.code, 1, r.out + r.err); assert.deepEqual(r.json.outside, ['src/unplanned.mjs']); } finally { rmSync(dir, { recursive: true, force: true }); } }); test('K6: only planned files and process files changed → 0', () => { const dir = repo(); try { git(dir, 'checkout', '-q', '-b', 'work'); put(dir, 'src/mod1.mjs'); put(dir, 'tests/mod2.test.mjs'); put(dir, '.claude/projects/p1/progress.json', '{}'); git(dir, 'add', 'src', 'tests', '.claude'); git(dir, 'commit', '-q', '-m', 'work'); const r = run(dir); assert.equal(r.code, 0, r.out + r.err); assert.deepEqual(r.json.outside, []); } finally { rmSync(dir, { recursive: true, force: true }); } }); test('K6: uncommitted and untracked changes count too', () => { const dir = repo(); try { git(dir, 'add', '.claude'); git(dir, 'commit', '-q', '-m', 'plan'); put(dir, 'README.md', '# changed\n'); put(dir, 'stray.txt'); const r = run(dir); assert.equal(r.code, 1); assert.deepEqual(r.json.outside, ['README.md', 'stray.txt']); } finally { rmSync(dir, { recursive: true, force: true }); } }); test('K6: --process names extra process files; a deviation is allowed once the plan names the file', () => { const dir = repo(); try { put(dir, 'CHANGELOG.md'); assert.equal(run(dir).code, 1); assert.equal(run(dir, ['--process', 'CHANGELOG.md']).code, 0); const plan = buildPlan().replace('- **Files:** `src/mod1.mjs`', '- **Files:** `src/mod1.mjs`, `CHANGELOG.md`'); writeFileSync(join(dir, '.claude', 'projects', 'p1', 'plan.md'), plan); assert.equal(run(dir).code, 0); } finally { rmSync(dir, { recursive: true, force: true }); } }); test('K6: a bad ref, a missing plan, or a plan without steps → 2', () => { const dir = repo(); try { assert.equal(run(dir, ['--base', 'no-such-ref']).code, 2); const r = spawnSync('node', [SWEEP, '--plan', 'missing.md', '--base', 'main'], { cwd: dir, encoding: 'utf-8' }); assert.equal(r.status, 2); writeFileSync(join(dir, '.claude', 'projects', 'p1', 'plan.md'), '# no steps\n'); assert.equal(run(dir).code, 2); } finally { rmSync(dir, { recursive: true, force: true }); } }); test('plannedPaths collects Files, Test first files and manifest expected_paths', () => { const p = plannedPaths(buildPlan()); for (const f of ['src/mod1.mjs', 'tests/mod1.test.mjs', 'src/mod2.mjs', 'tests/mod2.test.mjs']) { assert.ok(p.includes(f), `${f} missing from ${p.join(',')}`); } assert.ok(PROCESS_FILES.length > 0); }); // ---- review findings (independent code review, 06.10) ---- test('K6: renames list both paths, deletions count, non-ASCII and spaced names match when planned', () => { const dir = repo(); try { put(dir, 'src/old name.mjs'); put(dir, 'src/gone.mjs'); git(dir, 'add', 'src'); git(dir, 'commit', '-q', '-m', 'more'); git(dir, 'checkout', '-q', '-b', 'work'); git(dir, 'mv', 'src/old name.mjs', 'src/prøve fil.mjs'); git(dir, 'rm', '-q', 'src/gone.mjs'); git(dir, 'commit', '-q', '-m', 'work'); const r = run(dir); assert.equal(r.code, 1); assert.deepEqual(r.json.outside, ['src/gone.mjs', 'src/old name.mjs', 'src/prøve fil.mjs']); const plan = buildPlan().replace('- **Files:** `src/mod1.mjs`', '- **Files:** `src/mod1.mjs`, `src/old name.mjs`, `src/prøve fil.mjs`, `src/gone.mjs`'); writeFileSync(join(dir, '.claude', 'projects', 'p1', 'plan.md'), plan); const ok = run(dir); assert.equal(ok.code, 0, ok.out); } finally { rmSync(dir, { recursive: true, force: true }); } }); test('K6: a glob with no fixed directory (`**/*`) plans nothing and is reported as ignored', () => { const dir = repo(); try { put(dir, 'src/anything.mjs'); const plan = buildPlan().replace('- **Files:** `src/mod1.mjs`', '- **Files:** `src/mod1.mjs`, `**/*`'); writeFileSync(join(dir, '.claude', 'projects', 'p1', 'plan.md'), plan); const r = run(dir); assert.equal(r.code, 1); assert.deepEqual(r.json.ignored, ['**/*']); assert.ok(r.json.outside.includes('src/anything.mjs')); const scoped = buildPlan().replace('- **Files:** `src/mod1.mjs`', '- **Files:** `src/**`'); writeFileSync(join(dir, '.claude', 'projects', 'p1', 'plan.md'), scoped); assert.equal(run(dir).code, 0, 'a glob under a fixed directory still plans'); } finally { rmSync(dir, { recursive: true, force: true }); } }); test('K6: a flag without its value, or a directory as the plan → 2, never a crash', () => { const dir = repo(); try { for (const extra of [['--process'], ['--repo']]) { const r = run(dir, extra); assert.equal(r.code, 2, `${extra.join(' ')}: ${r.err}`); assert.ok(r.json); } const r = spawnSync('node', [SWEEP, '--plan', 'src', '--base', 'main'], { cwd: dir, encoding: 'utf-8' }); assert.equal(r.status, 2, r.stderr); } finally { rmSync(dir, { recursive: true, force: true }); } });