voyage/lib/verification/plan-scope-sweep.mjs
Kjell Tore Guttormsen ed6699da58
refactor(simplify): one source per rule, shared test helpers, a faster isolation test [skip-docs]
/simplify over a97efb6..HEAD (the plan_version 1.9 / trial-4 delivery).

- coordinator-contract: engine names come from engine-findings ENGINES;
  one markedMajorCodeReview predicate under both lowering checks;
  isEngineFinding everywhere; the unreachable branch (c) of
  illegalBriefLowering folded into (e).
- armB workflow: RULE_KEYS = Object.keys(RULE_TIERS); the engine map is a
  literal pinned equal to ENGINES by the workflow test.
- plan-schema: usesFilesGrammar() is the one 1.9 check (validator, sweep,
  testFirstFiles, stepTestsOnlyFromFields); opensEntry built on opensChunk.
- plan-validator: one issue builder for the Files and Test first grammar.
- plan-scope-sweep: the 1.9 and the 6.2.0 reader are one if/else.
- artifact-commit: TRAILER_RE/trailerProblem no longer exported (no
  importer); the order comment says the CLI finds the repo root first.
- tests: the lock drivers in session-id-isolation (d) run side by side per
  mode (~100 s -> ~30 s); a shared artifact-repo helper; severityRank and
  extractFindingsBlock reused; childEnv(env) without a spread; one copy of
  the PLAN_VERSION_CURRENT check.

Plan readers: validator errors/warnings, plannedPaths, fieldPaths,
testFirstFiles and stepTestsOnlyFromFields compared byte for byte against
HEAD over 510 plans / 1692 steps plus 210 synthetic 1.7-1.9 cases: same.
Suite 2384 (2382/0/2).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 21:06:47 +02:00

216 lines
9.4 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// lib/verification/plan-scope-sweep.mjs
// K6 — the sweep for files the plan does not name.
//
// node plan-scope-sweep.mjs --plan <plan.md> --base <ref> [--repo <dir>] [--process <path|glob>]...
// 0 = every changed file is named by the plan or is a process file
// 1 = deviation: changed files outside the plan (listed in `outside`)
// 2 = could not run (not a git repo, unknown ref, plan missing or without steps)
// JSON on stdout: {code, status, base, merge_base, changed, outside, planned, process}
//
// changed = `git diff --name-only --no-renames <merge-base>` (committed AND
// uncommitted tracked changes) + untracked, non-ignored files
// planned = every step's `Files:` paths, its `Test first:` File, and its
// manifest `expected_paths`. From plan_version 1.9 `Files:` and the
// `Test first:` `File:` line are read by the 1.9 grammar
// (plan-schema entryPaths / testFirstFiles): the leading backticked
// paths of each entry, never a path its description mentions.
// Below 1.9 (or without a version) K6 keeps its own reader,
// unchanged (operator 2026-10-11). It never reads `Files:` through
// the tests-only reader (plan-schema fieldPaths).
// process = PROCESS_FILES (the plan itself, its project directory, Voyage's
// own planning areas) + any `--process` entries
// outside = changed − planned − process. It must be empty.
//
// A deviation is allowed only when the plan names the file in the same
// commit: the sweep reads the plan from the working tree, so adding the path
// to the step's `Files:` is what clears it.
//
// `--repo` defaults to the current directory. Paths in the plan are relative
// to the repository root (git top level), as git reports changed files.
// Paths in a plan may end in `/` (a directory) or use `*` / `**` globs; a glob
// must start with a fixed directory (`src/**` is fine, `**/*` would plan the
// whole repo and is ignored and reported in `ignored`). git output is read
// NUL-separated (-z), so non-ASCII names and spaces arrive unquoted.
//
// Borrowed: the sweep for files outside the plan from ai-native-sdlc (MIT).
// Voyage before this checked only each step's expected and forbidden paths.
import { readFileSync, existsSync, realpathSync } from 'node:fs';
import { spawnSync } from 'node:child_process';
import { resolve, relative, dirname, isAbsolute, sep } from 'node:path';
import { sliceSteps, parseStepFields, entryPaths, testFirstFiles, extractPlanVersion, usesFilesGrammar } from '../parsers/plan-schema.mjs';
import { parseManifest } from '../parsers/manifest-yaml.mjs';
import { isMain } from '../util/is-main.mjs';
// Files Voyage's own process writes while planning and executing.
export const PROCESS_FILES = Object.freeze([
'.claude/projects/**',
'.claude/plans/**',
'.claude/trekplan-sessions/**',
]);
const BACKTICK_SPAN = /`([^`\n]+)`/g;
// Backticked: spaces allowed (`docs/my file.md`); bare: no whitespace.
const looksLikeTickedPath = s => /[./]/.test(s) && !/^https?:/.test(s) && !s.startsWith('-') && s.trim() === s;
const looksLikePath = s => !/\s/.test(s) && looksLikeTickedPath(s);
function pathsIn(text) {
const out = [];
for (const m of text.matchAll(BACKTICK_SPAN)) if (looksLikeTickedPath(m[1])) out.push(m[1]);
return out;
}
const norm = p => p.replace(/\\/g, '/').replace(/^\.\//, '');
/** Every path the plan names: Files, Test first File, manifest expected_paths. */
export function plannedPaths(planText) {
const out = new Set();
const version = extractPlanVersion(planText);
const grammar = usesFilesGrammar(version);
for (const s of sliceSteps(planText)) {
const f = parseStepFields(s.body);
const files = f.get('Files');
if (grammar) {
// plan_version 1.9: the grammar decides what each entry names.
if (files) for (const p of entryPaths(files.text).files) out.add(norm(p));
for (const p of testFirstFiles(f, version)) out.add(norm(p));
} else {
// v6.2.0's own reading, kept as it was (operator 2026-10-11): every
// backticked span that looks like a path, and plain words only when the
// field has none. A field that names its paths in backticks plans those,
// never a path its description mentions. Release review of 6.3.0, rounds
// 3-5: reading Files: through the tests-only check's reader
// (plan-schema.mjs fieldPaths) dropped paths v6.2.0 planned and planned
// directories a description named, so K6 has its own reader again; the
// two answer different questions (what may change; is every named file a test).
if (files) {
const ticked = pathsIn(files.text);
// Older plans write bare paths: `- Files: a.ts, b.ts`.
const bare = ticked.length ? [] : files.text.split(/[,\s]+/).filter(looksLikePath);
for (const p of [...ticked, ...bare]) out.add(norm(p));
}
const tf = f.get('Test first');
if (tf) {
for (const line of tf.text.split('\n')) {
if (/\bFile:/i.test(line)) for (const p of pathsIn(line)) out.add(norm(p));
}
}
}
const man = parseManifest(s.body);
for (const p of man?.parsed?.expected_paths ?? []) {
if (typeof p === 'string') out.add(norm(p));
}
}
return [...out].sort();
}
function globToRegExp(glob) {
let re = '';
for (let i = 0; i < glob.length; i++) {
const c = glob[i];
if (c === '*' && glob[i + 1] === '*') { re += '.*'; i++; if (glob[i + 1] === '/') i++; }
else if (c === '*') re += '[^/]*';
else if (c === '?') re += '[^/]';
else re += c.replace(/[.+^${}()|[\]\\]/g, '\\$&');
}
return new RegExp(`^${re}$`);
}
// A glob with no fixed directory in front (`*`, `**/*`, `*.js`) names no place.
export const unboundedGlob = p => /[*?]/.test(p) && /[*?]/.test(norm(p).split('/')[0]);
function matcher(patterns) {
const exact = new Set();
const dirs = [];
const globs = [];
for (const p of patterns.map(norm)) {
if (p.endsWith('/')) dirs.push(p);
else if (/[*?]/.test(p)) globs.push(globToRegExp(p));
else exact.add(p);
}
return f => exact.has(f) || dirs.some(d => f.startsWith(d)) || globs.some(g => g.test(f));
}
function git(repo, args) {
const r = spawnSync('git', ['-C', repo, ...args], { encoding: 'utf-8', maxBuffer: 64 * 1024 * 1024 });
if (r.status !== 0) throw new Error(`git ${args[0]}: ${(r.stderr || r.error?.message || '').trim().split('\n')[0]}`);
return r.stdout;
}
const nulList = out => out.split('\0').filter(Boolean);
/**
* @returns {{code: 0|1|2, status: string, outside?: string[], changed?: string[], planned?: string[], reason?: string}}
*/
export function sweep({ plan, base, repo = process.cwd(), process: extra = [] }) {
const repoAbs = resolve(repo);
const planAbs = resolve(repoAbs, plan);
if (!existsSync(planAbs)) return { code: 2, status: 'error', reason: `plan not found: ${plan}` };
let top;
let mergeBase;
let changed;
let planText;
try {
planText = readFileSync(planAbs, 'utf-8');
top = git(repoAbs, ['rev-parse', '--show-toplevel']).trim();
mergeBase = git(repoAbs, ['merge-base', base, 'HEAD']).trim();
const tracked = nulList(git(top, ['diff', '--name-only', '--no-renames', '-z', mergeBase]));
const untracked = nulList(git(top, ['ls-files', '--others', '--exclude-standard', '-z']));
changed = [...new Set([...tracked, ...untracked])].sort();
} catch (e) {
return { code: 2, status: 'error', reason: e.message };
}
if (sliceSteps(planText).length === 0) return { code: 2, status: 'error', reason: `plan has no "### Step N:" steps: ${plan}` };
const all = plannedPaths(planText);
const ignored = all.filter(unboundedGlob);
const planned = all.filter(p => !unboundedGlob(p));
const planRel = norm(relative(realpathSync(top), realpathSync(planAbs)).split(sep).join('/'));
const processList = [...PROCESS_FILES, planRel, ...extra];
const planDir = dirname(planRel);
if (planDir !== '.' && planDir.startsWith('.claude/')) processList.push(`${planDir}/`);
const isPlanned = matcher(planned);
const isProcess = matcher(processList);
const outside = changed.filter(f => !isPlanned(f) && !isProcess(f));
return {
code: outside.length === 0 ? 0 : 1,
status: outside.length === 0 ? 'clean' : 'deviation',
base,
merge_base: mergeBase,
changed,
outside,
planned,
ignored,
process: processList,
};
}
// ---- CLI --------------------------------------------------------------------
if (isMain(import.meta.url)) {
const argv = process.argv.slice(2);
const o = { process: [] };
let bad = null;
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
const v = argv[i + 1];
if (['--plan', '--base', '--repo', '--process'].includes(a)) {
if (v === undefined || v.startsWith('--')) { bad = bad ?? `${a} needs a value`; continue; }
i++;
if (a === '--plan') o.plan = v;
else if (a === '--base') o.base = v;
else if (a === '--repo') o.repo = v;
else o.process.push(v);
} else bad = bad ?? `unknown argument: ${a}`;
}
let result;
if (bad || !o.plan || !o.base) {
result = { code: 2, status: 'error', reason: bad ?? 'usage: plan-scope-sweep.mjs --plan <plan.md> --base <ref> [--repo <dir>] [--process <path|glob>]...' };
} else {
result = sweep(o);
}
process.stdout.write(JSON.stringify(result, null, 2) + '\n');
if (result.code === 1) process.stderr.write(`plan-scope-sweep: ${result.outside.length} file(s) outside the plan:\n${result.outside.map(f => ` ${f}`).join('\n')}\n`);
process.exit(result.code);
}