ucn 3.7.29 → 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
@@ -719,11 +719,20 @@ function findCallees(index, def, options = {}) {
719
719
  const sameDir = symbols.find(s => path.dirname(s.file) === defDir);
720
720
  if (sameDir) {
721
721
  callee = sameDir;
722
- } else if (defReceiver) {
723
- // Priority 3: Same receiver type (for methods)
724
- const sameReceiver = symbols.find(s => s.receiver === defReceiver);
725
- if (sameReceiver) {
726
- 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
+ }
727
736
  }
728
737
  }
729
738
  }
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 };
@@ -185,8 +186,9 @@ const HANDLERS = {
185
186
  const err = requireName(p.name);
186
187
  if (err) return { ok: false, error: err };
187
188
  // Auto-include tests when pattern clearly targets test functions
189
+ // But only if the user didn't explicitly set include_tests=false
188
190
  let includeTests = p.includeTests;
189
- if (!includeTests && p.name && /^test[_*?]/i.test(p.name)) {
191
+ if (includeTests === undefined && p.name && /^test[_*?]/i.test(p.name)) {
190
192
  includeTests = true;
191
193
  }
192
194
  const exclude = applyTestExclusions(p.exclude, includeTests);
@@ -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/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
@@ -2668,6 +2682,12 @@ class ProjectIndex {
2668
2682
  filteredSites = callSites.filter(s => this.matchesFilters(s.file, { exclude: options.exclude }));
2669
2683
  }
2670
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
+
2671
2691
  // Group by file
2672
2692
  const byFile = new Map();
2673
2693
  for (const site of filteredSites) {
@@ -2687,7 +2707,8 @@ class ProjectIndex {
2687
2707
  signature: this.formatSignature(def),
2688
2708
  params: def.params,
2689
2709
  paramsStructured: def.paramsStructured,
2690
- totalCallSites: filteredSites.length,
2710
+ totalCallSites: totalBeforeLimit,
2711
+ shownCallSites: filteredSites.length,
2691
2712
  byFile: Array.from(byFile.entries()).map(([file, sites]) => ({
2692
2713
  file,
2693
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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ucn",
3
- "version": "3.7.29",
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",