ucn 3.7.20 → 3.7.22

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/core/output.js CHANGED
@@ -8,6 +8,17 @@
8
8
  const fs = require('fs');
9
9
  const path = require('path');
10
10
 
11
+ const FILE_ERROR_MESSAGES = {
12
+ 'file-not-found': 'File not found in project',
13
+ 'file-ambiguous': 'Ambiguous file match'
14
+ };
15
+
16
+ function formatFileError(errorObj, fallbackPath) {
17
+ const msg = FILE_ERROR_MESSAGES[errorObj.error] || errorObj.error;
18
+ const file = errorObj.filePath || fallbackPath || '';
19
+ return `Error: ${msg}: ${file}`;
20
+ }
21
+
11
22
  /**
12
23
  * Normalize parameters for display
13
24
  * Collapses multiline params to single line
@@ -381,7 +392,7 @@ function formatSearchJson(results, term) {
381
392
  * Format imports as JSON
382
393
  */
383
394
  function formatImportsJson(imports, filePath) {
384
- if (imports?.error === 'file-not-found') return JSON.stringify({ found: false, error: 'File not found', file: imports.filePath }, null, 2);
395
+ if (imports?.error) return JSON.stringify({ found: false, error: imports.error, file: imports.filePath || filePath }, null, 2);
385
396
  return JSON.stringify({
386
397
  file: filePath,
387
398
  importCount: imports.length,
@@ -406,7 +417,7 @@ function formatStatsJson(stats) {
406
417
  * Format dependency graph as JSON
407
418
  */
408
419
  function formatGraphJson(graph) {
409
- if (graph?.error === 'file-not-found') return JSON.stringify({ found: false, error: 'File not found', file: graph.filePath }, null, 2);
420
+ if (graph?.error) return JSON.stringify({ found: false, error: graph.error, file: graph.filePath }, null, 2);
410
421
  return JSON.stringify({
411
422
  file: graph.file,
412
423
  depth: graph.depth,
@@ -456,7 +467,7 @@ function formatSmartJson(result) {
456
467
  * Format imports command output - text
457
468
  */
458
469
  function formatImports(imports, filePath) {
459
- if (imports?.error === 'file-not-found') return `Error: File not found in project: ${imports.filePath}`;
470
+ if (imports?.error) return formatFileError(imports, filePath);
460
471
  const lines = [`Imports in ${filePath}:\n`];
461
472
 
462
473
  const internal = imports.filter(i => !i.isExternal && !i.isDynamic);
@@ -505,7 +516,7 @@ function formatImports(imports, filePath) {
505
516
  * Format exporters command output - text
506
517
  */
507
518
  function formatExporters(exporters, filePath) {
508
- if (exporters?.error === 'file-not-found') return `Error: File not found in project: ${exporters.filePath}`;
519
+ if (exporters?.error) return formatFileError(exporters, filePath);
509
520
  const lines = [`Files that import ${filePath}:\n`];
510
521
 
511
522
  if (exporters.length === 0) {
@@ -641,7 +652,7 @@ function formatDisambiguation(matches, name, command) {
641
652
  * Format exporters as JSON
642
653
  */
643
654
  function formatExportersJson(exporters, filePath) {
644
- if (exporters?.error === 'file-not-found') return JSON.stringify({ found: false, error: 'File not found', file: exporters.filePath }, null, 2);
655
+ if (exporters?.error) return JSON.stringify({ found: false, error: exporters.error, file: exporters.filePath || filePath }, null, 2);
645
656
  return JSON.stringify({
646
657
  file: filePath,
647
658
  importerCount: exporters.length,
@@ -743,10 +754,13 @@ function formatTrace(trace, options = {}) {
743
754
  if (node.callCount) {
744
755
  label += ` ${node.callCount}x`;
745
756
  }
757
+ if (node.alreadyShown) {
758
+ label += ' (see above)';
759
+ }
746
760
 
747
761
  lines.push(prefix + connector + label);
748
762
 
749
- if (node.children) {
763
+ if (node.children && !node.alreadyShown) {
750
764
  const hasMore = node.truncatedChildren > 0;
751
765
  for (let i = 0; i < node.children.length; i++) {
752
766
  const isChildLast = !hasMore && i === node.children.length - 1;
@@ -1815,7 +1829,7 @@ function formatGraph(graph, options = {}) {
1815
1829
  if (typeof options === 'boolean') {
1816
1830
  options = { showAll: options };
1817
1831
  }
1818
- if (graph?.error === 'file-not-found') return `Error: File not found in project: ${graph.filePath}`;
1832
+ if (graph?.error) return formatFileError(graph);
1819
1833
  if (graph.nodes.length === 0) {
1820
1834
  const file = options.file || graph.root || '';
1821
1835
  return file ? `File not found: ${file}` : 'File not found.';
@@ -1985,7 +1999,7 @@ function formatSearch(results, term) {
1985
1999
  * Format file-exports command output
1986
2000
  */
1987
2001
  function formatFileExports(exports, filePath) {
1988
- if (exports?.error === 'file-not-found') return `Error: File not found in project: ${exports.filePath}`;
2002
+ if (exports?.error) return formatFileError(exports, filePath);
1989
2003
  if (exports.length === 0) return `No exports found in ${filePath}`;
1990
2004
 
1991
2005
  const lines = [];
package/core/parser.js CHANGED
@@ -67,6 +67,9 @@ const { detectLanguage, getParser, getLanguageModule, isSupported, PARSE_OPTIONS
67
67
  * @returns {ParseResult}
68
68
  */
69
69
  function parse(code, language) {
70
+ if (!language) {
71
+ throw new Error('Language parameter is required');
72
+ }
70
73
  // Detect language if file path provided
71
74
  if (language.includes('.') || language.includes('/')) {
72
75
  language = detectLanguage(language);
package/core/project.js CHANGED
@@ -1514,6 +1514,7 @@ class ProjectIndex {
1514
1514
  }
1515
1515
 
1516
1516
  const tree = safeParse(parser, content);
1517
+ if (!tree) return false;
1517
1518
 
1518
1519
  // Find all occurrences of name in the line
1519
1520
  const nameRegex = new RegExp('(?<![a-zA-Z0-9_$])' + escapeRegExp(name) + '(?![a-zA-Z0-9_$])', 'g');
@@ -2120,6 +2121,7 @@ class ProjectIndex {
2120
2121
  }
2121
2122
 
2122
2123
  const tree = safeParse(parser, content);
2124
+ if (!tree) return false;
2123
2125
  const tokenType = getTokenTypeAtPosition(tree.rootNode, lineNum, column);
2124
2126
  return tokenType === 'comment' || tokenType === 'string';
2125
2127
  } catch (e) {
@@ -3005,10 +3007,30 @@ class ProjectIndex {
3005
3007
  // Filter out usages that are at the definition location
3006
3008
  // nameLine: when decorators/annotations are present, startLine is the decorator line
3007
3009
  // but the name identifier is on a different line (nameLine). Check both.
3008
- const nonDefUsages = allUsages.filter(u =>
3010
+ let nonDefUsages = allUsages.filter(u =>
3009
3011
  !(u.file === symbol.file && (u.line === symbol.startLine || u.line === symbol.nameLine))
3010
3012
  );
3011
3013
 
3014
+ // For exported symbols in --include-exported mode, also filter out export-site
3015
+ // references (e.g., `module.exports = { helperC }` or `export { helperC }`).
3016
+ // These are just re-statements of the export, not actual consumption.
3017
+ if (isExported && options.includeExported) {
3018
+ nonDefUsages = nonDefUsages.filter(u => {
3019
+ if (u.file !== symbol.file) return true; // cross-file usage always counts
3020
+ // Check if same-file usage is on an export line
3021
+ const content = this._readFile(u.file);
3022
+ if (!content) return true;
3023
+ const lines = content.split('\n');
3024
+ const line = lines[u.line - 1] || '';
3025
+ const trimmed = line.trim();
3026
+ // CJS: module.exports = { ... } or exports.name = ...
3027
+ if (trimmed.startsWith('module.exports') || /^exports\.\w+\s*=/.test(trimmed)) return false;
3028
+ // ESM: export { ... } or export default
3029
+ if (/^export\s*\{/.test(trimmed) || /^export\s+default\s/.test(trimmed)) return false;
3030
+ return true;
3031
+ });
3032
+ }
3033
+
3012
3034
  // Total includes all usage types (calls, references, callbacks, re-exports)
3013
3035
  const totalUsages = nonDefUsages.length;
3014
3036
 
@@ -3374,10 +3396,22 @@ class ProjectIndex {
3374
3396
 
3375
3397
  const buildTree = (funcDef, currentDepth, dir) => {
3376
3398
  const funcName = funcDef.name;
3377
- if (currentDepth > maxDepth || visited.has(`${funcDef.file}:${funcDef.startLine}`)) {
3399
+ const key = `${funcDef.file}:${funcDef.startLine}`;
3400
+ if (currentDepth > maxDepth) {
3378
3401
  return null;
3379
3402
  }
3380
- visited.add(`${funcDef.file}:${funcDef.startLine}`);
3403
+ if (visited.has(key)) {
3404
+ // Already explored — show as leaf node without recursing (prevents infinite loops)
3405
+ return {
3406
+ name: funcName,
3407
+ file: funcDef.relativePath,
3408
+ line: funcDef.startLine,
3409
+ type: funcDef.type,
3410
+ children: [],
3411
+ alreadyShown: true
3412
+ };
3413
+ }
3414
+ visited.add(key);
3381
3415
 
3382
3416
  const node = {
3383
3417
  name: funcName,
@@ -4009,10 +4043,12 @@ class ProjectIndex {
4009
4043
  params = params.slice(1);
4010
4044
  }
4011
4045
  }
4012
- const expectedParamCount = params.length;
4013
- const optionalCount = params.filter(p => p.optional || p.default !== undefined).length;
4014
- const minArgs = expectedParamCount - optionalCount;
4015
4046
  const hasRest = params.some(p => p.rest);
4047
+ // Rest params don't count toward expected/min — they accept 0+ extra args
4048
+ const nonRestParams = params.filter(p => !p.rest);
4049
+ const expectedParamCount = nonRestParams.length;
4050
+ const optionalCount = nonRestParams.filter(p => p.optional || p.default !== undefined).length;
4051
+ const minArgs = expectedParamCount - optionalCount;
4016
4052
 
4017
4053
  // Get all call sites
4018
4054
  const usages = this.usages(name, { codeOnly: true });
@@ -5009,6 +5045,7 @@ class ProjectIndex {
5009
5045
  const parser = getParser(language);
5010
5046
  const content = this._readFile(filePath);
5011
5047
  const tree = safeParse(parser, content);
5048
+ if (!tree) return result;
5012
5049
 
5013
5050
  const row = lineNum - 1;
5014
5051
  const node = tree.rootNode.descendantForPosition({ row, column: 0 });
package/languages/go.js CHANGED
@@ -448,6 +448,7 @@ function findCallsInCode(code, parser) {
448
448
  if (node.type === 'short_var_declaration' || node.type === 'var_declaration') {
449
449
  // Check if RHS contains a func_literal
450
450
  const hasFunc = (n) => {
451
+ if (!n) return false;
451
452
  if (n.type === 'func_literal') return true;
452
453
  for (let i = 0; i < n.childCount; i++) {
453
454
  if (hasFunc(n.child(i))) return true;
@@ -518,7 +519,7 @@ function findCallsInCode(code, parser) {
518
519
  onLeave: (node) => {
519
520
  if (isFunctionNode(node)) {
520
521
  const leaving = functionStack.pop();
521
- closureScopes.delete(leaving.startLine);
522
+ if (leaving) closureScopes.delete(leaving.startLine);
522
523
  }
523
524
  }
524
525
  });
@@ -1453,7 +1453,9 @@ function findImportsInCode(code, parser) {
1453
1453
  }
1454
1454
  }
1455
1455
 
1456
- imports.push({ module: modulePath, names, type: 'require', line, dynamic });
1456
+ if (modulePath) {
1457
+ imports.push({ module: modulePath, names, type: 'require', line, dynamic });
1458
+ }
1457
1459
  }
1458
1460
  }
1459
1461
 
@@ -1466,8 +1468,8 @@ function findImportsInCode(code, parser) {
1466
1468
  if (firstArg && firstArg.type === 'string') {
1467
1469
  const modulePath = firstArg.text.slice(1, -1);
1468
1470
  imports.push({ module: modulePath, names: [], type: 'dynamic', line, dynamic: false });
1469
- } else {
1470
- imports.push({ module: firstArg ? firstArg.text : null, names: [], type: 'dynamic', line, dynamic: true });
1471
+ } else if (firstArg) {
1472
+ imports.push({ module: firstArg.text, names: [], type: 'dynamic', line, dynamic: true });
1471
1473
  }
1472
1474
  }
1473
1475
  }
package/languages/rust.js CHANGED
@@ -845,7 +845,7 @@ function findImportsInCode(code, parser) {
845
845
  if (node.type === 'macro_invocation') {
846
846
  const nameNode = node.childForFieldName('macro');
847
847
  if (nameNode && /^include(_str|_bytes)?$/.test(nameNode.text)) {
848
- const argsNode = node.childForFieldName('argument_list');
848
+ const argsNode = node.namedChildren.find(c => c.type === 'token_tree');
849
849
  const arg = argsNode?.namedChild(0);
850
850
  const dynamic = !arg || arg.type !== 'string_literal';
851
851
  const modulePath = arg ? arg.text.replace(/^["']|["']$/g, '') : null;
@@ -96,8 +96,9 @@ function parseJSParam(param, info) {
96
96
  if (patternNode) info.name = patternNode.text;
97
97
  if (typeNode) info.type = typeNode.text.replace(/^:\s*/, '');
98
98
  if (param.type === 'optional_parameter') info.optional = true;
99
- } else if (param.type === 'rest_parameter') {
100
- const patternNode = param.childForFieldName('pattern');
99
+ } else if (param.type === 'rest_parameter' || param.type === 'rest_pattern') {
100
+ // rest_parameter = TypeScript, rest_pattern = JavaScript
101
+ const patternNode = param.childForFieldName('pattern') || param.namedChild(0);
101
102
  if (patternNode) info.name = patternNode.text;
102
103
  info.rest = true;
103
104
  } else if (param.type === 'assignment_pattern') {
package/mcp/server.js CHANGED
@@ -108,6 +108,7 @@ const server = new McpServer({
108
108
  const MAX_OUTPUT_CHARS = 100000; // ~100KB, safe for all MCP clients
109
109
 
110
110
  function toolResult(text) {
111
+ if (!text) return { content: [{ type: 'text', text: '(no output)' }] };
111
112
  if (text.length > MAX_OUTPUT_CHARS) {
112
113
  const truncated = text.substring(0, MAX_OUTPUT_CHARS);
113
114
  // Cut at last newline to avoid breaking mid-line
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ucn",
3
- "version": "3.7.20",
3
+ "version": "3.7.22",
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",