#!/usr/bin/env bun /** * Enforce §18 of openspec/AGENTS.md: every Stage block in an umbrella * tasks.md MUST list a `N.0 e2e/-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 { 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 { 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 { 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/-stage-N/ alongside its package rows." ); process.exit(1); } await main();