#!/usr/bin/env bun /** * Enforce §19 of openspec/AGENTS.md: every proposal for a package * on the configured primitive-package list MUST identify its concrete * consumer, its host-bridge (if needed), and the e2e/ test path. * * The primitive list lives in `scripts/lint/primitives.json`. Each * entry is the package short-name (e.g. `api-sandbox-vm`) plus an * optional `requiresBridge` flag and a `bridgePackagePattern`. * * For each `openspec/changes//proposal.md` whose package * name matches the list, scan the proposal text for the three * sentinel sections: * - `## Concrete consumer` containing at least one backtick-fenced * `@suaveplan/...` reference. * - `## Host bridge` (only if requiresBridge=true) citing the * bridge package by name matching bridgePackagePattern. * - `## End-to-end test` citing an `e2e/foundation-stack-stage-N/` * path. * * Missing any sentinel for a listed primitive is a violation. */ import { readFile } from "node:fs/promises"; import { resolve } from "node:path"; import { Glob } from "bun"; const REPO_ROOT = resolve(import.meta.dir, "..", ".."); interface PrimitiveSpec { readonly name: string; readonly requiresBridge?: boolean; readonly bridgePackagePattern?: string; } async function loadPrimitives(): Promise { const abs = resolve(REPO_ROOT, "scripts/lint/primitives.json"); try { const raw = await readFile(abs, "utf8"); return JSON.parse(raw) as PrimitiveSpec[]; } catch { return []; } } async function checkProposal( rel: string, spec: PrimitiveSpec ): Promise { const abs = resolve(REPO_ROOT, rel); const source = await readFile(abs, "utf8"); const violations: string[] = []; if (!source.includes("## Concrete consumer")) { violations.push( `${rel} §19: proposal for primitive '${spec.name}' lacks a '## Concrete consumer' section` ); } if (spec.requiresBridge === true) { if (!source.includes("## Host bridge")) { violations.push( `${rel} §19: proposal for primitive '${spec.name}' lacks a '## Host bridge' section` ); } else if ( spec.bridgePackagePattern !== undefined && !new RegExp(spec.bridgePackagePattern).test(source) ) { violations.push( `${rel} §19: '## Host bridge' section does not cite a bridge package matching pattern '${spec.bridgePackagePattern}'` ); } } if (!source.includes("## End-to-end test")) { violations.push( `${rel} §19: proposal for primitive '${spec.name}' lacks a '## End-to-end test' section` ); } else if (!/e2e\/foundation-stack-stage-\d+/.test(source) && !/e2e\/[a-z0-9-]+/.test(source)) { violations.push( `${rel} §19: '## End-to-end test' section does not cite an e2e/-stage-N/ path` ); } return violations; } async function main(): Promise { const primitives = await loadPrimitives(); if (primitives.length === 0) { console.log("OK no primitives configured in scripts/lint/primitives.json — nothing to check."); return; } const byName = new Map(primitives.map((p) => [p.name, p])); const violations: string[] = []; const g = new Glob("openspec/changes/*/proposal.md"); for await (const f of g.scan({ cwd: REPO_ROOT })) { const changeId = f.split("/")[2] ?? "?"; const spec = byName.get(changeId); if (spec === undefined) continue; violations.push(...(await checkProposal(f, spec))); } if (violations.length === 0) { console.log( `OK ${primitives.length} primitive package(s) on the §19 list; every proposal carries the three required sections.` ); return; } console.error(`FAIL ${violations.length} §19 violation(s):`); for (const v of violations) console.error(` ${v}`); console.error( "\n§19: every primitive package proposal MUST include '## Concrete consumer', '## Host bridge' (if required), and '## End-to-end test' sections." ); process.exit(1); } await main();