#!/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(); for (const d of decls) byName.set(d.name, d); const violations = new Map(); 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);