sweet-search 0.0.1 → 2.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 (161) hide show
  1. package/LICENSE +190 -0
  2. package/NOTICE +23 -0
  3. package/core/cli.js +51 -0
  4. package/core/config.js +27 -0
  5. package/core/embedding/embedding-cache.js +467 -0
  6. package/core/embedding/embedding-local-model.js +845 -0
  7. package/core/embedding/embedding-remote.js +492 -0
  8. package/core/embedding/embedding-service.js +712 -0
  9. package/core/embedding/embedding-telemetry.js +219 -0
  10. package/core/embedding/index.js +40 -0
  11. package/core/graph/community-detector.js +294 -0
  12. package/core/graph/graph-expansion.js +839 -0
  13. package/core/graph/graph-extractor.js +2304 -0
  14. package/core/graph/graph-search.js +2148 -0
  15. package/core/graph/hcgs-generator.js +666 -0
  16. package/core/graph/index.js +16 -0
  17. package/core/graph/leiden-algorithm.js +547 -0
  18. package/core/graph/relationship-resolver.js +366 -0
  19. package/core/graph/repo-map.js +408 -0
  20. package/core/graph/summary-manager.js +549 -0
  21. package/core/indexing/artifact-builder.js +1054 -0
  22. package/core/indexing/ast-chunker.js +709 -0
  23. package/core/indexing/chunking/chunk-builder.js +170 -0
  24. package/core/indexing/chunking/markdown-chunker.js +503 -0
  25. package/core/indexing/chunking/plaintext-chunker.js +104 -0
  26. package/core/indexing/dedup/dedup-phase.js +159 -0
  27. package/core/indexing/dedup/exemplar-selector.js +65 -0
  28. package/core/indexing/document-chunker.js +56 -0
  29. package/core/indexing/incremental-parser.js +390 -0
  30. package/core/indexing/incremental-tracker.js +761 -0
  31. package/core/indexing/index-codebase-v21.js +472 -0
  32. package/core/indexing/index-maintainer.mjs +1674 -0
  33. package/core/indexing/index.js +90 -0
  34. package/core/indexing/indexer-ann.js +1077 -0
  35. package/core/indexing/indexer-build.js +742 -0
  36. package/core/indexing/indexer-phases.js +800 -0
  37. package/core/indexing/indexer-pool.js +764 -0
  38. package/core/indexing/indexer-sparse-gram.js +98 -0
  39. package/core/indexing/indexer-utils.js +536 -0
  40. package/core/indexing/indexer-worker.js +148 -0
  41. package/core/indexing/li-skip-policy.js +225 -0
  42. package/core/indexing/merkle-tracker.js +244 -0
  43. package/core/indexing/model-pool.js +166 -0
  44. package/core/infrastructure/code-graph-repository.js +120 -0
  45. package/core/infrastructure/codebase-repository.js +131 -0
  46. package/core/infrastructure/config/dedup.js +54 -0
  47. package/core/infrastructure/config/embedding.js +298 -0
  48. package/core/infrastructure/config/graph.js +80 -0
  49. package/core/infrastructure/config/index.js +82 -0
  50. package/core/infrastructure/config/indexing.js +8 -0
  51. package/core/infrastructure/config/platform.js +254 -0
  52. package/core/infrastructure/config/ranking.js +221 -0
  53. package/core/infrastructure/config/search.js +396 -0
  54. package/core/infrastructure/config/translation.js +89 -0
  55. package/core/infrastructure/config/vector-store.js +114 -0
  56. package/core/infrastructure/constants.js +86 -0
  57. package/core/infrastructure/coreml-cascade.js +909 -0
  58. package/core/infrastructure/coreml-cascade.json +46 -0
  59. package/core/infrastructure/coreml-provider.js +81 -0
  60. package/core/infrastructure/db-utils.js +69 -0
  61. package/core/infrastructure/dedup-hashing.js +83 -0
  62. package/core/infrastructure/hardware-capability.js +332 -0
  63. package/core/infrastructure/index.js +104 -0
  64. package/core/infrastructure/language-patterns/maps.js +121 -0
  65. package/core/infrastructure/language-patterns/registry-core.js +323 -0
  66. package/core/infrastructure/language-patterns/registry-data-query.js +155 -0
  67. package/core/infrastructure/language-patterns/registry-object-oriented.js +285 -0
  68. package/core/infrastructure/language-patterns/registry-tooling.js +240 -0
  69. package/core/infrastructure/language-patterns/registry-web-style.js +143 -0
  70. package/core/infrastructure/language-patterns/registry.js +19 -0
  71. package/core/infrastructure/language-patterns.js +141 -0
  72. package/core/infrastructure/llm-provider.js +733 -0
  73. package/core/infrastructure/manifest.json +46 -0
  74. package/core/infrastructure/maxsim.wasm +0 -0
  75. package/core/infrastructure/model-fetcher.js +423 -0
  76. package/core/infrastructure/model-registry.js +214 -0
  77. package/core/infrastructure/native-inference.js +587 -0
  78. package/core/infrastructure/native-resolver.js +187 -0
  79. package/core/infrastructure/native-sparse-gram.js +257 -0
  80. package/core/infrastructure/native-tokenizer.js +160 -0
  81. package/core/infrastructure/onnx-mutex.js +45 -0
  82. package/core/infrastructure/onnx-session-utils.js +261 -0
  83. package/core/infrastructure/ort-pipeline.js +111 -0
  84. package/core/infrastructure/project-detector.js +102 -0
  85. package/core/infrastructure/quantization.js +410 -0
  86. package/core/infrastructure/simd-distance.js +502 -0
  87. package/core/infrastructure/simd-distance.wasm +0 -0
  88. package/core/infrastructure/tree-sitter-provider.js +665 -0
  89. package/core/infrastructure/webgpu-maxsim.js +222 -0
  90. package/core/query/index.js +35 -0
  91. package/core/query/intent-detector.js +201 -0
  92. package/core/query/intent-router.js +156 -0
  93. package/core/query/query-router-catboost.js +222 -0
  94. package/core/query/query-router-ml.js +266 -0
  95. package/core/query/query-router.js +213 -0
  96. package/core/ranking/cascaded-scorer.js +379 -0
  97. package/core/ranking/flashrank.js +810 -0
  98. package/core/ranking/index.js +49 -0
  99. package/core/ranking/late-interaction-index.js +2383 -0
  100. package/core/ranking/late-interaction-model.js +812 -0
  101. package/core/ranking/local-reranker.js +374 -0
  102. package/core/ranking/mmr.js +379 -0
  103. package/core/ranking/quality-scorer.js +363 -0
  104. package/core/search/context-expander.js +1167 -0
  105. package/core/search/dedup/sibling-expander.js +327 -0
  106. package/core/search/index.js +16 -0
  107. package/core/search/search-boost.js +259 -0
  108. package/core/search/search-cli.js +544 -0
  109. package/core/search/search-format.js +282 -0
  110. package/core/search/search-fusion.js +327 -0
  111. package/core/search/search-hybrid.js +204 -0
  112. package/core/search/search-pattern-chunks.js +337 -0
  113. package/core/search/search-pattern-planner.js +439 -0
  114. package/core/search/search-pattern-prefilter.js +412 -0
  115. package/core/search/search-pattern-ripgrep.js +663 -0
  116. package/core/search/search-pattern.js +463 -0
  117. package/core/search/search-postprocess.js +452 -0
  118. package/core/search/search-semantic.js +706 -0
  119. package/core/search/search-server.js +554 -0
  120. package/core/search/session-daemon-prewarm.mjs +164 -0
  121. package/core/search/session-warmup.js +595 -0
  122. package/core/search/sweet-search.js +632 -0
  123. package/core/search/warmup-metrics.js +532 -0
  124. package/core/start-server.js +6 -0
  125. package/core/training/query-router/features/extractor.js +762 -0
  126. package/core/training/query-router/features/multilingual-patterns.js +431 -0
  127. package/core/training/query-router/features/text-segmenter.js +303 -0
  128. package/core/training/query-router/features/unicode-utils.js +383 -0
  129. package/core/training/query-router/output/v45_router_d4.js +11521 -0
  130. package/core/training/query-router/output/v46_router_d4.js +11498 -0
  131. package/core/vector-store/binary-heap.js +227 -0
  132. package/core/vector-store/binary-hnsw-index.js +1004 -0
  133. package/core/vector-store/float-vector-store.js +234 -0
  134. package/core/vector-store/hnsw-index.js +580 -0
  135. package/core/vector-store/index.js +39 -0
  136. package/core/vector-store/seismic-index.js +498 -0
  137. package/core/vocabulary/index.js +84 -0
  138. package/core/vocabulary/vocab-constants.js +20 -0
  139. package/core/vocabulary/vocab-miner-extractors.js +375 -0
  140. package/core/vocabulary/vocab-miner-nl.js +404 -0
  141. package/core/vocabulary/vocab-miner-utils.js +146 -0
  142. package/core/vocabulary/vocab-miner.js +574 -0
  143. package/core/vocabulary/vocab-prewarm-cli.js +110 -0
  144. package/core/vocabulary/vocab-ranker.js +492 -0
  145. package/core/vocabulary/vocab-warmer.js +523 -0
  146. package/core/vocabulary/vocab-warmup-orchestrator.js +425 -0
  147. package/core/vocabulary/vocabulary-utils.js +704 -0
  148. package/crates/wasm-router/pkg/package.json +13 -0
  149. package/crates/wasm-router/pkg/query_router_wasm.d.ts +36 -0
  150. package/crates/wasm-router/pkg/query_router_wasm.js +271 -0
  151. package/crates/wasm-router/pkg/query_router_wasm_bg.wasm +0 -0
  152. package/crates/wasm-router/pkg/query_router_wasm_bg.wasm.d.ts +19 -0
  153. package/mcp/config-gen.js +121 -0
  154. package/mcp/server.js +335 -0
  155. package/mcp/tool-handlers.js +476 -0
  156. package/package.json +131 -9
  157. package/scripts/benchmark-harness.js +794 -0
  158. package/scripts/init.js +1058 -0
  159. package/scripts/smoke-test.js +435 -0
  160. package/scripts/uninstall.js +478 -0
  161. package/scripts/verify-runtime.js +176 -0
@@ -0,0 +1,663 @@
1
+ /**
2
+ * Ripgrep Integration Module — binary discovery, capability detection, and execution.
3
+ *
4
+ * Handles finding the ripgrep binary (including Claude Code's embedded multicall
5
+ * binary), detecting --and support, ARG_MAX-safe file batching, and the three
6
+ * core spawn wrappers: executeRipgrep (JSON), runRipgrepFilesWithMatches
7
+ * (files-with-matches), and runRipgrepJson (JSON output).
8
+ *
9
+ * Split from search-pattern.js for the 500-line-limit rule.
10
+ */
11
+
12
+ import { spawn, execFileSync } from 'child_process';
13
+ import { existsSync, readdirSync } from 'fs';
14
+ import { StringDecoder } from 'string_decoder';
15
+ import path from 'path';
16
+ import { RIPGREP_CODE_TYPE_GLOB } from '../infrastructure/constants.js';
17
+
18
+ // =============================================================================
19
+ // Module-level state — ripgrep binary cache and capabilities
20
+ // =============================================================================
21
+
22
+ let _rgCheckPromise = null;
23
+ let _rgBinary = null; // Resolved path to rg binary
24
+ let _rgCapabilities = null;
25
+
26
+ const RIPGREP_CODE_TYPE = RIPGREP_CODE_TYPE_GLOB;
27
+ const RIPGREP_MAX_BATCH_FILES = 500;
28
+ const RIPGREP_MAX_BATCH_ARG_BYTES = 96 * 1024;
29
+
30
+ // =============================================================================
31
+ // Ripgrep detection (race-safe: caches the promise, not just the result)
32
+ // =============================================================================
33
+
34
+ /**
35
+ * Find the ripgrep binary. Tries:
36
+ * 1. 'rg' on PATH (direct spawn)
37
+ * 2. Known multicall binary locations (Claude Code embeds rg)
38
+ * 3. Common install paths (/opt/homebrew/bin/rg, /usr/local/bin/rg)
39
+ *
40
+ * Caches the result. Returns the binary path or null.
41
+ */
42
+ function _findRg() {
43
+ if (_rgBinary !== null) return _rgBinary || null;
44
+
45
+ const candidates = [
46
+ 'rg', // Direct PATH
47
+ ];
48
+
49
+ // Claude Code multicall binary: ARGV0=rg makes it act as ripgrep
50
+ const claudeVersionsDir = path.join(
51
+ process.env.HOME || '', '.local', 'share', 'claude', 'versions'
52
+ );
53
+ // Enumerate available versions instead of hardcoding a specific version
54
+ if (process.env.CLAUDE_VERSION) {
55
+ const versionedBin = path.join(claudeVersionsDir, process.env.CLAUDE_VERSION);
56
+ if (existsSync(versionedBin)) candidates.push(versionedBin);
57
+ } else if (existsSync(claudeVersionsDir)) {
58
+ try {
59
+ const entries = readdirSync(claudeVersionsDir).sort().reverse();
60
+ if (entries.length > 0) {
61
+ candidates.push(path.join(claudeVersionsDir, entries[0]));
62
+ }
63
+ } catch { /* versions dir unreadable */ }
64
+ }
65
+
66
+ // Common homebrew / system paths
67
+ candidates.push('/opt/homebrew/bin/rg', '/usr/local/bin/rg', '/usr/bin/rg');
68
+
69
+ for (const bin of candidates) {
70
+ try {
71
+ execFileSync(bin, ['--version'], { stdio: 'pipe', timeout: 3000, env: { ...process.env, ARGV0: 'rg' } });
72
+ _rgBinary = bin;
73
+ return bin;
74
+ } catch {
75
+ // Try next
76
+ }
77
+ }
78
+
79
+ _rgBinary = '';
80
+ return null;
81
+ }
82
+
83
+ /**
84
+ * Check if ripgrep (rg) is installed and available.
85
+ * Concurrent callers share a single probe — no duplicate spawns.
86
+ */
87
+ export function isRipgrepAvailable() {
88
+ if (_rgCheckPromise) return _rgCheckPromise;
89
+ _rgCheckPromise = Promise.resolve(_findRg() !== null);
90
+ return _rgCheckPromise;
91
+ }
92
+
93
+ /** Reset cached availability (for testing). */
94
+ export function _resetRgCache() {
95
+ _rgCheckPromise = null;
96
+ _rgBinary = null;
97
+ _rgCapabilities = null;
98
+ }
99
+
100
+ // =============================================================================
101
+ // Ripgrep capability detection
102
+ // =============================================================================
103
+
104
+ export function _getRgCapabilities() {
105
+ const rgBin = _findRg();
106
+ if (!rgBin) {
107
+ throw new Error('ripgrep (rg) not found. Install: brew install ripgrep');
108
+ }
109
+
110
+ if (_rgCapabilities) return _rgCapabilities;
111
+
112
+ try {
113
+ const help = execFileSync(rgBin, ['--help'], {
114
+ stdio: 'pipe',
115
+ timeout: 3000,
116
+ env: { ...process.env, ARGV0: 'rg' },
117
+ }).toString('utf-8');
118
+ _rgCapabilities = {
119
+ supportsAnd: /\s--and\b/.test(help),
120
+ };
121
+ } catch {
122
+ _rgCapabilities = { supportsAnd: false };
123
+ }
124
+
125
+ return _rgCapabilities;
126
+ }
127
+
128
+ // =============================================================================
129
+ // Path normalization and file batching
130
+ // =============================================================================
131
+
132
+ export function normalizeSearchPath(searchDir, filePath) {
133
+ if (!filePath) return null;
134
+ const relative = path.isAbsolute(filePath)
135
+ ? path.relative(searchDir, filePath)
136
+ : filePath;
137
+ return relative.replace(/\\/g, '/').replace(/^\.\//, '');
138
+ }
139
+
140
+ export function chunkRipgrepFiles(files) {
141
+ const batches = [];
142
+ let current = [];
143
+ let currentBytes = 0;
144
+
145
+ for (const file of files) {
146
+ const fileBytes = Buffer.byteLength(file) + 1;
147
+ const wouldOverflow =
148
+ current.length >= RIPGREP_MAX_BATCH_FILES ||
149
+ currentBytes + fileBytes > RIPGREP_MAX_BATCH_ARG_BYTES;
150
+
151
+ if (wouldOverflow && current.length > 0) {
152
+ batches.push(current);
153
+ current = [];
154
+ currentBytes = 0;
155
+ }
156
+
157
+ current.push(file);
158
+ currentBytes += fileBytes;
159
+ }
160
+
161
+ if (current.length > 0) {
162
+ batches.push(current);
163
+ }
164
+
165
+ return batches;
166
+ }
167
+
168
+ // =============================================================================
169
+ // Ripgrep runner — core spawn wrappers
170
+ // =============================================================================
171
+
172
+ async function executeRipgrep({
173
+ patterns,
174
+ searchDir,
175
+ files = null,
176
+ fixedString = false,
177
+ caseInsensitive = false,
178
+ globs = [],
179
+ outputMode = 'json',
180
+ timeout = 10000,
181
+ useAnd = false,
182
+ maxCount = 0,
183
+ lightweightParse = false,
184
+ }) {
185
+ const rgBin = _findRg();
186
+ if (!rgBin) {
187
+ throw new Error('ripgrep (rg) not found. Install: brew install ripgrep');
188
+ }
189
+
190
+ const effectivePatterns = Array.isArray(patterns) ? patterns.filter(Boolean) : [patterns].filter(Boolean);
191
+ if (effectivePatterns.length === 0) {
192
+ return outputMode === 'json' ? [] : [];
193
+ }
194
+
195
+ return new Promise((resolve, reject) => {
196
+ const args = [
197
+ outputMode === 'json' ? '--json' : '--files-with-matches',
198
+ '--type-add', RIPGREP_CODE_TYPE,
199
+ '--type', 'code',
200
+ ];
201
+
202
+ if (maxCount > 0) args.push('--max-count', String(maxCount));
203
+ if (caseInsensitive) args.push('-i');
204
+ for (const glob of globs) {
205
+ if (glob) args.push('--glob', glob);
206
+ }
207
+
208
+ if (useAnd && effectivePatterns.length > 1) {
209
+ args.push(fixedString ? '-F' : effectivePatterns[0]);
210
+ if (fixedString) args.push(effectivePatterns[0]);
211
+ for (const pattern of effectivePatterns.slice(1)) {
212
+ args.push('--and');
213
+ if (fixedString) args.push('-F');
214
+ args.push(pattern);
215
+ }
216
+ } else {
217
+ if (fixedString) args.push('-F');
218
+ args.push(effectivePatterns[0]);
219
+ }
220
+
221
+ if (Array.isArray(files) && files.length > 0) {
222
+ args.push('--', ...files);
223
+ } else {
224
+ args.push('.');
225
+ }
226
+
227
+ const proc = spawn(rgBin, args, {
228
+ cwd: searchDir,
229
+ stdio: ['ignore', 'pipe', 'pipe'],
230
+ timeout,
231
+ env: { ...process.env, ARGV0: 'rg' },
232
+ });
233
+
234
+ const stdoutChunks = [];
235
+ let stderr = '';
236
+
237
+ proc.stdout.on('data', (chunk) => { stdoutChunks.push(chunk); });
238
+ proc.stderr.on('data', (chunk) => { stderr += chunk; });
239
+
240
+ proc.on('close', (code) => {
241
+ if (code !== 0 && code !== 1) {
242
+ reject(new Error(`ripgrep failed (code ${code}): ${stderr.slice(0, 200)}`));
243
+ return;
244
+ }
245
+
246
+ const stdout = Buffer.concat(stdoutChunks).toString('utf-8');
247
+
248
+ if (outputMode === 'files') {
249
+ const matches = stdout
250
+ .split('\n')
251
+ .map(line => normalizeSearchPath(searchDir, line.trim()))
252
+ .filter(Boolean);
253
+ resolve(matches);
254
+ return;
255
+ }
256
+
257
+ const matches = [];
258
+ if (lightweightParse) {
259
+ // Fast path: extract only file + line using indexOf (no JSON.parse).
260
+ // For patternSearch, mapMatchesToChunks only reads .file and .line —
261
+ // skipping JSON.parse saves ~22ms on 20K-match queries (30ms→8ms parse).
262
+ // rg --json key order is stable (serde derive): path.text then line_number.
263
+ const PATH_MARKER = '"path":{"text":"';
264
+ const PATH_MARKER_LEN = PATH_MARKER.length;
265
+ const LN_MARKER = '"line_number":';
266
+ const LN_MARKER_LEN = LN_MARKER.length;
267
+ const MATCH_PREFIX = '{"type":"match"';
268
+ const pathCache = new Map();
269
+
270
+ let pos = 0;
271
+ while (pos < stdout.length) {
272
+ const nl = stdout.indexOf('\n', pos);
273
+ const end = nl === -1 ? stdout.length : nl;
274
+
275
+ if (end - pos > 40 && stdout.startsWith(MATCH_PREFIX, pos)) {
276
+ const pathIdx = stdout.indexOf(PATH_MARKER, pos + 15);
277
+ if (pathIdx !== -1 && pathIdx < end) {
278
+ const pathStart = pathIdx + PATH_MARKER_LEN;
279
+ const pathEnd = stdout.indexOf('"', pathStart);
280
+ if (pathEnd !== -1 && pathEnd < end) {
281
+ const lnIdx = stdout.indexOf(LN_MARKER, pathEnd);
282
+ if (lnIdx !== -1 && lnIdx < end) {
283
+ const lnStart = lnIdx + LN_MARKER_LEN;
284
+ let lnEnd = lnStart;
285
+ while (lnEnd < end && stdout.charCodeAt(lnEnd) >= 48 && stdout.charCodeAt(lnEnd) <= 57) lnEnd++;
286
+ if (lnEnd > lnStart) {
287
+ const rawPath = stdout.substring(pathStart, pathEnd);
288
+ let file = pathCache.get(rawPath);
289
+ if (file === undefined) {
290
+ file = normalizeSearchPath(searchDir, rawPath);
291
+ pathCache.set(rawPath, file);
292
+ }
293
+ if (file) matches.push({ file, line: parseInt(stdout.substring(lnStart, lnEnd), 10) });
294
+ }
295
+ }
296
+ }
297
+ }
298
+ }
299
+ pos = nl === -1 ? stdout.length : nl + 1;
300
+ }
301
+ } else {
302
+ for (const line of stdout.split('\n')) {
303
+ if (!line) continue;
304
+ try {
305
+ const obj = JSON.parse(line);
306
+ if (obj.type !== 'match') continue;
307
+ const file = normalizeSearchPath(searchDir, obj.data?.path?.text);
308
+ const lineNumber = obj.data?.line_number;
309
+ const text = obj.data?.lines?.text?.trimEnd() || '';
310
+ const firstSubmatch = obj.data?.submatches?.[0];
311
+ const column = typeof firstSubmatch?.start === 'number' ? firstSubmatch.start + 1 : null;
312
+ const matchText = firstSubmatch?.match?.text || '';
313
+ if (file && lineNumber != null) {
314
+ matches.push({ file, line: lineNumber, column, matchText, content: text });
315
+ }
316
+ } catch {
317
+ // Skip malformed lines.
318
+ }
319
+ }
320
+ }
321
+ resolve(matches);
322
+ });
323
+
324
+ proc.on('error', (err) => {
325
+ if (err.code === 'ENOENT') {
326
+ reject(new Error('ripgrep (rg) not found. Install: brew install ripgrep'));
327
+ } else {
328
+ reject(err);
329
+ }
330
+ });
331
+ });
332
+ }
333
+
334
+ // =============================================================================
335
+ // Streaming ripgrep JSON — incremental line-by-line parse with early-exit
336
+ // =============================================================================
337
+
338
+ /**
339
+ * Streaming ripgrep JSON executor. Parses rg --json output line-by-line as it
340
+ * arrives on stdout, avoiding the Buffer.concat + toString + split overhead of
341
+ * executeRipgrep. Supports --max-count and an onMatch callback that can kill
342
+ * the rg process for early exit.
343
+ *
344
+ * Uses StringDecoder for correct multi-byte UTF-8 handling across chunk
345
+ * boundaries.
346
+ */
347
+ async function executeRipgrepStreaming({
348
+ patterns,
349
+ searchDir,
350
+ files = null,
351
+ fixedString = false,
352
+ caseInsensitive = false,
353
+ globs = [],
354
+ timeout = 10000,
355
+ useAnd = false,
356
+ maxCount = 0,
357
+ onMatch = null,
358
+ }) {
359
+ const rgBin = _findRg();
360
+ if (!rgBin) {
361
+ throw new Error('ripgrep (rg) not found. Install: brew install ripgrep');
362
+ }
363
+
364
+ const effectivePatterns = Array.isArray(patterns)
365
+ ? patterns.filter(Boolean)
366
+ : [patterns].filter(Boolean);
367
+ if (effectivePatterns.length === 0) return [];
368
+
369
+ return new Promise((resolve, reject) => {
370
+ const args = [
371
+ '--json',
372
+ '--type-add', RIPGREP_CODE_TYPE,
373
+ '--type', 'code',
374
+ ];
375
+
376
+ if (maxCount > 0) args.push('--max-count', String(maxCount));
377
+ if (caseInsensitive) args.push('-i');
378
+ for (const glob of globs) {
379
+ if (glob) args.push('--glob', glob);
380
+ }
381
+
382
+ if (useAnd && effectivePatterns.length > 1) {
383
+ args.push(fixedString ? '-F' : effectivePatterns[0]);
384
+ if (fixedString) args.push(effectivePatterns[0]);
385
+ for (const pattern of effectivePatterns.slice(1)) {
386
+ args.push('--and');
387
+ if (fixedString) args.push('-F');
388
+ args.push(pattern);
389
+ }
390
+ } else {
391
+ if (fixedString) args.push('-F');
392
+ args.push(effectivePatterns[0]);
393
+ }
394
+
395
+ if (Array.isArray(files) && files.length > 0) {
396
+ args.push('--', ...files);
397
+ } else {
398
+ args.push('.');
399
+ }
400
+
401
+ const proc = spawn(rgBin, args, {
402
+ cwd: searchDir,
403
+ stdio: ['ignore', 'pipe', 'pipe'],
404
+ timeout,
405
+ env: { ...process.env, ARGV0: 'rg' },
406
+ });
407
+
408
+ const matches = [];
409
+ let killed = false;
410
+ const decoder = new StringDecoder('utf-8');
411
+ let partial = '';
412
+ let stderr = '';
413
+
414
+ proc.stdout.on('data', (chunk) => {
415
+ if (killed) return;
416
+
417
+ partial += decoder.write(chunk);
418
+ const lines = partial.split('\n');
419
+ partial = lines.pop() || '';
420
+
421
+ for (const line of lines) {
422
+ if (!line || killed) continue;
423
+ try {
424
+ const obj = JSON.parse(line);
425
+ if (obj.type !== 'match') continue;
426
+ const file = normalizeSearchPath(searchDir, obj.data?.path?.text);
427
+ const lineNumber = obj.data?.line_number;
428
+ const text = obj.data?.lines?.text?.trimEnd() || '';
429
+ const firstSubmatch = obj.data?.submatches?.[0];
430
+ const column = typeof firstSubmatch?.start === 'number'
431
+ ? firstSubmatch.start + 1
432
+ : null;
433
+ const matchText = firstSubmatch?.match?.text || '';
434
+ if (file && lineNumber != null) {
435
+ const match = { file, line: lineNumber, column, matchText, content: text };
436
+ matches.push(match);
437
+ if (onMatch && onMatch(match) === false) {
438
+ killed = true;
439
+ proc.kill('SIGTERM');
440
+ return;
441
+ }
442
+ }
443
+ } catch {
444
+ // Skip malformed lines.
445
+ }
446
+ }
447
+ });
448
+
449
+ proc.stderr.on('data', (chunk) => { stderr += chunk; });
450
+
451
+ proc.on('close', (code) => {
452
+ if (!killed) {
453
+ // Flush decoder and process any remaining partial line
454
+ partial += decoder.end();
455
+ if (partial) {
456
+ try {
457
+ const obj = JSON.parse(partial);
458
+ if (obj.type === 'match') {
459
+ const file = normalizeSearchPath(searchDir, obj.data?.path?.text);
460
+ const lineNumber = obj.data?.line_number;
461
+ const text = obj.data?.lines?.text?.trimEnd() || '';
462
+ const firstSubmatch = obj.data?.submatches?.[0];
463
+ const column = typeof firstSubmatch?.start === 'number'
464
+ ? firstSubmatch.start + 1
465
+ : null;
466
+ const matchText = firstSubmatch?.match?.text || '';
467
+ if (file && lineNumber != null) {
468
+ matches.push({ file, line: lineNumber, column, matchText, content: text });
469
+ }
470
+ }
471
+ } catch { /* ignore */ }
472
+ }
473
+ }
474
+
475
+ if (killed || code === 0 || code === 1) {
476
+ resolve(matches);
477
+ return;
478
+ }
479
+ reject(new Error(`ripgrep failed (code ${code}): ${stderr.slice(0, 200)}`));
480
+ });
481
+
482
+ proc.on('error', (err) => {
483
+ if (killed) {
484
+ resolve(matches);
485
+ } else if (err.code === 'ENOENT') {
486
+ reject(new Error('ripgrep (rg) not found. Install: brew install ripgrep'));
487
+ } else {
488
+ reject(err);
489
+ }
490
+ });
491
+ });
492
+ }
493
+
494
+ export async function runRipgrepFilesWithMatches(patterns, searchDir, opts = {}) {
495
+ const {
496
+ files = null,
497
+ fixedString = false,
498
+ caseInsensitive = false,
499
+ globs = [],
500
+ timeout = 10000,
501
+ useAnd = false,
502
+ } = opts;
503
+
504
+ if (Array.isArray(files) && files.length === 0) return [];
505
+
506
+ if (!Array.isArray(files)) {
507
+ return executeRipgrep({
508
+ patterns,
509
+ searchDir,
510
+ files: null,
511
+ fixedString,
512
+ caseInsensitive,
513
+ globs,
514
+ outputMode: 'files',
515
+ timeout,
516
+ useAnd,
517
+ });
518
+ }
519
+
520
+ const batches = chunkRipgrepFiles(files);
521
+ const batchResults = await Promise.all(batches.map(batch =>
522
+ executeRipgrep({
523
+ patterns,
524
+ searchDir,
525
+ files: batch,
526
+ fixedString,
527
+ caseInsensitive,
528
+ globs,
529
+ outputMode: 'files',
530
+ timeout,
531
+ useAnd,
532
+ })
533
+ ));
534
+ const matched = new Set();
535
+ for (const batchMatches of batchResults) {
536
+ for (const file of batchMatches) matched.add(file);
537
+ }
538
+
539
+ return [...matched];
540
+ }
541
+
542
+ export async function runRipgrepJson(regex, searchDir, opts = {}) {
543
+ const {
544
+ files = null,
545
+ fixedString = false,
546
+ globs = [],
547
+ timeout = 10000,
548
+ lightweightParse = false,
549
+ } = opts;
550
+
551
+ if (Array.isArray(files) && files.length === 0) return [];
552
+
553
+ const allMatches = [];
554
+ if (!Array.isArray(files)) {
555
+ return executeRipgrep({
556
+ patterns: [regex],
557
+ searchDir,
558
+ files: null,
559
+ fixedString,
560
+ globs,
561
+ outputMode: 'json',
562
+ timeout,
563
+ lightweightParse,
564
+ });
565
+ }
566
+
567
+ const batches = chunkRipgrepFiles(files);
568
+ const batchResults = await Promise.all(batches.map(batch =>
569
+ executeRipgrep({
570
+ patterns: [regex],
571
+ searchDir,
572
+ files: batch,
573
+ fixedString,
574
+ globs,
575
+ outputMode: 'json',
576
+ timeout,
577
+ lightweightParse,
578
+ })
579
+ ));
580
+ for (const batchMatches of batchResults) {
581
+ allMatches.push(...batchMatches);
582
+ }
583
+ return allMatches;
584
+ }
585
+
586
+ // =============================================================================
587
+ // Streaming ripgrep JSON — parallel batches with early-exit support
588
+ // =============================================================================
589
+
590
+ /**
591
+ * Streaming variant of runRipgrepJson. Parses rg output incrementally and
592
+ * supports an onMatch callback for early-exit. Batches run in parallel
593
+ * (unlike the original sequential loop in runRipgrepJson).
594
+ *
595
+ * @param {string} regex - Regex pattern
596
+ * @param {string} searchDir - Directory to search
597
+ * @param {Object} opts
598
+ * @param {string[]|null} opts.files - Explicit file list
599
+ * @param {boolean} opts.fixedString - Use -F mode
600
+ * @param {string[]} opts.globs - Glob filters
601
+ * @param {number} opts.timeout - Spawn timeout
602
+ * @param {number} opts.maxCount - --max-count per file (0 = unlimited)
603
+ * @param {function|null} opts.onMatch - Per-match callback; return false to kill rg
604
+ * @returns {Promise<Array<{file, line, column, matchText, content}>>}
605
+ */
606
+ export async function runRipgrepJsonStreaming(regex, searchDir, opts = {}) {
607
+ const {
608
+ files = null,
609
+ fixedString = false,
610
+ globs = [],
611
+ timeout = 10000,
612
+ maxCount = 0,
613
+ onMatch = null,
614
+ } = opts;
615
+
616
+ if (Array.isArray(files) && files.length === 0) return [];
617
+
618
+ if (!Array.isArray(files)) {
619
+ return executeRipgrepStreaming({
620
+ patterns: [regex],
621
+ searchDir,
622
+ files: null,
623
+ fixedString,
624
+ globs,
625
+ timeout,
626
+ maxCount,
627
+ onMatch,
628
+ });
629
+ }
630
+
631
+ const batches = chunkRipgrepFiles(files);
632
+
633
+ // Shared stop flag: when one batch triggers early-exit, stop all batches.
634
+ let stopped = false;
635
+ const sharedOnMatch = onMatch ? (match) => {
636
+ if (stopped) return false;
637
+ const result = onMatch(match);
638
+ if (result === false) {
639
+ stopped = true;
640
+ return false;
641
+ }
642
+ return result;
643
+ } : null;
644
+
645
+ const batchResults = await Promise.all(batches.map(batch =>
646
+ executeRipgrepStreaming({
647
+ patterns: [regex],
648
+ searchDir,
649
+ files: batch,
650
+ fixedString,
651
+ globs,
652
+ timeout,
653
+ maxCount,
654
+ onMatch: sharedOnMatch,
655
+ })
656
+ ));
657
+
658
+ const allMatches = [];
659
+ for (const batchMatches of batchResults) {
660
+ allMatches.push(...batchMatches);
661
+ }
662
+ return allMatches;
663
+ }