@j0hanz/filesystem-mcp 1.7.2 → 1.8.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.
Files changed (44) hide show
  1. package/dist/lib/file-operations/common.d.ts +42 -0
  2. package/dist/lib/file-operations/common.js +87 -0
  3. package/dist/lib/file-operations/file-info.js +13 -19
  4. package/dist/lib/file-operations/glob-engine.d.ts +1 -6
  5. package/dist/lib/file-operations/glob-engine.js +0 -9
  6. package/dist/lib/file-operations/glob-helpers.d.ts +18 -0
  7. package/dist/lib/file-operations/glob-helpers.js +23 -0
  8. package/dist/lib/file-operations/list-directory.js +22 -46
  9. package/dist/lib/file-operations/read-multiple-files.js +11 -19
  10. package/dist/lib/file-operations/search-content.d.ts +1 -8
  11. package/dist/lib/file-operations/search-content.js +126 -204
  12. package/dist/lib/file-operations/search-files.js +48 -94
  13. package/dist/lib/file-operations/search-matcher.d.ts +10 -0
  14. package/dist/lib/file-operations/search-matcher.js +72 -0
  15. package/dist/lib/file-operations/search-worker.js +3 -1
  16. package/dist/lib/file-operations/tree.d.ts +2 -2
  17. package/dist/lib/file-operations/tree.js +26 -42
  18. package/dist/lib/fs-helpers.d.ts +1 -0
  19. package/dist/lib/fs-helpers.js +9 -0
  20. package/dist/lib/option-utils.d.ts +3 -0
  21. package/dist/lib/option-utils.js +15 -0
  22. package/dist/lib/path-validation.d.ts +1 -0
  23. package/dist/lib/path-validation.js +7 -0
  24. package/dist/lib/progress-reporting.d.ts +11 -0
  25. package/dist/lib/progress-reporting.js +13 -0
  26. package/dist/prompts.js +3 -3
  27. package/dist/resources/generated-instructions.js +14 -14
  28. package/dist/resources/tool-catalog.js +9 -9
  29. package/dist/resources/tool-info.js +5 -5
  30. package/dist/resources/workflows.js +17 -17
  31. package/dist/schemas.js +21 -90
  32. package/dist/server/bootstrap.js +2 -2
  33. package/dist/tools/apply-patch.js +3 -0
  34. package/dist/tools/calculate-hash.js +12 -12
  35. package/dist/tools/delete-file.js +5 -2
  36. package/dist/tools/list-directory.js +3 -21
  37. package/dist/tools/read-multiple.js +11 -21
  38. package/dist/tools/replace-in-files.js +10 -11
  39. package/dist/tools/search-content.js +2 -2
  40. package/dist/tools/search-files.js +3 -21
  41. package/dist/tools/shared.d.ts +19 -0
  42. package/dist/tools/shared.js +64 -18
  43. package/dist/tools/stat-many.js +11 -22
  44. package/package.json +6 -2
@@ -3,22 +3,19 @@ import * as path from 'node:path';
3
3
  import { fileURLToPath, pathToFileURL } from 'node:url';
4
4
  import { Worker } from 'node:worker_threads';
5
5
  import { z } from 'zod';
6
- import RE2 from 're2';
7
- import safeRegex from 'safe-regex2';
8
6
  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
7
  import { ErrorCode, formatUnknownErrorMessage, isTimeoutLikeError, McpError, } from '../errors.js';
10
- import { assertNotAborted, createTimedAbortSignal, isProbablyBinary, withAbort, } from '../fs-helpers.js';
8
+ import { assertNotAborted, isProbablyBinary, withAbort, withTimedAbortSignal, } from '../fs-helpers.js';
9
+ import { mergeOptions, omitOptionKeys } from '../option-utils.js';
11
10
  import { assertAllowedFileAccess, isSensitivePath } from '../path-policy.js';
12
11
  import { isPathWithinDirectories, normalizePath, validateExistingDirectory, validateExistingPathDetailed, } from '../path-validation.js';
12
+ import { reportPeriodicProgress } from '../progress-reporting.js';
13
13
  import { withOptionalStoppedReason } from './common.js';
14
14
  import { globEntries } from './glob-engine.js';
15
+ import { buildGlobOptions } from './glob-helpers.js';
16
+ import { buildMatcher, validatePattern } from './search-matcher.js';
15
17
  // --- Configuration & Schemas ---
16
18
  const INTERNAL_MAX_RESULTS = 500;
17
- export const MatcherOptionsSchema = z.strictObject({
18
- caseSensitive: z.boolean(),
19
- wholeWord: z.boolean(),
20
- isLiteral: z.boolean(),
21
- });
22
19
  const SearchOptionsSchema = z.strictObject({
23
20
  filePattern: z.string().min(1),
24
21
  excludePatterns: z.array(z.string()),
@@ -55,80 +52,14 @@ const ERROR_SCAN_CANCELLED = 'Scan cancelled';
55
52
  const ERROR_WORKER_POOL_CLOSED = 'Worker pool closed';
56
53
  // --- Helpers ---
57
54
  function resolveOptions(options) {
58
- const rest = { ...options };
59
- delete rest.signal;
60
- delete rest.onProgress;
61
- const merged = { ...DEFAULTS, ...rest };
55
+ const normalizedOptions = omitOptionKeys(options, ['signal', 'onProgress']);
56
+ const merged = mergeOptions(DEFAULTS, normalizedOptions);
62
57
  const result = SearchOptionsSchema.safeParse(merged);
63
58
  if (!result.success) {
64
59
  throw new McpError(ErrorCode.E_INVALID_INPUT, `Invalid search options: ${result.error.message}`, undefined, { errors: z.treeifyError(result.error) });
65
60
  }
66
61
  return result.data;
67
62
  }
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
- }
78
- function escapeLiteral(pattern) {
79
- return pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
80
- }
81
- function buildRegexPattern(pattern, options) {
82
- const escaped = options.isLiteral ? escapeLiteral(pattern) : pattern;
83
- return options.wholeWord ? `\\b${escaped}\\b` : escaped;
84
- }
85
- function validatePattern(pattern, options) {
86
- if (options.isLiteral && pattern.length === 0)
87
- return;
88
- if (options.isLiteral && !options.wholeWord)
89
- return;
90
- const final = buildRegexPattern(pattern, options);
91
- if (!safeRegex(final)) {
92
- throw new Error(`Potentially unsafe regular expression (ReDoS risk): ${pattern}`);
93
- }
94
- }
95
- function buildLiteralMatcher(pattern, options) {
96
- if (!options.caseSensitive) {
97
- const final = escapeLiteral(pattern);
98
- const regex = new RegExp(final, 'gi');
99
- return (line) => countRegexLineMatches(regex, line);
100
- }
101
- // Fast path for case-sensitive literal
102
- const needle = pattern;
103
- if (needle.length === 0)
104
- return () => 0;
105
- return (line) => {
106
- if (line.length === 0)
107
- return 0;
108
- let count = 0;
109
- let pos = line.indexOf(needle);
110
- while (pos !== -1) {
111
- count++;
112
- pos = line.indexOf(needle, pos + needle.length);
113
- }
114
- return count;
115
- };
116
- }
117
- function buildRegexMatcher(final, caseSensitive) {
118
- const regex = new RE2(final, caseSensitive ? 'g' : 'gi');
119
- return (line) => countRegexLineMatches(regex, line);
120
- }
121
- export function buildMatcher(pattern, options) {
122
- if (options.isLiteral && pattern.length === 0)
123
- return () => 0;
124
- if (options.isLiteral && !options.wholeWord) {
125
- // fast path for simple literal search
126
- return buildLiteralMatcher(pattern, options);
127
- }
128
- const final = buildRegexPattern(pattern, options);
129
- validatePattern(pattern, options); // Re-validate to be safe
130
- return buildRegexMatcher(final, options.caseSensitive);
131
- }
132
63
  /**
133
64
  * Manages a sliding window of lines and pending context-after buffers.
134
65
  */
@@ -291,6 +222,25 @@ async function scanFileResolved(resolvedPath, requestedPath, matcher, options, s
291
222
  await handle.close();
292
223
  }
293
224
  }
225
+ function buildScanFileOptions(opts) {
226
+ return {
227
+ maxFileSize: opts.maxFileSize,
228
+ skipBinary: opts.skipBinary,
229
+ contextLines: opts.contextLines,
230
+ };
231
+ }
232
+ function applyScanOutcome(summary, outcome) {
233
+ if (outcome.matched)
234
+ summary.filesMatched++;
235
+ if (outcome.skippedBinary)
236
+ summary.skippedBinary++;
237
+ if (outcome.skippedTooLarge)
238
+ summary.skippedTooLarge++;
239
+ }
240
+ function markTruncated(summary, reason) {
241
+ summary.truncated = true;
242
+ summary.stoppedReason = reason;
243
+ }
294
244
  function createScanSummary() {
295
245
  return {
296
246
  filesScanned: 0,
@@ -474,32 +424,21 @@ function getPool() {
474
424
  async function executeSequential(files, pattern, opts, signal, summary) {
475
425
  const matches = [];
476
426
  const matcher = buildMatcher(pattern, opts);
477
- const scanOpts = {
478
- maxFileSize: opts.maxFileSize,
479
- skipBinary: opts.skipBinary,
480
- contextLines: opts.contextLines,
481
- };
427
+ const scanOpts = buildScanFileOptions(opts);
482
428
  for await (const file of files) {
483
429
  if (signal.aborted) {
484
- summary.truncated = true;
485
- summary.stoppedReason = 'timeout';
430
+ markTruncated(summary, 'timeout');
486
431
  break;
487
432
  }
488
433
  if (matches.length >= opts.maxResults) {
489
- summary.truncated = true;
490
- summary.stoppedReason = 'maxResults';
434
+ markTruncated(summary, 'maxResults');
491
435
  break;
492
436
  }
493
437
  try {
494
438
  assertAllowedFileAccess(file.requestedPath, file.resolvedPath);
495
439
  const remaining = opts.maxResults - matches.length;
496
440
  const result = await scanFileResolved(file.resolvedPath, file.requestedPath, matcher, scanOpts, signal, remaining);
497
- if (result.matched)
498
- summary.filesMatched++;
499
- if (result.skippedBinary)
500
- summary.skippedBinary++;
501
- if (result.skippedTooLarge)
502
- summary.skippedTooLarge++;
441
+ applyScanOutcome(summary, result);
503
442
  matches.push(...result.matches);
504
443
  }
505
444
  catch {
@@ -542,12 +481,7 @@ function processScanResult(winner, summary, matches, maxResults) {
542
481
  }
543
482
  if (winner.result) {
544
483
  const res = winner.result;
545
- if (res.matched)
546
- summary.filesMatched++;
547
- if (res.skippedBinary)
548
- summary.skippedBinary++;
549
- if (res.skippedTooLarge)
550
- summary.skippedTooLarge++;
484
+ applyScanOutcome(summary, res);
551
485
  const remaining = maxResults - matches.length;
552
486
  if (remaining > 0 && res.matches.length > 0) {
553
487
  const take = Math.min(remaining, res.matches.length);
@@ -559,13 +493,6 @@ function processScanResult(winner, summary, matches, maxResults) {
559
493
  }
560
494
  }
561
495
  }
562
- function reportSearchProgress(onProgress, current, total, force = false) {
563
- if (!onProgress || current === 0)
564
- return;
565
- if (!force && current % 25 !== 0)
566
- return;
567
- onProgress({ current, total });
568
- }
569
496
  async function waitForWinner(pending) {
570
497
  const raceCandidates = [];
571
498
  for (const task of pending) {
@@ -582,11 +509,7 @@ async function waitForWinner(pending) {
582
509
  async function executeParallel(files, pattern, opts, signal, summary) {
583
510
  const pool = getPool();
584
511
  const matches = [];
585
- const scanOpts = {
586
- maxFileSize: opts.maxFileSize,
587
- skipBinary: opts.skipBinary,
588
- contextLines: opts.contextLines,
589
- };
512
+ const scanOpts = buildScanFileOptions(opts);
590
513
  const matcherOpts = {
591
514
  caseSensitive: opts.caseSensitive,
592
515
  wholeWord: opts.wholeWord,
@@ -596,8 +519,7 @@ async function executeParallel(files, pattern, opts, signal, summary) {
596
519
  const iterator = files[Symbol.asyncIterator]();
597
520
  let exhausted = false;
598
521
  const onAbort = () => {
599
- summary.truncated = true;
600
- summary.stoppedReason = 'timeout';
522
+ markTruncated(summary, 'timeout');
601
523
  for (const t of pending)
602
524
  t.cancel();
603
525
  };
@@ -629,12 +551,10 @@ async function executeParallel(files, pattern, opts, signal, summary) {
629
551
  }
630
552
  // Update summary truncation
631
553
  if (signal.aborted) {
632
- summary.truncated = true;
633
- summary.stoppedReason = 'timeout';
554
+ markTruncated(summary, 'timeout');
634
555
  }
635
556
  else if (matches.length >= opts.maxResults) {
636
- summary.truncated = true;
637
- summary.stoppedReason = 'maxResults';
557
+ markTruncated(summary, 'maxResults');
638
558
  }
639
559
  return matches;
640
560
  }
@@ -649,108 +569,110 @@ export async function scanFileInWorker(resolvedPath, requestedPath, matcher, opt
649
569
  skippedTooLarge: res.skippedTooLarge,
650
570
  };
651
571
  }
572
+ async function searchSingleFile(details, opts, pattern, signal) {
573
+ const summary = createScanSummary();
574
+ summary.filesScanned = 1;
575
+ const matcher = buildMatcher(pattern, opts);
576
+ const result = await scanFileResolved(details.resolvedPath, details.requestedPath, matcher, {
577
+ ...buildScanFileOptions(opts),
578
+ }, signal, opts.maxResults);
579
+ if (result.matched)
580
+ summary.filesMatched = 1;
581
+ return buildSearchResult(path.dirname(details.resolvedPath), pattern, opts.filePattern, result.matches, summary);
582
+ }
583
+ async function searchDirectory(details, opts, pattern, signal, onProgress) {
584
+ const root = await validateExistingDirectory(details.resolvedPath, signal);
585
+ const rootDirectories = [root];
586
+ const stream = globEntries(buildGlobOptions({
587
+ cwd: root,
588
+ pattern: opts.filePattern,
589
+ excludePatterns: opts.excludePatterns,
590
+ includeHidden: opts.includeHidden,
591
+ baseNameMatch: opts.baseNameMatch,
592
+ caseSensitiveMatch: opts.caseSensitiveFileMatch,
593
+ followSymbolicLinks: false,
594
+ onlyFiles: true,
595
+ stats: false,
596
+ suppressErrors: true,
597
+ }));
598
+ async function* fileGenerator() {
599
+ let scanned = 0;
600
+ for await (const entry of stream) {
601
+ if (signal.aborted)
602
+ break;
603
+ if (scanned >= opts.maxFilesScanned)
604
+ break;
605
+ if (!entry.dirent.isFile())
606
+ continue;
607
+ const normalized = normalizePath(entry.path);
608
+ if (!isPathWithinDirectories(normalized, rootDirectories))
609
+ continue;
610
+ if (isSensitivePath(entry.path, normalized))
611
+ continue;
612
+ scanned++;
613
+ reportPeriodicProgress(onProgress, scanned, {
614
+ total: opts.maxFilesScanned,
615
+ throttleModulo: 25,
616
+ });
617
+ yield { resolvedPath: normalized, requestedPath: entry.path };
618
+ }
619
+ reportPeriodicProgress(onProgress, scanned, {
620
+ total: opts.maxFilesScanned,
621
+ throttleModulo: 25,
622
+ force: true,
623
+ });
624
+ }
625
+ const summary = createScanSummary();
626
+ const resolvedStream = fileGenerator();
627
+ async function* countingStream() {
628
+ for await (const f of resolvedStream) {
629
+ summary.filesScanned++;
630
+ yield f;
631
+ }
632
+ if (summary.filesScanned >= opts.maxFilesScanned) {
633
+ summary.truncated = true;
634
+ summary.stoppedReason = 'maxFiles';
635
+ }
636
+ }
637
+ const matcherOpts = {
638
+ caseSensitive: opts.caseSensitive,
639
+ wholeWord: opts.wholeWord,
640
+ isLiteral: opts.isLiteral,
641
+ };
642
+ validatePattern(pattern, matcherOpts);
643
+ const matches = shouldUseWorkers()
644
+ ? await executeParallel(countingStream(), pattern, opts, signal, summary)
645
+ : await executeSequential(countingStream(), pattern, opts, signal, summary);
646
+ return buildSearchResult(root, pattern, opts.filePattern, matches, summary);
647
+ }
648
+ function buildTimeoutSearchResult(basePath, pattern, filePattern) {
649
+ const timeoutSummary = createScanSummary();
650
+ markTruncated(timeoutSummary, 'timeout');
651
+ return buildSearchResult(basePath, pattern, filePattern, [], timeoutSummary);
652
+ }
652
653
  export async function searchContent(basePath, pattern, options = {}) {
653
654
  if (!basePath.trim())
654
655
  throw new McpError(ErrorCode.E_INVALID_INPUT, 'basePath required');
655
656
  if (typeof pattern !== 'string')
656
657
  throw new McpError(ErrorCode.E_INVALID_INPUT, 'pattern required');
657
658
  const opts = resolveOptions(options);
658
- const { signal, cleanup } = createTimedAbortSignal(options.signal, opts.timeoutMs);
659
659
  try {
660
- const details = await validateExistingPathDetailed(basePath, signal);
661
- const stats = await withAbort(fsp.stat(details.resolvedPath), signal);
662
- // Check if simple file scan
663
- if (stats.isFile()) {
664
- const summary = createScanSummary();
665
- summary.filesScanned = 1;
666
- // Single file execution
667
- const matcher = buildMatcher(pattern, opts);
668
- const result = await scanFileResolved(details.resolvedPath, details.requestedPath, matcher, {
669
- maxFileSize: opts.maxFileSize,
670
- skipBinary: opts.skipBinary,
671
- contextLines: opts.contextLines,
672
- }, signal, opts.maxResults);
673
- if (result.matched)
674
- summary.filesMatched = 1;
675
- return buildSearchResult(path.dirname(details.resolvedPath), pattern, opts.filePattern, result.matches, summary);
676
- }
677
- if (!stats.isDirectory()) {
678
- throw new McpError(ErrorCode.E_INVALID_INPUT, `Path must be file or directory`, basePath);
679
- }
680
- const root = await validateExistingDirectory(details.resolvedPath, signal);
681
- const rootDirectories = [root];
682
- // Glob
683
- const stream = globEntries({
684
- cwd: root,
685
- pattern: opts.filePattern,
686
- excludePatterns: opts.excludePatterns,
687
- includeHidden: opts.includeHidden,
688
- baseNameMatch: opts.baseNameMatch,
689
- caseSensitiveMatch: opts.caseSensitiveFileMatch,
690
- followSymbolicLinks: false,
691
- onlyFiles: true,
692
- stats: false,
693
- suppressErrors: true,
694
- });
695
- // Generator adapter to resolve paths
696
- async function* fileGenerator() {
697
- let scanned = 0;
698
- for await (const entry of stream) {
699
- if (signal.aborted)
700
- break;
701
- if (scanned >= opts.maxFilesScanned)
702
- break;
703
- if (!entry.dirent.isFile())
704
- continue;
705
- // Helper to resolve
706
- // We duplicate simple resolution logic to keep it fast
707
- const normalized = normalizePath(entry.path);
708
- if (!isPathWithinDirectories(normalized, rootDirectories))
709
- continue;
710
- if (isSensitivePath(entry.path, normalized))
711
- continue;
712
- scanned++;
713
- reportSearchProgress(options.onProgress, scanned, opts.maxFilesScanned);
714
- yield { resolvedPath: normalized, requestedPath: entry.path };
660
+ return await withTimedAbortSignal(options.signal, opts.timeoutMs, async (signal) => {
661
+ const details = await validateExistingPathDetailed(basePath, signal);
662
+ const stats = await withAbort(fsp.stat(details.resolvedPath), signal);
663
+ if (stats.isFile()) {
664
+ return searchSingleFile(details, opts, pattern, signal);
715
665
  }
716
- reportSearchProgress(options.onProgress, scanned, opts.maxFilesScanned, true);
717
- }
718
- // Choose Strategy
719
- // We recreate summary to track actual scans
720
- const summary = createScanSummary();
721
- const resolvedStream = fileGenerator();
722
- // Wrap generator to count scanned files in summary
723
- async function* countingStream() {
724
- for await (const f of resolvedStream) {
725
- summary.filesScanned++;
726
- yield f;
666
+ if (!stats.isDirectory()) {
667
+ throw new McpError(ErrorCode.E_INVALID_INPUT, 'Path must be file or directory', basePath);
727
668
  }
728
- if (summary.filesScanned >= opts.maxFilesScanned) {
729
- summary.truncated = true;
730
- summary.stoppedReason = 'maxFiles';
731
- }
732
- }
733
- const matcherOpts = {
734
- caseSensitive: opts.caseSensitive,
735
- wholeWord: opts.wholeWord,
736
- isLiteral: opts.isLiteral,
737
- };
738
- validatePattern(pattern, matcherOpts);
739
- const matches = shouldUseWorkers()
740
- ? await executeParallel(countingStream(), pattern, opts, signal, summary)
741
- : await executeSequential(countingStream(), pattern, opts, signal, summary);
742
- return buildSearchResult(root, pattern, opts.filePattern, matches, summary);
669
+ return searchDirectory(details, opts, pattern, signal, options.onProgress);
670
+ });
743
671
  }
744
672
  catch (error) {
745
673
  if (isTimeoutLikeError(error)) {
746
- const timeoutSummary = createScanSummary();
747
- timeoutSummary.truncated = true;
748
- timeoutSummary.stoppedReason = 'timeout';
749
- return buildSearchResult(basePath, pattern, opts.filePattern, [], timeoutSummary);
674
+ return buildTimeoutSearchResult(basePath, pattern, opts.filePattern);
750
675
  }
751
676
  throw error;
752
677
  }
753
- finally {
754
- cleanup();
755
- }
756
678
  }
@@ -1,11 +1,13 @@
1
1
  import * as path from 'node:path';
2
2
  import { DEFAULT_SEARCH_MAX_FILES, DEFAULT_SEARCH_TIMEOUT_MS, } from '../constants.js';
3
- import { createTimedAbortSignal } from '../fs-helpers.js';
3
+ import { withTimedAbortSignal } 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
+ import { reportPeriodicProgress } from '../progress-reporting.js';
7
+ import { compareOptionalNumberDesc, compareStringValues, isEntryAccessibleByType, needsStatsForSort, resolveEntryType, resolveStopReason, stableSortByDerivedString, withOptionalStoppedReason, } from './common.js';
7
8
  import { isIgnoredByGitignore, loadRootGitignore } from './gitignore.js';
8
- import { globEntries, resolveEntryType } from './glob-engine.js';
9
+ import { globEntries } from './glob-engine.js';
10
+ import { buildGlobOptions } from './glob-helpers.js';
9
11
  // Internal default for find tool - not exposed to MCP users
10
12
  const INTERNAL_MAX_RESULTS = 1000;
11
13
  function normalizeOptions(options) {
@@ -41,17 +43,17 @@ function buildSearchResult(entry, entryType, needsStats) {
41
43
  ...(modified !== undefined ? { modified } : {}),
42
44
  };
43
45
  }
44
- function markStopped(state, reason) {
45
- state.truncated = true;
46
- state.stoppedReason = reason;
47
- }
48
46
  function shouldStopCollecting(state, normalized, signal) {
49
- if (signal.aborted) {
50
- markStopped(state, 'timeout');
51
- return true;
52
- }
53
- if (state.filesScanned >= normalized.maxFilesScanned) {
54
- markStopped(state, 'maxFiles');
47
+ const stopReason = resolveStopReason({
48
+ signal,
49
+ current: state.filesScanned,
50
+ max: normalized.maxFilesScanned,
51
+ abortedReason: 'timeout',
52
+ maxReason: 'maxFiles',
53
+ });
54
+ if (stopReason !== undefined) {
55
+ state.truncated = true;
56
+ state.stoppedReason = stopReason;
55
57
  return true;
56
58
  }
57
59
  return false;
@@ -68,7 +70,7 @@ function createCollectState() {
68
70
  };
69
71
  }
70
72
  function buildSearchStream(root, pattern, excludePatterns, normalized, needsStats) {
71
- const options = {
73
+ const options = buildGlobOptions({
72
74
  cwd: root,
73
75
  pattern,
74
76
  excludePatterns,
@@ -78,10 +80,10 @@ function buildSearchStream(root, pattern, excludePatterns, normalized, needsStat
78
80
  followSymbolicLinks: false,
79
81
  onlyFiles: true,
80
82
  stats: needsStats,
81
- };
82
- if (normalized.maxDepth !== undefined) {
83
- options.maxDepth = normalized.maxDepth;
84
- }
83
+ ...(normalized.maxDepth !== undefined
84
+ ? { maxDepth: normalized.maxDepth }
85
+ : {}),
86
+ });
85
87
  return globEntries(options);
86
88
  }
87
89
  function buildCollectResult(state) {
@@ -99,22 +101,19 @@ function buildCollectResult(state) {
99
101
  function handleEntry(entry, entryType, needsStats, normalized, state) {
100
102
  state.results.push(buildSearchResult(entry, entryType, needsStats));
101
103
  if (state.results.length >= normalized.maxResults) {
102
- markStopped(state, 'maxResults');
104
+ state.truncated = true;
105
+ state.stoppedReason = 'maxResults';
103
106
  }
104
107
  }
105
- function reportSearchFilesProgress(onProgress, current, total, force = false) {
106
- if (!onProgress || current === 0)
107
- return;
108
- if (!force && current % 25 !== 0)
109
- return;
110
- onProgress({ current, total });
111
- }
112
- async function collectFromStream(stream, root, rootDirectories, gitignoreMatcher, normalized, needsStats, state, signal, onProgress) {
108
+ async function collectFromStream(stream, root, rootDirectories, gitignoreMatcher, normalized, needsStats, state, signal, accessDeps, onProgress) {
113
109
  for await (const entry of stream) {
114
110
  if (shouldStopCollecting(state, normalized, signal))
115
111
  break;
116
112
  state.filesScanned++;
117
- reportSearchFilesProgress(onProgress, state.filesScanned, normalized.maxFilesScanned);
113
+ reportPeriodicProgress(onProgress, state.filesScanned, {
114
+ total: normalized.maxFilesScanned,
115
+ throttleModulo: 25,
116
+ });
118
117
  if (isEntryIgnoredByGitignore(gitignoreMatcher, root, entry.path, entry.relativePath)) {
119
118
  continue;
120
119
  }
@@ -122,7 +121,7 @@ async function collectFromStream(stream, root, rootDirectories, gitignoreMatcher
122
121
  if (!shouldIncludeEntry(entryType, normalized)) {
123
122
  continue;
124
123
  }
125
- const isAccessible = await isEntryAccessible(entry, entryType, rootDirectories, signal);
124
+ const isAccessible = await isEntryAccessibleByType(entry.path, entryType, rootDirectories, signal, accessDeps);
126
125
  if (!isAccessible) {
127
126
  state.skippedInaccessible++;
128
127
  continue;
@@ -131,38 +130,32 @@ async function collectFromStream(stream, root, rootDirectories, gitignoreMatcher
131
130
  if (state.truncated)
132
131
  break;
133
132
  }
134
- reportSearchFilesProgress(onProgress, state.filesScanned, normalized.maxFilesScanned, true);
133
+ reportPeriodicProgress(onProgress, state.filesScanned, {
134
+ total: normalized.maxFilesScanned,
135
+ throttleModulo: 25,
136
+ force: true,
137
+ });
135
138
  }
136
139
  function isEntryIgnoredByGitignore(matcher, root, entryPath, relativePath) {
137
140
  if (!matcher)
138
141
  return false;
139
142
  return isIgnoredByGitignore(matcher, root, entryPath, relativePath ? { relativePath } : {});
140
143
  }
141
- async function isEntryAccessible(entry, entryType, rootDirectories, signal) {
142
- if (entryType === 'symlink') {
143
- try {
144
- const validated = await validateExistingPathDetailed(entry.path, signal);
145
- return !isSensitivePath(validated.requestedPath, validated.resolvedPath);
146
- }
147
- catch {
148
- return false;
149
- }
150
- }
151
- const resolvedPath = normalizePath(entry.path);
152
- if (!isPathWithinDirectories(resolvedPath, rootDirectories)) {
153
- return false;
154
- }
155
- return !isSensitivePath(entry.path, resolvedPath);
156
- }
157
144
  async function collectSearchResults(root, pattern, excludePatterns, normalized, signal, onProgress) {
158
145
  const needsStats = needsStatsForSort(normalized.sortBy);
159
146
  const stream = buildSearchStream(root, pattern, excludePatterns, normalized, needsStats);
160
147
  const state = createCollectState();
161
148
  const rootDirectories = [root];
149
+ const accessDeps = {
150
+ normalizePath,
151
+ isPathWithinDirectories,
152
+ isSensitivePath,
153
+ validateSymlinkPath: validateExistingPathDetailed,
154
+ };
162
155
  const gitignoreMatcher = normalized.respectGitignore
163
156
  ? await loadRootGitignore(root, signal)
164
157
  : null;
165
- await collectFromStream(stream, root, rootDirectories, gitignoreMatcher, normalized, needsStats, state, signal, onProgress);
158
+ await collectFromStream(stream, root, rootDirectories, gitignoreMatcher, normalized, needsStats, state, signal, accessDeps, onProgress);
166
159
  return buildCollectResult(state);
167
160
  }
168
161
  function buildSearchSummary(results, filesScanned, truncated, stoppedReason, skippedInaccessible) {
@@ -174,27 +167,17 @@ function buildSearchSummary(results, filesScanned, truncated, stoppedReason, ski
174
167
  };
175
168
  return withOptionalStoppedReason(summary, stoppedReason);
176
169
  }
177
- const collator = new Intl.Collator(undefined, { numeric: true });
178
- function compareString(a, b) {
179
- return collator.compare(a ?? '', b ?? '');
180
- }
181
170
  function compareNameThenPath(a, b) {
182
- const nameCompare = compareString(a.name, b.name);
171
+ const nameCompare = compareStringValues(a.name, b.name);
183
172
  if (nameCompare !== 0)
184
173
  return nameCompare;
185
- return compareString(a.path, b.path);
174
+ return compareStringValues(a.path, b.path);
186
175
  }
187
176
  function comparePathThenName(a, b) {
188
- const pathCompare = compareString(a.path, b.path);
177
+ const pathCompare = compareStringValues(a.path, b.path);
189
178
  if (pathCompare !== 0)
190
179
  return pathCompare;
191
- return compareString(a.name, b.name);
192
- }
193
- function compareOptionalNumberDesc(left, right, tieBreak) {
194
- const diff = (right ?? 0) - (left ?? 0);
195
- if (diff !== 0)
196
- return diff;
197
- return tieBreak();
180
+ return compareStringValues(a.name, b.name);
198
181
  }
199
182
  const SORT_COMPARATORS = {
200
183
  size: (a, b) => compareOptionalNumberDesc(a.size, b.size, () => compareNameThenPath(a, b)),
@@ -204,32 +187,7 @@ const SORT_COMPARATORS = {
204
187
  };
205
188
  export function sortSearchResults(results, sortBy) {
206
189
  if (sortBy === 'name') {
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
- }
218
- decorated.sort((a, b) => {
219
- const baseCompare = compareString(a.baseName, b.baseName);
220
- if (baseCompare !== 0)
221
- return baseCompare;
222
- const pathCompare = compareString(a.item.path, b.item.path);
223
- if (pathCompare !== 0)
224
- return pathCompare;
225
- return a.index - b.index;
226
- });
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
- }
190
+ stableSortByDerivedString(results, (item) => path.basename(item.path ?? ''), (left, right) => comparePathThenName(left, right));
233
191
  return;
234
192
  }
235
193
  const comparator = SORT_COMPARATORS[sortBy];
@@ -245,9 +203,8 @@ async function runSearchFiles(root, pattern, excludePatterns, normalized, signal
245
203
  }
246
204
  export async function searchFiles(basePath, pattern, excludePatterns = [], options = {}) {
247
205
  const normalized = normalizeOptions(options);
248
- const { signal, cleanup } = createTimedAbortSignal(options.signal, normalized.timeoutMs);
249
- const root = await validateExistingDirectory(basePath, signal);
250
- try {
206
+ return withTimedAbortSignal(options.signal, normalized.timeoutMs, async (signal) => {
207
+ const root = await validateExistingDirectory(basePath, signal);
251
208
  const { results, summary } = await runSearchFiles(root, pattern, excludePatterns, normalized, signal, options.onProgress);
252
209
  return {
253
210
  basePath: root,
@@ -255,8 +212,5 @@ export async function searchFiles(basePath, pattern, excludePatterns = [], optio
255
212
  results,
256
213
  summary,
257
214
  };
258
- }
259
- finally {
260
- cleanup();
261
- }
215
+ });
262
216
  }