Round 3 (v6.2.0..96cb977, BLOCK: 1 BLOCKER, 8 MAJOR, 2 MINOR), each fixed behind a test that failed first. - Every allowlisted runner, not only git, takes plain words and a short list of options that only read: an option that writes a file or loads code (--test-reporter-destination, --outputFile, --junitxml, --basetemp, --import, --config, -p, -o, -exec, a reporter path) is on no list; after `npm test --` only paths follow; `make test` takes no VAR=value. A built-in node test reporter (tap, spec, dot, junit, lcov) stays allowed. - /trekexecute: a restore the guard refuses (destructive git, in the mode that shows no prompts) or the operator declines is never recorded as reverted: simplify = "revert-refused" and the run stops before review; a refused step revert is reported with its paths. - stepTestsOnly: a fixture document under tests/ is a test file, "e.g." is not a path, ./ is dropped; the plan-file sweep reads Files: with the same reader (fieldPaths); the validator hands its parsed fields over. - plan-validator --steps-from asks for Verify: on a 1.8 plan too. - skill frontmatter: a value starting with # is a hazard; a quote after an escaped backslash closes the value. Routing reads kind tags with hasOwn. - release-check reads scope_sha_end as written (an all-digit sha keeps its leading zero), and Voyage's own artifacts under .claude/projects/ and .claude/voyage/ committed after the review do not count as unreviewed. Suite 2170 (2168/0/2). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
198 lines
8 KiB
JavaScript
198 lines
8 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`
|
||
// 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, fieldPaths } 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;
|
||
// A backticked path in `Test first:` (spaces allowed: `docs/my file.md`).
|
||
const looksLikeTickedPath = s => /[./]/.test(s) && !/^https?:/.test(s) && !s.startsWith('-') && s.trim() === 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');
|
||
// One reader with the tests-only check (plan-schema.mjs fieldPaths):
|
||
// backticked spans as written, else every path-like word.
|
||
if (files) for (const p of fieldPaths(files.text)) 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 <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);
|
||
}
|