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

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