fix: fall back when runtime closure is incomplete

This commit is contained in:
Frank Yang
2026-04-14 13:27:22 +08:00
parent 6f30d1a294
commit 1c11898f88
2 changed files with 162 additions and 49 deletions

View File

@@ -69,6 +69,15 @@ function dependencyVersionSatisfied(spec, installedVersion) {
return semverSatisfies(installedVersion, spec, { includePrerelease: false });
}
function readInstalledDependencyVersionFromRoot(depRoot) {
const packageJsonPath = path.join(depRoot, "package.json");
if (!fs.existsSync(packageJsonPath)) {
return null;
}
const version = readJson(packageJsonPath).version;
return typeof version === "string" ? version : null;
}
const defaultStagedRuntimeDepGlobalPruneSuffixes = [".d.ts", ".map"];
const defaultStagedRuntimeDepPruneRules = new Map([
// Type declarations only; runtime resolves through lib/es entrypoints.
@@ -120,38 +129,103 @@ function resolveRuntimeDepPruneConfig(params = {}) {
};
}
function collectInstalledRuntimeClosure(rootNodeModulesDir, dependencySpecs) {
const packageCache = new Map();
const closure = new Set();
const queue = Object.entries(dependencySpecs);
while (queue.length > 0) {
const [depName, spec] = queue.shift();
const installedVersion = readInstalledDependencyVersion(rootNodeModulesDir, depName);
if (installedVersion === null || !dependencyVersionSatisfied(spec, installedVersion)) {
return null;
}
if (closure.has(depName)) {
continue;
}
const packageJsonPath = path.join(
dependencyNodeModulesPath(rootNodeModulesDir, depName),
"package.json",
function resolveInstalledDependencyRoot(params) {
const candidates = [];
if (params.parentPackageRoot) {
candidates.push(
path.join(params.parentPackageRoot, "node_modules", ...params.depName.split("/")),
);
const packageJson = packageCache.get(depName) ?? readJson(packageJsonPath);
packageCache.set(depName, packageJson);
closure.add(depName);
}
candidates.push(dependencyNodeModulesPath(params.rootNodeModulesDir, params.depName));
for (const [childName, childSpec] of Object.entries(packageJson.dependencies ?? {})) {
queue.push([childName, childSpec]);
}
for (const [childName, childSpec] of Object.entries(packageJson.optionalDependencies ?? {})) {
queue.push([childName, childSpec]);
for (const depRoot of candidates) {
const installedVersion = readInstalledDependencyVersionFromRoot(depRoot);
if (installedVersion !== null && dependencyVersionSatisfied(params.spec, installedVersion)) {
return depRoot;
}
}
return [...closure];
return null;
}
function collectInstalledRuntimeDependencyRoots(rootNodeModulesDir, dependencySpecs) {
const packageCache = new Map();
const directRoots = [];
const allRoots = [];
const queue = Object.entries(dependencySpecs).map(([depName, spec]) => ({
depName,
spec,
parentPackageRoot: null,
direct: true,
}));
const seen = new Set();
while (queue.length > 0) {
const current = queue.shift();
const depRoot = resolveInstalledDependencyRoot({
depName: current.depName,
spec: current.spec,
parentPackageRoot: current.parentPackageRoot,
rootNodeModulesDir,
});
if (depRoot === null) {
return null;
}
const seenKey = `${current.depName}\0${depRoot}`;
if (seen.has(seenKey)) {
continue;
}
seen.add(seenKey);
const record = { name: current.depName, root: depRoot };
allRoots.push(record);
if (current.direct) {
directRoots.push(record);
}
const packageJson = packageCache.get(depRoot) ?? readJson(path.join(depRoot, "package.json"));
packageCache.set(depRoot, packageJson);
for (const [childName, childSpec] of Object.entries(packageJson.dependencies ?? {})) {
queue.push({
depName: childName,
spec: childSpec,
parentPackageRoot: depRoot,
direct: false,
});
}
for (const [childName, childSpec] of Object.entries(packageJson.optionalDependencies ?? {})) {
queue.push({
depName: childName,
spec: childSpec,
parentPackageRoot: depRoot,
direct: false,
});
}
}
return { allRoots, directRoots };
}
function pathIsInsideCopiedRoot(candidateRoot, copiedRoot) {
return candidateRoot === copiedRoot || candidateRoot.startsWith(`${copiedRoot}${path.sep}`);
}
function selectRuntimeDependencyRootsToCopy(resolution) {
const rootsToCopy = [];
for (const record of resolution.directRoots) {
rootsToCopy.push(record);
}
for (const record of resolution.allRoots) {
if (rootsToCopy.some((entry) => pathIsInsideCopiedRoot(record.root, entry.root))) {
continue;
}
rootsToCopy.push(record);
}
return rootsToCopy;
}
function resolveInstalledDirectDependencyNames(rootNodeModulesDir, dependencySpecs) {
@@ -209,24 +283,17 @@ function resolveInstalledRuntimeClosureFingerprint(params) {
if (Object.keys(dependencySpecs).length === 0 || !fs.existsSync(params.rootNodeModulesDir)) {
return null;
}
const directDependencyNames = resolveInstalledDirectDependencyNames(
const resolution = collectInstalledRuntimeDependencyRoots(
params.rootNodeModulesDir,
dependencySpecs,
);
if (directDependencyNames === null) {
if (resolution === null) {
return null;
}
const dependencyNames = new Set(directDependencyNames);
const transitiveClosure = collectInstalledRuntimeClosure(
return createInstalledRuntimeClosureFingerprint(
params.rootNodeModulesDir,
dependencySpecs,
selectRuntimeDependencyRootsToCopy(resolution).map((record) => record.name),
);
if (transitiveClosure !== null) {
for (const depName of transitiveClosure) {
dependencyNames.add(depName);
}
}
return createInstalledRuntimeClosureFingerprint(params.rootNodeModulesDir, dependencyNames);
}
function walkFiles(rootDir, visitFile) {
@@ -369,7 +436,6 @@ function createRuntimeDepsFingerprint(packageJson, pruneConfig, params = {}) {
pruneRules: [...pruneConfig.pruneRules.entries()],
rootInstalledRuntimeFingerprint: params.rootInstalledRuntimeFingerprint ?? null,
rootLockfile,
pruneRules: [...pruneConfig.pruneRules.entries()],
version: runtimeDepsStagingVersion,
}),
)
@@ -405,13 +471,11 @@ function stageInstalledRootRuntimeDeps(params) {
if (directDependencyNames === null) {
return false;
}
const dependencyNames = new Set(directDependencyNames);
const transitiveClosure = collectInstalledRuntimeClosure(rootNodeModulesDir, dependencySpecs);
if (transitiveClosure !== null) {
for (const depName of transitiveClosure) {
dependencyNames.add(depName);
}
const resolution = collectInstalledRuntimeDependencyRoots(rootNodeModulesDir, dependencySpecs);
if (resolution === null) {
return false;
}
const rootsToCopy = selectRuntimeDependencyRootsToCopy(resolution);
const nodeModulesDir = path.join(pluginDir, "node_modules");
const stampPath = resolveRuntimeDepsStampPath(pluginDir);
@@ -424,11 +488,11 @@ function stageInstalledRootRuntimeDeps(params) {
);
try {
for (const depName of [...dependencyNames].toSorted((left, right) =>
left.localeCompare(right),
for (const record of rootsToCopy.toSorted((left, right) =>
left.name.localeCompare(right.name),
)) {
const sourcePath = dependencyNodeModulesPath(rootNodeModulesDir, depName);
const targetPath = dependencyNodeModulesPath(stagedNodeModulesDir, depName);
const sourcePath = record.root;
const targetPath = dependencyNodeModulesPath(stagedNodeModulesDir, record.name);
fs.mkdirSync(path.dirname(targetPath), { recursive: true });
fs.cpSync(sourcePath, targetPath, { recursive: true, force: true, dereference: true });
}

View File

@@ -298,6 +298,55 @@ describe("stageBundledPluginRuntimeDeps", () => {
).toBe("module.exports = 'nested';\n");
});
it("falls back to install when the root transitive closure is incomplete", () => {
const { pluginDir, repoRoot } = createBundledPluginFixture({
packageJson: {
name: "@openclaw/fixture-plugin",
version: "1.0.0",
dependencies: { direct: "1.0.0" },
openclaw: { bundle: { stageRuntimeDependencies: true } },
},
});
const directDir = path.join(repoRoot, "node_modules", "direct");
fs.mkdirSync(directDir, { recursive: true });
fs.writeFileSync(
path.join(directDir, "package.json"),
'{ "name": "direct", "version": "1.0.0", "dependencies": { "missing-transitive": "^1.0.0" } }\n',
"utf8",
);
fs.writeFileSync(path.join(directDir, "index.js"), "module.exports = 'direct';\n", "utf8");
let installCount = 0;
stageBundledPluginRuntimeDeps({
cwd: repoRoot,
installPluginRuntimeDepsImpl: ({ fingerprint }: { fingerprint: string }) => {
installCount += 1;
const nodeModulesDir = path.join(pluginDir, "node_modules", "direct");
fs.mkdirSync(nodeModulesDir, { recursive: true });
fs.writeFileSync(
path.join(nodeModulesDir, "package.json"),
'{ "name": "direct", "version": "1.0.0" }\n',
"utf8",
);
fs.writeFileSync(
path.join(nodeModulesDir, "index.js"),
"module.exports = 'installed';\n",
"utf8",
);
fs.writeFileSync(
path.join(pluginDir, ".openclaw-runtime-deps-stamp.json"),
`${JSON.stringify({ fingerprint }, null, 2)}\n`,
"utf8",
);
},
});
expect(installCount).toBe(1);
expect(
fs.readFileSync(path.join(pluginDir, "node_modules", "direct", "index.js"), "utf8"),
).toBe("module.exports = 'installed';\n");
});
it("removes global non-runtime suffixes from staged runtime dependencies", () => {
const { pluginDir, repoRoot } = createBundledPluginFixture({
packageJson: {