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/README.md +192 -463
- package/cli/index.js +285 -1054
- package/core/cache.js +193 -0
- package/core/callers.js +817 -0
- package/core/deadcode.js +320 -0
- package/core/discovery.js +1 -1
- package/core/execute.js +207 -10
- package/core/expand-cache.js +16 -5
- package/core/imports.js +21 -15
- package/core/output.js +370 -35
- package/core/project.js +365 -2272
- package/core/shared.js +11 -1
- package/core/stacktrace.js +313 -0
- package/core/verify.js +533 -0
- package/languages/go.js +57 -21
- package/languages/html.js +14 -3
- package/languages/java.js +4 -2
- package/languages/javascript.js +36 -9
- package/languages/rust.js +49 -17
- package/mcp/server.js +39 -172
- package/package.json +1 -1
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
|
|
299
|
-
|
|
300
|
-
|
|
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
|
|
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 (
|
|
68
|
+
if (preParams.includes(kw + ' ') && !modifiers.includes(kw)) {
|
|
67
69
|
modifiers.push(kw);
|
|
68
70
|
}
|
|
69
71
|
}
|
package/languages/javascript.js
CHANGED
|
@@ -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
|
|
84
|
-
if (firstLine
|
|
85
|
-
if (firstLine
|
|
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]
|
|
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
|
|
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)
|
|
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
|
|
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:
|
|
553
|
-
...(typeName && { receiver: typeName }), //
|
|
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
|
-
|
|
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 === '
|
|
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
|
|
804
|
-
|
|
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 === '
|
|
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
|
|
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
|
|
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
|
|
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 (
|
|
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
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
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
|
-
|
|
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
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
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(
|
|
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
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
const
|
|
627
|
-
|
|
628
|
-
|
|
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
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
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.
|
|
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",
|