Initial Commit
This commit is contained in:
123
scripts/lint/check-cross-package-contracts.ts
Normal file
123
scripts/lint/check-cross-package-contracts.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
#!/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);
|
||||
Reference in New Issue
Block a user