ucn 3.7.11 → 3.7.12

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.
@@ -92,6 +92,7 @@ ucn deadcode --exclude=test # Skip test files (most useful)
92
92
  | Quick project overview | `ucn toc` | Every file with function/class counts and line counts |
93
93
  | Find by glob pattern | `ucn find "handle*"` | Locate definitions matching a glob (supports * and ?) |
94
94
  | Text search with context | `ucn search term --context=3` | Like grep -C 3, shows surrounding lines |
95
+ | Regex search | `ucn search '\d+' --regex` | Search with regex patterns (alternation, character classes, etc.) |
95
96
  | Text search filtered | `ucn search term --exclude=test` | Search only in matching files |
96
97
  | Finding all usages (not just calls) | `ucn usages <name>` | Groups into: definitions, calls, imports, type references |
97
98
  | Finding sibling/related functions | `ucn related <name>` | Name-based + structural matching (same file, shared deps). Not semantic — best for parse/format pairs |
@@ -126,6 +127,8 @@ ucn [target] <command> [name] [--flags]
126
127
  | `--staged` | Analyze staged changes (diff-impact) |
127
128
  | `--no-cache` | Force re-index after editing files |
128
129
  | `--context=N` | Lines of surrounding context in `usages`/`search` output |
130
+ | `--regex` | Use search term as a regex pattern |
131
+ | `--functions` | Show per-function line counts in `stats` (complexity audit) |
129
132
 
130
133
  ## Workflow Integration
131
134
 
@@ -1 +1 @@
1
- ghu_gwrBjpCa3Gl8WuJxABQ8XOgRdbPfFf36TmIA
1
+ ghu_Jo1ldIUK4d6zLMSctdSV2Jx8UkoXa50x6epI
@@ -1 +1 @@
1
- {"token":"eyJhbGciOiJFZERTQSIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJtY3AtcmVnaXN0cnkiLCJleHAiOjE3NzE3ODU4MTAsIm5iZiI6MTc3MTc4NTUxMCwiaWF0IjoxNzcxNzg1NTEwLCJhdXRoX21ldGhvZCI6ImdpdGh1Yi1hdCIsImF1dGhfbWV0aG9kX3N1YiI6Im1sZW9jYSIsInBlcm1pc3Npb25zIjpbeyJhY3Rpb24iOiJwdWJsaXNoIiwicmVzb3VyY2UiOiJpby5naXRodWIubWxlb2NhLyoifV19.DW2gbu6Lnoc43CqRsxlhrIy3CQaMxAlLykOfYP3lY08d0FHVmXhg9gUt6qt2G6ihn-G99tA6oOf8tGvxUteeBQ","expires_at":1771785810}
1
+ {"token":"eyJhbGciOiJFZERTQSIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJtY3AtcmVnaXN0cnkiLCJleHAiOjE3NzE5NjQ2MjMsIm5iZiI6MTc3MTk2NDMyMywiaWF0IjoxNzcxOTY0MzIzLCJhdXRoX21ldGhvZCI6ImdpdGh1Yi1hdCIsImF1dGhfbWV0aG9kX3N1YiI6Im1sZW9jYSIsInBlcm1pc3Npb25zIjpbeyJhY3Rpb24iOiJwdWJsaXNoIiwicmVzb3VyY2UiOiJpby5naXRodWIubWxlb2NhLyoifV19.weq7KLw5cAAB7qZpxtU45meX0nLUU1vTiHlzFB5Ecf9OTFtKM63-JSwYBXrr410pdt-7CgW6pV6aGruoIpiIBw","expires_at":1771964623}
package/cli/index.js CHANGED
@@ -82,7 +82,11 @@ const flags = {
82
82
  followSymlinks: !args.includes('--no-follow-symlinks'),
83
83
  // Diff-impact options
84
84
  base: args.find(a => a.startsWith('--base='))?.split('=')[1] || null,
85
- staged: args.includes('--staged')
85
+ staged: args.includes('--staged'),
86
+ // Regex search mode
87
+ regex: args.includes('--regex'),
88
+ // Stats: per-function line counts
89
+ functions: args.includes('--functions')
86
90
  };
87
91
 
88
92
  // Handle --file flag with space
@@ -101,7 +105,8 @@ const knownFlags = new Set([
101
105
  '--file', '--context', '--exclude', '--not', '--in',
102
106
  '--depth', '--direction', '--add-param', '--remove-param', '--rename-to',
103
107
  '--default', '--top', '--no-follow-symlinks',
104
- '--base', '--staged'
108
+ '--base', '--staged',
109
+ '--regex', '--functions'
105
110
  ]);
106
111
 
107
112
  // Handle help flag
@@ -905,7 +910,7 @@ function runProjectCommand(rootDir, command, arg) {
905
910
  if (arg.includes(',')) {
906
911
  const fnNames = arg.split(',').map(n => n.trim()).filter(Boolean);
907
912
  for (let i = 0; i < fnNames.length; i++) {
908
- if (i > 0) console.log('');
913
+ if (i > 0) console.log('\n' + '═'.repeat(60) + '\n');
909
914
  extractFunctionFromProject(index, fnNames[i]);
910
915
  }
911
916
  } else {
@@ -1016,7 +1021,7 @@ function runProjectCommand(rootDir, command, arg) {
1016
1021
  case 'search': {
1017
1022
  requireArg(arg, 'Usage: ucn . search <term>');
1018
1023
  const searchExclude = flags.includeTests ? flags.exclude : addTestExclusions(flags.exclude);
1019
- const searchResults = index.search(arg, { codeOnly: flags.codeOnly, context: flags.context, caseSensitive: flags.caseSensitive, exclude: searchExclude, in: flags.in });
1024
+ const searchResults = index.search(arg, { codeOnly: flags.codeOnly, context: flags.context, caseSensitive: flags.caseSensitive, exclude: searchExclude, in: flags.in, regex: flags.regex });
1020
1025
  printOutput(searchResults,
1021
1026
  r => output.formatSearchJson(r, arg),
1022
1027
  r => output.formatSearch(r, arg)
@@ -1040,8 +1045,11 @@ function runProjectCommand(rootDir, command, arg) {
1040
1045
  }
1041
1046
 
1042
1047
  case 'stats': {
1043
- const stats = index.getStats();
1044
- printOutput(stats, output.formatStatsJson, output.formatStats);
1048
+ const stats = index.getStats({ functions: flags.functions });
1049
+ printOutput(stats,
1050
+ output.formatStatsJson,
1051
+ r => output.formatStats(r, { top: flags.top })
1052
+ );
1045
1053
  break;
1046
1054
  }
1047
1055
 
@@ -1506,7 +1514,7 @@ function findInGlobFiles(files, name) {
1506
1514
 
1507
1515
  function searchGlobFiles(files, term) {
1508
1516
  const results = [];
1509
- const regex = new RegExp(escapeRegExp(term), flags.caseSensitive ? '' : 'i');
1517
+ const regex = flags.regex ? new RegExp(term, flags.caseSensitive ? '' : 'i') : new RegExp(escapeRegExp(term), flags.caseSensitive ? '' : 'i');
1510
1518
 
1511
1519
  for (const file of files) {
1512
1520
  try {
@@ -1557,7 +1565,7 @@ function searchGlobFiles(files, term) {
1557
1565
  // ============================================================================
1558
1566
 
1559
1567
  function searchFile(filePath, lines, term) {
1560
- const regex = new RegExp(escapeRegExp(term), flags.caseSensitive ? '' : 'i');
1568
+ const regex = flags.regex ? new RegExp(term, flags.caseSensitive ? '' : 'i') : new RegExp(escapeRegExp(term), flags.caseSensitive ? '' : 'i');
1561
1569
  const matches = [];
1562
1570
 
1563
1571
  lines.forEach((line, idx) => {
@@ -1686,7 +1694,7 @@ FIND CODE
1686
1694
  find <name> Find symbol definitions (supports glob: find "handle*")
1687
1695
  usages <name> All usages grouped: definitions, calls, imports, references
1688
1696
  toc Table of contents (compact; --detailed lists all symbols)
1689
- search <term> Text search (--context=N, --exclude=, --in=)
1697
+ search <term> Text search (--context=N, --exclude=, --in=, --regex)
1690
1698
  tests <name> Find test files for a function
1691
1699
 
1692
1700
  ═══════════════════════════════════════════════════════════════════════════════
@@ -1719,7 +1727,7 @@ OTHER
1719
1727
  ═══════════════════════════════════════════════════════════════════════════════
1720
1728
  api Show exported/public symbols
1721
1729
  typedef <name> Find type definitions
1722
- stats Project statistics
1730
+ stats Project statistics (--functions for per-function line counts)
1723
1731
  stacktrace <text> Parse stack trace, show code at each frame (alias: stack)
1724
1732
  example <name> Best usage example with context
1725
1733
 
@@ -1739,6 +1747,8 @@ Common Flags:
1739
1747
  --include-methods Include method calls (obj.fn) in caller/callee analysis
1740
1748
  --include-uncertain Include ambiguous/uncertain matches
1741
1749
  --include-exported Include exported symbols in deadcode
1750
+ --regex Use search term as a regex pattern
1751
+ --functions Show per-function line counts (stats command)
1742
1752
  --include-decorated Include decorated/annotated symbols in deadcode
1743
1753
  --exact Exact name match only (find)
1744
1754
  --calls-only Only show call/test-case matches (tests)
@@ -1898,6 +1908,8 @@ function parseInteractiveFlags(tokens) {
1898
1908
  base: tokens.find(a => a.startsWith('--base='))?.split('=')[1] || null,
1899
1909
  staged: tokens.includes('--staged'),
1900
1910
  maxLines: parseInt(tokens.find(a => a.startsWith('--max-lines='))?.split('=')[1] || '0') || null,
1911
+ regex: tokens.includes('--regex'),
1912
+ functions: tokens.includes('--functions'),
1901
1913
  };
1902
1914
  }
1903
1915
 
@@ -2011,7 +2023,7 @@ function executeInteractiveCommand(index, command, arg, iflags = {}) {
2011
2023
  if (arg.includes(',')) {
2012
2024
  const fnNames = arg.split(',').map(n => n.trim()).filter(Boolean);
2013
2025
  for (let i = 0; i < fnNames.length; i++) {
2014
- if (i > 0) console.log('');
2026
+ if (i > 0) console.log('\n' + '═'.repeat(60) + '\n');
2015
2027
  extractFunctionFromProject(index, fnNames[i], iflags);
2016
2028
  }
2017
2029
  } else {
@@ -2104,7 +2116,7 @@ function executeInteractiveCommand(index, command, arg, iflags = {}) {
2104
2116
  console.log('Usage: search <term>');
2105
2117
  return;
2106
2118
  }
2107
- const results = index.search(arg, { codeOnly: iflags.codeOnly, caseSensitive: iflags.caseSensitive, context: iflags.context, exclude: iflags.exclude, in: iflags.in });
2119
+ const results = index.search(arg, { codeOnly: iflags.codeOnly, caseSensitive: iflags.caseSensitive, context: iflags.context, exclude: iflags.exclude, in: iflags.in, regex: iflags.regex });
2108
2120
  console.log(output.formatSearch(results, arg));
2109
2121
  break;
2110
2122
  }
@@ -2136,8 +2148,8 @@ function executeInteractiveCommand(index, command, arg, iflags = {}) {
2136
2148
  }
2137
2149
 
2138
2150
  case 'stats': {
2139
- const stats = index.getStats();
2140
- console.log(output.formatStats(stats));
2151
+ const stats = index.getStats({ functions: iflags.functions });
2152
+ console.log(output.formatStats(stats, { top: iflags.top }));
2141
2153
  break;
2142
2154
  }
2143
2155
 
package/core/output.js CHANGED
@@ -1925,7 +1925,16 @@ function formatGraph(graph, options = {}) {
1925
1925
  */
1926
1926
  function formatSearch(results, term) {
1927
1927
  const totalMatches = results.reduce((sum, r) => sum + r.matches.length, 0);
1928
- if (totalMatches === 0) return `No matches found for "${term}"`;
1928
+ if (totalMatches === 0) {
1929
+ const meta = results.meta;
1930
+ if (meta) {
1931
+ const scope = meta.filesSkipped > 0
1932
+ ? `Searched ${meta.filesScanned} of ${meta.totalFiles} files (${meta.filesSkipped} excluded by filters).`
1933
+ : `Searched ${meta.filesScanned} files.`;
1934
+ return `No matches found for "${term}". ${scope}`;
1935
+ }
1936
+ return `No matches found for "${term}"`;
1937
+ }
1929
1938
 
1930
1939
  const lines = [];
1931
1940
  lines.push(`Found ${totalMatches} matches for "${term}" in ${results.length} files:`);
@@ -1969,7 +1978,7 @@ function formatFileExports(exports, filePath) {
1969
1978
  /**
1970
1979
  * Format stats command output
1971
1980
  */
1972
- function formatStats(stats) {
1981
+ function formatStats(stats, options = {}) {
1973
1982
  const lines = [];
1974
1983
  lines.push('PROJECT STATISTICS');
1975
1984
  lines.push('═'.repeat(60));
@@ -1988,6 +1997,19 @@ function formatStats(stats) {
1988
1997
  lines.push(` ${type}: ${count}`);
1989
1998
  }
1990
1999
 
2000
+ if (stats.functions) {
2001
+ const top = options.top || 30;
2002
+ const shown = stats.functions.slice(0, top);
2003
+ lines.push(`\nFunctions by line count (top ${shown.length} of ${stats.functions.length}):`);
2004
+ for (const fn of shown) {
2005
+ const loc = `${fn.file}:${fn.startLine}`;
2006
+ lines.push(` ${String(fn.lines).padStart(5)} lines ${fn.name} (${loc})`);
2007
+ }
2008
+ if (stats.functions.length > top) {
2009
+ lines.push(` ... ${stats.functions.length - top} more (use --top=N to show more)`);
2010
+ }
2011
+ }
2012
+
1991
2013
  return lines.join('\n');
1992
2014
  }
1993
2015
 
package/core/project.js CHANGED
@@ -4439,17 +4439,20 @@ class ProjectIndex {
4439
4439
  this._beginOp();
4440
4440
  try {
4441
4441
  const results = [];
4442
- // Escape the term to handle special regex characters
4442
+ let filesScanned = 0;
4443
+ let filesSkipped = 0;
4443
4444
  const regexFlags = options.caseSensitive ? 'g' : 'gi';
4444
- const regex = new RegExp(escapeRegExp(term), regexFlags);
4445
+ const regex = options.regex ? new RegExp(term, regexFlags) : new RegExp(escapeRegExp(term), regexFlags);
4445
4446
 
4446
4447
  for (const [filePath, fileEntry] of this.files) {
4447
4448
  // Apply exclude/in filters
4448
4449
  if ((options.exclude && options.exclude.length > 0) || options.in) {
4449
4450
  if (!this.matchesFilters(fileEntry.relativePath, { exclude: options.exclude, in: options.in })) {
4451
+ filesSkipped++;
4450
4452
  continue;
4451
4453
  }
4452
4454
  }
4455
+ filesScanned++;
4453
4456
  try {
4454
4457
  const content = this._readFile(filePath);
4455
4458
  const lines = content.split('\n');
@@ -4462,7 +4465,7 @@ class ProjectIndex {
4462
4465
  try {
4463
4466
  const parser = getParser(language);
4464
4467
  const { findMatchesWithASTFilter } = require('../languages/utils');
4465
- const astMatches = findMatchesWithASTFilter(content, term, parser, { codeOnly: true });
4468
+ const astMatches = findMatchesWithASTFilter(content, term, parser, { codeOnly: true, regex: options.regex });
4466
4469
 
4467
4470
  for (const m of astMatches) {
4468
4471
  const match = {
@@ -4542,6 +4545,7 @@ class ProjectIndex {
4542
4545
  }
4543
4546
  }
4544
4547
 
4548
+ results.meta = { filesScanned, filesSkipped, totalFiles: this.files.size };
4545
4549
  return results;
4546
4550
  } finally { this._endOp(); }
4547
4551
  }
@@ -4553,7 +4557,7 @@ class ProjectIndex {
4553
4557
  /**
4554
4558
  * Get project statistics
4555
4559
  */
4556
- getStats() {
4560
+ getStats(options = {}) {
4557
4561
  // Count total symbols (not just unique names)
4558
4562
  let totalSymbols = 0;
4559
4563
  for (const [name, symbols] of this.symbols) {
@@ -4588,6 +4592,27 @@ class ProjectIndex {
4588
4592
  }
4589
4593
  }
4590
4594
 
4595
+ // Per-function line counts for complexity audits
4596
+ if (options.functions) {
4597
+ const functions = [];
4598
+ for (const [name, symbols] of this.symbols) {
4599
+ for (const sym of symbols) {
4600
+ if (sym.type === 'function' || sym.params !== undefined) {
4601
+ const lineCount = sym.endLine - sym.startLine + 1;
4602
+ const relativePath = sym.relativePath || (sym.file ? path.relative(this.root, sym.file) : '');
4603
+ functions.push({
4604
+ name: sym.className ? `${sym.className}.${sym.name}` : sym.name,
4605
+ file: relativePath,
4606
+ startLine: sym.startLine,
4607
+ lines: lineCount
4608
+ });
4609
+ }
4610
+ }
4611
+ }
4612
+ functions.sort((a, b) => b.lines - a.lines);
4613
+ stats.functions = functions;
4614
+ }
4615
+
4591
4616
  return stats;
4592
4617
  }
4593
4618
 
@@ -417,9 +417,8 @@ function findMatchesWithASTFilter(content, term, parser, options = {}) {
417
417
  const lines = content.split('\n');
418
418
  const matches = [];
419
419
 
420
- // Escape special regex characters and create pattern
421
- const escapedTerm = term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
422
- const regex = new RegExp(escapedTerm, 'gi');
420
+ // Create search pattern — use raw regex when regex mode is enabled
421
+ const regex = options.regex ? new RegExp(term, 'gi') : new RegExp(term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'gi');
423
422
 
424
423
  lines.forEach((line, idx) => {
425
424
  const lineNum = idx + 1;
package/mcp/server.js CHANGED
@@ -196,7 +196,7 @@ FINDING CODE:
196
196
  - find <name>: Locate definitions ranked by usage count. Supports glob patterns (e.g. find "handle*" or "_update*"). Use when you know the name but not the file.
197
197
  - usages <name>: See every usage organized by type: definitions, calls, imports, references. Complete picture of how something is used. Use code_only=true to skip comments/strings.
198
198
  - toc: Get a quick overview of a project you haven't seen before — file counts, line counts, function/class counts, entry points. Use detailed=true for full symbol listing.
199
- - search <term>: Plain text search (like grep, respects .gitignore). Supports context=N for surrounding lines, exclude/in for file filtering. Case-insensitive by default; set case_sensitive=true for exact case.
199
+ - search <term>: Text search (like grep, respects .gitignore). Supports context=N for surrounding lines, exclude/in for file filtering. Case-insensitive by default; set case_sensitive=true for exact case. Set regex=true to use the term as a regex pattern (e.g. "\\d+" or "foo|bar").
200
200
  - tests <name>: Find test files covering a function, test case names, and how it's called in tests. Use before modifying or to find test patterns to follow.
201
201
  - deadcode: Find dead code: functions/classes with zero callers. Use during cleanup to identify safely deletable code. Excludes exported, decorated, and test symbols by default — use include_exported/include_decorated/include_tests to expand.
202
202
 
@@ -221,7 +221,7 @@ OTHER:
221
221
  - typedef <name>: Find type definitions matching a name: interfaces, enums, structs, traits, type aliases. See field shapes, required methods, or enum values.
222
222
  - stacktrace: Parse a stack trace, show source context per frame. Requires stack param. Handles JS, Python, Go, Rust, Java formats.
223
223
  - api: Public API surface of project or file: all exported/public symbols with signatures. Use to understand what a library exposes. Pass file to scope to one file. Python needs __all__; use toc instead.
224
- - stats: Quick project stats: file counts, symbol counts, lines of code by language and symbol type.`;
224
+ - stats: Quick project stats: file counts, symbol counts, lines of code by language and symbol type. Use functions=true for per-function line counts sorted by size (complexity audit).`;
225
225
 
226
226
  server.registerTool(
227
227
  'ucn',
@@ -256,7 +256,9 @@ server.registerTool(
256
256
  calls_only: z.boolean().optional().describe('Only direct calls and test-case matches (tests command)'),
257
257
  max_lines: z.number().optional().describe('Max source lines for class (large classes show summary by default)'),
258
258
  direction: z.enum(['imports', 'importers', 'both']).optional().describe('Graph direction: imports (what this file uses), importers (who uses this file), both (default: both)'),
259
- term: z.string().optional().describe('Search term (plain text, not regex)'),
259
+ term: z.string().optional().describe('Search term (plain text by default; set regex=true to use as regex pattern)'),
260
+ regex: z.boolean().optional().describe('Treat search term as a regex pattern (default: false, plain text)'),
261
+ functions: z.boolean().optional().describe('Include per-function line counts in stats output, sorted by size (complexity audit)'),
260
262
  add_param: z.string().optional().describe('Parameter name to add (plan command)'),
261
263
  remove_param: z.string().optional().describe('Parameter name to remove (plan command)'),
262
264
  rename_to: z.string().optional().describe('New function name (plan command)'),
@@ -276,7 +278,7 @@ server.registerTool(
276
278
  include_exported, include_decorated, calls_only, max_lines,
277
279
  direction, term, add_param, remove_param, rename_to,
278
280
  default_value, stack, item, range, base, staged,
279
- case_sensitive } = args;
281
+ case_sensitive, regex, functions } = args;
280
282
 
281
283
  try {
282
284
  switch (command) {
@@ -431,7 +433,8 @@ server.registerTool(
431
433
  context: ctxLines || 0,
432
434
  caseSensitive: case_sensitive || false,
433
435
  exclude: searchExclude,
434
- in: inPath || undefined
436
+ in: inPath || undefined,
437
+ regex: regex || false
435
438
  });
436
439
  return toolResult(output.formatSearch(result, term));
437
440
  }
@@ -495,7 +498,8 @@ server.registerTool(
495
498
  parts.push(note + output.formatFn(match, fnCode));
496
499
  }
497
500
 
498
- return toolResult(parts.join('\n\n'));
501
+ const separator = fnNames.length > 1 ? '\n\n' + '═'.repeat(60) + '\n\n' : '\n\n';
502
+ return toolResult(parts.join(separator));
499
503
  }
500
504
 
501
505
  case 'class': {
@@ -805,8 +809,8 @@ server.registerTool(
805
809
 
806
810
  case 'stats': {
807
811
  const index = getIndex(project_dir);
808
- const stats = index.getStats();
809
- return toolResult(output.formatStats(stats));
812
+ const stats = index.getStats({ functions: functions || false });
813
+ return toolResult(output.formatStats(stats, { top: top || 30 }));
810
814
  }
811
815
 
812
816
  default:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ucn",
3
- "version": "3.7.11",
3
+ "version": "3.7.12",
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",