From decdb92f34412c129daf02960057d27fad4f98c9 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 20 Apr 2026 16:07:03 +0100 Subject: [PATCH] test: enforce extension dependency ownership --- ...sion-runtime-dependencies.contract.test.ts | 248 ++++++++++++++++++ 1 file changed, 248 insertions(+) create mode 100644 src/plugins/contracts/extension-runtime-dependencies.contract.test.ts diff --git a/src/plugins/contracts/extension-runtime-dependencies.contract.test.ts b/src/plugins/contracts/extension-runtime-dependencies.contract.test.ts new file mode 100644 index 00000000000..ae8854cc3d4 --- /dev/null +++ b/src/plugins/contracts/extension-runtime-dependencies.contract.test.ts @@ -0,0 +1,248 @@ +import fs from "node:fs"; +import { builtinModules } from "node:module"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; + +const EXTENSION_ROOT = "extensions"; +const EXTENSION_RUNTIME_FILE_EXTENSIONS = new Set([".cjs", ".js", ".jsx", ".mjs", ".ts", ".tsx"]); +const BUILTIN_MODULES = new Set(builtinModules.map((moduleId) => moduleId.replace(/^node:/, ""))); +const OPTIONAL_UNDECLARED_RUNTIME_IMPORTS = new Map>([ + [ + "extensions/discord", + // Prefer the pure-JS opusscript decoder, but keep the optional native decoder + // fallback for users who install it themselves. + new Set(["@discordjs/opus"]), + ], +]); +const INDIRECT_RUNTIME_DEPENDENCIES = new Map>([ + [ + "extensions/whatsapp", + // Baileys loads jimp as an optional peer when it needs media thumbnails. + new Set(["jimp"]), + ], +]); + +type PackageManifest = { + dependencies?: Record; + devDependencies?: Record; + optionalDependencies?: Record; + peerDependencies?: Record; +}; + +function toPosixPath(filePath: string): string { + return filePath.split(path.sep).join("/"); +} + +function readPackageManifest(filePath: string): PackageManifest { + return JSON.parse(fs.readFileSync(filePath, "utf8")) as PackageManifest; +} + +function listPackageManifests(root: string): string[] { + const entries = fs.readdirSync(root, { withFileTypes: true }); + const manifests: string[] = []; + for (const entry of entries) { + if (!entry.isDirectory()) { + continue; + } + const manifestPath = path.join(root, entry.name, "package.json"); + if (fs.existsSync(manifestPath)) { + manifests.push(manifestPath); + } + } + return manifests.toSorted(); +} + +function shouldSkipRuntimeFile(filePath: string): boolean { + const normalized = toPosixPath(filePath); + if ( + normalized.includes("/node_modules/") || + normalized.includes("/dist/") || + normalized.includes("/coverage/") || + normalized.includes("/assets/") || + normalized.endsWith("/web/vite.config.ts") + ) { + return true; + } + return /(\.(test|spec|d)\.(ts|tsx|js|jsx|mjs|cjs)$|\/(test|tests|__tests__|test-support)\/|test-(helpers|support|harness|mocks|fixtures|runtime|shared|utils)|\.test-(helpers|support|harness|mocks|fixtures|runtime|shared|utils)|fixture-test-support|mock-setup|test-fixtures|test-runtime-mocks|\.harness\.|e2e-harness|\.mock\.|-mock\.|-mocks\.|mocks-test-support|\.fixture|\.fixtures)/.test( + normalized, + ); +} + +function listRuntimeFiles(root: string): string[] { + const files: string[] = []; + const visit = (dir: string) => { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const filePath = path.join(dir, entry.name); + if (entry.isDirectory()) { + if (!shouldSkipRuntimeFile(filePath)) { + visit(filePath); + } + continue; + } + if ( + EXTENSION_RUNTIME_FILE_EXTENSIONS.has(path.extname(entry.name)) && + !shouldSkipRuntimeFile(filePath) + ) { + files.push(filePath); + } + } + }; + visit(root); + return files.toSorted(); +} + +function packageNameForSpecifier(specifier: string): string | null { + if ( + specifier.startsWith("$") || + specifier.includes("${") || + specifier.startsWith(".") || + specifier.startsWith("/") || + specifier.startsWith("node:") + ) { + return null; + } + if (specifier.startsWith("@")) { + const [scope, name] = specifier.split("/"); + return scope && name ? `${scope}/${name}` : specifier; + } + return specifier.split("/")[0] ?? null; +} + +function isTypeOnlyClause(clause: string | undefined): boolean { + const trimmed = clause?.trim() ?? ""; + if (trimmed.startsWith("type ")) { + return true; + } + if (!trimmed.startsWith("{") || !trimmed.endsWith("}")) { + return false; + } + return trimmed + .slice(1, -1) + .split(",") + .map((part) => part.trim()) + .filter(Boolean) + .every((part) => part.startsWith("type ")); +} + +function collectRuntimeImports(filePath: string): string[] { + const source = fs.readFileSync(filePath, "utf8"); + const imports = new Set(); + const importRegex = + /(import|export)\s+([^'";]*?\s+from\s+)?["']([^"']+)["']|import\s*\(\s*["']([^"']+)["']\s*\)|require\s*\(\s*["']([^"']+)["']\s*\)/g; + let match: RegExpExecArray | null; + while ((match = importRegex.exec(source))) { + const clause = match[2]; + const specifier = match[3] ?? match[4] ?? match[5]; + if (!specifier || (match[1] && isTypeOnlyClause(clause))) { + continue; + } + const packageName = packageNameForSpecifier(specifier); + if (packageName) { + imports.add(packageName); + } + } + return [...imports].toSorted(); +} + +function runtimeDependencyNames(manifest: PackageManifest): Set { + return new Set([ + ...Object.keys(manifest.dependencies ?? {}), + ...Object.keys(manifest.optionalDependencies ?? {}), + ...Object.keys(manifest.peerDependencies ?? {}), + ]); +} + +function allDependencyNames(manifest: PackageManifest): string[] { + return [ + ...Object.keys(manifest.dependencies ?? {}), + ...Object.keys(manifest.devDependencies ?? {}), + ...Object.keys(manifest.optionalDependencies ?? {}), + ...Object.keys(manifest.peerDependencies ?? {}), + ].toSorted(); +} + +function isDiscordPackageDependency(dependencyName: string): boolean { + return ( + dependencyName === "@buape/carbon" || + dependencyName === "discord-api-types" || + dependencyName === "opusscript" || + dependencyName.startsWith("@discordjs/") || + dependencyName.startsWith("@snazzah/") + ); +} + +describe("Discord dependency ownership", () => { + it("keeps Discord packages out of the root manifest", () => { + const manifest = readPackageManifest("package.json"); + const discordDependencies = allDependencyNames(manifest).filter(isDiscordPackageDependency); + + expect(discordDependencies).toEqual([]); + }); + + for (const manifestPath of listPackageManifests(EXTENSION_ROOT)) { + const extensionDir = toPosixPath(path.dirname(manifestPath)); + + if (extensionDir === "extensions/discord") { + continue; + } + + it(`${extensionDir} does not own Discord package dependencies`, () => { + const manifest = readPackageManifest(manifestPath); + const discordDependencies = allDependencyNames(manifest).filter(isDiscordPackageDependency); + + expect(discordDependencies).toEqual([]); + }); + } +}); + +describe("extension runtime dependency manifests", () => { + for (const manifestPath of listPackageManifests(EXTENSION_ROOT)) { + const extensionDir = toPosixPath(path.dirname(manifestPath)); + + it(`${extensionDir} declares every runtime package import`, () => { + const manifest = readPackageManifest(manifestPath); + const declared = runtimeDependencyNames(manifest); + const allowedOptional = + OPTIONAL_UNDECLARED_RUNTIME_IMPORTS.get(extensionDir) ?? new Set(); + const missing = new Map(); + + for (const filePath of listRuntimeFiles(extensionDir)) { + for (const packageName of collectRuntimeImports(filePath)) { + if ( + packageName === "openclaw" || + packageName.startsWith("@openclaw/") || + BUILTIN_MODULES.has(packageName) || + declared.has(packageName) || + allowedOptional.has(packageName) + ) { + continue; + } + const files = missing.get(packageName) ?? []; + files.push(toPosixPath(filePath)); + missing.set(packageName, files); + } + } + + expect(Object.fromEntries(missing)).toEqual({}); + }); + + it(`${extensionDir} does not keep unused direct runtime dependencies`, () => { + const manifest = readPackageManifest(manifestPath); + const declared = [ + ...Object.keys(manifest.dependencies ?? {}), + ...Object.keys(manifest.optionalDependencies ?? {}), + ].toSorted(); + const allowedIndirect = INDIRECT_RUNTIME_DEPENDENCIES.get(extensionDir) ?? new Set(); + const runtimeText = listRuntimeFiles(extensionDir) + .map((filePath) => fs.readFileSync(filePath, "utf8")) + .join("\n"); + + const unused = declared.filter( + (dependencyName) => + !allowedIndirect.has(dependencyName) && !runtimeText.includes(dependencyName), + ); + + expect(unused).toEqual([]); + }); + } +});