feat(plan): plan 1.8, the plan gate, the design part and the plan-side checks
Wave 1, order 3 (P3 plan mode as the start, P2 design as its own part, P9
plan side). Contracts K3-K6 for the later merges.
- plan_version 1.8 (1.7 plans stay valid): Test first on every step, one
End-to-end ("run the thing") step, optional Interface in / Interface out /
Review focus fields, a ceremony class in the header (absent =
architectural), and for architectural plans a ## Design part: module map
without cycles, architecture rules that name their enforcer and the step
that proves them, a requirements check asked as keep / change.
- K3 lib/validators/plan-gate.mjs: the critic verdict is recorded INSIDE the
plan (Claude Code's plan approval hands a hook only the plan file's text),
bound to the hash taken before the reviewers read it; 0 go / 1 stop / 2
could not run; at most 3 critic rounds, then stop and ask.
- K5 lib/verification/arch-rules.mjs: runs the target repo's own
dependency-cruiser or import-linter (installs nothing; 2 = not measured,
never green); --prove --rule shows pass -> fail on a planted violation of
that rule -> pass. Verified against dependency-cruiser 18.5.0 and
import-linter 2.15; madge 8.0.0 left out (missed a planted .mjs cycle).
- K6 lib/verification/plan-scope-sweep.mjs: changed files no step names.
- /trekplan Phases 8-10: design part, bounded critic loop, the gate before
"execute", a plan-mode path through ExitPlanMode (not yet run end to end
inside plan mode). plan-critic 10 -> 12 dimensions; scope-guardian checks
the ceremony.
- Fixed on the way: plan-validator and plan-review-dedup used the
file://argv[1] main guard (no-op under a symlink or a space); CRLF plans
failed every manifest; the dedup dropped finding severity.
Borrowed, credited in place: obra/superpowers, Kiro (docs), addyosmani/
agent-skills, mattpocock/skills, GitHub Spec Kit, bashebr/ai-native-sdlc,
Boris Cherny's public threads.
Suite 1497 (1495 / 0 / 2), 86 new tests.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
2c89a6b580
commit
f5ca220bc0
24 changed files with 3115 additions and 61 deletions
|
|
@ -1,13 +1,17 @@
|
|||
// lib/parsers/plan-schema.mjs
|
||||
// Plan v1.7 schema parser — heading shape detection.
|
||||
// Plan schema parser — heading shape, per-step fields, ceremony, design part.
|
||||
//
|
||||
// The canonical step heading is `### Step N: <title>` (literal colon-space).
|
||||
// Forbidden narrative drift formats (introduced in v1.8.0 to defend against
|
||||
// Opus 4.7 schema-drift): `## Fase N`, `### Phase N`, `### Stage N`, `### Steg N`.
|
||||
//
|
||||
// This module extracts step boundaries; per-step body parsing lives elsewhere.
|
||||
// plan_version 1.8 adds per-step field parsing (`parseStepFields`), the
|
||||
// ceremony class the plan was made under (`small | bounded | architectural`,
|
||||
// absent = architectural) and the design-part helpers (`splitSections`,
|
||||
// `findModuleCycle`). The rules that use them live in plan-validator.mjs.
|
||||
|
||||
import { ok, fail, issue } from '../util/result.mjs';
|
||||
import { parseDocument } from '../util/frontmatter.mjs';
|
||||
|
||||
export const STEP_HEADING_REGEX = /^### Step (\d+):\s+(.+?)\s*$/m;
|
||||
export const STEP_HEADING_GLOBAL = /^### Step (\d+):\s+(.+?)\s*$/gm;
|
||||
|
|
@ -127,3 +131,308 @@ export function validatePlanHeadings(text, opts = {}) {
|
|||
|
||||
return { valid: errors.length === 0, errors, warnings, parsed: { steps, forbidden } };
|
||||
}
|
||||
|
||||
// ---- plan_version 1.8 ------------------------------------------------------
|
||||
|
||||
export const PLAN_VERSION_CURRENT = '1.8';
|
||||
// 1.7 plans stay valid: the 1.8 rules apply only from 1.8 on.
|
||||
export const PLAN_VERSIONS_SUPPORTED = Object.freeze(['1.7', '1.8']);
|
||||
|
||||
/** True when dotted version `v` is >= `min` (numeric per part; null → false). */
|
||||
export function versionAtLeast(v, min) {
|
||||
if (typeof v !== 'string' || !/^\d+(\.\d+)*$/.test(v)) return false;
|
||||
const a = v.split('.').map(Number);
|
||||
const b = min.split('.').map(Number);
|
||||
for (let i = 0; i < Math.max(a.length, b.length); i++) {
|
||||
const x = a[i] ?? 0;
|
||||
const y = b[i] ?? 0;
|
||||
if (x !== y) return x > y;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// ---- ceremony (the brief's size class) -------------------------------------
|
||||
|
||||
export const CEREMONY_VALUES = Object.freeze(['small', 'bounded', 'architectural']);
|
||||
// Heaviest on doubt: a plan or brief that does not say is architectural.
|
||||
export const CEREMONY_DEFAULT = 'architectural';
|
||||
// A plan states its ceremony in exactly two places: frontmatter, or the
|
||||
// "Generated by" header line (`> Generated by … · `ceremony: architectural``).
|
||||
// Prose elsewhere ("the brief sets `ceremony: small`") never counts.
|
||||
const HEADER_CEREMONY = /^> Generated by .*`ceremony:\s*([^`\s]+)\s*`/m;
|
||||
|
||||
/** @returns {{value: string, raw: string|null, defaulted: boolean, valid: boolean}} */
|
||||
export function resolveCeremony(raw) {
|
||||
if (raw === null || raw === undefined || raw === '') {
|
||||
return { value: CEREMONY_DEFAULT, raw: null, defaulted: true, valid: true };
|
||||
}
|
||||
const s = String(raw).trim();
|
||||
return { value: s, raw: s, defaulted: false, valid: CEREMONY_VALUES.includes(s) };
|
||||
}
|
||||
|
||||
function frontmatterCeremony(text) {
|
||||
const doc = typeof text === 'string' ? parseDocument(text) : null;
|
||||
const fm = doc?.parsed?.frontmatter;
|
||||
return fm && typeof fm === 'object' && fm.ceremony !== undefined ? fm.ceremony : null;
|
||||
}
|
||||
|
||||
export function extractPlanCeremony(text) {
|
||||
if (typeof text !== 'string') return resolveCeremony(null);
|
||||
const fm = frontmatterCeremony(text);
|
||||
if (fm !== null) return resolveCeremony(fm);
|
||||
const m = text.match(HEADER_CEREMONY);
|
||||
return resolveCeremony(m ? m[1] : null);
|
||||
}
|
||||
|
||||
/** Read `ceremony:` from a brief's frontmatter. Absent (or no frontmatter) → architectural. */
|
||||
export function ceremonyFromBrief(briefText) {
|
||||
return resolveCeremony(frontmatterCeremony(briefText));
|
||||
}
|
||||
|
||||
// ---- fences ------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Tracks fenced code blocks the CommonMark way: a fence closes only on the same
|
||||
* character (` or ~), at least as long as the opener, with nothing after it.
|
||||
* `feed(line)` returns true when the line is inside a fence or is a fence line.
|
||||
*/
|
||||
export function fenceTracker() {
|
||||
let open = null; // { ch, len }
|
||||
return {
|
||||
feed(line) {
|
||||
const m = line.match(/^\s{0,3}(`{3,}|~{3,})(.*)$/);
|
||||
if (!open) {
|
||||
if (m && !(m[1][0] === '`' && m[2].includes('`'))) {
|
||||
open = { ch: m[1][0], len: m[1].length };
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (m && m[1][0] === open.ch && m[1].length >= open.len && m[2].trim() === '') open = null;
|
||||
return true;
|
||||
},
|
||||
get inside() { return open !== null; },
|
||||
};
|
||||
}
|
||||
|
||||
// ---- per-step fields -------------------------------------------------------
|
||||
|
||||
// A top-level field bullet: `- **Name:** rest`, `- **Name**: rest` or `- Name: rest`.
|
||||
const FIELD_LINE = /^- (?:\*\*)?([A-Z][A-Za-z -]*?)(?::\*\*|\*\*:|:)(?:\s+(.*))?$/;
|
||||
|
||||
/**
|
||||
* Parse a step body into its top-level `- **Field:**` bullets. A field's block
|
||||
* runs until the next top-level field bullet; indented lines (sub-bullets, the
|
||||
* Manifest YAML fence) belong to the field above them. Parsing stops at the
|
||||
* first heading after the step's own, outside fenced code: sliceSteps gives the
|
||||
* last step the rest of the plan, and a later section must not lend it fields.
|
||||
* @returns {Map<string, {name: string, inline: string, text: string}>}
|
||||
* `inline` = text on the bullet line; `text` = inline + the indented block.
|
||||
*/
|
||||
export function parseStepFields(body) {
|
||||
const fields = new Map();
|
||||
if (typeof body !== 'string') return fields;
|
||||
const fence = fenceTracker();
|
||||
let current = null;
|
||||
let first = true;
|
||||
for (const line of body.split(/\r?\n/)) {
|
||||
const inFence = fence.feed(line);
|
||||
if (!inFence && /^#{1,6} /.test(line)) {
|
||||
if (!first) break;
|
||||
first = false;
|
||||
continue;
|
||||
}
|
||||
first = false;
|
||||
if (inFence) {
|
||||
if (current) current.lines.push(line);
|
||||
continue;
|
||||
}
|
||||
const m = line.match(FIELD_LINE);
|
||||
if (m) {
|
||||
current = { name: m[1].trim(), inline: (m[2] ?? '').trim(), lines: [] };
|
||||
if (!fields.has(current.name)) fields.set(current.name, current);
|
||||
continue;
|
||||
}
|
||||
if (current && /^\s/.test(line)) current.lines.push(line);
|
||||
else if (current && line.trim() !== '') current = null;
|
||||
}
|
||||
for (const f of fields.values()) {
|
||||
f.text = [f.inline, ...f.lines].join('\n').trim();
|
||||
delete f.lines;
|
||||
}
|
||||
return fields;
|
||||
}
|
||||
|
||||
// ---- sections (fence-aware) ------------------------------------------------
|
||||
|
||||
/**
|
||||
* Split text at headings of exactly `level` (#-count), ignoring headings inside
|
||||
* fenced code blocks. Text before the first heading has title null.
|
||||
* @returns {Array<{title: string|null, start: number, end: number, text: string}>}
|
||||
* start/end are line indexes (end exclusive).
|
||||
*/
|
||||
export function splitSections(text, level = 2) {
|
||||
const lines = typeof text === 'string' ? text.split('\n') : [];
|
||||
const prefix = '#'.repeat(level) + ' ';
|
||||
const fence = fenceTracker();
|
||||
const out = [];
|
||||
let cur = { title: null, start: 0 };
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const l = lines[i];
|
||||
if (fence.feed(l)) continue;
|
||||
if (l.startsWith(prefix)) {
|
||||
out.push({ ...cur, end: i });
|
||||
cur = { title: l.slice(prefix.length).trim(), start: i };
|
||||
}
|
||||
}
|
||||
out.push({ ...cur, end: lines.length });
|
||||
return out
|
||||
.filter(s => s.end > s.start || s.title !== null)
|
||||
.map(s => ({ ...s, text: lines.slice(s.start, s.end).join('\n') }));
|
||||
}
|
||||
|
||||
/** First section of `level` whose title equals `title` (case-insensitive), or null. */
|
||||
export function findSection(text, title, level = 2) {
|
||||
const want = title.toLowerCase();
|
||||
return splitSections(text, level).find(s => s.title && s.title.toLowerCase() === want) ?? null;
|
||||
}
|
||||
|
||||
/** Every fenced block whose info string is `lang`, in order. */
|
||||
export function fencedBlocks(text, lang) {
|
||||
const out = [];
|
||||
const lines = typeof text === 'string' ? text.split('\n') : [];
|
||||
const fence = fenceTracker();
|
||||
let cur = null;
|
||||
for (const l of lines) {
|
||||
const wasInside = fence.inside;
|
||||
fence.feed(l);
|
||||
if (!wasInside && fence.inside) {
|
||||
cur = new RegExp(`^\\s{0,3}(\`{3,}|~{3,})\\s*${lang}\\s*$`).test(l) ? [] : null;
|
||||
} else if (wasInside && !fence.inside) {
|
||||
if (cur) out.push(cur.join('\n'));
|
||||
cur = null;
|
||||
} else if (cur) {
|
||||
cur.push(l);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ---- module map ------------------------------------------------------------
|
||||
|
||||
// Directives that are not node statements: the keyword must stand alone or be
|
||||
// followed by whitespace, so a node id like `end-user` or `graph.mjs` is kept.
|
||||
const DIRECTIVE = /^(graph|flowchart|subgraph|end|classDef|class|style|linkStyle|direction|click|accTitle|accDescr)(\s|$)/;
|
||||
// Directed and undirected links, after labels are stripped:
|
||||
// --> ---> ==> ===> -.-> -..-> with an optional leading < (both ways);
|
||||
// --- === -.- ~~~ (no direction: not a dependency).
|
||||
const LINK = /\s*(<?-{2,}>|<?={2,}>|<?-\.+->|-{3,}|={3,}|-\.+-|~{3,})\s*/;
|
||||
const NODE_ID = /^[A-Za-z0-9_][\w.\-/]*$/;
|
||||
const ARROWISH = /-->|==>|-\.+->|<--|<==/;
|
||||
|
||||
function stripLabels(line) {
|
||||
return line
|
||||
.replace(/"[^"]*"/g, '')
|
||||
.replace(/:::[\w-]+/g, '')
|
||||
.replace(/\[\[[^\]]*\]\]|\[\([^\]]*\)\]|\[\/[^\]]*\/\]|\[\\[^\]]*\\\]|\[[^\]]*\]/g, '')
|
||||
.replace(/\(\(\([^)]*\)\)\)|\(\([^)]*\)\)|\(\[[^\]]*\]\)|\([^)]*\)/g, '')
|
||||
.replace(/\{\{[^}]*\}\}|\{[^}]*\}/g, '')
|
||||
.replace(/>[^\]\s][^\]]*\]/g, '')
|
||||
.replace(/\|[^|]*\|/g, '')
|
||||
// link text: `-- text -->`, `-. text .->`, `== text ==>`
|
||||
.replace(/--\s+[^-<>=]+?\s+(-{2,}>)/g, '$1')
|
||||
.replace(/-\.\s+[^.<>]+?\s+\.(-+>)/g, '-.$1')
|
||||
.replace(/==\s+[^=<>]+?\s+(={2,}>)/g, '$1');
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a mermaid flowchart into directed edges.
|
||||
* Handles chains (a --> b --> c), `&` groups, `;`-separated statements,
|
||||
* `<-->` (one edge each way), long arrows, labels and `:::class`.
|
||||
* @returns {{edges: Array<[string, string]>, unparsed: string[]}}
|
||||
* unparsed = statements that look like links but yielded no edge.
|
||||
*/
|
||||
export function parseMermaid(mermaid) {
|
||||
const edges = [];
|
||||
const unparsed = [];
|
||||
if (typeof mermaid !== 'string') return { edges, unparsed };
|
||||
for (const raw of mermaid.split(/\r?\n/)) {
|
||||
for (const stmt0 of raw.split(';')) {
|
||||
const stmt = stmt0.trim();
|
||||
if (!stmt || stmt.startsWith('%%') || DIRECTIVE.test(stmt)) continue;
|
||||
const parts = stripLabels(stmt).split(LINK).map(p => p.trim());
|
||||
if (parts.length < 3) {
|
||||
if (ARROWISH.test(stmt)) unparsed.push(stmt);
|
||||
continue;
|
||||
}
|
||||
let ok = true;
|
||||
const groups = [];
|
||||
for (let i = 0; i < parts.length; i += 2) {
|
||||
const ids = parts[i].split('&').map(s => s.trim());
|
||||
if (ids.length === 0 || ids.some(id => !NODE_ID.test(id))) { ok = false; break; }
|
||||
groups.push(ids);
|
||||
}
|
||||
if (!ok) { unparsed.push(stmt); continue; }
|
||||
for (let i = 1, g = 0; i < parts.length; i += 2, g++) {
|
||||
const link = parts[i];
|
||||
const forward = link.endsWith('>');
|
||||
const backward = link.startsWith('<');
|
||||
if (!forward) continue; // undirected: not a dependency
|
||||
for (const a of groups[g]) {
|
||||
for (const b of groups[g + 1]) {
|
||||
edges.push([a, b]);
|
||||
if (backward) edges.push([b, a]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return { edges, unparsed };
|
||||
}
|
||||
|
||||
/** Directed edges [from, to] from mermaid flowchart text (see parseMermaid). */
|
||||
export function parseMermaidEdges(mermaid) {
|
||||
return parseMermaid(mermaid).edges;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find one directed cycle in a module map (mermaid text or an edge list).
|
||||
* Iterative depth-first search: a deep chain cannot overflow the stack.
|
||||
* @returns {string[]|null} the cycle as a closed path (first === last), or null.
|
||||
*/
|
||||
export function findModuleCycle(input) {
|
||||
const edges = Array.isArray(input) ? input : parseMermaidEdges(input);
|
||||
const adj = new Map();
|
||||
for (const [a, b] of edges) {
|
||||
if (!adj.has(a)) adj.set(a, []);
|
||||
if (!adj.has(b)) adj.set(b, []);
|
||||
adj.get(a).push(b);
|
||||
}
|
||||
const state = new Map(); // unset = unvisited, 1 = on the path, 2 = done
|
||||
for (const root of adj.keys()) {
|
||||
if (state.has(root)) continue;
|
||||
const path = [root];
|
||||
const iters = [0];
|
||||
state.set(root, 1);
|
||||
while (path.length > 0) {
|
||||
const n = path[path.length - 1];
|
||||
const next = adj.get(n);
|
||||
const i = iters[iters.length - 1];
|
||||
if (i >= next.length) {
|
||||
state.set(n, 2);
|
||||
path.pop();
|
||||
iters.pop();
|
||||
continue;
|
||||
}
|
||||
iters[iters.length - 1] = i + 1;
|
||||
const nx = next[i];
|
||||
if (state.get(nx) === 1) return [...path.slice(path.indexOf(nx)), nx];
|
||||
if (!state.has(nx)) {
|
||||
state.set(nx, 1);
|
||||
path.push(nx);
|
||||
iters.push(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,10 @@
|
|||
// 2. Jaccard ≥ 0.7 on text-token sets → merge (catches near-duplicates).
|
||||
//
|
||||
// Provenance is preserved on the surviving finding's `raised_by` array.
|
||||
// Severity is kept (normalized to blocker | major | minor, else null) and a
|
||||
// merge keeps the more severe of the two: the plan gate (K3,
|
||||
// lib/validators/plan-gate.mjs) stops on a BLOCKER, so a merge must never
|
||||
// downgrade one.
|
||||
//
|
||||
// CLI shim — two input modes:
|
||||
// file mode: node lib/review/plan-review-dedup.mjs \
|
||||
|
|
@ -22,11 +26,29 @@
|
|||
// blocks here. Malformed stdin exits NON-ZERO — a broken hand-off must surface
|
||||
// loudly, never collapse into a silent empty merge.
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { readFileSync, realpathSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { jaccardSimilarity, meetsThreshold } from '../parsers/jaccard.mjs';
|
||||
import { computeFindingId } from '../parsers/finding-id.mjs';
|
||||
|
||||
export const DEFAULT_THRESHOLD = 0.7;
|
||||
export const SEVERITIES = Object.freeze(['blocker', 'major', 'minor']);
|
||||
|
||||
/** 'Blocker' / 'MAJOR' → canonical lowercase; anything else → null (unrated). */
|
||||
export function normalizeSeverity(s) {
|
||||
const v = typeof s === 'string' ? s.trim().toLowerCase() : '';
|
||||
return SEVERITIES.includes(v) ? v : null;
|
||||
}
|
||||
|
||||
// Lower rank = more severe; unrated ranks with blocker so a merge never hides it.
|
||||
const rank = s => (s === null ? 0 : SEVERITIES.indexOf(s));
|
||||
|
||||
function mergeInto(survivor, other) {
|
||||
for (const a of other.raised_by) {
|
||||
if (!survivor.raised_by.includes(a)) survivor.raised_by.push(a);
|
||||
}
|
||||
if (rank(other.severity) < rank(survivor.severity)) survivor.severity = other.severity;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tokenize a finding's text for Jaccard comparison: lowercase, split on
|
||||
|
|
@ -61,6 +83,7 @@ function annotate(finding, agent) {
|
|||
file: finding.file ?? null,
|
||||
line: finding.line ?? null,
|
||||
rule_key: finding.rule_key ?? null,
|
||||
severity: normalizeSeverity(finding.severity),
|
||||
text: typeof finding.text === 'string' ? finding.text : '',
|
||||
raised_by: [agent],
|
||||
};
|
||||
|
|
@ -73,6 +96,7 @@ function annotate(finding, agent) {
|
|||
* @param {{ threshold?: number }} [opts]
|
||||
* @returns {{
|
||||
* findings: Array<object>,
|
||||
* sources: Array<{agent: string, present: boolean}>,
|
||||
* dedup_stats: { total_in: number, total_out: number,
|
||||
* exact_id_dups: number, jaccard_dups: number }
|
||||
* }}
|
||||
|
|
@ -95,9 +119,7 @@ export function dedupFindings(sources, opts = {}) {
|
|||
for (const f of incoming) {
|
||||
const existing = byId.get(f.id);
|
||||
if (existing) {
|
||||
for (const a of f.raised_by) {
|
||||
if (!existing.raised_by.includes(a)) existing.raised_by.push(a);
|
||||
}
|
||||
mergeInto(existing, f);
|
||||
exact_id_dups += 1;
|
||||
} else {
|
||||
byId.set(f.id, f);
|
||||
|
|
@ -113,9 +135,7 @@ export function dedupFindings(sources, opts = {}) {
|
|||
for (const s of survivors) {
|
||||
const sim = jaccardSimilarity(tokens, tokenize(s.text));
|
||||
if (meetsThreshold(sim, threshold)) {
|
||||
for (const a of f.raised_by) {
|
||||
if (!s.raised_by.includes(a)) s.raised_by.push(a);
|
||||
}
|
||||
mergeInto(s, f);
|
||||
jaccard_dups += 1;
|
||||
merged = true;
|
||||
break;
|
||||
|
|
@ -126,6 +146,13 @@ export function dedupFindings(sources, opts = {}) {
|
|||
|
||||
return {
|
||||
findings: survivors,
|
||||
// Which reviewers actually delivered a findings block. `findings: []` from
|
||||
// a reviewer that was absent (null payload, misspelled key, no `findings`)
|
||||
// is not a clean review; plan-gate --record refuses it.
|
||||
sources: sources.map(s => ({
|
||||
agent: s.agent,
|
||||
present: Boolean(s.payload && typeof s.payload === 'object' && Array.isArray(s.payload.findings)),
|
||||
})),
|
||||
dedup_stats: {
|
||||
total_in,
|
||||
total_out: survivors.length,
|
||||
|
|
@ -190,7 +217,17 @@ function readStdinSourcesOrExit() {
|
|||
];
|
||||
}
|
||||
|
||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||
// realpath on both sides: `file://${argv[1]}` silently no-ops under a symlink
|
||||
// or a path with a space.
|
||||
function isMain() {
|
||||
try {
|
||||
return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (isMain()) {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
const sources = args.stdin
|
||||
? readStdinSourcesOrExit()
|
||||
|
|
|
|||
352
lib/validators/plan-gate.mjs
Normal file
352
lib/validators/plan-gate.mjs
Normal file
|
|
@ -0,0 +1,352 @@
|
|||
// lib/validators/plan-gate.mjs
|
||||
// K3 — the plan gate: may this plan go on to execution?
|
||||
//
|
||||
// node plan-gate.mjs <plan.md> [--json]
|
||||
// 0 = the plan may go on
|
||||
// 1 = stop: invalid plan (plan-validator --strict, plan_version >= 1.8),
|
||||
// no critic verdict, a malformed verdict, a verdict the plan has
|
||||
// moved away from (stale), or an open BLOCKER
|
||||
// 2 = could not run (missing/unreadable plan, bad arguments)
|
||||
//
|
||||
// node plan-gate.mjs --hash <plan.md>
|
||||
// Prints the sha256 of the plan's operative text. Phase 9 takes it BEFORE
|
||||
// the reviewers read the plan and hands it to --record.
|
||||
//
|
||||
// node plan-gate.mjs --record <plan.md> --reviewed <sha256> [--extra-round]
|
||||
// stdin = plan-review-dedup.mjs output. Writes this round's verdict into
|
||||
// the plan. 0 = written; 1 = refused: the round cap is spent (stop and ask
|
||||
// the operator; `--extra-round` only after their yes, and only then);
|
||||
// 2 = could not run: bad stdin, a reviewer missing from the dedup input,
|
||||
// the plan changed since --hash (the verdict would bless unreviewed text),
|
||||
// an unreadable plan or a failed write. Nothing is written on 1 or 2.
|
||||
//
|
||||
// Where the verdict lives: INSIDE the plan, as the `## Critic Verdict` section
|
||||
// with one fenced json block. Claude Code's plan approval hands a hook the
|
||||
// plan file's text (`tool_input.plan`, `planFilePath`) and nothing next to
|
||||
// it, so a verdict in a sibling file would be invisible there.
|
||||
//
|
||||
// What the verdict is bound to: sha256 of the plan's operative text — the plan
|
||||
// minus its review log, with CRLF and line-end whitespace normalized. The
|
||||
// review log is the `> **Plan quality:` header line and the level-2 sections
|
||||
// titled exactly Critic Verdict, Revisions, Plan Quality Score and
|
||||
// "Adversarial Pass 2 (v5.1.1 high-effort)" — each only up to its first
|
||||
// other heading, so a step or section placed under a log heading is hashed.
|
||||
// Edit a step, the design or the verification after the critic ran, and the
|
||||
// verdict is stale until the next round.
|
||||
//
|
||||
// The loop is bounded (Kiro's bounded review loop): MAX_ROUNDS critic rounds,
|
||||
// then stop and ask; never silently on. Unrated findings count as blockers.
|
||||
// The verdict is a trace of a process, not a signature: anything that can
|
||||
// write the plan can write the block.
|
||||
|
||||
import { readFileSync, existsSync, realpathSync, writeFileSync, renameSync, rmSync } from 'node:fs';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { validatePlanContent } from './plan-validator.mjs';
|
||||
import { splitSections, fenceTracker, extractPlanVersion, versionAtLeast } from '../parsers/plan-schema.mjs';
|
||||
import { normalizeSeverity } from '../review/plan-review-dedup.mjs';
|
||||
|
||||
export const MAX_ROUNDS = 3;
|
||||
export const VERDICT_SCHEMA = 'voyage.plan-verdict/1';
|
||||
export const VERDICT_HEADING = '## Critic Verdict';
|
||||
export const REQUIRED_REVIEWERS = Object.freeze(['plan-critic', 'scope-guardian']);
|
||||
// Level-2 sections that are review log, not plan: excluded from the hash.
|
||||
export const REVIEW_LOG_SECTIONS = Object.freeze([
|
||||
'Critic Verdict', 'Revisions', 'Plan Quality Score', 'Adversarial Pass 2 (v5.1.1 high-effort)',
|
||||
]);
|
||||
// The quality line alone; a line that also carries `plan_version`/`ceremony` stays hashed.
|
||||
const QUALITY_LINE = /^> \*\*Plan quality:[^`]*$/;
|
||||
|
||||
const isLogTitle = t => t !== null && REVIEW_LOG_SECTIONS.includes(t);
|
||||
const norm = t => String(t).replace(/\r\n?/g, '\n');
|
||||
|
||||
/** A log section counts as log only up to its first other heading (outside code). */
|
||||
function logPart(sectionText) {
|
||||
const lines = sectionText.split('\n');
|
||||
const fence = fenceTracker();
|
||||
for (let i = 1; i < lines.length; i++) {
|
||||
if (fence.feed(lines[i])) continue;
|
||||
if (/^#{1,6} /.test(lines[i])) return { log: lines.slice(0, i).join('\n'), rest: lines.slice(i).join('\n') };
|
||||
}
|
||||
return { log: sectionText, rest: '' };
|
||||
}
|
||||
|
||||
/** The plan minus its review log, normalized (LF, no line-end whitespace, one trailing newline). */
|
||||
export function operativeText(text) {
|
||||
const kept = splitSections(norm(text), 2)
|
||||
.map(s => (isLogTitle(s.title) ? logPart(s.text).rest : s.text))
|
||||
.join('\n');
|
||||
return kept
|
||||
.split('\n')
|
||||
.filter(l => !QUALITY_LINE.test(l))
|
||||
.map(l => l.replace(/[ \t]+$/, ''))
|
||||
.join('\n')
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.trim() + '\n';
|
||||
}
|
||||
|
||||
export function planHash(text) {
|
||||
return createHash('sha256').update(operativeText(text)).digest('hex');
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {{found: boolean, placeholder: boolean, verdict: object|null, error: string|null}}
|
||||
* placeholder = a `## Critic Verdict` heading with no fenced block at all
|
||||
* (copied prose, not a verdict): --record replaces it, the gate stops on it.
|
||||
*/
|
||||
export function extractVerdict(text) {
|
||||
const secs = splitSections(norm(text), 2).filter(s => s.title === 'Critic Verdict');
|
||||
const none = { found: false, placeholder: false, verdict: null };
|
||||
if (secs.length === 0) return { ...none, error: null };
|
||||
if (secs.length > 1) return { ...none, found: true, error: `${secs.length} "## Critic Verdict" sections; expected one` };
|
||||
const log = logPart(secs[0].text).log;
|
||||
const m = log.match(/```json[ \t]*\n([\s\S]*?)\n[ \t]*```/);
|
||||
if (!m) {
|
||||
const placeholder = !/^\s{0,3}(```|~~~)/m.test(log);
|
||||
return { ...none, found: true, placeholder, error: 'no ```json block in ## Critic Verdict' };
|
||||
}
|
||||
let v;
|
||||
try { v = JSON.parse(m[1]); } catch (e) { return { ...none, found: true, error: `verdict JSON does not parse: ${e.message}` }; }
|
||||
const okShape = v && typeof v === 'object' && v.schema === VERDICT_SCHEMA && Number.isInteger(v.round) && v.round >= 1 &&
|
||||
Number.isInteger(v.max_rounds) && v.max_rounds >= 1 && v.max_rounds <= MAX_ROUNDS &&
|
||||
Number.isInteger(v.extra_rounds ?? 0) && (v.extra_rounds ?? 0) >= 0 &&
|
||||
typeof v.plan_sha256 === 'string' && Array.isArray(v.findings) &&
|
||||
v.findings.every(f => f && typeof f === 'object' && typeof f.severity === 'string');
|
||||
if (!okShape) {
|
||||
return { ...none, found: true, error: `verdict block is not ${VERDICT_SCHEMA} (schema, round, max_rounds <= ${MAX_ROUNDS}, extra_rounds, plan_sha256, findings with severity)` };
|
||||
}
|
||||
return { found: true, placeholder: false, verdict: v, error: null };
|
||||
}
|
||||
|
||||
function verdictFor(counts) {
|
||||
if (counts.blocker > 0) return 'BLOCK';
|
||||
if (counts.major > 0) return 'REVISE';
|
||||
return 'PASS';
|
||||
}
|
||||
|
||||
function renderSection(v) {
|
||||
return [
|
||||
VERDICT_HEADING,
|
||||
'',
|
||||
`<!-- Written by lib/validators/plan-gate.mjs --record (round ${v.round} of ${v.max_rounds}` +
|
||||
`${v.extra_rounds ? ` + ${v.extra_rounds} extra` : ''}). Do not edit by hand: the gate re-hashes the plan and reads this block. -->`,
|
||||
'',
|
||||
'```json',
|
||||
JSON.stringify(v, null, 2),
|
||||
'```',
|
||||
'',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
/** Replace (or append) the `## Critic Verdict` section; anything after its log part is kept. */
|
||||
function placeSection(text, section) {
|
||||
const secs = splitSections(norm(text), 2);
|
||||
const idx = secs.findIndex(s => s.title === 'Critic Verdict');
|
||||
if (idx === -1) return norm(text).replace(/\s*$/, '\n\n') + section;
|
||||
const parts = secs.map(s => s.text);
|
||||
const { rest } = logPart(parts[idx]);
|
||||
parts[idx] = section.replace(/\n$/, '') + (rest ? `\n\n${rest}` : '');
|
||||
return parts.join('\n').replace(/\s*$/, '\n');
|
||||
}
|
||||
|
||||
/** Both reviewers must have been present in the dedup input; an absent one is not a clean review. */
|
||||
function missingReviewers(dedup) {
|
||||
const present = new Set(
|
||||
(Array.isArray(dedup.sources) ? dedup.sources : []).filter(s => s && s.present === true).map(s => s.agent),
|
||||
);
|
||||
return REQUIRED_REVIEWERS.filter(r => !present.has(r));
|
||||
}
|
||||
|
||||
/**
|
||||
* Record one critic round into the plan text.
|
||||
* @param {string} text - the plan
|
||||
* @param {{findings: Array<object>, sources: Array<{agent: string, present: boolean}>}} dedup
|
||||
* plan-review-dedup.mjs output
|
||||
* @param {{reviewed?: string, extraRound?: boolean, maxRounds?: number, now?: string}} [opts]
|
||||
* reviewed = planHash of the text the reviewers read (from --hash)
|
||||
* @returns {{code: 0|1|2, text?: string, verdict?: object, message: string}}
|
||||
*/
|
||||
export function recordVerdict(text, dedup, opts = {}) {
|
||||
if (!dedup || typeof dedup !== 'object' || !Array.isArray(dedup.findings)) {
|
||||
return { code: 2, message: 'record input must be plan-review-dedup output: {"findings": [...], "sources": [...]}' };
|
||||
}
|
||||
if (dedup.findings.some(f => !f || typeof f !== 'object')) {
|
||||
return { code: 2, message: 'record input has a finding that is not an object' };
|
||||
}
|
||||
const missing = missingReviewers(dedup);
|
||||
if (missing.length > 0) {
|
||||
return { code: 2, message: `reviewer(s) missing from the dedup input: ${missing.join(', ')} — an absent review is not a clean one; rerun the round` };
|
||||
}
|
||||
const current = planHash(text);
|
||||
if (typeof opts.reviewed !== 'string' || !/^[0-9a-f]{64}$/.test(opts.reviewed)) {
|
||||
return { code: 2, message: '--reviewed <sha256> is required: take it with --hash before the reviewers read the plan' };
|
||||
}
|
||||
if (opts.reviewed !== current) {
|
||||
return { code: 2, message: 'the plan changed after the reviewers read it (--reviewed differs from the plan now); rerun the round on the current plan' };
|
||||
}
|
||||
const prev = extractVerdict(text);
|
||||
if (prev.error && !prev.placeholder) return { code: 2, message: `existing verdict unreadable: ${prev.error}` };
|
||||
const maxRounds = Math.min(prev.verdict?.max_rounds ?? opts.maxRounds ?? MAX_ROUNDS, MAX_ROUNDS);
|
||||
const prevExtra = prev.verdict?.extra_rounds ?? 0;
|
||||
const round = (prev.verdict?.round ?? 0) + 1;
|
||||
const capped = round > maxRounds + prevExtra;
|
||||
if (opts.extraRound && !capped) {
|
||||
return { code: 2, message: `--extra-round refused: round ${round} is within the cap; it is only for a round after the cap, after the operator's yes` };
|
||||
}
|
||||
if (capped && !opts.extraRound) {
|
||||
return {
|
||||
code: 1,
|
||||
message: `round ${round} refused: ${maxRounds} critic rounds${prevExtra ? ` (+${prevExtra} extra)` : ''} are spent. ` +
|
||||
'Stop and ask the operator: one more round (rerun with --extra-round after their yes), or stop here.',
|
||||
};
|
||||
}
|
||||
const extra = prevExtra + (capped ? 1 : 0);
|
||||
const findings = dedup.findings.map((f) => {
|
||||
const sev = normalizeSeverity(f.severity);
|
||||
const out = {
|
||||
id: f.id ?? null,
|
||||
severity: sev ?? 'blocker',
|
||||
rule_key: f.rule_key ?? null,
|
||||
file: f.file ?? null,
|
||||
line: f.line ?? null,
|
||||
text: typeof f.text === 'string' ? f.text : '',
|
||||
raised_by: Array.isArray(f.raised_by) ? f.raised_by : [],
|
||||
};
|
||||
if (sev === null) out.severity_defaulted = true;
|
||||
return out;
|
||||
});
|
||||
const counts = { blocker: 0, major: 0, minor: 0 };
|
||||
for (const f of findings) counts[f.severity] += 1;
|
||||
const verdict = {
|
||||
schema: VERDICT_SCHEMA,
|
||||
round,
|
||||
max_rounds: maxRounds,
|
||||
extra_rounds: extra,
|
||||
recorded_at: opts.now ?? new Date().toISOString(),
|
||||
plan_sha256: current,
|
||||
verdict: verdictFor(counts),
|
||||
counts,
|
||||
findings,
|
||||
};
|
||||
return { code: 0, text: placeSection(text, renderSection(verdict)), verdict, message: `round ${round} recorded: ${verdict.verdict}` };
|
||||
}
|
||||
|
||||
/**
|
||||
* K3 — decide whether the plan may go on.
|
||||
* @returns {{code: 0|1, status: 'pass'|'stop', reasons: object[], warnings: object[], verdict: object|null, validation: object}}
|
||||
*/
|
||||
export function gatePlan(rawText) {
|
||||
const text = norm(rawText);
|
||||
const reasons = [];
|
||||
const warnings = [];
|
||||
const validation = validatePlanContent(text, { strict: true });
|
||||
if (!validation.valid) {
|
||||
reasons.push({ code: 'PLAN_GATE_INVALID_PLAN', message: `plan-validator --strict: ${validation.errors.map(e => e.code).join(', ')}`, errors: validation.errors });
|
||||
}
|
||||
const version = extractPlanVersion(text);
|
||||
if (!versionAtLeast(version, '1.8')) {
|
||||
reasons.push({ code: 'PLAN_GATE_VERSION', message: `plan_version ${version ?? '(none)'}: the gate passes only plans written to plan_version 1.8 or later` });
|
||||
}
|
||||
const ex = extractVerdict(text);
|
||||
const v = ex.verdict;
|
||||
if (!ex.found) {
|
||||
reasons.push({ code: 'PLAN_GATE_NO_VERDICT', message: 'No "## Critic Verdict": the plan has not been through the critic (run Phase 9).' });
|
||||
} else if (ex.error) {
|
||||
reasons.push({ code: 'PLAN_GATE_VERDICT_MALFORMED', message: ex.error });
|
||||
} else {
|
||||
if (v.plan_sha256 !== planHash(text)) {
|
||||
reasons.push({ code: 'PLAN_GATE_VERDICT_STALE', message: `The plan changed after critic round ${v.round}; run the next round.` });
|
||||
}
|
||||
const sev = f => normalizeSeverity(f.severity);
|
||||
const blockers = v.findings.filter(f => sev(f) !== 'major' && sev(f) !== 'minor');
|
||||
const majors = v.findings.filter(f => sev(f) === 'major');
|
||||
if (blockers.length > 0) {
|
||||
reasons.push({ code: 'PLAN_GATE_OPEN_BLOCKER', message: `${blockers.length} open BLOCKER(s) in critic round ${v.round}: ${blockers.map(b => b.text).join(' | ')}` });
|
||||
if (v.round >= v.max_rounds + (v.extra_rounds ?? 0)) {
|
||||
reasons.push({ code: 'PLAN_GATE_ROUNDS_EXHAUSTED', message: `All ${v.round} critic rounds are spent with a BLOCKER open: stop and ask the operator.` });
|
||||
}
|
||||
}
|
||||
if (majors.length > 0) {
|
||||
warnings.push({ code: 'PLAN_GATE_OPEN_MAJOR', message: `${majors.length} MAJOR finding(s) left open in round ${v.round}; show them to the operator.` });
|
||||
}
|
||||
}
|
||||
return { code: reasons.length === 0 ? 0 : 1, status: reasons.length === 0 ? 'pass' : 'stop', reasons, warnings, verdict: v ?? null, validation };
|
||||
}
|
||||
|
||||
// ---- CLI --------------------------------------------------------------------
|
||||
|
||||
function isMain() {
|
||||
try {
|
||||
return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function out(obj) {
|
||||
process.stdout.write(JSON.stringify(obj) + '\n');
|
||||
}
|
||||
|
||||
if (isMain()) {
|
||||
const args = process.argv.slice(2);
|
||||
const usage = 'Usage: plan-gate.mjs <plan.md> [--json] | --hash <plan.md> | --record <plan.md> --reviewed <sha256> [--extra-round] < dedup.json';
|
||||
const known = new Set(['--json', '--hash', '--record', '--reviewed', '--extra-round']);
|
||||
const rIdx = args.indexOf('--reviewed');
|
||||
const reviewed = rIdx >= 0 ? args[rIdx + 1] : undefined;
|
||||
const positional = args.filter((a, i) => !a.startsWith('--') && !(rIdx >= 0 && i === rIdx + 1));
|
||||
const unknown = args.filter(a => a.startsWith('--') && !known.has(a));
|
||||
const planPath = positional[0];
|
||||
if (!planPath || positional.length > 1 || unknown.length > 0 || (rIdx >= 0 && (!reviewed || reviewed.startsWith('--')))) {
|
||||
process.stderr.write(`${usage}\n`);
|
||||
out({ code: 2, status: 'error', message: unknown.length ? `unknown argument: ${unknown.join(' ')}` : 'bad arguments' });
|
||||
process.exit(2);
|
||||
}
|
||||
let text;
|
||||
try {
|
||||
if (!existsSync(planPath)) throw new Error('file not found');
|
||||
text = readFileSync(planPath, 'utf-8');
|
||||
} catch (e) {
|
||||
out({ code: 2, status: 'error', plan: planPath, message: `cannot read plan: ${e.message}` });
|
||||
process.exit(2);
|
||||
}
|
||||
if (args.includes('--hash')) {
|
||||
process.stdout.write(`${planHash(text)}\n`);
|
||||
process.exit(0);
|
||||
}
|
||||
if (args.includes('--record')) {
|
||||
let dedup;
|
||||
try { dedup = JSON.parse(readFileSync(0, 'utf-8')); } catch (e) {
|
||||
out({ code: 2, recorded: false, message: `stdin is not JSON: ${e.message}` });
|
||||
process.exit(2);
|
||||
}
|
||||
const r = recordVerdict(text, dedup, { extraRound: args.includes('--extra-round'), reviewed });
|
||||
if (r.code === 0) {
|
||||
const tmp = `${planPath}.plan-gate.tmp`;
|
||||
try {
|
||||
writeFileSync(tmp, r.text);
|
||||
renameSync(tmp, planPath);
|
||||
} catch (e) {
|
||||
rmSync(tmp, { force: true });
|
||||
out({ code: 2, recorded: false, message: `cannot write the plan: ${e.message}` });
|
||||
process.exit(2);
|
||||
}
|
||||
}
|
||||
out({
|
||||
code: r.code, recorded: r.code === 0, message: r.message,
|
||||
round: r.verdict?.round ?? null, max_rounds: r.verdict?.max_rounds ?? null,
|
||||
verdict: r.verdict?.verdict ?? null, counts: r.verdict?.counts ?? null,
|
||||
});
|
||||
process.exit(r.code);
|
||||
}
|
||||
const g = gatePlan(text);
|
||||
if (args.includes('--json')) {
|
||||
process.stdout.write(JSON.stringify({ code: g.code, status: g.status, plan: planPath, reasons: g.reasons, warnings: g.warnings, verdict: g.verdict }, null, 2) + '\n');
|
||||
} else {
|
||||
process.stdout.write(
|
||||
`plan-gate: ${g.status.toUpperCase()} ${planPath}\n` +
|
||||
g.reasons.map(r => ` STOP [${r.code}] ${r.message}\n`).join('') +
|
||||
g.warnings.map(w => ` WARN [${w.code}] ${w.message}\n`).join(''),
|
||||
);
|
||||
}
|
||||
process.exit(g.code);
|
||||
}
|
||||
|
|
@ -6,15 +6,150 @@
|
|||
// tolerated silently. Strict-key checks are intentionally avoided so new
|
||||
// optional fields (jf. the source_findings precedent) can be added without
|
||||
// a plan_version bump.
|
||||
//
|
||||
// plan_version 1.8 rules (apply only to plans declaring >= 1.8; 1.7 plans
|
||||
// validate exactly as before):
|
||||
// - every step has `Test first:` naming a File and what it Verifies;
|
||||
// - the plan has a "run the thing" step: an `End-to-end:` field with a
|
||||
// backticked command (without it a result is "not verified end to end");
|
||||
// - the optional step fields `Interface in:`, `Interface out:`,
|
||||
// `Review focus:` are never empty when present;
|
||||
// - `ceremony:` is small | bounded | architectural (absent = architectural);
|
||||
// an architectural plan carries `## Design` (see checkDesign).
|
||||
// Strict mode reports these as errors; --soft downgrades them to warnings.
|
||||
|
||||
import { readFileSync, existsSync } from 'node:fs';
|
||||
import { sliceSteps, validatePlanHeadings, extractPlanVersion } from '../parsers/plan-schema.mjs';
|
||||
import { readFileSync, existsSync, realpathSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import {
|
||||
sliceSteps, validatePlanHeadings, extractPlanVersion, extractPlanCeremony,
|
||||
parseStepFields, findSection, splitSections, findModuleCycle, parseMermaid, fencedBlocks,
|
||||
versionAtLeast, PLAN_VERSION_CURRENT, PLAN_VERSIONS_SUPPORTED, CEREMONY_VALUES,
|
||||
} from '../parsers/plan-schema.mjs';
|
||||
import { validateAllManifests } from '../parsers/manifest-yaml.mjs';
|
||||
import { parseDocument } from '../util/frontmatter.mjs';
|
||||
import { issue, fail } from '../util/result.mjs';
|
||||
|
||||
export function validatePlanContent(text, opts = {}) {
|
||||
export const OPTIONAL_STEP_FIELDS = Object.freeze(['Interface in', 'Interface out', 'Review focus']);
|
||||
export const DESIGN_SUBSECTIONS = Object.freeze([
|
||||
'Architecture', 'Data Flow', 'Interfaces', 'Data Models', 'Error Handling',
|
||||
'Module Map', 'Architecture Rules', 'Requirements Check',
|
||||
]);
|
||||
const BACKTICKED = /`[^`\n]+`/;
|
||||
|
||||
function checkSteps(sections) {
|
||||
const out = [];
|
||||
let endToEnd = 0;
|
||||
for (const s of sections) {
|
||||
const f = parseStepFields(s.body);
|
||||
const tf = f.get('Test first');
|
||||
const names = tf && /\bFile:\**[ \t]*`[^`\n]+`/i.test(tf.text) && /\bVerifies:\**[ \t]*\S/i.test(tf.text);
|
||||
if (!names) {
|
||||
out.push(issue(
|
||||
'PLAN_STEP_NO_TEST_FIRST',
|
||||
`Step ${s.n}: ${tf ? '`Test first:` does not name a File (backticked path) and what it Verifies' : 'no `Test first:` field'}`,
|
||||
'plan_version 1.8 requires `Test first:` on every step, with `File:` and `Verifies:` sub-bullets.',
|
||||
));
|
||||
}
|
||||
for (const name of OPTIONAL_STEP_FIELDS) {
|
||||
if (f.has(name) && f.get(name).text === '') {
|
||||
out.push(issue('PLAN_STEP_FIELD_EMPTY', `Step ${s.n}: \`${name}:\` is present but empty`,
|
||||
'Fill the field or remove it; it is optional.'));
|
||||
}
|
||||
}
|
||||
if (f.has('End-to-end') && BACKTICKED.test(f.get('End-to-end').text)) endToEnd += 1;
|
||||
}
|
||||
if (sections.length > 0 && endToEnd === 0) {
|
||||
out.push(issue(
|
||||
'PLAN_NO_END_TO_END',
|
||||
'No "run the thing" step: no step carries `End-to-end:` with a backticked command',
|
||||
'Add a step that runs the product the way a user does (`End-to-end: `<command>` → expected: …`). ' +
|
||||
'Without it the result is "not verified end to end", never green.',
|
||||
));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Table body rows (skips the header and the |---| separator). */
|
||||
function tableRows(text) {
|
||||
const rows = text.split('\n').filter(l => l.trim().startsWith('|'));
|
||||
return rows.slice(2).map(r => r.trim().replace(/^\||\|$/g, '').split('|').map(c => c.trim()));
|
||||
}
|
||||
|
||||
function checkDesign(text, stepNumbers) {
|
||||
const out = [];
|
||||
const design = findSection(text, 'Design', 2);
|
||||
if (!design) {
|
||||
return [issue('PLAN_DESIGN_MISSING', 'Architectural plan without a `## Design` section',
|
||||
'Add the design part (templates/design-template.md), or plan under `ceremony: small|bounded` if the brief says so.')];
|
||||
}
|
||||
const subs = splitSections(design.text, 3);
|
||||
const titles = new Set(subs.filter(s => s.title).map(s => s.title.toLowerCase()));
|
||||
const missing = DESIGN_SUBSECTIONS.filter(t => !titles.has(t.toLowerCase()));
|
||||
if (missing.length > 0) {
|
||||
out.push(issue('PLAN_DESIGN_SECTION_MISSING', `## Design lacks: ${missing.join(', ')}`,
|
||||
`Required subsections: ${DESIGN_SUBSECTIONS.join(', ')}.`));
|
||||
}
|
||||
if (!findSection(text, 'Test Strategy', 2)) {
|
||||
out.push(issue('PLAN_DESIGN_SECTION_MISSING', 'Architectural plan lacks `## Test Strategy`',
|
||||
'The design part\'s test strategy lives in the plan\'s `## Test Strategy` section.'));
|
||||
}
|
||||
const sub = t => subs.find(s => s.title && s.title.toLowerCase() === t.toLowerCase());
|
||||
|
||||
const map = sub('Module Map');
|
||||
if (map) {
|
||||
const blocks = fencedBlocks(map.text, 'mermaid');
|
||||
if (blocks.length === 0) {
|
||||
out.push(issue('PLAN_MODULE_MAP_EMPTY', '### Module Map has no ```mermaid block',
|
||||
'Draw the modules and their dependency direction as a mermaid graph.'));
|
||||
}
|
||||
// All blocks form one map: a cycle may run across two of them.
|
||||
const parsed = blocks.map(b => parseMermaid(b));
|
||||
const unparsed = parsed.flatMap(p => p.unparsed);
|
||||
if (unparsed.length > 0) {
|
||||
out.push(issue('PLAN_MODULE_MAP_UNPARSED', `Module map lines that look like links but give no edge: ${unparsed.join(' | ')}`,
|
||||
'Write links as `a --> b` (ids of letters, digits, _ . - /); the validator cannot check what it cannot read.'));
|
||||
}
|
||||
const cycle = findModuleCycle(parsed.flatMap(p => p.edges));
|
||||
if (cycle) {
|
||||
out.push(issue('PLAN_MODULE_CYCLE', `Module map has a dependency cycle: ${cycle.join(' -> ')}`,
|
||||
'Dependencies point one way. Break the cycle (extract the shared part, or invert one edge).'));
|
||||
}
|
||||
}
|
||||
|
||||
const rules = sub('Architecture Rules');
|
||||
if (rules) {
|
||||
const rows = tableRows(rules.text);
|
||||
if (rows.length === 0) {
|
||||
out.push(issue('PLAN_ARCH_RULE_NO_ENFORCER', '### Architecture Rules lists no rule',
|
||||
'List at least one rule as a table row: | Rule | Enforced by | Proof |.'));
|
||||
}
|
||||
for (const row of rows) {
|
||||
const line = row.join(' | ');
|
||||
const checked = /checked by\s+`[^`]+`/i.test(line);
|
||||
const advisory = /\badvisory\b/i.test(line) && /\.claude\/rules\/[^\s`|]+\.md/.test(line);
|
||||
if (!checked && !advisory) {
|
||||
out.push(issue('PLAN_ARCH_RULE_NO_ENFORCER', `Rule without an enforcer: ${row[0] || line}`,
|
||||
'Each rule says "checked by `<command>`" or "advisory — `.claude/rules/<file>.md`".'));
|
||||
continue;
|
||||
}
|
||||
if (checked) {
|
||||
const proof = (row[2] ?? '').match(/Step (\d+)/);
|
||||
if (!proof || !stepNumbers.includes(Number(proof[1]))) {
|
||||
out.push(issue('PLAN_ARCH_RULE_NO_PROOF',
|
||||
`Checked rule without an existing proving step: ${row[0] || line}`,
|
||||
'Name the step (Proof column, "Step N") that shows the check pass, fail on a planted violation, and pass again.'));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function validatePlanContent(rawText, opts = {}) {
|
||||
const strict = opts.strict !== false;
|
||||
// CRLF plans (an editor on Windows) used to fail every manifest with
|
||||
// FM_SYNTAX: the YAML line parser does not expect a trailing \r.
|
||||
const text = typeof rawText === 'string' ? rawText.replace(/\r\n?/g, '\n') : rawText;
|
||||
const headRes = validatePlanHeadings(text, { strict });
|
||||
const errors = [...headRes.errors];
|
||||
const warnings = [...headRes.warnings];
|
||||
|
|
@ -34,9 +169,22 @@ export function validatePlanContent(text, opts = {}) {
|
|||
|
||||
const planVersion = extractPlanVersion(text);
|
||||
if (planVersion === null) {
|
||||
warnings.push(issue('PLAN_NO_VERSION', 'No plan_version detected; current target is 1.7'));
|
||||
} else if (planVersion !== '1.7') {
|
||||
warnings.push(issue('PLAN_VERSION_MISMATCH', `plan_version=${planVersion}, current target is 1.7`));
|
||||
warnings.push(issue('PLAN_NO_VERSION', `No plan_version detected; current target is ${PLAN_VERSION_CURRENT}`));
|
||||
} else if (!PLAN_VERSIONS_SUPPORTED.includes(planVersion)) {
|
||||
warnings.push(issue('PLAN_VERSION_MISMATCH', `plan_version=${planVersion}, current target is ${PLAN_VERSION_CURRENT}`));
|
||||
}
|
||||
|
||||
const ceremony = extractPlanCeremony(text);
|
||||
if (versionAtLeast(planVersion, '1.8')) {
|
||||
const v18 = [...checkSteps(sections)];
|
||||
if (!ceremony.valid) {
|
||||
v18.push(issue('PLAN_CEREMONY_INVALID', `ceremony=${ceremony.raw}`,
|
||||
`Allowed: ${CEREMONY_VALUES.join(' | ')} (absent = architectural).`));
|
||||
} else if (ceremony.value === 'architectural') {
|
||||
v18.push(...checkDesign(text, steps.map(s => s.n)));
|
||||
}
|
||||
if (strict) errors.push(...v18);
|
||||
else warnings.push(...v18);
|
||||
}
|
||||
|
||||
// v4.1 SC #20 — MANIFEST_PROFILE_DRIFT detection. Strict-mode only.
|
||||
|
|
@ -66,7 +214,7 @@ export function validatePlanContent(text, opts = {}) {
|
|||
valid: errors.length === 0,
|
||||
errors,
|
||||
warnings,
|
||||
parsed: { steps, manifests: manRes.parsed, planVersion },
|
||||
parsed: { steps, manifests: manRes.parsed, planVersion, ceremony: ceremony.value },
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -79,7 +227,17 @@ export function validatePlan(filePath, opts = {}) {
|
|||
return { ...r, parsed: { ...r.parsed, filePath } };
|
||||
}
|
||||
|
||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||
// realpath on both sides: `file://${argv[1]}` silently no-ops under a symlink
|
||||
// or a path with a space (the CLI then exits 0 having checked nothing).
|
||||
function isMain() {
|
||||
try {
|
||||
return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (isMain()) {
|
||||
const args = process.argv.slice(2);
|
||||
const strict = !args.includes('--soft');
|
||||
const filePath = args.find(a => !a.startsWith('--'));
|
||||
|
|
@ -95,6 +253,7 @@ if (import.meta.url === `file://${process.argv[1]}`) {
|
|||
warnings: r.warnings,
|
||||
steps: r.parsed?.steps?.length ?? 0,
|
||||
planVersion: r.parsed?.planVersion ?? null,
|
||||
ceremony: r.parsed?.ceremony ?? null,
|
||||
}, null, 2) + '\n');
|
||||
} else {
|
||||
process.stdout.write(`plan-validator: ${r.valid ? 'READY' : 'FAIL'} ${filePath} (${r.parsed?.steps?.length ?? 0} steps)\n`);
|
||||
|
|
|
|||
303
lib/verification/arch-rules.mjs
Normal file
303
lib/verification/arch-rules.mjs
Normal file
|
|
@ -0,0 +1,303 @@
|
|||
// lib/verification/arch-rules.mjs
|
||||
// K5 — the architecture-rule runner. Runs the target repo's OWN architecture
|
||||
// tool and reports one verdict. It installs nothing and never reaches for a
|
||||
// package runner that could (no npx): a tool counts only when its config AND
|
||||
// a local binary are both present.
|
||||
//
|
||||
// node arch-rules.mjs --repo <dir> [--target <path>]...
|
||||
// 0 = every detected tool passed
|
||||
// 1 = a tool reported a violation
|
||||
// 2 = not measured: no tool, a tool that could not run, or a tool that
|
||||
// checked nothing. Never green.
|
||||
// JSON on stdout: {code, status, repo, tools: [{tool, status, targets?, reason?, violations?}]}
|
||||
//
|
||||
// node arch-rules.mjs --repo <dir> --prove --rule <name> --plant <file>=<repo-relative-dest> [...]
|
||||
// A rule is accepted only once it has been seen to pass, fail on a planted
|
||||
// violation, and pass again. Runs the tools, copies each plant in (never
|
||||
// over an existing path, never outside the repo, symlinks resolved), runs
|
||||
// them again, removes the plants (and the directories made for them), runs
|
||||
// a third time. The planted run must report a violation of THIS rule
|
||||
// (`--rule`: a dependency-cruiser rule name or an import-linter contract
|
||||
// name); another rule tripping proves nothing.
|
||||
// 0 = proven (0 → 1 with the rule → 0), 1 = not proven, 2 = not measured
|
||||
// or the plant could not be placed.
|
||||
//
|
||||
// Adapters (output shapes measured against the real tools on 2026-10-06):
|
||||
// dependency-cruiser 18.5.0 — config .dependency-cruiser.{cjs,js,mjs,json},
|
||||
// binary node_modules/.bin/depcruise, `--output-type json`. It exits 0
|
||||
// even with violations; the verdict is `summary.error` (> 0 = violation),
|
||||
// `summary.totalCruised` 0 = checked nothing. Default targets: every one of
|
||||
// src/ lib/ app/ packages/ source/ that exists, else the repo root.
|
||||
// import-linter 2.15 — config .importlinter, setup.cfg [importlinter] or
|
||||
// pyproject.toml [tool.importlinter]; binary .venv/bin, venv/bin or PATH.
|
||||
// Exit 0 + "Contracts: N kept, 0 broken." (N > 0) = pass; exit 1 + "M
|
||||
// broken" (M > 0) = violation, one per "<contract> BROKEN" line; anything
|
||||
// else (0 contracts included) = not measured. The repo root is put first
|
||||
// on PYTHONPATH so an uninstalled root package resolves.
|
||||
// Not used: madge 8.0.0 reported no cycle for a planted .mjs cycle without
|
||||
// extra configuration (measured) — a false green, so it is not an adapter.
|
||||
//
|
||||
// Borrowed: "use the de-facto tool" and the module map from agent-skills
|
||||
// (MIT); pass → fail → pass from Pocock's skills (MIT).
|
||||
|
||||
import {
|
||||
existsSync, readFileSync, statSync, lstatSync, realpathSync, mkdirSync, copyFileSync,
|
||||
rmSync, rmdirSync, readdirSync, constants as fsc,
|
||||
} from 'node:fs';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { join, resolve, dirname, basename, relative, isAbsolute, delimiter, sep } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const TIMEOUT_MS = 180_000;
|
||||
const DEPCRUISE_CONFIGS = ['.dependency-cruiser.cjs', '.dependency-cruiser.js', '.dependency-cruiser.mjs', '.dependency-cruiser.json'];
|
||||
const DEFAULT_TARGETS = ['src', 'lib', 'app', 'packages', 'source'];
|
||||
|
||||
function isFile(p) {
|
||||
try { return statSync(p).isFile(); } catch { return false; }
|
||||
}
|
||||
|
||||
function isDir(p) {
|
||||
try { return statSync(p).isDirectory(); } catch { return false; }
|
||||
}
|
||||
|
||||
/** True when anything sits at p — a file, a directory, or a symlink, dangling or not. */
|
||||
function occupied(p) {
|
||||
try { lstatSync(p); return true; } catch { return false; }
|
||||
}
|
||||
|
||||
function fileHas(p, re) {
|
||||
try { return re.test(readFileSync(p, 'utf-8')); } catch { return false; }
|
||||
}
|
||||
|
||||
function onPath(bin) {
|
||||
for (const d of (process.env.PATH ?? '').split(delimiter)) {
|
||||
if (d && isFile(join(d, bin))) return join(d, bin);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Which architecture tools does this repo configure? (configured ≠ runnable) */
|
||||
export function detectTools(repo) {
|
||||
const tools = [];
|
||||
const dcConfig = DEPCRUISE_CONFIGS.find(c => isFile(join(repo, c)));
|
||||
if (dcConfig) {
|
||||
const bin = join(repo, 'node_modules', '.bin', 'depcruise');
|
||||
tools.push({ tool: 'dependency-cruiser', config: dcConfig, bin: isFile(bin) ? bin : null, wanted: 'node_modules/.bin/depcruise' });
|
||||
}
|
||||
const ilConfig = isFile(join(repo, '.importlinter')) ? '.importlinter'
|
||||
: fileHas(join(repo, 'setup.cfg'), /^\[importlinter\]/m) ? 'setup.cfg'
|
||||
: fileHas(join(repo, 'pyproject.toml'), /^\[tool\.importlinter\]/m) ? 'pyproject.toml'
|
||||
: null;
|
||||
if (ilConfig) {
|
||||
const local = ['.venv', 'venv'].map(v => join(repo, v, 'bin', 'lint-imports')).find(isFile);
|
||||
tools.push({ tool: 'import-linter', config: ilConfig, bin: local ?? onPath('lint-imports'), wanted: '.venv/bin/lint-imports (or lint-imports on PATH)' });
|
||||
}
|
||||
return tools;
|
||||
}
|
||||
|
||||
function exec(bin, args, repo, env = {}) {
|
||||
return spawnSync(bin, args, {
|
||||
cwd: repo, encoding: 'utf-8', timeout: TIMEOUT_MS, maxBuffer: 64 * 1024 * 1024,
|
||||
env: { ...process.env, ...env },
|
||||
});
|
||||
}
|
||||
|
||||
function runDepcruise(t, repo, targets) {
|
||||
const tg = targets.length > 0 ? targets : DEFAULT_TARGETS.filter(d => isDir(join(repo, d)));
|
||||
const use = tg.length > 0 ? tg : ['.'];
|
||||
const r = exec(t.bin, ['--config', t.config, '--output-type', 'json', ...use], repo);
|
||||
let j = null;
|
||||
try { j = JSON.parse(r.stdout); } catch { /* handled below */ }
|
||||
const s = j?.summary;
|
||||
if (!s || typeof s.error !== 'number') {
|
||||
const why = r.error ? r.error.message : (r.stderr || '').trim().split('\n')[0] || `exit ${r.status}, no JSON summary`;
|
||||
return { status: 'not-measured', targets: use, reason: `dependency-cruiser could not run: ${why}` };
|
||||
}
|
||||
if (!s.totalCruised) return { status: 'not-measured', targets: use, reason: 'dependency-cruiser checked 0 modules' };
|
||||
const violations = (s.violations ?? [])
|
||||
.filter(v => v?.rule?.severity === 'error')
|
||||
.map(v => ({ rule: v.rule.name, text: `${v.type ?? 'violation'}: ${v.from} -> ${v.to}` }));
|
||||
if (s.error > 0) return { status: 'violation', targets: use, violations };
|
||||
return { status: 'passed', targets: use, checked: s.totalCruised, warn: s.warn ?? 0 };
|
||||
}
|
||||
|
||||
function runImportLinter(t, repo) {
|
||||
const py = process.env.PYTHONPATH ? `${repo}${delimiter}${process.env.PYTHONPATH}` : repo;
|
||||
const r = exec(t.bin, ['--no-cache'], repo, { PYTHONPATH: py });
|
||||
const out = `${r.stdout ?? ''}${r.stderr ?? ''}`;
|
||||
const m = out.match(/Contracts: (\d+) kept, (\d+) broken\./);
|
||||
if (!m) {
|
||||
const why = r.error ? r.error.message : out.trim().split('\n').pop() || `exit ${r.status}`;
|
||||
return { status: 'not-measured', reason: `import-linter could not run: ${why}` };
|
||||
}
|
||||
const kept = Number(m[1]);
|
||||
const broken = Number(m[2]);
|
||||
if (kept + broken === 0) return { status: 'not-measured', reason: 'import-linter checked 0 contracts' };
|
||||
if (r.status === 0 && broken === 0) return { status: 'passed', checked: kept };
|
||||
if (r.status === 1 && broken > 0) {
|
||||
const names = [...out.matchAll(/^(.+?) BROKEN$/gm)].map(x => x[1].trim());
|
||||
const detail = out.split('\n').filter(l => /not allowed to import|^- .+ -> /.test(l)).slice(0, 50).map(l => l.trim());
|
||||
const violations = names.length > 0
|
||||
? names.map(n => ({ rule: n, text: `${n} BROKEN${detail.length ? `: ${detail.join(' ')}` : ''}` }))
|
||||
: [{ rule: 'import-linter', text: detail.join(' ') || 'a contract is broken' }];
|
||||
return { status: 'violation', violations };
|
||||
}
|
||||
return { status: 'not-measured', reason: `import-linter: exit ${r.status} with "${m[0]}"` };
|
||||
}
|
||||
|
||||
/** Run every detected tool once. */
|
||||
export function runArchRules({ repo, targets = [] }) {
|
||||
if (!repo || !isDir(repo)) {
|
||||
return { code: 2, status: 'not-measured', repo, tools: [], reason: `repo dir not found: ${repo}` };
|
||||
}
|
||||
const tools = detectTools(repo).map((t) => {
|
||||
if (!t.bin) return { tool: t.tool, config: t.config, status: 'not-measured', reason: `${t.config} found but no ${t.wanted}; this runner installs nothing` };
|
||||
const res = t.tool === 'dependency-cruiser' ? runDepcruise(t, repo, targets) : runImportLinter(t, repo);
|
||||
return { tool: t.tool, config: t.config, ...res };
|
||||
});
|
||||
if (tools.length === 0) {
|
||||
return { code: 2, status: 'not-measured', repo, tools, reason: 'no architecture tool configured in this repo (dependency-cruiser, import-linter)' };
|
||||
}
|
||||
if (tools.some(t => t.status === 'violation')) return { code: 1, status: 'violation', repo, tools };
|
||||
if (tools.some(t => t.status === 'not-measured')) return { code: 2, status: 'not-measured', repo, tools };
|
||||
return { code: 0, status: 'passed', repo, tools };
|
||||
}
|
||||
|
||||
/**
|
||||
* Is dest (repo-relative) inside the repo once symlinks are resolved?
|
||||
* Resolves the deepest existing ancestor, so `link/x` through a symlink that
|
||||
* points outside the repo is refused.
|
||||
*/
|
||||
function insideRepo(repo, dest) {
|
||||
if (isAbsolute(dest)) return false;
|
||||
let root;
|
||||
try { root = realpathSync(repo); } catch { return false; }
|
||||
let probe = resolve(repo, dest);
|
||||
const tail = [];
|
||||
while (!existsSync(probe)) {
|
||||
tail.unshift(basename(probe));
|
||||
const up = dirname(probe);
|
||||
if (up === probe) return false;
|
||||
probe = up;
|
||||
}
|
||||
let real;
|
||||
try { real = realpathSync(probe); } catch { return false; }
|
||||
const full = join(real, ...tail);
|
||||
const rel = relative(root, full);
|
||||
return rel !== '' && !rel.startsWith('..') && !isAbsolute(rel);
|
||||
}
|
||||
|
||||
const rulesIn = run => (run.tools ?? []).flatMap(t => (t.violations ?? []).map(v => v.rule));
|
||||
|
||||
/** pass → fail on the planted violation of `rule` → pass. */
|
||||
export function proveRule({ repo, targets = [], plants, rule }) {
|
||||
const phases = [];
|
||||
const base = { mode: 'prove', repo, rule: rule ?? null, phases };
|
||||
const notMeasured = reason => ({ ...base, code: 2, proven: false, reason });
|
||||
if (typeof rule !== 'string' || rule === '') return notMeasured('--prove needs --rule <name>: the rule the plant must trip');
|
||||
if (!Array.isArray(plants) || plants.length === 0) return notMeasured('--prove needs at least one --plant <file>=<dest>');
|
||||
for (const p of plants) {
|
||||
if (!isFile(p.src)) return notMeasured(`plant source not found: ${p.src}`);
|
||||
if (!insideRepo(repo, p.dest)) return notMeasured(`plant destination is outside the repo (after resolving symlinks): ${p.dest}`);
|
||||
if (occupied(join(repo, p.dest))) return notMeasured(`refusing to overwrite an existing path: ${p.dest}`);
|
||||
}
|
||||
const before = runArchRules({ repo, targets });
|
||||
phases.push({ phase: 'before', code: before.code, status: before.status });
|
||||
if (before.code !== 0) {
|
||||
return { ...base, code: before.code === 2 ? 2 : 1, proven: false, reason: 'the base does not pass, so a failure after planting would prove nothing', detail: before };
|
||||
}
|
||||
|
||||
const madeDirs = [];
|
||||
const planted = [];
|
||||
const cleanup = () => {
|
||||
for (const f of planted.splice(0)) rmSync(f, { force: true });
|
||||
for (const d of madeDirs.splice(0).reverse()) {
|
||||
try { if (readdirSync(d).length === 0) rmdirSync(d); } catch { /* not empty: left in place, never force-removed */ }
|
||||
}
|
||||
};
|
||||
// A Ctrl-C during the planted run must not leave the plants in the repo.
|
||||
const onSignal = (sig) => { cleanup(); process.exit(sig === 'SIGINT' ? 130 : 143); };
|
||||
process.once('SIGINT', onSignal);
|
||||
process.once('SIGTERM', onSignal);
|
||||
let during;
|
||||
try {
|
||||
for (const p of plants) {
|
||||
const destAbs = join(repo, p.dest);
|
||||
let d = dirname(destAbs);
|
||||
const chain = [];
|
||||
while (!existsSync(d)) { chain.push(d); d = dirname(d); }
|
||||
for (const c of chain.reverse()) { mkdirSync(c); madeDirs.push(c); }
|
||||
// EXCL: if something appeared at dest since the check, fail rather than overwrite it.
|
||||
copyFileSync(p.src, destAbs, fsc.COPYFILE_EXCL);
|
||||
planted.push(destAbs);
|
||||
}
|
||||
during = runArchRules({ repo, targets });
|
||||
} catch (e) {
|
||||
cleanup();
|
||||
process.removeListener('SIGINT', onSignal);
|
||||
process.removeListener('SIGTERM', onSignal);
|
||||
return notMeasured(`could not place the plant: ${e.message}`);
|
||||
}
|
||||
cleanup();
|
||||
process.removeListener('SIGINT', onSignal);
|
||||
process.removeListener('SIGTERM', onSignal);
|
||||
phases.push({ phase: 'planted', code: during.code, status: during.status, rules: rulesIn(during) });
|
||||
const after = runArchRules({ repo, targets });
|
||||
phases.push({ phase: 'after', code: after.code, status: after.status });
|
||||
if (during.code === 2 || after.code === 2) return { ...base, code: 2, proven: false, reason: 'a run was not measured', detail: { during, after } };
|
||||
if (during.code !== 1) return { ...base, code: 1, proven: false, reason: 'the tool did not catch the planted violation: the rule does not bite', detail: during };
|
||||
if (!rulesIn(during).includes(rule)) {
|
||||
return { ...base, code: 1, proven: false, reason: `the plant tripped ${rulesIn(during).join(', ')}, not ${rule}: this proves nothing about ${rule}`, detail: during };
|
||||
}
|
||||
if (after.code !== 0) return { ...base, code: 1, proven: false, reason: 'the check still fails after the plant was removed', detail: after };
|
||||
return { ...base, code: 0, proven: true, caught: during.tools };
|
||||
}
|
||||
|
||||
// ---- CLI --------------------------------------------------------------------
|
||||
|
||||
function isMain() {
|
||||
try {
|
||||
return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const o = { targets: [], plants: [], prove: false, repo: null, rule: null, bad: null };
|
||||
const value = (i, flag) => {
|
||||
const v = argv[i + 1];
|
||||
if (v === undefined || v.startsWith('--')) { o.bad = o.bad ?? `${flag} needs a value`; return null; }
|
||||
return v;
|
||||
};
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const a = argv[i];
|
||||
if (a === '--repo') { o.repo = value(i, a); i++; }
|
||||
else if (a === '--target') { const v = value(i, a); if (v) o.targets.push(v); i++; }
|
||||
else if (a === '--rule') { o.rule = value(i, a); i++; }
|
||||
else if (a === '--prove') o.prove = true;
|
||||
else if (a === '--plant') {
|
||||
const v = value(i, a) ?? '';
|
||||
i++;
|
||||
const at = v.lastIndexOf('=');
|
||||
if (at <= 0 || at === v.length - 1) o.bad = o.bad ?? `--plant expects <file>=<dest>, got "${v}"`;
|
||||
else o.plants.push({ src: resolve(v.slice(0, at)), dest: v.slice(at + 1).split('/').join(sep) });
|
||||
} else o.bad = o.bad ?? `unknown argument: ${a}`;
|
||||
}
|
||||
return o;
|
||||
}
|
||||
|
||||
if (isMain()) {
|
||||
const o = parseArgs(process.argv.slice(2));
|
||||
let result;
|
||||
if (o.bad || !o.repo) {
|
||||
result = { code: 2, status: 'not-measured', reason: o.bad ?? 'usage: arch-rules.mjs --repo <dir> [--target <path>]... [--prove --rule <name> --plant <file>=<dest>...]' };
|
||||
} else {
|
||||
const repo = resolve(o.repo);
|
||||
result = o.prove
|
||||
? proveRule({ repo, targets: o.targets, plants: o.plants, rule: o.rule })
|
||||
: runArchRules({ repo, targets: o.targets });
|
||||
}
|
||||
process.stdout.write(JSON.stringify(result, null, 2) + '\n');
|
||||
process.exit(result.code);
|
||||
}
|
||||
202
lib/verification/plan-scope-sweep.mjs
Normal file
202
lib/verification/plan-scope-sweep.mjs
Normal file
|
|
@ -0,0 +1,202 @@
|
|||
// 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 } 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');
|
||||
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 <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);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue