// lib/verification/plan-scope-sweep.mjs // K6 — the sweep for files the plan does not name. // // node plan-scope-sweep.mjs --plan --base [--repo ] [--process ]... // 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 ` (committed AND // uncommitted tracked changes) + untracked, non-ignored files // planned = every step's `Files:` paths, its `Test first:` File, and its // manifest `expected_paths` // 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 { fileURLToPath } from 'node:url'; import { sliceSteps, parseStepFields } from '../parsers/plan-schema.mjs'; import { parseManifest } from '../parsers/manifest-yaml.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(); for (const s of sliceSteps(planText)) { const f = parseStepFields(s.body); const files = f.get('Files'); // 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 -------------------------------------------------------------------- function isMain() { try { return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url)); } catch { return false; } } if (isMain()) { 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 --base [--repo ] [--process ]...' }; } 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); }