/simplify over ed6699d..HEAD (closing review of the remediation): - red-proof reads and parses the step once (planStep) for the test list and tests_only; normTest goes, plan-schema exports normPlanPath, also used by plan-scope-sweep. - plan-schema builds LIST_ITEM and the 1.9 File: line from one LIST_MARKER; the below-1.9 File: regex ends in a lookahead instead of slicing back one. - the workflow script hoists tripletOf and puts back through dedupByTriplet. - coordinator-contract keeps the rank in its JSDoc only; engine-findings drops a redundant String(). - tests: reuse knownIssues, trekplan19Block, step1Fields, one red-proof cli helper with childEnv, one payload-pair builder; drop a test the decision-20 definition test covers. No behaviour change. Suite 2462 (2460/0/2). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
214 lines
9.4 KiB
JavaScript
214 lines
9.4 KiB
JavaScript
// 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, normPlanPath as norm } 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;
|
||
}
|
||
|
||
/** 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);
|
||
}
|