Files
suaveplan/scripts/lint/check-wasm-subpath-deps.ts
2026-05-27 23:47:53 +01:00

314 lines
9.2 KiB
TypeScript

#!/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<string> = 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<string>;
readonly visitedPackages: Set<string>;
readonly hits: BannedHit[];
}
async function resolveRelative(
fromFile: string,
spec: string
): Promise<string | null> {
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<string | null> {
let cats: Awaited<ReturnType<typeof readdir>>;
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<string, unknown>;
} | 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<string, unknown> };
}
async function walkFile(args: WalkArgs, file: string): Promise<void> {
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<void> {
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<string, unknown> | undefined),
...(pkg.pkgJson.peerDependencies as
| Record<string, unknown>
| 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<BannedHit[]> {
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<void> {
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();
}