From edf6b490a66ce4d663c38d02e096241ac05a049a Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 8 Apr 2026 15:14:11 +0100 Subject: [PATCH] fix: harden bundled plugin dependency release checks --- CHANGELOG.md | 1 + extensions/amazon-bedrock/package.json | 7 +- extensions/discord/package.json | 8 - extensions/feishu/package.json | 5 - extensions/matrix/package.json | 7 - extensions/slack/package.json | 7 - extensions/telegram/package.json | 7 - package.json | 12 +- pnpm-lock.yaml | 28 ++- .../bundled-plugin-root-runtime-mirrors.mjs | 177 ++++++++++++++ scripts/openclaw-npm-postpublish-verify.ts | 91 +++----- scripts/release-check.ts | 145 ++++-------- .../package-manifest.contract.test.ts | 11 +- ...in-sdk-package-contract-guardrails.test.ts | 40 +--- .../plugins/package-manifest-contract.ts | 6 - test/openclaw-npm-postpublish-verify.test.ts | 59 +++-- test/release-check.test.ts | 221 +++++++++--------- 17 files changed, 474 insertions(+), 358 deletions(-) create mode 100644 scripts/lib/bundled-plugin-root-runtime-mirrors.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 07a3ce401ae..91f1401e5f7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ Docs: https://docs.openclaw.ai - Agents/failover: classify Z.ai vendor code `1311` as billing and `1113` as auth, including long wrapped `1311` payloads, so these errors stop falling through to generic failover handling. (#49552) Thanks @1bcMax. - npm packaging: mirror bundled Slack, Telegram, Discord, and Feishu channel runtime deps at the root and harden published-install verification so fresh installs fail fast on manifest drift instead of missing-module crashes. (#63065) Thanks @scoootscooob. - Agents/timeouts: make the LLM idle timeout inherit `agents.defaults.timeoutSeconds` when configured, disable the unconfigured idle watchdog for cron runs, and point idle-timeout errors at `agents.defaults.llm.idleTimeoutSeconds`. Thanks @drvoss. +- npm packaging: derive required root runtime mirrors from bundled plugin manifests and built root chunks, then install packed release tarballs without the repo `node_modules` so release checks catch missing plugin deps before publish. ## 2026.4.8 diff --git a/extensions/amazon-bedrock/package.json b/extensions/amazon-bedrock/package.json index 72893333190..89c26104bc5 100644 --- a/extensions/amazon-bedrock/package.json +++ b/extensions/amazon-bedrock/package.json @@ -16,11 +16,6 @@ }, "extensions": [ "./index.ts" - ], - "releaseChecks": { - "rootDependencyMirrorAllowlist": [ - "@aws-sdk/client-bedrock" - ] - } + ] } } diff --git a/extensions/discord/package.json b/extensions/discord/package.json index 4eecc8b4539..17988e78403 100644 --- a/extensions/discord/package.json +++ b/extensions/discord/package.json @@ -60,14 +60,6 @@ "bundle": { "stageRuntimeDependencies": true }, - "releaseChecks": { - "rootDependencyMirrorAllowlist": [ - "@buape/carbon", - "@discordjs/opus", - "https-proxy-agent", - "opusscript" - ] - }, "release": { "publishToClawHub": true, "publishToNpm": true diff --git a/extensions/feishu/package.json b/extensions/feishu/package.json index eaf61700dda..979cbf7e219 100644 --- a/extensions/feishu/package.json +++ b/extensions/feishu/package.json @@ -51,11 +51,6 @@ "bundle": { "stageRuntimeDependencies": true }, - "releaseChecks": { - "rootDependencyMirrorAllowlist": [ - "@larksuiteoapi/node-sdk" - ] - }, "release": { "publishToClawHub": true, "publishToNpm": true diff --git a/extensions/matrix/package.json b/extensions/matrix/package.json index d71827b2e08..51d93c964af 100644 --- a/extensions/matrix/package.json +++ b/extensions/matrix/package.json @@ -47,13 +47,6 @@ "defaultChoice": "npm", "minHostVersion": ">=2026.4.9", "allowInvalidConfigRecovery": true - }, - "releaseChecks": { - "rootDependencyMirrorAllowlist": [ - "@matrix-org/matrix-sdk-crypto-wasm", - "@matrix-org/matrix-sdk-crypto-nodejs", - "matrix-js-sdk" - ] } } } diff --git a/extensions/slack/package.json b/extensions/slack/package.json index 76813f08d6b..c6e1e8faf01 100644 --- a/extensions/slack/package.json +++ b/extensions/slack/package.json @@ -34,13 +34,6 @@ }, "bundle": { "stageRuntimeDependencies": true - }, - "releaseChecks": { - "rootDependencyMirrorAllowlist": [ - "@slack/bolt", - "@slack/web-api", - "https-proxy-agent" - ] } } } diff --git a/extensions/telegram/package.json b/extensions/telegram/package.json index 1af28ab7512..5213f16b3c3 100644 --- a/extensions/telegram/package.json +++ b/extensions/telegram/package.json @@ -39,13 +39,6 @@ }, "bundle": { "stageRuntimeDependencies": true - }, - "releaseChecks": { - "rootDependencyMirrorAllowlist": [ - "@grammyjs/runner", - "@grammyjs/transformer-throttler", - "grammy" - ] } } } diff --git a/package.json b/package.json index 3d859f3619a..164b1922379 100644 --- a/package.json +++ b/package.json @@ -1311,11 +1311,14 @@ "@aws-sdk/client-bedrock": "3.1024.0", "@aws-sdk/client-bedrock-runtime": "3.1024.0", "@aws-sdk/credential-provider-node": "3.972.29", + "@aws/bedrock-token-generator": "^1.1.0", "@buape/carbon": "0.14.0", "@clack/prompts": "^1.2.0", + "@google/genai": "^1.48.0", "@grammyjs/runner": "^2.0.3", "@grammyjs/transformer-throttler": "^1.2.1", "@homebridge/ciao": "^1.3.6", + "@lancedb/lancedb": "^0.27.2", "@larksuiteoapi/node-sdk": "^1.60.0", "@line/bot-sdk": "^11.0.0", "@lydell/node-pty": "1.2.0-beta.10", @@ -1339,18 +1342,22 @@ "express": "^5.2.1", "file-type": "22.0.0", "gaxios": "7.1.4", + "google-auth-library": "^10.6.2", "grammy": "^1.42.0", "hono": "4.12.10", "https-proxy-agent": "^9.0.0", "ipaddr.js": "^2.3.0", + "jimp": "^1.6.0", "jiti": "^2.6.1", "json5": "^2.2.3", "jszip": "^3.10.1", "linkedom": "^0.18.12", "long": "^5.3.2", - "markdown-it": "^14.1.1", + "markdown-it": "14.1.1", "matrix-js-sdk": "41.3.0-rc.0", + "mpg123-decoder": "^1.0.3", "node-edge-tts": "^1.2.10", + "openai": "^6.33.0", "opusscript": "^0.1.1", "osc-progress": "^0.3.0", "pdfjs-dist": "^5.6.205", @@ -1358,10 +1365,11 @@ "proxy-agent": "^8.0.0", "qrcode-terminal": "^0.12.0", "sharp": "^0.34.5", + "silk-wasm": "^3.7.1", "sqlite-vec": "0.1.9", "tar": "7.5.13", "tslog": "^4.10.2", - "undici": "^8.0.2", + "undici": "8.0.2", "uuid": "^13.0.0", "ws": "^8.20.0", "yaml": "^2.8.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 61619019905..1b6ad0c13a0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -45,12 +45,18 @@ importers: '@aws-sdk/credential-provider-node': specifier: 3.972.29 version: 3.972.29 + '@aws/bedrock-token-generator': + specifier: ^1.1.0 + version: 1.1.0 '@buape/carbon': specifier: 0.14.0 version: 0.14.0(@discordjs/opus@0.10.0)(hono@4.12.10)(opusscript@0.1.1) '@clack/prompts': specifier: ^1.2.0 version: 1.2.0 + '@google/genai': + specifier: ^1.48.0 + version: 1.48.0(@modelcontextprotocol/sdk@1.29.0(zod@4.3.6)) '@grammyjs/runner': specifier: ^2.0.3 version: 2.0.3(grammy@1.42.0) @@ -60,6 +66,9 @@ importers: '@homebridge/ciao': specifier: ^1.3.6 version: 1.3.6 + '@lancedb/lancedb': + specifier: ^0.27.2 + version: 0.27.2(apache-arrow@18.1.0) '@larksuiteoapi/node-sdk': specifier: ^1.60.0 version: 1.60.0 @@ -132,6 +141,9 @@ importers: gaxios: specifier: 7.1.4 version: 7.1.4 + google-auth-library: + specifier: ^10.6.2 + version: 10.6.2 grammy: specifier: ^1.42.0 version: 1.42.0 @@ -144,6 +156,9 @@ importers: ipaddr.js: specifier: ^2.3.0 version: 2.3.0 + jimp: + specifier: ^1.6.0 + version: 1.6.0 jiti: specifier: ^2.6.1 version: 2.6.1 @@ -160,17 +175,23 @@ importers: specifier: ^5.3.2 version: 5.3.2 markdown-it: - specifier: ^14.1.1 + specifier: 14.1.1 version: 14.1.1 matrix-js-sdk: specifier: 41.3.0-rc.0 version: 41.3.0-rc.0 + mpg123-decoder: + specifier: ^1.0.3 + version: 1.0.3 node-edge-tts: specifier: ^1.2.10 version: 1.2.10 node-llama-cpp: specifier: 3.18.1 version: 3.18.1(typescript@6.0.2) + openai: + specifier: ^6.33.0 + version: 6.33.0(ws@8.20.0)(zod@4.3.6) opusscript: specifier: ^0.1.1 version: 0.1.1 @@ -192,6 +213,9 @@ importers: sharp: specifier: ^0.34.5 version: 0.34.5 + silk-wasm: + specifier: ^3.7.1 + version: 3.7.1 sqlite-vec: specifier: 0.1.9 version: 0.1.9 @@ -202,7 +226,7 @@ importers: specifier: ^4.10.2 version: 4.10.2 undici: - specifier: ^8.0.2 + specifier: 8.0.2 version: 8.0.2 uuid: specifier: ^13.0.0 diff --git a/scripts/lib/bundled-plugin-root-runtime-mirrors.mjs b/scripts/lib/bundled-plugin-root-runtime-mirrors.mjs new file mode 100644 index 00000000000..ac7b307f90b --- /dev/null +++ b/scripts/lib/bundled-plugin-root-runtime-mirrors.mjs @@ -0,0 +1,177 @@ +import fs from "node:fs"; +import path from "node:path"; + +const JS_EXTENSIONS = new Set([".cjs", ".js", ".mjs"]); + +export function collectRuntimeDependencySpecs(packageJson = {}) { + return new Map( + [ + ...Object.entries(packageJson.dependencies ?? {}), + ...Object.entries(packageJson.optionalDependencies ?? {}), + ].filter((entry) => typeof entry[1] === "string" && entry[1].length > 0), + ); +} + +export function packageNameFromSpecifier(specifier) { + if ( + typeof specifier !== "string" || + specifier.startsWith(".") || + specifier.startsWith("/") || + specifier.startsWith("node:") || + specifier.startsWith("#") + ) { + return null; + } + const [first, second] = specifier.split("/"); + if (!first) { + return null; + } + return first.startsWith("@") && second ? `${first}/${second}` : first; +} + +function readJson(filePath) { + return JSON.parse(fs.readFileSync(filePath, "utf8")); +} + +function collectPackageJsonPaths(rootDir) { + if (!fs.existsSync(rootDir)) { + return []; + } + return fs + .readdirSync(rootDir, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => path.join(rootDir, entry.name, "package.json")) + .filter((packageJsonPath) => fs.existsSync(packageJsonPath)) + .toSorted((left, right) => left.localeCompare(right)); +} + +export function collectBundledPluginRuntimeDependencySpecs(bundledPluginsDir) { + const specs = new Map(); + + for (const packageJsonPath of collectPackageJsonPaths(bundledPluginsDir)) { + const packageJson = readJson(packageJsonPath); + const pluginId = path.basename(path.dirname(packageJsonPath)); + for (const [name, spec] of collectRuntimeDependencySpecs(packageJson)) { + const existing = specs.get(name); + if (existing) { + if (existing.spec !== spec) { + existing.conflicts.push({ pluginId, spec }); + } else if (!existing.pluginIds.includes(pluginId)) { + existing.pluginIds.push(pluginId); + } + continue; + } + specs.set(name, { conflicts: [], pluginIds: [pluginId], spec }); + } + } + + return specs; +} + +function walkJavaScriptFiles(rootDir) { + const files = []; + if (!fs.existsSync(rootDir)) { + return files; + } + const queue = [rootDir]; + while (queue.length > 0) { + const current = queue.shift(); + for (const entry of fs.readdirSync(current, { withFileTypes: true })) { + const fullPath = path.join(current, entry.name); + if (entry.isDirectory()) { + if (fullPath.split(path.sep).includes("extensions")) { + continue; + } + queue.push(fullPath); + continue; + } + if (entry.isFile() && JS_EXTENSIONS.has(path.extname(entry.name))) { + files.push(fullPath); + } + } + } + return files.toSorted((left, right) => left.localeCompare(right)); +} + +function extractModuleSpecifiers(source) { + const specifiers = new Set(); + const patterns = [ + /\bfrom\s*["']([^"']+)["']/g, + /\bimport\s*["']([^"']+)["']/g, + /\bimport\s*\(\s*["']([^"']+)["']\s*\)/g, + /\brequire\s*\(\s*["']([^"']+)["']\s*\)/g, + ]; + for (const pattern of patterns) { + for (const match of source.matchAll(pattern)) { + if (match[1]) { + specifiers.add(match[1]); + } + } + } + return specifiers; +} + +export function collectRootDistBundledRuntimeMirrors(params) { + const distDir = params.distDir; + const bundledSpecs = params.bundledRuntimeDependencySpecs; + const mirrors = new Map(); + + for (const filePath of walkJavaScriptFiles(distDir)) { + const source = fs.readFileSync(filePath, "utf8"); + const relativePath = path.relative(distDir, filePath).replaceAll(path.sep, "/"); + for (const specifier of extractModuleSpecifiers(source)) { + const dependencyName = packageNameFromSpecifier(specifier); + if (!dependencyName || !bundledSpecs.has(dependencyName)) { + continue; + } + const bundledSpec = bundledSpecs.get(dependencyName); + const existing = mirrors.get(dependencyName); + if (existing) { + existing.importers.add(relativePath); + continue; + } + mirrors.set(dependencyName, { + importers: new Set([relativePath]), + pluginIds: bundledSpec.pluginIds, + spec: bundledSpec.spec, + }); + } + } + + return mirrors; +} + +export function collectBundledPluginRootRuntimeMirrorErrors(params) { + const rootRuntimeDeps = collectRuntimeDependencySpecs(params.rootPackageJson); + const errors = []; + + for (const [dependencyName, record] of params.bundledRuntimeDependencySpecs) { + for (const conflict of record.conflicts) { + errors.push( + `bundled runtime dependency '${dependencyName}' has conflicting plugin specs: ${record.pluginIds.join(", ")} use '${record.spec}', ${conflict.pluginId} uses '${conflict.spec}'.`, + ); + } + } + + for (const [dependencyName, mirror] of params.requiredRootMirrors) { + const rootSpec = rootRuntimeDeps.get(dependencyName); + const importers = [...mirror.importers].toSorted((left, right) => left.localeCompare(right)); + const importerLabel = importers.join(", "); + const pluginLabel = mirror.pluginIds + .toSorted((left, right) => left.localeCompare(right)) + .join(", "); + if (typeof rootSpec !== "string" || rootSpec.length === 0) { + errors.push( + `root dist imports bundled plugin runtime dependency '${dependencyName}' from ${importerLabel}; mirror '${dependencyName}: ${mirror.spec}' in root package.json (declared by ${pluginLabel}).`, + ); + continue; + } + if (rootSpec !== mirror.spec) { + errors.push( + `root dist imports bundled plugin runtime dependency '${dependencyName}' from ${importerLabel}; root package.json has '${rootSpec}' but plugin manifest declares '${mirror.spec}' (${pluginLabel}).`, + ); + } + } + + return errors; +} diff --git a/scripts/openclaw-npm-postpublish-verify.ts b/scripts/openclaw-npm-postpublish-verify.ts index ce245388c0a..b5b3b48656c 100644 --- a/scripts/openclaw-npm-postpublish-verify.ts +++ b/scripts/openclaw-npm-postpublish-verify.ts @@ -15,6 +15,11 @@ import { isAbsolute, join, relative } from "node:path"; import { pathToFileURL } from "node:url"; import { formatErrorMessage } from "../src/infra/errors.ts"; import { BUNDLED_RUNTIME_SIDECAR_PATHS } from "../src/plugins/runtime-sidecar-paths.ts"; +import { + collectBundledPluginRootRuntimeMirrorErrors, + collectRootDistBundledRuntimeMirrors, + collectRuntimeDependencySpecs, +} from "./lib/bundled-plugin-root-runtime-mirrors.mjs"; import { parseReleaseVersion, resolveNpmCommandInvocation } from "./openclaw-npm-release-check.ts"; type InstalledPackageJson = { @@ -26,14 +31,10 @@ type InstalledPackageJson = { type InstalledBundledExtensionPackageJson = { dependencies?: Record; optionalDependencies?: Record; - openclaw?: { - releaseChecks?: { - rootDependencyMirrorAllowlist?: unknown; - }; - }; }; type InstalledBundledExtensionManifestRecord = { + id: string; manifest: InstalledBundledExtensionPackageJson; path: string; }; @@ -109,13 +110,6 @@ export function resolveInstalledBinaryPath(prefixDir: string, platform = process : join(prefixDir, "bin", "openclaw"); } -function collectRuntimeDependencySpecs(packageJson: InstalledPackageJson): Map { - return new Map([ - ...Object.entries(packageJson.dependencies ?? {}), - ...Object.entries(packageJson.optionalDependencies ?? {}), - ]); -} - function collectExpectedBundledExtensionPackageIds( sourceExtensionsDir = join(process.cwd(), "extensions"), ): ReadonlySet | null { @@ -183,6 +177,7 @@ function readBundledExtensionPackageJsons(packageRoot: string): { } manifests.push({ + id: entry.name, manifest: JSON.parse( readFileSync(realPackageJsonPath, "utf8"), ) as InstalledBundledExtensionPackageJson, @@ -207,54 +202,40 @@ export function collectInstalledMirroredRootDependencyManifestErrors( } const rootPackageJson = JSON.parse(readFileSync(packageJsonPath, "utf8")) as InstalledPackageJson; - const rootRuntimeDeps = collectRuntimeDependencySpecs(rootPackageJson); const { manifests, errors } = readBundledExtensionPackageJsons(packageRoot); + const bundledRuntimeDependencySpecs = new Map< + string, + { conflicts: Array<{ pluginId: string; spec: string }>; pluginIds: string[]; spec: string } + >(); - for (const { manifest: extensionPackageJson } of manifests) { - const allowlist = extensionPackageJson.openclaw?.releaseChecks?.rootDependencyMirrorAllowlist; - if (allowlist === undefined) { - continue; - } - if (!Array.isArray(allowlist)) { - errors.push( - "installed bundled extension manifest invalid: openclaw.releaseChecks.rootDependencyMirrorAllowlist must be an array.", - ); - continue; - } - + for (const { id, manifest: extensionPackageJson } of manifests) { const extensionRuntimeDeps = collectRuntimeDependencySpecs(extensionPackageJson); - for (const entry of allowlist) { - if (typeof entry !== "string" || entry.trim().length === 0) { - errors.push( - "installed bundled extension manifest invalid: openclaw.releaseChecks.rootDependencyMirrorAllowlist entries must be non-empty strings.", - ); + for (const [dependencyName, spec] of extensionRuntimeDeps) { + const existing = bundledRuntimeDependencySpecs.get(dependencyName); + if (existing) { + if (existing.spec !== spec) { + existing.conflicts.push({ pluginId: id, spec }); + } else if (!existing.pluginIds.includes(id)) { + existing.pluginIds.push(id); + } continue; } - - const extensionSpec = extensionRuntimeDeps.get(entry); - if (!extensionSpec) { - errors.push( - `installed bundled extension manifest invalid: mirrored dependency '${entry}' must be declared in the extension runtime dependencies.`, - ); - continue; - } - - const rootSpec = rootRuntimeDeps.get(entry); - if (!rootSpec) { - errors.push( - `installed package is missing mirrored root runtime dependency '${entry}' required by a bundled extension.`, - ); - continue; - } - - if (rootSpec !== extensionSpec) { - errors.push( - `installed package mirrored dependency '${entry}' version mismatch: root '${rootSpec}', extension '${extensionSpec}'.`, - ); - } + bundledRuntimeDependencySpecs.set(dependencyName, { conflicts: [], pluginIds: [id], spec }); } } + const requiredRootMirrors = collectRootDistBundledRuntimeMirrors({ + bundledRuntimeDependencySpecs, + distDir: join(packageRoot, "dist"), + }); + errors.push( + ...collectBundledPluginRootRuntimeMirrorErrors({ + bundledRuntimeDependencySpecs, + requiredRootMirrors, + rootPackageJson, + }), + ); + return errors; } @@ -276,8 +257,12 @@ function resolveGlobalRoot(prefixDir: string, cwd: string): string { return npmExec(["root", "-g", "--prefix", prefixDir], cwd); } +export function buildPublishedInstallCommandArgs(prefixDir: string, spec: string): string[] { + return ["install", "-g", "--prefix", prefixDir, spec, "--no-fund", "--no-audit"]; +} + function installSpec(prefixDir: string, spec: string, cwd: string): void { - npmExec(["install", "-g", "--prefix", prefixDir, spec, "--no-fund", "--no-audit"], cwd); + npmExec(buildPublishedInstallCommandArgs(prefixDir, spec), cwd); } function readInstalledBinaryVersion(prefixDir: string, cwd: string): string { diff --git a/scripts/release-check.ts b/scripts/release-check.ts index adebfcacaed..68e476dd0f4 100755 --- a/scripts/release-check.ts +++ b/scripts/release-check.ts @@ -1,15 +1,7 @@ #!/usr/bin/env -S node --import tsx import { execFileSync, execSync } from "node:child_process"; -import { - existsSync, - mkdtempSync, - mkdirSync, - readdirSync, - readFileSync, - rmSync, - symlinkSync, -} from "node:fs"; +import { mkdtempSync, mkdirSync, readdirSync, readFileSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { pathToFileURL } from "node:url"; @@ -19,11 +11,21 @@ import { type ExtensionPackageJson as PackageJson, } from "./lib/bundled-extension-manifest.ts"; import { listBundledPluginPackArtifacts } from "./lib/bundled-plugin-build-entries.mjs"; +import { + collectBundledPluginRootRuntimeMirrorErrors, + collectBundledPluginRuntimeDependencySpecs, + collectRootDistBundledRuntimeMirrors, +} from "./lib/bundled-plugin-root-runtime-mirrors.mjs"; import { listPluginSdkDistArtifacts } from "./lib/plugin-sdk-entries.mjs"; import { listStaticExtensionAssetOutputs } from "./runtime-postbuild.mjs"; import { sparkleBuildFloorsFromShortVersion, type SparkleBuildFloors } from "./sparkle-build.ts"; export { collectBundledExtensionManifestErrors } from "./lib/bundled-extension-manifest.ts"; +export { + collectBundledPluginRootRuntimeMirrorErrors, + collectRootDistBundledRuntimeMirrors, + packageNameFromSpecifier, +} from "./lib/bundled-plugin-root-runtime-mirrors.mjs"; type PackFile = { path: string }; type PackResult = { files?: PackFile[]; filename?: string; unpackedSize?: number }; @@ -78,16 +80,6 @@ function collectBundledExtensions(): BundledExtension[] { }); } -function collectRuntimeDependencySpecs(packageJson: { - dependencies?: Record; - optionalDependencies?: Record; -}): Map { - return new Map([ - ...Object.entries(packageJson.dependencies ?? {}), - ...Object.entries(packageJson.optionalDependencies ?? {}), - ]); -} - function checkBundledExtensionMetadata() { const extensions = collectBundledExtensions(); const manifestErrors = collectBundledExtensionManifestErrors(extensions); @@ -95,11 +87,18 @@ function checkBundledExtensionMetadata() { dependencies?: Record; optionalDependencies?: Record; }; - const rootRuntimeDeps = collectRuntimeDependencySpecs(rootPackage); - const rootMirrorErrors = collectBundledExtensionRootDependencyMirrorErrors( - extensions, - rootRuntimeDeps, + const bundledRuntimeDependencySpecs = collectBundledPluginRuntimeDependencySpecs( + resolve("extensions"), ); + const requiredRootMirrors = collectRootDistBundledRuntimeMirrors({ + bundledRuntimeDependencySpecs, + distDir: resolve("dist"), + }); + const rootMirrorErrors = collectBundledPluginRootRuntimeMirrorErrors({ + bundledRuntimeDependencySpecs, + requiredRootMirrors, + rootPackageJson: rootPackage, + }); const errors = [...manifestErrors, ...rootMirrorErrors]; if (errors.length > 0) { console.error("release-check: bundled extension manifest validation failed:"); @@ -110,63 +109,6 @@ function checkBundledExtensionMetadata() { } } -export function collectBundledExtensionRootDependencyMirrorErrors( - extensions: BundledExtension[], - rootRuntimeDeps: ReadonlyMap, -): string[] { - const errors: string[] = []; - - for (const extension of extensions) { - const rawReleaseChecks = extension.packageJson.openclaw?.releaseChecks; - const allowlist = (rawReleaseChecks as { rootDependencyMirrorAllowlist?: unknown } | undefined) - ?.rootDependencyMirrorAllowlist; - - if (allowlist === undefined) { - continue; - } - if (!Array.isArray(allowlist)) { - errors.push( - `bundled extension '${extension.id}' manifest invalid | openclaw.releaseChecks.rootDependencyMirrorAllowlist must be an array`, - ); - continue; - } - - const extensionRuntimeDeps = collectRuntimeDependencySpecs(extension.packageJson); - - for (const entry of allowlist) { - if (typeof entry !== "string" || entry.trim().length === 0) { - errors.push( - `bundled extension '${extension.id}' manifest invalid | openclaw.releaseChecks.rootDependencyMirrorAllowlist entries must be non-empty strings`, - ); - continue; - } - - const extensionSpec = extensionRuntimeDeps.get(entry); - if (!extensionSpec) { - errors.push( - `bundled extension '${extension.id}' manifest invalid | openclaw.releaseChecks.rootDependencyMirrorAllowlist entry '${entry}' must be declared in extension runtime dependencies`, - ); - } - const rootSpec = rootRuntimeDeps.get(entry); - if (!rootSpec) { - errors.push( - `bundled extension '${extension.id}' manifest invalid | openclaw.releaseChecks.rootDependencyMirrorAllowlist entry '${entry}' must be mirrored in root runtime dependencies`, - ); - } - if (!extensionSpec || !rootSpec) { - continue; - } - if (extensionSpec !== rootSpec) { - errors.push( - `bundled extension '${extension.id}' manifest invalid | openclaw.releaseChecks.rootDependencyMirrorAllowlist entry '${entry}' must match root runtime dependency version (extension '${extensionSpec}', root '${rootSpec}')`, - ); - } - } - } - - return errors; -} - function runPackDry(): PackResult[] { const raw = execSync("npm pack --dry-run --json --ignore-scripts", { encoding: "utf8", @@ -201,32 +143,47 @@ function resolvePackedTarballPath(packDestination: string, results: PackResult[] return resolve(packDestination, filenames[0]); } -function linkRootNodeModules(packageRoot: string): void { - const rootNodeModules = resolve("node_modules"); - if (!existsSync(rootNodeModules)) { - return; - } - symlinkSync( - rootNodeModules, - join(packageRoot, "node_modules"), - process.platform === "win32" ? "junction" : "dir", +function installPackedTarball(prefixDir: string, tarballPath: string, cwd: string): void { + execFileSync( + "npm", + [ + "install", + "-g", + "--prefix", + prefixDir, + "--ignore-scripts", + "--no-audit", + "--no-fund", + tarballPath, + ], + { + cwd, + encoding: "utf8", + stdio: "inherit", + }, ); } +function resolveGlobalRoot(prefixDir: string, cwd: string): string { + return execFileSync("npm", ["root", "-g", "--prefix", prefixDir], { + cwd, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }).trim(); +} + function runPackedBundledChannelEntrySmoke(): void { const tmpRoot = mkdtempSync(join(tmpdir(), "openclaw-release-pack-smoke-")); try { const packDir = join(tmpRoot, "pack"); - const extractDir = join(tmpRoot, "extract"); mkdirSync(packDir); - mkdirSync(extractDir); const packResults = runPack(packDir); const tarballPath = resolvePackedTarballPath(packDir, packResults); - execFileSync("tar", ["-xzf", tarballPath, "-C", extractDir], { stdio: "inherit" }); + const prefixDir = join(tmpRoot, "prefix"); + installPackedTarball(prefixDir, tarballPath, tmpRoot); - const packageRoot = join(extractDir, "package"); - linkRootNodeModules(packageRoot); + const packageRoot = join(resolveGlobalRoot(prefixDir, tmpRoot), "openclaw"); execFileSync( process.execPath, [ diff --git a/src/plugins/contracts/package-manifest.contract.test.ts b/src/plugins/contracts/package-manifest.contract.test.ts index 5b20c3ec4c3..8d3b1d504f7 100644 --- a/src/plugins/contracts/package-manifest.contract.test.ts +++ b/src/plugins/contracts/package-manifest.contract.test.ts @@ -19,14 +19,18 @@ const packageManifestContractTests: PackageManifestContractParams[] = [ mirroredRootRuntimeDeps: ["@larksuiteoapi/node-sdk"], minHostVersionBaseline: "2026.3.22", }, - { pluginId: "googlechat", minHostVersionBaseline: "2026.3.22" }, + { + pluginId: "googlechat", + mirroredRootRuntimeDeps: ["google-auth-library"], + minHostVersionBaseline: "2026.3.22", + }, { pluginId: "irc", minHostVersionBaseline: "2026.3.22" }, { pluginId: "line", minHostVersionBaseline: "2026.3.22" }, { pluginId: "matrix", minHostVersionBaseline: "2026.3.22" }, { pluginId: "mattermost", minHostVersionBaseline: "2026.3.22" }, { pluginId: "memory-lancedb", - pluginLocalRuntimeDeps: ["@lancedb/lancedb"], + mirroredRootRuntimeDeps: ["@lancedb/lancedb", "openai"], minHostVersionBaseline: "2026.3.22", }, { pluginId: "msteams", minHostVersionBaseline: "2026.3.22" }, @@ -46,7 +50,8 @@ const packageManifestContractTests: PackageManifestContractParams[] = [ { pluginId: "voice-call", minHostVersionBaseline: "2026.3.22" }, { pluginId: "whatsapp", - pluginLocalRuntimeDeps: ["@whiskeysockets/baileys", "jimp"], + pluginLocalRuntimeDeps: ["@whiskeysockets/baileys"], + mirroredRootRuntimeDeps: ["jimp"], minHostVersionBaseline: "2026.3.22", }, { pluginId: "zalo", minHostVersionBaseline: "2026.3.22" }, diff --git a/src/plugins/contracts/plugin-sdk-package-contract-guardrails.test.ts b/src/plugins/contracts/plugin-sdk-package-contract-guardrails.test.ts index 59f689129a9..98a048bdebf 100644 --- a/src/plugins/contracts/plugin-sdk-package-contract-guardrails.test.ts +++ b/src/plugins/contracts/plugin-sdk-package-contract-guardrails.test.ts @@ -66,42 +66,22 @@ function readRootPackageJson(): { function readMatrixPackageJson(): { dependencies?: Record; optionalDependencies?: Record; - openclaw?: { - releaseChecks?: { - rootDependencyMirrorAllowlist?: unknown; - }; - }; } { return JSON.parse(readFileSync(resolve(REPO_ROOT, "extensions/matrix/package.json"), "utf8")) as { dependencies?: Record; optionalDependencies?: Record; - openclaw?: { - releaseChecks?: { - rootDependencyMirrorAllowlist?: unknown; - }; - }; }; } function readAmazonBedrockPackageJson(): { dependencies?: Record; optionalDependencies?: Record; - openclaw?: { - releaseChecks?: { - rootDependencyMirrorAllowlist?: unknown; - }; - }; } { return JSON.parse( readFileSync(resolve(REPO_ROOT, "extensions/amazon-bedrock/package.json"), "utf8"), ) as { dependencies?: Record; optionalDependencies?: Record; - openclaw?: { - releaseChecks?: { - rootDependencyMirrorAllowlist?: unknown; - }; - }; }; } @@ -327,15 +307,12 @@ describe("plugin-sdk package contract guardrails", () => { const rootRuntimeDeps = collectRuntimeDependencySpecs(readRootPackageJson()); const matrixPackageJson = readMatrixPackageJson(); const matrixRuntimeDeps = collectRuntimeDependencySpecs(matrixPackageJson); - const allowlist = matrixPackageJson.openclaw?.releaseChecks?.rootDependencyMirrorAllowlist; - expect(Array.isArray(allowlist)).toBe(true); - const matrixRootMirrorAllowlist = allowlist as string[]; - expect(matrixRootMirrorAllowlist).toEqual( - expect.arrayContaining(["@matrix-org/matrix-sdk-crypto-wasm"]), - ); - - for (const dep of matrixRootMirrorAllowlist) { + for (const dep of [ + "@matrix-org/matrix-sdk-crypto-wasm", + "@matrix-org/matrix-sdk-crypto-nodejs", + "matrix-js-sdk", + ]) { expect(rootRuntimeDeps.get(dep)).toBe(matrixRuntimeDeps.get(dep)); } }); @@ -344,13 +321,8 @@ describe("plugin-sdk package contract guardrails", () => { const rootRuntimeDeps = collectRuntimeDependencySpecs(readRootPackageJson()); const bedrockPackageJson = readAmazonBedrockPackageJson(); const bedrockRuntimeDeps = collectRuntimeDependencySpecs(bedrockPackageJson); - const allowlist = bedrockPackageJson.openclaw?.releaseChecks?.rootDependencyMirrorAllowlist; - expect(Array.isArray(allowlist)).toBe(true); - const bedrockRootMirrorAllowlist = allowlist as string[]; - expect(bedrockRootMirrorAllowlist).toEqual(expect.arrayContaining(["@aws-sdk/client-bedrock"])); - - for (const dep of bedrockRootMirrorAllowlist) { + for (const dep of ["@aws-sdk/client-bedrock"]) { expect(rootRuntimeDeps.get(dep)).toBe(bedrockRuntimeDeps.get(dep)); } }); diff --git a/test/helpers/plugins/package-manifest-contract.ts b/test/helpers/plugins/package-manifest-contract.ts index b6e705f4804..dc7663731ef 100644 --- a/test/helpers/plugins/package-manifest-contract.ts +++ b/test/helpers/plugins/package-manifest-contract.ts @@ -12,9 +12,6 @@ type PackageManifest = { install?: { minHostVersion?: string; }; - releaseChecks?: { - rootDependencyMirrorAllowlist?: unknown; - }; }; }; @@ -63,12 +60,9 @@ export function describePackageManifestContract(params: PackageManifestContractP const rootSpec = rootManifest.dependencies?.[dependencyName] ?? rootManifest.optionalDependencies?.[dependencyName]; - const allowlist = pluginManifest.openclaw?.releaseChecks?.rootDependencyMirrorAllowlist; expect(pluginSpec).toBeTruthy(); expect(rootSpec).toBe(pluginSpec); - expect(Array.isArray(allowlist)).toBe(true); - expect(allowlist).toContain(dependencyName); }); } } diff --git a/test/openclaw-npm-postpublish-verify.test.ts b/test/openclaw-npm-postpublish-verify.test.ts index ed76798d3db..c10505bae9d 100644 --- a/test/openclaw-npm-postpublish-verify.test.ts +++ b/test/openclaw-npm-postpublish-verify.test.ts @@ -3,6 +3,7 @@ import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { describe, expect, it } from "vitest"; import { + buildPublishedInstallCommandArgs, buildPublishedInstallScenarios, collectInstalledMirroredRootDependencyManifestErrors, collectInstalledPackageErrors, @@ -38,6 +39,23 @@ describe("buildPublishedInstallScenarios", () => { }); }); +describe("buildPublishedInstallCommandArgs", () => { + it("runs lifecycle scripts for published install verification", () => { + const args = buildPublishedInstallCommandArgs("/tmp/openclaw-prefix", "openclaw@2026.4.9"); + + expect(args).toEqual([ + "install", + "-g", + "--prefix", + "/tmp/openclaw-prefix", + "openclaw@2026.4.9", + "--no-fund", + "--no-audit", + ]); + expect(args).not.toContain("--ignore-scripts"); + }); +}); + describe("collectInstalledPackageErrors", () => { it("flags version mismatches and missing runtime sidecars", () => { const errors = collectInstalledPackageErrors({ @@ -95,7 +113,7 @@ describe("collectInstalledMirroredRootDependencyManifestErrors", () => { writeFileSync(fullPath, `${JSON.stringify(value, null, 2)}\n`, "utf8"); } - it("flags missing mirrored root dependencies required by bundled extensions", () => { + it("flags missing root mirrors for bundled plugin deps imported by root dist", () => { const packageRoot = makeInstalledPackageRoot(); try { @@ -107,15 +125,16 @@ describe("collectInstalledMirroredRootDependencyManifestErrors", () => { dependencies: { "@slack/web-api": "^7.15.0", }, - openclaw: { - releaseChecks: { - rootDependencyMirrorAllowlist: ["@slack/web-api"], - }, - }, }); + mkdirSync(join(packageRoot, "dist"), { recursive: true }); + writeFileSync( + join(packageRoot, "dist", "probe-Cz2PiFtC.js"), + 'import("@slack/web-api");\n', + "utf8", + ); expect(collectInstalledMirroredRootDependencyManifestErrors(packageRoot)).toEqual([ - "installed package is missing mirrored root runtime dependency '@slack/web-api' required by a bundled extension.", + "root dist imports bundled plugin runtime dependency '@slack/web-api' from probe-Cz2PiFtC.js; mirror '@slack/web-api: ^7.15.0' in root package.json (declared by slack).", ]); } finally { rmSync(packageRoot, { recursive: true, force: true }); @@ -136,12 +155,13 @@ describe("collectInstalledMirroredRootDependencyManifestErrors", () => { optionalDependencies: { "@discordjs/opus": "^0.10.0", }, - openclaw: { - releaseChecks: { - rootDependencyMirrorAllowlist: ["@discordjs/opus"], - }, - }, }); + mkdirSync(join(packageRoot, "dist"), { recursive: true }); + writeFileSync( + join(packageRoot, "dist", "probe-Cz2PiFtC.js"), + 'require("@discordjs/opus");\n', + "utf8", + ); expect(collectInstalledMirroredRootDependencyManifestErrors(packageRoot)).toEqual([]); } finally { @@ -149,7 +169,7 @@ describe("collectInstalledMirroredRootDependencyManifestErrors", () => { } }); - it("flags mirrored root dependency version mismatches", () => { + it("flags root mirror dependency version mismatches", () => { const packageRoot = makeInstalledPackageRoot(); try { @@ -163,15 +183,16 @@ describe("collectInstalledMirroredRootDependencyManifestErrors", () => { dependencies: { "@slack/web-api": "^7.15.0", }, - openclaw: { - releaseChecks: { - rootDependencyMirrorAllowlist: ["@slack/web-api"], - }, - }, }); + mkdirSync(join(packageRoot, "dist"), { recursive: true }); + writeFileSync( + join(packageRoot, "dist", "probe-Cz2PiFtC.js"), + 'import("@slack/web-api");\n', + "utf8", + ); expect(collectInstalledMirroredRootDependencyManifestErrors(packageRoot)).toEqual([ - "installed package mirrored dependency '@slack/web-api' version mismatch: root '^7.16.0', extension '^7.15.0'.", + "root dist imports bundled plugin runtime dependency '@slack/web-api' from probe-Cz2PiFtC.js; root package.json has '^7.16.0' but plugin manifest declares '^7.15.0' (slack).", ]); } finally { rmSync(packageRoot, { recursive: true, force: true }); diff --git a/test/release-check.test.ts b/test/release-check.test.ts index e8f8ffc11de..cf1358f438d 100644 --- a/test/release-check.test.ts +++ b/test/release-check.test.ts @@ -1,13 +1,18 @@ +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { describe, expect, it } from "vitest"; import { listBundledPluginPackArtifacts } from "../scripts/lib/bundled-plugin-build-entries.mjs"; import { listPluginSdkDistArtifacts } from "../scripts/lib/plugin-sdk-entries.mjs"; import { collectAppcastSparkleVersionErrors, collectBundledExtensionManifestErrors, - collectBundledExtensionRootDependencyMirrorErrors, + collectBundledPluginRootRuntimeMirrorErrors, + collectRootDistBundledRuntimeMirrors, collectForbiddenPackPaths, collectMissingPackPaths, collectPackUnpackedSizeErrors, + packageNameFromSpecifier, } from "../scripts/release-check.ts"; import { bundledDistPluginFile, bundledPluginFile } from "./helpers/bundled-plugin-paths.js"; @@ -110,126 +115,132 @@ describe("collectBundledExtensionManifestErrors", () => { }); }); -describe("collectBundledExtensionRootDependencyMirrorErrors", () => { - it("flags a non-array mirror allowlist", () => { +describe("bundled plugin root runtime mirrors", () => { + function makeBundledSpecs() { + return new Map([ + ["@larksuiteoapi/node-sdk", { conflicts: [], pluginIds: ["feishu"], spec: "^1.60.0" }], + ]); + } + + it("maps package names from import specifiers", () => { + expect(packageNameFromSpecifier("@larksuiteoapi/node-sdk/subpath")).toBe( + "@larksuiteoapi/node-sdk", + ); + expect(packageNameFromSpecifier("grammy/web")).toBe("grammy"); + expect(packageNameFromSpecifier("node:fs")).toBeNull(); + expect(packageNameFromSpecifier("./local")).toBeNull(); + }); + + it("derives required root mirrors from built root dist imports", () => { + const tempRoot = mkdtempSync(join(tmpdir(), "openclaw-root-mirror-")); + + try { + const distDir = join(tempRoot, "dist"); + mkdirSync(join(distDir, "extensions", "feishu"), { recursive: true }); + writeFileSync( + join(distDir, "probe-Cz2PiFtC.js"), + `import("@larksuiteoapi/node-sdk");\nrequire("grammy");\n`, + "utf8", + ); + writeFileSync( + join(distDir, "extensions", "feishu", "index.js"), + `import("@larksuiteoapi/node-sdk");\n`, + "utf8", + ); + + const mirrors = collectRootDistBundledRuntimeMirrors({ + bundledRuntimeDependencySpecs: makeBundledSpecs(), + distDir, + }); + + expect([...mirrors.keys()]).toEqual(["@larksuiteoapi/node-sdk"]); + expect([...mirrors.get("@larksuiteoapi/node-sdk")!.importers]).toEqual(["probe-Cz2PiFtC.js"]); + } finally { + rmSync(tempRoot, { recursive: true, force: true }); + } + }); + + it("flags missing root mirrors for plugin deps imported by root dist", () => { expect( - collectBundledExtensionRootDependencyMirrorErrors( - [ - { - id: "matrix", - packageJson: { - openclaw: { - releaseChecks: { - rootDependencyMirrorAllowlist: true, - }, - }, + collectBundledPluginRootRuntimeMirrorErrors({ + bundledRuntimeDependencySpecs: makeBundledSpecs(), + requiredRootMirrors: new Map([ + [ + "@larksuiteoapi/node-sdk", + { + importers: new Set(["probe-Cz2PiFtC.js"]), + pluginIds: ["feishu"], + spec: "^1.60.0", }, - }, - ], - new Map(), - ), + ], + ]), + rootPackageJson: { dependencies: {} }, + }), ).toEqual([ - "bundled extension 'matrix' manifest invalid | openclaw.releaseChecks.rootDependencyMirrorAllowlist must be an array", + "root dist imports bundled plugin runtime dependency '@larksuiteoapi/node-sdk' from probe-Cz2PiFtC.js; mirror '@larksuiteoapi/node-sdk: ^1.60.0' in root package.json (declared by feishu).", ]); }); - it("flags mirror entries missing from extension runtime dependencies", () => { + it("flags root mirror version drift from plugin manifests", () => { expect( - collectBundledExtensionRootDependencyMirrorErrors( - [ - { - id: "matrix", - packageJson: { - dependencies: { - "matrix-js-sdk": "41.2.0", - }, - openclaw: { - releaseChecks: { - rootDependencyMirrorAllowlist: ["@matrix-org/matrix-sdk-crypto-wasm"], - }, - }, + collectBundledPluginRootRuntimeMirrorErrors({ + bundledRuntimeDependencySpecs: makeBundledSpecs(), + requiredRootMirrors: new Map([ + [ + "@larksuiteoapi/node-sdk", + { + importers: new Set(["probe-Cz2PiFtC.js"]), + pluginIds: ["feishu"], + spec: "^1.60.0", }, - }, - ], - new Map([["@matrix-org/matrix-sdk-crypto-wasm", "18.0.0"]]), - ), + ], + ]), + rootPackageJson: { dependencies: { "@larksuiteoapi/node-sdk": "^1.61.0" } }, + }), ).toEqual([ - "bundled extension 'matrix' manifest invalid | openclaw.releaseChecks.rootDependencyMirrorAllowlist entry '@matrix-org/matrix-sdk-crypto-wasm' must be declared in extension runtime dependencies", + "root dist imports bundled plugin runtime dependency '@larksuiteoapi/node-sdk' from probe-Cz2PiFtC.js; root package.json has '^1.61.0' but plugin manifest declares '^1.60.0' (feishu).", ]); }); - it("flags mirror entries missing from root runtime dependencies", () => { + it("accepts matching root mirrors for plugin deps imported by root dist", () => { expect( - collectBundledExtensionRootDependencyMirrorErrors( - [ - { - id: "matrix", - packageJson: { - dependencies: { - "@matrix-org/matrix-sdk-crypto-wasm": "18.0.0", - }, - openclaw: { - releaseChecks: { - rootDependencyMirrorAllowlist: ["@matrix-org/matrix-sdk-crypto-wasm"], - }, - }, + collectBundledPluginRootRuntimeMirrorErrors({ + bundledRuntimeDependencySpecs: makeBundledSpecs(), + requiredRootMirrors: new Map([ + [ + "@larksuiteoapi/node-sdk", + { + importers: new Set(["probe-Cz2PiFtC.js"]), + pluginIds: ["feishu"], + spec: "^1.60.0", }, - }, - ], - new Map(), - ), - ).toEqual([ - "bundled extension 'matrix' manifest invalid | openclaw.releaseChecks.rootDependencyMirrorAllowlist entry '@matrix-org/matrix-sdk-crypto-wasm' must be mirrored in root runtime dependencies", - ]); - }); - - it("flags mirror entries whose root version drifts from the extension", () => { - expect( - collectBundledExtensionRootDependencyMirrorErrors( - [ - { - id: "matrix", - packageJson: { - dependencies: { - "@matrix-org/matrix-sdk-crypto-wasm": "18.0.0", - }, - openclaw: { - releaseChecks: { - rootDependencyMirrorAllowlist: ["@matrix-org/matrix-sdk-crypto-wasm"], - }, - }, - }, - }, - ], - new Map([["@matrix-org/matrix-sdk-crypto-wasm", "18.1.0"]]), - ), - ).toEqual([ - "bundled extension 'matrix' manifest invalid | openclaw.releaseChecks.rootDependencyMirrorAllowlist entry '@matrix-org/matrix-sdk-crypto-wasm' must match root runtime dependency version (extension '18.0.0', root '18.1.0')", - ]); - }); - - it("accepts mirror entries declared by both the extension and root package", () => { - expect( - collectBundledExtensionRootDependencyMirrorErrors( - [ - { - id: "matrix", - packageJson: { - dependencies: { - "@matrix-org/matrix-sdk-crypto-wasm": "18.0.0", - }, - openclaw: { - releaseChecks: { - rootDependencyMirrorAllowlist: ["@matrix-org/matrix-sdk-crypto-wasm"], - }, - }, - }, - }, - ], - new Map([["@matrix-org/matrix-sdk-crypto-wasm", "18.0.0"]]), - ), + ], + ]), + rootPackageJson: { dependencies: { "@larksuiteoapi/node-sdk": "^1.60.0" } }, + }), ).toEqual([]); }); + + it("flags conflicting plugin dependency specs", () => { + expect( + collectBundledPluginRootRuntimeMirrorErrors({ + bundledRuntimeDependencySpecs: new Map([ + [ + "@example/sdk", + { + conflicts: [{ pluginId: "right", spec: "2.0.0" }], + pluginIds: ["left"], + spec: "1.0.0", + }, + ], + ]), + requiredRootMirrors: new Map(), + rootPackageJson: { dependencies: {} }, + }), + ).toEqual([ + "bundled runtime dependency '@example/sdk' has conflicting plugin specs: left use '1.0.0', right uses '2.0.0'.", + ]); + }); }); describe("collectForbiddenPackPaths", () => {