ucn 3.7.24 → 3.7.26

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/languages/html.js CHANGED
@@ -190,6 +190,7 @@ function extractEventHandlerCalls(htmlContent, htmlParser) {
190
190
  if (!valueText) return;
191
191
 
192
192
  const line = nameNode.startPosition.row + 1; // 1-indexed
193
+ const column = nameNode.startPosition.column;
193
194
 
194
195
  // Extract standalone function calls (not method calls like obj.method())
195
196
  const regex = /([a-zA-Z_$][a-zA-Z0-9_$]*)\s*\(/g;
@@ -203,6 +204,7 @@ function extractEventHandlerCalls(htmlContent, htmlParser) {
203
204
  calls.push({
204
205
  name: fnName,
205
206
  line,
207
+ column,
206
208
  isMethod: false,
207
209
  enclosingFunction: null,
208
210
  uncertain: false,
@@ -295,9 +297,18 @@ function findExportsInCode(code, parser) {
295
297
  }
296
298
 
297
299
  function findUsagesInCode(code, name, parser) {
298
- const result = extractJS(code, parser);
299
- if (!result) return [];
300
- return result.jsModule.findUsagesInCode(result.virtualJS, name, result.jsParser);
300
+ const jsUsages = (() => {
301
+ const result = extractJS(code, parser);
302
+ if (!result) return [];
303
+ return result.jsModule.findUsagesInCode(result.virtualJS, name, result.jsParser);
304
+ })();
305
+ // Also check event handler attributes (onclick="foo()", onload="bar()")
306
+ const handlerCalls = extractEventHandlerCalls(code, parser);
307
+ const handlerUsages = handlerCalls
308
+ .filter(c => c.name === name)
309
+ .map(c => ({ line: c.line, column: c.column || 0, usageType: 'call' }));
310
+ if (handlerUsages.length === 0) return jsUsages;
311
+ return jsUsages.concat(handlerUsages);
301
312
  }
302
313
 
303
314
  module.exports = {
package/languages/java.js CHANGED
@@ -58,12 +58,14 @@ function extractModifiers(node) {
58
58
  }
59
59
  }
60
60
 
61
- // Also check first line for modifiers
61
+ // Also check text before parameter list for modifiers (avoid matching keywords in params)
62
62
  const text = node.text;
63
63
  const firstLine = text.split('\n')[0];
64
+ const parenIdx = firstLine.indexOf('(');
65
+ const preParams = parenIdx >= 0 ? firstLine.substring(0, parenIdx) : firstLine;
64
66
  const keywords = ['public', 'private', 'protected', 'static', 'final', 'abstract', 'synchronized', 'native', 'default'];
65
67
  for (const kw of keywords) {
66
- if (firstLine.includes(kw + ' ') && !modifiers.includes(kw)) {
68
+ if (preParams.includes(kw + ' ') && !modifiers.includes(kw)) {
67
69
  modifiers.push(kw);
68
70
  }
69
71
  }
@@ -80,9 +80,9 @@ function getAssignmentName(leftNode) {
80
80
  function extractModifiers(text) {
81
81
  const mods = [];
82
82
  const firstLine = text.split('\n')[0];
83
- if (firstLine.includes('export ')) mods.push('export');
84
- if (firstLine.includes('async ')) mods.push('async');
85
- if (firstLine.includes('default ')) mods.push('default');
83
+ if (/\bexport\b/.test(firstLine)) mods.push('export');
84
+ if (/\basync\b/.test(firstLine)) mods.push('async');
85
+ if (/\bdefault\b/.test(firstLine)) mods.push('default');
86
86
  return mods;
87
87
  }
88
88
 
@@ -462,6 +462,28 @@ function extractExtends(classNode) {
462
462
  return null;
463
463
  }
464
464
 
465
+ /**
466
+ * Split comma-separated type names, respecting angle bracket nesting.
467
+ * "Bar<A, B>, Baz" → ["Bar<A, B>", "Baz"]
468
+ */
469
+ function splitTypeList(text) {
470
+ const result = [];
471
+ let depth = 0;
472
+ let current = '';
473
+ for (const ch of text) {
474
+ if (ch === '<') depth++;
475
+ else if (ch === '>') depth--;
476
+ if (ch === ',' && depth === 0) {
477
+ result.push(current.trim());
478
+ current = '';
479
+ } else {
480
+ current += ch;
481
+ }
482
+ }
483
+ if (current.trim()) result.push(current.trim());
484
+ return result;
485
+ }
486
+
465
487
  /**
466
488
  * Extract implements clause from class
467
489
  */
@@ -472,7 +494,7 @@ function extractImplements(classNode) {
472
494
  if (child.type === 'class_heritage') {
473
495
  const implMatch = child.text.match(/implements\s+([^{]+)/);
474
496
  if (implMatch) {
475
- const names = implMatch[1].split(',').map(n => n.trim());
497
+ const names = splitTypeList(implMatch[1]);
476
498
  implements_.push(...names);
477
499
  }
478
500
  }
@@ -488,9 +510,9 @@ function extractInterfaceExtends(interfaceNode) {
488
510
  for (let i = 0; i < interfaceNode.namedChildCount; i++) {
489
511
  const child = interfaceNode.namedChild(i);
490
512
  if (child.type === 'extends_type_clause') {
491
- // Parse comma-separated type names
513
+ // Parse comma-separated type names respecting generics
492
514
  const text = child.text.replace(/^extends\s+/, '');
493
- const names = text.split(',').map(n => n.trim());
515
+ const names = splitTypeList(text);
494
516
  extends_.push(...names);
495
517
  }
496
518
  }
@@ -1532,18 +1554,23 @@ function findExportsInCode(code, parser) {
1532
1554
  }
1533
1555
  }
1534
1556
 
1535
- // Named exports: export function/class/const
1557
+ // Named/default exports: export function/class/const, export default function/class
1558
+ // Check if this is a default export by looking for the 'default' token
1559
+ let isDefaultExport = false;
1560
+ for (let ci = 0; ci < node.childCount; ci++) {
1561
+ if (node.child(ci).type === 'default') { isDefaultExport = true; break; }
1562
+ }
1536
1563
  for (let i = 0; i < node.namedChildCount; i++) {
1537
1564
  const child = node.namedChild(i);
1538
1565
  if (child.type === 'function_declaration' || child.type === 'generator_function_declaration') {
1539
1566
  const nameNode = child.childForFieldName('name');
1540
1567
  if (nameNode) {
1541
- exports.push({ name: nameNode.text, type: 'named', line });
1568
+ exports.push({ name: nameNode.text, type: isDefaultExport ? 'default' : 'named', line });
1542
1569
  }
1543
1570
  } else if (child.type === 'class_declaration') {
1544
1571
  const nameNode = child.childForFieldName('name');
1545
1572
  if (nameNode) {
1546
- exports.push({ name: nameNode.text, type: 'named', line });
1573
+ exports.push({ name: nameNode.text, type: isDefaultExport ? 'default' : 'named', line });
1547
1574
  }
1548
1575
  } else if (child.type === 'type_alias_declaration') {
1549
1576
  // export type X = ...
package/languages/rust.js CHANGED
@@ -69,7 +69,7 @@ function extractAttributes(node, code) {
69
69
  const startLine = node.startPosition.row;
70
70
  for (let i = startLine - 1; i >= 0 && i >= startLine - 5; i--) {
71
71
  const line = lines[i]?.trim();
72
- if (!line) continue;
72
+ if (!line) break;
73
73
  if (line.startsWith('#[')) {
74
74
  // Extract attribute name (e.g., #[test] -> test, #[tokio::main] -> tokio::main)
75
75
  const match = line.match(/#\[([^\]]+)\]/);
@@ -103,16 +103,16 @@ function findFunctions(code, parser) {
103
103
  if (processedRanges.has(rangeKey)) return true;
104
104
  processedRanges.add(rangeKey);
105
105
 
106
- // Skip functions inside impl blocks (they're extracted as impl members)
106
+ // Skip functions inside impl/trait blocks (they're extracted as members)
107
107
  let parent = node.parent;
108
- if (parent && (parent.type === 'impl_item' || parent.type === 'declaration_list')) {
109
- // declaration_list is the body of an impl block
108
+ if (parent && (parent.type === 'impl_item' || parent.type === 'trait_item' || parent.type === 'declaration_list')) {
109
+ // declaration_list is the body of an impl/trait block
110
110
  const grandparent = parent.parent;
111
- if (grandparent && grandparent.type === 'impl_item') {
112
- return true; // Skip - this is an impl method
111
+ if (grandparent && (grandparent.type === 'impl_item' || grandparent.type === 'trait_item')) {
112
+ return true; // Skip - this is an impl/trait method
113
113
  }
114
- if (parent.type === 'impl_item') {
115
- return true; // Skip - this is an impl method
114
+ if (parent.type === 'impl_item' || parent.type === 'trait_item') {
115
+ return true; // Skip - this is an impl/trait method
116
116
  }
117
117
  }
118
118
 
@@ -549,8 +549,8 @@ function extractImplMembers(implNode, code, typeName) {
549
549
  endLine,
550
550
  memberType: visibility ? 'public' : 'method',
551
551
  isAsync: firstLine.includes('async '),
552
- isMethod: true, // Mark as method for context() lookups
553
- ...(typeName && { receiver: typeName }), // Track which type this impl is for
552
+ isMethod: hasSelf, // Only true methods (with self) — associated functions are false
553
+ ...(typeName && { receiver: typeName }), // All impl members get receiver for findMethodsForType
554
554
  ...(returnType && { returnType }),
555
555
  ...(docstring && { docstring })
556
556
  });
@@ -666,11 +666,16 @@ function findCallsInCode(code, parser) {
666
666
  });
667
667
  }
668
668
 
669
- // Handle function calls: foo(), obj.method(), Type::func()
669
+ // Handle function calls: foo(), obj.method(), Type::func(), foo::<T>()
670
670
  if (node.type === 'call_expression') {
671
- const funcNode = node.childForFieldName('function');
671
+ let funcNode = node.childForFieldName('function');
672
672
  if (!funcNode) return true;
673
673
 
674
+ // Unwrap turbofish: parse::<i32>() has generic_function wrapping the actual function
675
+ if (funcNode.type === 'generic_function') {
676
+ funcNode = funcNode.childForFieldName('function') || funcNode;
677
+ }
678
+
674
679
  const enclosingFunction = getCurrentEnclosingFunction();
675
680
 
676
681
  if (funcNode.type === 'identifier') {
@@ -763,7 +768,22 @@ function findImportsInCode(code, parser) {
763
768
  for (let i = 0; i < node.namedChildCount; i++) {
764
769
  const child = node.namedChild(i);
765
770
 
766
- if (child.type === 'scoped_identifier' || child.type === 'identifier') {
771
+ if (child.type === 'use_as_clause') {
772
+ // use foo::bar as baz
773
+ const pathNode = child.namedChild(0); // the original path
774
+ const aliasNode = child.childForFieldName('alias');
775
+ if (pathNode) {
776
+ const originalPath = pathNode.text;
777
+ const alias = aliasNode ? aliasNode.text : originalPath.split('::').pop();
778
+ imports.push({
779
+ module: originalPath,
780
+ names: [alias],
781
+ type: 'use',
782
+ dynamic: false,
783
+ line
784
+ });
785
+ }
786
+ } else if (child.type === 'scoped_identifier' || child.type === 'identifier') {
767
787
  // use std::io or use foo
768
788
  const path = child.text;
769
789
  const segments = path.split('::');
@@ -800,8 +820,11 @@ function findImportsInCode(code, parser) {
800
820
  if (item.type === 'identifier') {
801
821
  names.push(item.text);
802
822
  } else if (item.type === 'use_as_clause') {
803
- const nameNode = item.namedChild(0);
804
- if (nameNode) names.push(nameNode.text);
823
+ const aliasNode = item.childForFieldName('alias');
824
+ const pathItem = item.namedChild(0);
825
+ names.push(aliasNode ? aliasNode.text : (pathItem ? pathItem.text : item.text));
826
+ } else if (item.type === 'scoped_identifier') {
827
+ names.push(item.text);
805
828
  }
806
829
  }
807
830
  imports.push({
@@ -1024,10 +1047,19 @@ function findUsagesInCode(code, name, parser) {
1024
1047
  let usageType = 'reference';
1025
1048
 
1026
1049
  if (parent) {
1027
- // Import: use path::name
1050
+ // Import: use path::name (walk up scoped_identifier chain for deeply nested paths)
1028
1051
  if (parent.type === 'use_declaration' ||
1029
1052
  parent.type === 'use_as_clause' ||
1030
- parent.type === 'scoped_identifier' && parent.parent?.type === 'use_declaration') {
1053
+ parent.type === 'use_list' ||
1054
+ (parent.type === 'scoped_identifier' && (() => {
1055
+ let p = parent;
1056
+ while (p) {
1057
+ if (p.type === 'use_declaration' || p.type === 'use_as_clause') return true;
1058
+ if (p.type !== 'scoped_identifier' && p.type !== 'scoped_use_list' && p.type !== 'use_list') return false;
1059
+ p = p.parent;
1060
+ }
1061
+ return false;
1062
+ })())) {
1031
1063
  usageType = 'import';
1032
1064
  }
1033
1065
  // Call: name()
package/mcp/server.js CHANGED
@@ -33,10 +33,9 @@ try {
33
33
  const { ProjectIndex } = require('../core/project');
34
34
  const { findProjectRoot } = require('../core/discovery');
35
35
  const output = require('../core/output');
36
- const { pickBestDefinition } = require('../core/shared');
37
36
  const { getMcpCommandEnum, normalizeParams } = require('../core/registry');
38
37
  const { execute } = require('../core/execute');
39
- const { ExpandCache, renderExpandItem } = require('../core/expand-cache');
38
+ const { ExpandCache } = require('../core/expand-cache');
40
39
 
41
40
  // ============================================================================
42
41
  // INDEX CACHE
@@ -73,7 +72,7 @@ function getIndex(projectDir) {
73
72
  }
74
73
 
75
74
  // LRU eviction
76
- if (indexCache.size >= MAX_CACHE_SIZE) {
75
+ if (indexCache.size >= MAX_CACHE_SIZE && !indexCache.has(root)) {
77
76
  let oldestKey = null;
78
77
  let oldestTime = Infinity;
79
78
  for (const [key, val] of indexCache) {
@@ -326,7 +325,7 @@ server.registerTool(
326
325
  case 'example': {
327
326
  const index = getIndex(project_dir);
328
327
  const { ok, result, error } = execute(index, 'example', { name });
329
- if (!ok) return toolError(error);
328
+ if (!ok) return toolResult(error);
330
329
  if (!result) return toolResult(`No usage examples found for "${name}".`);
331
330
  return toolResult(output.formatExample(result, name));
332
331
  }
@@ -334,7 +333,7 @@ server.registerTool(
334
333
  case 'related': {
335
334
  const index = getIndex(project_dir);
336
335
  const { ok, result, error } = execute(index, 'related', { name, file, top, all });
337
- if (!ok) return toolError(error);
336
+ if (!ok) return toolResult(error);
338
337
  if (!result) return toolResult(`Symbol "${name}" not found.`);
339
338
  return toolResult(output.formatRelated(result, {
340
339
  showAll: all || false, top,
@@ -487,179 +486,52 @@ server.registerTool(
487
486
  return toolResult(output.formatStats(result, { top: top || 0 }));
488
487
  }
489
488
 
490
- // ── Extracting Code (adapter-specific) ──────────────────────
489
+ // ── Extracting Code (via execute) ────────────────────────────
491
490
 
492
491
  case 'fn': {
493
492
  const err = requireName(name);
494
493
  if (err) return err;
495
494
  const index = getIndex(project_dir);
496
-
497
- // Support comma-separated names for bulk extraction
498
- const fnNames = name.includes(',') ? name.split(',').map(n => n.trim()).filter(Boolean) : [name];
499
- const parts = [];
500
-
501
- for (const fnName of fnNames) {
502
- const matches = index.find(fnName, { file }).filter(m => m.type === 'function' || m.params !== undefined);
503
-
504
- if (matches.length === 0) {
505
- parts.push(`Function "${fnName}" not found.`);
506
- continue;
507
- }
508
-
509
- // Show all definitions when all=true and multiple matches
510
- if (matches.length > 1 && !file && all) {
511
- for (const m of matches) {
512
- const mPathCheck = resolveAndValidatePath(index, m.relativePath || path.relative(index.root, m.file));
513
- if (typeof mPathCheck !== 'string') return mPathCheck;
514
- const mCode = fs.readFileSync(m.file, 'utf-8');
515
- const mLines = mCode.split('\n');
516
- const mFnCode = mLines.slice(m.startLine - 1, m.endLine).join('\n');
517
- parts.push(output.formatFn(m, mFnCode));
518
- }
519
- continue;
520
- }
521
-
522
- const match = matches.length > 1 ? pickBestDefinition(matches) : matches[0];
523
- const fnPathCheck = resolveAndValidatePath(index, match.relativePath || path.relative(index.root, match.file));
524
- if (typeof fnPathCheck !== 'string') return fnPathCheck;
525
- const code = fs.readFileSync(match.file, 'utf-8');
526
- const codeLines = code.split('\n');
527
- const fnCode = codeLines.slice(match.startLine - 1, match.endLine).join('\n');
528
-
529
- let note = '';
530
- if (matches.length > 1 && !file) {
531
- note = `Note: Found ${matches.length} definitions for "${fnName}". Showing ${match.relativePath}:${match.startLine}. Use file parameter or all=true to show all.\n`;
532
- }
533
- parts.push(note + output.formatFn(match, fnCode));
495
+ const ep = normalizeParams({ name, file, all });
496
+ const { ok, result, error } = execute(index, 'fn', ep);
497
+ if (!ok) return toolError(error);
498
+ // MCP path security: validate all result files are within project root
499
+ for (const entry of result.entries) {
500
+ const check = resolveAndValidatePath(index, entry.match.relativePath || path.relative(index.root, entry.match.file));
501
+ if (typeof check !== 'string') return check;
534
502
  }
535
-
536
- const separator = fnNames.length > 1 ? '\n\n' + '═'.repeat(60) + '\n\n' : '\n\n';
537
- return toolResult(parts.join(separator));
503
+ const notes = result.notes.length ? result.notes.map(n => 'Note: ' + n).join('\n') + '\n\n' : '';
504
+ return toolResult(notes + output.formatFnResult(result));
538
505
  }
539
506
 
540
507
  case 'class': {
541
508
  const err = requireName(name);
542
509
  if (err) return err;
543
- const index = getIndex(project_dir);
544
- const matches = index.find(name, { file }).filter(m =>
545
- ['class', 'interface', 'type', 'enum', 'struct', 'trait'].includes(m.type)
546
- );
547
-
548
- if (matches.length === 0) {
549
- return toolResult(`Class "${name}" not found.`);
550
- }
551
-
552
- // Show all definitions when all=true and multiple matches
553
- if (matches.length > 1 && !file && all) {
554
- const allParts = [];
555
- for (const m of matches) {
556
- const mPathCheck = resolveAndValidatePath(index, m.relativePath || path.relative(index.root, m.file));
557
- if (typeof mPathCheck !== 'string') return mPathCheck;
558
- const mCode = fs.readFileSync(m.file, 'utf-8');
559
- const mLines = mCode.split('\n');
560
- const clsCode = mLines.slice(m.startLine - 1, m.endLine).join('\n');
561
- allParts.push(output.formatClass(m, clsCode));
562
- }
563
- return toolResult(allParts.join('\n\n'));
564
- }
565
-
566
- const match = matches.length > 1 ? pickBestDefinition(matches) : matches[0];
567
- // Validate file is within project root
568
- const clsPathCheck = resolveAndValidatePath(index, match.relativePath || path.relative(index.root, match.file));
569
- if (typeof clsPathCheck !== 'string') return clsPathCheck;
570
-
571
- const code = fs.readFileSync(match.file, 'utf-8');
572
- const codeLines = code.split('\n');
573
- const clsCode = codeLines.slice(match.startLine - 1, match.endLine).join('\n');
574
-
575
- let note = '';
576
- if (matches.length > 1 && !file) {
577
- note = `Note: Found ${matches.length} definitions for "${name}". Showing ${match.relativePath}:${match.startLine}. Use file parameter to disambiguate.\n\n`;
578
- }
579
-
580
510
  if (max_lines !== undefined && (!Number.isInteger(max_lines) || max_lines < 1)) {
581
511
  return toolError(`Invalid max_lines: ${max_lines}. Must be a positive integer.`);
582
512
  }
583
-
584
- const classLineCount = match.endLine - match.startLine + 1;
585
-
586
- // Large class: show summary by default, truncated source with max_lines
587
- if (classLineCount > 200 && max_lines === undefined) {
588
- const lines = [];
589
- lines.push(`${match.relativePath}:${match.startLine}`);
590
- lines.push(`${output.lineRange(match.startLine, match.endLine)} ${output.formatClassSignature(match)}`);
591
- lines.push('\u2500'.repeat(60));
592
-
593
- const methods = index.findMethodsForType(match.name);
594
- if (methods.length > 0) {
595
- lines.push(`\nMethods (${methods.length}):`);
596
- for (const m of methods) {
597
- lines.push(` ${output.formatFunctionSignature(m)} [line ${m.startLine}]`);
598
- }
599
- }
600
-
601
- lines.push(`\nClass is ${classLineCount} lines. Use max_lines param to see source, or fn command for individual methods.`);
602
- return toolResult(note + lines.join('\n'));
603
- }
604
-
605
- if (max_lines !== undefined && classLineCount > max_lines) {
606
- const truncatedCode = codeLines.slice(match.startLine - 1, match.startLine - 1 + max_lines).join('\n');
607
- const result = output.formatClass(match, truncatedCode);
608
- return toolResult(note + result + `\n\n... showing ${max_lines} of ${classLineCount} lines`);
513
+ const index = getIndex(project_dir);
514
+ const ep = normalizeParams({ name, file, all, max_lines });
515
+ const { ok, result, error } = execute(index, 'class', ep);
516
+ if (!ok) return toolResult(error); // soft error (class not found)
517
+ // MCP path security: validate all result files are within project root
518
+ for (const entry of result.entries) {
519
+ const check = resolveAndValidatePath(index, entry.match.relativePath || path.relative(index.root, entry.match.file));
520
+ if (typeof check !== 'string') return check;
609
521
  }
610
-
611
- return toolResult(note + output.formatClass(match, clsCode));
522
+ const notes = result.notes.length ? result.notes.map(n => 'Note: ' + n).join('\n') + '\n\n' : '';
523
+ return toolResult(notes + output.formatClassResult(result));
612
524
  }
613
525
 
614
526
  case 'lines': {
615
- if (!file) {
616
- return toolError('File parameter is required for lines command.');
617
- }
618
- if (!range || !range.trim()) {
619
- return toolError('Line range is required (e.g. "10-20" or "15").');
620
- }
621
527
  const index = getIndex(project_dir);
622
- const resolved = resolveAndValidatePath(index, file);
623
- if (typeof resolved !== 'string') return resolved; // toolError response
624
- const filePath = resolved;
625
-
626
- const parts = range.split('-');
627
- const start = parseInt(parts[0], 10);
628
- const end = parts.length > 1 ? parseInt(parts[1], 10) : start;
629
-
630
- if (isNaN(start) || isNaN(end)) {
631
- return toolError(`Invalid line range: "${range}". Expected format: <start>-<end> or <line>`);
632
- }
633
- if (start < 1) {
634
- return toolError(`Invalid start line: ${start}. Line numbers must be >= 1`);
635
- }
636
- if (end < 1) {
637
- return toolError(`Invalid end line: ${end}. Line numbers must be >= 1`);
638
- }
639
- if (end < start) {
640
- return toolError(`Invalid range: end line (${end}) must be >= start line (${start})`);
641
- }
642
-
643
- const content = fs.readFileSync(filePath, 'utf-8');
644
- const fileLines = content.split('\n');
645
-
646
- const startLine = start;
647
- const endLine = end;
648
-
649
- if (startLine > fileLines.length) {
650
- return toolError(`Line ${startLine} is out of bounds. File has ${fileLines.length} lines.`);
651
- }
652
-
653
- const actualEnd = Math.min(endLine, fileLines.length);
654
- const lines = [];
655
- const relPath = path.relative(index.root, filePath);
656
- lines.push(`${relPath}:${startLine}-${actualEnd}`);
657
- lines.push('\u2500'.repeat(60));
658
- for (let i = startLine - 1; i < actualEnd; i++) {
659
- lines.push(`${output.lineNum(i + 1)} | ${fileLines[i]}`);
660
- }
661
-
662
- return toolResult(lines.join('\n'));
528
+ const ep = normalizeParams({ file, range });
529
+ const { ok, result, error } = execute(index, 'lines', ep);
530
+ if (!ok) return toolError(error);
531
+ // MCP path security: validate file is within project root
532
+ const check = resolveAndValidatePath(index, result.relativePath);
533
+ if (typeof check !== 'string') return check;
534
+ return toolResult(output.formatLines(result));
663
535
  }
664
536
 
665
537
  case 'expand': {
@@ -667,19 +539,14 @@ server.registerTool(
667
539
  return toolError('Item number is required (e.g. item=1).');
668
540
  }
669
541
  const index = getIndex(project_dir);
670
- const { match, itemCount, symbolName } = expandCacheInstance.lookup(index.root, item);
671
-
672
- if (!match && itemCount === 0) {
673
- return toolError('No expandable items found. Run context command first to get numbered items.');
674
- }
675
- if (!match) {
676
- const scopeHint = symbolName ? ` (from last context for "${symbolName}")` : '';
677
- return toolError(`Item ${item} not found${scopeHint}. Available items: 1-${itemCount}`);
678
- }
679
-
680
- const rendered = renderExpandItem(match, index.root, { validateRoot: true });
681
- if (!rendered.ok) return toolError(rendered.error);
682
- return toolResult(rendered.text);
542
+ const lookup = expandCacheInstance.lookup(index.root, item);
543
+ const { ok, result, error } = execute(index, 'expand', {
544
+ match: lookup.match, itemNum: item,
545
+ itemCount: lookup.itemCount, symbolName: lookup.symbolName,
546
+ validateRoot: true
547
+ });
548
+ if (!ok) return toolError(error);
549
+ return toolResult(result.text);
683
550
  }
684
551
 
685
552
  default:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ucn",
3
- "version": "3.7.24",
3
+ "version": "3.7.26",
4
4
  "mcpName": "io.github.mleoca/ucn",
5
5
  "description": "Universal Code Navigator — AST-based call graph analysis for AI agents. Find callers, trace impact, detect dead code across JS/TS, Python, Go, Rust, Java, and HTML. CLI, MCP server, and agent skill.",
6
6
  "main": "index.js",