ucn 3.7.18 → 3.7.20
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/.claude/skills/ucn/SKILL.md +5 -0
- package/cli/index.js +332 -428
- package/core/execute.js +376 -0
- package/core/expand-cache.js +162 -0
- package/core/project.js +14 -6
- package/core/registry.js +166 -0
- package/languages/rust.js +10 -7
- package/languages/utils.js +2 -2
- package/mcp/server.js +153 -315
- package/package.json +1 -1
package/core/execute.js
ADDED
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared Command Executor — single dispatch for CLI, MCP, and interactive mode.
|
|
3
|
+
*
|
|
4
|
+
* Handles: input validation, exclude normalization, test exclusion, index calls.
|
|
5
|
+
* Does NOT handle: output formatting, expand caching, file I/O commands.
|
|
6
|
+
*
|
|
7
|
+
* Each handler returns { ok: true, result } or { ok: false, error }.
|
|
8
|
+
* Adapters handle formatting and surface-specific concerns.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
'use strict';
|
|
12
|
+
|
|
13
|
+
const { addTestExclusions } = require('./shared');
|
|
14
|
+
|
|
15
|
+
// Commands handled directly by adapters (not in HANDLERS below).
|
|
16
|
+
// fn, class, lines need raw file content / line-range logic.
|
|
17
|
+
// expand needs per-session cache state that differs by surface.
|
|
18
|
+
const ADAPTER_ONLY_COMMANDS = new Set(['fn', 'class', 'lines', 'expand']);
|
|
19
|
+
|
|
20
|
+
// ============================================================================
|
|
21
|
+
// HELPERS
|
|
22
|
+
// ============================================================================
|
|
23
|
+
|
|
24
|
+
function requireName(name) {
|
|
25
|
+
if (!name || (typeof name === 'string' && !name.trim())) {
|
|
26
|
+
return 'Symbol name is required.';
|
|
27
|
+
}
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function requireFile(file) {
|
|
32
|
+
if (!file || (typeof file === 'string' && !file.trim())) {
|
|
33
|
+
return 'File parameter is required.';
|
|
34
|
+
}
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function requireTerm(term) {
|
|
39
|
+
if (!term || (typeof term === 'string' && !term.trim())) {
|
|
40
|
+
return 'Search term is required.';
|
|
41
|
+
}
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Normalize exclude to an array (accepts string CSV, array, or falsy). */
|
|
46
|
+
function toExcludeArray(exclude) {
|
|
47
|
+
if (!exclude) return [];
|
|
48
|
+
if (Array.isArray(exclude)) return exclude;
|
|
49
|
+
return exclude.split(',').map(s => s.trim()).filter(Boolean);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Apply test exclusions unless includeTests is set. */
|
|
53
|
+
function applyTestExclusions(exclude, includeTests) {
|
|
54
|
+
const arr = toExcludeArray(exclude);
|
|
55
|
+
return includeTests ? arr : addTestExclusions(arr);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Check if a file-based result has a file error. */
|
|
59
|
+
function checkFileError(result, file) {
|
|
60
|
+
if (!result) return null;
|
|
61
|
+
if (result.error === 'file-not-found') {
|
|
62
|
+
return `File not found in project: ${file}`;
|
|
63
|
+
}
|
|
64
|
+
if (result.error === 'file-ambiguous') {
|
|
65
|
+
const candidates = result.candidates ? result.candidates.map(c => ' ' + c).join('\n') : '';
|
|
66
|
+
return `Ambiguous file "${file}". Candidates:\n${candidates}`;
|
|
67
|
+
}
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Parse a number param (handles string from CLI, number from MCP). */
|
|
72
|
+
function num(val, fallback) {
|
|
73
|
+
if (val == null) return fallback;
|
|
74
|
+
const n = Number(val);
|
|
75
|
+
return isNaN(n) ? fallback : n;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// ============================================================================
|
|
79
|
+
// COMMAND HANDLERS
|
|
80
|
+
// ============================================================================
|
|
81
|
+
|
|
82
|
+
const HANDLERS = {
|
|
83
|
+
|
|
84
|
+
// ── Understanding Code ──────────────────────────────────────────────
|
|
85
|
+
|
|
86
|
+
about: (index, p) => {
|
|
87
|
+
const err = requireName(p.name);
|
|
88
|
+
if (err) return { ok: false, error: err };
|
|
89
|
+
const result = index.about(p.name, {
|
|
90
|
+
withTypes: p.withTypes || false,
|
|
91
|
+
file: p.file,
|
|
92
|
+
all: p.all,
|
|
93
|
+
includeMethods: p.includeMethods,
|
|
94
|
+
includeUncertain: p.includeUncertain || false,
|
|
95
|
+
exclude: toExcludeArray(p.exclude),
|
|
96
|
+
maxCallers: num(p.top, undefined),
|
|
97
|
+
maxCallees: num(p.top, undefined),
|
|
98
|
+
});
|
|
99
|
+
return { ok: true, result };
|
|
100
|
+
},
|
|
101
|
+
|
|
102
|
+
context: (index, p) => {
|
|
103
|
+
const err = requireName(p.name);
|
|
104
|
+
if (err) return { ok: false, error: err };
|
|
105
|
+
const result = index.context(p.name, {
|
|
106
|
+
includeMethods: p.includeMethods,
|
|
107
|
+
includeUncertain: p.includeUncertain || false,
|
|
108
|
+
file: p.file,
|
|
109
|
+
exclude: toExcludeArray(p.exclude),
|
|
110
|
+
});
|
|
111
|
+
if (!result) return { ok: false, error: `Symbol "${p.name}" not found.` };
|
|
112
|
+
return { ok: true, result };
|
|
113
|
+
},
|
|
114
|
+
|
|
115
|
+
impact: (index, p) => {
|
|
116
|
+
const err = requireName(p.name);
|
|
117
|
+
if (err) return { ok: false, error: err };
|
|
118
|
+
const result = index.impact(p.name, {
|
|
119
|
+
file: p.file,
|
|
120
|
+
exclude: toExcludeArray(p.exclude),
|
|
121
|
+
});
|
|
122
|
+
return { ok: true, result };
|
|
123
|
+
},
|
|
124
|
+
|
|
125
|
+
smart: (index, p) => {
|
|
126
|
+
const err = requireName(p.name);
|
|
127
|
+
if (err) return { ok: false, error: err };
|
|
128
|
+
const result = index.smart(p.name, {
|
|
129
|
+
file: p.file,
|
|
130
|
+
withTypes: p.withTypes || false,
|
|
131
|
+
includeMethods: p.includeMethods,
|
|
132
|
+
includeUncertain: p.includeUncertain || false,
|
|
133
|
+
});
|
|
134
|
+
if (!result) return { ok: false, error: `Function "${p.name}" not found.` };
|
|
135
|
+
return { ok: true, result };
|
|
136
|
+
},
|
|
137
|
+
|
|
138
|
+
trace: (index, p) => {
|
|
139
|
+
const err = requireName(p.name);
|
|
140
|
+
if (err) return { ok: false, error: err };
|
|
141
|
+
const depthVal = num(p.depth, undefined);
|
|
142
|
+
const result = index.trace(p.name, {
|
|
143
|
+
depth: depthVal ?? 3,
|
|
144
|
+
file: p.file,
|
|
145
|
+
all: p.all || depthVal !== undefined,
|
|
146
|
+
includeMethods: p.includeMethods,
|
|
147
|
+
includeUncertain: p.includeUncertain || false,
|
|
148
|
+
});
|
|
149
|
+
return { ok: true, result };
|
|
150
|
+
},
|
|
151
|
+
|
|
152
|
+
example: (index, p) => {
|
|
153
|
+
const err = requireName(p.name);
|
|
154
|
+
if (err) return { ok: false, error: err };
|
|
155
|
+
const result = index.example(p.name);
|
|
156
|
+
return { ok: true, result };
|
|
157
|
+
},
|
|
158
|
+
|
|
159
|
+
related: (index, p) => {
|
|
160
|
+
const err = requireName(p.name);
|
|
161
|
+
if (err) return { ok: false, error: err };
|
|
162
|
+
const result = index.related(p.name, {
|
|
163
|
+
file: p.file,
|
|
164
|
+
top: num(p.top, undefined),
|
|
165
|
+
all: p.all,
|
|
166
|
+
});
|
|
167
|
+
return { ok: true, result };
|
|
168
|
+
},
|
|
169
|
+
|
|
170
|
+
// ── Finding Code ────────────────────────────────────────────────────
|
|
171
|
+
|
|
172
|
+
find: (index, p) => {
|
|
173
|
+
const err = requireName(p.name);
|
|
174
|
+
if (err) return { ok: false, error: err };
|
|
175
|
+
const exclude = applyTestExclusions(p.exclude, p.includeTests);
|
|
176
|
+
const result = index.find(p.name, {
|
|
177
|
+
file: p.file,
|
|
178
|
+
exact: p.exact || false,
|
|
179
|
+
exclude,
|
|
180
|
+
in: p.in,
|
|
181
|
+
});
|
|
182
|
+
return { ok: true, result };
|
|
183
|
+
},
|
|
184
|
+
|
|
185
|
+
usages: (index, p) => {
|
|
186
|
+
const err = requireName(p.name);
|
|
187
|
+
if (err) return { ok: false, error: err };
|
|
188
|
+
const exclude = applyTestExclusions(p.exclude, p.includeTests);
|
|
189
|
+
const result = index.usages(p.name, {
|
|
190
|
+
codeOnly: p.codeOnly || false,
|
|
191
|
+
context: num(p.context, 0),
|
|
192
|
+
exclude,
|
|
193
|
+
in: p.in,
|
|
194
|
+
});
|
|
195
|
+
return { ok: true, result };
|
|
196
|
+
},
|
|
197
|
+
|
|
198
|
+
toc: (index, p) => {
|
|
199
|
+
const result = index.getToc({
|
|
200
|
+
detailed: p.detailed,
|
|
201
|
+
topLevel: p.topLevel,
|
|
202
|
+
all: p.all,
|
|
203
|
+
top: num(p.top, undefined),
|
|
204
|
+
});
|
|
205
|
+
return { ok: true, result };
|
|
206
|
+
},
|
|
207
|
+
|
|
208
|
+
search: (index, p) => {
|
|
209
|
+
const err = requireTerm(p.term);
|
|
210
|
+
if (err) return { ok: false, error: err };
|
|
211
|
+
const exclude = applyTestExclusions(p.exclude, p.includeTests);
|
|
212
|
+
const result = index.search(p.term, {
|
|
213
|
+
codeOnly: p.codeOnly || false,
|
|
214
|
+
context: num(p.context, 0),
|
|
215
|
+
caseSensitive: p.caseSensitive || false,
|
|
216
|
+
exclude,
|
|
217
|
+
in: p.in,
|
|
218
|
+
regex: p.regex,
|
|
219
|
+
});
|
|
220
|
+
return { ok: true, result };
|
|
221
|
+
},
|
|
222
|
+
|
|
223
|
+
tests: (index, p) => {
|
|
224
|
+
const err = requireName(p.name);
|
|
225
|
+
if (err) return { ok: false, error: err };
|
|
226
|
+
const result = index.tests(p.name, {
|
|
227
|
+
callsOnly: p.callsOnly || false,
|
|
228
|
+
});
|
|
229
|
+
return { ok: true, result };
|
|
230
|
+
},
|
|
231
|
+
|
|
232
|
+
deadcode: (index, p) => {
|
|
233
|
+
const result = index.deadcode({
|
|
234
|
+
includeExported: p.includeExported || false,
|
|
235
|
+
includeDecorated: p.includeDecorated || false,
|
|
236
|
+
includeTests: p.includeTests || false,
|
|
237
|
+
exclude: toExcludeArray(p.exclude),
|
|
238
|
+
in: p.in,
|
|
239
|
+
});
|
|
240
|
+
return { ok: true, result };
|
|
241
|
+
},
|
|
242
|
+
|
|
243
|
+
// ── File Dependencies ───────────────────────────────────────────────
|
|
244
|
+
|
|
245
|
+
imports: (index, p) => {
|
|
246
|
+
const err = requireFile(p.file);
|
|
247
|
+
if (err) return { ok: false, error: err };
|
|
248
|
+
const result = index.imports(p.file);
|
|
249
|
+
const fileErr = checkFileError(result, p.file);
|
|
250
|
+
if (fileErr) return { ok: false, error: fileErr };
|
|
251
|
+
return { ok: true, result };
|
|
252
|
+
},
|
|
253
|
+
|
|
254
|
+
exporters: (index, p) => {
|
|
255
|
+
const err = requireFile(p.file);
|
|
256
|
+
if (err) return { ok: false, error: err };
|
|
257
|
+
const result = index.exporters(p.file);
|
|
258
|
+
const fileErr = checkFileError(result, p.file);
|
|
259
|
+
if (fileErr) return { ok: false, error: fileErr };
|
|
260
|
+
return { ok: true, result };
|
|
261
|
+
},
|
|
262
|
+
|
|
263
|
+
fileExports: (index, p) => {
|
|
264
|
+
const err = requireFile(p.file);
|
|
265
|
+
if (err) return { ok: false, error: err };
|
|
266
|
+
const result = index.fileExports(p.file);
|
|
267
|
+
const fileErr = checkFileError(result, p.file);
|
|
268
|
+
if (fileErr) return { ok: false, error: fileErr };
|
|
269
|
+
return { ok: true, result };
|
|
270
|
+
},
|
|
271
|
+
|
|
272
|
+
graph: (index, p) => {
|
|
273
|
+
const err = requireFile(p.file);
|
|
274
|
+
if (err) return { ok: false, error: err };
|
|
275
|
+
const result = index.graph(p.file, {
|
|
276
|
+
direction: p.direction || 'both',
|
|
277
|
+
maxDepth: num(p.depth, 2),
|
|
278
|
+
});
|
|
279
|
+
const fileErr = checkFileError(result, p.file);
|
|
280
|
+
if (fileErr) return { ok: false, error: fileErr };
|
|
281
|
+
return { ok: true, result };
|
|
282
|
+
},
|
|
283
|
+
|
|
284
|
+
// ── Refactoring ─────────────────────────────────────────────────────
|
|
285
|
+
|
|
286
|
+
verify: (index, p) => {
|
|
287
|
+
const err = requireName(p.name);
|
|
288
|
+
if (err) return { ok: false, error: err };
|
|
289
|
+
const result = index.verify(p.name, { file: p.file });
|
|
290
|
+
return { ok: true, result };
|
|
291
|
+
},
|
|
292
|
+
|
|
293
|
+
plan: (index, p) => {
|
|
294
|
+
const err = requireName(p.name);
|
|
295
|
+
if (err) return { ok: false, error: err };
|
|
296
|
+
if (!p.addParam && !p.removeParam && !p.renameTo) {
|
|
297
|
+
return { ok: false, error: 'Plan requires an operation: add_param, remove_param, or rename_to.' };
|
|
298
|
+
}
|
|
299
|
+
const result = index.plan(p.name, {
|
|
300
|
+
addParam: p.addParam,
|
|
301
|
+
removeParam: p.removeParam,
|
|
302
|
+
renameTo: p.renameTo,
|
|
303
|
+
defaultValue: p.defaultValue,
|
|
304
|
+
file: p.file,
|
|
305
|
+
});
|
|
306
|
+
return { ok: true, result };
|
|
307
|
+
},
|
|
308
|
+
|
|
309
|
+
diffImpact: (index, p) => {
|
|
310
|
+
const result = index.diffImpact({
|
|
311
|
+
base: p.base || 'HEAD',
|
|
312
|
+
staged: p.staged || false,
|
|
313
|
+
file: p.file,
|
|
314
|
+
});
|
|
315
|
+
return { ok: true, result };
|
|
316
|
+
},
|
|
317
|
+
|
|
318
|
+
// ── Other ───────────────────────────────────────────────────────────
|
|
319
|
+
|
|
320
|
+
typedef: (index, p) => {
|
|
321
|
+
const err = requireName(p.name);
|
|
322
|
+
if (err) return { ok: false, error: err };
|
|
323
|
+
const result = index.typedef(p.name, { exact: p.exact || false });
|
|
324
|
+
return { ok: true, result };
|
|
325
|
+
},
|
|
326
|
+
|
|
327
|
+
stacktrace: (index, p) => {
|
|
328
|
+
if (!p.stack || (typeof p.stack === 'string' && !p.stack.trim())) {
|
|
329
|
+
return { ok: false, error: 'Stack trace text is required.' };
|
|
330
|
+
}
|
|
331
|
+
const result = index.parseStackTrace(p.stack);
|
|
332
|
+
return { ok: true, result };
|
|
333
|
+
},
|
|
334
|
+
|
|
335
|
+
api: (index, p) => {
|
|
336
|
+
const result = index.api(p.file);
|
|
337
|
+
if (p.file) {
|
|
338
|
+
const fileErr = checkFileError(result, p.file);
|
|
339
|
+
if (fileErr) return { ok: false, error: fileErr };
|
|
340
|
+
}
|
|
341
|
+
return { ok: true, result };
|
|
342
|
+
},
|
|
343
|
+
|
|
344
|
+
stats: (index, p) => {
|
|
345
|
+
const result = index.getStats({
|
|
346
|
+
functions: p.functions || false,
|
|
347
|
+
});
|
|
348
|
+
return { ok: true, result };
|
|
349
|
+
},
|
|
350
|
+
};
|
|
351
|
+
|
|
352
|
+
// ============================================================================
|
|
353
|
+
// MAIN DISPATCH
|
|
354
|
+
// ============================================================================
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* Execute a UCN command.
|
|
358
|
+
*
|
|
359
|
+
* @param {object} index - Built ProjectIndex instance
|
|
360
|
+
* @param {string} command - Canonical command name (camelCase)
|
|
361
|
+
* @param {object} params - Normalized parameters
|
|
362
|
+
* @returns {{ ok: boolean, result?: any, error?: string }}
|
|
363
|
+
*/
|
|
364
|
+
function execute(index, command, params = {}) {
|
|
365
|
+
const handler = HANDLERS[command];
|
|
366
|
+
if (!handler) {
|
|
367
|
+
return { ok: false, error: `Unknown command: ${command}` };
|
|
368
|
+
}
|
|
369
|
+
try {
|
|
370
|
+
return handler(index, params);
|
|
371
|
+
} catch (e) {
|
|
372
|
+
return { ok: false, error: e.message };
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
module.exports = { execute, ADAPTER_ONLY_COMMANDS };
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared expand cache for context → expand workflow.
|
|
3
|
+
*
|
|
4
|
+
* Used by MCP server (in-memory, multi-symbol) and interactive mode (in-memory, session-scoped).
|
|
5
|
+
* CLI one-shot mode uses file-based persistence instead (separate processes).
|
|
6
|
+
*
|
|
7
|
+
* LRU eviction keeps memory bounded. Cache entries are keyed by project:symbol:file
|
|
8
|
+
* to support multiple concurrent context results per project.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
'use strict';
|
|
12
|
+
|
|
13
|
+
const fs = require('fs');
|
|
14
|
+
const path = require('path');
|
|
15
|
+
|
|
16
|
+
class ExpandCache {
|
|
17
|
+
/**
|
|
18
|
+
* @param {object} [opts]
|
|
19
|
+
* @param {number} [opts.maxSize=50] - Maximum cached context results
|
|
20
|
+
*/
|
|
21
|
+
constructor({ maxSize = 50 } = {}) {
|
|
22
|
+
this.entries = new Map(); // cacheKey → { items, root, symbolName, usedAt }
|
|
23
|
+
this.lastKey = new Map(); // projectRoot → most recent cacheKey
|
|
24
|
+
this.maxSize = maxSize;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Save expandable items from a context result.
|
|
29
|
+
*
|
|
30
|
+
* @param {string} root - Project root path
|
|
31
|
+
* @param {string} name - Symbol name from the context call
|
|
32
|
+
* @param {string} [file] - Optional file filter used in the context call
|
|
33
|
+
* @param {Array} items - Expandable items from formatContext()
|
|
34
|
+
*/
|
|
35
|
+
save(root, name, file, items) {
|
|
36
|
+
if (!items || items.length === 0) return;
|
|
37
|
+
|
|
38
|
+
const key = `${root}:${name}:${file || ''}`;
|
|
39
|
+
|
|
40
|
+
// LRU eviction if at capacity
|
|
41
|
+
if (this.entries.size >= this.maxSize && !this.entries.has(key)) {
|
|
42
|
+
let oldestKey = null;
|
|
43
|
+
let oldestTime = Infinity;
|
|
44
|
+
for (const [k, v] of this.entries) {
|
|
45
|
+
if ((v.usedAt || 0) < oldestTime) {
|
|
46
|
+
oldestTime = v.usedAt || 0;
|
|
47
|
+
oldestKey = k;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
if (oldestKey) this.entries.delete(oldestKey);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
this.entries.set(key, { items, root, symbolName: name, usedAt: Date.now() });
|
|
54
|
+
this.lastKey.set(root, key);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Look up an expandable item by number.
|
|
59
|
+
* Tries the most recent context for the project first, then falls back to all entries.
|
|
60
|
+
*
|
|
61
|
+
* @param {string} root - Project root path
|
|
62
|
+
* @param {number} itemNum - Item number to find
|
|
63
|
+
* @returns {{ match: object|null, itemCount: number, symbolName: string|null }}
|
|
64
|
+
*/
|
|
65
|
+
lookup(root, itemNum) {
|
|
66
|
+
// Try most recent context for this project
|
|
67
|
+
const recentKey = this.lastKey.get(root);
|
|
68
|
+
const recent = recentKey ? this.entries.get(recentKey) : null;
|
|
69
|
+
|
|
70
|
+
if (recent && recent.items) {
|
|
71
|
+
recent.usedAt = Date.now();
|
|
72
|
+
const match = recent.items.find(i => i.num === itemNum);
|
|
73
|
+
if (match) {
|
|
74
|
+
return { match, itemCount: recent.items.length, symbolName: recent.symbolName };
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Fallback: scan all entries for this project
|
|
79
|
+
let maxCount = recent?.items?.length || 0;
|
|
80
|
+
for (const [, cached] of this.entries) {
|
|
81
|
+
if (cached.root === root && cached.items) {
|
|
82
|
+
cached.usedAt = Date.now();
|
|
83
|
+
maxCount = Math.max(maxCount, cached.items.length);
|
|
84
|
+
const found = cached.items.find(i => i.num === itemNum);
|
|
85
|
+
if (found) {
|
|
86
|
+
return { match: found, itemCount: maxCount, symbolName: cached.symbolName };
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return {
|
|
92
|
+
match: null,
|
|
93
|
+
itemCount: maxCount,
|
|
94
|
+
symbolName: recent?.symbolName || null
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Clear all expand cache entries for a project root.
|
|
100
|
+
* Called when the project index is rebuilt (entries become stale).
|
|
101
|
+
*
|
|
102
|
+
* @param {string} root - Project root path
|
|
103
|
+
*/
|
|
104
|
+
clearForRoot(root) {
|
|
105
|
+
for (const [key, cached] of this.entries) {
|
|
106
|
+
if (cached.root === root) this.entries.delete(key);
|
|
107
|
+
}
|
|
108
|
+
this.lastKey.delete(root);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Number of cached entries. */
|
|
112
|
+
get size() {
|
|
113
|
+
return this.entries.size;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Render an expand match to text lines.
|
|
119
|
+
* Shared by MCP and interactive mode to avoid duplicated rendering logic.
|
|
120
|
+
*
|
|
121
|
+
* @param {object} match - Expandable item from formatContext()
|
|
122
|
+
* @param {string} root - Project root path
|
|
123
|
+
* @param {object} [opts]
|
|
124
|
+
* @param {boolean} [opts.validateRoot=false] - Validate file is within project root (MCP security)
|
|
125
|
+
* @returns {{ ok: boolean, text?: string, error?: string }}
|
|
126
|
+
*/
|
|
127
|
+
function renderExpandItem(match, root, { validateRoot = false } = {}) {
|
|
128
|
+
const filePath = match.file || (root && match.relativePath ? path.join(root, match.relativePath) : null);
|
|
129
|
+
if (!filePath || !fs.existsSync(filePath)) {
|
|
130
|
+
return { ok: false, error: `Cannot locate file for ${match.name}` };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (validateRoot && root) {
|
|
134
|
+
try {
|
|
135
|
+
const realPath = fs.realpathSync(filePath);
|
|
136
|
+
const realRoot = fs.realpathSync(root);
|
|
137
|
+
if (realPath !== realRoot && !realPath.startsWith(realRoot + path.sep)) {
|
|
138
|
+
return { ok: false, error: `File is outside project root: ${match.name}` };
|
|
139
|
+
}
|
|
140
|
+
} catch (e) {
|
|
141
|
+
return { ok: false, error: `Cannot resolve file path for ${match.name}` };
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
146
|
+
const fileLines = content.split('\n');
|
|
147
|
+
const startLine = match.startLine || match.line || 1;
|
|
148
|
+
const endLine = match.endLine || startLine + 20;
|
|
149
|
+
|
|
150
|
+
const lines = [];
|
|
151
|
+
lines.push(`[${match.num}] ${match.name} (${match.type})`);
|
|
152
|
+
lines.push(`${match.relativePath}:${startLine}-${endLine}`);
|
|
153
|
+
lines.push('\u2550'.repeat(60));
|
|
154
|
+
|
|
155
|
+
for (let i = startLine - 1; i < Math.min(endLine, fileLines.length); i++) {
|
|
156
|
+
lines.push(fileLines[i]);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return { ok: true, text: lines.join('\n') };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
module.exports = { ExpandCache, renderExpandItem };
|
package/core/project.js
CHANGED
|
@@ -12,7 +12,7 @@ const { execSync, execFileSync } = require('child_process');
|
|
|
12
12
|
const { expandGlob, findProjectRoot, detectProjectPattern, isTestFile, parseGitignore, DEFAULT_IGNORES } = require('./discovery');
|
|
13
13
|
const { extractImports, extractExports, resolveImport } = require('./imports');
|
|
14
14
|
const { parse, parseFile, cleanHtmlScriptTags } = require('./parser');
|
|
15
|
-
const { detectLanguage, getParser, getLanguageModule,
|
|
15
|
+
const { detectLanguage, getParser, getLanguageModule, safeParse } = require('../languages');
|
|
16
16
|
const { getTokenTypeAtPosition } = require('../languages/utils');
|
|
17
17
|
|
|
18
18
|
// Read UCN version for cache invalidation
|
|
@@ -364,6 +364,9 @@ class ProjectIndex {
|
|
|
364
364
|
const seenModules = new Set();
|
|
365
365
|
|
|
366
366
|
for (const importModule of fileEntry.imports) {
|
|
367
|
+
// Skip null modules (e.g., dynamic include! macros in Rust)
|
|
368
|
+
if (!importModule) continue;
|
|
369
|
+
|
|
367
370
|
// Deduplicate: same module imported multiple times in one file
|
|
368
371
|
// (e.g., lazy imports inside different functions)
|
|
369
372
|
if (seenModules.has(importModule)) continue;
|
|
@@ -1510,7 +1513,7 @@ class ProjectIndex {
|
|
|
1510
1513
|
return false;
|
|
1511
1514
|
}
|
|
1512
1515
|
|
|
1513
|
-
const tree = parser
|
|
1516
|
+
const tree = safeParse(parser, content);
|
|
1514
1517
|
|
|
1515
1518
|
// Find all occurrences of name in the line
|
|
1516
1519
|
const nameRegex = new RegExp('(?<![a-zA-Z0-9_$])' + escapeRegExp(name) + '(?![a-zA-Z0-9_$])', 'g');
|
|
@@ -2116,7 +2119,7 @@ class ProjectIndex {
|
|
|
2116
2119
|
return false;
|
|
2117
2120
|
}
|
|
2118
2121
|
|
|
2119
|
-
const tree = parser
|
|
2122
|
+
const tree = safeParse(parser, content);
|
|
2120
2123
|
const tokenType = getTokenTypeAtPosition(tree.rootNode, lineNum, column);
|
|
2121
2124
|
return tokenType === 'comment' || tokenType === 'string';
|
|
2122
2125
|
} catch (e) {
|
|
@@ -2788,12 +2791,12 @@ class ProjectIndex {
|
|
|
2788
2791
|
if (blocks.length === 0 && !htmlModule.extractEventHandlerCalls) continue;
|
|
2789
2792
|
if (blocks.length > 0) {
|
|
2790
2793
|
const virtualJS = htmlModule.buildVirtualJSContent(content, blocks);
|
|
2791
|
-
tree = jsParser
|
|
2794
|
+
tree = safeParse(jsParser, virtualJS);
|
|
2792
2795
|
}
|
|
2793
2796
|
} else {
|
|
2794
2797
|
const parser = getParser(language);
|
|
2795
2798
|
if (!parser) continue;
|
|
2796
|
-
tree = parser
|
|
2799
|
+
tree = safeParse(parser, content);
|
|
2797
2800
|
}
|
|
2798
2801
|
|
|
2799
2802
|
// Collect all identifiers from this file in one pass
|
|
@@ -5005,7 +5008,7 @@ class ProjectIndex {
|
|
|
5005
5008
|
|
|
5006
5009
|
const parser = getParser(language);
|
|
5007
5010
|
const content = this._readFile(filePath);
|
|
5008
|
-
const tree = parser
|
|
5011
|
+
const tree = safeParse(parser, content);
|
|
5009
5012
|
|
|
5010
5013
|
const row = lineNum - 1;
|
|
5011
5014
|
const node = tree.rootNode.descendantForPosition({ row, column: 0 });
|
|
@@ -5067,6 +5070,11 @@ class ProjectIndex {
|
|
|
5067
5070
|
try {
|
|
5068
5071
|
const { base = 'HEAD', staged = false, file } = options;
|
|
5069
5072
|
|
|
5073
|
+
// Validate base ref format to prevent argument injection
|
|
5074
|
+
if (base && !/^[a-zA-Z0-9._\-~\/^@{}:]+$/.test(base)) {
|
|
5075
|
+
throw new Error(`Invalid git ref format: ${base}`);
|
|
5076
|
+
}
|
|
5077
|
+
|
|
5070
5078
|
// Verify git repo
|
|
5071
5079
|
let gitRoot;
|
|
5072
5080
|
try {
|