@j0hanz/filesystem-mcp 2.6.3 → 2.7.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 (141) hide show
  1. package/README.md +27 -18
  2. package/dist/cli-help.d.ts.map +1 -1
  3. package/dist/cli-help.js +5 -2
  4. package/dist/cli-help.js.map +1 -1
  5. package/dist/cli.d.ts.map +1 -1
  6. package/dist/cli.js +22 -3
  7. package/dist/cli.js.map +1 -1
  8. package/dist/core/concurrency.d.ts +0 -17
  9. package/dist/core/concurrency.d.ts.map +1 -1
  10. package/dist/core/concurrency.js +0 -10
  11. package/dist/core/concurrency.js.map +1 -1
  12. package/dist/core/config.d.ts +7 -0
  13. package/dist/core/config.d.ts.map +1 -1
  14. package/dist/core/config.js +7 -0
  15. package/dist/core/config.js.map +1 -1
  16. package/dist/core/diff.d.ts +0 -4
  17. package/dist/core/diff.d.ts.map +1 -1
  18. package/dist/core/diff.js +1 -15
  19. package/dist/core/diff.js.map +1 -1
  20. package/dist/core/file-uri.d.ts +9 -9
  21. package/dist/core/file-uri.d.ts.map +1 -1
  22. package/dist/core/file-uri.js +16 -11
  23. package/dist/core/file-uri.js.map +1 -1
  24. package/dist/core/fs.d.ts +1 -1
  25. package/dist/core/fs.d.ts.map +1 -1
  26. package/dist/core/fs.js +65 -56
  27. package/dist/core/fs.js.map +1 -1
  28. package/dist/core/glob.d.ts +6 -0
  29. package/dist/core/glob.d.ts.map +1 -1
  30. package/dist/core/glob.js +64 -8
  31. package/dist/core/glob.js.map +1 -1
  32. package/dist/core/input-required.d.ts +15 -3
  33. package/dist/core/input-required.d.ts.map +1 -1
  34. package/dist/core/input-required.js +30 -5
  35. package/dist/core/input-required.js.map +1 -1
  36. package/dist/core/mime.d.ts +2 -4
  37. package/dist/core/mime.d.ts.map +1 -1
  38. package/dist/core/mime.js +3 -8
  39. package/dist/core/mime.js.map +1 -1
  40. package/dist/core/path-discovery.d.ts.map +1 -1
  41. package/dist/core/path-discovery.js +2 -2
  42. package/dist/core/path-discovery.js.map +1 -1
  43. package/dist/core/path-utils.d.ts +1 -3
  44. package/dist/core/path-utils.d.ts.map +1 -1
  45. package/dist/core/path-utils.js +0 -3
  46. package/dist/core/path-utils.js.map +1 -1
  47. package/dist/core/path.d.ts +7 -0
  48. package/dist/core/path.d.ts.map +1 -1
  49. package/dist/core/path.js +69 -26
  50. package/dist/core/path.js.map +1 -1
  51. package/dist/core/read.d.ts +1 -0
  52. package/dist/core/read.d.ts.map +1 -1
  53. package/dist/core/read.js +4 -0
  54. package/dist/core/read.js.map +1 -1
  55. package/dist/core/schema.d.ts +0 -80
  56. package/dist/core/schema.d.ts.map +1 -1
  57. package/dist/core/schema.js +0 -89
  58. package/dist/core/schema.js.map +1 -1
  59. package/dist/core/search.d.ts +8 -1
  60. package/dist/core/search.d.ts.map +1 -1
  61. package/dist/core/search.js +169 -87
  62. package/dist/core/search.js.map +1 -1
  63. package/dist/core/store.d.ts +7 -11
  64. package/dist/core/store.d.ts.map +1 -1
  65. package/dist/core/store.js +19 -21
  66. package/dist/core/store.js.map +1 -1
  67. package/dist/core/util.d.ts +17 -0
  68. package/dist/core/util.d.ts.map +1 -1
  69. package/dist/core/util.js +19 -0
  70. package/dist/core/util.js.map +1 -1
  71. package/dist/instructions.js +1 -1
  72. package/dist/instructions.js.map +1 -1
  73. package/dist/resources.d.ts.map +1 -1
  74. package/dist/resources.js +5 -2
  75. package/dist/resources.js.map +1 -1
  76. package/dist/server.d.ts.map +1 -1
  77. package/dist/server.js +1 -0
  78. package/dist/server.js.map +1 -1
  79. package/dist/tools/batch.d.ts +2 -13
  80. package/dist/tools/batch.d.ts.map +1 -1
  81. package/dist/tools/batch.js +6 -18
  82. package/dist/tools/batch.js.map +1 -1
  83. package/dist/tools/create.d.ts.map +1 -1
  84. package/dist/tools/create.js +25 -42
  85. package/dist/tools/create.js.map +1 -1
  86. package/dist/tools/define.d.ts +20 -23
  87. package/dist/tools/define.d.ts.map +1 -1
  88. package/dist/tools/define.js +11 -23
  89. package/dist/tools/define.js.map +1 -1
  90. package/dist/tools/delete.d.ts.map +1 -1
  91. package/dist/tools/delete.js +9 -28
  92. package/dist/tools/delete.js.map +1 -1
  93. package/dist/tools/diff.d.ts.map +1 -1
  94. package/dist/tools/diff.js +2 -13
  95. package/dist/tools/diff.js.map +1 -1
  96. package/dist/tools/edit.d.ts.map +1 -1
  97. package/dist/tools/edit.js +20 -53
  98. package/dist/tools/edit.js.map +1 -1
  99. package/dist/tools/find-files.d.ts.map +1 -1
  100. package/dist/tools/find-files.js +3 -28
  101. package/dist/tools/find-files.js.map +1 -1
  102. package/dist/tools/index.js +1 -1
  103. package/dist/tools/index.js.map +1 -1
  104. package/dist/tools/list-roots.d.ts.map +1 -1
  105. package/dist/tools/list-roots.js +1 -12
  106. package/dist/tools/list-roots.js.map +1 -1
  107. package/dist/tools/list.d.ts.map +1 -1
  108. package/dist/tools/list.js +19 -53
  109. package/dist/tools/list.js.map +1 -1
  110. package/dist/tools/move.d.ts.map +1 -1
  111. package/dist/tools/move.js +54 -33
  112. package/dist/tools/move.js.map +1 -1
  113. package/dist/tools/patch.d.ts.map +1 -1
  114. package/dist/tools/patch.js +4 -24
  115. package/dist/tools/patch.js.map +1 -1
  116. package/dist/tools/progress.d.ts +0 -2
  117. package/dist/tools/progress.d.ts.map +1 -1
  118. package/dist/tools/progress.js +20 -12
  119. package/dist/tools/progress.js.map +1 -1
  120. package/dist/tools/read.d.ts.map +1 -1
  121. package/dist/tools/read.js +7 -42
  122. package/dist/tools/read.js.map +1 -1
  123. package/dist/tools/replace-text.d.ts.map +1 -1
  124. package/dist/tools/replace-text.js +20 -43
  125. package/dist/tools/replace-text.js.map +1 -1
  126. package/dist/tools/search-text.d.ts.map +1 -1
  127. package/dist/tools/search-text.js +24 -78
  128. package/dist/tools/search-text.js.map +1 -1
  129. package/dist/tools/stat.d.ts.map +1 -1
  130. package/dist/tools/stat.js +5 -19
  131. package/dist/tools/stat.js.map +1 -1
  132. package/dist/transport/http-policy.d.ts.map +1 -1
  133. package/dist/transport/http-policy.js +1 -0
  134. package/dist/transport/http-policy.js.map +1 -1
  135. package/dist/transport/http.d.ts.map +1 -1
  136. package/dist/transport/http.js +14 -9
  137. package/dist/transport/http.js.map +1 -1
  138. package/dist/transport/stdio.d.ts.map +1 -1
  139. package/dist/transport/stdio.js +18 -3
  140. package/dist/transport/stdio.js.map +1 -1
  141. package/package.json +3 -3
@@ -1,9 +1,9 @@
1
1
  import { stat as fsStat, readFile } from 'node:fs/promises';
2
2
  import { basename } from 'node:path';
3
3
  import { RE2 } from '@adguard/re2-wasm';
4
- import { resolveStopReason } from "./concurrency.js";
4
+ import { processEntriesConcurrently, resolveStopReason } from "./concurrency.js";
5
5
  import { globEntries } from "./glob.js";
6
- import { escapeRegExp, getMaxTextFileSize, MIB } from "./util.js";
6
+ import { escapeRegExp, getMaxTextFileSize, MIB, PARALLEL_CONCURRENCY } from "./util.js";
7
7
  /**
8
8
  * A fresh, flat copy of `s`. V8 keeps a substring of a long string as a slice
9
9
  * that references its parent, so a kept line would otherwise pin the whole
@@ -18,6 +18,10 @@ const MAX_MATCHES_PER_LINE = 100_000;
18
18
  * aborts the module. 4 MiB leaves room for the copy and the pattern set.
19
19
  */
20
20
  export const RE2_MAX_INPUT_BYTES = 4 * MIB;
21
+ const BUFFER_ANCHOR_RE = /\\[AzZ]/u;
22
+ /** `(?i)`, `(?-m)`, `(?s:…)`: inline flags can undo the `m` the prefilter relies on. */
23
+ const INLINE_FLAGS_RE = /\(\?[a-zA-Z-]+[:)]/u;
24
+ const CR = 13;
21
25
  /**
22
26
  * Compile a pattern on RE2 rather than on V8's irregexp.
23
27
  *
@@ -40,7 +44,7 @@ export const RE2_MAX_INPUT_BYTES = 4 * MIB;
40
44
  * {@link freeRegex} when it is done with it.
41
45
  */
42
46
  export function compileRegex(pattern, options = {}) {
43
- const flags = options.caseSensitive ? 'gu' : 'giu';
47
+ const flags = `g${options.multiline ? 'm' : ''}${options.caseSensitive ? '' : 'i'}u`;
44
48
  try {
45
49
  return new RE2(pattern, flags);
46
50
  }
@@ -130,110 +134,173 @@ function findLineMatches(regex, line) {
130
134
  }
131
135
  return count > 0 ? { count, column } : undefined;
132
136
  }
137
+ async function scanFile(entryPath, scanCtx) {
138
+ try {
139
+ await scanCtx.pathGuard.validateExistingPath(entryPath);
140
+ }
141
+ catch {
142
+ return { kind: 'inaccessible' };
143
+ }
144
+ try {
145
+ const stats = await fsStat(entryPath);
146
+ if (stats.size > scanCtx.maxFileSize)
147
+ return { kind: 'tooLarge' };
148
+ }
149
+ catch {
150
+ return { kind: 'inaccessible' };
151
+ }
152
+ let buffer;
153
+ try {
154
+ buffer = await readFile(entryPath, { signal: scanCtx.signal });
155
+ }
156
+ catch (error) {
157
+ if (scanCtx.signal?.aborted)
158
+ throw error;
159
+ return { kind: 'inaccessible' };
160
+ }
161
+ // A NUL byte marks a binary file, as it does for `read` and `grep -I`.
162
+ // Non-UTF-8 text is still searched: a lossy decode only affects the
163
+ // bytes a pattern could not have matched anyway.
164
+ if (buffer.includes(0))
165
+ return { kind: 'binary' };
166
+ const content = buffer.toString('utf-8');
167
+ if (canPrefilter(scanCtx.source, buffer)) {
168
+ scanCtx.prefilter.lastIndex = 0;
169
+ if (scanCtx.prefilter.exec(content) === null)
170
+ return { kind: 'scanned', matches: [] };
171
+ }
172
+ const lines = content.split(/\r?\n/u);
173
+ // A trailing newline splits into a phantom empty last element, and an
174
+ // empty file splits into one empty element: neither is a line the
175
+ // file has, for matching or for context.
176
+ const lineCount = content.length === 0 ? 0 : content.endsWith('\n') ? lines.length - 1 : lines.length;
177
+ const matches = [];
178
+ for (let i = 0; i < lineCount; i++) {
179
+ const line = lines[i];
180
+ if (line === undefined)
181
+ continue;
182
+ // One scan per line: the helper resets lastIndex itself, so it
183
+ // doubles as the "does this line match" test.
184
+ const found = findLineMatches(scanCtx.regex, line);
185
+ if (found) {
186
+ matches.push({
187
+ file: entryPath,
188
+ line: i + 1,
189
+ column: found.column,
190
+ content: own(line),
191
+ matchCount: found.count,
192
+ ...(scanCtx.context > 0
193
+ ? {
194
+ before: lines.slice(Math.max(0, i - scanCtx.context), i).map(own),
195
+ after: lines.slice(i + 1, Math.min(lineCount, i + 1 + scanCtx.context)).map(own),
196
+ }
197
+ : {}),
198
+ });
199
+ if (matches.length >= scanCtx.maxResults)
200
+ break;
201
+ }
202
+ }
203
+ return { kind: 'scanned', matches };
204
+ }
133
205
  export async function searchContent(directory, pattern, options, pathGuard) {
134
- const regex = compileRegex(options.isRegex ? pattern || '' : escapeRegExp(pattern || ''), {
206
+ const source = options.isRegex ? pattern || '' : escapeRegExp(pattern || '');
207
+ const regex = compileRegex(source, {
135
208
  caseSensitive: Boolean(options.caseSensitive),
136
209
  });
210
+ // Whole-buffer test: `m` makes ^/$ line-relative so a per-line hit is always a
211
+ // buffer hit (the converse can be false — a false positive just falls through
212
+ // to the per-line scan). \A/\z/\Z are buffer-relative even under `m`, inline
213
+ // flag groups can undo `m`, and $ cannot see past a \r, so those inputs skip
214
+ // the prefilter (see canPrefilter).
215
+ const prefilter = compileRegex(source, {
216
+ caseSensitive: Boolean(options.caseSensitive),
217
+ multiline: true,
218
+ });
137
219
  try {
138
220
  const matches = [];
139
221
  const maxResults = options.maxResults ?? 100;
140
222
  const maxFileSize = getMaxTextFileSize();
141
223
  const context = options.context ?? 0;
142
- const entries = globEntries({
143
- cwd: directory,
144
- pattern: options.filePattern ?? '**/*',
145
- // Same rule as replace_text, so a search with the same glob previews the
146
- // files a replace would touch: a slash-free glob matches at any depth.
147
- baseNameMatch: true,
148
- includeHidden: Boolean(options.includeHidden),
149
- skipIgnored: Boolean(options.skipIgnored),
150
- ...(options.signal ? { signal: options.signal } : {}),
151
- maxDepth: options.maxDepth ?? 100,
152
- suppressErrors: true,
153
- });
224
+ const ceiling = pathGuard.allowedRootContaining(directory);
225
+ const entries = options.explicitFile
226
+ ? [{ path: options.explicitFile }]
227
+ : globEntries({
228
+ cwd: directory,
229
+ pattern: options.filePattern ?? '**/*',
230
+ // Same rule as replace_text, so a search with the same glob previews the
231
+ // files a replace would touch: a slash-free glob matches at any depth.
232
+ baseNameMatch: true,
233
+ includeHidden: Boolean(options.includeHidden),
234
+ skipIgnored: Boolean(options.skipIgnored),
235
+ ...(ceiling !== undefined ? { ignoreCeiling: ceiling } : {}),
236
+ ...(options.signal ? { signal: options.signal } : {}),
237
+ maxDepth: options.maxDepth ?? 100,
238
+ suppressErrors: true,
239
+ });
154
240
  let filesScanned = 0;
155
241
  let filesMatched = 0;
156
242
  let matchingLines = 0;
157
243
  let skippedTooLarge = 0;
158
244
  let skippedBinary = 0;
159
- const counters = { skippedInaccessible: 0, stoppedByAbort: false };
160
- for await (const entry of guardedEntries(entries, pathGuard, options.signal, counters)) {
161
- if (matches.length >= maxResults)
162
- break;
163
- // Skip oversized files before reading to avoid unbounded memory use. Count
164
- // them: "no matches" for a reason other than the pattern must be visible.
165
- try {
166
- const stats = await fsStat(entry.path);
167
- if (stats.size > maxFileSize) {
168
- skippedTooLarge++;
169
- continue;
170
- }
171
- }
172
- catch {
173
- counters.skippedInaccessible++;
245
+ const scanCtx = {
246
+ pathGuard,
247
+ signal: options.signal,
248
+ maxFileSize,
249
+ maxResults,
250
+ regex,
251
+ prefilter,
252
+ source,
253
+ context,
254
+ };
255
+ const perFile = new Map();
256
+ let dispatched = 0;
257
+ let collected = 0;
258
+ const stopped = await processEntriesConcurrently(entries, {
259
+ signal: options.signal,
260
+ concurrency: PARALLEL_CONCURRENCY,
261
+ shouldStop: () => collected >= maxResults,
262
+ onEntry: () => {
263
+ dispatched++;
264
+ },
265
+ onError: () => undefined,
266
+ runEntry: async (entryPath) => {
267
+ const index = dispatched - 1;
268
+ const scan = await scanFile(entryPath, scanCtx);
269
+ perFile.set(index, scan);
270
+ if (scan.kind === 'scanned')
271
+ collected += scan.matches.length;
272
+ },
273
+ });
274
+ let skippedInaccessible = 0;
275
+ for (let i = 0; i < dispatched; i++) {
276
+ const scan = perFile.get(i);
277
+ if (!scan)
174
278
  continue;
175
- }
176
- filesScanned++;
177
- try {
178
- const buffer = await readFile(entry.path, { signal: options.signal });
179
- // A NUL byte marks a binary file, as it does for `read` and `grep -I`.
180
- // Non-UTF-8 text is still searched: a lossy decode only affects the
181
- // bytes a pattern could not have matched anyway.
182
- if (buffer.includes(0)) {
279
+ switch (scan.kind) {
280
+ case 'inaccessible':
281
+ skippedInaccessible++;
282
+ break;
283
+ case 'tooLarge':
284
+ skippedTooLarge++;
285
+ break;
286
+ case 'binary':
287
+ filesScanned++;
183
288
  skippedBinary++;
184
- continue;
185
- }
186
- const content = buffer.toString('utf-8');
187
- const lines = content.split(/\r?\n/u);
188
- // A trailing newline splits into a phantom empty last element, and an
189
- // empty file splits into one empty element: neither is a line the
190
- // file has, for matching or for context.
191
- const lineCount = content.length === 0 ? 0 : content.endsWith('\n') ? lines.length - 1 : lines.length;
192
- let matchedFile = false;
193
- for (let i = 0; i < lineCount; i++) {
194
- const line = lines[i];
195
- if (line === undefined)
196
- continue;
197
- // One scan per line: findLineMatches resets lastIndex itself, so it
198
- // doubles as the "does this line match" test.
199
- const found = findLineMatches(regex, line);
200
- if (found) {
201
- matchedFile = true;
202
- matchingLines++;
203
- matches.push({
204
- file: entry.path,
205
- line: i + 1,
206
- column: found.column,
207
- content: own(line),
208
- matchCount: found.count,
209
- ...(context > 0
210
- ? {
211
- before: lines.slice(Math.max(0, i - context), i).map(own),
212
- after: lines.slice(i + 1, Math.min(lineCount, i + 1 + context)).map(own),
213
- }
214
- : {}),
215
- });
289
+ break;
290
+ case 'scanned':
291
+ filesScanned++;
292
+ if (scan.matches.length > 0)
293
+ filesMatched++;
294
+ for (const match of scan.matches) {
216
295
  if (matches.length >= maxResults)
217
296
  break;
297
+ matches.push(match);
298
+ matchingLines++;
218
299
  }
219
- }
220
- if (matchedFile)
221
- filesMatched++;
222
- }
223
- catch {
224
- // A read failure while the signal is aborted IS the abort, not an
225
- // unreadable file — stop rather than spend another iteration and then
226
- // report a cut-short scan as complete.
227
- if (options.signal?.aborted) {
228
- counters.stoppedByAbort = true;
229
300
  break;
230
- }
231
- // unreadable mid-scan (deleted, permission changed): skip the file
232
301
  }
233
302
  }
234
- // The result cap is the definite cause even when the abort fired on the
235
- // same iteration (see the summary type's narrowing note).
236
- const stoppedReason = resolveStopReason(matches.length >= maxResults, counters.stoppedByAbort);
303
+ const stoppedReason = resolveStopReason(matches.length >= maxResults, stopped === 'timeout' || Boolean(options.signal?.aborted));
237
304
  return {
238
305
  basePath: directory,
239
306
  matches,
@@ -242,7 +309,7 @@ export async function searchContent(directory, pattern, options, pathGuard) {
242
309
  filesScanned,
243
310
  filesMatched,
244
311
  truncated: stoppedReason !== undefined,
245
- skippedInaccessible: counters.skippedInaccessible,
312
+ skippedInaccessible,
246
313
  skippedTooLarge,
247
314
  skippedBinary,
248
315
  ...(stoppedReason ? { stoppedReason } : {}),
@@ -250,9 +317,22 @@ export async function searchContent(directory, pattern, options, pathGuard) {
250
317
  };
251
318
  }
252
319
  finally {
320
+ freeRegex(prefilter);
253
321
  freeRegex(regex);
254
322
  }
255
323
  }
324
+ /** Whether one whole-buffer RE2 test is a sound "cannot match" check for this file. */
325
+ function canPrefilter(source, buffer) {
326
+ if (buffer.length > RE2_MAX_INPUT_BYTES)
327
+ return false;
328
+ if (BUFFER_ANCHOR_RE.test(source))
329
+ return false;
330
+ if (INLINE_FLAGS_RE.test(source))
331
+ return false;
332
+ if (source.includes('$') && buffer.includes(CR))
333
+ return false;
334
+ return true;
335
+ }
256
336
  async function* guardedEntries(entries, pathGuard, signal, counters) {
257
337
  // The signal carries both client cancellation and the tool's search timeout,
258
338
  // so an abort means "return what we have, marked incomplete" rather than
@@ -274,12 +354,14 @@ async function* guardedEntries(entries, pathGuard, signal, counters) {
274
354
  }
275
355
  export async function searchFiles(directory, pattern, options, pathGuard) {
276
356
  const maxResults = options.maxResults ?? 100;
357
+ const ceiling = pathGuard.allowedRootContaining(directory);
277
358
  const entries = globEntries({
278
359
  cwd: directory,
279
360
  pattern,
280
361
  baseNameMatch: true,
281
362
  includeHidden: Boolean(options.includeHidden),
282
363
  skipIgnored: Boolean(options.skipIgnored),
364
+ ...(ceiling !== undefined ? { ignoreCeiling: ceiling } : {}),
283
365
  ...(options.signal ? { signal: options.signal } : {}),
284
366
  maxDepth: options.maxDepth ?? 100,
285
367
  suppressErrors: true,
@@ -1 +1 @@
1
- {"version":3,"file":"search.js","sourceRoot":"","sources":["../../src/core/search.ts"],"names":[],"mappings":"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","sourcesContent":["import { stat as fsStat, readFile } from 'node:fs/promises';\nimport { basename } from 'node:path';\n\nimport type { RE2ExecArray } from '@adguard/re2-wasm';\nimport { RE2 } from '@adguard/re2-wasm';\n\nimport { resolveStopReason } from './concurrency.ts';\nimport { globEntries, type GlobEntry } from './glob.ts';\nimport type { PathGuard } from './path.ts';\nimport { escapeRegExp, getMaxTextFileSize, MIB } from './util.ts';\n\n/**\n * A fresh, flat copy of `s`. V8 keeps a substring of a long string as a slice\n * that references its parent, so a kept line would otherwise pin the whole\n * file's text for as long as the match list lives (60 s in the page store).\n */\nconst own = (s: string): string => Buffer.from(s, 'utf8').toString('utf8');\n\ninterface SearchResult {\n file: string;\n line: number;\n /** 0-indexed column of the first occurrence on the line. */\n column: number;\n content: string;\n matchCount?: number;\n /** Up to `context` lines either side of the match; absent when context is 0. */\n before?: string[];\n after?: string[];\n}\n\nexport type Regex = RE2;\n\n/** Occurrence-counting bound, so one pathological line cannot spin forever. */\nconst MAX_MATCHES_PER_LINE = 100_000;\n\n/**\n * Largest input one RE2 call may scan as a single string. The input is copied\n * into the same fixed 16 MB wasm heap as the patterns; about 10 MB of input\n * aborts the module. 4 MiB leaves room for the copy and the pattern set.\n */\nexport const RE2_MAX_INPUT_BYTES = 4 * MIB;\n\n/**\n * Compile a pattern on RE2 rather than on V8's irregexp.\n *\n * Patterns arrive from the MCP client, so a backtracking engine would let one\n * request pin the event loop with no way out: an abort signal cannot preempt a\n * synchronous `exec`, and on stdio that wedges the whole server. RE2 matches in\n * time linear in the input and cannot backtrack at all, so the hazard is gone\n * rather than bounded.\n *\n * RE2 rejects the constructs the tool schema documents as unsupported —\n * lookahead, lookbehind, backreferences — with its own {@link SyntaxError},\n * which the tool layer turns into a normal tool error. It always matches in\n * Unicode mode and requires the `u` flag to say so.\n *\n * Every compiled pattern owns memory in re2-wasm's fixed 16 MB heap, which\n * `ALLOW_MEMORY_GROWTH` is off for. re2-wasm never frees it and a\n * FinalizationRegistry does not keep up (V8 sees no pressure from the wasm\n * heap), so exhaustion is an emscripten `abort()` that kills regex search for\n * the rest of the process. Every caller MUST pass the result to\n * {@link freeRegex} when it is done with it.\n */\nexport function compileRegex(pattern: string, options: { caseSensitive?: boolean } = {}): Regex {\n const flags = options.caseSensitive ? 'gu' : 'giu';\n try {\n return new RE2(pattern, flags);\n } catch (error) {\n if (!(error instanceof SyntaxError)) throw error;\n throw new SyntaxError(\n `${error.message} — lookahead, lookbehind, and backreferences are not supported.`,\n { cause: error },\n );\n }\n}\n\n/**\n * Release a compiled pattern's wasm memory. re2-wasm exposes no disposal of its\n * own, so this reaches the embind handle it holds privately; a version that\n * renames the field degrades to the pre-existing leak rather than throwing.\n * Idempotent — a second call on an already-freed handle is swallowed. Using a\n * {@link Regex} after freeing it is undefined behaviour in the wasm heap, so\n * free only in a `finally` that owns the compile.\n */\nexport function freeRegex(regex: Regex | undefined): void {\n const handle = (regex as unknown as { wrapper?: { delete?: () => void } } | undefined)?.wrapper;\n try {\n handle?.delete?.();\n } catch {\n // already deleted, or a re2-wasm build without embind disposal\n }\n}\n\nexport interface RegexMatch {\n /** The matched text. */\n text: string;\n /** Capture groups, 1-indexed in the pattern, 0-indexed here. */\n groups: (string | undefined)[];\n /** Named capture groups, when the pattern declares any. */\n named: Record<string, string> | undefined;\n /** Start offset in UTF-16 code units — usable with `String.prototype.slice`. */\n start: number;\n /** End offset in UTF-16 code units. */\n end: number;\n}\n\n/** Code-point length of `text`, i.e. its UTF-16 length minus its surrogate pairs. */\nconst codePointLength = (text: string): number =>\n // eslint-disable-next-line @typescript-eslint/no-misused-spread -- code points are the intended unit (re2-wasm offsets count code points); grapheme segmentation would change the count, not preserve it.\n [...text].length;\n\n/**\n * Iterate a global pattern's non-overlapping matches with offsets JS can use.\n *\n * re2-wasm reports `index` and `lastIndex` in **code points**, while every JS\n * string operation indexes in UTF-16 code units. The two agree only until the\n * first astral character (most emoji), past which every offset is short by one\n * per surrogate pair — slicing on the raw index cuts into the middle of\n * neighbouring text. Its `lastIndex` bookkeeping is worse still: it adds the\n * match's UTF-16 length to a code-point index, so an astral character inside a\n * match can skip the next one. This is the single place that corrects both; no\n * caller should read `match.index` or `regex.lastIndex` directly.\n *\n * Zero-length matches (e.g. `a*`) advance by one code point so they cannot loop\n * forever. The regex is global and shared across calls, so `lastIndex` is reset\n * on entry.\n */\nexport function* execMatches(regex: Regex, text: string): Generator<RegexMatch, undefined> {\n regex.lastIndex = 0;\n // Cursor into `text`, carried forward across matches: RE2 reports them in\n // ascending order, so the walk is O(text) in total rather than per match.\n let cursorCp = 0;\n let cursorU16 = 0;\n let match: RE2ExecArray | null;\n while ((match = regex.exec(text)) !== null) {\n while (cursorCp < match.index && cursorU16 < text.length) {\n const isPair =\n (text.charCodeAt(cursorU16) & 0xfc00) === 0xd800 &&\n (text.charCodeAt(cursorU16 + 1) & 0xfc00) === 0xdc00;\n cursorU16 += isPair ? 2 : 1;\n cursorCp++;\n }\n const matched = match[0] ?? '';\n const start = cursorU16;\n yield {\n text: matched,\n groups: match.slice(1),\n named: match.groups,\n start,\n end: start + matched.length,\n };\n // Own the advance rather than trusting the wrapper's mixed-unit arithmetic.\n regex.lastIndex = match.index + (matched.length === 0 ? 1 : codePointLength(matched));\n }\n}\n\n/**\n * Find non-overlapping occurrences of a global regex in a single line, reporting\n * the first match's column alongside the count.\n */\nfunction findLineMatches(\n regex: Regex,\n line: string,\n): { count: number; column: number } | undefined {\n let count = 0;\n let column = -1;\n for (const match of execMatches(regex, line)) {\n if (column === -1) column = match.start;\n count++;\n if (count >= MAX_MATCHES_PER_LINE) break;\n }\n return count > 0 ? { count, column } : undefined;\n}\n\nexport interface SearchContentOptions {\n caseSensitive?: boolean;\n isRegex?: boolean;\n maxResults?: number;\n filePattern?: string;\n skipIgnored?: boolean;\n includeHidden?: boolean;\n maxDepth?: number;\n /** Lines of context to carry either side of each match; 0 (default) carries none. */\n context?: number;\n signal?: AbortSignal;\n}\n\nexport interface SearchContentOutcome {\n basePath: string;\n matches: SearchResult[];\n summary: {\n /**\n * Matching *lines*, one per entry in `matches` — not pattern occurrences.\n * A line with three occurrences counts once here and reports 3 in its own\n * `SearchResult.matchCount`.\n */\n matchingLines: number;\n filesScanned: number;\n filesMatched: number;\n truncated: boolean;\n /** Files the guard rejected or that could not be stat'd. */\n skippedInaccessible: number;\n /** Files skipped unread because they exceed maxFileSize. */\n skippedTooLarge: number;\n /** Files skipped because they contain a NUL byte (binary). */\n skippedBinary: number;\n /**\n * `StoppedReason` narrowed to the stops these scans can produce: both\n * resolve the stop through `resolveStopReason` (concurrency.ts) — never\n * `maxFiles`, which belongs to `replace_text`'s per-file cap.\n */\n stoppedReason?: 'maxResults' | 'timeout';\n };\n}\n\nexport async function searchContent(\n directory: string,\n pattern: string,\n options: SearchContentOptions,\n pathGuard: PathGuard,\n): Promise<SearchContentOutcome> {\n const regex = compileRegex(options.isRegex ? pattern || '' : escapeRegExp(pattern || ''), {\n caseSensitive: Boolean(options.caseSensitive),\n });\n try {\n const matches: SearchResult[] = [];\n const maxResults = options.maxResults ?? 100;\n const maxFileSize = getMaxTextFileSize();\n const context = options.context ?? 0;\n\n const entries = globEntries({\n cwd: directory,\n pattern: options.filePattern ?? '**/*',\n // Same rule as replace_text, so a search with the same glob previews the\n // files a replace would touch: a slash-free glob matches at any depth.\n baseNameMatch: true,\n includeHidden: Boolean(options.includeHidden),\n skipIgnored: Boolean(options.skipIgnored),\n ...(options.signal ? { signal: options.signal } : {}),\n maxDepth: options.maxDepth ?? 100,\n suppressErrors: true,\n });\n\n let filesScanned = 0;\n let filesMatched = 0;\n let matchingLines = 0;\n let skippedTooLarge = 0;\n let skippedBinary = 0;\n const counters = { skippedInaccessible: 0, stoppedByAbort: false };\n\n for await (const entry of guardedEntries(entries, pathGuard, options.signal, counters)) {\n if (matches.length >= maxResults) break;\n\n // Skip oversized files before reading to avoid unbounded memory use. Count\n // them: \"no matches\" for a reason other than the pattern must be visible.\n try {\n const stats = await fsStat(entry.path);\n if (stats.size > maxFileSize) {\n skippedTooLarge++;\n continue;\n }\n } catch {\n counters.skippedInaccessible++;\n continue;\n }\n\n filesScanned++;\n\n try {\n const buffer = await readFile(entry.path, { signal: options.signal });\n // A NUL byte marks a binary file, as it does for `read` and `grep -I`.\n // Non-UTF-8 text is still searched: a lossy decode only affects the\n // bytes a pattern could not have matched anyway.\n if (buffer.includes(0)) {\n skippedBinary++;\n continue;\n }\n const content = buffer.toString('utf-8');\n const lines = content.split(/\\r?\\n/u);\n // A trailing newline splits into a phantom empty last element, and an\n // empty file splits into one empty element: neither is a line the\n // file has, for matching or for context.\n const lineCount =\n content.length === 0 ? 0 : content.endsWith('\\n') ? lines.length - 1 : lines.length;\n let matchedFile = false;\n for (let i = 0; i < lineCount; i++) {\n const line = lines[i];\n if (line === undefined) continue;\n // One scan per line: findLineMatches resets lastIndex itself, so it\n // doubles as the \"does this line match\" test.\n const found = findLineMatches(regex, line);\n if (found) {\n matchedFile = true;\n matchingLines++;\n matches.push({\n file: entry.path,\n line: i + 1,\n column: found.column,\n content: own(line),\n matchCount: found.count,\n ...(context > 0\n ? {\n before: lines.slice(Math.max(0, i - context), i).map(own),\n after: lines.slice(i + 1, Math.min(lineCount, i + 1 + context)).map(own),\n }\n : {}),\n });\n if (matches.length >= maxResults) break;\n }\n }\n if (matchedFile) filesMatched++;\n } catch {\n // A read failure while the signal is aborted IS the abort, not an\n // unreadable file — stop rather than spend another iteration and then\n // report a cut-short scan as complete.\n if (options.signal?.aborted) {\n counters.stoppedByAbort = true;\n break;\n }\n // unreadable mid-scan (deleted, permission changed): skip the file\n }\n }\n\n // The result cap is the definite cause even when the abort fired on the\n // same iteration (see the summary type's narrowing note).\n const stoppedReason = resolveStopReason(matches.length >= maxResults, counters.stoppedByAbort);\n\n return {\n basePath: directory,\n matches,\n summary: {\n matchingLines,\n filesScanned,\n filesMatched,\n truncated: stoppedReason !== undefined,\n skippedInaccessible: counters.skippedInaccessible,\n skippedTooLarge,\n skippedBinary,\n ...(stoppedReason ? { stoppedReason } : {}),\n },\n };\n } finally {\n freeRegex(regex);\n }\n}\n\nasync function* guardedEntries(\n entries: AsyncIterable<GlobEntry>,\n pathGuard: PathGuard,\n signal: AbortSignal | undefined,\n counters: { skippedInaccessible: number; stoppedByAbort: boolean },\n): AsyncGenerator<GlobEntry> {\n // The signal carries both client cancellation and the tool's search timeout,\n // so an abort means \"return what we have, marked incomplete\" rather than\n // throw — but it must never be reported as a finished scan.\n for await (const entry of entries) {\n if (signal?.aborted) {\n counters.stoppedByAbort = true;\n return;\n }\n try {\n await pathGuard.validateExistingPath(entry.path);\n } catch {\n counters.skippedInaccessible++;\n continue;\n }\n yield entry;\n }\n}\n\nexport async function searchFiles(\n directory: string,\n pattern: string,\n options: {\n maxResults?: number;\n includeHidden?: boolean;\n sortBy?: 'name' | 'path';\n skipIgnored?: boolean;\n maxDepth?: number;\n signal?: AbortSignal;\n },\n pathGuard: PathGuard,\n): Promise<{\n basePath: string;\n results: { path: string }[];\n summary: {\n matched: number;\n filesScanned: number;\n truncated: boolean;\n skippedInaccessible: number;\n /** Narrowed like SearchContentOutcome's — scans never `maxFiles`. */\n stoppedReason?: 'maxResults' | 'timeout';\n };\n}> {\n const maxResults = options.maxResults ?? 100;\n const entries = globEntries({\n cwd: directory,\n pattern,\n baseNameMatch: true,\n includeHidden: Boolean(options.includeHidden),\n skipIgnored: Boolean(options.skipIgnored),\n ...(options.signal ? { signal: options.signal } : {}),\n maxDepth: options.maxDepth ?? 100,\n suppressErrors: true,\n });\n const results: { path: string }[] = [];\n let filesScanned = 0;\n const counters = { skippedInaccessible: 0, stoppedByAbort: false };\n\n for await (const entry of guardedEntries(entries, pathGuard, options.signal, counters)) {\n if (results.length >= maxResults) break;\n filesScanned++;\n results.push({ path: entry.path });\n }\n\n // Sorting — only name / path are supported; size / modified were removed\n // (the glob never collected stats, so they were always undefined).\n if (options.sortBy === 'name') {\n results.sort((a, b) => basename(a.path).localeCompare(basename(b.path)));\n } else {\n results.sort((a, b) => a.path.localeCompare(b.path));\n }\n\n // Same precedence as above: the result cap wins over a same-iteration abort.\n const stoppedReason = resolveStopReason(results.length >= maxResults, counters.stoppedByAbort);\n\n return {\n basePath: directory,\n results,\n summary: {\n matched: results.length,\n filesScanned,\n truncated: stoppedReason !== undefined,\n skippedInaccessible: counters.skippedInaccessible,\n ...(stoppedReason ? { stoppedReason } : {}),\n },\n };\n}\n"]}
1
+ {"version":3,"file":"search.js","sourceRoot":"","sources":["../../src/core/search.ts"],"names":[],"mappings":"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","sourcesContent":["import { stat as fsStat, readFile } from 'node:fs/promises';\nimport { basename } from 'node:path';\n\nimport type { RE2ExecArray } from '@adguard/re2-wasm';\nimport { RE2 } from '@adguard/re2-wasm';\n\nimport { processEntriesConcurrently, resolveStopReason } from './concurrency.ts';\nimport { globEntries, type GlobEntry } from './glob.ts';\nimport type { PathGuard } from './path.ts';\nimport { escapeRegExp, getMaxTextFileSize, MIB, PARALLEL_CONCURRENCY } from './util.ts';\n\n/**\n * A fresh, flat copy of `s`. V8 keeps a substring of a long string as a slice\n * that references its parent, so a kept line would otherwise pin the whole\n * file's text for as long as the match list lives (60 s in the page store).\n */\nconst own = (s: string): string => Buffer.from(s, 'utf8').toString('utf8');\n\ninterface SearchResult {\n file: string;\n line: number;\n /** 0-indexed column of the first occurrence on the line. */\n column: number;\n content: string;\n matchCount: number;\n /** Up to `context` lines either side of the match; absent when context is 0. */\n before?: string[];\n after?: string[];\n}\n\nexport type Regex = RE2;\n\n/** Occurrence-counting bound, so one pathological line cannot spin forever. */\nconst MAX_MATCHES_PER_LINE = 100_000;\n\n/**\n * Largest input one RE2 call may scan as a single string. The input is copied\n * into the same fixed 16 MB wasm heap as the patterns; about 10 MB of input\n * aborts the module. 4 MiB leaves room for the copy and the pattern set.\n */\nexport const RE2_MAX_INPUT_BYTES = 4 * MIB;\n\nconst BUFFER_ANCHOR_RE = /\\\\[AzZ]/u;\n/** `(?i)`, `(?-m)`, `(?s:…)`: inline flags can undo the `m` the prefilter relies on. */\nconst INLINE_FLAGS_RE = /\\(\\?[a-zA-Z-]+[:)]/u;\nconst CR = 13;\n\n/**\n * Compile a pattern on RE2 rather than on V8's irregexp.\n *\n * Patterns arrive from the MCP client, so a backtracking engine would let one\n * request pin the event loop with no way out: an abort signal cannot preempt a\n * synchronous `exec`, and on stdio that wedges the whole server. RE2 matches in\n * time linear in the input and cannot backtrack at all, so the hazard is gone\n * rather than bounded.\n *\n * RE2 rejects the constructs the tool schema documents as unsupported —\n * lookahead, lookbehind, backreferences — with its own {@link SyntaxError},\n * which the tool layer turns into a normal tool error. It always matches in\n * Unicode mode and requires the `u` flag to say so.\n *\n * Every compiled pattern owns memory in re2-wasm's fixed 16 MB heap, which\n * `ALLOW_MEMORY_GROWTH` is off for. re2-wasm never frees it and a\n * FinalizationRegistry does not keep up (V8 sees no pressure from the wasm\n * heap), so exhaustion is an emscripten `abort()` that kills regex search for\n * the rest of the process. Every caller MUST pass the result to\n * {@link freeRegex} when it is done with it.\n */\nexport function compileRegex(\n pattern: string,\n options: { caseSensitive?: boolean; multiline?: boolean } = {},\n): Regex {\n const flags = `g${options.multiline ? 'm' : ''}${options.caseSensitive ? '' : 'i'}u`;\n try {\n return new RE2(pattern, flags);\n } catch (error) {\n if (!(error instanceof SyntaxError)) throw error;\n throw new SyntaxError(\n `${error.message} — lookahead, lookbehind, and backreferences are not supported.`,\n { cause: error },\n );\n }\n}\n\n/**\n * Release a compiled pattern's wasm memory. re2-wasm exposes no disposal of its\n * own, so this reaches the embind handle it holds privately; a version that\n * renames the field degrades to the pre-existing leak rather than throwing.\n * Idempotent — a second call on an already-freed handle is swallowed. Using a\n * {@link Regex} after freeing it is undefined behaviour in the wasm heap, so\n * free only in a `finally` that owns the compile.\n */\nexport function freeRegex(regex: Regex | undefined): void {\n const handle = (regex as unknown as { wrapper?: { delete?: () => void } } | undefined)?.wrapper;\n try {\n handle?.delete?.();\n } catch {\n // already deleted, or a re2-wasm build without embind disposal\n }\n}\n\nexport interface RegexMatch {\n /** The matched text. */\n text: string;\n /** Capture groups, 1-indexed in the pattern, 0-indexed here. */\n groups: (string | undefined)[];\n /** Named capture groups, when the pattern declares any. */\n named: Record<string, string> | undefined;\n /** Start offset in UTF-16 code units — usable with `String.prototype.slice`. */\n start: number;\n /** End offset in UTF-16 code units. */\n end: number;\n}\n\n/** Code-point length of `text`, i.e. its UTF-16 length minus its surrogate pairs. */\nconst codePointLength = (text: string): number =>\n // eslint-disable-next-line @typescript-eslint/no-misused-spread -- code points are the intended unit (re2-wasm offsets count code points); grapheme segmentation would change the count, not preserve it.\n [...text].length;\n\n/**\n * Iterate a global pattern's non-overlapping matches with offsets JS can use.\n *\n * re2-wasm reports `index` and `lastIndex` in **code points**, while every JS\n * string operation indexes in UTF-16 code units. The two agree only until the\n * first astral character (most emoji), past which every offset is short by one\n * per surrogate pair — slicing on the raw index cuts into the middle of\n * neighbouring text. Its `lastIndex` bookkeeping is worse still: it adds the\n * match's UTF-16 length to a code-point index, so an astral character inside a\n * match can skip the next one. This is the single place that corrects both; no\n * caller should read `match.index` or `regex.lastIndex` directly.\n *\n * Zero-length matches (e.g. `a*`) advance by one code point so they cannot loop\n * forever. The regex is global and shared across calls, so `lastIndex` is reset\n * on entry.\n */\nexport function* execMatches(regex: Regex, text: string): Generator<RegexMatch, undefined> {\n regex.lastIndex = 0;\n // Cursor into `text`, carried forward across matches: RE2 reports them in\n // ascending order, so the walk is O(text) in total rather than per match.\n let cursorCp = 0;\n let cursorU16 = 0;\n let match: RE2ExecArray | null;\n while ((match = regex.exec(text)) !== null) {\n while (cursorCp < match.index && cursorU16 < text.length) {\n const isPair =\n (text.charCodeAt(cursorU16) & 0xfc00) === 0xd800 &&\n (text.charCodeAt(cursorU16 + 1) & 0xfc00) === 0xdc00;\n cursorU16 += isPair ? 2 : 1;\n cursorCp++;\n }\n const matched = match[0] ?? '';\n const start = cursorU16;\n yield {\n text: matched,\n groups: match.slice(1),\n named: match.groups,\n start,\n end: start + matched.length,\n };\n // Own the advance rather than trusting the wrapper's mixed-unit arithmetic.\n regex.lastIndex = match.index + (matched.length === 0 ? 1 : codePointLength(matched));\n }\n}\n\n/**\n * Find non-overlapping occurrences of a global regex in a single line, reporting\n * the first match's column alongside the count.\n */\nfunction findLineMatches(\n regex: Regex,\n line: string,\n): { count: number; column: number } | undefined {\n let count = 0;\n let column = -1;\n for (const match of execMatches(regex, line)) {\n if (column === -1) column = match.start;\n count++;\n if (count >= MAX_MATCHES_PER_LINE) break;\n }\n return count > 0 ? { count, column } : undefined;\n}\n\nexport interface SearchContentOptions {\n caseSensitive?: boolean;\n isRegex?: boolean;\n maxResults?: number;\n filePattern?: string;\n skipIgnored?: boolean;\n includeHidden?: boolean;\n maxDepth?: number;\n /**\n * Search exactly this file (absolute, already guard-validated) instead of\n * walking `directory` with `filePattern`. Set when the caller named a file:\n * its name must not be interpreted as a glob.\n */\n explicitFile?: string;\n /** Lines of context to carry either side of each match; 0 (default) carries none. */\n context?: number;\n signal?: AbortSignal;\n}\n\nexport interface SearchContentOutcome {\n basePath: string;\n matches: SearchResult[];\n summary: {\n /**\n * Matching *lines*, one per entry in `matches` — not pattern occurrences.\n * A line with three occurrences counts once here and reports 3 in its own\n * `SearchResult.matchCount`.\n */\n matchingLines: number;\n filesScanned: number;\n filesMatched: number;\n truncated: boolean;\n /** Files the guard rejected or that could not be stat'd. */\n skippedInaccessible: number;\n /** Files skipped unread because they exceed maxFileSize. */\n skippedTooLarge: number;\n /** Files skipped because they contain a NUL byte (binary). */\n skippedBinary: number;\n /**\n * `StoppedReason` narrowed to the stops these scans can produce: both\n * resolve the stop through `resolveStopReason` (concurrency.ts) — never\n * `maxFiles`, which belongs to `replace_text`'s per-file cap.\n */\n stoppedReason?: 'maxResults' | 'timeout';\n };\n}\n\ntype FileScan =\n | { kind: 'inaccessible' }\n | { kind: 'tooLarge' }\n | { kind: 'binary' }\n | { kind: 'scanned'; matches: SearchResult[] };\n\ninterface ScanContext {\n pathGuard: PathGuard;\n signal: AbortSignal | undefined;\n maxFileSize: number;\n maxResults: number;\n regex: Regex;\n prefilter: Regex;\n source: string;\n context: number;\n}\n\nasync function scanFile(entryPath: string, scanCtx: ScanContext): Promise<FileScan> {\n try {\n await scanCtx.pathGuard.validateExistingPath(entryPath);\n } catch {\n return { kind: 'inaccessible' };\n }\n\n try {\n const stats = await fsStat(entryPath);\n if (stats.size > scanCtx.maxFileSize) return { kind: 'tooLarge' };\n } catch {\n return { kind: 'inaccessible' };\n }\n\n let buffer: Buffer;\n try {\n buffer = await readFile(entryPath, { signal: scanCtx.signal });\n } catch (error) {\n if (scanCtx.signal?.aborted) throw error;\n return { kind: 'inaccessible' };\n }\n\n // A NUL byte marks a binary file, as it does for `read` and `grep -I`.\n // Non-UTF-8 text is still searched: a lossy decode only affects the\n // bytes a pattern could not have matched anyway.\n if (buffer.includes(0)) return { kind: 'binary' };\n\n const content = buffer.toString('utf-8');\n if (canPrefilter(scanCtx.source, buffer)) {\n scanCtx.prefilter.lastIndex = 0;\n if (scanCtx.prefilter.exec(content) === null) return { kind: 'scanned', matches: [] };\n }\n\n const lines = content.split(/\\r?\\n/u);\n // A trailing newline splits into a phantom empty last element, and an\n // empty file splits into one empty element: neither is a line the\n // file has, for matching or for context.\n const lineCount =\n content.length === 0 ? 0 : content.endsWith('\\n') ? lines.length - 1 : lines.length;\n const matches: SearchResult[] = [];\n for (let i = 0; i < lineCount; i++) {\n const line = lines[i];\n if (line === undefined) continue;\n // One scan per line: the helper resets lastIndex itself, so it\n // doubles as the \"does this line match\" test.\n const found = findLineMatches(scanCtx.regex, line);\n if (found) {\n matches.push({\n file: entryPath,\n line: i + 1,\n column: found.column,\n content: own(line),\n matchCount: found.count,\n ...(scanCtx.context > 0\n ? {\n before: lines.slice(Math.max(0, i - scanCtx.context), i).map(own),\n after: lines.slice(i + 1, Math.min(lineCount, i + 1 + scanCtx.context)).map(own),\n }\n : {}),\n });\n if (matches.length >= scanCtx.maxResults) break;\n }\n }\n return { kind: 'scanned', matches };\n}\n\nexport async function searchContent(\n directory: string,\n pattern: string,\n options: SearchContentOptions,\n pathGuard: PathGuard,\n): Promise<SearchContentOutcome> {\n const source = options.isRegex ? pattern || '' : escapeRegExp(pattern || '');\n const regex = compileRegex(source, {\n caseSensitive: Boolean(options.caseSensitive),\n });\n // Whole-buffer test: `m` makes ^/$ line-relative so a per-line hit is always a\n // buffer hit (the converse can be false — a false positive just falls through\n // to the per-line scan). \\A/\\z/\\Z are buffer-relative even under `m`, inline\n // flag groups can undo `m`, and $ cannot see past a \\r, so those inputs skip\n // the prefilter (see canPrefilter).\n const prefilter = compileRegex(source, {\n caseSensitive: Boolean(options.caseSensitive),\n multiline: true,\n });\n try {\n const matches: SearchResult[] = [];\n const maxResults = options.maxResults ?? 100;\n const maxFileSize = getMaxTextFileSize();\n const context = options.context ?? 0;\n const ceiling = pathGuard.allowedRootContaining(directory);\n\n const entries = options.explicitFile\n ? [{ path: options.explicitFile }]\n : globEntries({\n cwd: directory,\n pattern: options.filePattern ?? '**/*',\n // Same rule as replace_text, so a search with the same glob previews the\n // files a replace would touch: a slash-free glob matches at any depth.\n baseNameMatch: true,\n includeHidden: Boolean(options.includeHidden),\n skipIgnored: Boolean(options.skipIgnored),\n ...(ceiling !== undefined ? { ignoreCeiling: ceiling } : {}),\n ...(options.signal ? { signal: options.signal } : {}),\n maxDepth: options.maxDepth ?? 100,\n suppressErrors: true,\n });\n\n let filesScanned = 0;\n let filesMatched = 0;\n let matchingLines = 0;\n let skippedTooLarge = 0;\n let skippedBinary = 0;\n const scanCtx: ScanContext = {\n pathGuard,\n signal: options.signal,\n maxFileSize,\n maxResults,\n regex,\n prefilter,\n source,\n context,\n };\n const perFile = new Map<number, FileScan>();\n let dispatched = 0;\n let collected = 0;\n const stopped = await processEntriesConcurrently(entries, {\n signal: options.signal,\n concurrency: PARALLEL_CONCURRENCY,\n shouldStop: () => collected >= maxResults,\n onEntry: () => {\n dispatched++;\n },\n onError: () => undefined,\n runEntry: async (entryPath) => {\n const index = dispatched - 1;\n const scan = await scanFile(entryPath, scanCtx);\n perFile.set(index, scan);\n if (scan.kind === 'scanned') collected += scan.matches.length;\n },\n });\n\n let skippedInaccessible = 0;\n for (let i = 0; i < dispatched; i++) {\n const scan = perFile.get(i);\n if (!scan) continue;\n switch (scan.kind) {\n case 'inaccessible':\n skippedInaccessible++;\n break;\n case 'tooLarge':\n skippedTooLarge++;\n break;\n case 'binary':\n filesScanned++;\n skippedBinary++;\n break;\n case 'scanned':\n filesScanned++;\n if (scan.matches.length > 0) filesMatched++;\n for (const match of scan.matches) {\n if (matches.length >= maxResults) break;\n matches.push(match);\n matchingLines++;\n }\n break;\n }\n }\n\n const stoppedReason = resolveStopReason(\n matches.length >= maxResults,\n stopped === 'timeout' || Boolean(options.signal?.aborted),\n );\n\n return {\n basePath: directory,\n matches,\n summary: {\n matchingLines,\n filesScanned,\n filesMatched,\n truncated: stoppedReason !== undefined,\n skippedInaccessible,\n skippedTooLarge,\n skippedBinary,\n ...(stoppedReason ? { stoppedReason } : {}),\n },\n };\n } finally {\n freeRegex(prefilter);\n freeRegex(regex);\n }\n}\n\n/** Whether one whole-buffer RE2 test is a sound \"cannot match\" check for this file. */\nfunction canPrefilter(source: string, buffer: Buffer): boolean {\n if (buffer.length > RE2_MAX_INPUT_BYTES) return false;\n if (BUFFER_ANCHOR_RE.test(source)) return false;\n if (INLINE_FLAGS_RE.test(source)) return false;\n if (source.includes('$') && buffer.includes(CR)) return false;\n return true;\n}\n\nasync function* guardedEntries(\n entries: AsyncIterable<GlobEntry>,\n pathGuard: PathGuard,\n signal: AbortSignal | undefined,\n counters: { skippedInaccessible: number; stoppedByAbort: boolean },\n): AsyncGenerator<GlobEntry> {\n // The signal carries both client cancellation and the tool's search timeout,\n // so an abort means \"return what we have, marked incomplete\" rather than\n // throw — but it must never be reported as a finished scan.\n for await (const entry of entries) {\n if (signal?.aborted) {\n counters.stoppedByAbort = true;\n return;\n }\n try {\n await pathGuard.validateExistingPath(entry.path);\n } catch {\n counters.skippedInaccessible++;\n continue;\n }\n yield entry;\n }\n}\n\nexport async function searchFiles(\n directory: string,\n pattern: string,\n options: {\n maxResults?: number;\n includeHidden?: boolean;\n sortBy?: 'name' | 'path';\n skipIgnored?: boolean;\n maxDepth?: number;\n signal?: AbortSignal;\n },\n pathGuard: PathGuard,\n): Promise<{\n basePath: string;\n results: { path: string }[];\n summary: {\n matched: number;\n filesScanned: number;\n truncated: boolean;\n skippedInaccessible: number;\n /** Narrowed like SearchContentOutcome's — scans never `maxFiles`. */\n stoppedReason?: 'maxResults' | 'timeout';\n };\n}> {\n const maxResults = options.maxResults ?? 100;\n const ceiling = pathGuard.allowedRootContaining(directory);\n const entries = globEntries({\n cwd: directory,\n pattern,\n baseNameMatch: true,\n includeHidden: Boolean(options.includeHidden),\n skipIgnored: Boolean(options.skipIgnored),\n ...(ceiling !== undefined ? { ignoreCeiling: ceiling } : {}),\n ...(options.signal ? { signal: options.signal } : {}),\n maxDepth: options.maxDepth ?? 100,\n suppressErrors: true,\n });\n const results: { path: string }[] = [];\n let filesScanned = 0;\n const counters = { skippedInaccessible: 0, stoppedByAbort: false };\n\n for await (const entry of guardedEntries(entries, pathGuard, options.signal, counters)) {\n if (results.length >= maxResults) break;\n filesScanned++;\n results.push({ path: entry.path });\n }\n\n // Sorting — only name / path are supported; size / modified were removed\n // (the glob never collected stats, so they were always undefined).\n if (options.sortBy === 'name') {\n results.sort((a, b) => basename(a.path).localeCompare(basename(b.path)));\n } else {\n results.sort((a, b) => a.path.localeCompare(b.path));\n }\n\n // Same precedence as above: the result cap wins over a same-iteration abort.\n const stoppedReason = resolveStopReason(results.length >= maxResults, counters.stoppedByAbort);\n\n return {\n basePath: directory,\n results,\n summary: {\n matched: results.length,\n filesScanned,\n truncated: stoppedReason !== undefined,\n skippedInaccessible: counters.skippedInaccessible,\n ...(stoppedReason ? { stoppedReason } : {}),\n },\n };\n}\n"]}
@@ -5,6 +5,7 @@ interface ResourceEntry {
5
5
  name: string;
6
6
  mimeType: string;
7
7
  size: number;
8
+ createdAt: string;
8
9
  expiresAt: string;
9
10
  text: string;
10
11
  }
@@ -54,20 +55,15 @@ export declare function invalidCursor(): FsError;
54
55
  * Short-lived snapshots of a completed query's full result set, so later pages
55
56
  * slice a stored array instead of re-scanning and re-sorting the filesystem.
56
57
  *
57
- * ponytail: bounded by snapshot count and TTL, not by bytes — 32 x 20,000
58
- * `list` entries or 32 x 10,000 `search_text` matches, each match retaining up
59
- * to 20 more line strings at `context: 10`, order of a few hundred MB held for
60
- * 60s, and the HTTP leg shares one store so any caller can drive it. If that
61
- * shows up as memory pressure, pass a byte weight to `set` the way
62
- * `ResourceStore` does.
58
+ * ponytail: bounded by snapshot count and TTL, not by bytes — MAX_SNAPSHOTS x
59
+ * 20,000 `list` entries or MAX_SNAPSHOTS x 10,000 `search_text` matches, each
60
+ * match retaining up to 20 more line strings at `context: 10`, order of a few
61
+ * hundred MB held for 60s, and the HTTP leg shares one store so any caller can
62
+ * drive it. If that shows up as memory pressure, pass a byte weight to `set`
63
+ * the way `ResourceStore` does.
63
64
  */
64
65
  export declare class PageSnapshotStore {
65
66
  #private;
66
- constructor(options?: {
67
- maxSnapshots?: number;
68
- ttlMs?: number;
69
- now?: () => number;
70
- });
71
67
  create(params: {
72
68
  queryKey: string;
73
69
  items: readonly unknown[];
@@ -1 +1 @@
1
- {"version":3,"file":"store.d.ts","sourceRoot":"","sources":["../../src/core/store.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,8BAA8B,CAAC;AAIjE,OAAO,EAAa,OAAO,EAAE,MAAM,aAAa,CAAC;AAqFjD,UAAU,aAAa;IACrB,GAAG,EAAE,MAAM,CAAC;IACZ,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,EAAE,MAAM,CAAC;IACjB,IAAI,EAAE,MAAM,CAAC;IACb,SAAS,EAAE,MAAM,CAAC;IAClB,IAAI,EAAE,MAAM,CAAC;CACd;AAOD,qBAAa,aAAa;;gBAQZ,aAAa,CAAC,EAAE,MAAM,IAAI;IAItC,QAAQ,CAAC,GAAG,EAAE,MAAM,GAAG,aAAa;IAYpC,OAAO,CAAC,MAAM,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAA;KAAE,GAAG,aAAa;IAmBjF;;;;OAIG;IACH,IAAI,IAAI,MAAM,EAAE;CAGjB;AAED,MAAM,WAAW,kBAAkB;IACjC,KAAK,EAAE;QACL,GAAG,EAAE,MAAM,CAAC;QACZ,IAAI,EAAE,MAAM,CAAC;QACb,QAAQ,EAAE,MAAM,CAAC;QACjB,SAAS,EAAE,MAAM,CAAC;KACnB,CAAC;IACF,IAAI,EAAE,YAAY,CAAC;CACpB;AAED;;;;;GAKG;AACH,wBAAgB,eAAe,CAC7B,KAAK,EAAE,aAAa,EACpB,IAAI,EAAE,MAAM,EAIZ,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,SAAS,OAAO,EAAE,GAClD,kBAAkB,CAuBpB;AAID,MAAM,WAAW,YAAY,CAAC,CAAC,GAAG,OAAO,EAAE,CAAC,GAAG,OAAO;IACpD,QAAQ,CAAC,KAAK,EAAE,SAAS,CAAC,EAAE,CAAC;IAC7B,QAAQ,CAAC,QAAQ,EAAE,CAAC,CAAC;CACtB;AAED;;;;GAIG;AACH,wBAAgB,aAAa,IAAI,OAAO,CAKvC;AAED;;;;;;;;;;GAUG;AACH,qBAAa,iBAAiB;;gBAGhB,OAAO,GAAE;QAAE,YAAY,CAAC,EAAE,MAAM,CAAC;QAAC,KAAK,CAAC,EAAE,MAAM,CAAC;QAAC,GAAG,CAAC,EAAE,MAAM,MAAM,CAAA;KAAO;IAQvF,MAAM,CAAC,MAAM,EAAE;QAAE,QAAQ,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,SAAS,OAAO,EAAE,CAAC;QAAC,QAAQ,CAAC,EAAE,OAAO,CAAA;KAAE,GAAG,MAAM;IAU3F,IAAI,CAAC,CAAC,EAAE,CAAC,GAAG,SAAS,EAAE,UAAU,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,YAAY,CAAC,CAAC,EAAE,CAAC,CAAC;IAMhF,KAAK,IAAI,IAAI;CAGd"}
1
+ {"version":3,"file":"store.d.ts","sourceRoot":"","sources":["../../src/core/store.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,8BAA8B,CAAC;AAIjE,OAAO,EAAa,OAAO,EAAE,MAAM,aAAa,CAAC;AAmFjD,UAAU,aAAa;IACrB,GAAG,EAAE,MAAM,CAAC;IACZ,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,EAAE,MAAM,CAAC;IACjB,IAAI,EAAE,MAAM,CAAC;IACb,SAAS,EAAE,MAAM,CAAC;IAClB,SAAS,EAAE,MAAM,CAAC;IAClB,IAAI,EAAE,MAAM,CAAC;CACd;AAOD,qBAAa,aAAa;;gBAQZ,aAAa,CAAC,EAAE,MAAM,IAAI;IAItC,QAAQ,CAAC,GAAG,EAAE,MAAM,GAAG,aAAa;IAYpC,OAAO,CAAC,MAAM,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAA;KAAE,GAAG,aAAa;IAqBjF;;;;OAIG;IACH,IAAI,IAAI,MAAM,EAAE;CAGjB;AAED,MAAM,WAAW,kBAAkB;IACjC,KAAK,EAAE;QACL,GAAG,EAAE,MAAM,CAAC;QACZ,IAAI,EAAE,MAAM,CAAC;QACb,QAAQ,EAAE,MAAM,CAAC;QACjB,SAAS,EAAE,MAAM,CAAC;KACnB,CAAC;IACF,IAAI,EAAE,YAAY,CAAC;CACpB;AAED;;;;;GAKG;AACH,wBAAgB,eAAe,CAC7B,KAAK,EAAE,aAAa,EACpB,IAAI,EAAE,MAAM,EAIZ,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,SAAS,OAAO,EAAE,GAClD,kBAAkB,CAwBpB;AAID,MAAM,WAAW,YAAY,CAAC,CAAC,GAAG,OAAO,EAAE,CAAC,GAAG,OAAO;IACpD,QAAQ,CAAC,KAAK,EAAE,SAAS,CAAC,EAAE,CAAC;IAC7B,QAAQ,CAAC,QAAQ,EAAE,CAAC,CAAC;CACtB;AAED;;;;GAIG;AACH,wBAAgB,aAAa,IAAI,OAAO,CAKvC;AAID;;;;;;;;;;GAUG;AACH,qBAAa,iBAAiB;;IAM5B,MAAM,CAAC,MAAM,EAAE;QAAE,QAAQ,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,SAAS,OAAO,EAAE,CAAC;QAAC,QAAQ,CAAC,EAAE,OAAO,CAAA;KAAE,GAAG,MAAM;IAU3F,IAAI,CAAC,CAAC,EAAE,CAAC,GAAG,SAAS,EAAE,UAAU,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,YAAY,CAAC,CAAC,EAAE,CAAC,CAAC;IAMhF,KAAK,IAAI,IAAI;CAGd"}
@@ -12,17 +12,15 @@ class TtlLru {
12
12
  #maxEntries;
13
13
  #maxWeight;
14
14
  #ttlMs;
15
- #now;
16
15
  #weight = 0;
17
16
  constructor(opts) {
18
17
  this.#maxEntries = opts.maxEntries;
19
18
  this.#maxWeight = opts.maxWeight ?? Number.POSITIVE_INFINITY;
20
19
  this.#ttlMs = opts.ttlMs;
21
- this.#now = opts.now ?? Date.now;
22
20
  }
23
21
  /** Drop every expired entry; true when at least one went. */
24
22
  prune() {
25
- const now = this.#now();
23
+ const now = Date.now();
26
24
  const before = this.#byKey.size;
27
25
  for (const [key, entry] of this.#byKey) {
28
26
  if (entry.expiresAt <= now)
@@ -35,7 +33,7 @@ class TtlLru {
35
33
  const entry = this.#byKey.get(key);
36
34
  if (!entry)
37
35
  return undefined;
38
- if (entry.expiresAt <= this.#now()) {
36
+ if (entry.expiresAt <= Date.now()) {
39
37
  this.delete(key);
40
38
  return undefined;
41
39
  }
@@ -47,7 +45,7 @@ class TtlLru {
47
45
  set(key, value, weight = 0) {
48
46
  this.prune();
49
47
  this.delete(key);
50
- this.#byKey.set(key, { value, expiresAt: this.#now() + this.#ttlMs, weight });
48
+ this.#byKey.set(key, { value, expiresAt: Date.now() + this.#ttlMs, weight });
51
49
  this.#weight += weight;
52
50
  while (this.#byKey.size > 0 &&
53
51
  (this.#byKey.size > this.#maxEntries || this.#weight > this.#maxWeight)) {
@@ -105,12 +103,14 @@ export class ResourceStore {
105
103
  this.#onListChanged?.();
106
104
  throw new FsError(ErrorCode.TOO_LARGE, `Resource too large to cache (${entryBytes} bytes).`);
107
105
  }
106
+ const now = Date.now();
108
107
  const entry = {
109
108
  uri: `filesystem-mcp://result/${randomUUID()}`,
110
109
  name: params.name,
111
110
  mimeType: params.mimeType ?? 'text/plain',
112
111
  size: entryBytes,
113
- expiresAt: new Date(Date.now() + ENTRY_TTL_MS).toISOString(),
112
+ createdAt: new Date(now).toISOString(),
113
+ expiresAt: new Date(now + ENTRY_TTL_MS).toISOString(),
114
114
  text: params.text,
115
115
  };
116
116
  this.#entries.set(entry.uri, entry, entryBytes);
@@ -153,9 +153,10 @@ value) {
153
153
  type: 'resource_link',
154
154
  uri: entry.uri,
155
155
  name: entry.name,
156
+ description: `Cached tool result; expires ${entry.expiresAt}`,
156
157
  mimeType: entry.mimeType,
157
158
  size: entry.size,
158
- annotations: { audience: ['user'] },
159
+ annotations: { audience: ['user'], lastModified: entry.createdAt },
159
160
  },
160
161
  };
161
162
  }
@@ -167,26 +168,23 @@ value) {
167
168
  export function invalidCursor() {
168
169
  return new FsError(ErrorCode.INVALID_INPUT, 'Invalid cursor. Request the first page without a cursor.');
169
170
  }
171
+ const MAX_SNAPSHOTS = 32;
170
172
  /**
171
173
  * Short-lived snapshots of a completed query's full result set, so later pages
172
174
  * slice a stored array instead of re-scanning and re-sorting the filesystem.
173
175
  *
174
- * ponytail: bounded by snapshot count and TTL, not by bytes — 32 x 20,000
175
- * `list` entries or 32 x 10,000 `search_text` matches, each match retaining up
176
- * to 20 more line strings at `context: 10`, order of a few hundred MB held for
177
- * 60s, and the HTTP leg shares one store so any caller can drive it. If that
178
- * shows up as memory pressure, pass a byte weight to `set` the way
179
- * `ResourceStore` does.
176
+ * ponytail: bounded by snapshot count and TTL, not by bytes — MAX_SNAPSHOTS x
177
+ * 20,000 `list` entries or MAX_SNAPSHOTS x 10,000 `search_text` matches, each
178
+ * match retaining up to 20 more line strings at `context: 10`, order of a few
179
+ * hundred MB held for 60s, and the HTTP leg shares one store so any caller can
180
+ * drive it. If that shows up as memory pressure, pass a byte weight to `set`
181
+ * the way `ResourceStore` does.
180
182
  */
181
183
  export class PageSnapshotStore {
182
- #byId;
183
- constructor(options = {}) {
184
- this.#byId = new TtlLru({
185
- maxEntries: options.maxSnapshots ?? 32,
186
- ttlMs: options.ttlMs ?? 60 * 1000,
187
- ...(options.now ? { now: options.now } : {}),
188
- });
189
- }
184
+ #byId = new TtlLru({
185
+ maxEntries: MAX_SNAPSHOTS,
186
+ ttlMs: ENTRY_TTL_MS,
187
+ });
190
188
  create(params) {
191
189
  const snapshotId = randomUUID();
192
190
  this.#byId.set(snapshotId, {