ucn 3.7.18 → 3.7.19
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 +11 -6
- package/core/registry.js +166 -0
- 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
|
|
@@ -1510,7 +1510,7 @@ class ProjectIndex {
|
|
|
1510
1510
|
return false;
|
|
1511
1511
|
}
|
|
1512
1512
|
|
|
1513
|
-
const tree = parser
|
|
1513
|
+
const tree = safeParse(parser, content);
|
|
1514
1514
|
|
|
1515
1515
|
// Find all occurrences of name in the line
|
|
1516
1516
|
const nameRegex = new RegExp('(?<![a-zA-Z0-9_$])' + escapeRegExp(name) + '(?![a-zA-Z0-9_$])', 'g');
|
|
@@ -2116,7 +2116,7 @@ class ProjectIndex {
|
|
|
2116
2116
|
return false;
|
|
2117
2117
|
}
|
|
2118
2118
|
|
|
2119
|
-
const tree = parser
|
|
2119
|
+
const tree = safeParse(parser, content);
|
|
2120
2120
|
const tokenType = getTokenTypeAtPosition(tree.rootNode, lineNum, column);
|
|
2121
2121
|
return tokenType === 'comment' || tokenType === 'string';
|
|
2122
2122
|
} catch (e) {
|
|
@@ -2788,12 +2788,12 @@ class ProjectIndex {
|
|
|
2788
2788
|
if (blocks.length === 0 && !htmlModule.extractEventHandlerCalls) continue;
|
|
2789
2789
|
if (blocks.length > 0) {
|
|
2790
2790
|
const virtualJS = htmlModule.buildVirtualJSContent(content, blocks);
|
|
2791
|
-
tree = jsParser
|
|
2791
|
+
tree = safeParse(jsParser, virtualJS);
|
|
2792
2792
|
}
|
|
2793
2793
|
} else {
|
|
2794
2794
|
const parser = getParser(language);
|
|
2795
2795
|
if (!parser) continue;
|
|
2796
|
-
tree = parser
|
|
2796
|
+
tree = safeParse(parser, content);
|
|
2797
2797
|
}
|
|
2798
2798
|
|
|
2799
2799
|
// Collect all identifiers from this file in one pass
|
|
@@ -5005,7 +5005,7 @@ class ProjectIndex {
|
|
|
5005
5005
|
|
|
5006
5006
|
const parser = getParser(language);
|
|
5007
5007
|
const content = this._readFile(filePath);
|
|
5008
|
-
const tree = parser
|
|
5008
|
+
const tree = safeParse(parser, content);
|
|
5009
5009
|
|
|
5010
5010
|
const row = lineNum - 1;
|
|
5011
5011
|
const node = tree.rootNode.descendantForPosition({ row, column: 0 });
|
|
@@ -5067,6 +5067,11 @@ class ProjectIndex {
|
|
|
5067
5067
|
try {
|
|
5068
5068
|
const { base = 'HEAD', staged = false, file } = options;
|
|
5069
5069
|
|
|
5070
|
+
// Validate base ref format to prevent argument injection
|
|
5071
|
+
if (base && !/^[a-zA-Z0-9._\-~\/^@{}:]+$/.test(base)) {
|
|
5072
|
+
throw new Error(`Invalid git ref format: ${base}`);
|
|
5073
|
+
}
|
|
5074
|
+
|
|
5070
5075
|
// Verify git repo
|
|
5071
5076
|
let gitRoot;
|
|
5072
5077
|
try {
|