@gemini-designer/mcp-server 0.1.39 → 0.1.40
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/dist/context/builder.d.ts.map +1 -1
- package/dist/context/builder.js +29 -28
- package/dist/context/builder.js.map +1 -1
- package/dist/output/file-bundle.d.ts +1 -1
- package/dist/tools/catalog-components.d.ts.map +1 -1
- package/dist/tools/catalog-components.js +29 -3
- package/dist/tools/catalog-components.js.map +1 -1
- package/dist/tools/detect-ui-stack.d.ts.map +1 -1
- package/dist/tools/detect-ui-stack.js +45 -14
- package/dist/tools/detect-ui-stack.js.map +1 -1
- package/dist/tools/repo-search.d.ts.map +1 -1
- package/dist/tools/repo-search.js +26 -120
- package/dist/tools/repo-search.js.map +1 -1
- package/dist/tools/repo-tree.d.ts.map +1 -1
- package/dist/tools/repo-tree.js +44 -19
- package/dist/tools/repo-tree.js.map +1 -1
- package/dist/utils/concurrency.d.ts +2 -0
- package/dist/utils/concurrency.d.ts.map +1 -0
- package/dist/utils/concurrency.js +16 -0
- package/dist/utils/concurrency.js.map +1 -0
- package/dist/utils/ripgrep.d.ts +45 -0
- package/dist/utils/ripgrep.d.ts.map +1 -0
- package/dist/utils/ripgrep.js +312 -0
- package/dist/utils/ripgrep.js.map +1 -0
- package/dist/utils/walk.d.ts +1 -0
- package/dist/utils/walk.d.ts.map +1 -1
- package/dist/utils/walk.js +2 -1
- package/dist/utils/walk.js.map +1 -1
- package/package.json +2 -1
- package/src/__tests__/ripgrep.test.ts +136 -0
- package/src/context/builder.ts +27 -30
- package/src/tools/catalog-components.ts +28 -3
- package/src/tools/detect-ui-stack.ts +51 -14
- package/src/tools/repo-search.ts +27 -121
- package/src/tools/repo-tree.ts +44 -17
- package/src/utils/concurrency.ts +21 -0
- package/src/utils/ripgrep.ts +360 -0
- package/src/utils/walk.ts +3 -3
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import * as path from 'node:path';
|
|
3
|
+
import * as readline from 'node:readline';
|
|
4
|
+
|
|
5
|
+
let rgPathPromise: Promise<string> | undefined;
|
|
6
|
+
|
|
7
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
8
|
+
return typeof value === 'object' && value !== null;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function extractRgPath(mod: unknown): string | undefined {
|
|
12
|
+
if (!isRecord(mod)) return undefined;
|
|
13
|
+
|
|
14
|
+
const direct = mod.rgPath;
|
|
15
|
+
if (typeof direct === 'string' && direct.length > 0) return direct;
|
|
16
|
+
|
|
17
|
+
const def = mod.default;
|
|
18
|
+
if (isRecord(def)) {
|
|
19
|
+
const nested = def.rgPath;
|
|
20
|
+
if (typeof nested === 'string' && nested.length > 0) return nested;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
return undefined;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function resolveRipgrepPath(): Promise<string> {
|
|
27
|
+
if (!rgPathPromise) {
|
|
28
|
+
rgPathPromise = (async () => {
|
|
29
|
+
try {
|
|
30
|
+
const mod = (await import('@vscode/ripgrep')) as unknown;
|
|
31
|
+
const rgPath = extractRgPath(mod);
|
|
32
|
+
if (typeof rgPath === 'string' && rgPath.length > 0) return rgPath;
|
|
33
|
+
} catch {
|
|
34
|
+
// Fall back to system rg.
|
|
35
|
+
}
|
|
36
|
+
return 'rg';
|
|
37
|
+
})();
|
|
38
|
+
}
|
|
39
|
+
return rgPathPromise;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
type RipgrepEvent = { type: string; data?: unknown };
|
|
43
|
+
|
|
44
|
+
function getPathText(data: unknown): string | undefined {
|
|
45
|
+
if (!isRecord(data)) return undefined;
|
|
46
|
+
const p = data.path;
|
|
47
|
+
if (!isRecord(p)) return undefined;
|
|
48
|
+
const t = p.text;
|
|
49
|
+
return typeof t === 'string' && t.length > 0 ? t : undefined;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function getLineNumber(data: unknown): number | undefined {
|
|
53
|
+
if (!isRecord(data)) return undefined;
|
|
54
|
+
const n = data.line_number;
|
|
55
|
+
return typeof n === 'number' && Number.isFinite(n) ? n : undefined;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function getLineText(data: unknown): string {
|
|
59
|
+
if (!isRecord(data)) return '';
|
|
60
|
+
const lines = data.lines;
|
|
61
|
+
if (!isRecord(lines)) return '';
|
|
62
|
+
return typeof lines.text === 'string' ? lines.text : '';
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function getSubmatchStarts(data: unknown): number[] {
|
|
66
|
+
if (!isRecord(data)) return [];
|
|
67
|
+
const subs = data.submatches;
|
|
68
|
+
if (!Array.isArray(subs)) return [];
|
|
69
|
+
const out: number[] = [];
|
|
70
|
+
for (const sm of subs) {
|
|
71
|
+
if (!isRecord(sm)) continue;
|
|
72
|
+
const start = sm.start;
|
|
73
|
+
if (typeof start === 'number' && Number.isFinite(start)) out.push(start);
|
|
74
|
+
}
|
|
75
|
+
return out;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export interface RipgrepMatch {
|
|
79
|
+
path: string;
|
|
80
|
+
line: number;
|
|
81
|
+
column: number;
|
|
82
|
+
preview?: string;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export interface RipgrepSearchOptions {
|
|
86
|
+
cwd: string;
|
|
87
|
+
pattern: string;
|
|
88
|
+
fixedStrings: boolean;
|
|
89
|
+
caseSensitive: boolean;
|
|
90
|
+
includeHidden: boolean;
|
|
91
|
+
includeBinary: boolean;
|
|
92
|
+
maxFileBytes: number;
|
|
93
|
+
maxResults: number;
|
|
94
|
+
globs?: string[];
|
|
95
|
+
usePcre2?: boolean;
|
|
96
|
+
shouldIgnorePath?: (absPath: string) => boolean;
|
|
97
|
+
includePreview?: boolean;
|
|
98
|
+
previewMaxChars?: number;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export interface RipgrepSearchResult {
|
|
102
|
+
matches: RipgrepMatch[];
|
|
103
|
+
scannedFilesCount: number;
|
|
104
|
+
truncated: boolean;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export interface RipgrepListFilesOptions {
|
|
108
|
+
cwd: string;
|
|
109
|
+
includeHidden: boolean;
|
|
110
|
+
noIgnore: boolean;
|
|
111
|
+
maxFiles: number;
|
|
112
|
+
maxDepth?: number;
|
|
113
|
+
globs?: string[];
|
|
114
|
+
shouldIgnorePath?: (absPath: string) => boolean;
|
|
115
|
+
cacheTtlMs?: number;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export interface RipgrepListFilesResult {
|
|
119
|
+
files: string[];
|
|
120
|
+
truncated: boolean;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const filesCache = new Map<string, { expiresAt: number; promise: Promise<RipgrepListFilesResult> }>();
|
|
124
|
+
|
|
125
|
+
function makeFilesCacheKey(opts: RipgrepListFilesOptions): string {
|
|
126
|
+
return JSON.stringify({
|
|
127
|
+
cwd: opts.cwd,
|
|
128
|
+
includeHidden: opts.includeHidden,
|
|
129
|
+
noIgnore: opts.noIgnore,
|
|
130
|
+
maxFiles: opts.maxFiles,
|
|
131
|
+
maxDepth: opts.maxDepth ?? null,
|
|
132
|
+
globs: opts.globs || [],
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function pruneFilesCache(now: number, maxEntries = 50): void {
|
|
137
|
+
for (const [k, v] of filesCache) {
|
|
138
|
+
if (v.expiresAt <= now) filesCache.delete(k);
|
|
139
|
+
}
|
|
140
|
+
if (filesCache.size <= maxEntries) return;
|
|
141
|
+
const entries = [...filesCache.entries()].sort((a, b) => a[1].expiresAt - b[1].expiresAt);
|
|
142
|
+
for (let i = 0; i < entries.length - maxEntries; i++) {
|
|
143
|
+
filesCache.delete(entries[i][0]);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async function ripgrepListFilesUncached(opts: RipgrepListFilesOptions): Promise<RipgrepListFilesResult> {
|
|
148
|
+
const rgPath = await resolveRipgrepPath();
|
|
149
|
+
|
|
150
|
+
const args: string[] = ['--no-config', '--files', '--no-messages'];
|
|
151
|
+
if (opts.includeHidden) args.push('--hidden');
|
|
152
|
+
if (opts.noIgnore) args.push('--no-ignore');
|
|
153
|
+
if (typeof opts.maxDepth === 'number') args.push('--max-depth', String(opts.maxDepth));
|
|
154
|
+
for (const g of opts.globs || []) args.push('--glob', g);
|
|
155
|
+
|
|
156
|
+
const child = spawn(rgPath, args, {
|
|
157
|
+
cwd: opts.cwd,
|
|
158
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
159
|
+
windowsHide: true,
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
const files: string[] = [];
|
|
163
|
+
let truncated = false;
|
|
164
|
+
|
|
165
|
+
const stderrChunks: string[] = [];
|
|
166
|
+
child.stderr.setEncoding('utf8');
|
|
167
|
+
child.stderr.on('data', (d) => stderrChunks.push(String(d)));
|
|
168
|
+
|
|
169
|
+
const rl = readline.createInterface({ input: child.stdout });
|
|
170
|
+
|
|
171
|
+
const stopEarly = (): void => {
|
|
172
|
+
if (truncated) return;
|
|
173
|
+
truncated = true;
|
|
174
|
+
try {
|
|
175
|
+
child.kill();
|
|
176
|
+
} catch {
|
|
177
|
+
// ignore
|
|
178
|
+
}
|
|
179
|
+
try {
|
|
180
|
+
rl.close();
|
|
181
|
+
} catch {
|
|
182
|
+
// ignore
|
|
183
|
+
}
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
rl.on('line', (line) => {
|
|
187
|
+
if (truncated) return;
|
|
188
|
+
const rel = line.replace(/\r?\n$/, '').trim();
|
|
189
|
+
if (!rel) return;
|
|
190
|
+
const abs = path.resolve(opts.cwd, rel);
|
|
191
|
+
if (opts.shouldIgnorePath?.(abs)) return;
|
|
192
|
+
files.push(rel.replace(/\\/g, '/'));
|
|
193
|
+
if (files.length >= opts.maxFiles) stopEarly();
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
const exit = await new Promise<{ code: number | null; signal: NodeJS.Signals | null }>((resolve) => {
|
|
197
|
+
child.on('close', (code, signal) => resolve({ code, signal }));
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
if (!truncated && exit.code === 2) {
|
|
201
|
+
const stderr = stderrChunks.join('').trim();
|
|
202
|
+
const msg = stderr.length > 0 ? stderr : 'ripgrep failed';
|
|
203
|
+
throw new Error(msg);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
return { files, truncated };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export async function ripgrepListFiles(opts: RipgrepListFilesOptions): Promise<RipgrepListFilesResult> {
|
|
210
|
+
const ttlMs = opts.cacheTtlMs ?? 0;
|
|
211
|
+
if (ttlMs <= 0) return ripgrepListFilesUncached(opts);
|
|
212
|
+
|
|
213
|
+
const now = Date.now();
|
|
214
|
+
pruneFilesCache(now);
|
|
215
|
+
const key = makeFilesCacheKey(opts);
|
|
216
|
+
const cached = filesCache.get(key);
|
|
217
|
+
if (cached && cached.expiresAt > now) return cached.promise;
|
|
218
|
+
|
|
219
|
+
const promise = ripgrepListFilesUncached({ ...opts, cacheTtlMs: 0 });
|
|
220
|
+
filesCache.set(key, { expiresAt: now + ttlMs, promise });
|
|
221
|
+
return promise;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
export function extensionGlob(extensions: string[]): string | null {
|
|
225
|
+
const cleaned = [...new Set(extensions.map((e) => e.trim()).filter(Boolean))]
|
|
226
|
+
.map((e) => (e.startsWith('.') ? e.slice(1) : e))
|
|
227
|
+
.filter(Boolean);
|
|
228
|
+
if (cleaned.length === 0) return null;
|
|
229
|
+
if (cleaned.length === 1) return `**/*.${cleaned[0]}`;
|
|
230
|
+
return `**/*.{${cleaned.join(',')}}`;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
export async function ripgrepJsonSearch(opts: RipgrepSearchOptions): Promise<RipgrepSearchResult> {
|
|
234
|
+
const rgPath = await resolveRipgrepPath();
|
|
235
|
+
|
|
236
|
+
const args: string[] = ['--no-config', '--json', '--no-messages'];
|
|
237
|
+
|
|
238
|
+
if (opts.includeHidden) args.push('--hidden');
|
|
239
|
+
if (opts.includeBinary) args.push('--text');
|
|
240
|
+
if (!opts.caseSensitive) args.push('-i');
|
|
241
|
+
if (opts.fixedStrings) args.push('--fixed-strings');
|
|
242
|
+
if (opts.usePcre2 && !opts.fixedStrings) args.push('--pcre2');
|
|
243
|
+
args.push('--max-filesize', String(opts.maxFileBytes));
|
|
244
|
+
|
|
245
|
+
for (const g of opts.globs || []) {
|
|
246
|
+
args.push('--glob', g);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
args.push(opts.pattern);
|
|
250
|
+
|
|
251
|
+
const child = spawn(rgPath, args, {
|
|
252
|
+
cwd: opts.cwd,
|
|
253
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
254
|
+
windowsHide: true,
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
const matches: RipgrepMatch[] = [];
|
|
258
|
+
const scanned = new Set<string>();
|
|
259
|
+
const ignoredRelPaths = new Set<string>();
|
|
260
|
+
let truncated = false;
|
|
261
|
+
|
|
262
|
+
const includePreview = opts.includePreview !== false;
|
|
263
|
+
const previewMaxChars = opts.previewMaxChars ?? 240;
|
|
264
|
+
|
|
265
|
+
const stderrChunks: string[] = [];
|
|
266
|
+
child.stderr.setEncoding('utf8');
|
|
267
|
+
child.stderr.on('data', (d) => stderrChunks.push(String(d)));
|
|
268
|
+
|
|
269
|
+
const rl = readline.createInterface({ input: child.stdout });
|
|
270
|
+
|
|
271
|
+
const stopEarly = (): void => {
|
|
272
|
+
if (truncated) return;
|
|
273
|
+
truncated = true;
|
|
274
|
+
try {
|
|
275
|
+
child.kill();
|
|
276
|
+
} catch {
|
|
277
|
+
// ignore
|
|
278
|
+
}
|
|
279
|
+
try {
|
|
280
|
+
rl.close();
|
|
281
|
+
} catch {
|
|
282
|
+
// ignore
|
|
283
|
+
}
|
|
284
|
+
};
|
|
285
|
+
|
|
286
|
+
rl.on('line', (line) => {
|
|
287
|
+
if (truncated) return;
|
|
288
|
+
let evt: RipgrepEvent | undefined;
|
|
289
|
+
try {
|
|
290
|
+
const parsed = JSON.parse(line) as unknown;
|
|
291
|
+
if (!isRecord(parsed) || typeof parsed.type !== 'string') return;
|
|
292
|
+
evt = parsed as RipgrepEvent;
|
|
293
|
+
} catch {
|
|
294
|
+
// If we terminate early, the last line can be truncated; ignore parse failures.
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
if (!evt) return;
|
|
298
|
+
|
|
299
|
+
if (evt.type === 'begin') {
|
|
300
|
+
const rel = getPathText(evt.data);
|
|
301
|
+
if (!rel) return;
|
|
302
|
+
const abs = path.resolve(opts.cwd, rel);
|
|
303
|
+
if (opts.shouldIgnorePath?.(abs)) {
|
|
304
|
+
ignoredRelPaths.add(rel);
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
scanned.add(rel);
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
if (evt.type === 'match') {
|
|
312
|
+
const rel = getPathText(evt.data);
|
|
313
|
+
if (!rel) return;
|
|
314
|
+
if (ignoredRelPaths.has(rel)) return;
|
|
315
|
+
|
|
316
|
+
const abs = path.resolve(opts.cwd, rel);
|
|
317
|
+
if (opts.shouldIgnorePath?.(abs)) {
|
|
318
|
+
ignoredRelPaths.add(rel);
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
const lineNumber = getLineNumber(evt.data);
|
|
323
|
+
if (!lineNumber) return;
|
|
324
|
+
const rawLine = getLineText(evt.data);
|
|
325
|
+
const preview = includePreview ? rawLine.replace(/\r?\n$/, '').slice(0, previewMaxChars) : undefined;
|
|
326
|
+
|
|
327
|
+
const starts = getSubmatchStarts(evt.data);
|
|
328
|
+
if (starts.length === 0) {
|
|
329
|
+
if (matches.length < opts.maxResults) {
|
|
330
|
+
matches.push({ path: rel.replace(/\\/g, '/'), line: lineNumber, column: 1, preview });
|
|
331
|
+
if (matches.length >= opts.maxResults) stopEarly();
|
|
332
|
+
}
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
for (const start of starts) {
|
|
337
|
+
if (matches.length >= opts.maxResults) {
|
|
338
|
+
stopEarly();
|
|
339
|
+
break;
|
|
340
|
+
}
|
|
341
|
+
matches.push({ path: rel.replace(/\\/g, '/'), line: lineNumber, column: start + 1, preview });
|
|
342
|
+
}
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
const exit = await new Promise<{ code: number | null; signal: NodeJS.Signals | null }>((resolve) => {
|
|
348
|
+
child.on('close', (code, signal) => resolve({ code, signal }));
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
// rg exit codes:
|
|
352
|
+
// 0 = match found, 1 = no matches, 2 = error.
|
|
353
|
+
if (!truncated && exit.code === 2) {
|
|
354
|
+
const stderr = stderrChunks.join('').trim();
|
|
355
|
+
const msg = stderr.length > 0 ? stderr : 'ripgrep failed';
|
|
356
|
+
throw new Error(msg);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
return { matches, scannedFilesCount: scanned.size, truncated };
|
|
360
|
+
}
|
package/src/utils/walk.ts
CHANGED
|
@@ -27,11 +27,11 @@ const DEFAULT_EXCLUDE_DIRS = [
|
|
|
27
27
|
'coverage',
|
|
28
28
|
];
|
|
29
29
|
|
|
30
|
+
export const DEFAULT_EXCLUDE_DIR_NAMES = DEFAULT_EXCLUDE_DIRS;
|
|
31
|
+
|
|
30
32
|
export function walkFiles(rootDir: string, options: WalkOptions = {}): string[] {
|
|
31
33
|
const includeExtensions = (options.includeExtensions || []).map((e) => e.toLowerCase());
|
|
32
|
-
const exclude = new Set(
|
|
33
|
-
(options.excludeDirNames || DEFAULT_EXCLUDE_DIRS).map((d) => d.toLowerCase())
|
|
34
|
-
);
|
|
34
|
+
const exclude = new Set((options.excludeDirNames || DEFAULT_EXCLUDE_DIR_NAMES).map((d) => d.toLowerCase()));
|
|
35
35
|
const maxFiles = typeof options.maxFiles === 'number' ? options.maxFiles : 10_000;
|
|
36
36
|
|
|
37
37
|
const results: string[] = [];
|