Files
suaveplan/scripts/lint/check-verified-by.ts
2026-05-27 23:47:53 +01:00

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();