diff --git a/CHANGELOG.md b/CHANGELOG.md index e2861a7eb72..a42bf1b7e96 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -231,6 +231,7 @@ Docs: https://docs.openclaw.ai - Agents/CLI: keep `--agent` plus `--session-id` lookup scoped to the requested agent store, so explicit agent resumes cannot select another agent's session. (#70985) Thanks @frankekn. - Plugins/Comfy: read workflow and cloud auth configuration from `plugins.entries.comfy.config` while preserving legacy Comfy config fallback, so image, video, and music workflows pass config validation. Fixes #61915. (#63058) Thanks @547895019. - Gateway/secrets: restart secret-backed channels such as Slack and Zalo during `secrets.reload` so rotated webhook secrets take effect immediately, with the reload serialized and per-channel restart errors isolated. (#70720) Thanks @drobison00. +- Plugins/tokenjuice: preserve `node_modules/tokenjuice/dist/rules/tests/*.json` during bundled plugin runtime staging so the plugin stops failing to load with `Cannot find module '../rules/tests/bun-test.json'`. The global basename prune treats any `tests/` directory as test cargo, but tokenjuice's `dist/rules/tests/` is runtime-loaded rule data consumed by `dist/core/builtin-rules.generated.js`. Adds an opt-in `keepDirectories` field to the per-package prune rule so packages with asset directories that collide with pruned basenames can stage cleanly. ## 2026.4.22 diff --git a/scripts/stage-bundled-plugin-runtime-deps.mjs b/scripts/stage-bundled-plugin-runtime-deps.mjs index 8b4c0db2bfc..ee74ab0dcb0 100644 --- a/scripts/stage-bundled-plugin-runtime-deps.mjs +++ b/scripts/stage-bundled-plugin-runtime-deps.mjs @@ -209,8 +209,15 @@ const defaultStagedRuntimeDepPruneRules = new Map([ ["@jimp/plugin-print", { paths: ["src/__image_snapshots__"] }], ["@jimp/plugin-quantize", { paths: ["src/__image_snapshots__"] }], ["@jimp/plugin-threshold", { paths: ["src/__image_snapshots__"] }], + // tokenjuice ships built-in rules as JSON data under `dist/rules/tests/*.json` + // (e.g. `bun-test.json`, `jest.json`, `pytest.json`). These are NOT test + // fixtures — they are the runtime-loaded rule definitions consumed by + // `dist/core/builtin-rules.generated.js`. The global `tests` basename prune + // would strip them, and the plugin then fails to load with + // `Cannot find module '../rules/tests/bun-test.json'`. Keep them staged. + ["tokenjuice", { keepDirectories: ["dist/rules/tests"] }], ]); -const runtimeDepsStagingVersion = 6; +const runtimeDepsStagingVersion = 7; const exactVersionSpecRe = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/u; function resolveRuntimeDepPruneConfig(params = {}) { @@ -547,7 +554,7 @@ function isNodeModulesPackageRoot(segments, index) { return parent?.startsWith("@") === true && segments[index - 2] === "node_modules"; } -function pruneDependencyDirectoriesByBasename(depRoot, basenames) { +function pruneDependencyDirectoriesByBasename(depRoot, basenames, keepDirs = new Set()) { if (!basenames || basenames.length === 0 || !fs.existsSync(depRoot)) { return; } @@ -562,6 +569,15 @@ function pruneDependencyDirectoriesByBasename(depRoot, basenames) { const fullPath = path.join(currentDir, entry.name); const segments = relativePathSegments(depRoot, fullPath); if (basenameSet.has(entry.name) && !isNodeModulesPackageRoot(segments, segments.length - 1)) { + // Per-package opt-out: a pruneRule may keep specific directories that + // would otherwise match a global basename prune (e.g. a data/asset + // directory named `tests/` that is NOT test code). Descend into kept + // directories so their contents are still subject to suffix/pattern + // pruning, but do not remove the directory itself. + if (keepDirs.has(fullPath)) { + queue.push(fullPath); + continue; + } removePathIfExists(fullPath); continue; } @@ -591,7 +607,12 @@ function pruneStagedInstalledDependencyCargo(nodeModulesDir, depName, pruneConfi for (const relativePath of pruneRule?.paths ?? []) { removePathIfExists(path.join(depRoot, relativePath)); } - pruneDependencyDirectoriesByBasename(depRoot, pruneConfig.globalPruneDirectories); + // Resolve per-package keepDirectories (opt-out of global basename prune) + // against depRoot up front so the walk can skip them cheaply. + const keepDirs = new Set( + (pruneRule?.keepDirectories ?? []).map((relativePath) => path.resolve(depRoot, relativePath)), + ); + pruneDependencyDirectoriesByBasename(depRoot, pruneConfig.globalPruneDirectories, keepDirs); pruneDependencyFilesByPatterns(depRoot, pruneConfig.globalPruneFilePatterns); pruneDependencyFilesBySuffixes(depRoot, pruneConfig.globalPruneSuffixes); pruneDependencyFilesBySuffixes(depRoot, pruneRule?.suffixes ?? []); diff --git a/test/scripts/stage-bundled-plugin-runtime-deps.test.ts b/test/scripts/stage-bundled-plugin-runtime-deps.test.ts index f745ac988f5..4d7838e63ea 100644 --- a/test/scripts/stage-bundled-plugin-runtime-deps.test.ts +++ b/test/scripts/stage-bundled-plugin-runtime-deps.test.ts @@ -951,6 +951,66 @@ describe("stageBundledPluginRuntimeDeps", () => { ); }); + it("honors keepDirectories to opt a subtree out of global basename prune", () => { + // Regression: tokenjuice ships runtime-loaded rule data under + // `dist/rules/tests/*.json`. Without keepDirectories the global `tests` + // basename prune would strip that subtree and the plugin would fail to + // load with `Cannot find module '../rules/tests/bun-test.json'`. + const { pluginDir, repoRoot } = createBundledPluginFixture({ + packageJson: { + name: "@openclaw/fixture-plugin", + version: "1.0.0", + dependencies: { "keep-target": "1.0.0" }, + openclaw: { bundle: { stageRuntimeDependencies: true } }, + }, + }); + const depDir = path.join(repoRoot, "node_modules", "keep-target"); + fs.mkdirSync(path.join(depDir, "dist", "rules", "tests"), { recursive: true }); + fs.mkdirSync(path.join(depDir, "src", "tests"), { recursive: true }); + fs.writeFileSync( + path.join(depDir, "package.json"), + '{ "name": "keep-target", "version": "1.0.0" }\n', + "utf8", + ); + fs.writeFileSync( + path.join(depDir, "dist", "rules", "tests", "bun-test.json"), + '{"rule":"bun"}\n', + "utf8", + ); + fs.writeFileSync( + path.join(depDir, "src", "tests", "legit-test.spec.ts"), + "describe('x', () => {});\n", + "utf8", + ); + + stageBundledPluginRuntimeDeps({ + cwd: repoRoot, + stagedRuntimeDepPruneRules: new Map([ + ["keep-target", { keepDirectories: ["dist/rules/tests"] }], + ]), + }); + + // Opt-in path: preserved intact. + expect( + fs.existsSync( + path.join( + pluginDir, + "node_modules", + "keep-target", + "dist", + "rules", + "tests", + "bun-test.json", + ), + ), + ).toBe(true); + + // Unlisted `tests/` directories still get pruned. + expect(fs.existsSync(path.join(pluginDir, "node_modules", "keep-target", "src", "tests"))).toBe( + false, + ); + }); + it("applies default prune rules for known heavy non-runtime package cargo", () => { const { pluginDir, repoRoot } = createBundledPluginFixture({ packageJson: {