diff --git a/scripts/check-openclaw-package-tarball.mjs b/scripts/check-openclaw-package-tarball.mjs index ae817fc2d0b..2625d30a93e 100644 --- a/scripts/check-openclaw-package-tarball.mjs +++ b/scripts/check-openclaw-package-tarball.mjs @@ -7,6 +7,7 @@ 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 { expandPackageDistImportClosure } from "./lib/package-dist-imports.mjs"; function usage() { return "Usage: node scripts/check-openclaw-package-tarball.mjs "; @@ -243,6 +244,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)) { @@ -258,6 +261,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 new file mode 100644 index 00000000000..35ca7bac370 --- /dev/null +++ b/scripts/lib/package-dist-imports.mjs @@ -0,0 +1,170 @@ +import path from "node:path"; + +const JS_DIST_FILE_RE = /^dist\/.*\.(?:cjs|js|mjs)$/u; + +function normalizePackagePath(value) { + return value.replace(/\\/gu, "/").replace(/^package\//u, ""); +} + +function stripSpecifierSuffix(value) { + return value.replace(/[?#].*$/u, ""); +} + +function resolveDistImportPath(importerPath, specifier) { + if (!specifier.startsWith(".")) { + return null; + } + const stripped = stripSpecifierSuffix(specifier); + if (!stripped) { + return null; + } + return path.posix.normalize(path.posix.join(path.posix.dirname(importerPath), stripped)); +} + +function findStatementStart(source, index) { + return ( + Math.max( + source.lastIndexOf(";", index), + source.lastIndexOf("{", index), + source.lastIndexOf("}", index), + source.lastIndexOf("\n", index), + source.lastIndexOf("\r", index), + ) + 1 + ); +} + +function isImportSpecifierContext(source, index) { + const dynamicPrefix = source.slice(Math.max(0, index - 32), index); + if (/\bimport\s*\(\s*$/u.test(dynamicPrefix)) { + return true; + } + const statementPrefix = source.slice(findStatementStart(source, index), index).trimStart(); + return ( + /^(?:import|export)\b[\s\S]*\bfrom\s*$/u.test(statementPrefix) || + /^import\s*$/u.test(statementPrefix) + ); +} + +function collectImportSpecifiers(source) { + const specifiers = []; + let inBlockComment = false; + let inLineComment = false; + for (let index = 0; index < source.length; index += 1) { + if (inBlockComment) { + if (source[index] === "*" && source[index + 1] === "/") { + inBlockComment = false; + index += 1; + } + continue; + } + if (inLineComment) { + if (source[index] === "\n" || source[index] === "\r") { + inLineComment = false; + } + continue; + } + if (source[index] === "/" && source[index + 1] === "*") { + inBlockComment = true; + index += 1; + continue; + } + if (source[index] === "/" && source[index + 1] === "/") { + inLineComment = true; + index += 1; + continue; + } + + const quote = source[index]; + if (quote !== '"' && quote !== "'") { + continue; + } + + let cursor = index + 1; + let value = ""; + while (cursor < source.length) { + const char = source[cursor]; + if (char === "\\") { + value += source.slice(cursor, cursor + 2); + cursor += 2; + continue; + } + if (char === quote) { + break; + } + value += char; + cursor += 1; + } + if (cursor >= source.length) { + break; + } + + if (value.startsWith(".")) { + if (isImportSpecifierContext(source, index)) { + specifiers.push(value); + } + } + index = cursor; + } + return specifiers; +} + +export function collectPackageDistImportErrors(params) { + const files = [...new Set(params.files.map(normalizePackagePath))]; + 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; + } + const source = params.readText(importerPath); + for (const specifier of collectImportSpecifiers(source)) { + const importedPath = resolveDistImportPath(importerPath, specifier); + if (!importedPath) { + continue; + } + imports.push({ importerPath, importedPath }); + } + } + + 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 f3816d17ab0..279799459b5 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 ac017fb357a..e43a0378600 100644 --- a/test/scripts/check-openclaw-package-tarball.test.ts +++ b/test/scripts/check-openclaw-package-tarball.test.ts @@ -122,6 +122,26 @@ describe("check-openclaw-package-tarball", () => { expect(result.status, result.stderr).toBe(0); }, + "2026.4.27", + ); + }); + + 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", ); }); diff --git a/test/scripts/postinstall-bundled-plugins.test.ts b/test/scripts/postinstall-bundled-plugins.test.ts index 06cefb18fee..c689b3ad47b 100644 --- a/test/scripts/postinstall-bundled-plugins.test.ts +++ b/test/scripts/postinstall-bundled-plugins.test.ts @@ -397,6 +397,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");