114 lines
4.2 KiB
TypeScript
114 lines
4.2 KiB
TypeScript
#!/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/<primitive>/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<PrimitiveSpec[]> {
|
|
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<string[]> {
|
|
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/<umbrella>-stage-N/ path`
|
|
);
|
|
}
|
|
return violations;
|
|
}
|
|
|
|
async function main(): Promise<void> {
|
|
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();
|