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/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: typeKind === 'struct' ? extractStructFields(typeNode, code) : [],
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
- return toolResult(output.formatExample(index.example(name), name));
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.resolveFilePathForQuery(file);
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.4",
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": {
@@ -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
  // ============================================================================