ucn 3.8.21 → 3.8.23

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/project.js CHANGED
@@ -23,6 +23,7 @@ const tracingModule = require('./tracing');
23
23
  const searchModule = require('./search');
24
24
  const analysisModule = require('./analysis');
25
25
  const graphModule = require('./graph');
26
+ const graphBuildModule = require('./graph-build');
26
27
  const reportingModule = require('./reporting');
27
28
 
28
29
  // Lazy-initialized per-language keyword sets (populated on first isKeyword call)
@@ -76,6 +77,7 @@ class ProjectIndex {
76
77
  this._opContentCache = new Map();
77
78
  this._opUsagesCache = new Map();
78
79
  this._opCallsCountCache = new Map();
80
+ this._opEnclosingFnCache = new Map();
79
81
  this._opDepth = 0;
80
82
  }
81
83
  this._opDepth++;
@@ -83,10 +85,17 @@ class ProjectIndex {
83
85
 
84
86
  /** End a per-operation content cache scope (only clears when outermost scope ends) */
85
87
  _endOp() {
88
+ if (!this._opContentCache) return; // Mismatched call — no active operation
86
89
  if (--this._opDepth <= 0) {
87
90
  this._opContentCache = null;
88
91
  this._opUsagesCache = null;
89
92
  this._opCallsCountCache = null;
93
+ this._opEnclosingFnCache = null;
94
+ // Free cached file content from callsCache entries (retained during
95
+ // operation for _readFile caching, not needed between operations)
96
+ for (const entry of this.callsCache.values()) {
97
+ if (entry.content !== undefined) entry.content = undefined;
98
+ }
90
99
  this._opDepth = 0;
91
100
  }
92
101
  }
@@ -473,30 +482,78 @@ class ProjectIndex {
473
482
  }
474
483
  }
475
484
 
476
- // Invalidate cached call data for this file
485
+ // Incrementally update callee index before deleting cached calls
486
+ const oldCached = this.callsCache.get(filePath);
487
+ if (oldCached) {
488
+ this._removeFromCalleeIndex(filePath, oldCached.calls);
489
+ }
477
490
  this.callsCache.delete(filePath);
478
491
 
479
- // Invalidate callee index (will be rebuilt lazily)
480
- this.calleeIndex = null;
481
-
482
492
  // Invalidate attribute type cache for this file
483
493
  if (this._attrTypeCache) this._attrTypeCache.delete(filePath);
494
+
495
+ // Invalidate lazy Java file index (will be rebuilt on next use)
496
+ this._javaFileIndex = null;
484
497
  }
485
498
 
486
499
  /**
487
500
  * Build directory→files index for O(1) same-package lookups.
488
501
  * Replaces O(N) full-index scans in findCallers and countSymbolUsages.
489
502
  */
490
- _buildDirIndex() {
491
- this.dirToFiles = new Map();
492
- for (const filePath of this.files.keys()) {
493
- const dir = path.dirname(filePath);
494
- let list = this.dirToFiles.get(dir);
495
- if (!list) {
496
- list = [];
497
- this.dirToFiles.set(dir, list);
503
+ _buildDirIndex() { graphBuildModule.buildDirIndex(this); }
504
+
505
+ /**
506
+ * Add a file's calls to the callee index (name → Set<filePath>).
507
+ * Used by buildCalleeIndex (full build) and getCachedCalls (incremental update).
508
+ */
509
+ _addToCalleeIndex(filePath, calls) {
510
+ if (!this.calleeIndex || !calls) return;
511
+ for (const call of calls) {
512
+ const name = call.name;
513
+ if (!this.calleeIndex.has(name)) {
514
+ this.calleeIndex.set(name, new Set());
515
+ }
516
+ this.calleeIndex.get(name).add(filePath);
517
+ if (call.resolvedName && call.resolvedName !== name) {
518
+ if (!this.calleeIndex.has(call.resolvedName)) {
519
+ this.calleeIndex.set(call.resolvedName, new Set());
520
+ }
521
+ this.calleeIndex.get(call.resolvedName).add(filePath);
522
+ }
523
+ if (call.resolvedNames) {
524
+ for (const rn of call.resolvedNames) {
525
+ if (rn !== name) {
526
+ if (!this.calleeIndex.has(rn)) {
527
+ this.calleeIndex.set(rn, new Set());
528
+ }
529
+ this.calleeIndex.get(rn).add(filePath);
530
+ }
531
+ }
532
+ }
533
+ }
534
+ }
535
+
536
+ /**
537
+ * Remove a file's calls from the callee index.
538
+ * Used by removeFileSymbols for incremental updates instead of full invalidation.
539
+ */
540
+ _removeFromCalleeIndex(filePath, calls) {
541
+ if (!this.calleeIndex || !calls) return;
542
+ for (const call of calls) {
543
+ const removeName = (n) => {
544
+ const fileSet = this.calleeIndex.get(n);
545
+ if (fileSet) {
546
+ fileSet.delete(filePath);
547
+ if (fileSet.size === 0) this.calleeIndex.delete(n);
548
+ }
549
+ };
550
+ removeName(call.name);
551
+ if (call.resolvedName && call.resolvedName !== call.name) removeName(call.resolvedName);
552
+ if (call.resolvedNames) {
553
+ for (const rn of call.resolvedNames) {
554
+ if (rn !== call.name) removeName(rn);
555
+ }
498
556
  }
499
- list.push(filePath);
500
557
  }
501
558
  }
502
559
 
@@ -507,37 +564,15 @@ class ProjectIndex {
507
564
  */
508
565
  buildCalleeIndex() {
509
566
  const { getCachedCalls } = require('./callers');
567
+ const { ensureCallsCacheLoaded } = require('./cache');
568
+ ensureCallsCacheLoaded(this);
510
569
  this.calleeIndex = new Map();
511
570
 
512
571
  for (const [filePath] of this.files) {
513
572
  // Fast path: use pre-populated callsCache (avoids stat per file)
514
573
  const cached = this.callsCache.get(filePath);
515
574
  const calls = cached ? cached.calls : getCachedCalls(this, filePath);
516
- if (!calls) continue;
517
- for (const call of calls) {
518
- const name = call.name;
519
- if (!this.calleeIndex.has(name)) {
520
- this.calleeIndex.set(name, new Set());
521
- }
522
- this.calleeIndex.get(name).add(filePath);
523
- // Also index resolvedName and resolvedNames for alias resolution
524
- if (call.resolvedName && call.resolvedName !== name) {
525
- if (!this.calleeIndex.has(call.resolvedName)) {
526
- this.calleeIndex.set(call.resolvedName, new Set());
527
- }
528
- this.calleeIndex.get(call.resolvedName).add(filePath);
529
- }
530
- if (call.resolvedNames) {
531
- for (const rn of call.resolvedNames) {
532
- if (rn !== name) {
533
- if (!this.calleeIndex.has(rn)) {
534
- this.calleeIndex.set(rn, new Set());
535
- }
536
- this.calleeIndex.get(rn).add(filePath);
537
- }
538
- }
539
- }
540
- }
575
+ this._addToCalleeIndex(filePath, calls);
541
576
  }
542
577
  }
543
578
 
@@ -558,194 +593,32 @@ class ProjectIndex {
558
593
  * Progressively strips trailing segments to find the class file.
559
594
  */
560
595
  _resolveJavaPackageImport(importModule, javaFileIndex) {
561
- const isWildcard = importModule.endsWith('.*');
562
- // Strip wildcard suffix (e.g., "com.pkg.Class.*" -> "com.pkg.Class")
563
- const mod = isWildcard ? importModule.slice(0, -2) : importModule;
564
- const segments = mod.split('.');
565
-
566
- // Try progressively shorter paths: full path, then strip last segment, etc.
567
- // This handles static imports where path includes member name after class
568
- if (javaFileIndex) {
569
- // Fast path: use pre-built filename→files index (O(candidates) vs O(all files))
570
- for (let i = segments.length; i > 0; i--) {
571
- const className = segments[i - 1];
572
- const candidates = javaFileIndex.get(className);
573
- if (candidates) {
574
- const fileSuffix = '/' + segments.slice(0, i).join('/') + '.java';
575
- for (const absPath of candidates) {
576
- if (absPath.endsWith(fileSuffix)) {
577
- return absPath;
578
- }
579
- }
580
- }
581
- }
582
- } else {
583
- // Fallback: scan all files (used by imports() method outside buildImportGraph)
584
- for (let i = segments.length; i > 0; i--) {
585
- const fileSuffix = '/' + segments.slice(0, i).join('/') + '.java';
586
- for (const absPath of this.files.keys()) {
587
- if (absPath.endsWith(fileSuffix)) {
588
- return absPath;
596
+ if (!javaFileIndex) {
597
+ // Lazy-build index to avoid O(N) fallback scan of all files
598
+ if (!this._javaFileIndex) {
599
+ this._javaFileIndex = new Map();
600
+ for (const [fp, fe] of this.files) {
601
+ if (fe.language === 'java') {
602
+ const name = path.basename(fp, '.java');
603
+ if (!this._javaFileIndex.has(name)) this._javaFileIndex.set(name, []);
604
+ this._javaFileIndex.get(name).push(fp);
589
605
  }
590
606
  }
591
607
  }
608
+ javaFileIndex = this._javaFileIndex;
592
609
  }
593
-
594
- // For wildcard imports (com.pkg.model.*), the package may be a directory
595
- // containing .java files. Check if any file lives under this package path.
596
- if (isWildcard) {
597
- const dirSuffix = '/' + segments.join('/') + '/';
598
- for (const absPath of this.files.keys()) {
599
- if (absPath.includes(dirSuffix)) {
600
- return absPath;
601
- }
602
- }
603
- }
604
-
605
- return null;
610
+ return graphBuildModule._resolveJavaPackageImport(this, importModule, javaFileIndex);
606
611
  }
607
612
 
608
613
  /**
609
614
  * Build import/export relationship graphs
610
615
  */
611
- buildImportGraph() {
612
- this.importGraph.clear();
613
- this.exportGraph.clear();
614
-
615
- // Pre-build directory→files map for Go package linking (O(1) lookup vs O(n) scan)
616
- const dirToGoFiles = new Map();
617
- // Pre-build filename→files map for Java import resolution (O(1) vs O(n) scan)
618
- const javaFileIndex = new Map();
619
- for (const [fp, fe] of this.files) {
620
- if (langTraits(fe.language)?.packageScope === 'directory') {
621
- const dir = path.dirname(fp);
622
- if (!dirToGoFiles.has(dir)) dirToGoFiles.set(dir, []);
623
- dirToGoFiles.get(dir).push(fp);
624
- } else if (fe.language === 'java') {
625
- const name = path.basename(fp, '.java');
626
- if (!javaFileIndex.has(name)) javaFileIndex.set(name, []);
627
- javaFileIndex.get(name).push(fp);
628
- }
629
- }
630
-
631
- for (const [filePath, fileEntry] of this.files) {
632
- const importedFiles = [];
633
- const seenModules = new Set();
634
-
635
- for (const importModule of fileEntry.imports) {
636
- // Skip null modules (e.g., dynamic include! macros in Rust)
637
- if (!importModule) continue;
638
-
639
- // Deduplicate: same module imported multiple times in one file
640
- // (e.g., lazy imports inside different functions)
641
- if (seenModules.has(importModule)) continue;
642
- seenModules.add(importModule);
643
-
644
- let resolved = resolveImport(importModule, filePath, {
645
- aliases: this.config.aliases,
646
- language: fileEntry.language,
647
- root: this.root
648
- });
649
-
650
- // Java package imports: resolve by progressive suffix matching
651
- // Handles regular, static (com.pkg.Class.method), and wildcard (com.pkg.Class.*) imports
652
- if (!resolved && fileEntry.language === 'java' && !importModule.startsWith('.')) {
653
- resolved = this._resolveJavaPackageImport(importModule, javaFileIndex);
654
- }
655
-
656
- if (resolved && this.files.has(resolved)) {
657
- // For Go, a package import means all files in that directory are dependencies
658
- // (Go packages span multiple files in the same directory)
659
- const filesToLink = [resolved];
660
- if (langTraits(fileEntry.language)?.packageScope === 'directory') {
661
- const pkgDir = path.dirname(resolved);
662
- const dirFiles = dirToGoFiles.get(pkgDir) || [];
663
- const importerIsTest = filePath.endsWith('_test.go');
664
- for (const fp of dirFiles) {
665
- if (fp !== resolved) {
666
- if (!importerIsTest && fp.endsWith('_test.go')) continue;
667
- filesToLink.push(fp);
668
- }
669
- }
670
- }
671
-
672
- for (const linkedFile of filesToLink) {
673
- importedFiles.push(linkedFile);
674
- if (!this.exportGraph.has(linkedFile)) {
675
- this.exportGraph.set(linkedFile, []);
676
- }
677
- this.exportGraph.get(linkedFile).push(filePath);
678
- }
679
- }
680
- }
681
-
682
- this.importGraph.set(filePath, importedFiles);
683
- }
684
- }
616
+ buildImportGraph() { graphBuildModule.buildImportGraph(this); }
685
617
 
686
618
  /**
687
619
  * Build inheritance relationship graphs
688
620
  */
689
- buildInheritanceGraph() {
690
- this.extendsGraph.clear();
691
- this.extendedByGraph.clear();
692
-
693
- // Collect all class/interface/struct names for alias resolution
694
- const classNames = new Set();
695
- for (const [, fileEntry] of this.files) {
696
- for (const symbol of fileEntry.symbols) {
697
- if (['class', 'interface', 'struct', 'trait', 'record'].includes(symbol.type)) {
698
- classNames.add(symbol.name);
699
- }
700
- }
701
- }
702
-
703
- for (const [filePath, fileEntry] of this.files) {
704
- for (const symbol of fileEntry.symbols) {
705
- if (!['class', 'interface', 'struct', 'trait', 'record'].includes(symbol.type)) {
706
- continue;
707
- }
708
-
709
- if (symbol.extends) {
710
- // Parse comma-separated parents (Python MRO: "Flyable, Swimmable")
711
- const parents = symbol.extends.split(',').map(s => s.trim()).filter(Boolean);
712
-
713
- // Resolve aliased parent names via import aliases
714
- // e.g., const { BaseHandler: Handler } = require('./base')
715
- // class Child extends Handler → resolve Handler to BaseHandler
716
- const resolvedParents = parents.map(parent => {
717
- if (classNames.has(parent)) return parent;
718
- if (fileEntry.importAliases) {
719
- const alias = fileEntry.importAliases.find(a => a.local === parent);
720
- if (alias && classNames.has(alias.original)) return alias.original;
721
- }
722
- return parent;
723
- });
724
-
725
- // Store with file scope to avoid collisions when same class name
726
- // appears in multiple files (F-002 fix)
727
- if (!this.extendsGraph.has(symbol.name)) {
728
- this.extendsGraph.set(symbol.name, []);
729
- }
730
- this.extendsGraph.get(symbol.name).push({
731
- file: filePath,
732
- parents: resolvedParents
733
- });
734
-
735
- for (const parent of resolvedParents) {
736
- if (!this.extendedByGraph.has(parent)) {
737
- this.extendedByGraph.set(parent, []);
738
- }
739
- this.extendedByGraph.get(parent).push({
740
- name: symbol.name,
741
- type: symbol.type,
742
- file: filePath
743
- });
744
- }
745
- }
746
- }
747
- }
748
- }
621
+ buildInheritanceGraph() { graphBuildModule.buildInheritanceGraph(this); }
749
622
 
750
623
  /**
751
624
  * Get inheritance parents for a class, scoped by file to handle
@@ -768,7 +641,7 @@ class ProjectIndex {
768
641
  if (contextFile) {
769
642
  const imports = this.importGraph.get(contextFile);
770
643
  if (imports) {
771
- const imported = entries.find(e => imports.includes(e.file));
644
+ const imported = entries.find(e => imports.has(e.file));
772
645
  if (imported) return imported.parents;
773
646
  }
774
647
  }
@@ -800,7 +673,7 @@ class ProjectIndex {
800
673
  if (contextFile) {
801
674
  const imports = this.importGraph.get(contextFile);
802
675
  if (imports) {
803
- const imported = classSymbols.find(s => imports.includes(s.file));
676
+ const imported = classSymbols.find(s => imports.has(s.file));
804
677
  if (imported) return imported.file;
805
678
  }
806
679
  }
@@ -1010,7 +883,7 @@ class ProjectIndex {
1010
883
  for (const candidate of tiedCandidates) {
1011
884
  let importerCount = 0;
1012
885
  for (const [, importedFiles] of this.importGraph) {
1013
- if (importedFiles.includes(candidate.def.file)) {
886
+ if (importedFiles.has(candidate.def.file)) {
1014
887
  importerCount++;
1015
888
  }
1016
889
  }
@@ -1085,8 +958,7 @@ class ProjectIndex {
1085
958
  const hasFilters = options.exclude && options.exclude.length > 0;
1086
959
 
1087
960
  // Pre-compute which files can reference THIS specific definition
1088
- const importers = this.exportGraph.get(defFile) || [];
1089
- const importersSet = new Set(importers);
961
+ const importersSet = this.exportGraph.get(defFile) || new Set();
1090
962
  const defEntry = this.files.get(defFile);
1091
963
  const isDirectoryScope = langTraits(defEntry?.language)?.packageScope === 'directory';
1092
964
  const defDir = isDirectoryScope ? path.dirname(defFile) : null;
@@ -1149,7 +1021,7 @@ class ProjectIndex {
1149
1021
 
1150
1022
  // Count imports from import graph (files that import from defFile and use this name)
1151
1023
  let imports = 0;
1152
- for (const importer of importers) {
1024
+ for (const importer of importersSet) {
1153
1025
  const fe = this.files.get(importer);
1154
1026
  if (!fe) continue;
1155
1027
  if (hasFilters && !this.matchesFilters(fe.relativePath, { exclude: options.exclude })) continue;
@@ -1208,7 +1080,7 @@ class ProjectIndex {
1208
1080
 
1209
1081
  while (queue.length > 0) {
1210
1082
  const file = queue.pop();
1211
- const importersArr = this.exportGraph.get(file) || [];
1083
+ const importersArr = this.exportGraph.get(file) || new Set();
1212
1084
  for (const importer of importersArr) {
1213
1085
  if (!relevantFiles.has(importer)) {
1214
1086
  relevantFiles.add(importer);
@@ -1734,6 +1606,15 @@ class ProjectIndex {
1734
1606
  const fileEntry = this.files.get(filePath);
1735
1607
  if (!fileEntry) return null;
1736
1608
 
1609
+ // Per-operation cache: avoid rescanning symbols for same (file, line)
1610
+ const cacheKey = filePath + '\0' + lineNum;
1611
+ if (this._opEnclosingFnCache) {
1612
+ const cached = this._opEnclosingFnCache.get(cacheKey);
1613
+ if (cached !== undefined) {
1614
+ return cached === null ? null : (returnSymbol ? cached : cached.name);
1615
+ }
1616
+ }
1617
+
1737
1618
  let best = null;
1738
1619
  for (const symbol of fileEntry.symbols) {
1739
1620
  if (!NON_CALLABLE_TYPES.has(symbol.type) &&
@@ -1744,8 +1625,11 @@ class ProjectIndex {
1744
1625
  }
1745
1626
  }
1746
1627
  }
1747
- if (!best) return null;
1748
- return returnSymbol ? best : best.name;
1628
+
1629
+ if (this._opEnclosingFnCache) {
1630
+ this._opEnclosingFnCache.set(cacheKey, best);
1631
+ }
1632
+ return best ? (returnSymbol ? best : best.name) : null;
1749
1633
  }
1750
1634
 
1751
1635
  /** Get instance attribute types for a class in a file */
package/core/registry.js CHANGED
@@ -143,6 +143,10 @@ const BROAD_COMMANDS = new Set([
143
143
  'deadcode', 'usages', 'reverseTrace', 'circularDeps',
144
144
  ]);
145
145
 
146
+ // Commands that can operate on a single file without a project index.
147
+ // Used by CLI to decide whether to build a file-local or project-wide index.
148
+ const FILE_LOCAL_COMMANDS = new Set(['toc', 'fn', 'class', 'find', 'usages', 'search', 'lines', 'typedef', 'api']);
149
+
146
150
  // ============================================================================
147
151
  // HELPERS
148
152
  // ============================================================================
@@ -263,6 +267,7 @@ module.exports = {
263
267
  REVERSE_PARAM_MAP,
264
268
  FLAG_APPLICABILITY,
265
269
  BROAD_COMMANDS,
270
+ FILE_LOCAL_COMMANDS,
266
271
  resolveCommand,
267
272
  normalizeParams,
268
273
  getCliCommandSet,
package/core/search.js CHANGED
@@ -221,11 +221,15 @@ function usages(index, name, options = {}) {
221
221
  let _importedHasDef = null;
222
222
  const importedFileHasDef = () => {
223
223
  if (_importedHasDef !== null) return _importedHasDef;
224
- const importedFiles = index.importGraph.get(filePath) || [];
225
- _importedHasDef = importedFiles.some(imp => {
224
+ const importedFiles = index.importGraph.get(filePath);
225
+ _importedHasDef = false;
226
+ if (importedFiles) for (const imp of importedFiles) {
226
227
  const impEntry = index.files.get(imp);
227
- return impEntry?.symbols?.some(s => s.name === name);
228
- });
228
+ if (impEntry?.symbols?.some(s => s.name === name)) {
229
+ _importedHasDef = true;
230
+ break;
231
+ }
232
+ }
229
233
  return _importedHasDef;
230
234
  };
231
235
 
@@ -1008,7 +1012,7 @@ function _buildSourceFileImporters(index, defs) {
1008
1012
 
1009
1013
  while (queue.length > 0) {
1010
1014
  const current = queue.shift();
1011
- const directImporters = index.exportGraph?.get(current) || [];
1015
+ const directImporters = index.exportGraph?.get(current) || new Set();
1012
1016
  for (const imp of directImporters) {
1013
1017
  importers.add(imp);
1014
1018
  // Check if this importer re-exports the symbol (barrel pattern).
package/core/tracing.js CHANGED
@@ -435,13 +435,22 @@ function reverseTrace(index, name, options = {}) {
435
435
  if (callerEntries.length > maxChildren) {
436
436
  node.truncatedChildren = callerEntries.length - maxChildren;
437
437
  // Count entry points in truncated branches so summary is accurate
438
+ // Use callerCache to avoid redundant findCallers calls
438
439
  for (const { def: cDef } of callerEntries.slice(maxChildren)) {
439
- const key = `${cDef.file}:${cDef.startLine}`;
440
- if (!visited.has(key)) {
441
- const cCallers = index.findCallers(cDef.name, {
442
- includeMethods, includeUncertain,
443
- targetDefinitions: cDef.bindingId ? [cDef] : undefined,
444
- });
440
+ const cKey = `${cDef.file}:${cDef.startLine}`;
441
+ if (!visited.has(cKey)) {
442
+ const cCacheKey = cDef.bindingId
443
+ ? `${cDef.name}:${cDef.bindingId}`
444
+ : `${cDef.name}:${cKey}`;
445
+ let cCallers = callerCache.get(cCacheKey);
446
+ if (!cCallers) {
447
+ cCallers = index.findCallers(cDef.name, {
448
+ includeMethods, includeUncertain,
449
+ targetDefinitions: cDef.bindingId ? [cDef] : undefined,
450
+ maxResults: 1, // Only need to know if any exist
451
+ });
452
+ callerCache.set(cCacheKey, cCallers);
453
+ }
445
454
  if (cCallers.length === 0) {
446
455
  entryPoints.push({ name: cDef.name, file: cDef.relativePath || path.relative(index.root, cDef.file), line: cDef.startLine });
447
456
  }
package/languages/go.js CHANGED
@@ -45,14 +45,14 @@ function extractGoParams(paramsNode) {
45
45
  */
46
46
  function extractReceiver(receiverNode) {
47
47
  if (!receiverNode) return null;
48
- const text = receiverNode.text;
49
- // Match named receiver: (r *Router) or (r Router[T])
50
- const namedMatch = text.match(/\(\s*\w+\s+(\*?\w+(?:\[[\w,\s]+\])?)\s*\)/);
51
- if (namedMatch) return namedMatch[1];
52
- // Match unnamed receiver: (Router) or (*Router) or (Router[T])
53
- const unnamedMatch = text.match(/\(\s*(\*?\w+(?:\[[\w,\s]+\])?)\s*\)/);
54
- if (unnamedMatch) return unnamedMatch[1];
55
- return text.replace(/^\(|\)$/g, '').trim();
48
+ // receiverNode is a parameter_list: (r *Router)
49
+ // Find the parameter_declaration child
50
+ const param = receiverNode.namedChildren.find(c => c.type === 'parameter_declaration');
51
+ if (!param) return receiverNode.text.replace(/^\(|\)$/g, '').trim();
52
+ // The type is the last named child (name is first for named receivers)
53
+ const typeNode = param.namedChildren[param.namedChildren.length - 1];
54
+ if (!typeNode) return null;
55
+ return typeNode.text;
56
56
  }
57
57
 
58
58
  // --- Single-pass helpers: extracted from find* callbacks ---
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ucn",
3
- "version": "3.8.21",
3
+ "version": "3.8.23",
4
4
  "mcpName": "io.github.mleoca/ucn",
5
5
  "description": "Code intelligence toolkit for AI agents — extract functions, trace call chains, find callers, detect dead code without reading entire files. Works as MCP server, CLI, or agent skill. Supports JS/TS, Python, Go, Rust, Java.",
6
6
  "main": "index.js",
@@ -9,7 +9,7 @@
9
9
  "ucn-mcp": "mcp/server.js"
10
10
  },
11
11
  "scripts": {
12
- "test": "node --test test/parser-unit.test.js test/integration.test.js test/cache.test.js test/formatter.test.js test/interactive.test.js test/feature.test.js test/regression-js.test.js test/regression-py.test.js test/regression-go.test.js test/regression-java.test.js test/regression-rust.test.js test/regression-cross.test.js test/regression-mcp.test.js test/regression-parser.test.js test/regression-commands.test.js test/regression-fixes.test.js test/regression-bugfixes.test.js test/cross-language.test.js test/accuracy.test.js test/command-coverage.test.js test/systematic-test.js test/mcp-edge-cases.js test/parity-test.js",
12
+ "test": "node --test test/parser-unit.test.js test/integration.test.js test/cache.test.js test/formatter.test.js test/interactive.test.js test/feature.test.js test/regression-js.test.js test/regression-py.test.js test/regression-go.test.js test/regression-java.test.js test/regression-rust.test.js test/regression-cross.test.js test/regression-mcp.test.js test/regression-parser.test.js test/regression-commands.test.js test/regression-fixes.test.js test/regression-bugfixes.test.js test/cross-language.test.js test/accuracy.test.js test/command-coverage.test.js test/perf-optimizations.test.js test/systematic-test.js test/mcp-edge-cases.js test/parity-test.js",
13
13
  "benchmark:agent": "node test/agent-understanding-benchmark.js",
14
14
  "lint": "eslint core/ cli/ mcp/ languages/"
15
15
  },