141 lines
4.7 KiB
TypeScript
141 lines
4.7 KiB
TypeScript
#!/usr/bin/env bun
|
|
/**
|
|
* Enforce §18 of openspec/AGENTS.md: every Stage block in an umbrella
|
|
* tasks.md MUST list a `N.0 e2e/<umbrella>-stage-N/` closure-smoke row
|
|
* alongside the stage's per-package rows.
|
|
*
|
|
* Grandfather clause: umbrellas whose archive folder predates
|
|
* RULE_EFFECTIVE_DATE are exempt. `api-plugin-foundation` is also
|
|
* grandfathered (opened pre-§18).
|
|
*/
|
|
|
|
import { readFile } from "node:fs/promises";
|
|
import { resolve } from "node:path";
|
|
import { Glob } from "bun";
|
|
|
|
const REPO_ROOT = resolve(import.meta.dir, "..", "..");
|
|
const RULE_EFFECTIVE_DATE = "2026-05-27";
|
|
|
|
interface StageBlock {
|
|
readonly umbrella: string;
|
|
readonly file: string;
|
|
readonly stageId: number;
|
|
readonly stageHeadingLine: number;
|
|
readonly hasSmokeRow: boolean;
|
|
}
|
|
|
|
const STAGE_HEADING_RE = /^### Stage (\d+)\b/;
|
|
const PACKAGE_ROW_RE = /^- \[( |x)\]\s+(\d+)\.(\d+)\s+/i;
|
|
|
|
async function parseUmbrella(rel: string): Promise<StageBlock[]> {
|
|
const abs = resolve(REPO_ROOT, rel);
|
|
const source = await readFile(abs, "utf8");
|
|
const lines = source.split("\n");
|
|
const umbrellaName = rel.split("/")[2] ?? "?";
|
|
const blocks: StageBlock[] = [];
|
|
let inSection1 = false;
|
|
let currentStage: number | undefined;
|
|
let currentStageLine = 0;
|
|
let currentHasSmoke = false;
|
|
for (let i = 0; i < lines.length; i++) {
|
|
const line = lines[i] ?? "";
|
|
if (/^## 1\b/.test(line)) {
|
|
inSection1 = true;
|
|
continue;
|
|
}
|
|
if (inSection1 && /^## /.test(line)) {
|
|
if (currentStage !== undefined) {
|
|
blocks.push({
|
|
umbrella: umbrellaName,
|
|
file: rel,
|
|
stageId: currentStage,
|
|
stageHeadingLine: currentStageLine,
|
|
hasSmokeRow: currentHasSmoke,
|
|
});
|
|
currentStage = undefined;
|
|
}
|
|
inSection1 = false;
|
|
continue;
|
|
}
|
|
if (!inSection1) continue;
|
|
const sh = STAGE_HEADING_RE.exec(line);
|
|
if (sh !== null) {
|
|
if (currentStage !== undefined) {
|
|
blocks.push({
|
|
umbrella: umbrellaName,
|
|
file: rel,
|
|
stageId: currentStage,
|
|
stageHeadingLine: currentStageLine,
|
|
hasSmokeRow: currentHasSmoke,
|
|
});
|
|
}
|
|
currentStage = Number.parseInt(sh[1] ?? "0", 10);
|
|
currentStageLine = i + 1;
|
|
currentHasSmoke = false;
|
|
continue;
|
|
}
|
|
const pr = PACKAGE_ROW_RE.exec(line);
|
|
if (pr !== null) {
|
|
const [, , majorStr, minorStr] = pr;
|
|
if (minorStr === "0" && majorStr !== undefined) {
|
|
if (line.includes("e2e/") && line.includes("stage-")) {
|
|
currentHasSmoke = true;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if (currentStage !== undefined) {
|
|
blocks.push({
|
|
umbrella: umbrellaName,
|
|
file: rel,
|
|
stageId: currentStage,
|
|
stageHeadingLine: currentStageLine,
|
|
hasSmokeRow: currentHasSmoke,
|
|
});
|
|
}
|
|
return blocks;
|
|
}
|
|
|
|
async function isGrandfathered(umbrella: string): Promise<boolean> {
|
|
const g = new Glob(`openspec/archive/*-${umbrella}/proposal.md`);
|
|
for await (const f of g.scan({ cwd: REPO_ROOT })) {
|
|
const m = f.match(/openspec\/archive\/(\d{4}-\d{2}-\d{2})-/);
|
|
if (m !== null && m[1] !== undefined) {
|
|
if (m[1] < RULE_EFFECTIVE_DATE) return true;
|
|
}
|
|
}
|
|
if (umbrella === "api-plugin-foundation") return true;
|
|
return false;
|
|
}
|
|
|
|
async function main(): Promise<void> {
|
|
const violations: string[] = [];
|
|
const g = new Glob("openspec/changes/*/tasks.md");
|
|
for await (const f of g.scan({ cwd: REPO_ROOT })) {
|
|
const umbrella = f.split("/")[2] ?? "?";
|
|
if (await isGrandfathered(umbrella)) continue;
|
|
const blocks = await parseUmbrella(f);
|
|
for (const b of blocks) {
|
|
if (!b.hasSmokeRow) {
|
|
violations.push(
|
|
`${b.file}:${b.stageHeadingLine} Stage ${b.stageId} lacks an N.0 e2e/${b.umbrella}-stage-${b.stageId}/ closure-smoke row`
|
|
);
|
|
}
|
|
}
|
|
}
|
|
if (violations.length === 0) {
|
|
console.log("OK every Stage-N block lists its closure-smoke row.");
|
|
return;
|
|
}
|
|
console.error(
|
|
`FAIL ${violations.length} stage block(s) missing closure-smoke row:`
|
|
);
|
|
for (const v of violations) console.error(` ${v}`);
|
|
console.error(
|
|
"\n§18: every Stage N block MUST include - [ ] N.0 e2e/<umbrella>-stage-N/ alongside its package rows."
|
|
);
|
|
process.exit(1);
|
|
}
|
|
|
|
await main();
|