From 8cf6b9e5ab56835edd3daff7634faa864ef900df Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 27 Aug 2026 07:32:07 -0700 Subject: [PATCH] fix(scripts): resolve worktree implementation dependencies (#130950) * fix(scripts): resolve worktree implementation dependencies * test(scripts): complete bootstrap fixture and trust closures --- .github/workflows/docs-sync-publish.yml | 1 + .github/workflows/plugin-npm-release.yml | 1 + scripts/docs-sync-publish.mjs | 4 ++ scripts/lib/local-check-runtime.mts | 35 +++++++++--- scripts/lib/plugin-publication-candidates.ts | 1 + scripts/lib/tsx-cli-shim.mjs | 9 ++- scripts/pr | 1 + test/scripts/direct-run-entrypoints.test.ts | 42 +++++++++++++- test/scripts/docker-all-scheduler.test.ts | 1 + test/scripts/pr-operation-lock.test.ts | 1 + test/scripts/pr-wrappers.test.ts | 58 +++++++++----------- test/scripts/release-preflight.test.ts | 1 + 12 files changed, 113 insertions(+), 42 deletions(-) diff --git a/.github/workflows/docs-sync-publish.yml b/.github/workflows/docs-sync-publish.yml index 5467cb4b0580..2424675033e3 100644 --- a/.github/workflows/docs-sync-publish.yml +++ b/.github/workflows/docs-sync-publish.yml @@ -12,6 +12,7 @@ on: - scripts/check-docs-mdx.mts - scripts/lib/mintlify-accordion.mjs - scripts/lib/tsx-cli-shim.mjs + - scripts/lib/local-check-runtime.mts - .github/workflows/docs-sync-publish.yml workflow_dispatch: diff --git a/.github/workflows/plugin-npm-release.yml b/.github/workflows/plugin-npm-release.yml index f926376b789a..e7839c81f0bb 100644 --- a/.github/workflows/plugin-npm-release.yml +++ b/.github/workflows/plugin-npm-release.yml @@ -21,6 +21,7 @@ on: - "scripts/lib/plugin-npm-package-manifest.mjs" - "scripts/lib/plugin-npm-package-manifest.mts" - "scripts/lib/tsx-cli-shim.mjs" + - "scripts/lib/local-check-runtime.mts" - "scripts/lib/plugin-npm-release.ts" - "scripts/lib/plugin-publication-candidates.ts" - "scripts/lib/plugin-publication-collector.ts" diff --git a/scripts/docs-sync-publish.mjs b/scripts/docs-sync-publish.mjs index d7e48e37b018..896677bbea3c 100644 --- a/scripts/docs-sync-publish.mjs +++ b/scripts/docs-sync-publish.mjs @@ -33,6 +33,10 @@ const SYNC_SUPPORT_FILES = [ source: path.join(ROOT, "scripts", "lib", "tsx-cli-shim.mjs"), target: path.join(".openclaw-sync", "lib", "tsx-cli-shim.mjs"), }, + { + source: path.join(ROOT, "scripts", "lib", "local-check-runtime.mts"), + target: path.join(".openclaw-sync", "lib", "local-check-runtime.mts"), + }, { source: path.join(ROOT, "scripts", "lib", "mintlify-accordion.mjs"), target: path.join(".openclaw-sync", "lib", "mintlify-accordion.mjs"), diff --git a/scripts/lib/local-check-runtime.mts b/scripts/lib/local-check-runtime.mts index 8f79b26cea04..f34a2379e3bb 100644 --- a/scripts/lib/local-check-runtime.mts +++ b/scripts/lib/local-check-runtime.mts @@ -24,6 +24,10 @@ type RepoToolOptions = { fileExists?: (candidate: string) => boolean; resolveCommonDir?: (cwd: string) => string | null; }; +type NodeModulesLinkOptions = Pick & { + symlink?: typeof fs.symlinkSync; + platform?: NodeJS.Platform; +}; /** Return whether local check safeguards are enabled for an environment. */ export function isLocalCheckEnabled(env: Env) { @@ -81,10 +85,7 @@ export function ensureRepoToolNodeModulesLink( resolveCommonDir = resolveGitCommonDir, symlink = fs.symlinkSync, platform = process.platform, - }: RepoToolOptions & { - symlink?: typeof fs.symlinkSync; - platform?: NodeJS.Platform; - } = {}, + }: RepoToolOptions & NodeModulesLinkOptions = {}, ) { const localNodeModules = path.resolve(cwd, "node_modules"); if (fileExists(localNodeModules)) { @@ -102,10 +103,30 @@ export function ensureRepoToolNodeModulesLink( return null; } + return ensureRepoNodeModulesLink(primaryNodeModules, { cwd, fileExists, symlink, platform }); +} + +/** Make selected toolchain packages resolvable from dependency-less source paths. */ +export function ensureRepoNodeModulesLink( + modulesDir: string, + { + cwd = process.cwd(), + fileExists = fs.existsSync, + symlink = fs.symlinkSync, + platform = process.platform, + }: NodeModulesLinkOptions = {}, +) { + const localNodeModules = path.resolve(cwd, "node_modules"); + if (fileExists(localNodeModules)) { + return localNodeModules; + } + if (!fileExists(modulesDir)) { + return null; + } try { - // Match run-vitest.mjs's hydrated-toolchain behavior: keep one stable link - // so compilers can resolve imports from worktree source paths. - symlink(primaryNodeModules, localNodeModules, platform === "win32" ? "junction" : "dir"); + // Keep existing checkout dependencies locally owned; only absent modules + // reuse the selected installed toolchain, without reconciling dependencies. + symlink(modulesDir, localNodeModules, platform === "win32" ? "junction" : "dir"); } catch (error) { // Another local runner may have installed the same stable link concurrently. if (!fileExists(localNodeModules)) { diff --git a/scripts/lib/plugin-publication-candidates.ts b/scripts/lib/plugin-publication-candidates.ts index 2473d4b41db3..7c6bc462f6ea 100644 --- a/scripts/lib/plugin-publication-candidates.ts +++ b/scripts/lib/plugin-publication-candidates.ts @@ -25,6 +25,7 @@ export const PLUGIN_NPM_RELEASE_AUTHORITY_PATHS = [ "scripts/generate-npm-package-lock.mjs", "scripts/generate-npm-package-lock.mts", "scripts/lib/actions-artifact-archive.mjs", + "scripts/lib/local-check-runtime.mts", "scripts/lib/npm-json-output.mts", "scripts/lib/plugin-npm-package-manifest.mjs", "scripts/lib/plugin-npm-package-manifest.mts", diff --git a/scripts/lib/tsx-cli-shim.mjs b/scripts/lib/tsx-cli-shim.mjs index 62bf44896f43..c1a9a5fc5ef0 100644 --- a/scripts/lib/tsx-cli-shim.mjs +++ b/scripts/lib/tsx-cli-shim.mjs @@ -4,6 +4,7 @@ import { createRequire } from "node:module"; import { constants as osConstants } from "node:os"; import path from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; +import { ensureRepoNodeModulesLink } from "./local-check-runtime.mts"; const FORWARDED_SIGNALS = ["SIGINT", "SIGTERM", "SIGHUP"]; const DEFAULT_FORCE_KILL_DELAY_MS = 5_000; @@ -40,7 +41,13 @@ function resolveTsxImport(checkoutRoot) { ].filter(Boolean)) { try { const require = createRequire(path.join(candidateRoot, "package.json")); - return pathToFileURL(require.resolve("tsx")).href; + const importUrl = pathToFileURL(require.resolve("tsx")).href; + const selectedModulesDir = + candidateRoot === hydratedTsxRoot + ? path.dirname(candidateRoot) + : path.join(candidateRoot, "node_modules"); + ensureRepoNodeModulesLink(selectedModulesDir, { cwd: checkoutRoot }); + return importUrl; } catch (error) { resolutionError = error; } diff --git a/scripts/pr b/scripts/pr index 8976ff1b284a..ac3076f3c8fd 100755 --- a/scripts/pr +++ b/scripts/pr @@ -51,6 +51,7 @@ pr_wrapper_components=( scripts/lib/plain-gh.mjs scripts/lib/direct-run.mjs scripts/lib/tsx-cli-shim.mjs + scripts/lib/local-check-runtime.mts scripts/verify-pr-hosted-gates.mjs scripts/verify-pr-hosted-gates.mts scripts/watch-pr-ci.mjs diff --git a/test/scripts/direct-run-entrypoints.test.ts b/test/scripts/direct-run-entrypoints.test.ts index 9c5510fd834a..63f00bd1fc74 100644 --- a/test/scripts/direct-run-entrypoints.test.ts +++ b/test/scripts/direct-run-entrypoints.test.ts @@ -100,6 +100,13 @@ function writeTsxFixture(modulesDir: string, marker: string) { path.join(packageDir, "loader.mjs"), `process.env.OPENCLAW_TSX_FIXTURE_LOADER = ${JSON.stringify(marker)};\n`, ); + const dependencyDir = path.join(modulesDir, "shim-dependency"); + mkdirSync(dependencyDir, { recursive: true }); + writeFileSync( + path.join(dependencyDir, "package.json"), + JSON.stringify({ name: "shim-dependency", type: "module", exports: "./index.js" }), + ); + writeFileSync(path.join(dependencyDir, "index.js"), 'export const value = "loaded";\n'); } function runShimFixture( @@ -121,10 +128,14 @@ function runShimFixture( "scripts/lib/tsx-cli-shim.mjs", path.join(checkoutRoot, "scripts", "lib", "tsx-cli-shim.mjs"), ); + copyFileSync( + "scripts/lib/local-check-runtime.mts", + path.join(checkoutRoot, "scripts", "lib", "local-check-runtime.mts"), + ); writeFileSync(path.join(checkoutRoot, "pnpm-lock.yaml"), "lockfileVersion: '9.0'\n"); writeFileSync( implementationPath, - "process.stdout.write(JSON.stringify({ loader: process.env.OPENCLAW_TSX_FIXTURE_LOADER, args: process.argv.slice(2) }));\n", + 'import { value } from "shim-dependency";\nprocess.stdout.write(JSON.stringify({ loader: process.env.OPENCLAW_TSX_FIXTURE_LOADER, dependency: value, args: process.argv.slice(2) }));\n', ); writeTsxFixture(path.join(checkoutRoot, "node_modules"), "checkout"); const modulesEnv = configureModules({ checkoutRoot, fixtureRoot }); @@ -149,7 +160,11 @@ function runShimFixture( function expectShimLoader(result: ReturnType, loader: string) { expect(result.error).toBeUndefined(); expect(result.status, result.stderr).toBe(0); - expect(JSON.parse(result.stdout)).toEqual({ loader, args: ["--hydrated-proof"] }); + expect(JSON.parse(result.stdout)).toEqual({ + loader, + dependency: "loaded", + args: ["--hydrated-proof"], + }); } describe("script direct-run entrypoints", () => { @@ -196,6 +211,29 @@ describe("script direct-run entrypoints", () => { expectShimLoader(runShimFixture(TSX_SHIM_WRAPPERS[3]), "checkout"); }); + it.each(["hydrated", "primary"] as const)( + "resolves implementation dependencies from the %s toolchain without local modules", + (source) => { + const result = runShimFixture(TSX_SHIM_WRAPPERS[0], ({ checkoutRoot, fixtureRoot }) => { + rmSync(path.join(checkoutRoot, "node_modules"), { recursive: true }); + const primaryRoot = path.join(fixtureRoot, "primary"); + const modulesDir = path.join(primaryRoot, "node_modules"); + writeTsxFixture(modulesDir, source); + if (source === "hydrated") { + return { PNPM_CONFIG_MODULES_DIR: modulesDir }; + } + const initialized = spawnSync( + "git", + ["init", "--quiet", "--separate-git-dir", path.join(primaryRoot, ".git"), checkoutRoot], + { encoding: "utf8" }, + ); + expect(initialized.status, initialized.stderr).toBe(0); + return {}; + }); + expectShimLoader(result, source); + }, + ); + it("matches Windows drive paths case-insensitively", () => { expect( isDirectRunPath( diff --git a/test/scripts/docker-all-scheduler.test.ts b/test/scripts/docker-all-scheduler.test.ts index c037dee1d00b..7d184d85ca55 100644 --- a/test/scripts/docker-all-scheduler.test.ts +++ b/test/scripts/docker-all-scheduler.test.ts @@ -627,6 +627,7 @@ describe("scripts/test-docker-all scheduler", () => { for (const fileName of [ "docker-e2e-plan.mts", "docker-e2e-scenarios.mts", + "local-check-runtime.mts", "managed-child-process.mts", "official-external-channel-catalog.json", "release-version.mjs", diff --git a/test/scripts/pr-operation-lock.test.ts b/test/scripts/pr-operation-lock.test.ts index bd7ceea44204..9e12cd250f78 100644 --- a/test/scripts/pr-operation-lock.test.ts +++ b/test/scripts/pr-operation-lock.test.ts @@ -213,6 +213,7 @@ function installPrCliFixture(repoDir: string) { "scripts/lib/plain-gh.mjs", "scripts/lib/direct-run.mjs", "scripts/lib/tsx-cli-shim.mjs", + "scripts/lib/local-check-runtime.mts", "scripts/pr-lib/worktree.sh", "scripts/pr-lib/operation-lock.sh", "scripts/pr-lib/process-group-runner.mjs", diff --git a/test/scripts/pr-wrappers.test.ts b/test/scripts/pr-wrappers.test.ts index 7364e04d9271..800d422a6e46 100644 --- a/test/scripts/pr-wrappers.test.ts +++ b/test/scripts/pr-wrappers.test.ts @@ -87,6 +87,10 @@ function makeMismatchedWrapperRepo() { cpSync("scripts/lib/plain-gh.mjs", join(canonical, "scripts", "lib", "plain-gh.mjs")); cpSync("scripts/lib/direct-run.mjs", join(canonical, "scripts", "lib", "direct-run.mjs")); cpSync("scripts/lib/tsx-cli-shim.mjs", join(canonical, "scripts", "lib", "tsx-cli-shim.mjs")); + cpSync( + "scripts/lib/local-check-runtime.mts", + join(canonical, "scripts", "lib", "local-check-runtime.mts"), + ); writeFileSync( join(canonical, "scripts", "lib", "plain-gh.sh"), "resolve_plain_gh_bin() { printf '/usr/bin/true\\n'; }\ngh_plain() { :; }\n", @@ -438,6 +442,7 @@ describe("scripts/pr wrappers", () => { writeFileSync(join(repo, "scripts", "lib", "plain-gh.mjs"), "// canonical\n"); writeFileSync(join(repo, "scripts", "lib", "direct-run.mjs"), "// canonical\n"); writeFileSync(join(repo, "scripts", "lib", "tsx-cli-shim.mjs"), "// canonical\n"); + writeFileSync(join(repo, "scripts", "lib", "local-check-runtime.mts"), "// canonical\n"); writeFileSync(join(repo, "scripts", "watch-pr-ci.mjs"), "// canonical\n"); writeFileSync(join(repo, "scripts", "watch-pr-ci.mts"), "// canonical\n"); writeFileSync(join(repo, "scripts", "verify-pr-hosted-gates.mjs"), "// canonical\n"); @@ -454,36 +459,23 @@ describe("scripts/pr wrappers", () => { expect(git(repo, ["commit", "-m", "test: canonical wrapper"]).status).toBe(0); expect(git(repo, ["worktree", "add", "-b", "feature", linked]).status).toBe(0); - writeFileSync(join(linked, "scripts", "pr-lib", "merge.sh"), "# dirty linked\n"); - const dirtyLinkedResult = spawnSync(join(linked, "scripts", "pr"), ["ls"], { - cwd: linked, - encoding: "utf8", - }); - expect(dirtyLinkedResult.status).toBe(1); - expect(dirtyLinkedResult.stderr).toContain("scripts/pr wrapper files have uncommitted changes"); - expect(git(linked, ["restore", "scripts/pr-lib/merge.sh"]).status).toBe(0); - - writeFileSync(join(linked, "scripts", "watch-pr-ci.mts"), "// dirty watcher\n"); - const dirtyWatcherResult = spawnSync(join(linked, "scripts", "pr"), ["ls"], { - cwd: linked, - encoding: "utf8", - }); - expect(dirtyWatcherResult.status).toBe(1); - expect(dirtyWatcherResult.stderr).toContain( - "scripts/pr wrapper files have uncommitted changes", - ); - expect(git(linked, ["restore", "scripts/watch-pr-ci.mts"]).status).toBe(0); - - writeFileSync(join(linked, "scripts", "verify-pr-hosted-gates.mts"), "// dirty verifier\n"); - const dirtyVerifierResult = spawnSync(join(linked, "scripts", "pr"), ["ls"], { - cwd: linked, - encoding: "utf8", - }); - expect(dirtyVerifierResult.status).toBe(1); - expect(dirtyVerifierResult.stderr).toContain( - "scripts/pr wrapper files have uncommitted changes", - ); - expect(git(linked, ["restore", "scripts/verify-pr-hosted-gates.mts"]).status).toBe(0); + for (const component of [ + "scripts/pr-lib/merge.sh", + "scripts/watch-pr-ci.mts", + "scripts/verify-pr-hosted-gates.mts", + "scripts/lib/local-check-runtime.mts", + ]) { + writeFileSync(join(linked, component), "# dirty linked\n"); + const dirtyResult = spawnSync(join(linked, "scripts", "pr"), ["ls"], { + cwd: linked, + encoding: "utf8", + }); + expect(dirtyResult.status, component).toBe(1); + expect(dirtyResult.stderr, component).toContain( + "scripts/pr wrapper files have uncommitted changes", + ); + expect(git(linked, ["restore", component]).status).toBe(0); + } // A dirty canonical checkout no longer blocks a linked worktree whose // committed wrapper matches the origin/main trust anchor; without that @@ -499,8 +491,8 @@ describe("scripts/pr wrappers", () => { ); expect(git(repo, ["restore", "scripts/pr-lib/merge.sh"]).status).toBe(0); - writeFileSync(join(linked, "scripts", "pr-lib", "merge.sh"), "# linked\n"); - expect(git(linked, ["add", "scripts/pr-lib/merge.sh"]).status).toBe(0); + writeFileSync(join(linked, "scripts", "lib", "local-check-runtime.mts"), "// linked\n"); + expect(git(linked, ["add", "scripts/lib/local-check-runtime.mts"]).status).toBe(0); expect(git(linked, ["commit", "-m", "test: linked wrapper"]).status).toBe(0); const result = spawnSync(join(linked, "scripts", "pr"), ["ls"], { @@ -513,6 +505,7 @@ describe("scripts/pr wrappers", () => { expect(result.stderr).toContain( "scripts/pr implementation differs between this worktree and the canonical checkout", ); + expect(result.stderr).toContain("scripts/lib/local-check-runtime.mts"); }); it("runs the local wrapper when it matches origin/main and the canonical checkout is parked elsewhere", () => { @@ -526,6 +519,7 @@ describe("scripts/pr wrappers", () => { writeFileSync(join(repo, "scripts", "lib", "plain-gh.mjs"), "// canonical\n"); writeFileSync(join(repo, "scripts", "lib", "direct-run.mjs"), "// canonical\n"); writeFileSync(join(repo, "scripts", "lib", "tsx-cli-shim.mjs"), "// canonical\n"); + writeFileSync(join(repo, "scripts", "lib", "local-check-runtime.mts"), "// canonical\n"); writeFileSync(join(repo, "scripts", "watch-pr-ci.mjs"), "// canonical\n"); writeFileSync(join(repo, "scripts", "watch-pr-ci.mts"), "// canonical\n"); writeFileSync(join(repo, "scripts", "verify-pr-hosted-gates.mjs"), "// canonical\n"); diff --git a/test/scripts/release-preflight.test.ts b/test/scripts/release-preflight.test.ts index f882b563aeee..37015d654669 100644 --- a/test/scripts/release-preflight.test.ts +++ b/test/scripts/release-preflight.test.ts @@ -132,6 +132,7 @@ function makeIsolatedPreflightFixture(params: Parameters