diff --git a/src/plugins/compat/registry.test.ts b/src/plugins/compat/registry.test.ts index 3683525d301e..86f21bf8766a 100644 --- a/src/plugins/compat/registry.test.ts +++ b/src/plugins/compat/registry.test.ts @@ -57,9 +57,11 @@ function expectNonEmptyStringList(values: readonly string[], label: string) { } function listTrackedSourceFiles(): string[] { - return (listGitTrackedFiles({ pathspecs: sourceRootsForDeprecatedCallGuard }) ?? []).filter( - (file) => /\.(?:ts|tsx|mts|cts)$/u.test(file), - ); + const files = listGitTrackedFiles({ pathspecs: sourceRootsForDeprecatedCallGuard }); + if (!files) { + throw new Error("unable to list tracked source files for the deprecated-call guard"); + } + return files.filter((file) => /\.(?:ts|tsx|mts|cts)$/u.test(file)); } describe("plugin compatibility registry", () => { diff --git a/src/plugins/contracts/config-footprint-guardrails.test.ts b/src/plugins/contracts/config-footprint-guardrails.test.ts index e0f6ecc0c534..87400f2999ed 100644 --- a/src/plugins/contracts/config-footprint-guardrails.test.ts +++ b/src/plugins/contracts/config-footprint-guardrails.test.ts @@ -257,7 +257,10 @@ describe("config footprint guardrails", () => { ]); const facadeImportPattern = /\bfrom\s*["'][^"']*(?:channel-config-primitives|bundled-channel-config-schema)(?:\.js)?["']/u; - const files = listGitTrackedFiles({ repoRoot: REPO_ROOT, pathspecs: "src" }) ?? []; + const files = listGitTrackedFiles({ repoRoot: REPO_ROOT, pathspecs: "src" }); + if (!files) { + throw new Error("unable to list tracked source files for the config facade guard"); + } const offenders = files.filter((file) => { if (!(file.endsWith(".ts") || file.endsWith(".tsx")) || allowedShellImporters.has(file)) { diff --git a/src/plugins/contracts/model-catalog-core-imports.test.ts b/src/plugins/contracts/model-catalog-core-imports.test.ts index 02847d1c0b49..9925531bd0c4 100644 --- a/src/plugins/contracts/model-catalog-core-imports.test.ts +++ b/src/plugins/contracts/model-catalog-core-imports.test.ts @@ -34,12 +34,14 @@ const LEGACY_MODEL_CATALOG_BRIDGES = new Map([ ]); function listSourceFiles(): string[] { - return ( - listGitTrackedFiles({ - repoRoot: REPO_ROOT, - pathspecs: ["src", "extensions", "packages", "test"], - }) ?? [] - ) + const files = listGitTrackedFiles({ + repoRoot: REPO_ROOT, + pathspecs: ["src", "extensions", "packages", "test"], + }); + if (!files) { + throw new Error("unable to list tracked source files for the model-catalog import guard"); + } + return files .filter((file) => /\.(?:[cm]?ts|tsx|mts|cts)$/u.test(file)) .filter((file) => fs.existsSync(path.join(REPO_ROOT, file))); } diff --git a/src/plugins/contracts/plugin-sdk-package-contract-guardrails.test.ts b/src/plugins/contracts/plugin-sdk-package-contract-guardrails.test.ts index 4095f5b116ce..fe600df1c353 100644 --- a/src/plugins/contracts/plugin-sdk-package-contract-guardrails.test.ts +++ b/src/plugins/contracts/plugin-sdk-package-contract-guardrails.test.ts @@ -465,11 +465,13 @@ function collectNewDeprecatedMemoryEmbeddingProviderApiFiles(): string[] { function collectNewDeprecatedMemoryEmbeddingProviderManifestFiles(): string[] { const files: string[] = []; - const manifestFiles = - listGitTrackedFiles({ - repoRoot: REPO_ROOT, - pathspecs: "extensions/**/openclaw.plugin.json", - }) ?? []; + const manifestFiles = listGitTrackedFiles({ + repoRoot: REPO_ROOT, + pathspecs: "extensions/**/openclaw.plugin.json", + }); + if (!manifestFiles) { + throw new Error("unable to list plugin manifests for the deprecated manifest guard"); + } for (const repoRelativePath of manifestFiles) { const source = fs.readFileSync(resolve(REPO_ROOT, repoRelativePath), "utf8"); if ( diff --git a/src/test-utils/repo-files.test.ts b/src/test-utils/repo-files.test.ts new file mode 100644 index 000000000000..aa18c35cf59b --- /dev/null +++ b/src/test-utils/repo-files.test.ts @@ -0,0 +1,56 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { withTempDir } from "./temp-dir.js"; + +const spawnSyncMock = vi.hoisted(() => vi.fn()); + +vi.mock("node:child_process", () => ({ + spawnSync: spawnSyncMock, +})); + +import { listGitTrackedFiles } from "./repo-files.js"; + +describe("listGitTrackedFiles", () => { + beforeEach(() => { + spawnSyncMock.mockReset(); + }); + + it("bounds Git inventory lookup and returns sorted existing files", async () => { + await withTempDir("openclaw-repo-files-", async (repoRoot) => { + await fs.writeFile(path.join(repoRoot, "z.ts"), ""); + await fs.writeFile(path.join(repoRoot, "a.ts"), ""); + spawnSyncMock.mockReturnValue({ + status: 0, + stdout: "z.ts\nmissing.ts\na.ts\n", + }); + + expect(listGitTrackedFiles({ repoRoot, pathspecs: "src" })).toEqual(["a.ts", "z.ts"]); + expect(spawnSyncMock).toHaveBeenCalledWith( + "git", + ["ls-files", "--", "src"], + expect.objectContaining({ + cwd: repoRoot, + killSignal: "SIGKILL", + timeout: 5_000, + }), + ); + }); + }); + + it("caches a timed-out inventory lookup as unavailable", async () => { + await withTempDir("openclaw-repo-files-timeout-", async (repoRoot) => { + spawnSyncMock.mockReturnValue({ + error: Object.assign(new Error("spawnSync git ETIMEDOUT"), { code: "ETIMEDOUT" }), + signal: "SIGKILL", + status: null, + stdout: "", + }); + const params = { repoRoot, pathspecs: ["src", "extensions"] }; + + expect(listGitTrackedFiles(params)).toBeNull(); + expect(listGitTrackedFiles(params)).toBeNull(); + expect(spawnSyncMock).toHaveBeenCalledOnce(); + }); + }); +}); diff --git a/src/test-utils/repo-files.ts b/src/test-utils/repo-files.ts index 10d79e69c5a7..9c5d50b1d467 100644 --- a/src/test-utils/repo-files.ts +++ b/src/test-utils/repo-files.ts @@ -3,6 +3,7 @@ import { spawnSync } from "node:child_process"; import fs from "node:fs"; import path from "node:path"; +const GIT_LS_FILES_TIMEOUT_MS = 5_000; const gitTrackedFilesCache = new Map(); function filterExistingRepoFiles(repoRoot: string, files: readonly string[]): string[] { @@ -36,8 +37,11 @@ export function listGitTrackedFiles(params: { const result = spawnSync("git", ["ls-files", "--", ...pathspecs], { cwd: repoRoot, encoding: "utf8", + // Bound repository scans; SIGKILL avoids waiting on a hung Git process after timeout. + killSignal: "SIGKILL", maxBuffer: 16 * 1024 * 1024, stdio: ["ignore", "pipe", "ignore"], + timeout: GIT_LS_FILES_TIMEOUT_MS, }); if (result.status !== 0) { gitTrackedFilesCache.set(cacheKey, null);