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 +14 -5
- package/core/execute.js +3 -1
- package/core/expand-cache.js +51 -1
- package/core/output.js +5 -1
- package/core/project.js +22 -1
- package/core/verify.js +23 -0
- package/languages/python.js +12 -0
- package/package.json +1 -1
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
|
|
723
|
-
// Priority
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
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 (
|
|
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);
|
package/core/expand-cache.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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:
|
|
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 {
|
package/languages/python.js
CHANGED
|
@@ -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.
|
|
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",
|