Wave 1, order 3 (P3 plan mode as the start, P2 design as its own part, P9
plan side). Contracts K3-K6 for the later merges.
- plan_version 1.8 (1.7 plans stay valid): Test first on every step, one
End-to-end ("run the thing") step, optional Interface in / Interface out /
Review focus fields, a ceremony class in the header (absent =
architectural), and for architectural plans a ## Design part: module map
without cycles, architecture rules that name their enforcer and the step
that proves them, a requirements check asked as keep / change.
- K3 lib/validators/plan-gate.mjs: the critic verdict is recorded INSIDE the
plan (Claude Code's plan approval hands a hook only the plan file's text),
bound to the hash taken before the reviewers read it; 0 go / 1 stop / 2
could not run; at most 3 critic rounds, then stop and ask.
- K5 lib/verification/arch-rules.mjs: runs the target repo's own
dependency-cruiser or import-linter (installs nothing; 2 = not measured,
never green); --prove --rule shows pass -> fail on a planted violation of
that rule -> pass. Verified against dependency-cruiser 18.5.0 and
import-linter 2.15; madge 8.0.0 left out (missed a planted .mjs cycle).
- K6 lib/verification/plan-scope-sweep.mjs: changed files no step names.
- /trekplan Phases 8-10: design part, bounded critic loop, the gate before
"execute", a plan-mode path through ExitPlanMode (not yet run end to end
inside plan mode). plan-critic 10 -> 12 dimensions; scope-guardian checks
the ceremony.
- Fixed on the way: plan-validator and plan-review-dedup used the
file://argv[1] main guard (no-op under a symlink or a space); CRLF plans
failed every manifest; the dedup dropped finding severity.
Borrowed, credited in place: obra/superpowers, Kiro (docs), addyosmani/
agent-skills, mattpocock/skills, GitHub Spec Kit, bashebr/ai-native-sdlc,
Boris Cherny's public threads.
Suite 1497 (1495 / 0 / 2), 86 new tests.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
177 lines
7.4 KiB
JavaScript
177 lines
7.4 KiB
JavaScript
// tests/lib/plan-scope-sweep.test.mjs
|
|
// K6 — the sweep for files the plan does not name.
|
|
// `plan-scope-sweep.mjs --plan <plan.md> --base <ref>`: 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 }); }
|
|
});
|