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.
@@ -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, PARSE_OPTIONS, safeParse } = require('../languages');
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.parse(content, undefined, PARSE_OPTIONS);
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.parse(content, undefined, PARSE_OPTIONS);
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.parse(virtualJS, undefined, PARSE_OPTIONS);
2791
+ tree = safeParse(jsParser, virtualJS);
2792
2792
  }
2793
2793
  } else {
2794
2794
  const parser = getParser(language);
2795
2795
  if (!parser) continue;
2796
- tree = parser.parse(content, undefined, PARSE_OPTIONS);
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.parse(content, undefined, PARSE_OPTIONS);
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 {