voyage/lib/verification/plan-scope-sweep.mjs
Kjell Tore Guttormsen 0e612c88ac
fix: round 3 of the release review of 6.3.0
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>
2026-10-10 23:25:24 +02:00

198 lines
8 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`
// 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);
}