#!/usr/bin/env bun /** * check-wasm-subpath-deps.ts * * CI gate enforcing the wasm-tier dependency constraint declared in * openspec/changes/api-plugin-example-dual-shape/specs/api-plugin-example/spec.md * ("The ./wasm subpath SHALL have a constrained dependency graph"). * * Walks every static import / import-type transitively from * packages/api-stack/api/api-plugin-example/src/wasm/index.ts, * following relative paths AND package resolutions, and rejects any * specifier matching the wasm-tier ban list: * * Exact: @suaveplan/api-host, hono * Prefix: @hono/, @opentelemetry/sdk-metrics, * @opentelemetry/sdk-trace-, @opentelemetry/sdk-node * Pattern: node:* * Transitive: workspace package whose own deps include the above * * Exit codes: * 0 — clean * 1 — at least one violation found * 2 — internal error */ import { readdir, readFile, stat } from "node:fs/promises"; import { dirname, isAbsolute, join, relative, resolve } from "node:path"; export interface BannedHit { readonly file: string; readonly specifier: string; readonly reason: string; } const EXACT_BANS: ReadonlySet = new Set([ "@suaveplan/api-host", "hono", ]); const PREFIX_BANS: readonly string[] = [ "@hono/", "@opentelemetry/sdk-metrics", "@opentelemetry/sdk-trace-", "@opentelemetry/sdk-node", ]; export function banReason(specifier: string): string | null { if (specifier.startsWith("node:")) return "node:* specifier"; if (EXACT_BANS.has(specifier)) return `banned package: ${specifier}`; for (const prefix of PREFIX_BANS) { if (specifier === prefix || specifier.startsWith(prefix)) { return `banned prefix: ${prefix}`; } } return null; } const IMPORT_RE = /(?:^|\n)\s*(?:import|export)(?:\s+type)?\s+(?:[^'"`;]+\sfrom\s+)?['"]([^'"]+)['"]/g; export function extractSpecifiers(source: string): string[] { const stripped = source .replace(/\/\*[\s\S]*?\*\//g, "") .replace(/(^|[^:])\/\/[^\n]*/g, "$1"); const out: string[] = []; let match: RegExpExecArray | null = IMPORT_RE.exec(stripped); while (match !== null) { const spec = match[1]; if (spec !== undefined) out.push(spec); match = IMPORT_RE.exec(stripped); } return out; } interface WalkArgs { readonly repoRoot: string; readonly visitedFiles: Set; readonly visitedPackages: Set; readonly hits: BannedHit[]; } async function resolveRelative( fromFile: string, spec: string ): Promise { const base = resolve(dirname(fromFile), spec); const candidates = [ base, `${base}.ts`, `${base}.tsx`, `${base.replace(/\.js$/, "")}.ts`, `${base.replace(/\.js$/, "")}.tsx`, join(base, "index.ts"), join(base, "index.tsx"), ]; for (const c of candidates) { try { const s = await stat(c); if (s.isFile()) return c; } catch { // continue } } return null; } async function findPackageDir( wsRoot: string, pkgName: string ): Promise { let cats: Awaited>; try { cats = await readdir(wsRoot, { withFileTypes: true }); } catch { return null; } for (const cat of cats) { if (!cat.isDirectory()) continue; const catDir = join(wsRoot, cat.name); let plugins: typeof cats; try { plugins = await readdir(catDir, { withFileTypes: true }); } catch { continue; } for (const plugin of plugins) { if (!plugin.isDirectory()) continue; const pluginDir = join(catDir, plugin.name); let pkgs: typeof cats; try { pkgs = await readdir(pluginDir, { withFileTypes: true }); } catch { continue; } for (const pkg of pkgs) { if (!pkg.isDirectory()) continue; const pkgDir = join(pluginDir, pkg.name); const manifestPath = join(pkgDir, "package.json"); try { const raw = await readFile(manifestPath, "utf8"); const parsed = JSON.parse(raw) as { name?: string }; if (parsed.name === pkgName) return pkgDir; } catch { // continue } } } } return null; } async function resolvePackageJson( spec: string, repoRoot: string ): Promise<{ pkgName: string; pkgJson: Record; } | null> { let pkgName: string; if (spec.startsWith("@")) { const parts = spec.split("/"); if (parts.length < 2) return null; pkgName = `${parts[0]}/${parts[1]}`; } else { pkgName = spec.split("/")[0] ?? spec; } const dir = await findPackageDir(join(repoRoot, "packages"), pkgName); if (dir === null) return null; const raw = await readFile(join(dir, "package.json"), "utf8"); return { pkgName, pkgJson: JSON.parse(raw) as Record }; } async function walkFile(args: WalkArgs, file: string): Promise { if (args.visitedFiles.has(file)) return; args.visitedFiles.add(file); let source: string; try { source = await readFile(file, "utf8"); } catch { return; } const specs = extractSpecifiers(source); const relFile = relative(args.repoRoot, file); for (const spec of specs) { const banned = banReason(spec); if (banned !== null) { args.hits.push({ file: relFile, specifier: spec, reason: banned, }); continue; } if (spec.startsWith(".") || isAbsolute(spec)) { const resolved = await resolveRelative(file, spec); if (resolved !== null) { await walkFile(args, resolved); } continue; } await walkPackage(args, spec, relFile); } } async function walkPackage( args: WalkArgs, spec: string, importingFile: string ): Promise { const pkg = await resolvePackageJson(spec, args.repoRoot); if (pkg === null) return; if (args.visitedPackages.has(pkg.pkgName)) return; args.visitedPackages.add(pkg.pkgName); const deps = { ...(pkg.pkgJson.dependencies as Record | undefined), ...(pkg.pkgJson.peerDependencies as | Record | undefined), }; for (const depName of Object.keys(deps)) { const reason = banReason(depName); if (reason !== null) { args.hits.push({ file: `${importingFile} -> ${pkg.pkgName} (transitive)`, specifier: depName, reason: `${reason} (via ${pkg.pkgName})`, }); } } } export interface CheckOptions { readonly repoRoot: string; readonly entry: string; } export async function checkWasmSubpathDeps( options: CheckOptions ): Promise { const hits: BannedHit[] = []; const args: WalkArgs = { repoRoot: options.repoRoot, visitedFiles: new Set(), visitedPackages: new Set(), hits, }; await walkFile(args, options.entry); return hits; } export function formatHits(hits: readonly BannedHit[]): string { if (hits.length === 0) { return "wasm-subpath-deps: clean — no banned specifiers reachable from src/wasm/index.ts."; } const lines: string[] = [ "Banned dependency in wasm subpath import closure.", "", "Rule: openspec/changes/api-plugin-example-dual-shape/specs/api-plugin-example/spec.md", " 'The ./wasm subpath SHALL have a constrained dependency graph'", "", ]; for (const h of hits) { lines.push(` ${h.file}`); lines.push(` -> ${h.specifier} (${h.reason})`); } lines.push(""); lines.push( `Total: ${hits.length} violation${hits.length === 1 ? "" : "s"}.` ); return lines.join("\n"); } async function main(): Promise { const argv = process.argv.slice(2); let repoRoot = process.cwd(); let entry: string | null = null; for (let i = 0; i < argv.length; i += 1) { if (argv[i] === "--root" && i + 1 < argv.length) { repoRoot = resolve(argv[i + 1] ?? "."); i += 1; } else if (argv[i] === "--entry" && i + 1 < argv.length) { entry = resolve(argv[i + 1] ?? "."); i += 1; } } if (entry === null) { entry = resolve( repoRoot, "packages/api-stack/api/api-plugin-example/src/wasm/index.ts" ); } let hits: readonly BannedHit[]; try { hits = await checkWasmSubpathDeps({ repoRoot, entry }); } catch (cause) { process.stderr.write( `check-wasm-subpath-deps: internal error: ${(cause as Error).message}\n` ); process.exit(2); } const msg = formatHits(hits); if (hits.length === 0) { process.stdout.write(`${msg}\n`); process.exit(0); } process.stderr.write(`${msg}\n`); process.exit(1); } if (import.meta.main) { await main(); }