#!/usr/bin/env bun /** * Enforce §20 of `openspec/AGENTS.md`: every requirement in every spec * delta MUST carry a `**Verified by:**` line. At archive time, the * cited file path MUST exist and the cited it("...") title MUST be * present. * * Modes * ----- * - `--mode=author` (default): runs on changes in `openspec/changes/`. * Verified-by lines must be syntactically valid; cited files need * not exist yet. * - `--mode=archive`: runs on changes in `openspec/archive/`. Cited * files MUST exist and contain the cited title. */ import { readFile, stat } from "node:fs/promises"; import { resolve } from "node:path"; import { spawnSync } from "node:child_process"; import { Glob } from "bun"; const REPO_ROOT = resolve(import.meta.dir, "..", ".."); const RULE_EFFECTIVE_DATE = "2026-05-27"; /** * Change folders whose first commit predates RULE_EFFECTIVE_DATE are * grandfathered (§20 applies to new authoring only). Cached per * change-id so we run git log at most once per change. */ const grandfatherCache = new Map(); function changeIdOf(specRel: string): string | undefined { // openspec/(changes|archive)//specs/... const parts = specRel.split("/"); if (parts.length < 3) return undefined; return parts[2]; } function isGrandfathered(specRel: string): boolean { const changeId = changeIdOf(specRel); if (changeId === undefined) return false; const cached = grandfatherCache.get(changeId); if (cached !== undefined) return cached; const root = specRel.startsWith("openspec/archive/") ? `openspec/archive/${changeId}` : `openspec/changes/${changeId}`; const result = spawnSync( "git", [ "-C", REPO_ROOT, "log", "--diff-filter=A", "--format=%cI", "--", root, ], { encoding: "utf8" } ); const out = (result.stdout ?? "").trim().split("\n").filter(Boolean); if (out.length === 0) { grandfatherCache.set(changeId, false); return false; } const firstCommitISO = out[out.length - 1]; if (firstCommitISO === undefined) { grandfatherCache.set(changeId, false); return false; } const firstCommitDate = firstCommitISO.slice(0, 10); const grandfathered = firstCommitDate < RULE_EFFECTIVE_DATE; grandfatherCache.set(changeId, grandfathered); return grandfathered; } interface Violation { readonly file: string; readonly line: number; readonly reason: string; } const REQ_RE = /^### Requirement:\s+(.+)$/; const VERIFIED_RE = /^\*\*Verified by:\*\*\s+(.+)$/; const CITE_RE = /`([^`]+\.test\.tsx?|[^`]+\.spec\.tsx?)::"([^"]+)"`/g; function modeFromArgv(): "author" | "archive" { const arg = process.argv.find((a) => a.startsWith("--mode=")); if (arg === undefined) return "author"; const val = arg.slice("--mode=".length); if (val !== "author" && val !== "archive") { console.error(`bad --mode value: ${val}`); process.exit(2); } return val; } async function fileExists(absPath: string): Promise { try { await stat(absPath); return true; } catch { return false; } } async function checkSpecFile( rel: string, mode: "author" | "archive" ): Promise { const abs = resolve(REPO_ROOT, rel); const source = await readFile(abs, "utf8"); const lines = source.split("\n"); const violations: Violation[] = []; let inRequirement = false; let reqStartLine = 0; let reqTitle = ""; let sawVerified = false; let verifiedLine = ""; let verifiedLineNo = 0; for (let i = 0; i < lines.length; i++) { const line = lines[i] ?? ""; const m = REQ_RE.exec(line); if (m !== null) { if (inRequirement && !sawVerified) { violations.push({ file: rel, line: reqStartLine, reason: `Requirement "${reqTitle}" lacks a **Verified by:** line`, }); } inRequirement = true; reqStartLine = i + 1; reqTitle = m[1] ?? ""; sawVerified = false; verifiedLine = ""; verifiedLineNo = 0; continue; } if (line.startsWith("### ") || line.startsWith("## ")) { if (inRequirement && !sawVerified) { violations.push({ file: rel, line: reqStartLine, reason: `Requirement "${reqTitle}" lacks a **Verified by:** line`, }); } inRequirement = false; continue; } if (!inRequirement) continue; const vm = VERIFIED_RE.exec(line); if (vm !== null) { sawVerified = true; verifiedLine = vm[1] ?? ""; verifiedLineNo = i + 1; const cites = [...verifiedLine.matchAll(CITE_RE)]; if (cites.length === 0) { violations.push({ file: rel, line: verifiedLineNo, reason: `Verified-by line lacks a parseable path::"title" citation (got: ${verifiedLine.slice(0, 80)})`, }); continue; } if (mode === "archive") { for (const c of cites) { const path = c[1]; const title = c[2]; if (path === undefined || title === undefined) continue; const pkgGuess = pkgRootOf(rel); if (pkgGuess === undefined) continue; const testAbs = resolve(REPO_ROOT, pkgGuess, path); if (!(await fileExists(testAbs))) { violations.push({ file: rel, line: verifiedLineNo, reason: `cited test file does not exist: ${pkgGuess}/${path}`, }); continue; } const src = await readFile(testAbs, "utf8"); if (!src.includes(title)) { violations.push({ file: rel, line: verifiedLineNo, reason: `cited title not found in ${path}: "${title}"`, }); } } } } } if (inRequirement && !sawVerified) { violations.push({ file: rel, line: reqStartLine, reason: `Requirement "${reqTitle}" lacks a **Verified by:** line`, }); } return violations; } function pkgRootOf(specRel: string): string | undefined { // Capability-name -> package root mapping is ambiguous in a 3-level // monorepo. Skip strict archive-time existence check unless the // resolver can be made unambiguous via a `package-root-map.json`. void specRel; return undefined; } async function main(): Promise { const mode = modeFromArgv(); const pattern = mode === "archive" ? "openspec/archive/*/specs/*/spec.md" : "openspec/changes/*/specs/*/spec.md"; const g = new Glob(pattern); const files: string[] = []; for await (const f of g.scan({ cwd: REPO_ROOT })) { files.push(f); } const allViolations: Violation[] = []; let skipped = 0; for (const f of files) { if (isGrandfathered(f)) { skipped++; continue; } allViolations.push(...(await checkSpecFile(f, mode))); } if (allViolations.length === 0) { console.log( `OK ${files.length} spec deltas scanned (mode=${mode}, ${skipped} grandfathered); every checked Requirement has a **Verified by:** citation.` ); return; } console.error( `FAIL ${allViolations.length} Verified-by violation(s) (mode=${mode}):` ); for (const v of allViolations) { console.error(` ${v.file}:${v.line} ${v.reason}`); } console.error( "\n§20: every Requirement MUST have **Verified by:** path::title." ); process.exit(1); } await main();