@j0hanz/filesystem-mcp 1.1.2 → 1.2.1

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.
Files changed (62) hide show
  1. package/README.md +514 -188
  2. package/dist/cli.js +29 -12
  3. package/dist/completions.js +50 -24
  4. package/dist/config.d.ts +4 -2
  5. package/dist/config.js +2 -1
  6. package/dist/index.js +14 -12
  7. package/dist/instructions.md +109 -97
  8. package/dist/lib/constants.js +25 -14
  9. package/dist/lib/errors.js +15 -8
  10. package/dist/lib/file-operations/common.d.ts +4 -0
  11. package/dist/lib/file-operations/common.js +9 -0
  12. package/dist/lib/file-operations/file-info.js +22 -10
  13. package/dist/lib/file-operations/gitignore.js +14 -11
  14. package/dist/lib/file-operations/glob-engine.d.ts +1 -0
  15. package/dist/lib/file-operations/glob-engine.js +46 -33
  16. package/dist/lib/file-operations/list-directory.js +31 -35
  17. package/dist/lib/file-operations/read-multiple-files.js +70 -62
  18. package/dist/lib/file-operations/search-content.js +83 -64
  19. package/dist/lib/file-operations/search-files.js +32 -30
  20. package/dist/lib/file-operations/search-worker.js +22 -12
  21. package/dist/lib/file-operations/tree.js +43 -34
  22. package/dist/lib/fs-helpers.js +61 -124
  23. package/dist/lib/observability.js +29 -28
  24. package/dist/lib/path-format.d.ts +1 -0
  25. package/dist/lib/path-format.js +7 -0
  26. package/dist/lib/path-policy.js +22 -20
  27. package/dist/lib/path-validation.js +13 -7
  28. package/dist/lib/resource-store.d.ts +2 -0
  29. package/dist/lib/resource-store.js +26 -5
  30. package/dist/lib/type-guards.d.ts +1 -0
  31. package/dist/lib/type-guards.js +3 -0
  32. package/dist/prompts.d.ts +1 -5
  33. package/dist/prompts.js +9 -16
  34. package/dist/resources.d.ts +1 -5
  35. package/dist/resources.js +12 -26
  36. package/dist/schemas.d.ts +232 -30
  37. package/dist/schemas.js +52 -90
  38. package/dist/server.js +96 -44
  39. package/dist/tools/apply-patch.js +23 -22
  40. package/dist/tools/calculate-hash.js +41 -43
  41. package/dist/tools/create-directory.js +17 -19
  42. package/dist/tools/delete-file.js +35 -37
  43. package/dist/tools/diff-files.js +15 -19
  44. package/dist/tools/edit-file.js +15 -18
  45. package/dist/tools/list-directory.js +24 -23
  46. package/dist/tools/move-file.js +17 -19
  47. package/dist/tools/read-multiple.js +55 -66
  48. package/dist/tools/read.js +26 -30
  49. package/dist/tools/replace-in-files.js +27 -33
  50. package/dist/tools/roots.js +8 -8
  51. package/dist/tools/search-content.js +73 -72
  52. package/dist/tools/search-files.js +44 -50
  53. package/dist/tools/shared.d.ts +44 -6
  54. package/dist/tools/shared.js +86 -64
  55. package/dist/tools/stat-many.js +44 -66
  56. package/dist/tools/stat.js +10 -37
  57. package/dist/tools/task-support.d.ts +9 -1
  58. package/dist/tools/task-support.js +86 -81
  59. package/dist/tools/tree.js +12 -28
  60. package/dist/tools/write-file.js +17 -19
  61. package/dist/tools.js +23 -18
  62. package/package.json +6 -7
@@ -2,6 +2,7 @@ import * as fsp from 'node:fs/promises';
2
2
  import { DEFAULT_READ_MANY_MAX_TOTAL_SIZE, MAX_TEXT_FILE_SIZE, PARALLEL_CONCURRENCY, } from '../constants.js';
3
3
  import { processInParallel, readFile, readFileWithStats, withAbort, } from '../fs-helpers.js';
4
4
  import { validateExistingPath } from '../path-validation.js';
5
+ const UNKNOWN_PATH = '(unknown)';
5
6
  function estimateReadSize(stats, maxSize) {
6
7
  // `readFile`/`readFileWithStats` are always invoked with a `maxSize` cap, so the
7
8
  // combined budget should reflect the maximum number of bytes we might actually read.
@@ -12,45 +13,33 @@ function buildReadOptions(options) {
12
13
  encoding: options.encoding,
13
14
  maxSize: options.maxSize,
14
15
  };
15
- if (options.head !== undefined) {
16
- readOptions.head = options.head;
17
- }
18
- if (options.startLine !== undefined) {
19
- readOptions.startLine = options.startLine;
20
- }
21
- if (options.endLine !== undefined) {
22
- readOptions.endLine = options.endLine;
23
- }
16
+ applyLineSelection(readOptions, options);
24
17
  return readOptions;
25
18
  }
26
19
  function buildReadMultipleResult(filePath, result) {
27
- const value = {
20
+ const output = {
28
21
  path: filePath,
29
22
  content: result.content,
30
23
  truncated: result.truncated,
31
24
  readMode: result.readMode,
32
25
  };
33
26
  if (result.totalLines !== undefined)
34
- value.totalLines = result.totalLines;
27
+ output.totalLines = result.totalLines;
35
28
  if (result.head !== undefined)
36
- value.head = result.head;
29
+ output.head = result.head;
37
30
  if (result.startLine !== undefined)
38
- value.startLine = result.startLine;
31
+ output.startLine = result.startLine;
39
32
  if (result.endLine !== undefined)
40
- value.endLine = result.endLine;
33
+ output.endLine = result.endLine;
41
34
  if (result.linesRead !== undefined)
42
- value.linesRead = result.linesRead;
35
+ output.linesRead = result.linesRead;
43
36
  if (result.hasMoreLines !== undefined) {
44
- value.hasMoreLines = result.hasMoreLines;
37
+ output.hasMoreLines = result.hasMoreLines;
45
38
  }
46
- return value;
39
+ return output;
47
40
  }
48
- async function readSingleFile(task, options, signal) {
41
+ async function readSingleFile(task, readOptions) {
49
42
  const { filePath, index, validPath, stats } = task;
50
- const readOptions = buildReadOptions(options);
51
- if (signal) {
52
- readOptions.signal = signal;
53
- }
54
43
  const result = validPath && stats
55
44
  ? await readFileWithStats(filePath, validPath, stats, readOptions)
56
45
  : await readFile(filePath, readOptions);
@@ -60,7 +49,11 @@ async function readSingleFile(task, options, signal) {
60
49
  };
61
50
  }
62
51
  async function readFilesInParallel(filesToProcess, options, signal) {
63
- return await processInParallel(filesToProcess, async (task) => readSingleFile(task, options, signal), PARALLEL_CONCURRENCY, signal);
52
+ const readOptions = buildReadOptions(options);
53
+ if (signal) {
54
+ readOptions.signal = signal;
55
+ }
56
+ return processInParallel(filesToProcess, async (task) => readSingleFile(task, readOptions), PARALLEL_CONCURRENCY, signal);
64
57
  }
65
58
  function normalizeReadMultipleOptions(options) {
66
59
  const normalized = {
@@ -68,24 +61,23 @@ function normalizeReadMultipleOptions(options) {
68
61
  maxSize: Math.min(options.maxSize ?? MAX_TEXT_FILE_SIZE, MAX_TEXT_FILE_SIZE),
69
62
  maxTotalSize: options.maxTotalSize ?? DEFAULT_READ_MANY_MAX_TOTAL_SIZE,
70
63
  };
71
- if (options.head !== undefined) {
72
- normalized.head = options.head;
73
- }
74
- if (options.startLine !== undefined) {
75
- normalized.startLine = options.startLine;
76
- }
77
- if (options.endLine !== undefined) {
78
- normalized.endLine = options.endLine;
79
- }
64
+ applyLineSelection(normalized, options);
80
65
  return normalized;
81
66
  }
67
+ function applyLineSelection(target, source) {
68
+ if (source.head !== undefined)
69
+ target.head = source.head;
70
+ if (source.startLine !== undefined)
71
+ target.startLine = source.startLine;
72
+ if (source.endLine !== undefined)
73
+ target.endLine = source.endLine;
74
+ }
82
75
  function resolveNormalizedOptions(options) {
83
76
  const { signal, ...rest } = options;
84
- const resolved = { normalized: normalizeReadMultipleOptions(rest) };
85
- if (signal) {
86
- resolved.signal = signal;
87
- }
88
- return resolved;
77
+ return {
78
+ normalized: normalizeReadMultipleOptions(rest),
79
+ ...(signal ? { signal } : {}),
80
+ };
89
81
  }
90
82
  async function validateFile(filePath, index, signal) {
91
83
  const validPath = await validateExistingPath(filePath, signal);
@@ -108,7 +100,7 @@ async function tryValidateFile(filePath, index, signal) {
108
100
  async function validateBatch(tasks, signal) {
109
101
  if (tasks.length === 0)
110
102
  return new Map();
111
- const { results } = await processInParallel(tasks, async (task) => await tryValidateFile(task.filePath, task.index, signal), PARALLEL_CONCURRENCY, signal);
103
+ const { results } = await processInParallel(tasks, async (task) => tryValidateFile(task.filePath, task.index, signal), PARALLEL_CONCURRENCY, signal);
112
104
  const infos = new Map();
113
105
  for (const info of results) {
114
106
  if (!info)
@@ -124,7 +116,9 @@ async function applyBudgetForRange(options) {
124
116
  const filePath = filePaths[index];
125
117
  if (!filePath)
126
118
  continue;
127
- const info = await resolveValidatedInfo(filePath, index, validated, signal);
119
+ const cached = validated.get(index);
120
+ const info = cached ??
121
+ (await resolveValidatedInfo(filePath, index, validated, signal));
128
122
  if (!info)
129
123
  continue;
130
124
  const { exceeded, totalSize: nextTotalSize } = applyBudget(totalSize, estimateReadSize(info.stats, maxSize), maxTotalSize, index, totalFiles, skippedBudget);
@@ -176,24 +170,23 @@ async function collectFileBudget(filePaths, maxTotalSize, maxSize, signal) {
176
170
  return { skippedBudget, validated };
177
171
  }
178
172
  async function resolveValidatedInfo(filePath, index, validated, signal) {
179
- if (!validated.has(index)) {
180
- const info = await tryValidateFile(filePath, index, signal);
181
- if (info) {
182
- validated.set(index, info);
183
- }
173
+ const existing = validated.get(index);
174
+ if (existing) {
175
+ return existing;
184
176
  }
185
- return validated.get(index);
186
- }
187
- function applyBudget(totalSize, estimatedSize, maxTotalSize, index, totalFiles, skippedBudget) {
188
- if (totalSize + estimatedSize > maxTotalSize) {
189
- skippedBudget.add(index);
190
- markRemainingSkipped(index + 1, totalFiles, skippedBudget);
191
- return { totalSize, exceeded: true };
177
+ const info = await tryValidateFile(filePath, index, signal);
178
+ if (info) {
179
+ validated.set(index, info);
180
+ return info;
192
181
  }
193
- return { totalSize: totalSize + estimatedSize, exceeded: false };
182
+ return undefined;
194
183
  }
195
184
  function buildOutput(filePaths) {
196
- return filePaths.map((filePath) => ({ path: filePath }));
185
+ const output = new Array(filePaths.length);
186
+ for (let index = 0; index < filePaths.length; index += 1) {
187
+ output[index] = { path: filePaths[index] ?? UNKNOWN_PATH };
188
+ }
189
+ return output;
197
190
  }
198
191
  function applyResults(output, results) {
199
192
  for (const result of results) {
@@ -219,7 +212,7 @@ function applyErrors(output, errors, filesToProcess, filePaths) {
219
212
  const originalIndex = resolveErrorOriginalIndex(failure.index, filesToProcess, filePaths.length);
220
213
  if (originalIndex === undefined)
221
214
  continue;
222
- const filePath = filePaths[originalIndex] ?? '(unknown)';
215
+ const filePath = filePaths[originalIndex] ?? UNKNOWN_PATH;
223
216
  output[originalIndex] = {
224
217
  path: filePath,
225
218
  error: failure.error.message,
@@ -227,19 +220,34 @@ function applyErrors(output, errors, filesToProcess, filePaths) {
227
220
  }
228
221
  }
229
222
  function buildFilesToProcess(filePaths, validated, skippedBudget) {
230
- return filePaths
231
- .map((filePath, index) => {
223
+ const filesToProcess = [];
224
+ for (let index = 0; index < filePaths.length; index += 1) {
225
+ if (skippedBudget.has(index))
226
+ continue;
227
+ const filePath = filePaths[index];
228
+ if (!filePath)
229
+ continue;
232
230
  const cached = validated.get(index);
233
- return cached
234
- ? {
231
+ if (cached) {
232
+ filesToProcess.push({
235
233
  filePath,
236
234
  index,
237
235
  validPath: cached.validPath,
238
236
  stats: cached.stats,
239
- }
240
- : { filePath, index };
241
- })
242
- .filter(({ index }) => !skippedBudget.has(index));
237
+ });
238
+ continue;
239
+ }
240
+ filesToProcess.push({ filePath, index });
241
+ }
242
+ return filesToProcess;
243
+ }
244
+ function applyBudget(totalSize, estimatedSize, maxTotalSize, index, totalFiles, skippedBudget) {
245
+ if (totalSize + estimatedSize > maxTotalSize) {
246
+ skippedBudget.add(index);
247
+ markRemainingSkipped(index + 1, totalFiles, skippedBudget);
248
+ return { totalSize, exceeded: true };
249
+ }
250
+ return { totalSize: totalSize + estimatedSize, exceeded: false };
243
251
  }
244
252
  function applySkippedBudget(output, skippedBudget, filePaths, maxTotalSize) {
245
253
  for (const index of skippedBudget) {
@@ -10,6 +10,7 @@ import { ErrorCode, formatUnknownErrorMessage, isTimeoutLikeError, McpError, } f
10
10
  import { assertNotAborted, createTimedAbortSignal, isProbablyBinary, withAbort, } from '../fs-helpers.js';
11
11
  import { assertAllowedFileAccess, isSensitivePath } from '../path-policy.js';
12
12
  import { isPathWithinDirectories, normalizePath, validateExistingDirectory, validateExistingPathDetailed, } from '../path-validation.js';
13
+ import { withOptionalStoppedReason } from './common.js';
13
14
  import { globEntries } from './glob-engine.js';
14
15
  // --- Configuration & Schemas ---
15
16
  const INTERNAL_MAX_RESULTS = 500;
@@ -64,6 +65,16 @@ function resolveOptions(options) {
64
65
  }
65
66
  return result.data;
66
67
  }
68
+ function countRegexLineMatches(regex, line) {
69
+ regex.lastIndex = 0;
70
+ let count = 0;
71
+ while (regex.exec(line) !== null) {
72
+ count++;
73
+ if (regex.lastIndex === 0)
74
+ regex.lastIndex++;
75
+ }
76
+ return count;
77
+ }
67
78
  function escapeLiteral(pattern) {
68
79
  return pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
69
80
  }
@@ -85,16 +96,7 @@ function buildLiteralMatcher(pattern, options) {
85
96
  if (!options.caseSensitive) {
86
97
  const final = escapeLiteral(pattern);
87
98
  const regex = new RegExp(final, 'gi');
88
- return (line) => {
89
- regex.lastIndex = 0;
90
- let count = 0;
91
- while (regex.exec(line) !== null) {
92
- count++;
93
- if (regex.lastIndex === 0)
94
- regex.lastIndex++;
95
- }
96
- return count;
97
- };
99
+ return (line) => countRegexLineMatches(regex, line);
98
100
  }
99
101
  // Fast path for case-sensitive literal
100
102
  const needle = pattern;
@@ -114,16 +116,7 @@ function buildLiteralMatcher(pattern, options) {
114
116
  }
115
117
  function buildRegexMatcher(final, caseSensitive) {
116
118
  const regex = new RE2(final, caseSensitive ? 'g' : 'gi');
117
- return (line) => {
118
- regex.lastIndex = 0;
119
- let count = 0;
120
- while (regex.exec(line) !== null) {
121
- count++;
122
- if (regex.lastIndex === 0)
123
- regex.lastIndex++; // Avoid infinite loop on zero-width match
124
- }
125
- return count;
126
- };
119
+ return (line) => countRegexLineMatches(regex, line);
127
120
  }
128
121
  export function buildMatcher(pattern, options) {
129
122
  if (options.isLiteral && pattern.length === 0)
@@ -213,7 +206,8 @@ function trimContent(line) {
213
206
  }
214
207
  async function readMatches(handle, requestedPath, matcher, options, maxMatches, isCancelled, signal) {
215
208
  const matches = [];
216
- const ctx = new ContextBuffer(options.contextLines);
209
+ const hasContext = options.contextLines > 0;
210
+ const ctx = hasContext ? new ContextBuffer(options.contextLines) : undefined;
217
211
  let lineNumber = 1;
218
212
  // Use for-await with readLines for memory efficiency
219
213
  const lines = handle.readLines({ encoding: 'utf-8', signal });
@@ -224,22 +218,34 @@ async function readMatches(handle, requestedPath, matcher, options, maxMatches,
224
218
  if (isCancelled())
225
219
  break;
226
220
  const matchCount = matcher(rawLine);
227
- const content = trimContent(rawLine);
221
+ let content;
222
+ const getContent = () => {
223
+ content ??= trimContent(rawLine);
224
+ return content;
225
+ };
228
226
  if (matchCount > 0) {
229
- matches.push({
230
- file: requestedPath,
231
- line: lineNumber,
232
- content,
233
- matchCount,
234
- ...(options.contextLines > 0
235
- ? {
236
- contextBefore: ctx.snapshotBefore(),
237
- contextAfter: ctx.scheduleAfter(),
238
- }
239
- : {}),
240
- });
227
+ if (ctx) {
228
+ matches.push({
229
+ file: requestedPath,
230
+ line: lineNumber,
231
+ content: getContent(),
232
+ matchCount,
233
+ contextBefore: ctx.snapshotBefore(),
234
+ contextAfter: ctx.scheduleAfter(),
235
+ });
236
+ }
237
+ else {
238
+ matches.push({
239
+ file: requestedPath,
240
+ line: lineNumber,
241
+ content: getContent(),
242
+ matchCount,
243
+ });
244
+ }
245
+ }
246
+ if (ctx) {
247
+ ctx.add(getContent());
241
248
  }
242
- ctx.add(content);
243
249
  lineNumber++;
244
250
  }
245
251
  }
@@ -304,24 +310,22 @@ function createScanSummary() {
304
310
  };
305
311
  }
306
312
  function buildSearchResult(root, pattern, filePattern, matches, summary) {
313
+ const baseSummary = {
314
+ filesScanned: summary.filesScanned,
315
+ filesMatched: summary.filesMatched,
316
+ matches: matches.length,
317
+ truncated: summary.truncated,
318
+ skippedTooLarge: summary.skippedTooLarge,
319
+ skippedBinary: summary.skippedBinary,
320
+ skippedInaccessible: summary.skippedInaccessible,
321
+ linesSkippedDueToRegexTimeout: 0,
322
+ };
307
323
  return {
308
324
  basePath: root,
309
325
  pattern,
310
326
  filePattern,
311
327
  matches,
312
- summary: {
313
- filesScanned: summary.filesScanned,
314
- filesMatched: summary.filesMatched,
315
- matches: matches.length,
316
- truncated: summary.truncated,
317
- skippedTooLarge: summary.skippedTooLarge,
318
- skippedBinary: summary.skippedBinary,
319
- skippedInaccessible: summary.skippedInaccessible,
320
- linesSkippedDueToRegexTimeout: 0,
321
- ...(summary.stoppedReason
322
- ? { stoppedReason: summary.stoppedReason }
323
- : {}),
324
- },
328
+ summary: withOptionalStoppedReason(baseSummary, summary.stoppedReason),
325
329
  };
326
330
  }
327
331
  const currentDir = path.dirname(fileURLToPath(import.meta.url));
@@ -448,9 +452,15 @@ class SearchWorkerPool {
448
452
  for (const p of this.pending.values())
449
453
  p.reject(new Error(ERROR_WORKER_POOL_CLOSED));
450
454
  this.pending.clear();
451
- const workers = this.workers.filter((worker) => worker !== undefined);
452
- await Promise.all(workers.map((worker) => worker.terminate()));
453
- this.workers = Array.from({ length: this.size }, () => undefined);
455
+ const terminations = [];
456
+ for (let index = 0; index < this.workers.length; index += 1) {
457
+ const worker = this.workers[index];
458
+ if (!worker)
459
+ continue;
460
+ terminations.push(worker.terminate());
461
+ this.workers[index] = undefined;
462
+ }
463
+ await Promise.all(terminations);
454
464
  }
455
465
  }
456
466
  function isWorkerPoolAvailable() {
@@ -545,9 +555,14 @@ function processScanResult(winner, summary, matches, maxResults) {
545
555
  summary.skippedBinary++;
546
556
  if (res.skippedTooLarge)
547
557
  summary.skippedTooLarge++;
548
- const take = maxResults - matches.length;
549
- if (take > 0 && res.matches.length > 0) {
550
- matches.push(...res.matches.slice(0, take));
558
+ const remaining = maxResults - matches.length;
559
+ if (remaining > 0 && res.matches.length > 0) {
560
+ const take = Math.min(remaining, res.matches.length);
561
+ for (let index = 0; index < take; index += 1) {
562
+ const match = res.matches[index];
563
+ if (match)
564
+ matches.push(match);
565
+ }
551
566
  }
552
567
  }
553
568
  }
@@ -559,14 +574,17 @@ function reportSearchProgress(onProgress, current, total, force = false) {
559
574
  onProgress({ current, total });
560
575
  }
561
576
  async function waitForWinner(pending) {
562
- const pendingTasks = Array.from(pending);
563
- return Promise.race(pendingTasks.map((t) => t.promise.then((res) => ({ task: t, result: res, error: undefined }), (err) => ({
564
- task: t,
565
- result: undefined,
566
- error: err instanceof Error
567
- ? err
568
- : new Error(formatUnknownErrorMessage(err)),
569
- }))));
577
+ const raceCandidates = [];
578
+ for (const task of pending) {
579
+ raceCandidates.push(task.promise.then((result) => ({ task, result, error: undefined }), (err) => ({
580
+ task,
581
+ result: undefined,
582
+ error: err instanceof Error
583
+ ? err
584
+ : new Error(formatUnknownErrorMessage(err)),
585
+ })));
586
+ }
587
+ return Promise.race(raceCandidates);
570
588
  }
571
589
  async function executeParallel(files, pattern, opts, signal, summary) {
572
590
  const pool = getPool();
@@ -661,12 +679,13 @@ export async function searchContent(basePath, pattern, options = {}) {
661
679
  }, signal, opts.maxResults);
662
680
  if (result.matched)
663
681
  summary.filesMatched = 1;
664
- return buildSearchResult(path.dirname(details.resolvedPath), pattern, opts.filePattern, [...result.matches], summary);
682
+ return buildSearchResult(path.dirname(details.resolvedPath), pattern, opts.filePattern, result.matches, summary);
665
683
  }
666
684
  if (!stats.isDirectory()) {
667
685
  throw new McpError(ErrorCode.E_INVALID_INPUT, `Path must be file or directory`, basePath);
668
686
  }
669
687
  const root = await validateExistingDirectory(details.resolvedPath, signal);
688
+ const rootDirectories = [root];
670
689
  // Glob
671
690
  const stream = globEntries({
672
691
  cwd: root,
@@ -693,7 +712,7 @@ export async function searchContent(basePath, pattern, options = {}) {
693
712
  // Helper to resolve
694
713
  // We duplicate simple resolution logic to keep it fast
695
714
  const normalized = normalizePath(entry.path);
696
- if (!isPathWithinDirectories(normalized, [root]))
715
+ if (!isPathWithinDirectories(normalized, rootDirectories))
697
716
  continue;
698
717
  if (isSensitivePath(entry.path, normalized))
699
718
  continue;
@@ -3,8 +3,9 @@ import { DEFAULT_SEARCH_MAX_FILES, DEFAULT_SEARCH_TIMEOUT_MS, } from '../constan
3
3
  import { createTimedAbortSignal } from '../fs-helpers.js';
4
4
  import { isSensitivePath } from '../path-policy.js';
5
5
  import { isPathWithinDirectories, normalizePath, validateExistingDirectory, validateExistingPathDetailed, } from '../path-validation.js';
6
+ import { needsStatsForSort, withOptionalStoppedReason } from './common.js';
6
7
  import { isIgnoredByGitignore, loadRootGitignore } from './gitignore.js';
7
- import { globEntries } from './glob-engine.js';
8
+ import { globEntries, resolveEntryType } from './glob-engine.js';
8
9
  // Internal default for find tool - not exposed to MCP users
9
10
  const INTERNAL_MAX_RESULTS = 1000;
10
11
  function normalizeOptions(options) {
@@ -23,15 +24,6 @@ function normalizeOptions(options) {
23
24
  }
24
25
  return normalized;
25
26
  }
26
- function resolveEntryType(dirent) {
27
- if (dirent.isDirectory())
28
- return 'directory';
29
- if (dirent.isSymbolicLink())
30
- return 'symlink';
31
- if (dirent.isFile())
32
- return 'file';
33
- return 'other';
34
- }
35
27
  function buildSearchResult(entry, entryType, needsStats) {
36
28
  let resolvedType = 'other';
37
29
  if (entryType === 'directory') {
@@ -49,9 +41,6 @@ function buildSearchResult(entry, entryType, needsStats) {
49
41
  ...(modified !== undefined ? { modified } : {}),
50
42
  };
51
43
  }
52
- function needsStatsForSort(sortBy) {
53
- return sortBy === 'size' || sortBy === 'modified';
54
- }
55
44
  function markStopped(state, reason) {
56
45
  state.truncated = true;
57
46
  state.stoppedReason = reason;
@@ -108,8 +97,6 @@ function buildCollectResult(state) {
108
97
  return outcome;
109
98
  }
110
99
  function handleEntry(entry, entryType, needsStats, normalized, state) {
111
- if (!shouldIncludeEntry(entryType, normalized))
112
- return;
113
100
  state.results.push(buildSearchResult(entry, entryType, needsStats));
114
101
  if (state.results.length >= normalized.maxResults) {
115
102
  markStopped(state, 'maxResults');
@@ -122,21 +109,20 @@ function reportSearchFilesProgress(onProgress, current, total, force = false) {
122
109
  return;
123
110
  onProgress({ current, total });
124
111
  }
125
- async function collectFromStream(stream, root, gitignoreMatcher, normalized, needsStats, state, signal, onProgress) {
112
+ async function collectFromStream(stream, root, rootDirectories, gitignoreMatcher, normalized, needsStats, state, signal, onProgress) {
126
113
  for await (const entry of stream) {
127
114
  if (shouldStopCollecting(state, normalized, signal))
128
115
  break;
129
116
  state.filesScanned++;
130
117
  reportSearchFilesProgress(onProgress, state.filesScanned, normalized.maxFilesScanned);
131
- if (gitignoreMatcher &&
132
- isIgnoredByGitignore(gitignoreMatcher, root, entry.path)) {
118
+ if (isEntryIgnoredByGitignore(gitignoreMatcher, root, entry.path)) {
133
119
  continue;
134
120
  }
135
121
  const entryType = resolveEntryType(entry.dirent);
136
122
  if (!shouldIncludeEntry(entryType, normalized)) {
137
123
  continue;
138
124
  }
139
- const isAccessible = await isEntryAccessible(entry, entryType, root, signal);
125
+ const isAccessible = await isEntryAccessible(entry, entryType, rootDirectories, signal);
140
126
  if (!isAccessible) {
141
127
  state.skippedInaccessible++;
142
128
  continue;
@@ -147,7 +133,12 @@ async function collectFromStream(stream, root, gitignoreMatcher, normalized, nee
147
133
  }
148
134
  reportSearchFilesProgress(onProgress, state.filesScanned, normalized.maxFilesScanned, true);
149
135
  }
150
- async function isEntryAccessible(entry, entryType, root, signal) {
136
+ function isEntryIgnoredByGitignore(matcher, root, entryPath) {
137
+ if (!matcher)
138
+ return false;
139
+ return isIgnoredByGitignore(matcher, root, entryPath);
140
+ }
141
+ async function isEntryAccessible(entry, entryType, rootDirectories, signal) {
151
142
  if (entryType === 'symlink') {
152
143
  try {
153
144
  const validated = await validateExistingPathDetailed(entry.path, signal);
@@ -158,7 +149,7 @@ async function isEntryAccessible(entry, entryType, root, signal) {
158
149
  }
159
150
  }
160
151
  const resolvedPath = normalizePath(entry.path);
161
- if (!isPathWithinDirectories(resolvedPath, [root])) {
152
+ if (!isPathWithinDirectories(resolvedPath, rootDirectories)) {
162
153
  return false;
163
154
  }
164
155
  return !isSensitivePath(entry.path, resolvedPath);
@@ -167,10 +158,11 @@ async function collectSearchResults(root, pattern, excludePatterns, normalized,
167
158
  const needsStats = needsStatsForSort(normalized.sortBy);
168
159
  const stream = buildSearchStream(root, pattern, excludePatterns, normalized, needsStats);
169
160
  const state = createCollectState();
161
+ const rootDirectories = [root];
170
162
  const gitignoreMatcher = normalized.respectGitignore
171
163
  ? await loadRootGitignore(root, signal)
172
164
  : null;
173
- await collectFromStream(stream, root, gitignoreMatcher, normalized, needsStats, state, signal, onProgress);
165
+ await collectFromStream(stream, root, rootDirectories, gitignoreMatcher, normalized, needsStats, state, signal, onProgress);
174
166
  return buildCollectResult(state);
175
167
  }
176
168
  function buildSearchSummary(results, filesScanned, truncated, stoppedReason, skippedInaccessible) {
@@ -179,9 +171,8 @@ function buildSearchSummary(results, filesScanned, truncated, stoppedReason, ski
179
171
  truncated,
180
172
  skippedInaccessible,
181
173
  filesScanned,
182
- ...(stoppedReason !== undefined ? { stoppedReason } : {}),
183
174
  };
184
- return summary;
175
+ return withOptionalStoppedReason(summary, stoppedReason);
185
176
  }
186
177
  const collator = new Intl.Collator(undefined, { numeric: true });
187
178
  function compareString(a, b) {
@@ -213,11 +204,17 @@ const SORT_COMPARATORS = {
213
204
  };
214
205
  export function sortSearchResults(results, sortBy) {
215
206
  if (sortBy === 'name') {
216
- const decorated = results.map((item, index) => ({
217
- item,
218
- baseName: path.basename(item.path ?? ''),
219
- index,
220
- }));
207
+ const decorated = [];
208
+ for (let index = 0; index < results.length; index += 1) {
209
+ const item = results[index];
210
+ if (!item)
211
+ continue;
212
+ decorated.push({
213
+ item,
214
+ baseName: path.basename(item.path ?? ''),
215
+ index,
216
+ });
217
+ }
221
218
  decorated.sort((a, b) => {
222
219
  const baseCompare = compareString(a.baseName, b.baseName);
223
220
  if (baseCompare !== 0)
@@ -227,7 +224,12 @@ export function sortSearchResults(results, sortBy) {
227
224
  return pathCompare;
228
225
  return a.index - b.index;
229
226
  });
230
- results.splice(0, results.length, ...decorated.map((entry) => entry.item));
227
+ for (let index = 0; index < decorated.length; index += 1) {
228
+ const entry = decorated[index];
229
+ if (entry) {
230
+ results[index] = entry.item;
231
+ }
232
+ }
231
233
  return;
232
234
  }
233
235
  const comparator = SORT_COMPARATORS[sortBy];