diff --git a/src/agents/openclaw-tools.subagents.sessions-spawn.lifecycle.test.ts b/src/agents/openclaw-tools.subagents.sessions-spawn.lifecycle.test.ts index 1e522c043..d10be4b42 100644 --- a/src/agents/openclaw-tools.subagents.sessions-spawn.lifecycle.test.ts +++ b/src/agents/openclaw-tools.subagents.sessions-spawn.lifecycle.test.ts @@ -3,7 +3,9 @@ import { emitAgentEvent } from "../infra/agent-events.js"; import "./test-helpers/fast-core-tools.js"; import { getCallGatewayMock, + getSessionsSpawnTool, resetSessionsSpawnConfigOverride, + setupSessionsSpawnGatewayMock, setSessionsSpawnConfigOverride, } from "./openclaw-tools.subagents.sessions-spawn.test-harness.js"; import { resetSubagentRegistryForTests } from "./subagent-registry.js"; @@ -18,22 +20,6 @@ vi.mock("./pi-embedded.js", () => ({ const callGatewayMock = getCallGatewayMock(); const RUN_TIMEOUT_SECONDS = 1; -type CreateOpenClawTools = (typeof import("./openclaw-tools.js"))["createOpenClawTools"]; -type CreateOpenClawToolsOpts = Parameters[0]; - -async function getSessionsSpawnTool(opts: CreateOpenClawToolsOpts) { - // Dynamic import: ensure harness mocks are installed before tool modules load. - const { createOpenClawTools } = await import("./openclaw-tools.js"); - const tool = createOpenClawTools(opts).find((candidate) => candidate.name === "sessions_spawn"); - if (!tool) { - throw new Error("missing sessions_spawn tool"); - } - return tool; -} - -type GatewayRequest = { method?: string; params?: unknown }; -type AgentWaitCall = { runId?: string; timeoutMs?: number }; - function buildDiscordCleanupHooks(onDelete: (key: string | undefined) => void) { return { onAgentSubagentSpawn: (params: unknown) => { @@ -48,98 +34,6 @@ function buildDiscordCleanupHooks(onDelete: (key: string | undefined) => void) { }; } -function setupSessionsSpawnGatewayMock(opts: { - includeSessionsList?: boolean; - includeChatHistory?: boolean; - onAgentSubagentSpawn?: (params: unknown) => void; - onSessionsPatch?: (params: unknown) => void; - onSessionsDelete?: (params: unknown) => void; - agentWaitResult?: { status: "ok" | "timeout"; startedAt: number; endedAt: number }; -}): { - calls: Array; - waitCalls: Array; - getChild: () => { runId?: string; sessionKey?: string }; -} { - const calls: Array = []; - const waitCalls: Array = []; - let agentCallCount = 0; - let childRunId: string | undefined; - let childSessionKey: string | undefined; - - callGatewayMock.mockImplementation(async (optsUnknown: unknown) => { - const request = optsUnknown as GatewayRequest; - calls.push(request); - - if (request.method === "sessions.list" && opts.includeSessionsList) { - return { - sessions: [ - { - key: "main", - lastChannel: "whatsapp", - lastTo: "+123", - }, - ], - }; - } - - if (request.method === "agent") { - agentCallCount += 1; - const runId = `run-${agentCallCount}`; - const params = request.params as { lane?: string; sessionKey?: string } | undefined; - // Only capture the first agent call (subagent spawn, not main agent trigger) - if (params?.lane === "subagent") { - childRunId = runId; - childSessionKey = params?.sessionKey ?? ""; - opts.onAgentSubagentSpawn?.(params); - } - return { - runId, - status: "accepted", - acceptedAt: 1000 + agentCallCount, - }; - } - - if (request.method === "agent.wait") { - const params = request.params as AgentWaitCall | undefined; - waitCalls.push(params ?? {}); - const res = opts.agentWaitResult ?? { status: "ok", startedAt: 1000, endedAt: 2000 }; - return { - runId: params?.runId ?? "run-1", - ...res, - }; - } - - if (request.method === "sessions.patch") { - opts.onSessionsPatch?.(request.params); - return { ok: true }; - } - - if (request.method === "sessions.delete") { - opts.onSessionsDelete?.(request.params); - return { ok: true }; - } - - if (request.method === "chat.history" && opts.includeChatHistory) { - return { - messages: [ - { - role: "assistant", - content: [{ type: "text", text: "done" }], - }, - ], - }; - } - - return {}; - }); - - return { - calls, - waitCalls, - getChild: () => ({ runId: childRunId, sessionKey: childSessionKey }), - }; -} - const waitFor = async (predicate: () => boolean, timeoutMs = 1_500) => { await vi.waitFor( () => { diff --git a/src/agents/openclaw-tools.subagents.sessions-spawn.test-harness.ts b/src/agents/openclaw-tools.subagents.sessions-spawn.test-harness.ts index d13bf231f..6a50517eb 100644 --- a/src/agents/openclaw-tools.subagents.sessions-spawn.test-harness.ts +++ b/src/agents/openclaw-tools.subagents.sessions-spawn.test-harness.ts @@ -3,6 +3,16 @@ import { vi } from "vitest"; type SessionsSpawnTestConfig = ReturnType<(typeof import("../config/config.js"))["loadConfig"]>; type CreateOpenClawTools = (typeof import("./openclaw-tools.js"))["createOpenClawTools"]; export type CreateOpenClawToolsOpts = Parameters[0]; +export type GatewayRequest = { method?: string; params?: unknown }; +export type AgentWaitCall = { runId?: string; timeoutMs?: number }; +type SessionsSpawnGatewayMockOptions = { + includeSessionsList?: boolean; + includeChatHistory?: boolean; + onAgentSubagentSpawn?: (params: unknown) => void; + onSessionsPatch?: (params: unknown) => void; + onSessionsDelete?: (params: unknown) => void; + agentWaitResult?: { status: "ok" | "timeout"; startedAt: number; endedAt: number }; +}; // Avoid exporting vitest mock types (TS2742 under pnpm + d.ts emit). // oxlint-disable-next-line typescript/no-explicit-any @@ -24,6 +34,18 @@ export function getCallGatewayMock(): AnyMock { return hoisted.callGatewayMock; } +export function getGatewayRequests(): Array { + return getCallGatewayMock().mock.calls.map((call: [unknown]) => call[0] as GatewayRequest); +} + +export function getGatewayMethods(): Array { + return getGatewayRequests().map((request) => request.method); +} + +export function findGatewayRequest(method: string): GatewayRequest | undefined { + return getGatewayRequests().find((request) => request.method === method); +} + export function resetSessionsSpawnConfigOverride(): void { hoisted.state.configOverride = hoisted.defaultConfigOverride; } @@ -42,6 +64,95 @@ export async function getSessionsSpawnTool(opts: CreateOpenClawToolsOpts) { return tool; } +export function setupSessionsSpawnGatewayMock(setupOpts: SessionsSpawnGatewayMockOptions): { + calls: Array; + waitCalls: Array; + getChild: () => { runId?: string; sessionKey?: string }; +} { + const calls: Array = []; + const waitCalls: Array = []; + let agentCallCount = 0; + let childRunId: string | undefined; + let childSessionKey: string | undefined; + + getCallGatewayMock().mockImplementation(async (optsUnknown: unknown) => { + const request = optsUnknown as GatewayRequest; + calls.push(request); + + if (request.method === "sessions.list" && setupOpts.includeSessionsList) { + return { + sessions: [ + { + key: "main", + lastChannel: "whatsapp", + lastTo: "+123", + }, + ], + }; + } + + if (request.method === "agent") { + agentCallCount += 1; + const runId = `run-${agentCallCount}`; + const params = request.params as { lane?: string; sessionKey?: string } | undefined; + // Capture only the subagent run metadata. + if (params?.lane === "subagent") { + childRunId = runId; + childSessionKey = params.sessionKey ?? ""; + setupOpts.onAgentSubagentSpawn?.(params); + } + return { + runId, + status: "accepted", + acceptedAt: 1000 + agentCallCount, + }; + } + + if (request.method === "agent.wait") { + const params = request.params as AgentWaitCall | undefined; + waitCalls.push(params ?? {}); + const waitResult = setupOpts.agentWaitResult ?? { + status: "ok", + startedAt: 1000, + endedAt: 2000, + }; + return { + runId: params?.runId ?? "run-1", + ...waitResult, + }; + } + + if (request.method === "sessions.patch") { + setupOpts.onSessionsPatch?.(request.params); + return { ok: true }; + } + + if (request.method === "sessions.delete") { + setupOpts.onSessionsDelete?.(request.params); + return { ok: true }; + } + + if (request.method === "chat.history" && setupOpts.includeChatHistory) { + return { + messages: [ + { + role: "assistant", + content: [{ type: "text", text: "done" }], + }, + ], + }; + } + + return {}; + }); + + return { + calls, + waitCalls, + getChild: () => ({ runId: childRunId, sessionKey: childSessionKey }), + }; +} + vi.mock("../gateway/call.js", () => ({ callGateway: (opts: unknown) => hoisted.callGatewayMock(opts), })); diff --git a/src/agents/sessions-spawn-hooks.test.ts b/src/agents/sessions-spawn-hooks.test.ts index 4efa7caf6..0a8c82ca6 100644 --- a/src/agents/sessions-spawn-hooks.test.ts +++ b/src/agents/sessions-spawn-hooks.test.ts @@ -1,7 +1,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import "./test-helpers/fast-core-tools.js"; import { + findGatewayRequest, getCallGatewayMock, + getGatewayMethods, getSessionsSpawnTool, setSessionsSpawnConfigOverride, } from "./openclaw-tools.subagents.sessions-spawn.test-harness.js"; @@ -46,21 +48,6 @@ vi.mock("../plugins/hook-runner-global.js", () => ({ })), })); -type GatewayRequest = { method?: string; params?: Record }; - -function getGatewayRequests(): GatewayRequest[] { - const callGatewayMock = getCallGatewayMock(); - return callGatewayMock.mock.calls.map((call: [unknown]) => call[0] as GatewayRequest); -} - -function getGatewayMethods(): Array { - return getGatewayRequests().map((request) => request.method); -} - -function findGatewayRequest(method: string): GatewayRequest | undefined { - return getGatewayRequests().find((request) => request.method === method); -} - function expectSessionsDeleteWithoutAgentStart() { const methods = getGatewayMethods(); expect(methods).toContain("sessions.delete"); diff --git a/src/process/exec.test.ts b/src/process/exec.test.ts index f90769fa4..703d13a94 100644 --- a/src/process/exec.test.ts +++ b/src/process/exec.test.ts @@ -1,6 +1,11 @@ import { describe, expect, it } from "vitest"; import { withEnvAsync } from "../test-utils/env.js"; import { runCommandWithTimeout, shouldSpawnWithShell } from "./exec.js"; +import { + PROCESS_TEST_NO_OUTPUT_TIMEOUT_MS, + PROCESS_TEST_SCRIPT_DELAY_MS, + PROCESS_TEST_TIMEOUT_MS, +} from "./test-timeouts.js"; describe("runCommandWithTimeout", () => { it("never enables shell execution (Windows cmd.exe injection hardening)", () => { @@ -21,7 +26,7 @@ describe("runCommandWithTimeout", () => { 'process.stdout.write((process.env.OPENCLAW_BASE_ENV ?? "") + "|" + (process.env.OPENCLAW_TEST_ENV ?? ""))', ], { - timeoutMs: 5_000, + timeoutMs: PROCESS_TEST_TIMEOUT_MS.medium, env: { OPENCLAW_TEST_ENV: "ok" }, }, ); @@ -34,10 +39,14 @@ describe("runCommandWithTimeout", () => { it("kills command when no output timeout elapses", async () => { const result = await runCommandWithTimeout( - [process.execPath, "-e", "setTimeout(() => {}, 120)"], + [ + process.execPath, + "-e", + `setTimeout(() => {}, ${PROCESS_TEST_SCRIPT_DELAY_MS.silentProcess})`, + ], { - timeoutMs: 3_000, - noOutputTimeoutMs: 120, + timeoutMs: PROCESS_TEST_TIMEOUT_MS.standard, + noOutputTimeoutMs: PROCESS_TEST_NO_OUTPUT_TIMEOUT_MS.exec, }, ); @@ -51,11 +60,11 @@ describe("runCommandWithTimeout", () => { [ process.execPath, "-e", - 'process.stdout.write(".\\n"); const interval = setInterval(() => process.stdout.write(".\\n"), 1800); setTimeout(() => { clearInterval(interval); process.exit(0); }, 9000);', + `process.stdout.write(".\\n"); const interval = setInterval(() => process.stdout.write(".\\n"), ${PROCESS_TEST_SCRIPT_DELAY_MS.streamingInterval}); setTimeout(() => { clearInterval(interval); process.exit(0); }, ${PROCESS_TEST_SCRIPT_DELAY_MS.streamingDuration});`, ], { - timeoutMs: 15_000, - noOutputTimeoutMs: 6_000, + timeoutMs: PROCESS_TEST_TIMEOUT_MS.extraLong, + noOutputTimeoutMs: PROCESS_TEST_NO_OUTPUT_TIMEOUT_MS.streamingAllowance, }, ); @@ -68,9 +77,13 @@ describe("runCommandWithTimeout", () => { it("reports global timeout termination when overall timeout elapses", async () => { const result = await runCommandWithTimeout( - [process.execPath, "-e", "setTimeout(() => {}, 120)"], + [ + process.execPath, + "-e", + `setTimeout(() => {}, ${PROCESS_TEST_SCRIPT_DELAY_MS.silentProcess})`, + ], { - timeoutMs: 100, + timeoutMs: PROCESS_TEST_TIMEOUT_MS.short, }, ); diff --git a/src/process/supervisor/supervisor.test.ts b/src/process/supervisor/supervisor.test.ts index 194af43f7..825832b25 100644 --- a/src/process/supervisor/supervisor.test.ts +++ b/src/process/supervisor/supervisor.test.ts @@ -1,4 +1,9 @@ import { describe, expect, it } from "vitest"; +import { + PROCESS_TEST_NO_OUTPUT_TIMEOUT_MS, + PROCESS_TEST_SCRIPT_DELAY_MS, + PROCESS_TEST_TIMEOUT_MS, +} from "../test-timeouts.js"; import { createProcessSupervisor } from "./supervisor.js"; describe("process supervisor", () => { @@ -9,7 +14,7 @@ describe("process supervisor", () => { backendId: "test", mode: "child", argv: [process.execPath, "-e", 'process.stdout.write("ok")'], - timeoutMs: 10_000, + timeoutMs: PROCESS_TEST_TIMEOUT_MS.long, stdinMode: "pipe-closed", }); const exit = await run.wait(); @@ -24,9 +29,13 @@ describe("process supervisor", () => { sessionId: "s1", backendId: "test", mode: "child", - argv: [process.execPath, "-e", "setTimeout(() => {}, 120)"], - timeoutMs: 3_000, - noOutputTimeoutMs: 100, + argv: [ + process.execPath, + "-e", + `setTimeout(() => {}, ${PROCESS_TEST_SCRIPT_DELAY_MS.silentProcess})`, + ], + timeoutMs: PROCESS_TEST_TIMEOUT_MS.standard, + noOutputTimeoutMs: PROCESS_TEST_NO_OUTPUT_TIMEOUT_MS.supervisor, stdinMode: "pipe-closed", }); const exit = await run.wait(); @@ -42,8 +51,12 @@ describe("process supervisor", () => { backendId: "test", scopeKey: "scope:a", mode: "child", - argv: [process.execPath, "-e", "setTimeout(() => {}, 120)"], - timeoutMs: 3_000, + argv: [ + process.execPath, + "-e", + `setTimeout(() => {}, ${PROCESS_TEST_SCRIPT_DELAY_MS.silentProcess})`, + ], + timeoutMs: PROCESS_TEST_TIMEOUT_MS.standard, stdinMode: "pipe-open", }); @@ -54,7 +67,7 @@ describe("process supervisor", () => { replaceExistingScope: true, mode: "child", argv: [process.execPath, "-e", 'process.stdout.write("new")'], - timeoutMs: 10_000, + timeoutMs: PROCESS_TEST_TIMEOUT_MS.long, stdinMode: "pipe-closed", }); @@ -71,8 +84,12 @@ describe("process supervisor", () => { sessionId: "s-timeout", backendId: "test", mode: "child", - argv: [process.execPath, "-e", "setTimeout(() => {}, 120)"], - timeoutMs: 25, + argv: [ + process.execPath, + "-e", + `setTimeout(() => {}, ${PROCESS_TEST_SCRIPT_DELAY_MS.silentProcess})`, + ], + timeoutMs: PROCESS_TEST_TIMEOUT_MS.tiny, stdinMode: "pipe-closed", }); const exit = await run.wait(); @@ -88,7 +105,7 @@ describe("process supervisor", () => { backendId: "test", mode: "child", argv: [process.execPath, "-e", 'process.stdout.write("streamed")'], - timeoutMs: 10_000, + timeoutMs: PROCESS_TEST_TIMEOUT_MS.long, stdinMode: "pipe-closed", captureOutput: false, onStdout: (chunk) => { diff --git a/src/process/test-timeouts.ts b/src/process/test-timeouts.ts new file mode 100644 index 000000000..d1721d5bf --- /dev/null +++ b/src/process/test-timeouts.ts @@ -0,0 +1,20 @@ +export const PROCESS_TEST_TIMEOUT_MS = { + tiny: 25, + short: 100, + standard: 3_000, + medium: 5_000, + long: 10_000, + extraLong: 15_000, +} as const; + +export const PROCESS_TEST_SCRIPT_DELAY_MS = { + silentProcess: 120, + streamingInterval: 1_800, + streamingDuration: 9_000, +} as const; + +export const PROCESS_TEST_NO_OUTPUT_TIMEOUT_MS = { + exec: 120, + supervisor: 100, + streamingAllowance: 6_000, +} as const; diff --git a/src/security/temp-path-guard.test.ts b/src/security/temp-path-guard.test.ts index dbff38b50..05dfb9d9d 100644 --- a/src/security/temp-path-guard.test.ts +++ b/src/security/temp-path-guard.test.ts @@ -2,8 +2,9 @@ import fs from "node:fs/promises"; import path from "node:path"; import ts from "typescript"; import { describe, expect, it } from "vitest"; +import { listRepoFiles } from "../test-utils/repo-scan.js"; -const RUNTIME_ROOTS = ["src", "extensions"]; +const RUNTIME_ROOTS = ["src", "extensions"] as const; const SKIP_PATTERNS = [ /\.test\.tsx?$/, /\.test-helpers\.tsx?$/, @@ -83,28 +84,6 @@ function hasDynamicTmpdirJoin(source: string, filePath = "fixture.ts"): boolean return found; } -async function listTsFiles(dir: string): Promise { - const entries = await fs.readdir(dir, { withFileTypes: true }); - const out: string[] = []; - for (const entry of entries) { - if (entry.name === "node_modules" || entry.name === "dist" || entry.name.startsWith(".")) { - continue; - } - const fullPath = path.join(dir, entry.name); - if (entry.isDirectory()) { - out.push(...(await listTsFiles(fullPath))); - continue; - } - if (!entry.isFile()) { - continue; - } - if (fullPath.endsWith(".ts") || fullPath.endsWith(".tsx")) { - out.push(fullPath); - } - } - return out; -} - describe("temp path guard", () => { it("skips test helper filename variants", () => { expect(shouldSkip("src/commands/test-helpers.ts")).toBe(true); @@ -138,21 +117,22 @@ describe("temp path guard", () => { const repoRoot = process.cwd(); const offenders: string[] = []; - for (const root of RUNTIME_ROOTS) { - const absRoot = path.join(repoRoot, root); - const files = await listTsFiles(absRoot); - for (const file of files) { - const relativePath = path.relative(repoRoot, file); - if (shouldSkip(relativePath)) { - continue; - } - const source = await fs.readFile(file, "utf-8"); - if (!QUICK_TMPDIR_JOIN_PATTERN.test(source)) { - continue; - } - if (hasDynamicTmpdirJoin(source, relativePath)) { - offenders.push(relativePath); - } + const files = await listRepoFiles(repoRoot, { + roots: RUNTIME_ROOTS, + extensions: [".ts", ".tsx"], + skipHiddenDirectories: true, + }); + for (const file of files) { + const relativePath = path.relative(repoRoot, file); + if (shouldSkip(relativePath)) { + continue; + } + const source = await fs.readFile(file, "utf-8"); + if (!QUICK_TMPDIR_JOIN_PATTERN.test(source)) { + continue; + } + if (hasDynamicTmpdirJoin(source, relativePath)) { + offenders.push(relativePath); } } diff --git a/src/security/weak-random-patterns.test.ts b/src/security/weak-random-patterns.test.ts index fa1d0b342..fca78a76a 100644 --- a/src/security/weak-random-patterns.test.ts +++ b/src/security/weak-random-patterns.test.ts @@ -1,68 +1,38 @@ -import fs from "node:fs"; +import fs from "node:fs/promises"; import path from "node:path"; import { describe, expect, it } from "vitest"; +import { listRepoFiles } from "../test-utils/repo-scan.js"; const SCAN_ROOTS = ["src", "extensions"] as const; -const SKIP_DIRS = new Set([".git", "dist", "node_modules"]); -function collectTypeScriptFiles(rootDir: string): string[] { - const out: string[] = []; - const stack = [rootDir]; - while (stack.length > 0) { - const current = stack.pop(); - if (!current) { - continue; - } - for (const entry of fs.readdirSync(current, { withFileTypes: true })) { - const fullPath = path.join(current, entry.name); - if (entry.isDirectory()) { - if (!SKIP_DIRS.has(entry.name)) { - stack.push(fullPath); - } - continue; - } - if (!entry.isFile()) { - continue; - } - if ( - !entry.name.endsWith(".ts") || - entry.name.endsWith(".test.ts") || - entry.name.endsWith(".d.ts") - ) { - continue; - } - out.push(fullPath); - } - } - return out; +function isRuntimeTypeScriptFile(relativePath: string): boolean { + return !relativePath.endsWith(".test.ts") && !relativePath.endsWith(".d.ts"); } -function findWeakRandomPatternMatches(repoRoot: string): string[] { +async function findWeakRandomPatternMatches(repoRoot: string): Promise { const matches: string[] = []; - for (const scanRoot of SCAN_ROOTS) { - const root = path.join(repoRoot, scanRoot); - if (!fs.existsSync(root)) { - continue; - } - const files = collectTypeScriptFiles(root); - for (const filePath of files) { - const lines = fs.readFileSync(filePath, "utf8").split(/\r?\n/); - for (let idx = 0; idx < lines.length; idx += 1) { - const line = lines[idx] ?? ""; - if (!line.includes("Date.now") || !line.includes("Math.random")) { - continue; - } - matches.push(`${path.relative(repoRoot, filePath)}:${idx + 1}`); + const files = await listRepoFiles(repoRoot, { + roots: SCAN_ROOTS, + extensions: [".ts"], + shouldIncludeFile: isRuntimeTypeScriptFile, + }); + for (const filePath of files) { + const lines = (await fs.readFile(filePath, "utf8")).split(/\r?\n/); + for (let idx = 0; idx < lines.length; idx += 1) { + const line = lines[idx] ?? ""; + if (!line.includes("Date.now") || !line.includes("Math.random")) { + continue; } + matches.push(`${path.relative(repoRoot, filePath)}:${idx + 1}`); } } return matches; } describe("weak random pattern guardrail", () => { - it("rejects Date.now + Math.random token/id patterns in runtime code", () => { + it("rejects Date.now + Math.random token/id patterns in runtime code", async () => { const repoRoot = path.resolve(process.cwd()); - const matches = findWeakRandomPatternMatches(repoRoot); + const matches = await findWeakRandomPatternMatches(repoRoot); expect(matches).toEqual([]); }); }); diff --git a/src/test-utils/repo-scan.ts b/src/test-utils/repo-scan.ts new file mode 100644 index 000000000..c01509ea6 --- /dev/null +++ b/src/test-utils/repo-scan.ts @@ -0,0 +1,78 @@ +import fs from "node:fs/promises"; +import path from "node:path"; + +export const DEFAULT_REPO_SCAN_SKIP_DIR_NAMES = new Set([".git", "dist", "node_modules"]); + +export type RepoFileScanOptions = { + roots: readonly string[]; + extensions: readonly string[]; + skipDirNames?: ReadonlySet; + skipHiddenDirectories?: boolean; + shouldIncludeFile?: (relativePath: string) => boolean; +}; + +type PendingDir = { + absolutePath: string; +}; + +function shouldSkipDirectory( + name: string, + options: Pick, +): boolean { + if (options.skipHiddenDirectories && name.startsWith(".")) { + return true; + } + return (options.skipDirNames ?? DEFAULT_REPO_SCAN_SKIP_DIR_NAMES).has(name); +} + +function hasAllowedExtension(fileName: string, extensions: readonly string[]): boolean { + return extensions.some((extension) => fileName.endsWith(extension)); +} + +export async function listRepoFiles( + repoRoot: string, + options: RepoFileScanOptions, +): Promise> { + const files: Array = []; + const pending: Array = []; + + for (const root of options.roots) { + const absolutePath = path.join(repoRoot, root); + try { + const stats = await fs.stat(absolutePath); + if (stats.isDirectory()) { + pending.push({ absolutePath }); + } + } catch { + // Skip missing roots. Useful when extensions/ is absent. + } + } + + while (pending.length > 0) { + const current = pending.pop(); + if (!current) { + continue; + } + const entries = await fs.readdir(current.absolutePath, { withFileTypes: true }); + for (const entry of entries) { + if (entry.isDirectory()) { + if (!shouldSkipDirectory(entry.name, options)) { + pending.push({ absolutePath: path.join(current.absolutePath, entry.name) }); + } + continue; + } + if (!entry.isFile() || !hasAllowedExtension(entry.name, options.extensions)) { + continue; + } + const filePath = path.join(current.absolutePath, entry.name); + const relativePath = path.relative(repoRoot, filePath); + if (options.shouldIncludeFile && !options.shouldIncludeFile(relativePath)) { + continue; + } + files.push(filePath); + } + } + + files.sort((a, b) => a.localeCompare(b)); + return files; +}