Initial Commit
This commit is contained in:
313
scripts/lint/check-wasm-subpath-deps.ts
Normal file
313
scripts/lint/check-wasm-subpath-deps.ts
Normal file
@@ -0,0 +1,313 @@
|
||||
#!/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();
|
||||
}
|
||||
Reference in New Issue
Block a user