124 lines
3.6 KiB
TypeScript
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);
|