mouaif 0.3.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 (116) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +140 -0
  3. package/bin/mouaif.js +281 -0
  4. package/frontend/dist/assets/AgentFilePicker-CcKLJorU.js +1 -0
  5. package/frontend/dist/assets/CliModal-Hs5phmNZ.js +7 -0
  6. package/frontend/dist/assets/DictationPage-BI23lp42.js +2 -0
  7. package/frontend/dist/assets/FileEditor-DDl31c6d.js +2 -0
  8. package/frontend/dist/assets/GitModal-3EC_gpJ5.js +2 -0
  9. package/frontend/dist/assets/Inspector-Ba3R1w04.js +73 -0
  10. package/frontend/dist/assets/SettingsAbout-bvZGDEDw.js +1 -0
  11. package/frontend/dist/assets/SettingsActions-Dk6WX9jv.js +1 -0
  12. package/frontend/dist/assets/SettingsAgents-BNV0MgDB.js +1 -0
  13. package/frontend/dist/assets/SettingsDefaults-DbMmQbzc.js +1 -0
  14. package/frontend/dist/assets/SettingsHiddenContent-BZ2sloH1.js +1 -0
  15. package/frontend/dist/assets/SettingsMcp-DOrfbQd1.js +1 -0
  16. package/frontend/dist/assets/SettingsMcpEdit-BGMQ2CWC.js +3 -0
  17. package/frontend/dist/assets/SettingsMcpRegistry-BywXee_A.js +1 -0
  18. package/frontend/dist/assets/SettingsNotifications-B0LEs11a.js +1 -0
  19. package/frontend/dist/assets/SettingsPricing-BAg33iVF.js +1 -0
  20. package/frontend/dist/assets/SettingsProject-DNrKhCcZ.js +14 -0
  21. package/frontend/dist/assets/SettingsProjects-IqkBfDcm.js +1 -0
  22. package/frontend/dist/assets/SettingsPrompts-BgeiASuk.js +1 -0
  23. package/frontend/dist/assets/SettingsProviders-k0xJN0IK.js +1 -0
  24. package/frontend/dist/assets/SettingsTags-B5kjFdQi.js +1 -0
  25. package/frontend/dist/assets/agentNavigation-BiiCpFz5.js +1 -0
  26. package/frontend/dist/assets/codemirror-Bp6CUUFk.js +30 -0
  27. package/frontend/dist/assets/index-BGvI4n0T.js +61 -0
  28. package/frontend/dist/assets/index-Bgg1gnDf.css +1 -0
  29. package/frontend/dist/assets/index-C1sQFIC-.css +1 -0
  30. package/frontend/dist/assets/index-CANPYzQg.css +1 -0
  31. package/frontend/dist/assets/index-Crn1LdzK.css +1 -0
  32. package/frontend/dist/assets/index-FbCWDPiB.css +1 -0
  33. package/frontend/dist/assets/projectQS-D1cSZ7Gr.js +1 -0
  34. package/frontend/dist/assets/virtual-list-6H9b4K51.js +1 -0
  35. package/frontend/dist/icons/favicon-32.png +0 -0
  36. package/frontend/dist/icons/icon-180-apple.png +0 -0
  37. package/frontend/dist/icons/icon-192.png +0 -0
  38. package/frontend/dist/icons/icon-512.png +0 -0
  39. package/frontend/dist/icons/icon-maskable-512.png +0 -0
  40. package/frontend/dist/index.html +83 -0
  41. package/frontend/dist/manifest.webmanifest +33 -0
  42. package/frontend/dist/sw.js +482 -0
  43. package/package.json +98 -0
  44. package/scripts/patch-zimmerframe.js +58 -0
  45. package/src/access-auth.js +515 -0
  46. package/src/agentFeatures.js +294 -0
  47. package/src/agentFiles.js +164 -0
  48. package/src/agentSkills.js +147 -0
  49. package/src/agents.js +230 -0
  50. package/src/ai-chat.js +21 -0
  51. package/src/ai-endpoints.js +1880 -0
  52. package/src/ai-stream.js +2048 -0
  53. package/src/ai.js +68 -0
  54. package/src/auth.js +391 -0
  55. package/src/chatdb.js +816 -0
  56. package/src/chats.js +275 -0
  57. package/src/custom-actions.js +65 -0
  58. package/src/files.js +431 -0
  59. package/src/hideFileContent.js +327 -0
  60. package/src/http-server.js +535 -0
  61. package/src/index.js +15 -0
  62. package/src/inspector.js +731 -0
  63. package/src/inspectorProfiles.js +503 -0
  64. package/src/live-chat.js +107 -0
  65. package/src/mcp.js +1517 -0
  66. package/src/messages.js +238 -0
  67. package/src/modelList.js +137 -0
  68. package/src/notifications.js +52 -0
  69. package/src/oauth-anthropic.js +280 -0
  70. package/src/oauth-github-copilot.js +417 -0
  71. package/src/oauth-mcp.js +216 -0
  72. package/src/oauth-openrouter.js +285 -0
  73. package/src/package-version.js +20 -0
  74. package/src/projects.js +285 -0
  75. package/src/promptProfiles.js +256 -0
  76. package/src/prompts.js +384 -0
  77. package/src/providerShapes.js +44 -0
  78. package/src/providers/base.js +41 -0
  79. package/src/providers/index.js +25 -0
  80. package/src/push.js +315 -0
  81. package/src/qr.js +192 -0
  82. package/src/restart.js +47 -0
  83. package/src/server-handlers-access.js +306 -0
  84. package/src/server-handlers-actions.js +100 -0
  85. package/src/server-handlers-ai.js +248 -0
  86. package/src/server-handlers-auth.js +273 -0
  87. package/src/server-handlers-chats.js +1436 -0
  88. package/src/server-handlers-git.js +467 -0
  89. package/src/server-handlers-mcp-oauth.js +56 -0
  90. package/src/server-handlers-misc.js +783 -0
  91. package/src/server-handlers-projects.js +289 -0
  92. package/src/server-handlers-prompts.js +259 -0
  93. package/src/server-handlers-push.js +102 -0
  94. package/src/server-handlers-settings.js +406 -0
  95. package/src/server-handlers-tools.js +654 -0
  96. package/src/server-handlers-transcribe.js +399 -0
  97. package/src/server-shared.js +780 -0
  98. package/src/server-web-static.js +191 -0
  99. package/src/settings.js +898 -0
  100. package/src/statusBar.js +541 -0
  101. package/src/tags.js +414 -0
  102. package/src/toolFeedback.js +225 -0
  103. package/src/tools/ask.js +154 -0
  104. package/src/tools/authorization.js +932 -0
  105. package/src/tools/files.js +1150 -0
  106. package/src/tools/progress.js +71 -0
  107. package/src/tools/restart.js +32 -0
  108. package/src/tools/searchEngine.js +957 -0
  109. package/src/tools/shell.js +341 -0
  110. package/src/tools/subagent.js +47 -0
  111. package/src/tools/task.js +234 -0
  112. package/src/tools/webpreview.js +448 -0
  113. package/src/trace.js +103 -0
  114. package/src/transcribe.js +683 -0
  115. package/src/usage.js +389 -0
  116. package/src/util.js +151 -0
@@ -0,0 +1,957 @@
1
+ 'use strict';
2
+
3
+ // Text-search engine behind the `search_files` tool.
4
+ //
5
+ // The tool used to run a hand-written JS walker: read every allowlisted file
6
+ // in full, split it into lines, and test a RegExp per line, with a call into
7
+ // hideFileContent for every line to apply the redaction rules. On this
8
+ // repository that meant 843 files / 15 MB of I/O and 27,680 re-reads of
9
+ // .mouaif.json to answer one query — 12.6 s for a full walk, ~2 s for a
10
+ // single-directory query.
11
+ //
12
+ // This module replaces that with ripgrep when a binary is available on the
13
+ // machine, and keeps a reworked JS walk for the machines where it is not.
14
+ // Both engines return the identical result object, so the tool, the formatter
15
+ // and the tests cannot tell which one ran.
16
+ //
17
+ // What the ripgrep path buys, beyond the speed:
18
+ // - .gitignore / .ignore are honored, so a build output tree that is
19
+ // checked in nowhere (`docs-dist/`) stops being searched.
20
+ // - Files with no extension or an extension outside the old allowlist
21
+ // (`Dockerfile`, `Makefile`, `LICENSE`, shell scripts) are searched.
22
+ // - ripgrep skips binary files and detects file encodings itself.
23
+ // - The real ripgrep regex dialect: `(?i)foo` works, and `(?i)` is passed
24
+ // through as `--ignore-case` so it also works on the older ripgrep builds
25
+ // that ship without PCRE2.
26
+ //
27
+ // Redaction is preserved on both paths, and it is line-granular by
28
+ // construction: every row this module returns is a single line of a single
29
+ // file, which is what `matchIsHiddenIn` can decide. ripgrep reports a
30
+ // 1-indexed line number and byte offsets for each submatch; the offsets are
31
+ // converted to 1-indexed character columns so a match inside a hidden
32
+ // character span is suppressed by the same predicate the walker uses. A match
33
+ // on a hidden line is dropped outright, never printed.
34
+ //
35
+ // That contract is why `(?s)` / dot-matches-newline is REJECTED with
36
+ // EBADINPUT instead of passed to `--multiline`: ripgrep reports a
37
+ // dot-matches-newline match as one record whose `line_number` is only the
38
+ // first line, so a hidden line in the middle of the match would be printed
39
+ // verbatim. A single-line engine cannot redact a multi-line result, so it does
40
+ // not accept one. See assertPatternSupported().
41
+ //
42
+ // There is no "search without redaction" mode, and a search whose redaction
43
+ // rules cannot be read behaves exactly as if none were configured.
44
+ //
45
+ // Public surface:
46
+ // findRipgrepBinary() -> string | null (cached for the process)
47
+ // runSearch({ projectDir, args, settings }) -> Promise<result>
48
+ //
49
+ // `result` is the shape src/tools/files.js formats and the tests assert on:
50
+ // { query, matches: [{ path, line, text }], filesScanned, truncated,
51
+ // engine: 'ripgrep' | 'walk', capMatches?, capBytes?, filesSkipped? }
52
+ // `filesScanned` counts the files that produced at least one match — the
53
+ // "N files" the chat card prints next to "N matches". Both engines count it
54
+ // the same way. It is deliberately NOT "files opened": ripgrep emits its
55
+ // per-file `begin` record lazily, so the files it opened without matching are
56
+ // never mentioned in the NDJSON, and a number that meant "opened" would have
57
+ // been structurally unavailable on one backend and a guess on the other.
58
+ //
59
+ // src/tools/files.js re-exports DEFAULT_MAX_MATCHES / DEFAULT_MAX_BYTES as
60
+ // DEFAULT_SEARCH_MAX_MATCHES / DEFAULT_SEARCH_MAX_BYTES and reuses SKIP_DIRS
61
+ // and TEXT_EXTS, so the two modules cannot disagree about what a search sees.
62
+
63
+ const { spawn } = require('child_process');
64
+ const fs = require('fs');
65
+ const fsp = require('fs/promises');
66
+ const path = require('path');
67
+ const hideFileContent = require('../hideFileContent.js');
68
+ const { err } = require('../util.js');
69
+
70
+ // Caps applied when the caller did not override them in settings.
71
+ const DEFAULT_MAX_MATCHES = 200;
72
+ const DEFAULT_MAX_BYTES = 2 * 1024 * 1024;
73
+ // The walker still uses the old extension allowlist for files it cannot
74
+ // identify, but the list is now a fallback, not the filter: nameless files
75
+ // (no extension) are searched too, because that is where Dockerfile and
76
+ // friends live.
77
+ const TEXT_EXTS = new Set([
78
+ '.js', '.jsx', '.ts', '.tsx', '.mjs', '.cjs', '.json', '.md', '.mdx',
79
+ '.txt', '.py', '.rb', '.go', '.rs', '.java', '.kt', '.swift',
80
+ '.c', '.h', '.cpp', '.hpp', '.cc', '.cs', '.php',
81
+ '.css', '.scss', '.less', '.html', '.htm', '.xml', '.svg',
82
+ '.yml', '.yaml', '.toml', '.ini', '.sh', '.bash', '.zsh', '.fish',
83
+ '.lua', '.pl', '.r', '.dart', '.ex', '.exs', '.clj', '.scala',
84
+ '.sql', '.graphql', '.vue', '.svelte', '.astro'
85
+ ]);
86
+
87
+ // Directories neither engine descends into. ripgrep gets these as `-g`
88
+ // exclusions so it never walks them at all.
89
+ const SKIP_DIRS = ['node_modules', '.git', '.mouaif', 'dist', 'build', '.cache'];
90
+
91
+ // Segments the fallback walk drops from its candidate list. ripgrep excludes
92
+ // these because the project's own .gitignore lists them; the walk has no
93
+ // ignore-file engine, so it matches the generated names directly.
94
+ const GENERATED_DIRS = ['docs-dist', 'coverage', '.next', '.nuxt', '.svelte-kit', 'target', 'vendor', '__pycache__', '.venv', 'venv'];
95
+
96
+ // How many matching lines per file ripgrep may print before it stops. We
97
+ // apply the real (project-wide) cap ourselves while streaming, so the reported
98
+ // count matches the setting; `--max-count` is only a pipe guard.
99
+ //
100
+ // Deliberately above the default per-search cap of 200, so the default search
101
+ // can reach its own cap from a single file and still report `truncated`.
102
+ const RG_MAX_MATCHES = 2000;
103
+ // `--max-columns` bounds what ripgrep *prints* on a non-JSON run. It is not a
104
+ // ceiling on the `lines.text` in a `--json` record — that still carries the
105
+ // whole line, and `--max-columns-preview` is what lets a match past the limit
106
+ // be reported at all. The real bound on a pathological one-line bundle is the
107
+ // `--max-count` pipe guard plus RG_HARD_TIMEOUT_MS, not this number.
108
+ const RG_MAX_LINE_BYTES = 500;
109
+ const RG_KILL_GRACE_MS = 500;
110
+ const RG_HARD_TIMEOUT_MS = 60_000;
111
+
112
+ function capFor(settings) {
113
+ return {
114
+ matches: (settings && settings.fileSearchMaxMatches) || DEFAULT_MAX_MATCHES,
115
+ bytes: (settings && settings.fileSearchMaxBytes) || DEFAULT_MAX_BYTES
116
+ };
117
+ }
118
+
119
+ // ---- ripgrep discovery -------------------------------------------------
120
+
121
+ let rgCache;
122
+
123
+ // Find a ripgrep binary. Checked in order: an explicit override, then PATH,
124
+ // then the copies that ship inside the tools this app already integrates
125
+ // with (VS Code / Cursor ship one, the packaged codex CLI ships one). The
126
+ // answer is cached for the process — the binary does not move while the
127
+ // server runs.
128
+ function findRipgrepBinary() {
129
+ if (rgCache !== undefined) return rgCache;
130
+ rgCache = resolveRipgrepBinary();
131
+ return rgCache;
132
+ }
133
+
134
+ function resolveRipgrepBinary() {
135
+ // An explicit opt-out, for the two cases where the walker is the right
136
+ // answer: a machine whose bundled rg turns out to be incompatible, and the
137
+ // test that has to prove the fallback still passes the same assertions.
138
+ if (process.env.MOUAIF_RG_DISABLE) return null;
139
+ const explicit = process.env.MOUAIF_RG_PATH;
140
+ if (explicit && isExecutableFile(explicit)) return explicit;
141
+ const onPath = whichRg();
142
+ if (onPath) return onPath;
143
+ for (const candidate of bundledRgCandidates()) {
144
+ if (candidate && isExecutableFile(candidate)) return candidate;
145
+ }
146
+ return null;
147
+ }
148
+
149
+ function whichRg() {
150
+ const dirs = String(process.env.PATH || '').split(path.delimiter).filter(Boolean);
151
+ const names = process.platform === 'win32' ? ['rg.exe', 'rg'] : ['rg'];
152
+ for (const dir of dirs) {
153
+ for (const name of names) {
154
+ const candidate = path.join(dir, name);
155
+ if (isExecutableFile(candidate)) return candidate;
156
+ }
157
+ }
158
+ return null;
159
+ }
160
+
161
+ function bundledRgCandidates() {
162
+ const out = [];
163
+ const home = process.env.HOME || process.env.USERPROFILE || '';
164
+ if (process.env.VSCODE_SERVER_PATH) {
165
+ out.push(path.join(process.env.VSCODE_SERVER_PATH, 'node_modules', '@vscode', 'ripgrep', 'bin', 'rg'));
166
+ }
167
+ if (home) {
168
+ // VS Code server installs: ~/.vscode-server/bin/<commit>/node_modules/@vscode/ripgrep/bin/rg
169
+ for (const base of ['.vscode-server', '.cursor-server', '.vscode-remote']) {
170
+ const bin = path.join(home, base, 'bin');
171
+ for (const commit of safeReaddir(bin)) {
172
+ out.push(path.join(bin, commit, 'node_modules', '@vscode', 'ripgrep', 'bin', 'rg'));
173
+ }
174
+ }
175
+ // The packaged codex CLI keeps an rg next to its own scripts.
176
+ for (const base of [['.opencode', 'bin'], ['.cache', 'opencode', 'bin']]) {
177
+ out.push(path.join(home, ...base, 'rg'));
178
+ }
179
+ }
180
+ return out;
181
+ }
182
+
183
+ function safeReaddir(dir) {
184
+ try { return fs.readdirSync(dir); } catch { return []; }
185
+ }
186
+
187
+ function isExecutableFile(p) {
188
+ try {
189
+ const st = fs.statSync(p);
190
+ if (!st.isFile()) return false;
191
+ if (process.platform === 'win32') return true;
192
+ fs.accessSync(p, fs.constants.X_OK);
193
+ return true;
194
+ } catch { return false; }
195
+ }
196
+
197
+ // ---- regex handling ----------------------------------------------------
198
+
199
+ // ripgrep speaks Rust's regex dialect. One syntax that is valid in the
200
+ // JavaScript RegExp the tool schema documents is not valid there:
201
+ //
202
+ // (?i) inline case-insensitive — PCRE2-only on old ripgrep builds
203
+ //
204
+ // It is handled here: the flag is peeled off the front of the pattern and
205
+ // turned into a real CLI flag, so it works regardless of whether the installed
206
+ // ripgrep was built with PCRE2. Any other inline group (e.g. the valid Rust
207
+ // `(?P<name>...)`) is left untouched.
208
+ //
209
+ // `(?s)` — dot-matches-newline — is *detected* here only so it can be rejected
210
+ // (see `multiline` below); it is deliberately not translated into
211
+ // `--multiline`.
212
+ //
213
+ // Returns { pattern, ignoreCase, multiline }.
214
+ function normalizePattern(query) {
215
+ let pattern = String(query);
216
+ let ignoreCase = false;
217
+ let multiline = false;
218
+ // Peel leading inline flags in any order: (?i), (?s), or (?is).
219
+ for (;;) {
220
+ const m = /^\(\?([is]+)\)/.exec(pattern);
221
+ if (!m) break;
222
+ if (m[1].includes('i')) ignoreCase = true;
223
+ if (m[1].includes('s')) multiline = true;
224
+ pattern = pattern.slice(m[0].length);
225
+ }
226
+ // Only peel the JS-style flags when a real pattern is left, so a query
227
+ // that is *only* the flags falls through to a normal syntax error.
228
+ if (!pattern) return { pattern: String(query), ignoreCase: false, multiline: false };
229
+ return { pattern, ignoreCase, multiline };
230
+ }
231
+
232
+ // Reject a pattern neither engine can answer correctly, and report it as
233
+ // EBADINPUT with a message that names the construct — the schema documents
234
+ // JavaScript regular expressions, so a model that sends an unsupported one
235
+ // deserves to be told which, rather than getting a bare "regex parse error"
236
+ // or an empty result.
237
+ function assertPatternSupported(pattern, multiline) {
238
+ // `\1` .. `\9` are backreferences; `\0` and `\n` are ordinary escapes.
239
+ if (/(?<![\\[])\\[1-9]/.test(pattern)) {
240
+ throw err('EBADINPUT', 'backreferences (\\1) are not supported; rewrite the pattern without them');
241
+ }
242
+ if (/\(\?<?[=!]/.test(pattern)) {
243
+ throw err('EBADINPUT', 'lookahead / lookbehind are not supported; use a capture group or two searches instead');
244
+ }
245
+ // `(?s)` used to become `--multiline --multiline-dotall` on the ripgrep path
246
+ // and nothing at all on the walk path, which made one query mean two
247
+ // different things and — worse — let ripgrep return a multi-line match whose
248
+ // `line_number` is only the first line, so a hidden line in the middle of the
249
+ // match was printed in full. The redaction rules are per line, so a
250
+ // dot-matches-newline result is not something this engine can redact.
251
+ // Refusing it is also what makes the two backends agree.
252
+ if (multiline) {
253
+ throw err('EBADINPUT', '(?s) / dot-matches-newline is not supported: matches are reported and redacted per line; search for the two anchors separately');
254
+ }
255
+ }
256
+
257
+ // ---- search entry point ------------------------------------------------
258
+
259
+ // Run a search with the best engine available. Falls back to the JS walk if
260
+ // ripgrep is missing, fails to start, or exits with a usage error (an old
261
+ // build that does not understand a flag we pass).
262
+ async function runSearch(opts) {
263
+ const cap = capFor(opts.settings);
264
+ const query = (opts.args && typeof opts.args.query === 'string') ? opts.args.query : '';
265
+ if (!query) throw err('EBADINPUT', 'query is required');
266
+
267
+ const { pattern, ignoreCase, multiline } = normalizePattern(query);
268
+ assertPatternSupported(pattern, multiline);
269
+
270
+ const scope = resolveScope(opts.projectDir, opts.args && opts.args.path);
271
+ const include = sanitizeInclude(opts.args && opts.args.include);
272
+
273
+ const rg = findRipgrepBinary();
274
+ if (rg) {
275
+ try {
276
+ return await runRipgrep({ ...opts, query, pattern, ignoreCase, multiline, scope, include, cap, rg });
277
+ } catch (e) {
278
+ if (e && e.code === 'EBADINPUT') throw e;
279
+ // Any other failure (spawn error, unexpected exit code) is a reason to
280
+ // use the walker, not to fail the tool call.
281
+ }
282
+ }
283
+ return runWalk({ ...opts, query, pattern, ignoreCase, cap, scope, include });
284
+ }
285
+
286
+ // ---- path scope --------------------------------------------------------
287
+
288
+ // Sanitize the model-supplied `include` glob. The value reaches ripgrep as a
289
+ // `-g` argument, so a leading `!` (which would turn it into an exclusion) and
290
+ // a leading `/` (which anchors it somewhere unexpected) are stripped rather
291
+ // than honored. The rest is passed through: ripgrep's glob grammar is what
292
+ // the tool description promises.
293
+ function sanitizeInclude(raw) {
294
+ if (typeof raw !== 'string') return null;
295
+ let s = raw.trim();
296
+ if (!s) return null;
297
+ while (s.startsWith('!')) s = s.slice(1);
298
+ s = s.replace(/^\/+/, '');
299
+ return s.trim() || null;
300
+ }
301
+
302
+ // Does a project-relative path match the include glob? Used by the walk
303
+ // engine; ripgrep applies the same glob itself.
304
+ function includeMatches(glob, rel) {
305
+ if (!glob) return true;
306
+ const re = includeToRegExp(glob);
307
+ return re ? re.test(rel) : true;
308
+ }
309
+
310
+ const includeRegExpCache = new Map();
311
+
312
+ // Translate a ripgrep-style glob into a RegExp. The grammar supported here is
313
+ // deliberately the one the tool documents and the one a model actually sends:
314
+ //
315
+ // *.js a name pattern: matches at any depth
316
+ // src/**/*.test.js a path pattern: matches from the root
317
+ // *.{ts,tsx} brace alternation (nested braces are not supported)
318
+ // src/[ab].js character class, with `^`-negation and ranges
319
+ // ? exactly one character, never a `/`
320
+ //
321
+ // Anything else is escaped and matched literally. This must stay in step with
322
+ // what ripgrep does with the same `-g` argument: the fallback walk used to
323
+ // escape `{`, `}`, `[` and `]`, so a documented query like `*.{ts,tsx}`
324
+ // returned matches under ripgrep and nothing at all on a machine without it.
325
+ function includeToRegExp(glob) {
326
+ if (includeRegExpCache.has(glob)) return includeRegExpCache.get(glob);
327
+ const re = buildGlobRegExp(glob);
328
+ includeRegExpCache.set(glob, re);
329
+ return re;
330
+ }
331
+
332
+ function buildGlobRegExp(glob) {
333
+ // A bare `*.js` is a name pattern: it should match at any depth, which is
334
+ // what the ripgrep `-g` behavior gives and what a model means by it.
335
+ const prefix = glob.includes('/') ? '^' : '(?:^|/)';
336
+ let re = null;
337
+ try { re = new RegExp(prefix + expandBraces(glob).map(globToRegexSource).join('|') + '$'); }
338
+ catch { re = null; }
339
+ return re;
340
+ }
341
+
342
+ // Expand `{a,b}` alternation into one glob per alternative, so the alternation
343
+ // becomes a real alternation in the RegExp (the old code escaped the braces and
344
+ // matched them literally). Nested braces are left alone.
345
+ function expandBraces(glob) {
346
+ const m = /\{([^{}]*)\}/.exec(glob);
347
+ if (!m) return [glob];
348
+ const out = [];
349
+ for (const part of m[1].split(',')) {
350
+ out.push(...expandBraces(glob.slice(0, m.index) + part + glob.slice(m.index + m[0].length)));
351
+ }
352
+ return out;
353
+ }
354
+
355
+ function globToRegexSource(glob) {
356
+ let src = '';
357
+ for (let i = 0; i < glob.length; i++) {
358
+ const c = glob[i];
359
+ if (c === '*' && glob[i + 1] === '*') {
360
+ src += '.*';
361
+ i++;
362
+ if (glob[i + 1] === '/') i++;
363
+ } else if (c === '*') {
364
+ src += '[^/]*';
365
+ } else if (c === '?') {
366
+ src += '[^/]';
367
+ } else if (c === '[') {
368
+ // `globClassSource` returns the class source (brackets included) or null
369
+ // when it is unterminated; the loop then steps past the closing bracket.
370
+ const cls = globClassSource(glob, i);
371
+ if (cls) { src += cls; i += cls.length - 1; }
372
+ else src += '\\[';
373
+ } else if ('.+^$()|{}\\'.includes(c)) {
374
+ src += '\\' + c;
375
+ } else {
376
+ src += c;
377
+ }
378
+ }
379
+ return src;
380
+ }
381
+
382
+ // Translate one `[...]` class. Returns the source text (including brackets) or
383
+ // null when the class is unterminated, in which case the caller escapes the
384
+ // `[` and moves on. `!` is ripgrep's (and git's) negation, `^` is accepted too.
385
+ function globClassSource(glob, start) {
386
+ const end = glob.indexOf(']', start + 1);
387
+ if (end === -1) return null;
388
+ let body = glob.slice(start + 1, end);
389
+ const negated = body.startsWith('!') || body.startsWith('^');
390
+ if (negated) body = body.slice(1);
391
+ if (!body) return null;
392
+ let src = '';
393
+ for (let i = 0; i < body.length; i++) {
394
+ const c = body[i];
395
+ if (c === '\\' || c === ']') { src += '\\' + c; continue; }
396
+ // `-` is a range operator unless it is first or last.
397
+ if (c === '-' && (i === 0 || i === body.length - 1)) { src += '\\-'; continue; }
398
+ src += c;
399
+ }
400
+ return '[' + (negated ? '^' : '') + src + ']';
401
+ }
402
+
403
+ // Resolve the `path` argument to an absolute directory to search plus the
404
+ // relative prefix its results carry.
405
+ //
406
+ // Semantics, unchanged from the walker:
407
+ // - omitted / "." / "./" -> the whole project
408
+ // - an existing directory -> that directory ("src" or "src/")
409
+ // - an existing file -> just that file
410
+ // - a path that does not exist -> its longest existing ancestor, so a
411
+ // typo or a not-yet-created directory still searches something
412
+ // (`src/util` searches `src/`) instead of silently returning nothing.
413
+ function resolveScope(projectDir, rawPath) {
414
+ const root = fs.realpathSync(projectDir);
415
+ const cleaned = cleanRelPath(rawPath);
416
+ if (!cleaned) return { root, dir: root, relDir: '', relFile: null };
417
+
418
+ let abs;
419
+ try { abs = toAbsInside(root, cleaned); }
420
+ catch (e) {
421
+ if (e && e.code === 'EOUTSIDE_PROJECT') return { root, dir: root, relDir: '', relFile: null };
422
+ throw e;
423
+ }
424
+
425
+ let st = null;
426
+ try { st = fs.statSync(abs); } catch { st = null; }
427
+ if (st && st.isFile()) {
428
+ return { root, dir: path.dirname(abs), relDir: relRelOf(root, path.dirname(abs)), relFile: relRelOf(root, abs) };
429
+ }
430
+ if (st && st.isDirectory()) {
431
+ return { root, dir: abs, relDir: relRelOf(root, abs), relFile: null };
432
+ }
433
+
434
+ // Does not exist: climb to the longest existing ancestor directory.
435
+ let probe = abs;
436
+ for (;;) {
437
+ const parent = path.dirname(probe);
438
+ if (parent === probe) break;
439
+ probe = parent;
440
+ try {
441
+ if (fs.statSync(probe).isDirectory()) {
442
+ return { root, dir: probe, relDir: relRelOf(root, probe), relFile: null };
443
+ }
444
+ } catch { /* keep climbing */ }
445
+ }
446
+ return { root, dir: root, relDir: '', relFile: null };
447
+ }
448
+
449
+ function cleanRelPath(raw) {
450
+ if (typeof raw !== 'string' || !raw.trim()) return null;
451
+ let s = raw.trim().replace(/\\/g, '/');
452
+ while (s.startsWith('./')) s = s.slice(2);
453
+ s = s.replace(/\/+/g, '/');
454
+ if (s === '.' || s === '') return null;
455
+ return s.replace(/\/+$/, '');
456
+ }
457
+
458
+ function toRelPath(root, p) {
459
+ const abs = path.isAbsolute(p) ? path.resolve(p) : path.resolve(root, p);
460
+ const rel = path.relative(root, abs);
461
+ if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) {
462
+ throw err('EOUTSIDE_PROJECT', 'Path escapes the project root', { path: p });
463
+ }
464
+ return rel.split(path.sep).join('/');
465
+ }
466
+
467
+ function toAbsInside(root, rel) {
468
+ const abs = path.resolve(root, rel);
469
+ let real;
470
+ try { real = fs.realpathSync(abs); } catch { real = null; }
471
+ const check = real || abs;
472
+ const relOut = path.relative(root, check);
473
+ if (relOut && (relOut.startsWith('..') || path.isAbsolute(relOut))) {
474
+ throw err('EOUTSIDE_PROJECT', 'Path escapes the project root', { path: rel });
475
+ }
476
+ return check;
477
+ }
478
+
479
+ function relRelOf(root, abs) {
480
+ const rel = path.relative(root, abs);
481
+ return rel.split(path.sep).join('/');
482
+ }
483
+
484
+ // ---- ripgrep engine ----------------------------------------------------
485
+
486
+ function buildRgArgs({ scope, pattern, ignoreCase, include, cap }) {
487
+ const args = [
488
+ '--json',
489
+ '--no-config', // the project's .ripgreprc must not change results
490
+ // Read the project's own .gitignore / .ignore files even when the
491
+ // directory is not a git repository. Without this a plain folder of
492
+ // sources gets its generated trees searched.
493
+ '--no-require-git',
494
+ '--line-number',
495
+ '--with-filename',
496
+ '--hidden', // .github/ and friends are project source
497
+ '--no-ignore-global', // a user's global ignore file is not part of this project
498
+ '--max-count', String(Math.max(RG_MAX_MATCHES, cap.matches)),
499
+ '--max-columns', String(RG_MAX_LINE_BYTES),
500
+ '--max-columns-preview'
501
+ ];
502
+ if (ignoreCase) args.push('--ignore-case');
503
+ // No `--multiline`: `(?s)` is refused in assertPatternSupported, because a
504
+ // dot-matches-newline match spans lines that the per-line redaction rules
505
+ // cannot describe (and the walk backend has no `s` flag to match it with).
506
+ // Belt and braces next to --no-require-git: pass the root .gitignore
507
+ // explicitly. The path must be absolute because ripgrep resolves
508
+ // `--ignore-file` against its cwd (the scope directory), not the project
509
+ // root, and a project-scoped search runs from a subdirectory.
510
+ const rootIgnore = path.join(scope.root, '.gitignore');
511
+ if (fs.existsSync(rootIgnore)) {
512
+ args.push('--ignore-file', rootIgnore);
513
+ }
514
+ // The `include` filter from the tool call. Pushed BEFORE the skip-dir
515
+ // exclusions on purpose: ripgrep applies `-g` globs in order and a later one
516
+ // can re-include what an earlier one excluded, so `include` last would make
517
+ // `include: "**/node_modules/**"` match vendored files that this tool
518
+ // documents as never searched. The order below makes the skip dirs final.
519
+ if (include) {
520
+ args.push('-g', include);
521
+ }
522
+ // Generated / vendored trees are excluded before ripgrep walks them, and
523
+ // this list is the last word on what is searched.
524
+ for (const d of SKIP_DIRS) {
525
+ args.push('-g', '!**/' + d + '/**');
526
+ if (scope.relDir) args.push('-g', '!' + scope.relDir + '/' + d + '/**');
527
+ }
528
+ // A single-file scope restricts ripgrep to that file. The glob is
529
+ // project-relative and anchored, so a same-named file elsewhere in the
530
+ // tree cannot leak into the results.
531
+ if (scope.relFile) {
532
+ args.push('-g', '/' + escapeGlob(scope.relFile));
533
+ }
534
+ const target = scope.relFile
535
+ ? path.relative(scope.dir, path.join(scope.root, scope.relFile)) || '.'
536
+ : '.';
537
+ args.push('--', pattern, target);
538
+ return args;
539
+ }
540
+
541
+ function escapeGlob(s) {
542
+ return String(s).replace(/([*?\[\]{}()!+@\\])/g, '\\$1');
543
+ }
544
+
545
+ // Spawn ripgrep and turn its NDJSON stream into the shared result shape.
546
+ async function runRipgrep(ctx) {
547
+ const { scope, cap, rg } = ctx;
548
+ const args = buildRgArgs(ctx);
549
+ const matches = [];
550
+ // Files that produced at least one match. Populated from the match records
551
+ // rather than from `begin`/`end`, which ripgrep emits lazily.
552
+ const matched = new Set();
553
+ let truncated = false;
554
+ let stderr = '';
555
+ let exited = false;
556
+ let exitCode = null;
557
+ let exitSignal = null;
558
+
559
+ // Redaction rules are read once for the whole search. This is the fix for
560
+ // the per-line settings re-read that made the old engine slow.
561
+ const ruleIndex = hideFileContent.buildRuleIndex(ctx.projectDir);
562
+
563
+ const child = spawn(rg, args, { cwd: scope.dir, windowsHide: true });
564
+ child.stdout.setEncoding('utf8');
565
+ child.stderr.setEncoding('utf8');
566
+ child.stderr.on('data', (chunk) => { if (stderr.length < 4096) stderr += chunk; });
567
+ child.on('error', (e) => { stderr += String(e && e.message); });
568
+
569
+ const done = new Promise((resolve) => {
570
+ child.on('close', (code, signal) => { exited = true; exitCode = code; exitSignal = signal; resolve(); });
571
+ });
572
+
573
+ const killTimer = setTimeout(() => { try { child.kill('SIGKILL'); } catch { /* gone */ } }, RG_HARD_TIMEOUT_MS);
574
+ killTimer.unref && killTimer.unref();
575
+
576
+ let buffered = '';
577
+ try {
578
+ for await (const chunk of child.stdout) {
579
+ buffered += chunk;
580
+ let nl;
581
+ // Process whole lines only; a chunk boundary can split a JSON record.
582
+ while ((nl = buffered.indexOf('\n')) !== -1) {
583
+ const line = buffered.slice(0, nl);
584
+ buffered = buffered.slice(nl + 1);
585
+ if (!line) continue;
586
+ const stop = consumeRgLine(line, {
587
+ matches, cap, ruleIndex, scope,
588
+ // One match record per matching LINE, and `filesScanned` counts the
589
+ // distinct files among them, which is the number the card shows and
590
+ // the only file count ripgrep's NDJSON can support (see the header).
591
+ onMatch: (relPath) => { matched.add(relPath); }
592
+ });
593
+ if (stop) { truncated = true; break; }
594
+ }
595
+ if (truncated) break;
596
+ }
597
+ } catch { /* stream torn down by the kill timer */ }
598
+
599
+ if (truncated) {
600
+ // Stop the child now that the cap is reached; no need to keep streaming.
601
+ try { child.kill('SIGTERM'); } catch { /* gone */ }
602
+ }
603
+
604
+ // Wait for the process, but never hang the tool call on a wedged child.
605
+ await Promise.race([done, new Promise((r) => setTimeout(r, RG_KILL_GRACE_MS))]);
606
+ clearTimeout(killTimer);
607
+ if (!exited) { try { child.kill('SIGKILL'); } catch { /* gone */ } }
608
+
609
+ // A usage error means this build does not understand our arguments (very
610
+ // old ripgrep). Signal the caller to fall back to the walker.
611
+ if (!truncated && exitCode !== 0 && exitCode !== 1 && exited && !exitSignal) {
612
+ if (/unrecognized|invalid|unknown flag|error:/i.test(stderr)) {
613
+ const e = err('ERGFALLBACK', 'ripgrep rejected the arguments');
614
+ e.stderr = stderr;
615
+ throw e;
616
+ }
617
+ }
618
+ if (!exited && !truncated) {
619
+ const e = err('ERGFALLBACK', 'ripgrep did not exit in time');
620
+ e.stderr = stderr;
621
+ throw e;
622
+ }
623
+
624
+ const result = { query: ctx.query, matches, filesScanned: matched.size, truncated, engine: 'ripgrep' };
625
+ if (truncated) {
626
+ result.capMatches = cap.matches;
627
+ result.capBytes = cap.bytes;
628
+ }
629
+ return result;
630
+ }
631
+
632
+ // Fold one NDJSON record into the running result. Returns true when the
633
+ // caller should stop reading.
634
+ function consumeRgLine(line, ctx) {
635
+ let rec;
636
+ try { rec = JSON.parse(line); } catch { return false; }
637
+ if (!rec || typeof rec !== 'object') return false;
638
+
639
+ if (rec.type !== 'match') return false;
640
+
641
+ const data = rec.data || {};
642
+ const absPath = data.path && data.path.text;
643
+ if (!absPath) return false;
644
+ const relPath = toRel(ctx.scope, absPath);
645
+ const lineNumber = data.line_number;
646
+ const rawLine = (data.lines && data.lines.text) || '';
647
+ const text = trimLine(rawLine);
648
+ // A record that covers more than one line cannot be redacted: the redaction
649
+ // rules are (line, column) pairs, and `line_number` names only the first line
650
+ // of the match, so a hidden line in the middle would be printed verbatim.
651
+ // `assertPatternSupported` refuses `(?s)`, which is the only documented way
652
+ // to ask for this, so reaching here means an engine reported something
653
+ // unexpected — drop the row rather than risk leaking a hidden line.
654
+ if (text.includes('\n')) return false;
655
+ // One row per matching line, like every other search surface here and in
656
+ // the peer tools: ripgrep reports a submatch per occurrence, and a line
657
+ // with the pattern three times must not become three rows.
658
+ //
659
+ // Redaction is the reason the check stays per submatch: if *any* occurrence
660
+ // on the line falls on a hidden line or inside a hidden character span, the
661
+ // whole row is dropped. Printing the line because only one of its three
662
+ // occurrences was hidden would hand the model the redacted text.
663
+ const submatches = Array.isArray(data.submatches) ? data.submatches : [];
664
+ if (submatches.length) {
665
+ for (const sm of submatches) {
666
+ const cols = submatchColumns(rawLine, sm);
667
+ if (!cols) continue;
668
+ if (hideFileContent.matchIsHiddenIn(ctx.ruleIndex, relPath, lineNumber, cols.start, cols.end)) return false;
669
+ }
670
+ } else if (hideFileContent.matchIsHiddenIn(ctx.ruleIndex, relPath, lineNumber)) {
671
+ // No submatch detail available: treat the line as wholly suspect.
672
+ return false;
673
+ }
674
+
675
+ ctx.matches.push({ path: relPath, line: lineNumber, text: text.slice(0, 240) });
676
+ if (ctx.onMatch) ctx.onMatch(relPath);
677
+ return ctx.matches.length >= ctx.cap.matches;
678
+ }
679
+
680
+ // Convert a submatch's byte range in the record's line into 1-indexed
681
+ // inclusive character columns, which is what the redaction rules are stored
682
+ // in. Multi-byte characters are counted once, matching how the editor counts
683
+ // a selection.
684
+ function submatchColumns(rawLine, sm) {
685
+ const startByte = sm.start;
686
+ const endByte = sm.end;
687
+ if (!Number.isInteger(startByte) || !Number.isInteger(endByte)) return null;
688
+ const buf = Buffer.from(rawLine, 'utf8');
689
+ if (endByte > buf.length) return null;
690
+ const startChar = buf.subarray(0, startByte).toString('utf8').length;
691
+ const endChar = buf.subarray(0, endByte).toString('utf8').length;
692
+ if (endChar <= startChar) return null;
693
+ return { start: startChar + 1, end: endChar };
694
+ }
695
+
696
+ function trimLine(raw) {
697
+ return String(raw).replace(/\r?\n$/, '');
698
+ }
699
+
700
+ // ripgrep paths are relative to its cwd (the scope directory), except with
701
+ // an absolute or single-file target. Normalize to project-relative POSIX,
702
+ // and reject anything the scope did not authorize.
703
+ function toRel(scope, absPath) {
704
+ let abs = absPath;
705
+ if (!path.isAbsolute(abs)) abs = path.resolve(scope.dir, abs);
706
+ let real = abs;
707
+ try { real = fs.realpathSync(abs); } catch { /* keep the lexical path */ }
708
+ const rel = path.relative(scope.root, real);
709
+ if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) {
710
+ // Outside the project root: the walker never returns these, and neither
711
+ // should the engine. Returning a marker makes the leak visible instead
712
+ // of silently dropping or silently including it.
713
+ return path.relative(scope.root, abs).split(path.sep).join('/');
714
+ }
715
+ return rel.split(path.sep).join('/');
716
+ }
717
+
718
+ // ---- JS walk engine (fallback) -----------------------------------------
719
+
720
+ // Used when no ripgrep binary is available. Keeps the walker's behavior
721
+ // (skip dirs, text-extension allowlist, byte cap, same result shape) but
722
+ // fixes what made it slow and incomplete:
723
+ // - the redaction rules are read once per search instead of once per line
724
+ // - files are read with a bounded thread pool instead of one at a time
725
+ // - .gitignore is honored for the files it covers, so a git-ignored build
726
+ // tree is not searched
727
+ // - files with no extension are searched (Dockerfile, Makefile, ...)
728
+ async function runWalk(ctx) {
729
+ const { scope, cap } = ctx;
730
+ let re;
731
+ try { re = new RegExp(ctx.pattern, ctx.ignoreCase ? 'i' : ''); }
732
+ catch (e) { throw err('EBADINPUT', 'invalid regex: ' + e.message); }
733
+
734
+ const ruleIndex = hideFileContent.buildRuleIndex(ctx.projectDir);
735
+ const ignored = loadGitignore(scope.root);
736
+ const matches = [];
737
+ // One entry per file that produced at least one match — the same definition
738
+ // of `filesScanned` the ripgrep backend uses.
739
+ const matched = new Set();
740
+ let matchedChars = 0;
741
+ let bytesRead = 0;
742
+ let filesSkipped = 0;
743
+ let truncated = false;
744
+
745
+ const candidates = [];
746
+ await collectCandidates(scope, ignored, candidates);
747
+ // A single-file scope reads exactly that file, whatever the size.
748
+ const fileScoped = Boolean(scope.relFile);
749
+
750
+ const CONCURRENCY = 16;
751
+ let next = 0;
752
+ async function worker() {
753
+ for (;;) {
754
+ if (matches.length >= cap.matches || matchedChars >= cap.bytes) { truncated = true; return; }
755
+ const idx = next++;
756
+ if (idx >= candidates.length) return;
757
+ const rel = candidates[idx];
758
+ if (!includeMatches(ctx.include, rel)) continue;
759
+ let content;
760
+ try { content = await fsp.readFile(path.join(scope.root, rel), 'utf8'); }
761
+ catch { continue; }
762
+ // Re-check after the await: 16 workers run concurrently, so several can
763
+ // pass the cap check above before any of them appends a match. Without
764
+ // this the result overshoots the cap by up to one row per worker.
765
+ if (matches.length >= cap.matches || matchedChars >= cap.bytes) { truncated = true; return; }
766
+ // Read ceiling, separate from the reported cap and not tied to the
767
+ // directory order: the walk has to read a file to know whether it
768
+ // matches, so the tool-level byte budget cannot be allowed to decide
769
+ // *which* files get looked at — spending it alphabetically meant
770
+ // `docs/features/*.html` exhausted the budget before `src/` was ever
771
+ // reached, and a search for a function in `src/` came back empty. The
772
+ // budget below bounds the answer instead, and this ceiling only stops
773
+ // a pathological tree from being read forever.
774
+ if (!fileScoped && bytesRead > WALK_MAX_BYTES) { truncated = true; filesSkipped++; return; }
775
+ bytesRead += content.length;
776
+ const found = scanContent(content, rel, re, ruleIndex, matches, cap);
777
+ matchedChars += found.chars;
778
+ if (found.rows) matched.add(rel);
779
+ }
780
+ }
781
+ await Promise.all(Array.from({ length: CONCURRENCY }, worker));
782
+
783
+ const result = { query: ctx.query, matches, filesScanned: matched.size, truncated, engine: 'walk' };
784
+ if (filesSkipped) result.filesSkipped = filesSkipped;
785
+ if (truncated) { result.capMatches = cap.matches; result.capBytes = cap.bytes; }
786
+ return result;
787
+ }
788
+
789
+ // Ceiling on how much the fallback walk will read for one call. Deliberately
790
+ // far above the reported cap: it exists to stop a runaway tree, not to pick
791
+ // which files are searched.
792
+ const WALK_MAX_BYTES = 64 * 1024 * 1024;
793
+
794
+ // Scan one file body and append its non-redacted matching lines. Returns
795
+ // `{ chars, rows }`: the number of characters of matched line text (what the
796
+ // tool's byte budget bounds), and how many rows were appended (so the caller
797
+ // can tell whether this file matched at all).
798
+ function scanContent(content, rel, re, ruleIndex, matches, cap) {
799
+ const lines = content.split('\n');
800
+ // No rule for this file means nothing on it can be hidden, so both redaction
801
+ // passes below are skipped. That is the common case, and it is what keeps a
802
+ // project with no rules as fast as it was before redaction existed.
803
+ const hidden = hideFileContent.rulesForPathIn(ruleIndex, rel).length > 0;
804
+ // The stored pattern is deliberately NOT global (an unexpected `lastIndex`
805
+ // would make `exec` skip rows), so the per-occurrence scan below works on its
806
+ // own global clone.
807
+ const occRe = re.global ? re : new RegExp(re.source, re.flags + 'g');
808
+ let matched = 0;
809
+ let rows = 0;
810
+ for (let i = 0; i < lines.length; i++) {
811
+ const lineNumber = i + 1;
812
+ const line = lines[i];
813
+ occRe.lastIndex = 0;
814
+ if (!occRe.exec(line)) continue;
815
+ if (hidden) {
816
+ // A match on a hidden line is dropped, not redacted in place: the model
817
+ // must not be able to infer it. Testing the *line* (not just the match)
818
+ // drops a whole-window match on a hidden line, exactly as ripgrep does.
819
+ if (hideFileContent.lineIsHiddenIn(ruleIndex, rel, lineNumber)) continue;
820
+ // EVERY occurrence on the line is checked, not just the first: a row that
821
+ // printed its visible copy while the hidden copy sat next to it would hand
822
+ // the model the redacted text, which is the whole promise.
823
+ if (lineHasHiddenOccurrence(occRe, line, ruleIndex, rel, lineNumber)) continue;
824
+ }
825
+ // Hard stop: the cap is a promise to the caller, so it must not be exceeded
826
+ // even by one row.
827
+ if (matches.length >= cap.matches) return { chars: matched, rows };
828
+ matches.push({ path: rel, line: lineNumber, text: line.slice(0, 240) });
829
+ rows++;
830
+ matched += line.length;
831
+ }
832
+ return { chars: matched, rows };
833
+ }
834
+
835
+ // True when any occurrence of `re` on this line overlaps a hidden character
836
+ // span. The pattern may be zero-width (`^`, `x?`, `\b`), in which case the
837
+ // span is the single column the empty match sits at, which is the closest a
838
+ // column-based rule can be asked about.
839
+ function lineHasHiddenOccurrence(re, line, ruleIndex, rel, lineNumber) {
840
+ re.lastIndex = 0;
841
+ let m;
842
+ while ((m = re.exec(line)) !== null) {
843
+ const colStart = m.index + 1;
844
+ const colEnd = m.index + Math.max(m[0].length, 1);
845
+ if (hideFileContent.matchIsHiddenIn(ruleIndex, rel, lineNumber, colStart, colEnd)) return true;
846
+ if (m[0].length === 0) re.lastIndex++;
847
+ }
848
+ return false;
849
+ }
850
+
851
+ async function collectCandidates(scope, ignored, out) {
852
+ async function walk(dirAbs, dirRel) {
853
+ let entries;
854
+ try { entries = await fsp.readdir(dirAbs, { withFileTypes: true }); }
855
+ catch { return; }
856
+ for (const ent of entries) {
857
+ const childAbs = path.join(dirAbs, ent.name);
858
+ const childRel = dirRel ? dirRel + '/' + ent.name : ent.name;
859
+ if (ent.isDirectory()) {
860
+ if (SKIP_DIRS.includes(ent.name)) continue;
861
+ if (ignored && ignored.ignores(childRel)) continue;
862
+ await walk(childAbs, childRel);
863
+ continue;
864
+ }
865
+ if (!ent.isFile()) continue;
866
+ if (scope.relFile && childRel !== scope.relFile) continue;
867
+ if (ignored && ignored.ignores(childRel)) continue;
868
+ const ext = path.extname(ent.name).toLowerCase();
869
+ // A file with no extension has no way to be identified as text, so it
870
+ // is searched (that is where Dockerfile lives). An unrecognized
871
+ // extension with a dot is still skipped, which keeps a stray
872
+ // `dump.001` or a photo out of the walk.
873
+ if (ext && !TEXT_EXTS.has(ext)) continue;
874
+ // The walk has no ignore-file engine of its own, so the trees every
875
+ // project treats as output are excluded here by name. Without this a
876
+ // checked-out build output (`docs-dist/`) doubles the walk and can
877
+ // consume the read ceiling before `src/` is reached.
878
+ if (GENERATED_DIRS.some((d) => childRel === d || childRel.startsWith(d + '/') || childRel.includes('/' + d + '/'))) continue;
879
+ out.push(childRel);
880
+ }
881
+ }
882
+ await walk(scope.dir, scope.relDir);
883
+ }
884
+
885
+ // A deliberately small .gitignore reader: comments, blank lines, trailing
886
+ // slashes, leading slashes and `**` are honored; `!` re-includes. It exists
887
+ // so the walker's file set roughly matches ripgrep's on the machines that
888
+ // need the fallback, not to be a complete git implementation.
889
+ function loadGitignore(root) {
890
+ let text;
891
+ try { text = fs.readFileSync(path.join(root, '.gitignore'), 'utf8'); }
892
+ catch { return null; }
893
+ const rules = [];
894
+ for (const raw of text.split('\n')) {
895
+ const line = raw.trim();
896
+ if (!line || line.startsWith('#')) continue;
897
+ const negate = line.startsWith('!');
898
+ const body = (negate ? line.slice(1) : line).replace(/^\/+/, '').replace(/\/+$/, '');
899
+ if (!body) continue;
900
+ const anchored = /^\//.test(negate ? line.slice(1) : line) || body.includes('/');
901
+ const re = gitignoreToRegExp(body, anchored);
902
+ if (re) rules.push({ re, negate });
903
+ }
904
+ if (!rules.length) return null;
905
+ return {
906
+ ignores(rel) {
907
+ // Last matching rule wins, git-style: `docs-dist/` then `!docs-dist/keep`
908
+ // re-includes the exception.
909
+ let verdict = false;
910
+ for (const rule of rules) {
911
+ if (rule.re.test(rel)) verdict = !rule.negate;
912
+ }
913
+ return verdict;
914
+ }
915
+ };
916
+ }
917
+
918
+ function gitignoreToRegExp(body, anchored) {
919
+ let src = '';
920
+ for (let i = 0; i < body.length; i++) {
921
+ const c = body[i];
922
+ if (c === '*' && body[i + 1] === '*') {
923
+ src += '.*';
924
+ i++;
925
+ if (body[i + 1] === '/') i++;
926
+ } else if (c === '*') {
927
+ src += '[^/]*';
928
+ } else if (c === '?') {
929
+ src += '[^/]';
930
+ } else if ('.+^$()|{}[]\\'.includes(c)) {
931
+ src += '\\' + c;
932
+ } else {
933
+ src += c;
934
+ }
935
+ }
936
+ // An unanchored pattern matches at any depth (git semantics); an anchored
937
+ // one starts at the root.
938
+ const prefix = anchored ? '^' : '(?:^|/)';
939
+ try { return new RegExp(prefix + src + '(?:/.*)?$'); } catch { return null; }
940
+ }
941
+
942
+ module.exports = {
943
+ runSearch,
944
+ findRipgrepBinary,
945
+ normalizePattern,
946
+ resolveScope,
947
+ sanitizeInclude,
948
+ includeMatches,
949
+ escapeGlob,
950
+ collectCandidates,
951
+ loadGitignore,
952
+ _clearRipgrepCache: () => { rgCache = undefined; },
953
+ TEXT_EXTS,
954
+ SKIP_DIRS,
955
+ DEFAULT_MAX_MATCHES,
956
+ DEFAULT_MAX_BYTES
957
+ };