Files
suaveplan/scripts/lint/check-cross-package-contracts.ts
2026-05-27 23:47:53 +01:00

124 lines
3.6 KiB
TypeScript

#!/usr/bin/env bun
// §15 lint: cross-package contracts must live in contract packages
import { readdirSync, readFileSync } from "node:fs";
import { join, relative, resolve } from "node:path";
const ROOT = resolve(import.meta.dir, "../..");
const PACKAGES_DIR = join(ROOT, "packages");
const CONTRACT_BASENAMES = new Set([
"types",
"api-types",
"api-manifest",
"api-router",
"api-graphql",
"api-ws",
"api-sse",
"api-auth",
"api-auth-provider",
"api-secrets",
"api-tenant-provider",
"api-loader",
"api-lifecycle",
]);
const SUPPRESSION = "@cross-package-contract";
interface Decl {
readonly name: string;
readonly file: string;
readonly pkg: string;
}
function walk(dir: string, out: string[]): void {
for (const e of readdirSync(dir, { withFileTypes: true })) {
if (
e.name === "node_modules" ||
e.name === "dist" ||
e.name.startsWith(".")
)
continue;
const full = join(dir, e.name);
if (e.isDirectory()) walk(full, out);
else if (
e.isFile() &&
full.endsWith(".ts") &&
!full.endsWith(".test.ts") &&
!full.endsWith(".spec.ts") &&
!full.endsWith(".stories.tsx")
)
out.push(full);
}
}
function pkgOf(src: string): string | null {
const rel = relative(PACKAGES_DIR, src);
const parts = rel.split("/");
const idx = parts.indexOf("src");
return idx > 0 ? (parts[idx - 1] ?? null) : null;
}
const files: string[] = [];
walk(PACKAGES_DIR, files);
const decls: Decl[] = [];
for (const f of files) {
const text = readFileSync(f, "utf-8");
const lines = text.split("\n");
const re = /^\s*export\s+(?:interface\s+(\w+)|type\s+(\w+)\s*[=<])/;
for (let i = 0; i < lines.length; i++) {
const prev = i > 0 ? (lines[i - 1] ?? "") : "";
if (prev.includes(SUPPRESSION)) continue;
const m = re.exec(lines[i] ?? "");
if (!m) continue;
const name = m[1] ?? m[2];
const pkg = pkgOf(f);
if (!name || !pkg) continue;
decls.push({ name, file: f, pkg });
}
}
const byName = new Map<string, Decl>();
for (const d of decls) byName.set(d.name, d);
const violations = new Map<string, string[]>();
const implRe = /\bimplements\s+([A-Z]\w*)/g;
for (const f of files) {
const consumerPkg = pkgOf(f);
if (!consumerPkg) continue;
const text = readFileSync(f, "utf-8");
implRe.lastIndex = 0;
let m: RegExpExecArray | null;
while ((m = implRe.exec(text)) !== null) {
const name = m[1];
if (!name) continue;
const d = byName.get(name);
if (!d || d.pkg === consumerPkg) continue;
if (CONTRACT_BASENAMES.has(d.pkg)) continue;
const key = `${d.pkg}#${name}`;
const arr = violations.get(key) ?? [];
arr.push(`${relative(ROOT, f)} (${consumerPkg})`);
violations.set(key, arr);
}
}
if (violations.size === 0) {
console.log(
`✓ check-cross-package-contracts: 0 violations across ${files.length} files`
);
process.exit(0);
}
console.error(
`✗ check-cross-package-contracts: ${violations.size} violation(s)`
);
console.error(
"Per openspec/AGENTS.md §15, these types should move to a contract package"
);
console.error(
"OR add `// @cross-package-contract` above the declaration to document why.\n"
);
for (const [k, cs] of violations) {
const [pkg, name] = k.split("#");
console.error(` ${name} (in ${pkg}):`);
for (const c of cs) console.error(` implemented by: ${c}`);
}
process.exit(1);