#!/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 [--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 [--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("