cc-viewer 1.6.339 → 1.6.341

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.
@@ -0,0 +1,208 @@
1
+ // Codebase replace-in-files — the write half of the Search view.
2
+ //
3
+ // Safety model (see plan review): isReadAllowed() is NOT a sufficient WRITE gate — its
4
+ // allowlist spans ~/.claude, workspaces and tmp dirs. Every target additionally must pass an
5
+ // in-project realpath containment check (like deleteFile/moveFile), protected-dir refusal, and
6
+ // the search's own hidden/ignored filters, so a client-named scope:'file'/'match' path cannot
7
+ // escape the project or hit .git/node_modules/.env. Writes are atomic (tmp+rename), preserve
8
+ // file mode, verify UTF-8 round-trip (never corrupt Latin-1), and never 500 the whole batch.
9
+ import {
10
+ readFileSync, writeFileSync, statSync, lstatSync, realpathSync, chmodSync, unlinkSync,
11
+ } from 'node:fs';
12
+ import { join, dirname, basename, sep, isAbsolute } from 'node:path';
13
+ import { isReadAllowed } from './file-access-policy.js';
14
+ import { renameSyncWithRetry } from './file-api.js';
15
+ import {
16
+ buildQueryRegExp, looksCatastrophic, isHidden, hasIgnoredSegment, normRel, DEFAULTS, searchCode, isBinary,
17
+ } from './code-search.js';
18
+
19
+ const REPLACE_TIME_BUDGET_MS = 15000;
20
+ // Match COUNT cap for enumerating scope:'all' candidate FILES. Set high (not the search default of
21
+ // 2000) so replace-all touches every matching file in normal repos; `truncated` is surfaced only
22
+ // on pathological scale so the UI can warn the user matches remain.
23
+ const REPLACE_CANDIDATE_CAP = 1000000;
24
+
25
+ // ─── Pure replacement primitive (mirrored verbatim on the client) ───
26
+
27
+ /**
28
+ * Compute the replacement text for a single matched substring.
29
+ * regex mode → native JS $-substitution ($1/$&/$$…); literal mode → the string verbatim.
30
+ * Using native String.replace on BOTH client and server guarantees preview === what's written.
31
+ */
32
+ export function applyMatch(matchedText, reNoG, regex, replacement) {
33
+ if (!regex) return replacement;
34
+ return matchedText.replace(reNoG, replacement);
35
+ }
36
+
37
+ /**
38
+ * Apply the replacement to file content.
39
+ * @param {RegExp} reG global query regex (from buildQueryRegExp)
40
+ * @param {RegExp} reNoG same regex without the 'g' flag (for native $-expansion of one match)
41
+ * @param {{regex:boolean, target:'all-in-file'|{line:number,col:number}, expectText?:string}} o
42
+ * @returns {{newContent:string, count:number}}
43
+ */
44
+ export function replaceInContent(content, reG, reNoG, replacement, { regex, target, expectText }) {
45
+ // Split preserving exact line terminators (even = line text, odd = \r\n|\n) so CRLF/LF are
46
+ // kept and ^/$ stay line-scoped, matching the search's line-by-line semantics.
47
+ const parts = content.split(/(\r?\n)/);
48
+ let count = 0;
49
+
50
+ const replaceWholeLine = (line) => {
51
+ if (line.length > DEFAULTS.maxLineLength) return line; // parity with search (skips long lines)
52
+ reG.lastIndex = 0;
53
+ return line.replace(reG, (m0) => {
54
+ if (m0 === '') return m0; // skip zero-width (consistent with search display)
55
+ const rep = applyMatch(m0, reNoG, regex, replacement);
56
+ // Count only real changes: a context-dependent pattern (lookahead/lookbehind, $`/$')
57
+ // re-run against the isolated match can no-op — don't report/write those.
58
+ if (rep !== m0) count++;
59
+ return rep;
60
+ });
61
+ };
62
+
63
+ if (target === 'all-in-file') {
64
+ for (let i = 0; i < parts.length; i += 2) parts[i] = replaceWholeLine(parts[i]);
65
+ } else {
66
+ const idx = (target.line - 1) * 2;
67
+ if (idx >= 0 && idx < parts.length) {
68
+ const line = parts[idx];
69
+ reG.lastIndex = 0;
70
+ let m;
71
+ while ((m = reG.exec(line)) !== null) {
72
+ if (m.index === target.col) {
73
+ // Re-verify: the match at this column must still be the exact text the user saw.
74
+ if (m[0] !== '' && (expectText == null || m[0] === expectText)) {
75
+ const rep = applyMatch(m[0], reNoG, regex, replacement);
76
+ if (rep !== m[0]) {
77
+ parts[idx] = line.slice(0, m.index) + rep + line.slice(m.index + m[0].length);
78
+ count = 1;
79
+ }
80
+ }
81
+ break;
82
+ }
83
+ if (m.index === reG.lastIndex) reG.lastIndex++; // zero-width guard
84
+ }
85
+ reG.lastIndex = 0;
86
+ }
87
+ }
88
+ return { newContent: parts.join(''), count };
89
+ }
90
+
91
+ // ─── Orchestrator ───────────────────────────────────────────────────
92
+
93
+ const PROTECTED = new Set(['node_modules', '.git', '.svn', '.hg']);
94
+ function isProtected(rel) {
95
+ return rel.split('/').some((seg) => PROTECTED.has(seg));
96
+ }
97
+
98
+ function makeTmpPath(real) {
99
+ const rand = Math.random().toString(36).slice(2, 10);
100
+ // leading dot → isHidden() skips any crash-orphaned tmp; same dir → same-fs atomic rename.
101
+ return join(dirname(real), `.${basename(real)}.ccv-tmp-${process.pid}-${rand}`);
102
+ }
103
+
104
+ /**
105
+ * Replace matches of `query` with `replacement` across the project.
106
+ * @returns {Promise<{changed:{file,replacements}[], skipped:{file,reason}[], total:number, error?:string}>}
107
+ * reasons: dirty | forbidden | symlink | binary | too_large | encoding | changed | write_failed
108
+ */
109
+ export async function searchReplace(opts) {
110
+ const empty = (extra) => ({ changed: [], skipped: [], total: 0, ...extra });
111
+ if (!opts.query || !opts.root || typeof opts.replacement !== 'string') return empty();
112
+ if (opts.regex && looksCatastrophic(opts.query)) return empty({ error: 'invalid_regex' });
113
+
114
+ let reG;
115
+ try { reG = buildQueryRegExp(opts); } catch { return empty({ error: 'invalid_regex' }); }
116
+ const reNoG = new RegExp(reG.source, reG.flags.replace('g', ''));
117
+ const regex = !!opts.regex;
118
+ const maxFileSize = opts.maxFileSize ?? DEFAULTS.maxFileSize;
119
+ const dryRun = !!opts.dryRun;
120
+
121
+ const root = opts.root;
122
+ let realRoot;
123
+ try { realRoot = realpathSync(root); } catch { realRoot = root; }
124
+ const rootPrefix = realRoot.endsWith(sep) ? realRoot : realRoot + sep;
125
+
126
+ // Candidate files.
127
+ let files;
128
+ let truncated = false;
129
+ if (opts.scope === 'all') {
130
+ const s = await searchCode({
131
+ query: opts.query, root, caseSensitive: opts.caseSensitive, wholeWord: opts.wholeWord,
132
+ regex: opts.regex, includeGlobs: opts.includeGlobs, excludeGlobs: opts.excludeGlobs,
133
+ engine: 'node', maxResults: REPLACE_CANDIDATE_CAP, signal: opts.signal,
134
+ });
135
+ if (s.error) return empty({ error: s.error });
136
+ files = s.results.map((r) => r.file);
137
+ truncated = !!s.truncated; // matches remain beyond the cap → the UI warns "run again"
138
+ } else {
139
+ if (!opts.file) return empty();
140
+ files = [normRel(opts.file)];
141
+ }
142
+
143
+ const skipSet = new Set((opts.skipPaths || []).map(normRel));
144
+ const changed = [];
145
+ const skipped = [];
146
+ let total = 0;
147
+ let processed = 0;
148
+ const started = Date.now();
149
+ const skip = (file, reason) => skipped.push({ file, reason });
150
+
151
+ for (const rel of files) {
152
+ if (opts.signal?.aborted) break;
153
+ if (Date.now() - started > REPLACE_TIME_BUDGET_MS) break;
154
+ // Yield to the event loop periodically so a large batch doesn't starve other HTTP/WS requests
155
+ // (the loop is otherwise fully synchronous readFileSync/replace/writeFileSync per file).
156
+ if ((processed++ & 63) === 0) await new Promise((r) => setImmediate(r));
157
+ if (skipSet.has(rel)) { skip(rel, 'dirty'); continue; }
158
+ if (isAbsolute(rel) || rel.split('/').includes('..') || isProtected(rel) || isHidden(rel) || hasIgnoredSegment(rel)) {
159
+ skip(rel, 'forbidden'); continue;
160
+ }
161
+
162
+ const full = join(root, rel);
163
+ let lst;
164
+ try { lst = lstatSync(full); } catch { skip(rel, 'changed'); continue; }
165
+ if (lst.isSymbolicLink()) { skip(rel, 'symlink'); continue; }
166
+
167
+ let real;
168
+ try { real = realpathSync(full); } catch { skip(rel, 'changed'); continue; }
169
+ if (real !== realRoot && !real.startsWith(rootPrefix)) { skip(rel, 'forbidden'); continue; }
170
+ if (!isReadAllowed(real).ok) { skip(rel, 'forbidden'); continue; }
171
+
172
+ let st;
173
+ try { st = statSync(real); } catch { skip(rel, 'changed'); continue; }
174
+ if (!st.isFile()) { skip(rel, 'forbidden'); continue; }
175
+ if (st.size > maxFileSize) { skip(rel, 'too_large'); continue; }
176
+
177
+ let raw;
178
+ try { raw = readFileSync(real); } catch { skip(rel, 'changed'); continue; }
179
+ if (isBinary(raw)) { skip(rel, 'binary'); continue; }
180
+ const text = raw.toString('utf8');
181
+ if (!Buffer.from(text, 'utf8').equals(raw)) { skip(rel, 'encoding'); continue; } // don't corrupt non-UTF-8
182
+
183
+ const target = opts.scope === 'match'
184
+ ? { line: opts.line, col: opts.col }
185
+ : 'all-in-file';
186
+ const expectText = opts.scope === 'match' ? opts.expectText : undefined;
187
+ const { newContent, count } = replaceInContent(text, reG, reNoG, opts.replacement, { regex, target, expectText });
188
+ if (count === 0) { skip(rel, 'changed'); continue; }
189
+ total += count;
190
+
191
+ if (dryRun) { changed.push({ file: rel, replacements: count }); continue; }
192
+
193
+ const tmp = makeTmpPath(real);
194
+ try {
195
+ writeFileSync(tmp, newContent, 'utf8');
196
+ chmodSync(tmp, st.mode & 0o777); // preserve exec bit / restrictive perms (tmp is created 0644)
197
+ renameSyncWithRetry(tmp, real);
198
+ } catch {
199
+ try { unlinkSync(tmp); } catch { /* nothing to clean */ }
200
+ total -= count;
201
+ skip(rel, 'write_failed');
202
+ continue;
203
+ }
204
+ changed.push({ file: rel, replacements: count });
205
+ }
206
+
207
+ return { changed, skipped, total, truncated };
208
+ }
@@ -0,0 +1,480 @@
1
+ // Codebase text search — VS Code-style "search across files".
2
+ //
3
+ // Two interchangeable engines behind one `searchCode()` shape:
4
+ // - ripgrep (`rg --json`): fast, respects .gitignore, skips hidden + binary natively.
5
+ // - node (pure walker): fallback when rg is absent; deliberately mirrors rg's file
6
+ // selection (gitignore-aware via `git ls-files`, hidden skipped) so both
7
+ // engines return the SAME results.
8
+ //
9
+ // Security: the node engine reads arbitrary project files, so every candidate is gated
10
+ // through isReadAllowed(realpath) and symlinks are skipped — a symlinked file must not be
11
+ // able to exfiltrate secrets (e.g. notes.txt -> ~/.ssh/id_rsa). rg does not follow symlinks.
12
+ import { spawn } from 'node:child_process';
13
+ import { readFileSync, readdirSync, lstatSync, statSync, realpathSync } from 'node:fs';
14
+ import { join } from 'node:path';
15
+ import { StringDecoder } from 'node:string_decoder';
16
+ import { isReadAllowed } from './file-access-policy.js';
17
+
18
+ // Directory/entry names never searched (superset of server IGNORED_PATTERNS + node_modules).
19
+ export const IGNORED_NAMES = new Set(['.git', '.svn', '.hg', '.DS_Store', '.idea', '.vscode', 'node_modules']);
20
+
21
+ export const DEFAULTS = {
22
+ maxResults: 2000,
23
+ maxMatchesPerFile: 200,
24
+ maxFileSize: 1024 * 1024, // 1 MB
25
+ nodeTimeBudgetMs: 8000,
26
+ maxLineLength: 5000,
27
+ binarySniffBytes: 8192,
28
+ };
29
+
30
+ // Kill a hung `git ls-files` (index.lock contention, slow/NFS mount). Unrelated to nodeTimeBudgetMs.
31
+ const GIT_LS_FILES_TIMEOUT_MS = 8000;
32
+
33
+ // ─── Pure helpers (exported for tests) ──────────────────────────────
34
+
35
+ export function escapeRegExp(s) {
36
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
37
+ }
38
+
39
+ /**
40
+ * Build the match RegExp honoring the toggles. Throws on invalid regex (regex mode).
41
+ * whole-word wraps the pattern in \b…\b (approximates rg -w).
42
+ */
43
+ export function buildQueryRegExp({ query, regex, wholeWord, caseSensitive }) {
44
+ let pattern = regex ? query : escapeRegExp(query);
45
+ if (wholeWord) pattern = `\\b(?:${pattern})\\b`;
46
+ const flags = 'g' + (caseSensitive ? '' : 'i');
47
+ return new RegExp(pattern, flags);
48
+ }
49
+
50
+ /**
51
+ * Conservative guard against classic exponential-backtracking patterns — a quantified group
52
+ * whose body already contains a quantifier, e.g. (a+)+, (.*)*, (a+){2,}. The node engine scans
53
+ * with JS RegExp (backtracking) and runs in the single server process, so one such pattern on a
54
+ * modest line can pin the event loop. ripgrep uses a linear engine and needs no guard. Not
55
+ * exhaustive — defense-in-depth for the fallback path; matched patterns are rejected as invalid.
56
+ */
57
+ export function looksCatastrophic(pattern) {
58
+ return /\([^()]*[+*}][^()]*\)\s*[+*]/.test(pattern)
59
+ || /\([^()]*[+*][^()]*\)\{\d/.test(pattern);
60
+ }
61
+
62
+ /**
63
+ * Translate a gitignore-style glob into a RegExp matched against a forward-slash,
64
+ * project-relative path. A glob with no '/' matches at any depth (rg `-g` semantics),
65
+ * so it is prefixed with `**​/`. Supports *, **, ?, {a,b}, [..], and literal escaping.
66
+ * Leading '!' must be stripped by the caller (it denotes negation).
67
+ */
68
+ export function globToRegExp(glob) {
69
+ let g = glob.trim();
70
+ if (g.startsWith('!')) g = g.slice(1);
71
+ // dir-only trailing slash → match everything beneath it
72
+ if (g.endsWith('/')) g += '**';
73
+ // no slash → match at any depth
74
+ if (!g.includes('/')) g = '**/' + g;
75
+
76
+ let re = '';
77
+ let inClass = false;
78
+ for (let i = 0; i < g.length; i++) {
79
+ const c = g[i];
80
+ if (inClass) {
81
+ if (c === ']') inClass = false;
82
+ re += c;
83
+ continue;
84
+ }
85
+ if (c === '[') { inClass = true; re += c; continue; }
86
+ if (c === '*') {
87
+ if (g[i + 1] === '*') {
88
+ // ** — match across path separators
89
+ i++;
90
+ if (g[i + 1] === '/') { i++; re += '(?:.*/)?'; } // **/ → zero or more segments
91
+ else re += '.*';
92
+ } else {
93
+ re += '[^/]*'; // * → within a segment
94
+ }
95
+ continue;
96
+ }
97
+ if (c === '?') { re += '[^/]'; continue; }
98
+ if (c === '{') { re += '(?:'; continue; }
99
+ if (c === '}') { re += ')'; continue; }
100
+ if (c === ',') { re += '|'; continue; }
101
+ if ('.+^$()|\\'.includes(c)) { re += '\\' + c; continue; }
102
+ re += c;
103
+ }
104
+ return new RegExp('^' + re + '$');
105
+ }
106
+
107
+ function makeGlobFilter(includeGlobs, excludeGlobs) {
108
+ // Parity with rg: a '!'-prefixed entry in the include field is a negation (exclude),
109
+ // mirroring rg's `-g !glob`. globToRegExp strips the leading '!'.
110
+ const inc = [];
111
+ const exc = [];
112
+ for (const g of includeGlobs || []) { if (!g) continue; (g.startsWith('!') ? exc : inc).push(globToRegExp(g)); }
113
+ for (const g of excludeGlobs || []) { if (!g) continue; exc.push(globToRegExp(g)); }
114
+ return (relPath) => {
115
+ if (inc.length && !inc.some((r) => r.test(relPath))) return false;
116
+ if (exc.some((r) => r.test(relPath))) return false;
117
+ return true;
118
+ };
119
+ }
120
+
121
+ export function normRel(p) {
122
+ let s = String(p).replace(/\\/g, '/');
123
+ if (s.startsWith('./')) s = s.slice(2);
124
+ return s;
125
+ }
126
+
127
+ function byteToChar(buf, byteOffset) {
128
+ const b = Math.max(0, Math.min(byteOffset, buf.length));
129
+ return buf.slice(0, b).toString('utf8').length;
130
+ }
131
+
132
+ function rgTextOf(obj) {
133
+ // rg emits {text} for valid UTF-8, {bytes: base64} for invalid UTF-8.
134
+ if (!obj) return null;
135
+ if (typeof obj.text === 'string') return obj.text;
136
+ if (typeof obj.bytes === 'string') return Buffer.from(obj.bytes, 'base64').toString('utf8');
137
+ return null;
138
+ }
139
+
140
+ /**
141
+ * Parse an array of rg `--json` stdout lines into flat match records.
142
+ * Handles byte→char submatch offsets, the {bytes} invalid-UTF-8 variant, CRLF trailing
143
+ * newlines, and zero-width / out-of-range submatches. Pure — unit-tested with canned lines.
144
+ */
145
+ export function parseRgJsonLines(lines) {
146
+ const out = [];
147
+ for (const raw of lines) {
148
+ if (!raw) continue;
149
+ let obj;
150
+ try { obj = JSON.parse(raw); } catch { continue; }
151
+ if (!obj || obj.type !== 'match' || !obj.data) continue;
152
+ const rec = rgMatchToRecord(obj.data);
153
+ if (rec) out.push(rec);
154
+ }
155
+ return out;
156
+ }
157
+
158
+ function rgMatchToRecord(data) {
159
+ const file = rgTextOf(data.path);
160
+ if (file == null) return null;
161
+ const rawText = rgTextOf(data.lines);
162
+ if (rawText == null) return null;
163
+ const byteBuf = Buffer.from(rawText, 'utf8');
164
+ const display = rawText.replace(/\r?\n$/, '');
165
+ const displayLen = display.length;
166
+ const submatches = [];
167
+ for (const sm of data.submatches || []) {
168
+ const start = Math.min(byteToChar(byteBuf, sm.start), displayLen);
169
+ const end = Math.min(byteToChar(byteBuf, sm.end), displayLen);
170
+ if (end > start) submatches.push({ start, end });
171
+ }
172
+ return { file: normRel(file), line: data.line_number, text: display.slice(0, DEFAULTS.maxLineLength), submatches };
173
+ }
174
+
175
+ // ─── Result grouping ────────────────────────────────────────────────
176
+
177
+ function createGrouper(maxResults, maxMatchesPerFile) {
178
+ const byFile = new Map();
179
+ let total = 0;
180
+ let capped = false; // a per-file or global cap dropped some matches
181
+ return {
182
+ /** @returns {boolean} true if still under the global cap, false once maxResults is hit */
183
+ add(file, match) {
184
+ if (total >= maxResults) { capped = true; return false; }
185
+ let entry = byFile.get(file);
186
+ if (!entry) { entry = []; byFile.set(file, entry); }
187
+ if (entry.length >= maxMatchesPerFile) { capped = true; return true; }
188
+ entry.push(match);
189
+ total++;
190
+ return total < maxResults;
191
+ },
192
+ get total() { return total; },
193
+ get capped() { return capped; },
194
+ results() {
195
+ return [...byFile.entries()].map(([file, matches]) => ({ file, matches }));
196
+ },
197
+ };
198
+ }
199
+
200
+ // ─── ripgrep engine ─────────────────────────────────────────────────
201
+
202
+ let _rgProbe = null;
203
+ export function hasRipgrep() {
204
+ if (_rgProbe) return _rgProbe;
205
+ _rgProbe = new Promise((resolve) => {
206
+ try {
207
+ const child = spawn('rg', ['--version'], { windowsHide: true });
208
+ child.on('error', () => resolve(false));
209
+ child.on('close', (code) => resolve(code === 0));
210
+ } catch { resolve(false); }
211
+ });
212
+ return _rgProbe;
213
+ }
214
+
215
+ function rgArgs(opts) {
216
+ const args = ['--json'];
217
+ if (!opts.regex) args.push('-F');
218
+ args.push(opts.caseSensitive ? '-s' : '-i');
219
+ if (opts.wholeWord) args.push('-w');
220
+ if (opts.maxFileSize) args.push('--max-filesize', String(opts.maxFileSize));
221
+ for (const g of opts.includeGlobs || []) if (g) args.push('-g', g);
222
+ for (const g of opts.excludeGlobs || []) if (g) args.push('-g', g.startsWith('!') ? g : `!${g}`);
223
+ // -e guards against a pattern starting with '-'; '.' scopes the search to cwd (root).
224
+ args.push('-e', opts.query, '.');
225
+ return args;
226
+ }
227
+
228
+ function rgSearch(opts) {
229
+ const maxResults = opts.maxResults ?? DEFAULTS.maxResults;
230
+ const maxPerFile = opts.maxMatchesPerFile ?? DEFAULTS.maxMatchesPerFile;
231
+ return new Promise((resolve, reject) => {
232
+ let child;
233
+ try {
234
+ child = spawn('rg', rgArgs(opts), { cwd: opts.root, signal: opts.signal, windowsHide: true });
235
+ } catch (err) { reject(err); return; }
236
+
237
+ const grouper = createGrouper(maxResults, maxPerFile);
238
+ const files = new Set();
239
+ let truncated = false;
240
+ let killed = false;
241
+ let stderr = '';
242
+ let buf = '';
243
+ const decoder = new StringDecoder('utf8'); // preserves multibyte runs across chunk boundaries
244
+
245
+ const consume = (line) => {
246
+ if (killed) return;
247
+ const recs = parseRgJsonLines([line]);
248
+ for (const rec of recs) {
249
+ files.add(rec.file);
250
+ const ok = grouper.add(rec.file, { line: rec.line, text: rec.text, submatches: rec.submatches });
251
+ if (!ok) {
252
+ truncated = true;
253
+ killed = true;
254
+ try { child.kill(); } catch { /* already gone */ }
255
+ return;
256
+ }
257
+ }
258
+ };
259
+
260
+ child.stdout.on('data', (d) => {
261
+ buf += decoder.write(d);
262
+ let nl;
263
+ while ((nl = buf.indexOf('\n')) !== -1) {
264
+ const line = buf.slice(0, nl);
265
+ buf = buf.slice(nl + 1);
266
+ consume(line);
267
+ }
268
+ });
269
+ child.stderr.on('data', (d) => { if (stderr.length < 4096) stderr += d.toString('utf8'); });
270
+ // Guard stream-level 'error' (e.g. EPIPE after child.kill on cap) so it can't become an
271
+ // unhandled 'error' event that crashes the process.
272
+ child.stdout.on('error', () => {});
273
+ child.stderr.on('error', () => {});
274
+ child.on('error', (err) => reject(err));
275
+ child.on('close', (code) => {
276
+ buf += decoder.end();
277
+ if (buf) consume(buf);
278
+ // 0 = matches, 1 = no matches (success), null = killed (cap/abort) → all fine.
279
+ if (code === 2 && !killed) {
280
+ if (/regex parse error|error parsing|unclosed|repetition/i.test(stderr)) {
281
+ const e = new Error('invalid regex'); e.code = 'INVALID_REGEX'; reject(e); return;
282
+ }
283
+ const e = new Error('ripgrep failed'); e.code = 'RG_ERROR'; reject(e); return;
284
+ }
285
+ resolve({ results: grouper.results(), truncated: truncated || grouper.capped, filesScanned: files.size });
286
+ });
287
+ });
288
+ }
289
+
290
+ // ─── node engine ────────────────────────────────────────────────────
291
+
292
+ function gitListFiles(root, signal) {
293
+ return new Promise((resolve) => {
294
+ let child;
295
+ try {
296
+ child = spawn('git', ['ls-files', '--cached', '--others', '--exclude-standard', '-z'],
297
+ { cwd: root, windowsHide: true, signal });
298
+ } catch { resolve(null); return; }
299
+ let out = '';
300
+ const decoder = new StringDecoder('utf8');
301
+ // Bound a hung git (index.lock contention, slow/NFS mount) so nodeSearch always resolves.
302
+ const timer = setTimeout(() => { try { child.kill(); } catch { /* gone */ } }, GIT_LS_FILES_TIMEOUT_MS);
303
+ child.stdout.on('data', (d) => { out += decoder.write(d); });
304
+ child.stdout.on('error', () => {});
305
+ child.on('error', () => { clearTimeout(timer); resolve(null); });
306
+ child.on('close', (code) => {
307
+ clearTimeout(timer);
308
+ out += decoder.end();
309
+ if (code !== 0) { resolve(null); return; }
310
+ resolve(out.split('\0').filter(Boolean));
311
+ });
312
+ });
313
+ }
314
+
315
+ function walkDir(root) {
316
+ const out = [];
317
+ const stack = [''];
318
+ while (stack.length) {
319
+ const rel = stack.pop();
320
+ let entries;
321
+ try { entries = readdirSync(rel ? join(root, rel) : root, { withFileTypes: true }); }
322
+ catch { continue; }
323
+ for (const e of entries) {
324
+ if (e.name.startsWith('.') || IGNORED_NAMES.has(e.name)) continue;
325
+ const childRel = rel ? `${rel}/${e.name}` : e.name;
326
+ if (e.isSymbolicLink()) continue; // don't follow symlinks (parity + safety)
327
+ if (e.isDirectory()) stack.push(childRel);
328
+ else if (e.isFile()) out.push(childRel);
329
+ }
330
+ }
331
+ return out;
332
+ }
333
+
334
+ export function isHidden(relPath) {
335
+ return relPath.split('/').some((seg) => seg.startsWith('.'));
336
+ }
337
+
338
+ export function hasIgnoredSegment(relPath) {
339
+ return relPath.split('/').some((seg) => IGNORED_NAMES.has(seg));
340
+ }
341
+
342
+ export function isBinary(buf, n = DEFAULTS.binarySniffBytes) {
343
+ const lim = Math.min(buf.length, n);
344
+ for (let i = 0; i < lim; i++) if (buf[i] === 0) return true;
345
+ return false;
346
+ }
347
+
348
+ async function nodeSearch(opts) {
349
+ // Catastrophic-backtracking guard applies ONLY to the JS-backtracking node engine (ripgrep's
350
+ // engine is linear and safe), so it lives here rather than gating the rg path too.
351
+ if (opts.regex && looksCatastrophic(opts.query)) {
352
+ return { results: [], truncated: false, filesScanned: 0, error: 'invalid_regex' };
353
+ }
354
+ const maxResults = opts.maxResults ?? DEFAULTS.maxResults;
355
+ const maxPerFile = opts.maxMatchesPerFile ?? DEFAULTS.maxMatchesPerFile;
356
+ const maxFileSize = opts.maxFileSize ?? DEFAULTS.maxFileSize;
357
+ const timeBudget = opts.nodeTimeBudgetMs ?? DEFAULTS.nodeTimeBudgetMs;
358
+ const started = Date.now();
359
+
360
+ let candidates = await gitListFiles(opts.root, opts.signal);
361
+ if (candidates == null) candidates = walkDir(opts.root);
362
+ candidates = candidates.map(normRel).filter((p) => !isHidden(p) && !hasIgnoredSegment(p));
363
+
364
+ const passesGlob = makeGlobFilter(opts.includeGlobs, opts.excludeGlobs);
365
+ const grouper = createGrouper(maxResults, maxPerFile);
366
+ const scanned = new Set();
367
+ let truncated = false;
368
+ let processed = 0;
369
+
370
+ for (const rel of candidates) {
371
+ if (opts.signal?.aborted) { truncated = true; break; }
372
+ if (Date.now() - started > timeBudget) { truncated = true; break; }
373
+ // Yield to the event loop periodically so a large scan doesn't starve other HTTP/WS
374
+ // requests and a client disconnect (signal) can be observed between files.
375
+ if ((processed++ & 63) === 0) await new Promise((r) => setImmediate(r));
376
+ if (!passesGlob(rel)) continue;
377
+
378
+ const full = join(opts.root, rel);
379
+ let lst;
380
+ try { lst = lstatSync(full); } catch { continue; }
381
+ if (lst.isSymbolicLink()) continue; // never follow symlinks
382
+
383
+ let real;
384
+ try { real = realpathSync(full); } catch { continue; }
385
+ const policy = isReadAllowed(real);
386
+ if (!policy.ok) continue;
387
+
388
+ let st;
389
+ try { st = statSync(real); } catch { continue; }
390
+ if (!st.isFile() || st.size > maxFileSize) continue;
391
+
392
+ let raw;
393
+ try { raw = readFileSync(real); } catch { continue; }
394
+ if (isBinary(raw)) continue;
395
+
396
+ scanned.add(rel);
397
+ const lines = raw.toString('utf8').split(/\r?\n/);
398
+ let stop = false;
399
+ for (let i = 0; i < lines.length && !stop; i++) {
400
+ const text = lines[i];
401
+ if (!text || text.length > DEFAULTS.maxLineLength) continue; // skip empty / pathological long lines
402
+ opts.queryRe.lastIndex = 0;
403
+ const submatches = [];
404
+ let m;
405
+ while ((m = opts.queryRe.exec(text)) !== null) {
406
+ const start = m.index;
407
+ const end = m.index + m[0].length;
408
+ if (end > start) submatches.push({ start, end });
409
+ if (m.index === opts.queryRe.lastIndex) opts.queryRe.lastIndex++; // zero-width guard
410
+ if (submatches.length >= 1000) break; // pathological single-line match count
411
+ }
412
+ if (submatches.length) {
413
+ const ok = grouper.add(rel, { line: i + 1, text: text.slice(0, DEFAULTS.maxLineLength), submatches });
414
+ if (!ok) { truncated = true; stop = true; }
415
+ }
416
+ }
417
+ if (stop) break;
418
+ }
419
+ return { results: grouper.results(), truncated: truncated || grouper.capped, filesScanned: scanned.size };
420
+ }
421
+
422
+ // ─── Public entry ───────────────────────────────────────────────────
423
+
424
+ /**
425
+ * Search the project codebase for `query`.
426
+ * @param {object} opts see fields below
427
+ * @param {string} opts.query the search term (or regex source when regex=true)
428
+ * @param {string} opts.root project root to scope the search
429
+ * @param {boolean} [opts.caseSensitive]
430
+ * @param {boolean} [opts.wholeWord]
431
+ * @param {boolean} [opts.regex]
432
+ * @param {string[]} [opts.includeGlobs]
433
+ * @param {string[]} [opts.excludeGlobs]
434
+ * @param {'auto'|'ripgrep'|'node'} [opts.engine='auto']
435
+ * @param {AbortSignal} [opts.signal]
436
+ * @returns {Promise<{results, truncated, engine, filesScanned, elapsedMs, error?}>}
437
+ */
438
+ export async function searchCode(opts) {
439
+ const engine = opts.engine || 'auto';
440
+ const started = Date.now();
441
+ const empty = (extra) => ({ results: [], truncated: false, filesScanned: 0, elapsedMs: Date.now() - started, ...extra });
442
+
443
+ if (!opts.query || !opts.root) return empty({ engine: 'none' });
444
+
445
+ // Validate the JS regex up front so both engines report invalid_regex consistently.
446
+ // (The catastrophic-backtracking guard is node-only and lives inside nodeSearch.)
447
+ let queryRe;
448
+ try {
449
+ queryRe = buildQueryRegExp(opts);
450
+ } catch {
451
+ return empty({ engine, error: 'invalid_regex' });
452
+ }
453
+ const runOpts = { ...opts, queryRe };
454
+
455
+ let useEngine = engine;
456
+ if (engine === 'auto') useEngine = (await hasRipgrep()) ? 'ripgrep' : 'node';
457
+
458
+ if (useEngine === 'ripgrep') {
459
+ try {
460
+ const r = await rgSearch(runOpts);
461
+ return { ...r, engine: 'ripgrep', elapsedMs: Date.now() - started };
462
+ } catch (err) {
463
+ if (err.code === 'INVALID_REGEX') return empty({ engine: 'ripgrep', error: 'invalid_regex' });
464
+ // Client disconnected / superseded — don't fall through to a fresh node scan.
465
+ if (err.name === 'AbortError') return empty({ engine: 'ripgrep', truncated: true });
466
+ // Any other rg failure (ENOENT, or an exit-2 whose stderr we couldn't classify — locale/
467
+ // version-dependent wording) → fall back to the node engine when auto selection was allowed,
468
+ // rather than 500-ing a query the fallback would have handled. Explicit engine:'ripgrep'
469
+ // surfaces the error.
470
+ if (engine === 'auto') {
471
+ const r = await nodeSearch(runOpts);
472
+ return { ...r, engine: 'node', elapsedMs: Date.now() - started };
473
+ }
474
+ throw err;
475
+ }
476
+ }
477
+
478
+ const r = await nodeSearch(runOpts);
479
+ return { ...r, engine: 'node', elapsedMs: Date.now() - started };
480
+ }