import * as fsp from 'node:fs/promises';
import * as path from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { Worker } from 'node:worker_threads';
import { z } from 'zod';
import RE2 from 're2';
import safeRegex from 'safe-regex2';
import type { ContentMatch, SearchContentResult } from '../../config.js';
import {
DEFAULT_EXCLUDE_PATTERNS,
DEFAULT_SEARCH_MAX_FILES,
DEFAULT_SEARCH_TIMEOUT_MS,
MAX_LINE_CONTENT_LENGTH,
MAX_SEARCHABLE_FILE_SIZE,
SEARCH_WORKERS,
} from '../constants.js';
import { ErrorCode, McpError } from '../errors.js';
import {
assertNotAborted,
createTimedAbortSignal,
isProbablyBinary,
withAbort,
} from '../fs-helpers.js';
import { assertAllowedFileAccess, isSensitivePath } from '../path-policy.js';
import {
isPathWithinDirectories,
normalizePath,
validateExistingDirectory,
validateExistingPathDetailed,
} from '../path-validation.js';
import { globEntries } from './glob-engine.js';
// --- Configuration & Schemas ---
const INTERNAL_MAX_RESULTS = 500;
export const MatcherOptionsSchema = z.strictObject({
caseSensitive: z.boolean(),
wholeWord: z.boolean(),
isLiteral: z.boolean(),
});
export const ScanFileOptionsSchema = z.strictObject({
maxFileSize: z.number().int().nonnegative(),
skipBinary: z.boolean(),
contextLines: z.number().int().nonnegative(),
});
export type MatcherOptions = z.infer<typeof MatcherOptionsSchema>;
export type ScanFileOptions = z.infer<typeof ScanFileOptionsSchema>;
const SearchOptionsSchema = z.strictObject({
filePattern: z.string().min(1),
excludePatterns: z.array(z.string()),
caseSensitive: z.boolean(),
maxResults: z.number().int().nonnegative(),
maxFileSize: z.number().int().nonnegative(),
maxFilesScanned: z.number().int().nonnegative(),
timeoutMs: z.number().int().nonnegative(),
skipBinary: z.boolean(),
contextLines: z.number().int().nonnegative(),
wholeWord: z.boolean(),
isLiteral: z.boolean(),
includeHidden: z.boolean(),
baseNameMatch: z.boolean(),
caseSensitiveFileMatch: z.boolean(),
});
type ResolvedOptions = z.infer<typeof SearchOptionsSchema>;
export interface SearchContentOptions extends Partial<ResolvedOptions> {
signal?: AbortSignal;
onProgress?: (progress: { total?: number; current: number }) => void;
}
const DEFAULTS: ResolvedOptions = {
filePattern: '**/*',
excludePatterns: DEFAULT_EXCLUDE_PATTERNS,
caseSensitive: false,
maxResults: INTERNAL_MAX_RESULTS,
maxFileSize: MAX_SEARCHABLE_FILE_SIZE,
maxFilesScanned: DEFAULT_SEARCH_MAX_FILES,
timeoutMs: DEFAULT_SEARCH_TIMEOUT_MS,
skipBinary: true,
contextLines: 0,
wholeWord: false,
isLiteral: true,
includeHidden: false,
baseNameMatch: false,
caseSensitiveFileMatch: true,
};
const ERROR_SCAN_CANCELLED = 'Scan cancelled';
const ERROR_WORKER_POOL_CLOSED = 'Worker pool closed';
// --- Helpers ---
function resolveOptions(options: SearchContentOptions): ResolvedOptions {
const rest = { ...options };
delete rest.signal;
delete rest.onProgress;
const merged = { ...DEFAULTS, ...rest };
const result = SearchOptionsSchema.safeParse(merged);
if (!result.success) {
throw new McpError(
ErrorCode.E_INVALID_INPUT,
`Invalid search options: ${result.error.message}`,
undefined,
{ errors: z.treeifyError(result.error) }
);
}
return result.data;
}
// --- Matcher Logic ---
export type Matcher = (line: string) => number;
function escapeLiteral(pattern: string): string {
return pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function buildRegexPattern(pattern: string, options: MatcherOptions): string {
const escaped = options.isLiteral ? escapeLiteral(pattern) : pattern;
return options.wholeWord ? `\\b${escaped}\\b` : escaped;
}
function validatePattern(pattern: string, options: MatcherOptions): void {
if (options.isLiteral && pattern.length === 0) return;
if (options.isLiteral && !options.wholeWord) return;
const final = buildRegexPattern(pattern, options);
if (!safeRegex(final)) {
throw new Error(
`Potentially unsafe regular expression (ReDoS risk): ${pattern}`
);
}
}
function buildLiteralMatcher(
pattern: string,
options: MatcherOptions
): Matcher {
const needle = options.caseSensitive ? pattern : pattern.toLowerCase();
if (needle.length === 0) return () => 0;
return (line: string): number => {
const hay = options.caseSensitive ? line : line.toLowerCase();
if (hay.length === 0) return 0;
let count = 0;
let pos = hay.indexOf(needle);
while (pos !== -1) {
count++;
pos = hay.indexOf(needle, pos + needle.length);
}
return count;
};
}
function buildRegexMatcher(final: string, caseSensitive: boolean): Matcher {
const regex = new RE2(final, caseSensitive ? 'g' : 'gi');
return (line: string): number => {
regex.lastIndex = 0;
let count = 0;
while (regex.exec(line) !== null) {
count++;
if (regex.lastIndex === 0) regex.lastIndex++; // Avoid infinite loop on zero-width match
}
return count;
};
}
export function buildMatcher(
pattern: string,
options: MatcherOptions
): Matcher {
if (options.isLiteral && pattern.length === 0) return () => 0;
if (options.isLiteral && !options.wholeWord) {
// fast path for simple literal search
return buildLiteralMatcher(pattern, options);
}
const final = buildRegexPattern(pattern, options);
validatePattern(pattern, options); // Re-validate to be safe
return buildRegexMatcher(final, options.caseSensitive);
}
// --- Context Management ---
interface PendingContext {
buffer: string[];
remaining: number;
}
/**
* Manages a sliding window of lines and pending context-after buffers.
*/
class ContextBuffer {
private readonly capacity: number;
private buffer: string[] = []; // Circular buffer simulation using simple array + push/shift could be inefficient for huge contexts, but typical context is small (<10).
// For strictly strictly correctness with potentially large context, we'll use an array and slice.
private pending: PendingContext[] = [];
constructor(contextLines: number) {
this.capacity = Math.max(0, contextLines);
}
add(line: string): void {
// 1. Fill Pending 'After' Contexts
if (this.pending.length > 0) {
// iterate backwards to allow safe removal or use filter
for (const p of this.pending) {
if (p.remaining > 0) {
p.buffer.push(line);
p.remaining--;
}
}
this.pending = this.pending.filter((p) => p.remaining > 0);
}
// 2. Maintain 'Before' Buffer
if (this.capacity > 0) {
this.buffer.push(line);
if (this.buffer.length > this.capacity) {
this.buffer.shift();
}
}
}
snapshotBefore(): string[] {
return [...this.buffer];
}
scheduleAfter(): string[] {
if (this.capacity === 0) return [];
const buffer: string[] = [];
this.pending.push({ buffer, remaining: this.capacity });
return buffer;
}
}
function trimContent(line: string): string {
return line.length > MAX_LINE_CONTENT_LENGTH
? line.slice(0, MAX_LINE_CONTENT_LENGTH)
: line;
}
// --- Scanning ---
interface ScanFileResult {
readonly matches: readonly ContentMatch[];
readonly matched: boolean;
readonly skippedTooLarge: boolean;
readonly skippedBinary: boolean;
}
async function readMatches(
handle: fsp.FileHandle,
requestedPath: string,
matcher: Matcher,
options: ScanFileOptions,
maxMatches: number,
isCancelled: () => boolean,
signal?: AbortSignal
): Promise<ContentMatch[]> {
const matches: ContentMatch[] = [];
const ctx = new ContextBuffer(options.contextLines);
let lineNumber = 0;
// Use for-await with readLines for memory efficiency
const lines = handle.readLines({ encoding: 'utf-8', signal });
try {
for await (const rawLine of lines) {
if (matches.length >= maxMatches) break;
if (isCancelled()) break;
const matchCount = matcher(rawLine);
const content = trimContent(rawLine);
if (matchCount > 0) {
matches.push({
file: requestedPath,
line: lineNumber,
content,
matchCount,
...(options.contextLines > 0
? {
contextBefore: ctx.snapshotBefore(),
contextAfter: ctx.scheduleAfter(),
}
: {}),
});
}
ctx.add(content);
lineNumber++;
}
} finally {
// Ensure we drain the async iterator to release file handle and resources
}
return matches;
}
type BinaryDetector = (
resolvedPath: string,
handle: fsp.FileHandle,
signal?: AbortSignal
) => Promise<boolean>;
async function scanFileResolved(
resolvedPath: string,
requestedPath: string,
matcher: Matcher,
options: ScanFileOptions,
signal?: AbortSignal,
maxMatches: number = Number.POSITIVE_INFINITY,
injectedBinaryDetector?: BinaryDetector
): Promise<ScanFileResult> {
assertNotAborted(signal);
const handle = await withAbort(fsp.open(resolvedPath, 'r'), signal);
try {
const stats = await withAbort(handle.stat(), signal);
// 1. Size Check
if (stats.size > options.maxFileSize) {
return {
matches: [],
matched: false,
skippedTooLarge: true,
skippedBinary: false,
};
}
// 2. Binary Check
if (options.skipBinary) {
const detect = injectedBinaryDetector ?? isProbablyBinary;
if (await detect(resolvedPath, handle, signal)) {
return {
matches: [],
matched: false,
skippedTooLarge: false,
skippedBinary: true,
};
}
}
// 3. Scan Content
const matches = await readMatches(
handle,
requestedPath,
matcher,
options,
maxMatches,
() => Boolean(signal?.aborted),
signal
);
return {
matches,
matched: matches.length > 0,
skippedTooLarge: false,
skippedBinary: false,
};
} finally {
await handle.close();
}
}
// --- Orchestration (Single & Multi-threaded) ---
interface ResolvedFile {
resolvedPath: string;
requestedPath: string;
}
interface ScanSummary {
filesScanned: number;
filesMatched: number;
skippedTooLarge: number;
skippedBinary: number;
skippedInaccessible: number;
truncated: boolean;
stoppedReason: SearchContentResult['summary']['stoppedReason'];
}
function createScanSummary(): ScanSummary {
return {
filesScanned: 0,
filesMatched: 0,
skippedTooLarge: 0,
skippedBinary: 0,
skippedInaccessible: 0,
truncated: false,
stoppedReason: undefined,
};
}
function buildSearchResult(
root: string,
pattern: string,
filePattern: string,
matches: ContentMatch[],
summary: ScanSummary
): SearchContentResult {
return {
basePath: root,
pattern,
filePattern,
matches,
summary: {
filesScanned: summary.filesScanned,
filesMatched: summary.filesMatched,
matches: matches.length,
truncated: summary.truncated,
skippedTooLarge: summary.skippedTooLarge,
skippedBinary: summary.skippedBinary,
skippedInaccessible: summary.skippedInaccessible,
linesSkippedDueToRegexTimeout: 0,
...(summary.stoppedReason
? { stoppedReason: summary.stoppedReason }
: {}),
},
};
}
export interface ScanRequest {
type: 'scan';
id: number;
resolvedPath: string;
requestedPath: string;
pattern: string;
matcherOptions: MatcherOptions;
scanOptions: ScanFileOptions;
maxMatches: number;
}
export interface ScanResult {
type: 'result';
id: number;
result: {
matches: readonly ContentMatch[];
matched: boolean;
skippedTooLarge: boolean;
skippedBinary: boolean;
};
}
export interface ScanError {
type: 'error';
id: number;
error: string;
}
export type WorkerResponse = ScanResult | ScanError;
interface WorkerScanRequest {
resolvedPath: string;
requestedPath: string;
pattern: string;
matcherOptions: MatcherOptions;
scanOptions: ScanFileOptions;
maxMatches: number;
}
interface WorkerScanResult {
matches: readonly ContentMatch[];
matched: boolean;
skippedTooLarge: boolean;
skippedBinary: boolean;
}
interface ScanTask {
id: number;
promise: Promise<WorkerScanResult>;
cancel: () => void;
}
const currentDir = path.dirname(fileURLToPath(import.meta.url));
const isSourceContext =
currentDir.endsWith('src\\lib\\file-operations') ||
currentDir.endsWith('src/lib/file-operations');
const WORKER_SCRIPT_PATH = path.join(
currentDir,
isSourceContext ? 'search-worker.ts' : 'search-worker.js'
);
const WORKER_SCRIPT_URL = pathToFileURL(WORKER_SCRIPT_PATH);
class SearchWorkerPool {
private workers: Worker[] = [];
private pending = new Map<
number,
{
resolve: (val: WorkerScanResult) => void;
reject: (err: Error) => void;
workerIndex: number;
}
>();
private nextRequestId = 0;
private closed = false;
private workerRoundRobin = 0;
constructor(
private size: number,
private debug: boolean
) {
if (size <= 0) throw new Error('Pool size must be positive');
}
private initWorker(index: number): Worker {
const worker = new Worker(WORKER_SCRIPT_URL, {
workerData: { debug: this.debug },
execArgv: isSourceContext ? ['--import', 'tsx/esm'] : undefined,
});
worker.on('message', (msg: WorkerResponse) => {
const p = this.pending.get(msg.id);
if (!p) return;
this.pending.delete(msg.id);
if (msg.type === 'result') p.resolve(msg.result);
else p.reject(new Error(msg.error));
});
worker.on('error', (err) => {
// Fail all pending for this worker with the error
for (const [id, p] of this.pending) {
if (p.workerIndex === index) {
p.reject(err);
this.pending.delete(id);
}
}
});
worker.unref();
return worker;
}
scan(req: WorkerScanRequest): ScanTask {
if (this.closed) throw new Error(ERROR_WORKER_POOL_CLOSED);
// Lazy init
if (this.workers.length < this.size) {
this.workers.push(this.initWorker(this.workers.length));
}
const id = this.nextRequestId++;
const workerIndex = this.workerRoundRobin % this.workers.length;
const worker = this.workers[workerIndex];
if (!worker) throw new Error('Worker not available');
this.workerRoundRobin++;
const promise = new Promise<WorkerScanResult>((resolve, reject) => {
this.pending.set(id, { resolve, reject, workerIndex });
worker.postMessage({ type: 'scan', id, ...req } as ScanRequest);
});
return {
id,
promise,
cancel: () => {
const entry = this.pending.get(id);
if (entry) {
this.pending.delete(id);
try {
worker.postMessage({ type: 'cancel', id });
} catch {
// Worker may already be terminating
}
entry.reject(new Error(ERROR_SCAN_CANCELLED));
}
},
};
}
async close(): Promise<void> {
this.closed = true;
for (const p of this.pending.values())
p.reject(new Error(ERROR_WORKER_POOL_CLOSED));
this.pending.clear();
await Promise.all(this.workers.map((w) => w.terminate()));
this.workers = [];
}
}
function isWorkerPoolAvailable(): boolean {
return !isSourceContext;
}
function shouldUseWorkers(): boolean {
return isWorkerPoolAvailable() && SEARCH_WORKERS >= 2;
}
let poolInstance: SearchWorkerPool | null = null;
function getPool(): SearchWorkerPool {
if (!poolInstance) {
const debug = process.env['FS_CONTEXT_SEARCH_WORKERS_DEBUG'] === '1';
poolInstance = new SearchWorkerPool(SEARCH_WORKERS, debug);
}
return poolInstance;
}
// --- Execution Strategies ---
async function executeSequential(
files: AsyncIterable<ResolvedFile>,
pattern: string,
opts: ResolvedOptions,
signal: AbortSignal,
summary: ScanSummary
): Promise<ContentMatch[]> {
const matches: ContentMatch[] = [];
const matcher = buildMatcher(pattern, opts);
const scanOpts: ScanFileOptions = {
maxFileSize: opts.maxFileSize,
skipBinary: opts.skipBinary,
contextLines: opts.contextLines,
};
for await (const file of files) {
if (signal.aborted) {
summary.truncated = true;
summary.stoppedReason = 'timeout';
break;
}
if (matches.length >= opts.maxResults) {
summary.truncated = true;
summary.stoppedReason = 'maxResults';
break;
}
try {
assertAllowedFileAccess(file.requestedPath, file.resolvedPath);
const remaining = opts.maxResults - matches.length;
const result = await scanFileResolved(
file.resolvedPath,
file.requestedPath,
matcher,
scanOpts,
signal,
remaining
);
if (result.matched) summary.filesMatched++;
if (result.skippedBinary) summary.skippedBinary++;
if (result.skippedTooLarge) summary.skippedTooLarge++;
matches.push(...result.matches);
} catch {
// Ignore access errors during mass scan
summary.skippedInaccessible++;
}
}
return matches;
}
// Helper to manage pool filling
async function fillWorkerPool(
pool: SearchWorkerPool,
pending: Set<ScanTask>,
iterator: AsyncIterator<ResolvedFile>,
pattern: string,
matcherOpts: MatcherOptions,
scanOpts: ScanFileOptions,
maxResults: number,
currentMatches: number,
summary: ScanSummary
): Promise<boolean> {
while (pending.size < SEARCH_WORKERS) {
const result = await iterator.next();
if (result.done) return true;
try {
const remaining = Math.max(1, maxResults - currentMatches);
const task = pool.scan({
resolvedPath: result.value.resolvedPath,
requestedPath: result.value.requestedPath,
pattern,
matcherOptions: matcherOpts,
scanOptions: scanOpts,
maxMatches: remaining,
});
pending.add(task);
} catch {
summary.skippedInaccessible++;
}
}
return false;
}
function processScanResult(
winner: { result: WorkerScanResult | undefined; error: Error | undefined },
summary: ScanSummary,
matches: ContentMatch[],
maxResults: number
): void {
if (winner.error) {
if (winner.error.message !== ERROR_SCAN_CANCELLED) {
summary.skippedInaccessible++;
}
return;
}
if (winner.result) {
const res = winner.result;
if (res.matched) summary.filesMatched++;
if (res.skippedBinary) summary.skippedBinary++;
if (res.skippedTooLarge) summary.skippedTooLarge++;
const take = maxResults - matches.length;
if (take > 0 && res.matches.length > 0) {
matches.push(...res.matches.slice(0, take));
}
}
}
async function waitForWinner(pending: Set<ScanTask>): Promise<{
task: ScanTask;
result: WorkerScanResult | undefined;
error: Error | undefined;
}> {
const pendingTasks = Array.from(pending);
interface RaceResult {
task: ScanTask;
result: WorkerScanResult | undefined;
error: Error | undefined;
}
return Promise.race(
pendingTasks.map((t) =>
t.promise.then(
(res): RaceResult => ({ task: t, result: res, error: undefined }),
(err: unknown): RaceResult => ({
task: t,
result: undefined,
error: err instanceof Error ? err : new Error(String(err)),
})
)
)
);
}
async function executeParallel(
files: AsyncIterable<ResolvedFile>,
pattern: string,
opts: ResolvedOptions,
signal: AbortSignal,
summary: ScanSummary
): Promise<ContentMatch[]> {
const pool = getPool();
const matches: ContentMatch[] = [];
const scanOpts: ScanFileOptions = {
maxFileSize: opts.maxFileSize,
skipBinary: opts.skipBinary,
contextLines: opts.contextLines,
};
const matcherOpts: MatcherOptions = {
caseSensitive: opts.caseSensitive,
wholeWord: opts.wholeWord,
isLiteral: opts.isLiteral,
};
const pending = new Set<ScanTask>();
const iterator = files[Symbol.asyncIterator]();
let exhausted = false;
const onAbort = (): void => {
summary.truncated = true;
summary.stoppedReason = 'timeout';
for (const t of pending) t.cancel();
};
signal.addEventListener('abort', onAbort, { once: true });
try {
for (;;) {
if (signal.aborted || matches.length >= opts.maxResults) {
break;
}
if (!exhausted) {
exhausted = await fillWorkerPool(
pool,
pending,
iterator,
pattern,
matcherOpts,
scanOpts,
opts.maxResults,
matches.length,
summary
);
}
if (pending.size === 0 && exhausted) {
break;
}
// Wait for at least one
const winner = await waitForWinner(pending);
pending.delete(winner.task);
processScanResult(winner, summary, matches, opts.maxResults);
}
} finally {
signal.removeEventListener('abort', onAbort);
// Cancel remaining
for (const t of pending) t.cancel();
if (iterator.return) await iterator.return();
}
// Update summary truncation
if (signal.aborted) {
summary.truncated = true;
summary.stoppedReason = 'timeout';
} else if (matches.length >= opts.maxResults) {
summary.truncated = true;
summary.stoppedReason = 'maxResults';
}
return matches;
}
// --- Entry Points ---
export async function scanFileInWorker(
resolvedPath: string,
requestedPath: string,
matcher: Matcher,
options: ScanFileOptions,
maxMatches: number,
isCancelled: () => boolean,
isBinaryDetector: BinaryDetector
): Promise<WorkerScanResult> {
// Direct scan used by worker script
const res = await scanFileResolved(
resolvedPath,
requestedPath,
matcher,
options,
undefined,
maxMatches,
isBinaryDetector
);
return {
matches: res.matches,
matched: res.matched,
skippedBinary: res.skippedBinary,
skippedTooLarge: res.skippedTooLarge,
};
}
export async function searchContent(
basePath: string,
pattern: string,
options: SearchContentOptions = {}
): Promise<SearchContentResult> {
if (!basePath.trim())
throw new McpError(ErrorCode.E_INVALID_INPUT, 'basePath required');
if (typeof pattern !== 'string')
throw new McpError(ErrorCode.E_INVALID_INPUT, 'pattern required');
const opts = resolveOptions(options);
const { signal, cleanup } = createTimedAbortSignal(
options.signal,
opts.timeoutMs
);
try {
const details = await validateExistingPathDetailed(basePath, signal);
const stats = await withAbort(fsp.stat(details.resolvedPath), signal);
// Check if simple file scan
if (stats.isFile()) {
const summary = createScanSummary();
summary.filesScanned = 1;
// Single file execution
const matcher = buildMatcher(pattern, opts);
const result = await scanFileResolved(
details.resolvedPath,
details.requestedPath,
matcher,
{
maxFileSize: opts.maxFileSize,
skipBinary: opts.skipBinary,
contextLines: opts.contextLines,
},
signal,
opts.maxResults
);
if (result.matched) summary.filesMatched = 1;
return buildSearchResult(
path.dirname(details.resolvedPath),
pattern,
opts.filePattern,
[...result.matches],
summary
);
}
if (!stats.isDirectory()) {
throw new McpError(
ErrorCode.E_INVALID_INPUT,
`Path must be file or directory`,
basePath
);
}
const root = await validateExistingDirectory(details.resolvedPath, signal);
// Glob
const stream = globEntries({
cwd: root,
pattern: opts.filePattern,
excludePatterns: opts.excludePatterns,
includeHidden: opts.includeHidden,
baseNameMatch: opts.baseNameMatch,
caseSensitiveMatch: opts.caseSensitiveFileMatch,
followSymbolicLinks: false,
onlyFiles: true,
stats: false,
suppressErrors: true,
});
// Generator adapter to resolve paths
async function* fileGenerator(): AsyncGenerator<ResolvedFile> {
let scanned = 0;
for await (const entry of stream) {
if (signal.aborted) break;
if (scanned >= opts.maxFilesScanned) break;
if (!entry.dirent.isFile()) continue;
// Helper to resolve
// We duplicate simple resolution logic to keep it fast
const normalized = normalizePath(entry.path);
if (!isPathWithinDirectories(normalized, [root])) continue;
if (isSensitivePath(entry.path, normalized)) continue;
scanned++;
if (options.onProgress && scanned % 50 === 0)
options.onProgress({ current: scanned });
yield { resolvedPath: normalized, requestedPath: entry.path };
}
}
// Choose Strategy
// We recreate summary to track actual scans
const summary = createScanSummary();
const resolvedStream = fileGenerator();
// Wrap generator to count scanned files in summary
async function* countingStream(): AsyncGenerator<ResolvedFile> {
for await (const f of resolvedStream) {
summary.filesScanned++;
yield f;
}
if (summary.filesScanned >= opts.maxFilesScanned) {
summary.truncated = true;
summary.stoppedReason = 'maxFiles';
}
}
const matcherOpts: MatcherOptions = {
caseSensitive: opts.caseSensitive,
wholeWord: opts.wholeWord,
isLiteral: opts.isLiteral,
};
validatePattern(pattern, matcherOpts);
const matches = shouldUseWorkers()
? await executeParallel(countingStream(), pattern, opts, signal, summary)
: await executeSequential(
countingStream(),
pattern,
opts,
signal,
summary
);
return buildSearchResult(root, pattern, opts.filePattern, matches, summary);
} catch (error: unknown) {
if (error instanceof Error && error.name === 'AbortError') {
const timeoutSummary = createScanSummary();
timeoutSummary.truncated = true;
timeoutSummary.stoppedReason = 'timeout';
return buildSearchResult(
basePath,
pattern,
opts.filePattern,
[],
timeoutSummary
);
}
throw error;
} finally {
cleanup();
}
}