ucn 3.7.29 → 3.7.31
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/cli/index.js +6 -3
- package/core/callers.js +41 -5
- package/core/execute.js +100 -8
- package/core/expand-cache.js +51 -1
- package/core/output.js +5 -1
- package/core/project.js +49 -11
- package/core/verify.js +26 -3
- package/languages/python.js +12 -0
- package/mcp/server.js +5 -3
- package/package.json +1 -1
package/cli/index.js
CHANGED
|
@@ -306,8 +306,9 @@ function runFileCommand(filePath, command, arg) {
|
|
|
306
306
|
api: { file: relativePath },
|
|
307
307
|
};
|
|
308
308
|
|
|
309
|
-
const { ok, result, error } = execute(index, canonical, paramsByCommand[canonical]);
|
|
309
|
+
const { ok, result, error, note } = execute(index, canonical, paramsByCommand[canonical]);
|
|
310
310
|
if (!ok) fail(error);
|
|
311
|
+
if (note) console.error(note);
|
|
311
312
|
|
|
312
313
|
// Format output using same formatters as project mode
|
|
313
314
|
switch (canonical) {
|
|
@@ -416,8 +417,9 @@ function runProjectCommand(rootDir, command, arg) {
|
|
|
416
417
|
}
|
|
417
418
|
|
|
418
419
|
case 'find': {
|
|
419
|
-
const { ok, result, error } = execute(index, 'find', { name: arg, ...flags });
|
|
420
|
+
const { ok, result, error, note } = execute(index, 'find', { name: arg, ...flags });
|
|
420
421
|
if (!ok) fail(error);
|
|
422
|
+
if (note) console.error(note);
|
|
421
423
|
printOutput(result,
|
|
422
424
|
r => output.formatSymbolJson(r, arg),
|
|
423
425
|
r => output.formatFindDetailed(r, arg, { depth: flags.depth, top: flags.top, all: flags.all })
|
|
@@ -1257,8 +1259,9 @@ function executeInteractiveCommand(index, command, arg, iflags = {}, cache = nul
|
|
|
1257
1259
|
}
|
|
1258
1260
|
|
|
1259
1261
|
case 'find': {
|
|
1260
|
-
const { ok, result, error } = execute(index, 'find', { name: arg, ...iflags });
|
|
1262
|
+
const { ok, result, error, note } = execute(index, 'find', { name: arg, ...iflags });
|
|
1261
1263
|
if (!ok) { console.log(error); return; }
|
|
1264
|
+
if (note) console.log(note);
|
|
1262
1265
|
console.log(output.formatFindDetailed(result, arg, { depth: iflags.depth, top: iflags.top, all: iflags.all }));
|
|
1263
1266
|
break;
|
|
1264
1267
|
}
|
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
|
}
|
|
@@ -749,6 +758,33 @@ function findCallees(index, def, options = {}) {
|
|
|
749
758
|
if (nonTest) callee = nonTest;
|
|
750
759
|
}
|
|
751
760
|
}
|
|
761
|
+
// Priority 6: Usage-based tiebreaker for cross-language/cross-directory ambiguity
|
|
762
|
+
// Matches resolveSymbol() scoring logic in project.js
|
|
763
|
+
if (!bindingId && callee === symbols[0] && symbols.length > 1) {
|
|
764
|
+
const typeOrder = new Set(['class', 'struct', 'interface', 'type', 'impl']);
|
|
765
|
+
const scored = symbols.map(s => {
|
|
766
|
+
let score = 0;
|
|
767
|
+
const fe = index.files.get(s.file);
|
|
768
|
+
const rp = fe ? fe.relativePath : (s.relativePath || '');
|
|
769
|
+
if (typeOrder.has(s.type)) score += 1000;
|
|
770
|
+
if (isTestFile(rp, detectLanguage(s.file))) score -= 500;
|
|
771
|
+
if (/^(examples?|docs?|vendor|third[_-]?party|benchmarks?|samples?)\//i.test(rp)) score -= 300;
|
|
772
|
+
if (/^(lib|src|core|internal|pkg|crates)\//i.test(rp)) score += 200;
|
|
773
|
+
return { symbol: s, score };
|
|
774
|
+
});
|
|
775
|
+
scored.sort((a, b) => b.score - a.score);
|
|
776
|
+
if (scored.length > 1 && scored[0].score === scored[1].score) {
|
|
777
|
+
const tiedScore = scored[0].score;
|
|
778
|
+
const tiedCandidates = scored.filter(s => s.score === tiedScore);
|
|
779
|
+
for (const c of tiedCandidates) {
|
|
780
|
+
c.usageCount = index.countSymbolUsages(c.symbol).total;
|
|
781
|
+
}
|
|
782
|
+
tiedCandidates.sort((a, b) => b.usageCount - a.usageCount);
|
|
783
|
+
callee = tiedCandidates[0].symbol;
|
|
784
|
+
} else {
|
|
785
|
+
callee = scored[0].symbol;
|
|
786
|
+
}
|
|
787
|
+
}
|
|
752
788
|
}
|
|
753
789
|
|
|
754
790
|
// Skip test-file callees when caller is production code and
|
package/core/execute.js
CHANGED
|
@@ -41,6 +41,38 @@ function requireTerm(term) {
|
|
|
41
41
|
return null;
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
+
/**
|
|
45
|
+
* Split Class.method syntax into className and methodName.
|
|
46
|
+
* Returns { className, methodName } or null if not applicable.
|
|
47
|
+
* Handles: "Class.method" → { className: "Class", methodName: "method" }
|
|
48
|
+
* Skips: ".method", "a.b.c" (multi-dot), names without dots
|
|
49
|
+
*/
|
|
50
|
+
function splitClassMethod(name) {
|
|
51
|
+
if (!name || typeof name !== 'string') return null;
|
|
52
|
+
const dotIndex = name.indexOf('.');
|
|
53
|
+
if (dotIndex <= 0 || dotIndex === name.length - 1) return null;
|
|
54
|
+
// Only split on first dot, and only if there's exactly one dot
|
|
55
|
+
if (name.indexOf('.', dotIndex + 1) !== -1) return null;
|
|
56
|
+
return {
|
|
57
|
+
className: name.substring(0, dotIndex),
|
|
58
|
+
methodName: name.substring(dotIndex + 1)
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Apply Class.method syntax to params object.
|
|
64
|
+
* If name contains ".", splits it and sets p.name and p.className.
|
|
65
|
+
* Only applies if p.className is not already set.
|
|
66
|
+
*/
|
|
67
|
+
function applyClassMethodSyntax(p) {
|
|
68
|
+
if (p.className) return; // already set explicitly
|
|
69
|
+
const split = splitClassMethod(p.name);
|
|
70
|
+
if (split) {
|
|
71
|
+
p.name = split.methodName;
|
|
72
|
+
p.className = split.className;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
44
76
|
/** Normalize exclude to an array (accepts string CSV, array, or falsy). */
|
|
45
77
|
function toExcludeArray(exclude) {
|
|
46
78
|
if (!exclude) return [];
|
|
@@ -93,9 +125,11 @@ const HANDLERS = {
|
|
|
93
125
|
about: (index, p) => {
|
|
94
126
|
const err = requireName(p.name);
|
|
95
127
|
if (err) return { ok: false, error: err };
|
|
128
|
+
applyClassMethodSyntax(p);
|
|
96
129
|
const result = index.about(p.name, {
|
|
97
130
|
withTypes: p.withTypes || false,
|
|
98
131
|
file: p.file,
|
|
132
|
+
className: p.className,
|
|
99
133
|
all: p.all,
|
|
100
134
|
includeMethods: p.includeMethods,
|
|
101
135
|
includeUncertain: p.includeUncertain || false,
|
|
@@ -103,17 +137,36 @@ const HANDLERS = {
|
|
|
103
137
|
maxCallers: num(p.top, undefined),
|
|
104
138
|
maxCallees: num(p.top, undefined),
|
|
105
139
|
});
|
|
106
|
-
if (!result)
|
|
140
|
+
if (!result) {
|
|
141
|
+
// Give better error if file/className filter is the problem
|
|
142
|
+
if (p.file || p.className) {
|
|
143
|
+
const unfiltered = index.about(p.name, {
|
|
144
|
+
withTypes: p.withTypes || false,
|
|
145
|
+
all: false,
|
|
146
|
+
includeMethods: p.includeMethods,
|
|
147
|
+
includeUncertain: p.includeUncertain || false,
|
|
148
|
+
exclude: toExcludeArray(p.exclude),
|
|
149
|
+
});
|
|
150
|
+
if (unfiltered && unfiltered.found !== false && unfiltered.symbol) {
|
|
151
|
+
const loc = `${unfiltered.symbol.file}:${unfiltered.symbol.startLine}`;
|
|
152
|
+
const filterDesc = p.className ? `class "${p.className}"` : `file "${p.file}"`;
|
|
153
|
+
return { ok: false, error: `Symbol "${p.name}" not found in ${filterDesc}. Found in: ${loc}. Use the correct --file or Class.method syntax.` };
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return { ok: false, error: `Symbol "${p.name}" not found.` };
|
|
157
|
+
}
|
|
107
158
|
return { ok: true, result };
|
|
108
159
|
},
|
|
109
160
|
|
|
110
161
|
context: (index, p) => {
|
|
111
162
|
const err = requireName(p.name);
|
|
112
163
|
if (err) return { ok: false, error: err };
|
|
164
|
+
applyClassMethodSyntax(p);
|
|
113
165
|
const result = index.context(p.name, {
|
|
114
166
|
includeMethods: p.includeMethods,
|
|
115
167
|
includeUncertain: p.includeUncertain || false,
|
|
116
168
|
file: p.file,
|
|
169
|
+
className: p.className,
|
|
117
170
|
exclude: toExcludeArray(p.exclude),
|
|
118
171
|
});
|
|
119
172
|
if (!result) return { ok: false, error: `Symbol "${p.name}" not found.` };
|
|
@@ -123,9 +176,12 @@ const HANDLERS = {
|
|
|
123
176
|
impact: (index, p) => {
|
|
124
177
|
const err = requireName(p.name);
|
|
125
178
|
if (err) return { ok: false, error: err };
|
|
179
|
+
applyClassMethodSyntax(p);
|
|
126
180
|
const result = index.impact(p.name, {
|
|
127
181
|
file: p.file,
|
|
182
|
+
className: p.className,
|
|
128
183
|
exclude: toExcludeArray(p.exclude),
|
|
184
|
+
top: num(p.top, undefined),
|
|
129
185
|
});
|
|
130
186
|
if (!result) return { ok: false, error: `Function "${p.name}" not found.` };
|
|
131
187
|
return { ok: true, result };
|
|
@@ -134,8 +190,10 @@ const HANDLERS = {
|
|
|
134
190
|
smart: (index, p) => {
|
|
135
191
|
const err = requireName(p.name);
|
|
136
192
|
if (err) return { ok: false, error: err };
|
|
193
|
+
applyClassMethodSyntax(p);
|
|
137
194
|
const result = index.smart(p.name, {
|
|
138
195
|
file: p.file,
|
|
196
|
+
className: p.className,
|
|
139
197
|
withTypes: p.withTypes || false,
|
|
140
198
|
includeMethods: p.includeMethods,
|
|
141
199
|
includeUncertain: p.includeUncertain || false,
|
|
@@ -147,10 +205,12 @@ const HANDLERS = {
|
|
|
147
205
|
trace: (index, p) => {
|
|
148
206
|
const err = requireName(p.name);
|
|
149
207
|
if (err) return { ok: false, error: err };
|
|
208
|
+
applyClassMethodSyntax(p);
|
|
150
209
|
const depthVal = num(p.depth, undefined);
|
|
151
210
|
const result = index.trace(p.name, {
|
|
152
211
|
depth: depthVal ?? 3,
|
|
153
212
|
file: p.file,
|
|
213
|
+
className: p.className,
|
|
154
214
|
all: p.all || depthVal !== undefined,
|
|
155
215
|
includeMethods: p.includeMethods,
|
|
156
216
|
includeUncertain: p.includeUncertain || false,
|
|
@@ -162,7 +222,8 @@ const HANDLERS = {
|
|
|
162
222
|
example: (index, p) => {
|
|
163
223
|
const err = requireName(p.name);
|
|
164
224
|
if (err) return { ok: false, error: err };
|
|
165
|
-
|
|
225
|
+
applyClassMethodSyntax(p);
|
|
226
|
+
const result = index.example(p.name, { file: p.file, className: p.className });
|
|
166
227
|
if (!result) return { ok: false, error: `No examples found for "${p.name}".` };
|
|
167
228
|
return { ok: true, result };
|
|
168
229
|
},
|
|
@@ -170,8 +231,10 @@ const HANDLERS = {
|
|
|
170
231
|
related: (index, p) => {
|
|
171
232
|
const err = requireName(p.name);
|
|
172
233
|
if (err) return { ok: false, error: err };
|
|
234
|
+
applyClassMethodSyntax(p);
|
|
173
235
|
const result = index.related(p.name, {
|
|
174
236
|
file: p.file,
|
|
237
|
+
className: p.className,
|
|
175
238
|
top: num(p.top, undefined),
|
|
176
239
|
all: p.all,
|
|
177
240
|
});
|
|
@@ -184,28 +247,38 @@ const HANDLERS = {
|
|
|
184
247
|
find: (index, p) => {
|
|
185
248
|
const err = requireName(p.name);
|
|
186
249
|
if (err) return { ok: false, error: err };
|
|
250
|
+
applyClassMethodSyntax(p);
|
|
187
251
|
// Auto-include tests when pattern clearly targets test functions
|
|
252
|
+
// But only if the user didn't explicitly set include_tests=false
|
|
188
253
|
let includeTests = p.includeTests;
|
|
189
|
-
if (
|
|
254
|
+
if (includeTests === undefined && p.name && /^test[_*?]/i.test(p.name)) {
|
|
190
255
|
includeTests = true;
|
|
191
256
|
}
|
|
192
257
|
const exclude = applyTestExclusions(p.exclude, includeTests);
|
|
193
258
|
const result = index.find(p.name, {
|
|
194
259
|
file: p.file,
|
|
260
|
+
className: p.className,
|
|
195
261
|
exact: p.exact || false,
|
|
196
262
|
exclude,
|
|
197
263
|
in: p.in,
|
|
198
264
|
});
|
|
199
|
-
|
|
265
|
+
// Warn if exact mode silently disables glob expansion
|
|
266
|
+
let note;
|
|
267
|
+
if (p.exact && p.name && (p.name.includes('*') || p.name.includes('?'))) {
|
|
268
|
+
note = `Note: exact=true treats "${p.name}" as a literal name (glob expansion disabled).`;
|
|
269
|
+
}
|
|
270
|
+
return { ok: true, result, note };
|
|
200
271
|
},
|
|
201
272
|
|
|
202
273
|
usages: (index, p) => {
|
|
203
274
|
const err = requireName(p.name);
|
|
204
275
|
if (err) return { ok: false, error: err };
|
|
276
|
+
applyClassMethodSyntax(p);
|
|
205
277
|
const exclude = applyTestExclusions(p.exclude, p.includeTests);
|
|
206
278
|
const result = index.usages(p.name, {
|
|
207
279
|
codeOnly: p.codeOnly || false,
|
|
208
280
|
context: num(p.context, 0),
|
|
281
|
+
className: p.className,
|
|
209
282
|
exclude,
|
|
210
283
|
in: p.in,
|
|
211
284
|
});
|
|
@@ -240,8 +313,10 @@ const HANDLERS = {
|
|
|
240
313
|
tests: (index, p) => {
|
|
241
314
|
const err = requireName(p.name);
|
|
242
315
|
if (err) return { ok: false, error: err };
|
|
316
|
+
applyClassMethodSyntax(p);
|
|
243
317
|
const result = index.tests(p.name, {
|
|
244
318
|
callsOnly: p.callsOnly || false,
|
|
319
|
+
className: p.className,
|
|
245
320
|
});
|
|
246
321
|
return { ok: true, result };
|
|
247
322
|
},
|
|
@@ -262,6 +337,7 @@ const HANDLERS = {
|
|
|
262
337
|
fn: (index, p) => {
|
|
263
338
|
const err = requireName(p.name);
|
|
264
339
|
if (err) return { ok: false, error: err };
|
|
340
|
+
applyClassMethodSyntax(p);
|
|
265
341
|
|
|
266
342
|
const fnNames = p.name.includes(',')
|
|
267
343
|
? p.name.split(',').map(n => n.trim()).filter(Boolean)
|
|
@@ -271,11 +347,23 @@ const HANDLERS = {
|
|
|
271
347
|
const notes = [];
|
|
272
348
|
|
|
273
349
|
for (const fnName of fnNames) {
|
|
274
|
-
|
|
350
|
+
// For comma-separated names, each may have Class.method syntax
|
|
351
|
+
const fnSplit = splitClassMethod(fnName);
|
|
352
|
+
const actualName = fnSplit ? fnSplit.methodName : fnName;
|
|
353
|
+
const fnClassName = fnSplit ? fnSplit.className : p.className;
|
|
354
|
+
const matches = index.find(actualName, { file: p.file, className: fnClassName, skipCounts: true })
|
|
275
355
|
.filter(m => m.type === 'function' || m.params !== undefined);
|
|
276
356
|
|
|
277
357
|
if (matches.length === 0) {
|
|
278
|
-
|
|
358
|
+
// Check if it's a class — suggest `class` command instead
|
|
359
|
+
const CLASS_TYPES = ['class', 'interface', 'type', 'enum', 'struct', 'trait'];
|
|
360
|
+
const classMatches = index.find(actualName, { file: p.file, className: fnClassName, skipCounts: true })
|
|
361
|
+
.filter(m => CLASS_TYPES.includes(m.type));
|
|
362
|
+
if (classMatches.length > 0) {
|
|
363
|
+
notes.push(`"${fnName}" is a ${classMatches[0].type}, not a function. Use \`class ${fnName}\` instead.`);
|
|
364
|
+
} else {
|
|
365
|
+
notes.push(`Function "${fnName}" not found.`);
|
|
366
|
+
}
|
|
279
367
|
continue;
|
|
280
368
|
}
|
|
281
369
|
|
|
@@ -470,13 +558,15 @@ const HANDLERS = {
|
|
|
470
558
|
verify: (index, p) => {
|
|
471
559
|
const err = requireName(p.name);
|
|
472
560
|
if (err) return { ok: false, error: err };
|
|
473
|
-
|
|
561
|
+
applyClassMethodSyntax(p);
|
|
562
|
+
const result = index.verify(p.name, { file: p.file, className: p.className });
|
|
474
563
|
return { ok: true, result };
|
|
475
564
|
},
|
|
476
565
|
|
|
477
566
|
plan: (index, p) => {
|
|
478
567
|
const err = requireName(p.name);
|
|
479
568
|
if (err) return { ok: false, error: err };
|
|
569
|
+
applyClassMethodSyntax(p);
|
|
480
570
|
if (!p.addParam && !p.removeParam && !p.renameTo) {
|
|
481
571
|
return { ok: false, error: 'Plan requires an operation: add_param, remove_param, or rename_to.' };
|
|
482
572
|
}
|
|
@@ -486,6 +576,7 @@ const HANDLERS = {
|
|
|
486
576
|
renameTo: p.renameTo,
|
|
487
577
|
defaultValue: p.defaultValue,
|
|
488
578
|
file: p.file,
|
|
579
|
+
className: p.className,
|
|
489
580
|
});
|
|
490
581
|
return { ok: true, result };
|
|
491
582
|
},
|
|
@@ -504,7 +595,8 @@ const HANDLERS = {
|
|
|
504
595
|
typedef: (index, p) => {
|
|
505
596
|
const err = requireName(p.name);
|
|
506
597
|
if (err) return { ok: false, error: err };
|
|
507
|
-
|
|
598
|
+
applyClassMethodSyntax(p);
|
|
599
|
+
const result = index.typedef(p.name, { exact: p.exact || false, className: p.className });
|
|
508
600
|
return { ok: true, result };
|
|
509
601
|
},
|
|
510
602
|
|
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
|
@@ -700,6 +700,14 @@ class ProjectIndex {
|
|
|
700
700
|
return { def: null, definitions: [], warnings: [] };
|
|
701
701
|
}
|
|
702
702
|
|
|
703
|
+
// Filter by class name (Class.method syntax)
|
|
704
|
+
if (options.className) {
|
|
705
|
+
const filtered = definitions.filter(d => d.className === options.className);
|
|
706
|
+
if (filtered.length > 0) {
|
|
707
|
+
definitions = filtered;
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
|
|
703
711
|
// Filter by file if specified
|
|
704
712
|
if (options.file) {
|
|
705
713
|
const filtered = definitions.filter(d =>
|
|
@@ -735,6 +743,20 @@ class ProjectIndex {
|
|
|
735
743
|
// Sort by score descending, then by index order for stability
|
|
736
744
|
scored.sort((a, b) => b.score - a.score);
|
|
737
745
|
|
|
746
|
+
// Tiebreaker: when top candidates have equal score, prefer by usage count
|
|
747
|
+
if (scored.length > 1 && scored[0].score === scored[1].score) {
|
|
748
|
+
const tiedScore = scored[0].score;
|
|
749
|
+
const tiedCandidates = scored.filter(s => s.score === tiedScore);
|
|
750
|
+
for (const candidate of tiedCandidates) {
|
|
751
|
+
candidate.usageCount = this.countSymbolUsages(candidate.def).total;
|
|
752
|
+
}
|
|
753
|
+
tiedCandidates.sort((a, b) => b.usageCount - a.usageCount);
|
|
754
|
+
// Rebuild scored array: sorted tied candidates first, then rest
|
|
755
|
+
const rest = scored.filter(s => s.score !== tiedScore);
|
|
756
|
+
scored.length = 0;
|
|
757
|
+
scored.push(...tiedCandidates, ...rest);
|
|
758
|
+
}
|
|
759
|
+
|
|
738
760
|
const def = scored[0].def;
|
|
739
761
|
|
|
740
762
|
// Build warnings
|
|
@@ -812,6 +834,11 @@ class ProjectIndex {
|
|
|
812
834
|
_applyFindFilters(matches, options) {
|
|
813
835
|
let filtered = matches;
|
|
814
836
|
|
|
837
|
+
// Filter by class name (Class.method syntax)
|
|
838
|
+
if (options.className) {
|
|
839
|
+
filtered = filtered.filter(m => m.className === options.className);
|
|
840
|
+
}
|
|
841
|
+
|
|
815
842
|
// Filter by file pattern
|
|
816
843
|
if (options.file) {
|
|
817
844
|
filtered = filtered.filter(m =>
|
|
@@ -944,7 +971,10 @@ class ProjectIndex {
|
|
|
944
971
|
const usages = [];
|
|
945
972
|
|
|
946
973
|
// Get definitions (filtered)
|
|
947
|
-
|
|
974
|
+
let allDefinitions = this.symbols.get(name) || [];
|
|
975
|
+
if (options.className) {
|
|
976
|
+
allDefinitions = allDefinitions.filter(d => d.className === options.className);
|
|
977
|
+
}
|
|
948
978
|
const definitions = options.exclude || options.in
|
|
949
979
|
? allDefinitions.filter(d => this.matchesFilters(d.relativePath, options))
|
|
950
980
|
: allDefinitions;
|
|
@@ -1136,7 +1166,7 @@ class ProjectIndex {
|
|
|
1136
1166
|
context(name, options = {}) {
|
|
1137
1167
|
this._beginOp();
|
|
1138
1168
|
try {
|
|
1139
|
-
const resolved = this.resolveSymbol(name, { file: options.file });
|
|
1169
|
+
const resolved = this.resolveSymbol(name, { file: options.file, className: options.className });
|
|
1140
1170
|
let { def, definitions, warnings } = resolved;
|
|
1141
1171
|
if (!def) {
|
|
1142
1172
|
return null;
|
|
@@ -1296,7 +1326,7 @@ class ProjectIndex {
|
|
|
1296
1326
|
smart(name, options = {}) {
|
|
1297
1327
|
this._beginOp();
|
|
1298
1328
|
try {
|
|
1299
|
-
const { def } = this.resolveSymbol(name, { file: options.file });
|
|
1329
|
+
const { def } = this.resolveSymbol(name, { file: options.file, className: options.className });
|
|
1300
1330
|
if (!def) {
|
|
1301
1331
|
return null;
|
|
1302
1332
|
}
|
|
@@ -2348,7 +2378,7 @@ class ProjectIndex {
|
|
|
2348
2378
|
related(name, options = {}) {
|
|
2349
2379
|
this._beginOp();
|
|
2350
2380
|
try {
|
|
2351
|
-
const { def } = this.resolveSymbol(name, { file: options.file });
|
|
2381
|
+
const { def } = this.resolveSymbol(name, { file: options.file, className: options.className });
|
|
2352
2382
|
if (!def) {
|
|
2353
2383
|
return null;
|
|
2354
2384
|
}
|
|
@@ -2499,7 +2529,7 @@ class ProjectIndex {
|
|
|
2499
2529
|
// trace defaults to includeMethods=true (execution flow should show method calls)
|
|
2500
2530
|
const includeMethods = options.includeMethods ?? true;
|
|
2501
2531
|
|
|
2502
|
-
const { def, definitions, warnings } = this.resolveSymbol(name, { file: options.file });
|
|
2532
|
+
const { def, definitions, warnings } = this.resolveSymbol(name, { file: options.file, className: options.className });
|
|
2503
2533
|
if (!def) {
|
|
2504
2534
|
return null;
|
|
2505
2535
|
}
|
|
@@ -2605,7 +2635,7 @@ class ProjectIndex {
|
|
|
2605
2635
|
impact(name, options = {}) {
|
|
2606
2636
|
this._beginOp();
|
|
2607
2637
|
try {
|
|
2608
|
-
const { def } = this.resolveSymbol(name, { file: options.file });
|
|
2638
|
+
const { def } = this.resolveSymbol(name, { file: options.file, className: options.className });
|
|
2609
2639
|
if (!def) {
|
|
2610
2640
|
return null;
|
|
2611
2641
|
}
|
|
@@ -2668,6 +2698,12 @@ class ProjectIndex {
|
|
|
2668
2698
|
filteredSites = callSites.filter(s => this.matchesFilters(s.file, { exclude: options.exclude }));
|
|
2669
2699
|
}
|
|
2670
2700
|
|
|
2701
|
+
// Apply top limit if specified (limits total call sites shown)
|
|
2702
|
+
const totalBeforeLimit = filteredSites.length;
|
|
2703
|
+
if (options.top && options.top > 0 && filteredSites.length > options.top) {
|
|
2704
|
+
filteredSites = filteredSites.slice(0, options.top);
|
|
2705
|
+
}
|
|
2706
|
+
|
|
2671
2707
|
// Group by file
|
|
2672
2708
|
const byFile = new Map();
|
|
2673
2709
|
for (const site of filteredSites) {
|
|
@@ -2687,7 +2723,8 @@ class ProjectIndex {
|
|
|
2687
2723
|
signature: this.formatSignature(def),
|
|
2688
2724
|
params: def.params,
|
|
2689
2725
|
paramsStructured: def.paramsStructured,
|
|
2690
|
-
totalCallSites:
|
|
2726
|
+
totalCallSites: totalBeforeLimit,
|
|
2727
|
+
shownCallSites: filteredSites.length,
|
|
2691
2728
|
byFile: Array.from(byFile.entries()).map(([file, sites]) => ({
|
|
2692
2729
|
file,
|
|
2693
2730
|
count: sites.length,
|
|
@@ -2752,10 +2789,10 @@ class ProjectIndex {
|
|
|
2752
2789
|
const includeMethods = options.includeMethods ?? true;
|
|
2753
2790
|
|
|
2754
2791
|
// Find symbol definition(s) — skip counts since about() computes its own via usages()
|
|
2755
|
-
const definitions = this.find(name, { exact: true, file: options.file, skipCounts: true });
|
|
2792
|
+
const definitions = this.find(name, { exact: true, file: options.file, className: options.className, skipCounts: true });
|
|
2756
2793
|
if (definitions.length === 0) {
|
|
2757
2794
|
// Try fuzzy match (needs counts for suggestion ranking)
|
|
2758
|
-
const fuzzy = this.find(name, { file: options.file });
|
|
2795
|
+
const fuzzy = this.find(name, { file: options.file, className: options.className });
|
|
2759
2796
|
if (fuzzy.length === 0) {
|
|
2760
2797
|
return null;
|
|
2761
2798
|
}
|
|
@@ -2773,7 +2810,7 @@ class ProjectIndex {
|
|
|
2773
2810
|
}
|
|
2774
2811
|
|
|
2775
2812
|
// Use resolveSymbol for consistent primary selection (prefers non-test files)
|
|
2776
|
-
const { def: resolved } = this.resolveSymbol(name, { file: options.file });
|
|
2813
|
+
const { def: resolved } = this.resolveSymbol(name, { file: options.file, className: options.className });
|
|
2777
2814
|
const primary = resolved || definitions[0];
|
|
2778
2815
|
const others = definitions.filter(d =>
|
|
2779
2816
|
d.relativePath !== primary.relativePath || d.startLine !== primary.startLine
|
|
@@ -3237,11 +3274,12 @@ class ProjectIndex {
|
|
|
3237
3274
|
* @param {string} name - Symbol name
|
|
3238
3275
|
* @returns {{ best: object, totalCalls: number } | null}
|
|
3239
3276
|
*/
|
|
3240
|
-
example(name) {
|
|
3277
|
+
example(name, options = {}) {
|
|
3241
3278
|
this._beginOp();
|
|
3242
3279
|
try {
|
|
3243
3280
|
const usages = this.usages(name, {
|
|
3244
3281
|
codeOnly: true,
|
|
3282
|
+
className: options.className,
|
|
3245
3283
|
exclude: ['test', 'spec', '__tests__', '__mocks__', 'fixture', 'mock'],
|
|
3246
3284
|
context: 5
|
|
3247
3285
|
});
|
package/core/verify.js
CHANGED
|
@@ -177,7 +177,7 @@ function identifyCallPatterns(callSites, funcName) {
|
|
|
177
177
|
function verify(index, name, options = {}) {
|
|
178
178
|
index._beginOp();
|
|
179
179
|
try {
|
|
180
|
-
const { def } = index.resolveSymbol(name, { file: options.file });
|
|
180
|
+
const { def } = index.resolveSymbol(name, { file: options.file, className: options.className });
|
|
181
181
|
if (!def) {
|
|
182
182
|
return { found: false, function: name };
|
|
183
183
|
}
|
|
@@ -315,9 +315,9 @@ function plan(index, name, options = {}) {
|
|
|
315
315
|
return { found: false, function: name };
|
|
316
316
|
}
|
|
317
317
|
|
|
318
|
-
const resolved = index.resolveSymbol(name, { file: options.file });
|
|
318
|
+
const resolved = index.resolveSymbol(name, { file: options.file, className: options.className });
|
|
319
319
|
const def = resolved.def || definitions[0];
|
|
320
|
-
const impact = index.impact(name, { file: options.file });
|
|
320
|
+
const impact = index.impact(name, { file: options.file, className: options.className });
|
|
321
321
|
const currentParams = def.paramsStructured || [];
|
|
322
322
|
const currentSignature = index.formatSignature(def);
|
|
323
323
|
|
|
@@ -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/mcp/server.js
CHANGED
|
@@ -297,7 +297,7 @@ server.registerTool(
|
|
|
297
297
|
|
|
298
298
|
case 'impact': {
|
|
299
299
|
const index = getIndex(project_dir);
|
|
300
|
-
const ep = normalizeParams({ name, file, exclude });
|
|
300
|
+
const ep = normalizeParams({ name, file, exclude, top });
|
|
301
301
|
const { ok, result, error } = execute(index, 'impact', ep);
|
|
302
302
|
if (!ok) return toolResult(error); // soft error
|
|
303
303
|
return toolResult(output.formatImpact(result));
|
|
@@ -346,9 +346,11 @@ server.registerTool(
|
|
|
346
346
|
case 'find': {
|
|
347
347
|
const index = getIndex(project_dir);
|
|
348
348
|
const ep = normalizeParams({ name, file, exclude, include_tests, exact, in: inPath });
|
|
349
|
-
const { ok, result, error } = execute(index, 'find', ep);
|
|
349
|
+
const { ok, result, error, note } = execute(index, 'find', ep);
|
|
350
350
|
if (!ok) return toolResult(error); // soft error
|
|
351
|
-
|
|
351
|
+
let text = output.formatFind(result, name, top);
|
|
352
|
+
if (note) text += '\n\n' + note;
|
|
353
|
+
return toolResult(text);
|
|
352
354
|
}
|
|
353
355
|
|
|
354
356
|
case 'usages': {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ucn",
|
|
3
|
-
"version": "3.7.
|
|
3
|
+
"version": "3.7.31",
|
|
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",
|