250 lines
8.2 KiB
TypeScript
250 lines
8.2 KiB
TypeScript
#!/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<string, boolean>();
|
|
|
|
function changeIdOf(specRel: string): string | undefined {
|
|
// openspec/(changes|archive)/<change-id>/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<boolean> {
|
|
try {
|
|
await stat(absPath);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
async function checkSpecFile(
|
|
rel: string,
|
|
mode: "author" | "archive"
|
|
): Promise<Violation[]> {
|
|
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<void> {
|
|
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();
|