@j0hanz/filesystem-mcp 1.7.3 → 1.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +635 -377
- package/dist/cli.js +2 -2
- package/dist/completions.js +2 -3
- package/dist/index.js +2 -2
- package/dist/lib/file-operations/{common.d.ts → core.d.ts} +6 -0
- package/dist/lib/file-operations/{common.js → core.js} +45 -0
- package/dist/lib/file-operations/metadata.d.ts +73 -0
- package/dist/lib/file-operations/metadata.js +889 -0
- package/dist/lib/file-operations/{search-content.d.ts → search.d.ts} +29 -3
- package/dist/lib/file-operations/{search-content.js → search.js} +409 -82
- package/dist/lib/file-operations/{glob-engine.d.ts → traversal.d.ts} +18 -1
- package/dist/lib/file-operations/{glob-engine.js → traversal.js} +25 -2
- package/dist/lib/fs-helpers.d.ts +1 -0
- package/dist/lib/fs-helpers.js +10 -2
- package/dist/lib/observability.js +1 -1
- package/dist/lib/{path-validation.d.ts → paths.d.ts} +4 -0
- package/dist/lib/{path-validation.js → paths.js} +112 -1
- package/dist/lib/utils.d.ts +15 -0
- package/dist/lib/utils.js +33 -0
- package/dist/resources/generated-instructions.js +6 -6
- package/dist/resources/tool-catalog.js +9 -9
- package/dist/resources/tool-info.js +3 -3
- package/dist/resources/workflows.js +10 -23
- package/dist/schemas.js +15 -15
- package/dist/server/bootstrap.d.ts +19 -1
- package/dist/server/bootstrap.js +103 -9
- package/dist/server/roots-manager.d.ts +2 -2
- package/dist/server/roots-manager.js +3 -3
- package/dist/tools/apply-patch.js +7 -3
- package/dist/tools/calculate-hash.js +15 -15
- package/dist/tools/create-directory.js +1 -1
- package/dist/tools/delete-file.js +7 -4
- package/dist/tools/diff-files.js +2 -2
- package/dist/tools/edit-file.js +14 -13
- package/dist/tools/list-directory.js +5 -7
- package/dist/tools/move-file.js +2 -1
- package/dist/tools/read-multiple.js +13 -26
- package/dist/tools/read.js +3 -3
- package/dist/tools/replace-in-files.js +17 -20
- package/dist/tools/roots.js +4 -6
- package/dist/tools/search-content.js +9 -11
- package/dist/tools/search-files.js +4 -6
- package/dist/tools/shared.d.ts +18 -1
- package/dist/tools/shared.js +39 -19
- package/dist/tools/stat-many.js +14 -24
- package/dist/tools/stat.js +4 -3
- package/dist/tools/task-support.js +1 -1
- package/dist/tools/tree.js +3 -4
- package/dist/tools/write-file.js +1 -1
- package/package.json +9 -5
- package/dist/lib/file-operations/file-info.d.ts +0 -10
- package/dist/lib/file-operations/file-info.js +0 -143
- package/dist/lib/file-operations/gitignore.d.ts +0 -6
- package/dist/lib/file-operations/gitignore.js +0 -45
- package/dist/lib/file-operations/list-directory.d.ts +0 -14
- package/dist/lib/file-operations/list-directory.js +0 -256
- package/dist/lib/file-operations/read-multiple-files.d.ts +0 -25
- package/dist/lib/file-operations/read-multiple-files.js +0 -252
- package/dist/lib/file-operations/search-files.d.ts +0 -27
- package/dist/lib/file-operations/search-files.js +0 -218
- package/dist/lib/file-operations/search-worker.d.ts +0 -2
- package/dist/lib/file-operations/search-worker.js +0 -129
- package/dist/lib/file-operations/tree.d.ts +0 -28
- package/dist/lib/file-operations/tree.js +0 -269
- package/dist/lib/path-format.d.ts +0 -1
- package/dist/lib/path-format.js +0 -7
- package/dist/lib/path-policy.d.ts +0 -2
- package/dist/lib/path-policy.js +0 -100
- package/dist/lib/type-guards.d.ts +0 -1
- package/dist/lib/type-guards.js +0 -3
- package/dist/server/capabilities.d.ts +0 -10
- package/dist/server/capabilities.js +0 -48
- package/dist/server/logging.d.ts +0 -7
- package/dist/server/logging.js +0 -41
- package/dist/server/types.d.ts +0 -4
- package/dist/server/types.js +0 -1
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
import * as fsp from 'node:fs/promises';
|
|
2
2
|
import { z } from 'zod';
|
|
3
|
-
import type { ContentMatch, SearchContentResult } from '../../config.js';
|
|
3
|
+
import type { ContentMatch, SearchContentResult, SearchFilesResult } from '../../config.js';
|
|
4
4
|
export declare const MatcherOptionsSchema: z.ZodObject<{
|
|
5
5
|
caseSensitive: z.ZodBoolean;
|
|
6
6
|
wholeWord: z.ZodBoolean;
|
|
7
7
|
isLiteral: z.ZodBoolean;
|
|
8
8
|
}, z.core.$strict>;
|
|
9
9
|
export type MatcherOptions = z.infer<typeof MatcherOptionsSchema>;
|
|
10
|
+
export type Matcher = (line: string) => number;
|
|
11
|
+
export declare function validatePattern(pattern: string, options: MatcherOptions): void;
|
|
12
|
+
export declare function buildMatcher(pattern: string, options: MatcherOptions): Matcher;
|
|
10
13
|
export interface ScanFileOptions {
|
|
11
14
|
maxFileSize: number;
|
|
12
15
|
skipBinary: boolean;
|
|
@@ -36,8 +39,6 @@ export interface SearchContentOptions extends Partial<ResolvedOptions> {
|
|
|
36
39
|
current: number;
|
|
37
40
|
}) => void;
|
|
38
41
|
}
|
|
39
|
-
export type Matcher = (line: string) => number;
|
|
40
|
-
export declare function buildMatcher(pattern: string, options: MatcherOptions): Matcher;
|
|
41
42
|
type BinaryDetector = (resolvedPath: string, handle: fsp.FileHandle, signal?: AbortSignal) => Promise<boolean>;
|
|
42
43
|
export interface ScanRequest {
|
|
43
44
|
type: 'scan';
|
|
@@ -73,4 +74,29 @@ interface WorkerScanResult {
|
|
|
73
74
|
}
|
|
74
75
|
export declare function scanFileInWorker(resolvedPath: string, requestedPath: string, matcher: Matcher, options: ScanFileOptions, maxMatches: number, isCancelled: () => boolean, isBinaryDetector: BinaryDetector): Promise<WorkerScanResult>;
|
|
75
76
|
export declare function searchContent(basePath: string, pattern: string, options?: SearchContentOptions): Promise<SearchContentResult>;
|
|
77
|
+
type SortBy = 'name' | 'size' | 'modified' | 'path';
|
|
78
|
+
interface SearchFilesOptions {
|
|
79
|
+
maxResults?: number;
|
|
80
|
+
sortBy?: SortBy;
|
|
81
|
+
maxDepth?: number;
|
|
82
|
+
maxFilesScanned?: number;
|
|
83
|
+
timeoutMs?: number;
|
|
84
|
+
baseNameMatch?: boolean;
|
|
85
|
+
skipSymlinks?: boolean;
|
|
86
|
+
includeHidden?: boolean;
|
|
87
|
+
respectGitignore?: boolean;
|
|
88
|
+
signal?: AbortSignal;
|
|
89
|
+
onProgress?: (progress: {
|
|
90
|
+
total?: number;
|
|
91
|
+
current: number;
|
|
92
|
+
}) => void;
|
|
93
|
+
}
|
|
94
|
+
interface Sortable {
|
|
95
|
+
name?: string;
|
|
96
|
+
size?: number;
|
|
97
|
+
modified?: Date;
|
|
98
|
+
path?: string;
|
|
99
|
+
}
|
|
100
|
+
export declare function sortSearchResults(results: Sortable[], sortBy: SortBy): void;
|
|
101
|
+
export declare function searchFiles(basePath: string, pattern: string, excludePatterns?: readonly string[], options?: SearchFilesOptions): Promise<SearchFilesResult>;
|
|
76
102
|
export {};
|
|
@@ -1,70 +1,23 @@
|
|
|
1
1
|
import * as fsp from 'node:fs/promises';
|
|
2
2
|
import * as path from 'node:path';
|
|
3
3
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
4
|
-
import { Worker } from 'node:worker_threads';
|
|
5
|
-
import { z } from 'zod';
|
|
4
|
+
import { parentPort, threadId, Worker, workerData } from 'node:worker_threads';
|
|
6
5
|
import RE2 from 're2';
|
|
7
6
|
import safeRegex from 'safe-regex2';
|
|
7
|
+
import { z } from 'zod';
|
|
8
8
|
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';
|
|
9
9
|
import { ErrorCode, formatUnknownErrorMessage, isTimeoutLikeError, McpError, } from '../errors.js';
|
|
10
|
-
import { assertNotAborted,
|
|
11
|
-
import {
|
|
12
|
-
import { isPathWithinDirectories, normalizePath, validateExistingDirectory, validateExistingPathDetailed, } from '../
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
15
|
-
|
|
16
|
-
const INTERNAL_MAX_RESULTS = 500;
|
|
10
|
+
import { assertNotAborted, isProbablyBinary, withAbort, withTimedAbortSignal, } from '../fs-helpers.js';
|
|
11
|
+
import { startPerfMeasure } from '../observability.js';
|
|
12
|
+
import { assertAllowedFileAccess, isPathWithinDirectories, isSensitivePath, normalizePath, validateExistingDirectory, validateExistingPathDetailed, } from '../paths.js';
|
|
13
|
+
import { mergeOptions, omitOptionKeys, reportPeriodicProgress, } from '../utils.js';
|
|
14
|
+
import { compareOptionalNumberDesc, compareStringValues, isEntryAccessibleByType, isIgnoredByGitignore, loadRootGitignore, needsStatsForSort, resolveEntryType, resolveStopReason, stableSortByDerivedString, withOptionalStoppedReason, } from './core.js';
|
|
15
|
+
import { buildGlobOptions, globEntries } from './traversal.js';
|
|
17
16
|
export const MatcherOptionsSchema = z.strictObject({
|
|
18
17
|
caseSensitive: z.boolean(),
|
|
19
18
|
wholeWord: z.boolean(),
|
|
20
19
|
isLiteral: z.boolean(),
|
|
21
20
|
});
|
|
22
|
-
const SearchOptionsSchema = z.strictObject({
|
|
23
|
-
filePattern: z.string().min(1),
|
|
24
|
-
excludePatterns: z.array(z.string()),
|
|
25
|
-
caseSensitive: z.boolean(),
|
|
26
|
-
maxResults: z.number().int().nonnegative(),
|
|
27
|
-
maxFileSize: z.number().int().nonnegative(),
|
|
28
|
-
maxFilesScanned: z.number().int().nonnegative(),
|
|
29
|
-
timeoutMs: z.number().int().nonnegative(),
|
|
30
|
-
skipBinary: z.boolean(),
|
|
31
|
-
contextLines: z.number().int().nonnegative(),
|
|
32
|
-
wholeWord: z.boolean(),
|
|
33
|
-
isLiteral: z.boolean(),
|
|
34
|
-
includeHidden: z.boolean(),
|
|
35
|
-
baseNameMatch: z.boolean(),
|
|
36
|
-
caseSensitiveFileMatch: z.boolean(),
|
|
37
|
-
});
|
|
38
|
-
const DEFAULTS = {
|
|
39
|
-
filePattern: '**/*',
|
|
40
|
-
excludePatterns: DEFAULT_EXCLUDE_PATTERNS,
|
|
41
|
-
caseSensitive: false,
|
|
42
|
-
maxResults: INTERNAL_MAX_RESULTS,
|
|
43
|
-
maxFileSize: MAX_SEARCHABLE_FILE_SIZE,
|
|
44
|
-
maxFilesScanned: DEFAULT_SEARCH_MAX_FILES,
|
|
45
|
-
timeoutMs: DEFAULT_SEARCH_TIMEOUT_MS,
|
|
46
|
-
skipBinary: true,
|
|
47
|
-
contextLines: 0,
|
|
48
|
-
wholeWord: false,
|
|
49
|
-
isLiteral: true,
|
|
50
|
-
includeHidden: false,
|
|
51
|
-
baseNameMatch: false,
|
|
52
|
-
caseSensitiveFileMatch: true,
|
|
53
|
-
};
|
|
54
|
-
const ERROR_SCAN_CANCELLED = 'Scan cancelled';
|
|
55
|
-
const ERROR_WORKER_POOL_CLOSED = 'Worker pool closed';
|
|
56
|
-
// --- Helpers ---
|
|
57
|
-
function resolveOptions(options) {
|
|
58
|
-
const rest = { ...options };
|
|
59
|
-
delete rest.signal;
|
|
60
|
-
delete rest.onProgress;
|
|
61
|
-
const merged = { ...DEFAULTS, ...rest };
|
|
62
|
-
const result = SearchOptionsSchema.safeParse(merged);
|
|
63
|
-
if (!result.success) {
|
|
64
|
-
throw new McpError(ErrorCode.E_INVALID_INPUT, `Invalid search options: ${result.error.message}`, undefined, { errors: z.treeifyError(result.error) });
|
|
65
|
-
}
|
|
66
|
-
return result.data;
|
|
67
|
-
}
|
|
68
21
|
function countRegexLineMatches(regex, line) {
|
|
69
22
|
regex.lastIndex = 0;
|
|
70
23
|
let count = 0;
|
|
@@ -82,7 +35,7 @@ function buildRegexPattern(pattern, options) {
|
|
|
82
35
|
const escaped = options.isLiteral ? escapeLiteral(pattern) : pattern;
|
|
83
36
|
return options.wholeWord ? `\\b${escaped}\\b` : escaped;
|
|
84
37
|
}
|
|
85
|
-
function validatePattern(pattern, options) {
|
|
38
|
+
export function validatePattern(pattern, options) {
|
|
86
39
|
if (options.isLiteral && pattern.length === 0)
|
|
87
40
|
return;
|
|
88
41
|
if (options.isLiteral && !options.wholeWord)
|
|
@@ -129,6 +82,52 @@ export function buildMatcher(pattern, options) {
|
|
|
129
82
|
validatePattern(pattern, options); // Re-validate to be safe
|
|
130
83
|
return buildRegexMatcher(final, options.caseSensitive);
|
|
131
84
|
}
|
|
85
|
+
// --- Configuration & Schemas ---
|
|
86
|
+
const SEARCH_CONTENT_MAX_RESULTS = 500;
|
|
87
|
+
const SearchOptionsSchema = z.strictObject({
|
|
88
|
+
filePattern: z.string().min(1),
|
|
89
|
+
excludePatterns: z.array(z.string()),
|
|
90
|
+
caseSensitive: z.boolean(),
|
|
91
|
+
maxResults: z.number().int().nonnegative(),
|
|
92
|
+
maxFileSize: z.number().int().nonnegative(),
|
|
93
|
+
maxFilesScanned: z.number().int().nonnegative(),
|
|
94
|
+
timeoutMs: z.number().int().nonnegative(),
|
|
95
|
+
skipBinary: z.boolean(),
|
|
96
|
+
contextLines: z.number().int().nonnegative(),
|
|
97
|
+
wholeWord: z.boolean(),
|
|
98
|
+
isLiteral: z.boolean(),
|
|
99
|
+
includeHidden: z.boolean(),
|
|
100
|
+
baseNameMatch: z.boolean(),
|
|
101
|
+
caseSensitiveFileMatch: z.boolean(),
|
|
102
|
+
});
|
|
103
|
+
const DEFAULTS = {
|
|
104
|
+
filePattern: '**/*',
|
|
105
|
+
excludePatterns: DEFAULT_EXCLUDE_PATTERNS,
|
|
106
|
+
caseSensitive: false,
|
|
107
|
+
maxResults: SEARCH_CONTENT_MAX_RESULTS,
|
|
108
|
+
maxFileSize: MAX_SEARCHABLE_FILE_SIZE,
|
|
109
|
+
maxFilesScanned: DEFAULT_SEARCH_MAX_FILES,
|
|
110
|
+
timeoutMs: DEFAULT_SEARCH_TIMEOUT_MS,
|
|
111
|
+
skipBinary: true,
|
|
112
|
+
contextLines: 0,
|
|
113
|
+
wholeWord: false,
|
|
114
|
+
isLiteral: true,
|
|
115
|
+
includeHidden: false,
|
|
116
|
+
baseNameMatch: false,
|
|
117
|
+
caseSensitiveFileMatch: true,
|
|
118
|
+
};
|
|
119
|
+
const ERROR_SCAN_CANCELLED = 'Scan cancelled';
|
|
120
|
+
const ERROR_WORKER_POOL_CLOSED = 'Worker pool closed';
|
|
121
|
+
// --- Helpers ---
|
|
122
|
+
function resolveOptions(options) {
|
|
123
|
+
const normalizedOptions = omitOptionKeys(options, ['signal', 'onProgress']);
|
|
124
|
+
const merged = mergeOptions(DEFAULTS, normalizedOptions);
|
|
125
|
+
const result = SearchOptionsSchema.safeParse(merged);
|
|
126
|
+
if (!result.success) {
|
|
127
|
+
throw new McpError(ErrorCode.E_INVALID_INPUT, `Invalid search options: ${result.error.message}`, undefined, { errors: z.treeifyError(result.error) });
|
|
128
|
+
}
|
|
129
|
+
return result.data;
|
|
130
|
+
}
|
|
132
131
|
/**
|
|
133
132
|
* Manages a sliding window of lines and pending context-after buffers.
|
|
134
133
|
*/
|
|
@@ -321,7 +320,7 @@ function createScanSummary() {
|
|
|
321
320
|
stoppedReason: undefined,
|
|
322
321
|
};
|
|
323
322
|
}
|
|
324
|
-
function
|
|
323
|
+
function buildSearchContentResult(root, pattern, filePattern, matches, summary) {
|
|
325
324
|
const baseSummary = {
|
|
326
325
|
filesScanned: summary.filesScanned,
|
|
327
326
|
filesMatched: summary.filesMatched,
|
|
@@ -562,13 +561,6 @@ function processScanResult(winner, summary, matches, maxResults) {
|
|
|
562
561
|
}
|
|
563
562
|
}
|
|
564
563
|
}
|
|
565
|
-
function reportSearchProgress(onProgress, current, total, force = false) {
|
|
566
|
-
if (!onProgress || current === 0)
|
|
567
|
-
return;
|
|
568
|
-
if (!force && current % 25 !== 0)
|
|
569
|
-
return;
|
|
570
|
-
onProgress({ current, total });
|
|
571
|
-
}
|
|
572
564
|
async function waitForWinner(pending) {
|
|
573
565
|
const raceCandidates = [];
|
|
574
566
|
for (const task of pending) {
|
|
@@ -654,12 +646,12 @@ async function searchSingleFile(details, opts, pattern, signal) {
|
|
|
654
646
|
}, signal, opts.maxResults);
|
|
655
647
|
if (result.matched)
|
|
656
648
|
summary.filesMatched = 1;
|
|
657
|
-
return
|
|
649
|
+
return buildSearchContentResult(path.dirname(details.resolvedPath), pattern, opts.filePattern, result.matches, summary);
|
|
658
650
|
}
|
|
659
651
|
async function searchDirectory(details, opts, pattern, signal, onProgress) {
|
|
660
652
|
const root = await validateExistingDirectory(details.resolvedPath, signal);
|
|
661
653
|
const rootDirectories = [root];
|
|
662
|
-
const stream = globEntries({
|
|
654
|
+
const stream = globEntries(buildGlobOptions({
|
|
663
655
|
cwd: root,
|
|
664
656
|
pattern: opts.filePattern,
|
|
665
657
|
excludePatterns: opts.excludePatterns,
|
|
@@ -670,7 +662,7 @@ async function searchDirectory(details, opts, pattern, signal, onProgress) {
|
|
|
670
662
|
onlyFiles: true,
|
|
671
663
|
stats: false,
|
|
672
664
|
suppressErrors: true,
|
|
673
|
-
});
|
|
665
|
+
}));
|
|
674
666
|
async function* fileGenerator() {
|
|
675
667
|
let scanned = 0;
|
|
676
668
|
for await (const entry of stream) {
|
|
@@ -686,10 +678,17 @@ async function searchDirectory(details, opts, pattern, signal, onProgress) {
|
|
|
686
678
|
if (isSensitivePath(entry.path, normalized))
|
|
687
679
|
continue;
|
|
688
680
|
scanned++;
|
|
689
|
-
|
|
681
|
+
reportPeriodicProgress(onProgress, scanned, {
|
|
682
|
+
total: opts.maxFilesScanned,
|
|
683
|
+
throttleModulo: 25,
|
|
684
|
+
});
|
|
690
685
|
yield { resolvedPath: normalized, requestedPath: entry.path };
|
|
691
686
|
}
|
|
692
|
-
|
|
687
|
+
reportPeriodicProgress(onProgress, scanned, {
|
|
688
|
+
total: opts.maxFilesScanned,
|
|
689
|
+
throttleModulo: 25,
|
|
690
|
+
force: true,
|
|
691
|
+
});
|
|
693
692
|
}
|
|
694
693
|
const summary = createScanSummary();
|
|
695
694
|
const resolvedStream = fileGenerator();
|
|
@@ -712,12 +711,12 @@ async function searchDirectory(details, opts, pattern, signal, onProgress) {
|
|
|
712
711
|
const matches = shouldUseWorkers()
|
|
713
712
|
? await executeParallel(countingStream(), pattern, opts, signal, summary)
|
|
714
713
|
: await executeSequential(countingStream(), pattern, opts, signal, summary);
|
|
715
|
-
return
|
|
714
|
+
return buildSearchContentResult(root, pattern, opts.filePattern, matches, summary);
|
|
716
715
|
}
|
|
717
716
|
function buildTimeoutSearchResult(basePath, pattern, filePattern) {
|
|
718
717
|
const timeoutSummary = createScanSummary();
|
|
719
718
|
markTruncated(timeoutSummary, 'timeout');
|
|
720
|
-
return
|
|
719
|
+
return buildSearchContentResult(basePath, pattern, filePattern, [], timeoutSummary);
|
|
721
720
|
}
|
|
722
721
|
export async function searchContent(basePath, pattern, options = {}) {
|
|
723
722
|
if (!basePath.trim())
|
|
@@ -725,17 +724,18 @@ export async function searchContent(basePath, pattern, options = {}) {
|
|
|
725
724
|
if (typeof pattern !== 'string')
|
|
726
725
|
throw new McpError(ErrorCode.E_INVALID_INPUT, 'pattern required');
|
|
727
726
|
const opts = resolveOptions(options);
|
|
728
|
-
const { signal, cleanup } = createTimedAbortSignal(options.signal, opts.timeoutMs);
|
|
729
727
|
try {
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
728
|
+
return await withTimedAbortSignal(options.signal, opts.timeoutMs, async (signal) => {
|
|
729
|
+
const details = await validateExistingPathDetailed(basePath, signal);
|
|
730
|
+
const stats = await withAbort(fsp.stat(details.resolvedPath), signal);
|
|
731
|
+
if (stats.isFile()) {
|
|
732
|
+
return searchSingleFile(details, opts, pattern, signal);
|
|
733
|
+
}
|
|
734
|
+
if (!stats.isDirectory()) {
|
|
735
|
+
throw new McpError(ErrorCode.E_INVALID_INPUT, 'Path must be file or directory', basePath);
|
|
736
|
+
}
|
|
737
|
+
return searchDirectory(details, opts, pattern, signal, options.onProgress);
|
|
738
|
+
});
|
|
739
739
|
}
|
|
740
740
|
catch (error) {
|
|
741
741
|
if (isTimeoutLikeError(error)) {
|
|
@@ -743,7 +743,334 @@ export async function searchContent(basePath, pattern, options = {}) {
|
|
|
743
743
|
}
|
|
744
744
|
throw error;
|
|
745
745
|
}
|
|
746
|
+
}
|
|
747
|
+
// Internal default for find tool - not exposed to MCP users
|
|
748
|
+
const SEARCH_FILES_MAX_RESULTS = 1000;
|
|
749
|
+
function normalizeSearchFilesOptions(options) {
|
|
750
|
+
const normalized = {
|
|
751
|
+
maxResults: options.maxResults ?? SEARCH_FILES_MAX_RESULTS,
|
|
752
|
+
sortBy: options.sortBy ?? 'path',
|
|
753
|
+
maxFilesScanned: options.maxFilesScanned ?? DEFAULT_SEARCH_MAX_FILES,
|
|
754
|
+
timeoutMs: options.timeoutMs ?? DEFAULT_SEARCH_TIMEOUT_MS,
|
|
755
|
+
baseNameMatch: options.baseNameMatch ?? false,
|
|
756
|
+
skipSymlinks: options.skipSymlinks ?? true,
|
|
757
|
+
includeHidden: options.includeHidden ?? false,
|
|
758
|
+
respectGitignore: options.respectGitignore ?? false,
|
|
759
|
+
};
|
|
760
|
+
if (options.maxDepth !== undefined) {
|
|
761
|
+
normalized.maxDepth = options.maxDepth;
|
|
762
|
+
}
|
|
763
|
+
return normalized;
|
|
764
|
+
}
|
|
765
|
+
function buildSearchFilesResult(entry, entryType, needsStats) {
|
|
766
|
+
let resolvedType = 'other';
|
|
767
|
+
if (entryType === 'directory') {
|
|
768
|
+
resolvedType = 'directory';
|
|
769
|
+
}
|
|
770
|
+
else if (entryType === 'file') {
|
|
771
|
+
resolvedType = 'file';
|
|
772
|
+
}
|
|
773
|
+
const size = needsStats && entry.stats?.isFile() ? entry.stats.size : undefined;
|
|
774
|
+
const modified = needsStats ? entry.stats?.mtime : undefined;
|
|
775
|
+
return {
|
|
776
|
+
path: entry.path,
|
|
777
|
+
type: resolvedType,
|
|
778
|
+
...(size !== undefined ? { size } : {}),
|
|
779
|
+
...(modified !== undefined ? { modified } : {}),
|
|
780
|
+
};
|
|
781
|
+
}
|
|
782
|
+
function shouldStopCollecting(state, normalized, signal) {
|
|
783
|
+
const stopReason = resolveStopReason({
|
|
784
|
+
signal,
|
|
785
|
+
current: state.filesScanned,
|
|
786
|
+
max: normalized.maxFilesScanned,
|
|
787
|
+
abortedReason: 'timeout',
|
|
788
|
+
maxReason: 'maxFiles',
|
|
789
|
+
});
|
|
790
|
+
if (stopReason !== undefined) {
|
|
791
|
+
state.truncated = true;
|
|
792
|
+
state.stoppedReason = stopReason;
|
|
793
|
+
return true;
|
|
794
|
+
}
|
|
795
|
+
return false;
|
|
796
|
+
}
|
|
797
|
+
function shouldIncludeEntry(entryType, normalized) {
|
|
798
|
+
return !normalized.skipSymlinks || entryType !== 'symlink';
|
|
799
|
+
}
|
|
800
|
+
function createCollectState() {
|
|
801
|
+
return {
|
|
802
|
+
results: [],
|
|
803
|
+
filesScanned: 0,
|
|
804
|
+
truncated: false,
|
|
805
|
+
skippedInaccessible: 0,
|
|
806
|
+
};
|
|
807
|
+
}
|
|
808
|
+
function buildSearchStream(root, pattern, excludePatterns, normalized, needsStats) {
|
|
809
|
+
const options = buildGlobOptions({
|
|
810
|
+
cwd: root,
|
|
811
|
+
pattern,
|
|
812
|
+
excludePatterns,
|
|
813
|
+
includeHidden: normalized.includeHidden,
|
|
814
|
+
baseNameMatch: normalized.baseNameMatch,
|
|
815
|
+
caseSensitiveMatch: true,
|
|
816
|
+
followSymbolicLinks: false,
|
|
817
|
+
onlyFiles: true,
|
|
818
|
+
stats: needsStats,
|
|
819
|
+
...(normalized.maxDepth !== undefined
|
|
820
|
+
? { maxDepth: normalized.maxDepth }
|
|
821
|
+
: {}),
|
|
822
|
+
});
|
|
823
|
+
return globEntries(options);
|
|
824
|
+
}
|
|
825
|
+
function buildCollectResult(state) {
|
|
826
|
+
const outcome = {
|
|
827
|
+
results: state.results,
|
|
828
|
+
filesScanned: state.filesScanned,
|
|
829
|
+
truncated: state.truncated,
|
|
830
|
+
skippedInaccessible: state.skippedInaccessible,
|
|
831
|
+
};
|
|
832
|
+
if (state.stoppedReason !== undefined) {
|
|
833
|
+
outcome.stoppedReason = state.stoppedReason;
|
|
834
|
+
}
|
|
835
|
+
return outcome;
|
|
836
|
+
}
|
|
837
|
+
function handleEntry(entry, entryType, needsStats, normalized, state) {
|
|
838
|
+
state.results.push(buildSearchFilesResult(entry, entryType, needsStats));
|
|
839
|
+
if (state.results.length >= normalized.maxResults) {
|
|
840
|
+
state.truncated = true;
|
|
841
|
+
state.stoppedReason = 'maxResults';
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
async function collectFromStream(stream, root, rootDirectories, gitignoreMatcher, normalized, needsStats, state, signal, accessDeps, onProgress) {
|
|
845
|
+
for await (const entry of stream) {
|
|
846
|
+
if (shouldStopCollecting(state, normalized, signal))
|
|
847
|
+
break;
|
|
848
|
+
state.filesScanned++;
|
|
849
|
+
reportPeriodicProgress(onProgress, state.filesScanned, {
|
|
850
|
+
total: normalized.maxFilesScanned,
|
|
851
|
+
throttleModulo: 25,
|
|
852
|
+
});
|
|
853
|
+
if (isEntryIgnoredByGitignore(gitignoreMatcher, root, entry.path, entry.relativePath)) {
|
|
854
|
+
continue;
|
|
855
|
+
}
|
|
856
|
+
const entryType = resolveEntryType(entry.dirent);
|
|
857
|
+
if (!shouldIncludeEntry(entryType, normalized)) {
|
|
858
|
+
continue;
|
|
859
|
+
}
|
|
860
|
+
const isAccessible = await isEntryAccessibleByType(entry.path, entryType, rootDirectories, signal, accessDeps);
|
|
861
|
+
if (!isAccessible) {
|
|
862
|
+
state.skippedInaccessible++;
|
|
863
|
+
continue;
|
|
864
|
+
}
|
|
865
|
+
handleEntry(entry, entryType, needsStats, normalized, state);
|
|
866
|
+
if (state.truncated)
|
|
867
|
+
break;
|
|
868
|
+
}
|
|
869
|
+
reportPeriodicProgress(onProgress, state.filesScanned, {
|
|
870
|
+
total: normalized.maxFilesScanned,
|
|
871
|
+
throttleModulo: 25,
|
|
872
|
+
force: true,
|
|
873
|
+
});
|
|
874
|
+
}
|
|
875
|
+
function isEntryIgnoredByGitignore(matcher, root, entryPath, relativePath) {
|
|
876
|
+
if (!matcher)
|
|
877
|
+
return false;
|
|
878
|
+
return isIgnoredByGitignore(matcher, root, entryPath, relativePath ? { relativePath } : {});
|
|
879
|
+
}
|
|
880
|
+
async function collectSearchResults(root, pattern, excludePatterns, normalized, signal, onProgress) {
|
|
881
|
+
const needsStats = needsStatsForSort(normalized.sortBy);
|
|
882
|
+
const stream = buildSearchStream(root, pattern, excludePatterns, normalized, needsStats);
|
|
883
|
+
const state = createCollectState();
|
|
884
|
+
const rootDirectories = [root];
|
|
885
|
+
const accessDeps = {
|
|
886
|
+
normalizePath,
|
|
887
|
+
isPathWithinDirectories,
|
|
888
|
+
isSensitivePath,
|
|
889
|
+
validateSymlinkPath: validateExistingPathDetailed,
|
|
890
|
+
};
|
|
891
|
+
const gitignoreMatcher = normalized.respectGitignore
|
|
892
|
+
? await loadRootGitignore(root, signal)
|
|
893
|
+
: null;
|
|
894
|
+
await collectFromStream(stream, root, rootDirectories, gitignoreMatcher, normalized, needsStats, state, signal, accessDeps, onProgress);
|
|
895
|
+
return buildCollectResult(state);
|
|
896
|
+
}
|
|
897
|
+
function buildSearchSummary(results, filesScanned, truncated, stoppedReason, skippedInaccessible) {
|
|
898
|
+
const summary = {
|
|
899
|
+
matched: results.length,
|
|
900
|
+
truncated,
|
|
901
|
+
skippedInaccessible,
|
|
902
|
+
filesScanned,
|
|
903
|
+
};
|
|
904
|
+
return withOptionalStoppedReason(summary, stoppedReason);
|
|
905
|
+
}
|
|
906
|
+
function compareNameThenPath(a, b) {
|
|
907
|
+
const nameCompare = compareStringValues(a.name, b.name);
|
|
908
|
+
if (nameCompare !== 0)
|
|
909
|
+
return nameCompare;
|
|
910
|
+
return compareStringValues(a.path, b.path);
|
|
911
|
+
}
|
|
912
|
+
function comparePathThenName(a, b) {
|
|
913
|
+
const pathCompare = compareStringValues(a.path, b.path);
|
|
914
|
+
if (pathCompare !== 0)
|
|
915
|
+
return pathCompare;
|
|
916
|
+
return compareStringValues(a.name, b.name);
|
|
917
|
+
}
|
|
918
|
+
const SORT_COMPARATORS = {
|
|
919
|
+
size: (a, b) => compareOptionalNumberDesc(a.size, b.size, () => compareNameThenPath(a, b)),
|
|
920
|
+
modified: (a, b) => compareOptionalNumberDesc(a.modified?.getTime(), b.modified?.getTime(), () => compareNameThenPath(a, b)),
|
|
921
|
+
path: (a, b) => comparePathThenName(a, b),
|
|
922
|
+
name: (a, b) => compareNameThenPath(a, b),
|
|
923
|
+
};
|
|
924
|
+
export function sortSearchResults(results, sortBy) {
|
|
925
|
+
if (sortBy === 'name') {
|
|
926
|
+
stableSortByDerivedString(results, (item) => path.basename(item.path ?? ''), (left, right) => comparePathThenName(left, right));
|
|
927
|
+
return;
|
|
928
|
+
}
|
|
929
|
+
const comparator = SORT_COMPARATORS[sortBy];
|
|
930
|
+
results.sort(comparator);
|
|
931
|
+
}
|
|
932
|
+
async function runSearchFiles(root, pattern, excludePatterns, normalized, signal, onProgress) {
|
|
933
|
+
const { results, filesScanned, truncated, stoppedReason, skippedInaccessible, } = await collectSearchResults(root, pattern, excludePatterns, normalized, signal, onProgress);
|
|
934
|
+
sortSearchResults(results, normalized.sortBy);
|
|
935
|
+
return {
|
|
936
|
+
results,
|
|
937
|
+
summary: buildSearchSummary(results, filesScanned, truncated, stoppedReason, skippedInaccessible),
|
|
938
|
+
};
|
|
939
|
+
}
|
|
940
|
+
export async function searchFiles(basePath, pattern, excludePatterns = [], options = {}) {
|
|
941
|
+
const normalized = normalizeSearchFilesOptions(options);
|
|
942
|
+
return withTimedAbortSignal(options.signal, normalized.timeoutMs, async (signal) => {
|
|
943
|
+
const root = await validateExistingDirectory(basePath, signal);
|
|
944
|
+
const { results, summary } = await runSearchFiles(root, pattern, excludePatterns, normalized, signal, options.onProgress);
|
|
945
|
+
return {
|
|
946
|
+
basePath: root,
|
|
947
|
+
pattern,
|
|
948
|
+
results,
|
|
949
|
+
summary,
|
|
950
|
+
};
|
|
951
|
+
});
|
|
952
|
+
}
|
|
953
|
+
const matcherCache = new Map();
|
|
954
|
+
const MAX_MATCHER_CACHE_SIZE = 100;
|
|
955
|
+
function getMatcherCacheKey(pattern, options) {
|
|
956
|
+
const cs = options.caseSensitive ? '1' : '0';
|
|
957
|
+
const ww = options.wholeWord ? '1' : '0';
|
|
958
|
+
const lit = options.isLiteral ? '1' : '0';
|
|
959
|
+
return `${pattern}|${cs}|${ww}|${lit}`;
|
|
960
|
+
}
|
|
961
|
+
function getCachedMatcher(pattern, options) {
|
|
962
|
+
const key = getMatcherCacheKey(pattern, options);
|
|
963
|
+
const cached = matcherCache.get(key);
|
|
964
|
+
if (cached) {
|
|
965
|
+
refreshMatcherCacheEntry(key, cached);
|
|
966
|
+
return cached;
|
|
967
|
+
}
|
|
968
|
+
const matcher = buildMatcher(pattern, options);
|
|
969
|
+
refreshMatcherCacheEntry(key, matcher);
|
|
970
|
+
evictOldestMatcherIfNeeded();
|
|
971
|
+
return matcher;
|
|
972
|
+
}
|
|
973
|
+
function refreshMatcherCacheEntry(key, matcher) {
|
|
974
|
+
matcherCache.delete(key);
|
|
975
|
+
matcherCache.set(key, matcher);
|
|
976
|
+
}
|
|
977
|
+
function evictOldestMatcherIfNeeded() {
|
|
978
|
+
if (matcherCache.size <= MAX_MATCHER_CACHE_SIZE)
|
|
979
|
+
return;
|
|
980
|
+
const firstKey = matcherCache.keys().next().value;
|
|
981
|
+
if (firstKey !== undefined) {
|
|
982
|
+
matcherCache.delete(firstKey);
|
|
983
|
+
}
|
|
984
|
+
}
|
|
985
|
+
const cancelledRequests = new Set();
|
|
986
|
+
const activeRequests = new Set();
|
|
987
|
+
let shuttingDown = false;
|
|
988
|
+
function maybeFinishShutdown() {
|
|
989
|
+
if (!shuttingDown)
|
|
990
|
+
return;
|
|
991
|
+
if (activeRequests.size > 0)
|
|
992
|
+
return;
|
|
993
|
+
parentPort?.close();
|
|
994
|
+
}
|
|
995
|
+
function consumeCancelled(id) {
|
|
996
|
+
if (!cancelledRequests.has(id)) {
|
|
997
|
+
return false;
|
|
998
|
+
}
|
|
999
|
+
cancelledRequests.delete(id);
|
|
1000
|
+
return true;
|
|
1001
|
+
}
|
|
1002
|
+
function markCancelledIfActive(id) {
|
|
1003
|
+
if (activeRequests.has(id)) {
|
|
1004
|
+
cancelledRequests.add(id);
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
function buildScanResponse(id, result) {
|
|
1008
|
+
return {
|
|
1009
|
+
type: 'result',
|
|
1010
|
+
id,
|
|
1011
|
+
result,
|
|
1012
|
+
};
|
|
1013
|
+
}
|
|
1014
|
+
function buildErrorResponse(id, error) {
|
|
1015
|
+
return {
|
|
1016
|
+
type: 'error',
|
|
1017
|
+
id,
|
|
1018
|
+
error: formatUnknownErrorMessage(error),
|
|
1019
|
+
};
|
|
1020
|
+
}
|
|
1021
|
+
async function handleScanRequest(request) {
|
|
1022
|
+
const { id, resolvedPath, requestedPath, pattern, matcherOptions, scanOptions, maxMatches, } = request;
|
|
1023
|
+
if (consumeCancelled(id))
|
|
1024
|
+
return;
|
|
1025
|
+
activeRequests.add(id);
|
|
1026
|
+
const endMeasure = startPerfMeasure('searchWorker.scan', {
|
|
1027
|
+
maxMatches,
|
|
1028
|
+
});
|
|
1029
|
+
let ok = false;
|
|
1030
|
+
try {
|
|
1031
|
+
const matcher = getCachedMatcher(pattern, matcherOptions);
|
|
1032
|
+
const isCancelled = () => cancelledRequests.has(id);
|
|
1033
|
+
const result = await scanFileInWorker(resolvedPath, requestedPath, matcher, scanOptions, maxMatches, isCancelled, isProbablyBinary);
|
|
1034
|
+
if (consumeCancelled(id))
|
|
1035
|
+
return;
|
|
1036
|
+
parentPort?.postMessage(buildScanResponse(id, result));
|
|
1037
|
+
ok = true;
|
|
1038
|
+
}
|
|
1039
|
+
catch (err) {
|
|
1040
|
+
if (consumeCancelled(id))
|
|
1041
|
+
return;
|
|
1042
|
+
parentPort?.postMessage(buildErrorResponse(id, err));
|
|
1043
|
+
}
|
|
746
1044
|
finally {
|
|
747
|
-
|
|
1045
|
+
activeRequests.delete(id);
|
|
1046
|
+
cancelledRequests.delete(id);
|
|
1047
|
+
endMeasure?.(ok);
|
|
1048
|
+
maybeFinishShutdown();
|
|
1049
|
+
}
|
|
1050
|
+
}
|
|
1051
|
+
function handleMessage(message) {
|
|
1052
|
+
switch (message.type) {
|
|
1053
|
+
case 'scan':
|
|
1054
|
+
if (shuttingDown)
|
|
1055
|
+
return;
|
|
1056
|
+
void handleScanRequest(message);
|
|
1057
|
+
break;
|
|
1058
|
+
case 'cancel':
|
|
1059
|
+
markCancelledIfActive(message.id);
|
|
1060
|
+
break;
|
|
1061
|
+
case 'shutdown':
|
|
1062
|
+
shuttingDown = true;
|
|
1063
|
+
for (const id of activeRequests) {
|
|
1064
|
+
markCancelledIfActive(id);
|
|
1065
|
+
}
|
|
1066
|
+
maybeFinishShutdown();
|
|
1067
|
+
break;
|
|
1068
|
+
}
|
|
1069
|
+
}
|
|
1070
|
+
if (parentPort) {
|
|
1071
|
+
parentPort.on('message', handleMessage);
|
|
1072
|
+
const data = workerData;
|
|
1073
|
+
if (data?.debug) {
|
|
1074
|
+
console.error(`[SearchWorker] Started with threadId=${String(threadId)}`);
|
|
748
1075
|
}
|
|
749
1076
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { Stats } from 'node:fs';
|
|
2
|
-
import type { DirentLike } from './
|
|
2
|
+
import type { DirentLike } from './core.js';
|
|
3
3
|
interface GlobEntry {
|
|
4
4
|
path: string;
|
|
5
5
|
relativePath?: string;
|
|
@@ -20,4 +20,21 @@ export interface GlobEntriesOptions {
|
|
|
20
20
|
suppressErrors?: boolean;
|
|
21
21
|
}
|
|
22
22
|
export declare function globEntries(options: GlobEntriesOptions): AsyncGenerator<GlobEntry>;
|
|
23
|
+
export interface GlobConfig {
|
|
24
|
+
cwd: string;
|
|
25
|
+
pattern: string;
|
|
26
|
+
excludePatterns?: readonly string[];
|
|
27
|
+
includeHidden?: boolean;
|
|
28
|
+
baseNameMatch?: boolean;
|
|
29
|
+
caseSensitiveMatch?: boolean;
|
|
30
|
+
followSymbolicLinks?: boolean;
|
|
31
|
+
onlyFiles?: boolean;
|
|
32
|
+
stats?: boolean;
|
|
33
|
+
maxDepth?: number;
|
|
34
|
+
suppressErrors?: boolean;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Builds standard options for globEntries to ensure consistency across search tools.
|
|
38
|
+
*/
|
|
39
|
+
export declare function buildGlobOptions(config: GlobConfig): Parameters<typeof globEntries>[0];
|
|
23
40
|
export {};
|