ucn 3.7.28 → 3.7.30

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/callers.js CHANGED
@@ -386,6 +386,13 @@ function findCallees(index, def, options = {}) {
386
386
  let selfAttrCalls = null; // collected for Python self.attr.method() resolution
387
387
  let selfMethodCalls = null; // collected for Python self.method() resolution
388
388
 
389
+ // Build local variable type map for receiver resolution
390
+ // Scans for patterns like: bt = Backtester(...) → bt maps to Backtester
391
+ let localTypes = null;
392
+ if (language === 'python' || language === 'javascript') {
393
+ localTypes = _buildLocalTypeMap(index, def, calls);
394
+ }
395
+
389
396
  for (const call of calls) {
390
397
  // Filter to calls within this function's scope
391
398
  // Method 1: Direct match via enclosingFunction (fast path for direct calls)
@@ -412,6 +419,22 @@ function findCallees(index, def, options = {}) {
412
419
  // self.method() / cls.method() / this.method() — resolve to same-class method below
413
420
  } else if (call.receiver === 'super') {
414
421
  // super().method() — resolve to parent class method below
422
+ } else if (localTypes && localTypes.has(call.receiver)) {
423
+ // Resolve method calls on locally-constructed objects:
424
+ // bt = Backtester(...); bt.run_backtest() → Backtester.run_backtest
425
+ const className = localTypes.get(call.receiver);
426
+ const symbols = index.symbols.get(call.name);
427
+ const match = symbols?.find(s => s.className === className);
428
+ if (match) {
429
+ const key = match.bindingId || `${className}.${call.name}`;
430
+ const existing = callees.get(key);
431
+ if (existing) {
432
+ existing.count += 1;
433
+ } else {
434
+ callees.set(key, { name: call.name, bindingId: match.bindingId, count: 1 });
435
+ }
436
+ }
437
+ continue;
415
438
  } else if (language !== 'go' && language !== 'java' && language !== 'rust' && !options.includeMethods) {
416
439
  continue;
417
440
  }
@@ -696,11 +719,20 @@ function findCallees(index, def, options = {}) {
696
719
  const sameDir = symbols.find(s => path.dirname(s.file) === defDir);
697
720
  if (sameDir) {
698
721
  callee = sameDir;
699
- } else if (defReceiver) {
700
- // Priority 3: Same receiver type (for methods)
701
- const sameReceiver = symbols.find(s => s.receiver === defReceiver);
702
- if (sameReceiver) {
703
- callee = sameReceiver;
722
+ } else {
723
+ // Priority 2.5: Imported file check if the caller's file imports
724
+ // from any of the candidate callee files
725
+ const callerImports = fileEntry?.imports || [];
726
+ const importedFiles = new Set(callerImports.map(imp => imp.resolvedPath).filter(Boolean));
727
+ const importedCallee = symbols.find(s => importedFiles.has(s.file));
728
+ if (importedCallee) {
729
+ callee = importedCallee;
730
+ } else if (defReceiver) {
731
+ // Priority 3: Same receiver type (for methods)
732
+ const sameReceiver = symbols.find(s => s.receiver === defReceiver);
733
+ if (sameReceiver) {
734
+ callee = sameReceiver;
735
+ }
704
736
  }
705
737
  }
706
738
  }
@@ -789,6 +821,60 @@ function getInstanceAttributeTypes(index, filePath, className) {
789
821
  return fileCache.get(className) || null;
790
822
  }
791
823
 
824
+ /**
825
+ * Build a local variable type map for a function body.
826
+ * Scans for constructor-call assignments: var = ClassName(...)
827
+ * Returns Map<varName, className> or null if none found.
828
+ * @param {object} index - ProjectIndex instance
829
+ * @param {object} def - Function definition with file, startLine, endLine
830
+ * @param {Array} calls - Cached call sites for the file
831
+ */
832
+ function _buildLocalTypeMap(index, def, calls) {
833
+ let content;
834
+ try {
835
+ content = index._readFile(def.file);
836
+ } catch {
837
+ return null;
838
+ }
839
+ const lines = content.split('\n');
840
+ const localTypes = new Map();
841
+
842
+ for (const call of calls) {
843
+ // Only look at calls within this function's scope
844
+ if (call.line < def.startLine || call.line > def.endLine) continue;
845
+ // Only direct calls (not method calls) — these are potential constructors
846
+ if (call.isMethod || call.isPotentialCallback) continue;
847
+
848
+ // Check if this call's name corresponds to a class in the symbol table
849
+ const symbols = index.symbols.get(call.name);
850
+ if (!symbols) continue;
851
+ const isClass = symbols.some(s => NON_CALLABLE_TYPES.has(s.type));
852
+ if (!isClass) continue;
853
+
854
+ // Check the source line for assignment pattern: var = ClassName(...)
855
+ const sourceLine = lines[call.line - 1];
856
+ if (!sourceLine) continue;
857
+
858
+ // Match: identifier = ClassName(...) or identifier: Type = ClassName(...)
859
+ const escapedName = call.name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
860
+ const assignMatch = sourceLine.match(
861
+ new RegExp(`(\\w+)\\s*(?::\\s*\\w+)?\\s*=\\s*${escapedName}\\s*\\(`)
862
+ );
863
+ if (assignMatch) {
864
+ localTypes.set(assignMatch[1], call.name);
865
+ }
866
+ // Match: with ClassName(...) as identifier:
867
+ const withMatch = sourceLine.match(
868
+ new RegExp(`with\\s+${escapedName}\\s*\\([^)]*\\)\\s+as\\s+(\\w+)`)
869
+ );
870
+ if (withMatch) {
871
+ localTypes.set(withMatch[1], call.name);
872
+ }
873
+ }
874
+
875
+ return localTypes.size > 0 ? localTypes : null;
876
+ }
877
+
792
878
  /**
793
879
  * Check if a function is used as a callback anywhere in the codebase
794
880
  * @param {object} index - ProjectIndex instance
package/core/execute.js CHANGED
@@ -126,6 +126,7 @@ const HANDLERS = {
126
126
  const result = index.impact(p.name, {
127
127
  file: p.file,
128
128
  exclude: toExcludeArray(p.exclude),
129
+ top: num(p.top, undefined),
129
130
  });
130
131
  if (!result) return { ok: false, error: `Function "${p.name}" not found.` };
131
132
  return { ok: true, result };
@@ -184,7 +185,13 @@ const HANDLERS = {
184
185
  find: (index, p) => {
185
186
  const err = requireName(p.name);
186
187
  if (err) return { ok: false, error: err };
187
- const exclude = applyTestExclusions(p.exclude, p.includeTests);
188
+ // Auto-include tests when pattern clearly targets test functions
189
+ // But only if the user didn't explicitly set include_tests=false
190
+ let includeTests = p.includeTests;
191
+ if (includeTests === undefined && p.name && /^test[_*?]/i.test(p.name)) {
192
+ includeTests = true;
193
+ }
194
+ const exclude = applyTestExclusions(p.exclude, includeTests);
188
195
  const result = index.find(p.name, {
189
196
  file: p.file,
190
197
  exact: p.exact || false,
@@ -125,6 +125,50 @@ class ExpandCache {
125
125
  }
126
126
  }
127
127
 
128
+ /**
129
+ * Detect the end of a function/method body starting from startLine.
130
+ * Uses brace/indent counting to find the closing boundary.
131
+ * Falls back to startLine + 30 if detection fails.
132
+ */
133
+ function _detectFunctionEnd(fileLines, startLine) {
134
+ const maxScan = 500; // Avoid scanning huge files
135
+ const idx = startLine - 1;
136
+ if (idx >= fileLines.length) return startLine;
137
+
138
+ const firstLine = fileLines[idx];
139
+
140
+ // Python: indentation-based — find the first non-empty line at same or lesser indent
141
+ if (/^\s*def\s|^\s*class\s|^\s*async\s+def\s/.test(firstLine)) {
142
+ const baseIndent = firstLine.match(/^(\s*)/)[1].length;
143
+ let end = startLine;
144
+ for (let i = idx + 1; i < Math.min(idx + maxScan, fileLines.length); i++) {
145
+ const line = fileLines[i];
146
+ if (line.trim() === '') { end = i + 1; continue; } // blank lines are part of body
147
+ const indent = line.match(/^(\s*)/)[1].length;
148
+ if (indent <= baseIndent) break;
149
+ end = i + 1;
150
+ }
151
+ return end;
152
+ }
153
+
154
+ // Brace-based languages (JS/TS/Go/Java/Rust): count braces
155
+ let braceCount = 0;
156
+ let foundBrace = false;
157
+ for (let i = idx; i < Math.min(idx + maxScan, fileLines.length); i++) {
158
+ const line = fileLines[i];
159
+ for (const ch of line) {
160
+ if (ch === '{') { braceCount++; foundBrace = true; }
161
+ else if (ch === '}') { braceCount--; }
162
+ }
163
+ if (foundBrace && braceCount <= 0) {
164
+ return i + 1;
165
+ }
166
+ }
167
+
168
+ // Fallback: show 30 lines from start
169
+ return Math.min(startLine + 30, fileLines.length);
170
+ }
171
+
128
172
  /**
129
173
  * Render an expand match to text lines.
130
174
  * Shared by MCP and interactive mode to avoid duplicated rendering logic.
@@ -156,7 +200,13 @@ function renderExpandItem(match, root, { validateRoot = false } = {}) {
156
200
  const content = fs.readFileSync(filePath, 'utf-8');
157
201
  const fileLines = content.split('\n');
158
202
  const startLine = match.startLine || match.line || 1;
159
- const endLine = match.endLine || startLine + 20;
203
+ let endLine = match.endLine;
204
+
205
+ // When endLine is missing or equals startLine, the expand would show only 1 line.
206
+ // Scan forward from startLine to find the actual function/method body end.
207
+ if (!endLine || endLine <= startLine) {
208
+ endLine = _detectFunctionEnd(fileLines, startLine);
209
+ }
160
210
 
161
211
  const lines = [];
162
212
  lines.push(`[${match.num}] ${match.name} (${match.type})`);
package/core/imports.js CHANGED
@@ -507,6 +507,32 @@ function loadTsConfig(tsconfigPath, visited) {
507
507
  }
508
508
  }
509
509
 
510
+ // Follow project references to collect paths from referenced configs
511
+ // (tsconfig.json with "references" pointing to tsconfig.app.json, tsconfig.node.json, etc.)
512
+ if (config.references && Array.isArray(config.references)) {
513
+ for (const ref of config.references) {
514
+ if (!ref.path) continue;
515
+ let refPath = path.resolve(configDir, ref.path);
516
+ // If reference points to a directory, look for tsconfig.json inside it
517
+ if (fs.existsSync(refPath) && fs.statSync(refPath).isDirectory()) {
518
+ refPath = path.join(refPath, 'tsconfig.json');
519
+ }
520
+ // Add .json extension if not present
521
+ if (!refPath.endsWith('.json')) refPath += '.json';
522
+ if (fs.existsSync(refPath)) {
523
+ try {
524
+ const refResult = loadTsConfig(refPath, visited);
525
+ if (refResult) {
526
+ basePaths = { ...basePaths, ...refResult.paths };
527
+ if (refResult.baseUrl && !baseUrl) baseUrl = refResult.baseUrl;
528
+ }
529
+ } catch {
530
+ // Skip malformed reference config
531
+ }
532
+ }
533
+ }
534
+ }
535
+
510
536
  // Child config values override base config
511
537
  const mergedPaths = { ...basePaths, ...(config.compilerOptions?.paths || {}) };
512
538
  const compiledPaths = Object.entries(mergedPaths).map(([pattern, targets]) => ({
package/core/output.js CHANGED
@@ -938,7 +938,11 @@ function formatImpact(impact, options = {}) {
938
938
  lines.push('');
939
939
 
940
940
  // Summary
941
- lines.push(`CALL SITES: ${impact.totalCallSites}`);
941
+ if (impact.shownCallSites !== undefined && impact.shownCallSites < impact.totalCallSites) {
942
+ lines.push(`CALL SITES: ${impact.shownCallSites} shown of ${impact.totalCallSites} total`);
943
+ } else {
944
+ lines.push(`CALL SITES: ${impact.totalCallSites}`);
945
+ }
942
946
  lines.push(` Files affected: ${impact.byFile.length}`);
943
947
 
944
948
  // Patterns
package/core/project.js CHANGED
@@ -735,6 +735,20 @@ class ProjectIndex {
735
735
  // Sort by score descending, then by index order for stability
736
736
  scored.sort((a, b) => b.score - a.score);
737
737
 
738
+ // Tiebreaker: when top candidates have equal score, prefer by usage count
739
+ if (scored.length > 1 && scored[0].score === scored[1].score) {
740
+ const tiedScore = scored[0].score;
741
+ const tiedCandidates = scored.filter(s => s.score === tiedScore);
742
+ for (const candidate of tiedCandidates) {
743
+ candidate.usageCount = this.countSymbolUsages(candidate.def).total;
744
+ }
745
+ tiedCandidates.sort((a, b) => b.usageCount - a.usageCount);
746
+ // Rebuild scored array: sorted tied candidates first, then rest
747
+ const rest = scored.filter(s => s.score !== tiedScore);
748
+ scored.length = 0;
749
+ scored.push(...tiedCandidates, ...rest);
750
+ }
751
+
738
752
  const def = scored[0].def;
739
753
 
740
754
  // Build warnings
@@ -759,10 +773,17 @@ class ProjectIndex {
759
773
  // Glob pattern matching (e.g., _update*, handle*Request, get?ata)
760
774
  const isGlob = name.includes('*') || name.includes('?');
761
775
  if (isGlob && !options.exact) {
762
- // Guard against bare wildcards that would match everything
776
+ // Bare wildcard: return all symbols
763
777
  const stripped = name.replace(/[*?]/g, '');
764
778
  if (stripped.length === 0) {
765
- return [];
779
+ const all = [];
780
+ for (const [, symbols] of this.symbols) {
781
+ for (const sym of symbols) {
782
+ all.push({ ...sym, _fuzzyScore: 800 });
783
+ }
784
+ }
785
+ all.sort((a, b) => (a.name || '').localeCompare(b.name || ''));
786
+ return this._applyFindFilters(all, options);
766
787
  }
767
788
  const globRegex = new RegExp('^' + name.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*').replace(/\?/g, '.') + '$', 'i');
768
789
  const matches = [];
@@ -2661,6 +2682,12 @@ class ProjectIndex {
2661
2682
  filteredSites = callSites.filter(s => this.matchesFilters(s.file, { exclude: options.exclude }));
2662
2683
  }
2663
2684
 
2685
+ // Apply top limit if specified (limits total call sites shown)
2686
+ const totalBeforeLimit = filteredSites.length;
2687
+ if (options.top && options.top > 0 && filteredSites.length > options.top) {
2688
+ filteredSites = filteredSites.slice(0, options.top);
2689
+ }
2690
+
2664
2691
  // Group by file
2665
2692
  const byFile = new Map();
2666
2693
  for (const site of filteredSites) {
@@ -2680,7 +2707,8 @@ class ProjectIndex {
2680
2707
  signature: this.formatSignature(def),
2681
2708
  params: def.params,
2682
2709
  paramsStructured: def.paramsStructured,
2683
- totalCallSites: filteredSites.length,
2710
+ totalCallSites: totalBeforeLimit,
2711
+ shownCallSites: filteredSites.length,
2684
2712
  byFile: Array.from(byFile.entries()).map(([file, sites]) => ({
2685
2713
  file,
2686
2714
  count: sites.length,
package/core/verify.js CHANGED
@@ -433,6 +433,29 @@ function plan(index, name, options = {}) {
433
433
  });
434
434
  }
435
435
  }
436
+
437
+ // Also include import statements that reference the renamed function
438
+ const usages = index.usages(name, { codeOnly: true });
439
+ const importUsages = usages.filter(u => u.usageType === 'import' && !u.isDefinition);
440
+ for (const imp of importUsages) {
441
+ // Skip if already covered by a call site change in the same file:line
442
+ const alreadyCovered = changes.some(c =>
443
+ c.file === (imp.relativePath || imp.file) && c.line === imp.line
444
+ );
445
+ if (alreadyCovered) continue;
446
+ const newImport = imp.content.trim().replace(
447
+ new RegExp('\\b' + escapeRegExp(name) + '\\b'),
448
+ options.renameTo
449
+ );
450
+ changes.push({
451
+ file: imp.relativePath || imp.file,
452
+ line: imp.line,
453
+ expression: imp.content.trim(),
454
+ suggestion: `Update import: ${newImport}`,
455
+ newExpression: newImport,
456
+ isImport: true
457
+ });
458
+ }
436
459
  }
437
460
 
438
461
  return {
@@ -330,6 +330,14 @@ function findStateObjects(code, parser) {
330
330
  const objects = [];
331
331
 
332
332
  const statePattern = /^(CONFIG|SETTINGS|[A-Z][A-Z0-9_]+|[A-Z][a-zA-Z]*(?:Config|Settings|Options|State|Store|Context))$/;
333
+ // Pattern for UPPER_CASE constants that may have scalar values (string, number, bool, etc.)
334
+ const constantPattern = /^[A-Z][A-Z0-9_]{1,}$/;
335
+ // RHS types that are scalar/simple values (not dict/list which are handled separately)
336
+ const scalarTypes = new Set([
337
+ 'string', 'concatenated_string', 'integer', 'float', 'true', 'false', 'none',
338
+ 'unary_operator', 'binary_operator', 'tuple', 'set', 'parenthesized_expression',
339
+ 'call', 'attribute', 'identifier', 'subscript',
340
+ ]);
333
341
 
334
342
  traverseTree(tree.rootNode, (node) => {
335
343
  if (node.type === 'expression_statement' && node.parent === tree.rootNode) {
@@ -346,6 +354,10 @@ function findStateObjects(code, parser) {
346
354
  if ((isObject || isArray) && statePattern.test(name)) {
347
355
  const { startLine, endLine } = nodeToLocation(node, code);
348
356
  objects.push({ name, startLine, endLine });
357
+ } else if (constantPattern.test(name) && scalarTypes.has(rightNode.type)) {
358
+ // Module-level UPPER_CASE constants with scalar values
359
+ const { startLine, endLine } = nodeToLocation(node, code);
360
+ objects.push({ name, startLine, endLine, isConstant: true });
349
361
  }
350
362
  }
351
363
  }
@@ -487,10 +499,25 @@ function findCallsInCode(code, parser) {
487
499
  }
488
500
  }
489
501
  }
490
- // Track non-callable assignments: count = 5, name = "hello"
502
+ // Track non-callable assignments.
503
+ // First: explicit literal check (handles dicts-with-lambdas correctly)
491
504
  if (right && isNonCallableInit(right)) {
492
505
  nonCallableNames.add(left.text);
493
506
  }
507
+ // Second: function call results are generally non-callable data
508
+ // (e.g., close = series.dropna(), result = db.query(...))
509
+ // Exception: partial() already handled above via alias tracking.
510
+ else if (right?.type === 'call' && !aliases.has(left.text)) {
511
+ nonCallableNames.add(left.text);
512
+ }
513
+ // Third: subscript/attribute access results are non-callable data
514
+ // (e.g., close = candles["close"].values, item = data[0])
515
+ else if (right && !aliases.has(left.text) &&
516
+ ['subscript', 'attribute', 'binary_operator', 'comparison_operator',
517
+ 'unary_operator', 'conditional_expression', 'await',
518
+ 'parenthesized_expression', 'not_operator', 'boolean_operator'].includes(right.type)) {
519
+ nonCallableNames.add(left.text);
520
+ }
494
521
  }
495
522
  }
496
523
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ucn",
3
- "version": "3.7.28",
3
+ "version": "3.7.30",
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",