314 lines
9.2 KiB
TypeScript
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();
|
|
}
|