diff --git a/scripts/lib/tsgo-sparse-guard.mjs b/scripts/lib/tsgo-sparse-guard.mjs new file mode 100644 index 00000000000..8e9feae391c --- /dev/null +++ b/scripts/lib/tsgo-sparse-guard.mjs @@ -0,0 +1,88 @@ +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; + +const CORE_TEST_CONFIGS = new Set([ + "tsconfig.core.test.json", + "tsconfig.core.test.agents.json", + "tsconfig.core.test.non-agents.json", +]); + +const CORE_TEST_REQUIRED_PATHS = [ + "packages/plugin-package-contract/src/index.ts", + "ui/src/i18n/lib/registry.ts", + "ui/src/i18n/lib/types.ts", + "ui/src/ui/app-settings.ts", + "ui/src/ui/gateway.ts", +]; + +export function getSparseTsgoGuardError( + args, + { cwd = process.cwd(), fileExists = fs.existsSync, isSparseCheckoutEnabled } = {}, +) { + const projectPath = readProjectFlag(args); + if ( + !projectPath || + !CORE_TEST_CONFIGS.has(path.basename(projectPath)) || + isMetadataOnlyCommand(args) + ) { + return null; + } + + const sparseEnabled = + isSparseCheckoutEnabled?.({ cwd }) ?? getGitBooleanConfig("core.sparseCheckout", { cwd }); + if (!sparseEnabled) { + return null; + } + + const missingPaths = CORE_TEST_REQUIRED_PATHS.filter( + (relativePath) => !fileExists(path.join(cwd, relativePath)), + ); + if (missingPaths.length === 0) { + return null; + } + + return [ + `${path.basename(projectPath)} requires a full worktree, but this checkout is sparse and missing files that the core test graph imports:`, + ...missingPaths.map((relativePath) => `- ${relativePath}`), + 'Run "gwt sparse full" in this worktree, then rerun the tsgo command.', + ].join("\n"); +} + +function getGitBooleanConfig(name, { cwd }) { + const result = spawnSync("git", ["config", "--get", "--bool", name], { + cwd, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + shell: process.platform === "win32", + }); + + if (result.error || (result.status ?? 1) !== 0) { + return false; + } + + return (result.stdout ?? "").trim() === "true"; +} + +function readProjectFlag(args) { + return readFlagValue(args, "-p") ?? readFlagValue(args, "--project"); +} + +function isMetadataOnlyCommand(args) { + return args.some((arg) => + ["--help", "-h", "--version", "-v", "--init", "--showConfig"].includes(arg), + ); +} + +function readFlagValue(args, name) { + for (let index = 0; index < args.length; index++) { + const arg = args[index]; + if (arg === name) { + return args[index + 1]; + } + if (arg.startsWith(`${name}=`)) { + return arg.slice(name.length + 1); + } + } + return undefined; +} diff --git a/scripts/run-tsgo.mjs b/scripts/run-tsgo.mjs index 9663eefb769..1f3b7dbbe70 100644 --- a/scripts/run-tsgo.mjs +++ b/scripts/run-tsgo.mjs @@ -6,6 +6,7 @@ import { applyLocalTsgoPolicy, shouldAcquireLocalHeavyCheckLockForTsgo, } from "./lib/local-heavy-check-runtime.mjs"; +import { getSparseTsgoGuardError } from "./lib/tsgo-sparse-guard.mjs"; const { args: finalArgs, env } = applyLocalTsgoPolicy(process.argv.slice(2), process.env); @@ -14,26 +15,33 @@ const tsBuildInfoFile = readFlagValue(finalArgs, "--tsBuildInfoFile"); if (tsBuildInfoFile) { fs.mkdirSync(path.dirname(path.resolve(tsBuildInfoFile)), { recursive: true }); } -const releaseLock = shouldAcquireLocalHeavyCheckLockForTsgo(finalArgs, env) - ? acquireLocalHeavyCheckLockSync({ - cwd: process.cwd(), - env, - toolName: "tsgo", - }) - : () => {}; +const sparseGuardError = getSparseTsgoGuardError(finalArgs, { cwd: process.cwd() }); +const releaseLock = + sparseGuardError || !shouldAcquireLocalHeavyCheckLockForTsgo(finalArgs, env) + ? () => {} + : acquireLocalHeavyCheckLockSync({ + cwd: process.cwd(), + env, + toolName: "tsgo", + }); try { - const result = spawnSync(tsgoPath, finalArgs, { - stdio: "inherit", - env, - shell: process.platform === "win32", - }); + if (sparseGuardError) { + console.error(sparseGuardError); + process.exitCode = 1; + } else { + const result = spawnSync(tsgoPath, finalArgs, { + stdio: "inherit", + env, + shell: process.platform === "win32", + }); - if (result.error) { - throw result.error; + if (result.error) { + throw result.error; + } + + process.exitCode = result.status ?? 1; } - - process.exitCode = result.status ?? 1; } finally { releaseLock(); } diff --git a/test/scripts/run-tsgo.test.ts b/test/scripts/run-tsgo.test.ts new file mode 100644 index 00000000000..9c1b023ce52 --- /dev/null +++ b/test/scripts/run-tsgo.test.ts @@ -0,0 +1,86 @@ +import fs from "node:fs"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { getSparseTsgoGuardError } from "../../scripts/lib/tsgo-sparse-guard.mjs"; +import { createScriptTestHarness } from "./test-helpers.js"; + +const { createTempDir } = createScriptTestHarness(); + +describe("run-tsgo sparse guard", () => { + it("ignores non-core-test projects", () => { + const cwd = createTempDir("openclaw-run-tsgo-"); + + expect( + getSparseTsgoGuardError(["-p", "tsconfig.core.json"], { + cwd, + isSparseCheckoutEnabled: () => true, + }), + ).toBeNull(); + }); + + it("ignores full worktrees", () => { + const cwd = createTempDir("openclaw-run-tsgo-"); + + expect( + getSparseTsgoGuardError(["-p", "tsconfig.core.test.json"], { + cwd, + isSparseCheckoutEnabled: () => false, + }), + ).toBeNull(); + }); + + it("ignores metadata-only commands", () => { + const cwd = createTempDir("openclaw-run-tsgo-"); + + expect( + getSparseTsgoGuardError(["-p", "tsconfig.core.test.json", "--showConfig"], { + cwd, + isSparseCheckoutEnabled: () => true, + }), + ).toBeNull(); + }); + + it("ignores sparse worktrees when the required files are present", () => { + const cwd = createTempDir("openclaw-run-tsgo-"); + const requiredPaths = [ + "packages/plugin-package-contract/src/index.ts", + "ui/src/i18n/lib/registry.ts", + "ui/src/i18n/lib/types.ts", + "ui/src/ui/app-settings.ts", + "ui/src/ui/gateway.ts", + ]; + + for (const relativePath of requiredPaths) { + const absolutePath = path.join(cwd, relativePath); + const dir = path.dirname(absolutePath); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(absolutePath, "", "utf8"); + } + + expect( + getSparseTsgoGuardError(["-p", "tsconfig.core.test.non-agents.json"], { + cwd, + isSparseCheckoutEnabled: () => true, + }), + ).toBeNull(); + }); + + it("returns a helpful message for sparse core-test worktrees missing ui and packages files", () => { + const cwd = createTempDir("openclaw-run-tsgo-"); + + expect( + getSparseTsgoGuardError(["-p", "tsconfig.core.test.json"], { + cwd, + isSparseCheckoutEnabled: () => true, + }), + ).toMatchInlineSnapshot(` + "tsconfig.core.test.json requires a full worktree, but this checkout is sparse and missing files that the core test graph imports: + - packages/plugin-package-contract/src/index.ts + - ui/src/i18n/lib/registry.ts + - ui/src/i18n/lib/types.ts + - ui/src/ui/app-settings.ts + - ui/src/ui/gateway.ts + Run "gwt sparse full" in this worktree, then rerun the tsgo command." + `); + }); +});