ucn 3.7.4 → 3.7.6
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/cli/index.js +265 -83
- package/core/discovery.js +8 -2
- package/core/output.js +158 -0
- package/core/project.js +133 -42
- package/core/shared.js +43 -0
- package/languages/go.js +61 -1
- package/languages/java.js +78 -2
- package/languages/rust.js +71 -2
- package/mcp/server.js +54 -33
- package/package.json +1 -1
- package/test/mcp-edge-cases.js +81 -0
- package/test/parser.test.js +559 -8
package/languages/go.js
CHANGED
|
@@ -183,12 +183,16 @@ function findClasses(code, parser) {
|
|
|
183
183
|
// Check if exported
|
|
184
184
|
const isExported = /^[A-Z]/.test(name);
|
|
185
185
|
|
|
186
|
+
const members = typeKind === 'struct' ? extractStructFields(typeNode, code)
|
|
187
|
+
: typeKind === 'interface' ? extractInterfaceMembers(typeNode, code)
|
|
188
|
+
: [];
|
|
189
|
+
|
|
186
190
|
types.push({
|
|
187
191
|
name,
|
|
188
192
|
startLine,
|
|
189
193
|
endLine,
|
|
190
194
|
type: typeKind,
|
|
191
|
-
members
|
|
195
|
+
members,
|
|
192
196
|
modifiers: isExported ? ['export'] : [],
|
|
193
197
|
...(docstring && { docstring }),
|
|
194
198
|
...(typeParams && { generics: typeParams })
|
|
@@ -235,6 +239,62 @@ function extractStructFields(structNode, code) {
|
|
|
235
239
|
return fields;
|
|
236
240
|
}
|
|
237
241
|
|
|
242
|
+
/**
|
|
243
|
+
* Extract interface method signatures
|
|
244
|
+
*/
|
|
245
|
+
function extractInterfaceMembers(interfaceNode, code) {
|
|
246
|
+
const members = [];
|
|
247
|
+
for (let i = 0; i < interfaceNode.namedChildCount; i++) {
|
|
248
|
+
const child = interfaceNode.namedChild(i);
|
|
249
|
+
// tree-sitter Go uses method_elem (or method_spec in older versions)
|
|
250
|
+
if (child.type === 'method_elem' || child.type === 'method_spec') {
|
|
251
|
+
const { startLine, endLine } = nodeToLocation(child, code);
|
|
252
|
+
// Name is in a field_identifier child
|
|
253
|
+
let nameText = null;
|
|
254
|
+
let paramsText = null;
|
|
255
|
+
let returnType = null;
|
|
256
|
+
for (let j = 0; j < child.namedChildCount; j++) {
|
|
257
|
+
const sub = child.namedChild(j);
|
|
258
|
+
if (sub.type === 'field_identifier' || sub.type === 'type_identifier') {
|
|
259
|
+
if (!nameText) nameText = sub.text;
|
|
260
|
+
} else if (sub.type === 'parameter_list') {
|
|
261
|
+
if (!paramsText) {
|
|
262
|
+
paramsText = sub.text.slice(1, -1); // strip parens
|
|
263
|
+
} else {
|
|
264
|
+
// Second parameter_list is the return type tuple
|
|
265
|
+
returnType = sub.text;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
// Also check childForFieldName for compatibility
|
|
270
|
+
if (!nameText) {
|
|
271
|
+
const nameNode = child.childForFieldName('name');
|
|
272
|
+
if (nameNode) nameText = nameNode.text;
|
|
273
|
+
}
|
|
274
|
+
if (!returnType) {
|
|
275
|
+
// Single return type (not a tuple) is a type_identifier
|
|
276
|
+
for (let j = 0; j < child.namedChildCount; j++) {
|
|
277
|
+
const sub = child.namedChild(j);
|
|
278
|
+
if (sub.type === 'type_identifier' && sub.text !== nameText) {
|
|
279
|
+
returnType = sub.text;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
if (nameText) {
|
|
284
|
+
members.push({
|
|
285
|
+
name: nameText,
|
|
286
|
+
startLine,
|
|
287
|
+
endLine,
|
|
288
|
+
memberType: 'method',
|
|
289
|
+
...(paramsText !== null && { params: paramsText }),
|
|
290
|
+
...(returnType && { returnType })
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
return members;
|
|
296
|
+
}
|
|
297
|
+
|
|
238
298
|
/**
|
|
239
299
|
* Find state objects (constants) in Go code
|
|
240
300
|
*/
|
package/languages/java.js
CHANGED
|
@@ -279,7 +279,7 @@ function findClasses(code, parser) {
|
|
|
279
279
|
startLine,
|
|
280
280
|
endLine,
|
|
281
281
|
type: 'interface',
|
|
282
|
-
members:
|
|
282
|
+
members: extractClassMembers(node, code),
|
|
283
283
|
modifiers,
|
|
284
284
|
...(docstring && { docstring }),
|
|
285
285
|
...(generics && { generics }),
|
|
@@ -307,7 +307,7 @@ function findClasses(code, parser) {
|
|
|
307
307
|
startLine,
|
|
308
308
|
endLine,
|
|
309
309
|
type: 'enum',
|
|
310
|
-
members:
|
|
310
|
+
members: extractEnumConstants(node, code),
|
|
311
311
|
modifiers,
|
|
312
312
|
...(docstring && { docstring }),
|
|
313
313
|
...(annotations.length > 0 && { annotations })
|
|
@@ -405,6 +405,82 @@ function extractInterfaceExtends(interfaceNode) {
|
|
|
405
405
|
return [];
|
|
406
406
|
}
|
|
407
407
|
|
|
408
|
+
/**
|
|
409
|
+
* Extract enum constants from enum body
|
|
410
|
+
*/
|
|
411
|
+
function extractEnumConstants(enumNode, code) {
|
|
412
|
+
const constants = [];
|
|
413
|
+
const bodyNode = enumNode.childForFieldName('body');
|
|
414
|
+
if (!bodyNode) return constants;
|
|
415
|
+
|
|
416
|
+
for (let i = 0; i < bodyNode.namedChildCount; i++) {
|
|
417
|
+
const child = bodyNode.namedChild(i);
|
|
418
|
+
if (child.type === 'enum_constant') {
|
|
419
|
+
const nameNode = child.childForFieldName('name');
|
|
420
|
+
if (nameNode) {
|
|
421
|
+
const { startLine, endLine } = nodeToLocation(child, code);
|
|
422
|
+
const argsNode = child.childForFieldName('arguments');
|
|
423
|
+
constants.push({
|
|
424
|
+
name: nameNode.text,
|
|
425
|
+
startLine,
|
|
426
|
+
endLine,
|
|
427
|
+
memberType: 'constant',
|
|
428
|
+
...(argsNode && { params: argsNode.text.slice(1, -1) })
|
|
429
|
+
});
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// Also extract methods from enum_body_declarations
|
|
435
|
+
if (bodyNode) {
|
|
436
|
+
for (let i = 0; i < bodyNode.namedChildCount; i++) {
|
|
437
|
+
const child = bodyNode.namedChild(i);
|
|
438
|
+
if (child.type === 'enum_body_declarations') {
|
|
439
|
+
for (let j = 0; j < child.namedChildCount; j++) {
|
|
440
|
+
const member = child.namedChild(j);
|
|
441
|
+
if (member.type === 'method_declaration') {
|
|
442
|
+
const nameNode = member.childForFieldName('name');
|
|
443
|
+
const paramsNode = member.childForFieldName('parameters');
|
|
444
|
+
if (nameNode) {
|
|
445
|
+
const { startLine, endLine } = nodeToLocation(member, code);
|
|
446
|
+
const modifiers = extractModifiers(member);
|
|
447
|
+
const returnType = extractReturnType(member);
|
|
448
|
+
constants.push({
|
|
449
|
+
name: nameNode.text,
|
|
450
|
+
params: extractJavaParams(paramsNode),
|
|
451
|
+
startLine,
|
|
452
|
+
endLine,
|
|
453
|
+
memberType: modifiers.includes('static') ? 'static' : 'method',
|
|
454
|
+
modifiers,
|
|
455
|
+
isMethod: true,
|
|
456
|
+
...(returnType && { returnType })
|
|
457
|
+
});
|
|
458
|
+
}
|
|
459
|
+
} else if (member.type === 'constructor_declaration') {
|
|
460
|
+
const nameNode = member.childForFieldName('name');
|
|
461
|
+
const paramsNode = member.childForFieldName('parameters');
|
|
462
|
+
if (nameNode) {
|
|
463
|
+
const { startLine, endLine } = nodeToLocation(member, code);
|
|
464
|
+
const modifiers = extractModifiers(member);
|
|
465
|
+
constants.push({
|
|
466
|
+
name: nameNode.text,
|
|
467
|
+
params: extractJavaParams(paramsNode),
|
|
468
|
+
startLine,
|
|
469
|
+
endLine,
|
|
470
|
+
memberType: 'constructor',
|
|
471
|
+
modifiers,
|
|
472
|
+
isMethod: true
|
|
473
|
+
});
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
return constants;
|
|
482
|
+
}
|
|
483
|
+
|
|
408
484
|
/**
|
|
409
485
|
* Extract class members (methods, constructors)
|
|
410
486
|
*/
|
package/languages/rust.js
CHANGED
|
@@ -232,7 +232,7 @@ function findClasses(code, parser) {
|
|
|
232
232
|
startLine,
|
|
233
233
|
endLine,
|
|
234
234
|
type: 'enum',
|
|
235
|
-
members:
|
|
235
|
+
members: extractEnumVariants(node, code),
|
|
236
236
|
modifiers: visibility ? [visibility] : [],
|
|
237
237
|
...(docstring && { docstring }),
|
|
238
238
|
...(generics && { generics })
|
|
@@ -258,7 +258,7 @@ function findClasses(code, parser) {
|
|
|
258
258
|
startLine,
|
|
259
259
|
endLine,
|
|
260
260
|
type: 'trait',
|
|
261
|
-
members:
|
|
261
|
+
members: extractTraitMembers(node, code),
|
|
262
262
|
modifiers: visibility ? [visibility] : [],
|
|
263
263
|
...(docstring && { docstring }),
|
|
264
264
|
...(generics && { generics })
|
|
@@ -443,6 +443,75 @@ function extractImplInfo(implNode) {
|
|
|
443
443
|
return { name, traitName, typeName };
|
|
444
444
|
}
|
|
445
445
|
|
|
446
|
+
/**
|
|
447
|
+
* Extract enum variants
|
|
448
|
+
*/
|
|
449
|
+
function extractEnumVariants(enumNode, code) {
|
|
450
|
+
const variants = [];
|
|
451
|
+
const bodyNode = enumNode.childForFieldName('body');
|
|
452
|
+
if (!bodyNode) return variants;
|
|
453
|
+
|
|
454
|
+
for (let i = 0; i < bodyNode.namedChildCount; i++) {
|
|
455
|
+
const child = bodyNode.namedChild(i);
|
|
456
|
+
if (child.type === 'enum_variant') {
|
|
457
|
+
const nameNode = child.childForFieldName('name');
|
|
458
|
+
if (nameNode) {
|
|
459
|
+
const { startLine, endLine } = nodeToLocation(child, code);
|
|
460
|
+
// Check for tuple/struct variant data
|
|
461
|
+
let params = undefined;
|
|
462
|
+
for (let j = 0; j < child.namedChildCount; j++) {
|
|
463
|
+
const variantChild = child.namedChild(j);
|
|
464
|
+
if (variantChild.type === 'field_declaration_list' || variantChild.type === 'ordered_field_declaration_list') {
|
|
465
|
+
params = variantChild.text.slice(1, -1);
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
variants.push({
|
|
469
|
+
name: nameNode.text,
|
|
470
|
+
startLine,
|
|
471
|
+
endLine,
|
|
472
|
+
memberType: 'variant',
|
|
473
|
+
...(params !== undefined && { params })
|
|
474
|
+
});
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
return variants;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/**
|
|
482
|
+
* Extract trait method signatures
|
|
483
|
+
*/
|
|
484
|
+
function extractTraitMembers(traitNode, code) {
|
|
485
|
+
const members = [];
|
|
486
|
+
const bodyNode = traitNode.childForFieldName('body');
|
|
487
|
+
if (!bodyNode) return members;
|
|
488
|
+
|
|
489
|
+
for (let i = 0; i < bodyNode.namedChildCount; i++) {
|
|
490
|
+
const child = bodyNode.namedChild(i);
|
|
491
|
+
if (child.type === 'function_item' || child.type === 'function_signature_item') {
|
|
492
|
+
const nameNode = child.childForFieldName('name');
|
|
493
|
+
if (nameNode) {
|
|
494
|
+
const { startLine, endLine } = nodeToLocation(child, code);
|
|
495
|
+
const paramsNode = child.childForFieldName('parameters');
|
|
496
|
+
const returnType = extractReturnType(child);
|
|
497
|
+
const hasSelf = paramsNode && paramsNode.text.includes('self');
|
|
498
|
+
|
|
499
|
+
members.push({
|
|
500
|
+
name: nameNode.text,
|
|
501
|
+
startLine,
|
|
502
|
+
endLine,
|
|
503
|
+
memberType: 'method',
|
|
504
|
+
isMethod: true,
|
|
505
|
+
...(paramsNode && { params: extractRustParams(paramsNode) }),
|
|
506
|
+
...(returnType && { returnType }),
|
|
507
|
+
...(hasSelf && { receiver: 'self' })
|
|
508
|
+
});
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
return members;
|
|
513
|
+
}
|
|
514
|
+
|
|
446
515
|
/**
|
|
447
516
|
* Extract impl block members (functions)
|
|
448
517
|
* @param {Node} implNode - The impl block AST node
|
package/mcp/server.js
CHANGED
|
@@ -34,6 +34,7 @@ const { ProjectIndex } = require('../core/project');
|
|
|
34
34
|
const { findProjectRoot, isTestFile } = require('../core/discovery');
|
|
35
35
|
const { detectLanguage } = require('../core/parser');
|
|
36
36
|
const output = require('../core/output');
|
|
37
|
+
const { pickBestDefinition, addTestExclusions } = require('../core/shared');
|
|
37
38
|
|
|
38
39
|
// ============================================================================
|
|
39
40
|
// INDEX CACHE
|
|
@@ -98,24 +99,6 @@ function getIndex(projectDir) {
|
|
|
98
99
|
return index;
|
|
99
100
|
}
|
|
100
101
|
|
|
101
|
-
function pickBestDefinition(matches) {
|
|
102
|
-
const typeOrder = new Set(['class', 'struct', 'interface', 'type', 'impl']);
|
|
103
|
-
const scored = matches.map(m => {
|
|
104
|
-
let score = 0;
|
|
105
|
-
const rp = m.relativePath || '';
|
|
106
|
-
if (typeOrder.has(m.type)) score += 1000;
|
|
107
|
-
if (isTestFile(rp, detectLanguage(m.file))) score -= 500;
|
|
108
|
-
if (/^(examples?|docs?|vendor|third[_-]?party|benchmarks?|samples?)\//i.test(rp)) score -= 300;
|
|
109
|
-
if (/^(lib|src|core|internal|pkg|crates)\//i.test(rp)) score += 200;
|
|
110
|
-
if (m.startLine && m.endLine) {
|
|
111
|
-
score += Math.min(m.endLine - m.startLine, 100);
|
|
112
|
-
}
|
|
113
|
-
return { match: m, score };
|
|
114
|
-
});
|
|
115
|
-
scored.sort((a, b) => b.score - a.score);
|
|
116
|
-
return scored[0].match;
|
|
117
|
-
}
|
|
118
|
-
|
|
119
102
|
// ============================================================================
|
|
120
103
|
// SERVER SETUP
|
|
121
104
|
// ============================================================================
|
|
@@ -129,13 +112,6 @@ const server = new McpServer({
|
|
|
129
112
|
// TOOL HELPERS
|
|
130
113
|
// ============================================================================
|
|
131
114
|
|
|
132
|
-
function addTestExclusions(exclude) {
|
|
133
|
-
const testPatterns = ['test', 'spec', '__tests__', '__mocks__', 'fixture', 'mock'];
|
|
134
|
-
const existing = new Set((exclude || []).map(e => e.toLowerCase()));
|
|
135
|
-
const additions = testPatterns.filter(p => !existing.has(p));
|
|
136
|
-
return [...(exclude || []), ...additions];
|
|
137
|
-
}
|
|
138
|
-
|
|
139
115
|
function parseExclude(excludeStr) {
|
|
140
116
|
if (!excludeStr) return [];
|
|
141
117
|
return excludeStr.split(',').map(s => s.trim()).filter(Boolean);
|
|
@@ -158,6 +134,31 @@ function toolError(message) {
|
|
|
158
134
|
return { content: [{ type: 'text', text: `Error: ${message}` }], isError: true };
|
|
159
135
|
}
|
|
160
136
|
|
|
137
|
+
/**
|
|
138
|
+
* Resolve a file path via index and validate it's within the project root.
|
|
139
|
+
* Returns the resolved absolute path string, or a toolError response.
|
|
140
|
+
*/
|
|
141
|
+
function resolveAndValidatePath(index, file) {
|
|
142
|
+
const resolved = index.resolveFilePathForQuery(file);
|
|
143
|
+
if (typeof resolved !== 'string') {
|
|
144
|
+
if (resolved.error === 'file-ambiguous') {
|
|
145
|
+
return toolError(`Ambiguous file "${file}". Candidates:\n${resolved.candidates.map(c => ' ' + c).join('\n')}`);
|
|
146
|
+
}
|
|
147
|
+
return toolError(`File not found: ${file}`);
|
|
148
|
+
}
|
|
149
|
+
// Path boundary check: ensure resolved path is within the project root
|
|
150
|
+
try {
|
|
151
|
+
const realPath = fs.realpathSync(resolved);
|
|
152
|
+
const realRoot = fs.realpathSync(index.root);
|
|
153
|
+
if (realPath !== realRoot && !realPath.startsWith(realRoot + path.sep)) {
|
|
154
|
+
return toolError(`File is outside project root: ${file}`);
|
|
155
|
+
}
|
|
156
|
+
} catch (e) {
|
|
157
|
+
return toolError(`Cannot resolve file path: ${file}`);
|
|
158
|
+
}
|
|
159
|
+
return resolved;
|
|
160
|
+
}
|
|
161
|
+
|
|
161
162
|
function requireName(name) {
|
|
162
163
|
if (!name || !name.trim()) {
|
|
163
164
|
return toolError('Symbol name is required.');
|
|
@@ -305,6 +306,7 @@ server.registerTool(
|
|
|
305
306
|
file,
|
|
306
307
|
exclude: parseExclude(exclude)
|
|
307
308
|
});
|
|
309
|
+
if (!ctx) return toolResult(`Symbol "${name}" not found.`);
|
|
308
310
|
const { text, expandable } = output.formatContext(ctx, {
|
|
309
311
|
expandHint: 'Use expand command with item number to see code for any item.'
|
|
310
312
|
});
|
|
@@ -346,6 +348,7 @@ server.registerTool(
|
|
|
346
348
|
includeMethods: include_methods,
|
|
347
349
|
includeUncertain: include_uncertain || false
|
|
348
350
|
});
|
|
351
|
+
if (!result) return toolResult(`Function "${name}" not found.`);
|
|
349
352
|
return toolResult(output.formatSmart(result));
|
|
350
353
|
}
|
|
351
354
|
|
|
@@ -364,7 +367,9 @@ server.registerTool(
|
|
|
364
367
|
const err = requireName(name);
|
|
365
368
|
if (err) return err;
|
|
366
369
|
const index = getIndex(project_dir);
|
|
367
|
-
|
|
370
|
+
const exResult = index.example(name);
|
|
371
|
+
if (!exResult) return toolResult(`No usage examples found for "${name}".`);
|
|
372
|
+
return toolResult(output.formatExample(exResult, name));
|
|
368
373
|
}
|
|
369
374
|
|
|
370
375
|
case 'related': {
|
|
@@ -372,6 +377,7 @@ server.registerTool(
|
|
|
372
377
|
if (err) return err;
|
|
373
378
|
const index = getIndex(project_dir);
|
|
374
379
|
const result = index.related(name, { file, top, all: top !== undefined });
|
|
380
|
+
if (!result) return toolResult(`Symbol "${name}" not found.`);
|
|
375
381
|
return toolResult(output.formatRelated(result, {
|
|
376
382
|
showAll: top !== undefined,
|
|
377
383
|
top,
|
|
@@ -465,6 +471,9 @@ server.registerTool(
|
|
|
465
471
|
}
|
|
466
472
|
|
|
467
473
|
const match = matches.length > 1 ? pickBestDefinition(matches) : matches[0];
|
|
474
|
+
// Validate file is within project root
|
|
475
|
+
const fnPathCheck = resolveAndValidatePath(index, match.relativePath || path.relative(index.root, match.file));
|
|
476
|
+
if (typeof fnPathCheck !== 'string') return fnPathCheck;
|
|
468
477
|
const code = fs.readFileSync(match.file, 'utf-8');
|
|
469
478
|
const codeLines = code.split('\n');
|
|
470
479
|
const fnCode = codeLines.slice(match.startLine - 1, match.endLine).join('\n');
|
|
@@ -490,6 +499,9 @@ server.registerTool(
|
|
|
490
499
|
}
|
|
491
500
|
|
|
492
501
|
const match = matches.length > 1 ? pickBestDefinition(matches) : matches[0];
|
|
502
|
+
// Validate file is within project root
|
|
503
|
+
const clsPathCheck = resolveAndValidatePath(index, match.relativePath || path.relative(index.root, match.file));
|
|
504
|
+
if (typeof clsPathCheck !== 'string') return clsPathCheck;
|
|
493
505
|
|
|
494
506
|
const code = fs.readFileSync(match.file, 'utf-8');
|
|
495
507
|
const codeLines = code.split('\n');
|
|
@@ -542,13 +554,8 @@ server.registerTool(
|
|
|
542
554
|
return toolError('Line range is required (e.g. "10-20" or "15").');
|
|
543
555
|
}
|
|
544
556
|
const index = getIndex(project_dir);
|
|
545
|
-
const resolved = index
|
|
546
|
-
if (typeof resolved !== 'string')
|
|
547
|
-
if (resolved.error === 'file-ambiguous') {
|
|
548
|
-
return toolError(`Ambiguous file "${file}". Candidates:\n${resolved.candidates.map(c => ' ' + c).join('\n')}`);
|
|
549
|
-
}
|
|
550
|
-
return toolError(`File not found: ${file}`);
|
|
551
|
-
}
|
|
557
|
+
const resolved = resolveAndValidatePath(index, file);
|
|
558
|
+
if (typeof resolved !== 'string') return resolved; // toolError response
|
|
552
559
|
const filePath = resolved;
|
|
553
560
|
|
|
554
561
|
const parts = range.split('-');
|
|
@@ -631,6 +638,16 @@ server.registerTool(
|
|
|
631
638
|
if (!filePath || !fs.existsSync(filePath)) {
|
|
632
639
|
return toolError(`Cannot locate file for ${match.name}`);
|
|
633
640
|
}
|
|
641
|
+
// Validate file is within project root
|
|
642
|
+
try {
|
|
643
|
+
const realPath = fs.realpathSync(filePath);
|
|
644
|
+
const realRoot = fs.realpathSync(index.root);
|
|
645
|
+
if (realPath !== realRoot && !realPath.startsWith(realRoot + path.sep)) {
|
|
646
|
+
return toolError(`File is outside project root: ${match.name}`);
|
|
647
|
+
}
|
|
648
|
+
} catch (e) {
|
|
649
|
+
return toolError(`Cannot resolve file path for ${match.name}`);
|
|
650
|
+
}
|
|
634
651
|
|
|
635
652
|
const content = fs.readFileSync(filePath, 'utf-8');
|
|
636
653
|
const fileLines = content.split('\n');
|
|
@@ -732,6 +749,10 @@ server.registerTool(
|
|
|
732
749
|
}
|
|
733
750
|
|
|
734
751
|
case 'diff_impact': {
|
|
752
|
+
// Validate git ref format to prevent argument injection
|
|
753
|
+
if (base && !/^[a-zA-Z0-9._\-~\/^@{}:]+$/.test(base)) {
|
|
754
|
+
return toolError(`Invalid git ref format: ${base}`);
|
|
755
|
+
}
|
|
735
756
|
const index = getIndex(project_dir);
|
|
736
757
|
const result = index.diffImpact({
|
|
737
758
|
base: base || 'HEAD',
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ucn",
|
|
3
|
-
"version": "3.7.
|
|
3
|
+
"version": "3.7.6",
|
|
4
4
|
"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.",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"bin": {
|
package/test/mcp-edge-cases.js
CHANGED
|
@@ -376,6 +376,34 @@ const tests = [
|
|
|
376
376
|
args: { command: 'api', project_dir: PROJECT_DIR, file: 'nonexistent.js' },
|
|
377
377
|
assert: (res, text, isError) => (isError && /not found/i.test(text)) || 'Expected file-not-found error message'
|
|
378
378
|
},
|
|
379
|
+
{
|
|
380
|
+
category: 'Correctness',
|
|
381
|
+
tool: 'ucn',
|
|
382
|
+
desc: 'smart(nonexistent) returns "not found" message',
|
|
383
|
+
args: { command: 'smart', project_dir: PROJECT_DIR, name: 'zzz_nonexistent_symbol_xyz' },
|
|
384
|
+
assert: (res, text, isError) => (!isError && /not found/i.test(text)) || 'Expected "not found" message for nonexistent smart target'
|
|
385
|
+
},
|
|
386
|
+
{
|
|
387
|
+
category: 'Correctness',
|
|
388
|
+
tool: 'ucn',
|
|
389
|
+
desc: 'context(nonexistent) returns "not found" message',
|
|
390
|
+
args: { command: 'context', project_dir: PROJECT_DIR, name: 'zzz_nonexistent_symbol_xyz' },
|
|
391
|
+
assert: (res, text, isError) => (!isError && /not found/i.test(text)) || 'Expected "not found" message for nonexistent context target'
|
|
392
|
+
},
|
|
393
|
+
{
|
|
394
|
+
category: 'Correctness',
|
|
395
|
+
tool: 'ucn',
|
|
396
|
+
desc: 'example(nonexistent) returns "no examples" message',
|
|
397
|
+
args: { command: 'example', project_dir: PROJECT_DIR, name: 'zzz_nonexistent_symbol_xyz' },
|
|
398
|
+
assert: (res, text, isError) => (!isError && /no .* examples found|not found/i.test(text)) || 'Expected "no examples found" message for nonexistent example target'
|
|
399
|
+
},
|
|
400
|
+
{
|
|
401
|
+
category: 'Correctness',
|
|
402
|
+
tool: 'ucn',
|
|
403
|
+
desc: 'related(nonexistent) returns "not found" message',
|
|
404
|
+
args: { command: 'related', project_dir: PROJECT_DIR, name: 'zzz_nonexistent_symbol_xyz' },
|
|
405
|
+
assert: (res, text, isError) => (!isError && /not found/i.test(text)) || 'Expected "not found" message for nonexistent related target'
|
|
406
|
+
},
|
|
379
407
|
{
|
|
380
408
|
category: 'Correctness',
|
|
381
409
|
tool: 'ucn',
|
|
@@ -411,6 +439,59 @@ const tests = [
|
|
|
411
439
|
args: { command: 'imports', project_dir: PROJECT_DIR, file: 'utils.js' },
|
|
412
440
|
assert: (res, text, isError) => (isError && /ambiguous/i.test(text)) || 'Expected file-ambiguous error for utils.js'
|
|
413
441
|
},
|
|
442
|
+
|
|
443
|
+
// ========================================================================
|
|
444
|
+
// CATEGORY 3: Security (path traversal, argument injection)
|
|
445
|
+
// ========================================================================
|
|
446
|
+
{
|
|
447
|
+
category: 'Security',
|
|
448
|
+
tool: 'ucn',
|
|
449
|
+
desc: 'lines rejects path traversal (../../../../etc/passwd)',
|
|
450
|
+
args: { command: 'lines', project_dir: PROJECT_DIR, file: '../../../../etc/passwd', range: '1-5' },
|
|
451
|
+
assert: (res, text, isError) => (isError && (/not found/i.test(text) || /outside project/i.test(text))) || 'Expected error for path traversal'
|
|
452
|
+
},
|
|
453
|
+
{
|
|
454
|
+
category: 'Security',
|
|
455
|
+
tool: 'ucn',
|
|
456
|
+
desc: 'lines rejects path traversal (../../other-project/secret.js)',
|
|
457
|
+
args: { command: 'lines', project_dir: PROJECT_DIR, file: '../../other-project/secret.js', range: '1-5' },
|
|
458
|
+
assert: (res, text, isError) => (isError && (/not found/i.test(text) || /outside project/i.test(text))) || 'Expected error for path traversal'
|
|
459
|
+
},
|
|
460
|
+
{
|
|
461
|
+
category: 'Security',
|
|
462
|
+
tool: 'ucn',
|
|
463
|
+
desc: 'lines works with valid file',
|
|
464
|
+
args: { command: 'lines', project_dir: PROJECT_DIR, file: 'core/discovery.js', range: '1-3' },
|
|
465
|
+
assert: (res, text, isError) => (!isError && text.length > 0) || 'Expected valid output for core/discovery.js'
|
|
466
|
+
},
|
|
467
|
+
{
|
|
468
|
+
category: 'Security',
|
|
469
|
+
tool: 'ucn',
|
|
470
|
+
desc: 'diff_impact rejects --config argument injection',
|
|
471
|
+
args: { command: 'diff_impact', project_dir: PROJECT_DIR, base: '--config=malicious' },
|
|
472
|
+
assert: (res, text, isError) => (isError && /invalid git ref/i.test(text)) || 'Expected error for argument injection in base'
|
|
473
|
+
},
|
|
474
|
+
{
|
|
475
|
+
category: 'Security',
|
|
476
|
+
tool: 'ucn',
|
|
477
|
+
desc: 'diff_impact rejects -o flag injection',
|
|
478
|
+
args: { command: 'diff_impact', project_dir: PROJECT_DIR, base: '-o /tmp/evil' },
|
|
479
|
+
assert: (res, text, isError) => (isError && /invalid git ref/i.test(text)) || 'Expected error for flag injection in base'
|
|
480
|
+
},
|
|
481
|
+
{
|
|
482
|
+
category: 'Security',
|
|
483
|
+
tool: 'ucn',
|
|
484
|
+
desc: 'diff_impact accepts valid ref HEAD~3',
|
|
485
|
+
args: { command: 'diff_impact', project_dir: PROJECT_DIR, base: 'HEAD~3' },
|
|
486
|
+
assert: (res, text, isError) => true // Should not error on valid ref format
|
|
487
|
+
},
|
|
488
|
+
{
|
|
489
|
+
category: 'Security',
|
|
490
|
+
tool: 'ucn',
|
|
491
|
+
desc: 'diff_impact accepts valid ref origin/main',
|
|
492
|
+
args: { command: 'diff_impact', project_dir: PROJECT_DIR, base: 'origin/main' },
|
|
493
|
+
assert: (res, text, isError) => true // Should not error on valid ref format
|
|
494
|
+
},
|
|
414
495
|
];
|
|
415
496
|
|
|
416
497
|
// ============================================================================
|