diff --git a/scripts/check-openclaw-package-tarball.mjs b/scripts/check-openclaw-package-tarball.mjs index f383203840b..576a8a62169 100644 --- a/scripts/check-openclaw-package-tarball.mjs +++ b/scripts/check-openclaw-package-tarball.mjs @@ -7,7 +7,10 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { LOCAL_BUILD_METADATA_DIST_PATHS } from "./lib/local-build-metadata-paths.mjs"; -import { collectPackageDistImportErrors } from "./lib/package-dist-imports.mjs"; +import { + collectPackageDistImportErrors, + expandPackageDistImportClosure, +} from "./lib/package-dist-imports.mjs"; function usage() { return "Usage: node scripts/check-openclaw-package-tarball.mjs "; @@ -186,6 +189,8 @@ if (entrySet.has("dist/postinstall-inventory.json")) { if (!Array.isArray(inventory) || inventory.some((entry) => typeof entry !== "string")) { errors.push("invalid dist/postinstall-inventory.json"); } else { + const normalizedInventory = inventory.map((entry) => entry.replace(/\\/gu, "/")); + const normalizedInventorySet = new Set(normalizedInventory); for (const inventoryEntry of inventory) { const normalizedEntry = inventoryEntry.replace(/\\/gu, "/"); if (!entrySet.has(normalizedEntry)) { @@ -201,6 +206,16 @@ if (entrySet.has("dist/postinstall-inventory.json")) { errors.push(`inventory references missing tar entry ${normalizedEntry}`); } } + const expandedInventory = expandPackageDistImportClosure({ + files: normalized, + seedFiles: normalizedInventory, + readText: readTarEntry, + }); + for (const importedEntry of expandedInventory) { + if (!normalizedInventorySet.has(importedEntry)) { + errors.push(`inventory omits imported dist file ${importedEntry}`); + } + } } } catch (error) { errors.push( diff --git a/scripts/lib/package-dist-imports.mjs b/scripts/lib/package-dist-imports.mjs index 4d8cffcc90f..35ca7bac370 100644 --- a/scripts/lib/package-dist-imports.mjs +++ b/scripts/lib/package-dist-imports.mjs @@ -113,6 +113,22 @@ export function collectPackageDistImportErrors(params) { const fileSet = new Set(files); const errors = []; + for (const { importerPath, importedPath } of collectPackageDistImports({ + files, + readText: params.readText, + })) { + if (!fileSet.has(importedPath)) { + errors.push(`${importerPath} imports missing ${importedPath}`); + } + } + + return errors; +} + +export function collectPackageDistImports(params) { + const files = [...new Set(params.files.map(normalizePackagePath))]; + const imports = []; + for (const importerPath of files.toSorted((left, right) => left.localeCompare(right))) { if (!JS_DIST_FILE_RE.test(importerPath) || importerPath.includes("/node_modules/")) { continue; @@ -120,12 +136,35 @@ export function collectPackageDistImportErrors(params) { const source = params.readText(importerPath); for (const specifier of collectImportSpecifiers(source)) { const importedPath = resolveDistImportPath(importerPath, specifier); - if (!importedPath || fileSet.has(importedPath)) { + if (!importedPath) { continue; } - errors.push(`${importerPath} imports missing ${importedPath}`); + imports.push({ importerPath, importedPath }); } } - return errors; + return imports; +} + +export function expandPackageDistImportClosure(params) { + const files = [...new Set(params.files.map(normalizePackagePath))]; + const fileSet = new Set(files); + const expectedSet = new Set(params.seedFiles.map(normalizePackagePath)); + let changed = true; + + while (changed) { + changed = false; + for (const { importedPath } of collectPackageDistImports({ + files: [...expectedSet].filter((file) => fileSet.has(file)), + readText: params.readText, + })) { + if (!fileSet.has(importedPath) || expectedSet.has(importedPath)) { + continue; + } + expectedSet.add(importedPath); + changed = true; + } + } + + return [...expectedSet].toSorted((left, right) => left.localeCompare(right)); } diff --git a/scripts/postinstall-bundled-plugins.mjs b/scripts/postinstall-bundled-plugins.mjs index 0aa9112b72a..b93e0730f2f 100644 --- a/scripts/postinstall-bundled-plugins.mjs +++ b/scripts/postinstall-bundled-plugins.mjs @@ -23,6 +23,7 @@ import { } from "node:fs"; import { basename, dirname, isAbsolute, join, relative } from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; +import { expandPackageDistImportClosure } from "./lib/package-dist-imports.mjs"; import { resolveNpmRunner } from "./npm-runner.mjs"; export const BUNDLED_PLUGIN_INSTALL_TARGETS = []; @@ -292,6 +293,16 @@ export function pruneInstalledPackageDist(params = {}) { } } const installedFiles = listInstalledDistFiles(params); + const readFile = params.readFileSync ?? readFileSync; + expectedFiles = new Set( + expandPackageDistImportClosure({ + files: installedFiles, + seedFiles: [...expectedFiles], + readText(relativePath) { + return readFile(join(packageRoot, relativePath), "utf8"); + }, + }), + ); const removed = []; for (const relativePath of installedFiles) { diff --git a/test/scripts/check-openclaw-package-tarball.test.ts b/test/scripts/check-openclaw-package-tarball.test.ts index 164edc40257..4820520cc71 100644 --- a/test/scripts/check-openclaw-package-tarball.test.ts +++ b/test/scripts/check-openclaw-package-tarball.test.ts @@ -127,6 +127,25 @@ describe("check-openclaw-package-tarball", () => { ); }); + it("rejects imported dist chunks omitted from the postinstall inventory", () => { + withTarball( + ["dist/cli/run-main.js"], + { + "dist/cli/run-main.js": 'await import("../memory-state-current.js");\n', + "dist/memory-state-current.js": "export {};\n", + }, + (tarball) => { + const result = spawnSync("node", [CHECK_SCRIPT, tarball], { encoding: "utf8" }); + + expect(result.status).not.toBe(0); + expect(result.stderr).toContain( + "inventory omits imported dist file dist/memory-state-current.js", + ); + }, + "2026.4.27", + ); + }); + it("rejects missing Control UI assets", () => { withTarball( ["dist/index.js"], diff --git a/test/scripts/postinstall-bundled-plugins.test.ts b/test/scripts/postinstall-bundled-plugins.test.ts index 868cf578538..150234558ce 100644 --- a/test/scripts/postinstall-bundled-plugins.test.ts +++ b/test/scripts/postinstall-bundled-plugins.test.ts @@ -401,6 +401,28 @@ describe("bundled plugin postinstall", () => { await expect(fs.stat(staleFile)).rejects.toMatchObject({ code: "ENOENT" }); }); + it("keeps imported dist chunks even when inventory is stale", async () => { + const packageRoot = await createTempDirAsync("openclaw-packaged-install-import-"); + const entryFile = path.join(packageRoot, "dist", "cli", "run-main.js"); + const importedChunk = path.join(packageRoot, "dist", "memory-state-CcqRgDZU.js"); + const staleFile = path.join(packageRoot, "dist", "memory-state-old.js"); + await fs.mkdir(path.dirname(entryFile), { recursive: true }); + await fs.writeFile(entryFile, 'await import("../memory-state-CcqRgDZU.js");\n'); + await writePackageDistInventory(packageRoot); + await fs.writeFile(importedChunk, "export {};\n"); + await fs.writeFile(staleFile, "export {};\n"); + + expect( + pruneInstalledPackageDist({ + packageRoot, + log: { log: vi.fn(), warn: vi.fn() }, + }), + ).toEqual(["dist/memory-state-old.js"]); + + await expect(fs.stat(importedChunk)).resolves.toBeTruthy(); + await expect(fs.stat(staleFile)).rejects.toMatchObject({ code: "ENOENT" }); + }); + it("prunes stale private QA files without restoring compat sidecars", async () => { const packageRoot = await createTempDirAsync("openclaw-packaged-install-qa-compat-"); const currentFile = path.join(packageRoot, "dist", "entry.js");