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.
- package/LICENSE +21 -0
- package/README.md +140 -0
- package/bin/mouaif.js +281 -0
- package/frontend/dist/assets/AgentFilePicker-CcKLJorU.js +1 -0
- package/frontend/dist/assets/CliModal-Hs5phmNZ.js +7 -0
- package/frontend/dist/assets/DictationPage-BI23lp42.js +2 -0
- package/frontend/dist/assets/FileEditor-DDl31c6d.js +2 -0
- package/frontend/dist/assets/GitModal-3EC_gpJ5.js +2 -0
- package/frontend/dist/assets/Inspector-Ba3R1w04.js +73 -0
- package/frontend/dist/assets/SettingsAbout-bvZGDEDw.js +1 -0
- package/frontend/dist/assets/SettingsActions-Dk6WX9jv.js +1 -0
- package/frontend/dist/assets/SettingsAgents-BNV0MgDB.js +1 -0
- package/frontend/dist/assets/SettingsDefaults-DbMmQbzc.js +1 -0
- package/frontend/dist/assets/SettingsHiddenContent-BZ2sloH1.js +1 -0
- package/frontend/dist/assets/SettingsMcp-DOrfbQd1.js +1 -0
- package/frontend/dist/assets/SettingsMcpEdit-BGMQ2CWC.js +3 -0
- package/frontend/dist/assets/SettingsMcpRegistry-BywXee_A.js +1 -0
- package/frontend/dist/assets/SettingsNotifications-B0LEs11a.js +1 -0
- package/frontend/dist/assets/SettingsPricing-BAg33iVF.js +1 -0
- package/frontend/dist/assets/SettingsProject-DNrKhCcZ.js +14 -0
- package/frontend/dist/assets/SettingsProjects-IqkBfDcm.js +1 -0
- package/frontend/dist/assets/SettingsPrompts-BgeiASuk.js +1 -0
- package/frontend/dist/assets/SettingsProviders-k0xJN0IK.js +1 -0
- package/frontend/dist/assets/SettingsTags-B5kjFdQi.js +1 -0
- package/frontend/dist/assets/agentNavigation-BiiCpFz5.js +1 -0
- package/frontend/dist/assets/codemirror-Bp6CUUFk.js +30 -0
- package/frontend/dist/assets/index-BGvI4n0T.js +61 -0
- package/frontend/dist/assets/index-Bgg1gnDf.css +1 -0
- package/frontend/dist/assets/index-C1sQFIC-.css +1 -0
- package/frontend/dist/assets/index-CANPYzQg.css +1 -0
- package/frontend/dist/assets/index-Crn1LdzK.css +1 -0
- package/frontend/dist/assets/index-FbCWDPiB.css +1 -0
- package/frontend/dist/assets/projectQS-D1cSZ7Gr.js +1 -0
- package/frontend/dist/assets/virtual-list-6H9b4K51.js +1 -0
- package/frontend/dist/icons/favicon-32.png +0 -0
- package/frontend/dist/icons/icon-180-apple.png +0 -0
- package/frontend/dist/icons/icon-192.png +0 -0
- package/frontend/dist/icons/icon-512.png +0 -0
- package/frontend/dist/icons/icon-maskable-512.png +0 -0
- package/frontend/dist/index.html +83 -0
- package/frontend/dist/manifest.webmanifest +33 -0
- package/frontend/dist/sw.js +482 -0
- package/package.json +98 -0
- package/scripts/patch-zimmerframe.js +58 -0
- package/src/access-auth.js +515 -0
- package/src/agentFeatures.js +294 -0
- package/src/agentFiles.js +164 -0
- package/src/agentSkills.js +147 -0
- package/src/agents.js +230 -0
- package/src/ai-chat.js +21 -0
- package/src/ai-endpoints.js +1880 -0
- package/src/ai-stream.js +2048 -0
- package/src/ai.js +68 -0
- package/src/auth.js +391 -0
- package/src/chatdb.js +816 -0
- package/src/chats.js +275 -0
- package/src/custom-actions.js +65 -0
- package/src/files.js +431 -0
- package/src/hideFileContent.js +327 -0
- package/src/http-server.js +535 -0
- package/src/index.js +15 -0
- package/src/inspector.js +731 -0
- package/src/inspectorProfiles.js +503 -0
- package/src/live-chat.js +107 -0
- package/src/mcp.js +1517 -0
- package/src/messages.js +238 -0
- package/src/modelList.js +137 -0
- package/src/notifications.js +52 -0
- package/src/oauth-anthropic.js +280 -0
- package/src/oauth-github-copilot.js +417 -0
- package/src/oauth-mcp.js +216 -0
- package/src/oauth-openrouter.js +285 -0
- package/src/package-version.js +20 -0
- package/src/projects.js +285 -0
- package/src/promptProfiles.js +256 -0
- package/src/prompts.js +384 -0
- package/src/providerShapes.js +44 -0
- package/src/providers/base.js +41 -0
- package/src/providers/index.js +25 -0
- package/src/push.js +315 -0
- package/src/qr.js +192 -0
- package/src/restart.js +47 -0
- package/src/server-handlers-access.js +306 -0
- package/src/server-handlers-actions.js +100 -0
- package/src/server-handlers-ai.js +248 -0
- package/src/server-handlers-auth.js +273 -0
- package/src/server-handlers-chats.js +1436 -0
- package/src/server-handlers-git.js +467 -0
- package/src/server-handlers-mcp-oauth.js +56 -0
- package/src/server-handlers-misc.js +783 -0
- package/src/server-handlers-projects.js +289 -0
- package/src/server-handlers-prompts.js +259 -0
- package/src/server-handlers-push.js +102 -0
- package/src/server-handlers-settings.js +406 -0
- package/src/server-handlers-tools.js +654 -0
- package/src/server-handlers-transcribe.js +399 -0
- package/src/server-shared.js +780 -0
- package/src/server-web-static.js +191 -0
- package/src/settings.js +898 -0
- package/src/statusBar.js +541 -0
- package/src/tags.js +414 -0
- package/src/toolFeedback.js +225 -0
- package/src/tools/ask.js +154 -0
- package/src/tools/authorization.js +932 -0
- package/src/tools/files.js +1150 -0
- package/src/tools/progress.js +71 -0
- package/src/tools/restart.js +32 -0
- package/src/tools/searchEngine.js +957 -0
- package/src/tools/shell.js +341 -0
- package/src/tools/subagent.js +47 -0
- package/src/tools/task.js +234 -0
- package/src/tools/webpreview.js +448 -0
- package/src/trace.js +103 -0
- package/src/transcribe.js +683 -0
- package/src/usage.js +389 -0
- 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
|
+
};
|