295 lines
15 KiB
JavaScript
295 lines
15 KiB
JavaScript
#!/usr/bin/env node
|
|
// Deterministic checks for wealth-ledger-sdd-lab changes. OpenSpec CLI 1.13.1 has no conditional
|
|
// artifacts, gate records or archive policy, so this file covers only what the CLI cannot.
|
|
// It is the single source for profile, ordering, gate and readiness rules; schema.yaml points here.
|
|
// Semantic quality (contradictions, invented facts, design fit) stays with reviewers.
|
|
//
|
|
// Usage: node check.cjs <plan|apply|archive> <change-name> [--root <openspec-root>]
|
|
// plan — artifact consistency while planning; missing gates are notes, not errors.
|
|
// apply — plan + every apply blocker is an error.
|
|
// archive — apply + all tasks done, G4 approved, lab retrospective.
|
|
// Exit: 0 ok, 1 violations, 2 invocation/environment error.
|
|
"use strict";
|
|
const fs = require("node:fs");
|
|
const path = require("node:path");
|
|
|
|
const SA_MODULES = ["data-mapping", "failure-model", "nfr", "observability"];
|
|
// Primary file of each conditional artifact, relative to the change directory.
|
|
const PRIMARY = {
|
|
research: "research.md",
|
|
"ux-design": "ux-design.md",
|
|
"system-analysis": "system-analysis/system-analysis.md",
|
|
"data-mapping": "system-analysis/data-mapping.md",
|
|
"failure-model": "system-analysis/failure-model.md",
|
|
nfr: "system-analysis/nfr.md",
|
|
observability: "system-analysis/observability.md",
|
|
"security-analysis": "security-analysis.md",
|
|
"test-cases": "test-cases",
|
|
"docs-impact": "docs-impact.md",
|
|
};
|
|
const CONDITIONAL = Object.keys(PRIMARY);
|
|
// A selected conditional input must exist before any of these files is written.
|
|
const BEFORE_SOLUTION = ["design.md", "qa-analysis.md", "tasks.md"];
|
|
const DOWNSTREAM = {
|
|
research: ["specs", ...BEFORE_SOLUTION],
|
|
"ux-design": BEFORE_SOLUTION,
|
|
"system-analysis": BEFORE_SOLUTION,
|
|
...Object.fromEntries(SA_MODULES.map((id) => [id, BEFORE_SOLUTION])),
|
|
"security-analysis": BEFORE_SOLUTION,
|
|
"test-cases": ["tasks.md"],
|
|
"docs-impact": ["tasks.md"],
|
|
};
|
|
// Gate that may approve each artifact. Complex changes approve system analysis at G2 (contract-first).
|
|
const SA_IDS = ["system-analysis", ...SA_MODULES];
|
|
const allowedGates = (id, complex, hasG2) => {
|
|
if (id === "proposal") return ["G1"];
|
|
if (id === "change-profile") return ["G1", "G2", "G3"]; // later profile changes need a recorded decision
|
|
if (id === "specs") return hasG2 ? ["G2"] : ["G3"];
|
|
if (SA_IDS.includes(id)) return complex ? ["G2"] : hasG2 ? ["G2", "G3"] : ["G3"];
|
|
return ["G3"];
|
|
};
|
|
const NOT_APPROVED = new Set(["research", "process-record"]); // input context / the log itself
|
|
const REF = /`([a-z0-9][a-z0-9/-]*)`\s*[—–-]\s*`([^`]+)`/g;
|
|
|
|
const fail = (msg) => { console.error(msg); process.exit(2); };
|
|
const args = process.argv.slice(2);
|
|
const [mode, changeName] = args;
|
|
if (!["plan", "apply", "archive"].includes(mode) || !changeName) {
|
|
fail("Usage: node check.cjs <plan|apply|archive> <change-name> [--root <openspec-root>]");
|
|
}
|
|
const rootIdx = args.indexOf("--root");
|
|
if (rootIdx > 0 && !args[rootIdx + 1]) fail("--root needs a path");
|
|
const root = path.resolve(rootIdx > 0 ? args[rootIdx + 1] : path.join(__dirname, "..", ".."));
|
|
const changeDir = path.join(root, "changes", changeName);
|
|
if (!fs.existsSync(changeDir)) fail(`Change directory not found: ${changeDir}`);
|
|
|
|
const errors = [];
|
|
const warnings = [];
|
|
const notes = [];
|
|
const rel = (p) => path.relative(changeDir, p);
|
|
const read = (p) => fs.readFileSync(p, "utf8");
|
|
const at = (p) => path.join(changeDir, p);
|
|
const walk = (dir) => fs.existsSync(dir)
|
|
? fs.readdirSync(dir, { withFileTypes: true }).flatMap((e) =>
|
|
e.isDirectory() ? walk(path.join(dir, e.name)) : [path.join(dir, e.name)])
|
|
: [];
|
|
const present = (p) => p === "test-cases" || p === "specs"
|
|
? walk(at(p)).some((f) => f.endsWith(".md"))
|
|
: fs.existsSync(at(p));
|
|
const mdFiles = walk(changeDir).filter((f) => f.endsWith(".md"));
|
|
const strip = (s) => (s ?? "").replace(/`/g, "").trim();
|
|
// Columns: fixed count, the last cell keeps any `|` it contains.
|
|
const tableRows = (text, heading, columns) => {
|
|
const section = text.split(/^## /m).find((s) => s.startsWith(heading));
|
|
if (!section) return null;
|
|
return section.split("\n").filter((l) => /^\|/.test(l)).slice(2).map((l) => {
|
|
const parts = l.replace(/^\|/, "").replace(/\|\s*$/, "").split("|");
|
|
return [...parts.slice(0, columns - 1), parts.slice(columns - 1).join("|")].map((c) => c.trim());
|
|
});
|
|
};
|
|
|
|
finishIfMissing();
|
|
const record = read(at("process-record.md"));
|
|
|
|
// 1. Unfilled templates.
|
|
for (const f of mdFiles) {
|
|
if (read(f).includes("<!-- guide:")) errors.push(`${rel(f)}: unfilled template guide comment remains`);
|
|
}
|
|
|
|
// 2. Mode, class, Change Profile.
|
|
const labMode = (record.match(/\*\*Mode:\*\*\s*(lab|delivery)\b/i) || [])[1]?.toLowerCase();
|
|
if (!labMode) errors.push("process-record.md: **Mode:** must be `lab` or `delivery`");
|
|
const klass = (record.match(/\*\*Class:\*\*\s*(standard|complex)\b/i) || [])[1]?.toLowerCase();
|
|
if (!klass) errors.push("process-record.md: **Class:** must be `standard` or `complex`");
|
|
const complex = klass === "complex";
|
|
const hasG2 = labMode === "lab" || complex;
|
|
|
|
const profile = new Map();
|
|
for (const [rawId, selected, reason] of tableRows(record, "Change Profile", 3) ?? []) {
|
|
const id = strip(rawId);
|
|
if (!CONDITIONAL.includes(id)) { errors.push(`Change Profile: unknown artifact '${id}'`); continue; }
|
|
if (!/^(yes|no)$/i.test(strip(selected))) errors.push(`Change Profile: '${id}' Selected must be yes|no`);
|
|
if (!reason) errors.push(`Change Profile: '${id}' needs a trigger or reason`);
|
|
profile.set(id, /^yes$/i.test(strip(selected)));
|
|
}
|
|
const pending = [];
|
|
for (const id of CONDITIONAL) {
|
|
if (!profile.has(id)) { errors.push(`Change Profile: no decision for '${id}'`); continue; }
|
|
const has = present(PRIMARY[id]);
|
|
if (profile.get(id) && !has) {
|
|
const consumer = DOWNSTREAM[id].find(present);
|
|
if (consumer) errors.push(`'${id}' is selected but ${PRIMARY[id]} does not exist while ${consumer} was already written`);
|
|
else pending.push(id);
|
|
}
|
|
if (!profile.get(id) && has) errors.push(`'${id}' is not selected but ${PRIMARY[id]} exists; select it through a gate decision or remove the file`);
|
|
}
|
|
for (const id of SA_MODULES) {
|
|
if (profile.get(id) && !profile.get("system-analysis")) errors.push(`'${id}' requires system-analysis to be selected`);
|
|
}
|
|
if (fs.existsSync(at("system-analysis")) && !present(PRIMARY["system-analysis"])) {
|
|
errors.push("system-analysis/ exists without system-analysis/system-analysis.md");
|
|
}
|
|
|
|
// 3. Requirement references resolve; delta requirements are covered.
|
|
const skipSpecs = present(".openspec.yaml") && /^skip_specs:\s*true/m.test(read(at(".openspec.yaml")));
|
|
if (skipSpecs && present("specs")) errors.push("skip_specs: true but specs/ contains delta files");
|
|
const deltaReqs = [];
|
|
const known = new Map();
|
|
const removed = new Set();
|
|
const addKnown = (cap, h) => (known.get(cap) ?? known.set(cap, new Set()).get(cap)).add(h);
|
|
const key = (cap, req) => `${cap}\u0000${req}`;
|
|
for (const f of walk(at("specs")).filter((x) => x.endsWith("spec.md"))) {
|
|
const cap = path.relative(at("specs"), path.dirname(f)).split(path.sep).join("/");
|
|
let op = null;
|
|
const seen = new Set();
|
|
for (const line of read(f).split("\n")) {
|
|
op = (line.match(/^## (ADDED|MODIFIED|REMOVED|RENAMED) Requirements/) || [])[1] ?? op;
|
|
const req = (line.match(/^### Requirement:\s*(.+?)\s*$/) || [])[1];
|
|
if (req) {
|
|
if (seen.has(req)) errors.push(`specs/${cap}/spec.md: duplicate Requirement '${req}'`);
|
|
seen.add(req);
|
|
if (op === "REMOVED") removed.add(key(cap, req));
|
|
else addKnown(cap, req);
|
|
if (op === "ADDED" || op === "MODIFIED") deltaReqs.push(key(cap, req));
|
|
}
|
|
const to = (line.match(/^-?\s*TO:\s*`?###\s*Requirement:\s*(.+?)`?\s*$/) || [])[1];
|
|
if (to) addKnown(cap, to);
|
|
}
|
|
}
|
|
for (const f of walk(path.join(root, "specs")).filter((x) => x.endsWith("spec.md"))) {
|
|
const cap = path.relative(path.join(root, "specs"), path.dirname(f)).split(path.sep).join("/");
|
|
for (const m of read(f).matchAll(/^### Requirement:\s*(.+?)\s*$/gm)) {
|
|
if (!removed.has(key(cap, m[1]))) addKnown(cap, m[1]);
|
|
}
|
|
}
|
|
const refs = (text) => [...text.matchAll(REF)].map((m) => ({ cap: m[1], req: m[2] }));
|
|
for (const f of mdFiles.filter((x) => !rel(x).startsWith(`specs${path.sep}`))) {
|
|
for (const { cap, req } of refs(read(f))) {
|
|
if (!known.get(cap)?.has(req)) errors.push(`${rel(f)}: unknown requirement reference \`${cap}\` — \`${req}\``);
|
|
}
|
|
}
|
|
const tasksPath = at("tasks.md");
|
|
const tasksText = fs.existsSync(tasksPath) ? read(tasksPath) : "";
|
|
const coversLines = tasksText.split("\n").filter((l) => /\*\*Covers:\*\*/.test(l)).join("\n");
|
|
const taskCover = new Set(refs(coversLines).map((r) => key(r.cap, r.req)));
|
|
const qaText = [at("qa-analysis.md"), ...walk(at("test-cases"))].filter(fs.existsSync).map(read).join("\n");
|
|
const qaCover = new Set(refs(qaText).map((r) => key(r.cap, r.req)));
|
|
for (const k of deltaReqs) {
|
|
const label = k.replace("\u0000", "` — `");
|
|
if (tasksText && !taskCover.has(k)) errors.push(`tasks.md: delta requirement \`${label}\` has no task Covers`);
|
|
if (present("qa-analysis.md") && !qaCover.has(k)) warnings.push(`qa-analysis: delta requirement \`${label}\` has no verification reference (allowed only with a stated risk reason)`);
|
|
}
|
|
if (!skipSpecs && present("specs") && deltaReqs.length === 0) warnings.push("no ADDED/MODIFIED requirements found in delta specs");
|
|
|
|
// 4. Test case identity and traceability.
|
|
const tcIds = new Set();
|
|
for (const f of walk(at("test-cases")).filter((x) => x.endsWith(".md"))) {
|
|
for (const block of read(f).split(/^###\s+(?=TC-)/m).slice(1)) {
|
|
const id = block.split(/\s/)[0];
|
|
if (tcIds.has(id)) errors.push(`${rel(f)}: duplicate test case id ${id}`);
|
|
tcIds.add(id);
|
|
if (!refs(block).length) errors.push(`${rel(f)}: test case ${id} has no requirement reference`);
|
|
}
|
|
}
|
|
|
|
// 5. Contracts and diagrams are referenced, JSON parses, required task kinds exist.
|
|
const saText = walk(at("system-analysis")).filter((f) => f.endsWith(".md")).map(read).join("\n");
|
|
const contracts = walk(at("system-analysis/contracts"));
|
|
for (const f of [...contracts, ...walk(at("system-analysis/diagrams"))]) {
|
|
if (!saText.includes(path.basename(f))) errors.push(`${rel(f)}: not referenced from any system-analysis/*.md`);
|
|
if (f.endsWith(".json")) {
|
|
try { JSON.parse(read(f)); } catch (e) { errors.push(`${rel(f)}: invalid JSON (${e.message})`); }
|
|
}
|
|
}
|
|
const taskLines = tasksText.split("\n").filter((l) => /^\s*[-*+]\s*\[.?\]/.test(l));
|
|
if (tasksText) {
|
|
if (!taskLines.some((l) => /Independent Review:/.test(l))) errors.push("tasks.md: no `Independent Review:` task");
|
|
if (contracts.length && !taskLines.some((l) => /Contract Parity:/.test(l))) errors.push("tasks.md: contracts exist but no `Contract Parity:` task");
|
|
if (profile.get("docs-impact") === false && !/^## Docs Impact/m.test(tasksText)) {
|
|
errors.push("tasks.md: docs-impact not selected, so a `## Docs Impact` section (changes or no-change reason) is required");
|
|
}
|
|
}
|
|
|
|
// 6. Gate Log: chronological; invalidating Gn withdraws Gn and every later gate.
|
|
const gates = new Map();
|
|
const order = [];
|
|
for (const [rawGate, , scope, validation, , decision] of tableRows(record, "Gate Log", 6) ?? []) {
|
|
const id = (strip(rawGate).match(/^G[1-4]\b/) || [])[0];
|
|
if (!id) continue;
|
|
if (/^invalidated\b/i.test(decision)) {
|
|
for (const g of [...gates.keys()]) if (g >= id) gates.delete(g);
|
|
continue;
|
|
}
|
|
if (!/^approved\b/i.test(decision)) continue;
|
|
if (!/"[^"]+"|«[^»]+»/.test(decision)) errors.push(`Gate ${id}: approval must quote the Change Owner decision`);
|
|
if (!validation) errors.push(`Gate ${id}: approval has no validation evidence`);
|
|
const prior = ["G1", "G2", "G3", "G4"].filter((g) => g < id && (g !== "G2" || hasG2));
|
|
if (prior.some((g) => !gates.has(g))) errors.push(`Gate ${id}: approved before ${prior.filter((g) => !gates.has(g)).join(", ")}`);
|
|
const ids = strip(scope).split(/[,\s]+/).filter(Boolean);
|
|
for (const a of ids) {
|
|
if (id !== "G4" && !allowedGates(a, complex, hasG2).includes(id)) errors.push(`Gate ${id}: '${a}' must be approved at ${allowedGates(a, complex, hasG2).join(" or ")}`);
|
|
}
|
|
gates.set(id, ids);
|
|
order.push(id);
|
|
}
|
|
if (gates.has("G1") && !gates.get("G1").includes("change-profile")) errors.push("Gate G1: scope must include change-profile");
|
|
|
|
// 7. Readiness.
|
|
const artifactPaths = [
|
|
["proposal", "proposal.md"], ["specs", "specs"], ["design", "design.md"],
|
|
["qa-analysis", "qa-analysis.md"], ["tasks", "tasks.md"],
|
|
...CONDITIONAL.map((id) => [id, PRIMARY[id]]),
|
|
];
|
|
const approvedScope = new Set([...gates.values()].flat());
|
|
const required = hasG2 ? ["G1", "G2", "G3"] : ["G1", "G3"];
|
|
const missingGates = required.filter((g) => !gates.has(g));
|
|
const unapproved = artifactPaths.filter(([id, p]) => !NOT_APPROVED.has(id) && present(p) && !approvedScope.has(id)).map(([id]) => id);
|
|
const blockers = [
|
|
...(tasksText ? [] : ["tasks.md missing"]),
|
|
...(missingGates.length ? [`missing gates: ${missingGates.join(", ")}`] : []),
|
|
...(unapproved.length ? [`not in any approved gate scope: ${unapproved.join(", ")}`] : []),
|
|
...(pending.length ? [`selected but missing: ${pending.join(", ")}`] : []),
|
|
];
|
|
notes.push(`apply-ready: ${blockers.length || errors.length ? `no (${[...blockers, ...(errors.length ? ["errors"] : [])].join("; ")})` : "yes"}`);
|
|
if (mode !== "plan") blockers.forEach((b) => errors.push(`apply blocker: ${b}`));
|
|
|
|
const sequence = ["proposal", "process-record", "research", "specs", "ux-design", "system-analysis",
|
|
...SA_MODULES, "security-analysis", "design", "qa-analysis", "test-cases", "docs-impact", "tasks"];
|
|
const pathOf = Object.fromEntries([...artifactPaths, ["process-record", "process-record.md"]]);
|
|
const next = sequence.find((id) => {
|
|
if (CONDITIONAL.includes(id) && !profile.get(id)) return false;
|
|
if (id === "specs" && skipSpecs) return false;
|
|
return !present(pathOf[id]);
|
|
});
|
|
notes.push(`next artifact: ${next ?? "none (planning complete for this profile)"}`);
|
|
|
|
// 8. Archive readiness. Native archive treats any non-[x] marker as open, so descoped work is
|
|
// removed from tasks.md by a gate decision and recorded in "Descoped Work".
|
|
if (mode === "archive") {
|
|
for (const l of taskLines) if (!/\[x\]/i.test(l)) errors.push(`tasks.md: open task: ${l.trim()}`);
|
|
for (const [task, reason, gate] of tableRows(record, "Descoped Work", 3) ?? []) {
|
|
if (task && (!reason || !/^G[1-4]$/.test(strip(gate)))) errors.push(`Descoped Work '${task}': needs reason and approving gate`);
|
|
}
|
|
if (!gates.has("G4")) errors.push("Gate G4 Delivery Acceptance is not approved");
|
|
if (labMode === "lab") {
|
|
const retro = record.split(/^## /m).find((s) => s.startsWith("Retrospective")) ?? "";
|
|
if (!retro.replace(/^Retrospective.*\n/, "").trim()) errors.push("process-record.md: Retrospective is empty (lab mode)");
|
|
if ((tableRows(record, "Lab Journal", 9) ?? []).length < gates.size) warnings.push("Lab Journal has fewer rows than approved gates");
|
|
}
|
|
}
|
|
finish();
|
|
|
|
function finishIfMissing() {
|
|
if (fs.existsSync(at("process-record.md"))) return;
|
|
errors.push("process-record.md is missing: Change Profile and Gate Log are required");
|
|
finish();
|
|
}
|
|
|
|
function finish() {
|
|
for (const n of notes) console.log(`note: ${n}`);
|
|
for (const w of warnings) console.log(`warning: ${w}`);
|
|
for (const e of errors) console.log(`error: ${e}`);
|
|
console.log(errors.length ? `FAIL ${mode} ${changeName}: ${errors.length} error(s)` : `OK ${mode} ${changeName}`);
|
|
process.exit(errors.length ? 1 : 0);
|
|
}
|