ucn 4.0.2 → 4.1.0
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/.claude/skills/ucn/SKILL.md +31 -7
- package/README.md +89 -50
- package/cli/index.js +199 -94
- package/core/account.js +3 -1
- package/core/analysis.js +322 -304
- package/core/bridge.js +13 -8
- package/core/cache.js +109 -19
- package/core/callers.js +969 -77
- package/core/check.js +19 -8
- package/core/deadcode.js +368 -40
- package/core/discovery.js +31 -11
- package/core/entrypoints.js +149 -17
- package/core/execute.js +330 -43
- package/core/graph-build.js +61 -10
- package/core/graph.js +282 -61
- package/core/imports.js +72 -3
- package/core/output/analysis-ext.js +70 -10
- package/core/output/analysis.js +52 -33
- package/core/output/check.js +4 -1
- package/core/output/endpoints.js +8 -3
- package/core/output/extraction.js +12 -1
- package/core/output/find.js +23 -9
- package/core/output/graph.js +32 -9
- package/core/output/refactoring.js +147 -49
- package/core/output/reporting.js +104 -5
- package/core/output/search.js +30 -3
- package/core/output/shared.js +31 -4
- package/core/output/tracing.js +22 -11
- package/core/parser.js +8 -6
- package/core/project.js +167 -7
- package/core/registry.js +20 -16
- package/core/reporting.js +131 -13
- package/core/search.js +270 -55
- package/core/shared.js +240 -1
- package/core/stacktrace.js +23 -1
- package/core/tracing.js +278 -36
- package/core/verify.js +352 -349
- package/languages/go.js +29 -17
- package/languages/index.js +56 -0
- package/languages/java.js +133 -16
- package/languages/javascript.js +215 -27
- package/languages/python.js +113 -47
- package/languages/rust.js +89 -26
- package/languages/utils.js +35 -7
- package/mcp/server.js +69 -30
- package/package.json +4 -1
package/core/execute.js
CHANGED
|
@@ -100,8 +100,10 @@ function applyHandleSyntax(p) {
|
|
|
100
100
|
/** Normalize exclude to an array (accepts string CSV, array, or falsy). */
|
|
101
101
|
function toExcludeArray(exclude) {
|
|
102
102
|
if (!exclude) return [];
|
|
103
|
-
if (Array.isArray(exclude)) return exclude;
|
|
104
|
-
|
|
103
|
+
if (Array.isArray(exclude)) return exclude.map(String);
|
|
104
|
+
// Programmatic callers can pass anything — a number here used to
|
|
105
|
+
// surface the raw "exclude.split is not a function" TypeError (fix #245).
|
|
106
|
+
return String(exclude).split(',').map(s => s.trim()).filter(Boolean);
|
|
105
107
|
}
|
|
106
108
|
|
|
107
109
|
/** Apply test exclusions unless includeTests is set. */
|
|
@@ -127,6 +129,27 @@ function buildCallerOptions(p) {
|
|
|
127
129
|
};
|
|
128
130
|
}
|
|
129
131
|
|
|
132
|
+
/** Kinds the `class` command extracts (fix #248: Java records and their
|
|
133
|
+
* fn-side suggestion were unreachable — indexed as type 'record'). */
|
|
134
|
+
const CLASS_KIND_TYPES = ['class', 'interface', 'type', 'enum', 'struct', 'trait', 'record', 'namespace'];
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Disambiguation advice that can actually work (fix #248): --file cannot
|
|
138
|
+
* split same-file matches, and repeating it when already given is circular.
|
|
139
|
+
* Class.method (or --class-name) splits same-name methods across classes.
|
|
140
|
+
*/
|
|
141
|
+
function disambiguationHint(matches, chosen, fileGiven) {
|
|
142
|
+
const sameFile = matches.every(m => m.relativePath === chosen.relativePath);
|
|
143
|
+
if (sameFile || fileGiven) {
|
|
144
|
+
const classes = new Set(matches.map(m => m.className).filter(Boolean));
|
|
145
|
+
if (classes.size > 1) {
|
|
146
|
+
return 'Use Class.method syntax (or --class-name) to disambiguate, or --all to show all.';
|
|
147
|
+
}
|
|
148
|
+
return 'Use --all to show all.';
|
|
149
|
+
}
|
|
150
|
+
return 'Use --file to disambiguate or --all to show all.';
|
|
151
|
+
}
|
|
152
|
+
|
|
130
153
|
/** Check if a file-based result has a file error. */
|
|
131
154
|
function checkFileError(result, file) {
|
|
132
155
|
if (!result) return null;
|
|
@@ -150,6 +173,12 @@ function validateClassName(index, name, className) {
|
|
|
150
173
|
if (!allDefs || allDefs.length === 0) return null; // no defs at all — let the command handle "not found"
|
|
151
174
|
const matching = allDefs.filter(d => d.className === className);
|
|
152
175
|
if (matching.length > 0) return null; // className matched
|
|
176
|
+
// Namespace-qualified names (fix #252): namespace members are indexed
|
|
177
|
+
// at top level, so "Geometry.area" must not misdirect to an unrelated
|
|
178
|
+
// class that happens to define an `area` method.
|
|
179
|
+
if ((index.symbols.get(className) || []).some(d => d.type === 'namespace')) {
|
|
180
|
+
return `"${className}" is a namespace — its members are indexed at top level. Use \`${name}\` directly (with --file to scope).`;
|
|
181
|
+
}
|
|
153
182
|
// className specified but no definitions match
|
|
154
183
|
const available = [...new Set(allDefs.filter(d => d.className).map(d => d.className))];
|
|
155
184
|
if (available.length > 0) {
|
|
@@ -190,9 +219,9 @@ function truncationNote(index) {
|
|
|
190
219
|
/** Build notes for tree-based results (blast, trace, reverseTrace, affectedTests). */
|
|
191
220
|
function treeNote(result) {
|
|
192
221
|
const parts = [];
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
222
|
+
// result.warnings are NOT copied here — the tree formatters render them
|
|
223
|
+
// in the body (Note: lines under the header); copying them into the
|
|
224
|
+
// handler note printed each warning twice (fix #237).
|
|
196
225
|
if (result?.tree?.truncatedChildren > 0) {
|
|
197
226
|
parts.push(`${result.tree.truncatedChildren} children truncated. Use --depth=N or --all to expand.`);
|
|
198
227
|
}
|
|
@@ -231,6 +260,47 @@ function checkFilePatternMatch(index, filePattern) {
|
|
|
231
260
|
return `No files matched pattern '${filePattern}'.`;
|
|
232
261
|
}
|
|
233
262
|
|
|
263
|
+
/**
|
|
264
|
+
* Definition-pin validation for commands that analyze ONE symbol: when
|
|
265
|
+
* `file`/`line` are given, some definition of the symbol must actually match
|
|
266
|
+
* them. Without this, resolution silently falls back to the full definition
|
|
267
|
+
* set and the command answers about a DIFFERENT definition than the one the
|
|
268
|
+
* user pinned. Returns an error string, or null when the pin is satisfiable
|
|
269
|
+
* (or when the symbol has no definitions at all — the not-found path
|
|
270
|
+
* downstream owns that case).
|
|
271
|
+
*/
|
|
272
|
+
function checkDefinitionPin(index, p) {
|
|
273
|
+
if (!p.name || (!p.file && !p.line)) return null;
|
|
274
|
+
const defs = index.symbols.get(p.name) || [];
|
|
275
|
+
if (defs.length === 0) return null;
|
|
276
|
+
let candidates = defs;
|
|
277
|
+
if (p.className) {
|
|
278
|
+
const byClass = candidates.filter(d => d.className === p.className);
|
|
279
|
+
if (byClass.length > 0) candidates = byClass;
|
|
280
|
+
}
|
|
281
|
+
const describe = (list) => {
|
|
282
|
+
const shown = list.slice(0, 10)
|
|
283
|
+
.map(d => ` ${d.relativePath}:${d.startLine}${d.className ? ` (${d.className})` : ''}`)
|
|
284
|
+
.join('\n');
|
|
285
|
+
return list.length > 10 ? `${shown}\n ... and ${list.length - 10} more` : shown;
|
|
286
|
+
};
|
|
287
|
+
if (p.file) {
|
|
288
|
+
const byFile = candidates.filter(d => d.relativePath && d.relativePath.includes(p.file));
|
|
289
|
+
if (byFile.length === 0) {
|
|
290
|
+
return `Symbol "${p.name}" not found in files matching "${p.file}". Found ${candidates.length} definition(s) elsewhere:\n${describe(candidates)}\nUse file= with a path fragment from the list above to disambiguate.`;
|
|
291
|
+
}
|
|
292
|
+
candidates = byFile;
|
|
293
|
+
}
|
|
294
|
+
const line = Number(p.line);
|
|
295
|
+
if (p.line && Number.isFinite(line)) {
|
|
296
|
+
const atLine = candidates.filter(d => d.startLine === line);
|
|
297
|
+
if (atLine.length === 0) {
|
|
298
|
+
return `No definition of "${p.name}" at line ${line}${p.file ? ` in files matching "${p.file}"` : ''}. Definitions:\n${describe(candidates)}`;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
return null;
|
|
302
|
+
}
|
|
303
|
+
|
|
234
304
|
/** Read a file and extract lines for a symbol match, applying HTML cleanup. */
|
|
235
305
|
function readAndExtract(match) {
|
|
236
306
|
const content = fs.readFileSync(match.file, 'utf-8');
|
|
@@ -289,6 +359,8 @@ const HANDLERS = {
|
|
|
289
359
|
if (fileErr) return { ok: false, error: fileErr };
|
|
290
360
|
const classErr = validateClassName(index, p.name, p.className);
|
|
291
361
|
if (classErr) return { ok: false, error: classErr };
|
|
362
|
+
const pinErr = checkDefinitionPin(index, p);
|
|
363
|
+
if (pinErr) return { ok: false, error: pinErr };
|
|
292
364
|
const result = index.context(p.name, {
|
|
293
365
|
...buildCallerOptions(p),
|
|
294
366
|
unreachableOnly: !!p.unreachableOnly,
|
|
@@ -307,6 +379,8 @@ const HANDLERS = {
|
|
|
307
379
|
if (fileErr) return { ok: false, error: fileErr };
|
|
308
380
|
const classErr = validateClassName(index, p.name, p.className);
|
|
309
381
|
if (classErr) return { ok: false, error: classErr };
|
|
382
|
+
const pinErr = checkDefinitionPin(index, p);
|
|
383
|
+
if (pinErr) return { ok: false, error: pinErr };
|
|
310
384
|
const result = index.impact(p.name, {
|
|
311
385
|
file: p.file,
|
|
312
386
|
className: p.className,
|
|
@@ -333,6 +407,8 @@ const HANDLERS = {
|
|
|
333
407
|
if (fileErr) return { ok: false, error: fileErr };
|
|
334
408
|
const classErr = validateClassName(index, p.name, p.className);
|
|
335
409
|
if (classErr) return { ok: false, error: classErr };
|
|
410
|
+
const pinErr = checkDefinitionPin(index, p);
|
|
411
|
+
if (pinErr) return { ok: false, error: pinErr };
|
|
336
412
|
const depthVal = num(p.depth, undefined);
|
|
337
413
|
const result = index.blast(p.name, {
|
|
338
414
|
...buildCallerOptions(p),
|
|
@@ -355,6 +431,8 @@ const HANDLERS = {
|
|
|
355
431
|
if (fileErr) return { ok: false, error: fileErr };
|
|
356
432
|
const classErr = validateClassName(index, p.name, p.className);
|
|
357
433
|
if (classErr) return { ok: false, error: classErr };
|
|
434
|
+
const pinErr = checkDefinitionPin(index, p);
|
|
435
|
+
if (pinErr) return { ok: false, error: pinErr };
|
|
358
436
|
const depthVal = num(p.depth, undefined);
|
|
359
437
|
const result = index.reverseTrace(p.name, {
|
|
360
438
|
...buildCallerOptions(p),
|
|
@@ -377,6 +455,8 @@ const HANDLERS = {
|
|
|
377
455
|
if (fileErr) return { ok: false, error: fileErr };
|
|
378
456
|
const classErr = validateClassName(index, p.name, p.className);
|
|
379
457
|
if (classErr) return { ok: false, error: classErr };
|
|
458
|
+
const pinErr = checkDefinitionPin(index, p);
|
|
459
|
+
if (pinErr) return { ok: false, error: pinErr };
|
|
380
460
|
const result = index.smart(p.name, {
|
|
381
461
|
...buildCallerOptions(p),
|
|
382
462
|
withTypes: p.withTypes || false,
|
|
@@ -394,6 +474,8 @@ const HANDLERS = {
|
|
|
394
474
|
if (fileErr) return { ok: false, error: fileErr };
|
|
395
475
|
const classErr = validateClassName(index, p.name, p.className);
|
|
396
476
|
if (classErr) return { ok: false, error: classErr };
|
|
477
|
+
const pinErr = checkDefinitionPin(index, p);
|
|
478
|
+
if (pinErr) return { ok: false, error: pinErr };
|
|
397
479
|
const depthVal = num(p.depth, undefined);
|
|
398
480
|
const result = index.trace(p.name, {
|
|
399
481
|
...buildCallerOptions(p),
|
|
@@ -427,14 +509,9 @@ const HANDLERS = {
|
|
|
427
509
|
});
|
|
428
510
|
if (!result) return { ok: false, error: `No examples found for "${p.name}".` };
|
|
429
511
|
// MEDIUM-8: when no non-test examples found but test-file usages
|
|
430
|
-
// exist,
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
ok: true,
|
|
434
|
-
result,
|
|
435
|
-
note: `0 examples found (excluded ${result.excludedTestCalls} test-file usage${result.excludedTestCalls === 1 ? '' : 's'} — pass --include-tests to include them)`,
|
|
436
|
-
};
|
|
437
|
-
}
|
|
512
|
+
// exist, the formatter surfaces that fact in the body ("No call
|
|
513
|
+
// examples found ... excluded N test-file usages") — no handler
|
|
514
|
+
// note, which printed the same message twice (fix #237).
|
|
438
515
|
return { ok: true, result };
|
|
439
516
|
},
|
|
440
517
|
|
|
@@ -444,6 +521,10 @@ const HANDLERS = {
|
|
|
444
521
|
applyClassMethodSyntax(p);
|
|
445
522
|
const fileErr = checkFilePatternMatch(index, p.file);
|
|
446
523
|
if (fileErr) return { ok: false, error: fileErr };
|
|
524
|
+
const pinErr = checkDefinitionPin(index, p);
|
|
525
|
+
if (pinErr) return { ok: false, error: pinErr };
|
|
526
|
+
const classErr = validateClassName(index, p.name, p.className);
|
|
527
|
+
if (classErr) return { ok: false, error: classErr };
|
|
447
528
|
const result = index.related(p.name, {
|
|
448
529
|
file: p.file,
|
|
449
530
|
className: p.className,
|
|
@@ -472,12 +553,30 @@ const HANDLERS = {
|
|
|
472
553
|
if (fileErr) return { ok: false, error: fileErr };
|
|
473
554
|
const classErr = validateClassName(index, p.name, p.className);
|
|
474
555
|
if (classErr) return { ok: false, error: classErr };
|
|
556
|
+
const pinErr = checkDefinitionPin(index, p);
|
|
557
|
+
if (pinErr) return { ok: false, error: pinErr };
|
|
475
558
|
const { brief } = require('./brief');
|
|
476
|
-
|
|
559
|
+
// Thread the line pin (fix #249: a `lib.js:5:run` handle silently
|
|
560
|
+
// resolved the OTHER same-name def — the pin's whole point).
|
|
561
|
+
const result = brief(index, p.name, { file: p.file, className: p.className, line: p.line, git: !!p.git });
|
|
477
562
|
if (!result) return { ok: false, error: `Symbol "${p.name}" not found.` };
|
|
478
563
|
return { ok: true, result };
|
|
479
564
|
},
|
|
480
565
|
|
|
566
|
+
orient: (index, p) => {
|
|
567
|
+
let top;
|
|
568
|
+
if (p.top != null) {
|
|
569
|
+
const n = Number(String(p.top).trim());
|
|
570
|
+
if (!Number.isInteger(n) || n <= 0) {
|
|
571
|
+
return { ok: false, error: `Invalid --top value: must be a positive integer (got "${p.top}")` };
|
|
572
|
+
}
|
|
573
|
+
top = Math.min(n, 100);
|
|
574
|
+
}
|
|
575
|
+
const { orient } = require('./reporting');
|
|
576
|
+
const result = orient(index, { top });
|
|
577
|
+
return { ok: true, result };
|
|
578
|
+
},
|
|
579
|
+
|
|
481
580
|
doctor: (index, p) => {
|
|
482
581
|
const { doctor } = require('./reporting');
|
|
483
582
|
const result = doctor(index, {
|
|
@@ -559,23 +658,54 @@ const HANDLERS = {
|
|
|
559
658
|
const classErr = validateClassName(index, p.name, p.className);
|
|
560
659
|
if (classErr) return { ok: false, error: classErr };
|
|
561
660
|
}
|
|
562
|
-
|
|
661
|
+
// Scan once WITHOUT the default test exclusion, then filter in the
|
|
662
|
+
// handler — the hidden count must be VISIBLE (fix #234, campaign
|
|
663
|
+
// G2-java: usages silently hid test-file usages while search noted
|
|
664
|
+
// them — a silently incomplete answer from the raw escape hatch).
|
|
665
|
+
// Normalize the user's exclude first (fix #239): MCP delivers a CSV
|
|
666
|
+
// STRING, and matchesFilters iterates its CHARACTERS — exclude=test
|
|
667
|
+
// emptied every TypeScript project ('t' matched the .ts extension).
|
|
668
|
+
const userExclude = toExcludeArray(p.exclude);
|
|
669
|
+
const unfiltered = index.usages(p.name, {
|
|
563
670
|
codeOnly: p.codeOnly || false,
|
|
564
671
|
context: num(p.context, 0),
|
|
565
672
|
className: p.className,
|
|
566
673
|
file: p.file,
|
|
567
|
-
exclude,
|
|
674
|
+
exclude: userExclude,
|
|
568
675
|
in: p.in,
|
|
569
676
|
});
|
|
677
|
+
const notes = [];
|
|
678
|
+
let result = unfiltered;
|
|
679
|
+
if (exclude.length !== userExclude.length && Array.isArray(unfiltered)) {
|
|
680
|
+
result = unfiltered.filter(u => index.matchesFilters(u.relativePath, { exclude }));
|
|
681
|
+
const hidden = unfiltered.length - result.length;
|
|
682
|
+
if (hidden > 0) notes.push(`${hidden} test-file usage(s) hidden by default — pass --include-tests to include them.`);
|
|
683
|
+
}
|
|
570
684
|
// Apply limit to total usages (result is a flat array)
|
|
571
685
|
const limit = num(p.limit, undefined);
|
|
572
|
-
let note;
|
|
573
686
|
let limited = result;
|
|
574
687
|
if (limit && limit > 0 && Array.isArray(result) && result.length > limit) {
|
|
575
|
-
|
|
688
|
+
notes.push(limitNote(limit, result.length));
|
|
576
689
|
limited = result.slice(0, limit);
|
|
690
|
+
// Summary counts describe the FULL result set — the limit applies
|
|
691
|
+
// to listed entries only (fix #237: the header claimed '0 calls'
|
|
692
|
+
// for a called function whenever the definition filled the limit).
|
|
693
|
+
// Non-enumerable so the JSON array shape is unchanged.
|
|
694
|
+
Object.defineProperty(limited, 'summaryCounts', {
|
|
695
|
+
value: {
|
|
696
|
+
definitions: result.filter(u => u.isDefinition).length,
|
|
697
|
+
calls: result.filter(u => u.usageType === 'call').length,
|
|
698
|
+
imports: result.filter(u => u.usageType === 'import').length,
|
|
699
|
+
// Exhaustive complement (fix #241): every non-definition
|
|
700
|
+
// record that isn't a call or import is a reference —
|
|
701
|
+
// same-name definer sites (usageType 'definition' with
|
|
702
|
+
// isDefinition false) used to render in NO band.
|
|
703
|
+
references: result.filter(u => !u.isDefinition && u.usageType !== 'call' && u.usageType !== 'import').length,
|
|
704
|
+
},
|
|
705
|
+
enumerable: false, writable: true, configurable: true,
|
|
706
|
+
});
|
|
577
707
|
}
|
|
578
|
-
return { ok: true, result: limited, note };
|
|
708
|
+
return { ok: true, result: limited, note: notes.length ? notes.join(' ') : undefined };
|
|
579
709
|
},
|
|
580
710
|
|
|
581
711
|
toc: (index, p) => {
|
|
@@ -587,12 +717,21 @@ const HANDLERS = {
|
|
|
587
717
|
all: p.all,
|
|
588
718
|
top: num(p.top, undefined),
|
|
589
719
|
file: p.file,
|
|
590
|
-
|
|
720
|
+
// Normalized (fix #239) — a raw MCP CSV string iterated
|
|
721
|
+
// per-character emptied whole projects (exclude=test vs .ts).
|
|
722
|
+
exclude: toExcludeArray(p.exclude),
|
|
591
723
|
in: p.in,
|
|
592
724
|
});
|
|
593
725
|
// Apply limit to detailed toc entries (symbols are in f.symbols.functions/classes arrays)
|
|
594
726
|
const limit = num(p.limit, undefined);
|
|
595
727
|
let note;
|
|
728
|
+
if (limit && limit > 0 && !p.detailed) {
|
|
729
|
+
// --limit only bounds the per-symbol listing, which compact mode
|
|
730
|
+
// doesn't render (fix #234 — three campaign cells hit the silent
|
|
731
|
+
// no-op; FLAG_APPLICABILITY lists limit for toc, so no
|
|
732
|
+
// inapplicable-flag warning fires either).
|
|
733
|
+
note = '--limit applies to the symbol listing — combine it with --detailed.';
|
|
734
|
+
}
|
|
596
735
|
if (limit && limit > 0 && p.detailed && result.files) {
|
|
597
736
|
let totalEntries = result.files.reduce((s, f) => {
|
|
598
737
|
const syms = f.symbols || {};
|
|
@@ -688,7 +827,15 @@ const HANDLERS = {
|
|
|
688
827
|
tests: (index, p) => {
|
|
689
828
|
const err = requireName(p.name);
|
|
690
829
|
if (err) return { ok: false, error: err };
|
|
691
|
-
|
|
830
|
+
// tests() accepts a FILE PATH as the target ("tests helper.go" — the
|
|
831
|
+
// engine's isFilePath branch searches the basename). Class.method
|
|
832
|
+
// splitting would shear the filename at the dot into
|
|
833
|
+
// className='helper', name='go' and silently return nothing
|
|
834
|
+
// (fix #239, G3-go-measured). Mirror the engine's file-path test.
|
|
835
|
+
const testsTargetIsFile = typeof p.name === 'string' && (
|
|
836
|
+
p.name.includes('/') || p.name.includes('\\') ||
|
|
837
|
+
/\.(js|ts|py|go|java|rs)$/.test(p.name));
|
|
838
|
+
if (!testsTargetIsFile) applyClassMethodSyntax(p);
|
|
692
839
|
if (p.file) {
|
|
693
840
|
const fileErr = checkFilePatternMatch(index, p.file);
|
|
694
841
|
if (fileErr) return { ok: false, error: fileErr };
|
|
@@ -722,6 +869,8 @@ const HANDLERS = {
|
|
|
722
869
|
if (fileErr) return { ok: false, error: fileErr };
|
|
723
870
|
const classErr = validateClassName(index, p.name, p.className);
|
|
724
871
|
if (classErr) return { ok: false, error: classErr };
|
|
872
|
+
const pinErr = checkDefinitionPin(index, p);
|
|
873
|
+
if (pinErr) return { ok: false, error: pinErr };
|
|
725
874
|
const depthVal = num(p.depth, undefined);
|
|
726
875
|
const result = index.affectedTests(p.name, {
|
|
727
876
|
...buildCallerOptions(p),
|
|
@@ -737,6 +886,15 @@ const HANDLERS = {
|
|
|
737
886
|
deadcode: (index, p) => {
|
|
738
887
|
const fileErr = checkFilePatternMatch(index, p.file);
|
|
739
888
|
if (fileErr) return { ok: false, error: fileErr };
|
|
889
|
+
// A typo'd 'in' directory used to yield a clean-sounding 'No dead
|
|
890
|
+
// code found.' (fix #243) — validate it like the file pattern.
|
|
891
|
+
if (p.in) {
|
|
892
|
+
let anyIn = false;
|
|
893
|
+
for (const [, fe] of index.files) {
|
|
894
|
+
if (index.matchesFilters(fe.relativePath, { in: p.in })) { anyIn = true; break; }
|
|
895
|
+
}
|
|
896
|
+
if (!anyIn) return { ok: false, error: `No files matched the 'in' directory filter '${p.in}'.` };
|
|
897
|
+
}
|
|
740
898
|
let result = index.deadcode({
|
|
741
899
|
includeExported: p.includeExported || false,
|
|
742
900
|
includeDecorated: p.includeDecorated || false,
|
|
@@ -755,6 +913,13 @@ const HANDLERS = {
|
|
|
755
913
|
if (result.excludedExported != null) sliced.excludedExported = result.excludedExported;
|
|
756
914
|
if (result.excludedDecorated != null) sliced.excludedDecorated = result.excludedDecorated;
|
|
757
915
|
if (result.excludedExternalContract != null) sliced.excludedExternalContract = result.excludedExternalContract;
|
|
916
|
+
// Truncation must be visible IN the JSON payload, not only in the
|
|
917
|
+
// stderr note (fix #242) — the formatter reads this to emit
|
|
918
|
+
// meta.total + truncated.
|
|
919
|
+
Object.defineProperty(sliced, 'limitInfo', {
|
|
920
|
+
value: { total: result.length, shown: limit },
|
|
921
|
+
enumerable: false, writable: true, configurable: true,
|
|
922
|
+
});
|
|
758
923
|
result = sliced;
|
|
759
924
|
}
|
|
760
925
|
const tNote = truncationNote(index);
|
|
@@ -783,11 +948,22 @@ const HANDLERS = {
|
|
|
783
948
|
file: p.file,
|
|
784
949
|
exclude,
|
|
785
950
|
});
|
|
951
|
+
if (result && result.error) {
|
|
952
|
+
return { ok: false, error: result.message || result.error };
|
|
953
|
+
}
|
|
786
954
|
const limit = num(p.limit, undefined);
|
|
787
955
|
let note;
|
|
788
956
|
if (limit && limit > 0 && Array.isArray(result) && result.length > limit) {
|
|
789
957
|
note = limitNote(limit, result.length);
|
|
790
|
-
|
|
958
|
+
const sliced = result.slice(0, limit);
|
|
959
|
+
// Full-set size travels with the payload so --json can carry
|
|
960
|
+
// meta.total + truncated (fix #247 — mirrors deadcode's #242
|
|
961
|
+
// shape; the stderr note alone loses the signal).
|
|
962
|
+
Object.defineProperty(sliced, 'limitInfo', {
|
|
963
|
+
value: { total: result.length, shown: limit },
|
|
964
|
+
enumerable: false, writable: true, configurable: true,
|
|
965
|
+
});
|
|
966
|
+
result = sliced;
|
|
791
967
|
}
|
|
792
968
|
return { ok: true, result, note };
|
|
793
969
|
},
|
|
@@ -890,7 +1066,11 @@ const HANDLERS = {
|
|
|
890
1066
|
fn: (index, p) => {
|
|
891
1067
|
const err = requireName(p.name);
|
|
892
1068
|
if (err) return { ok: false, error: err };
|
|
893
|
-
|
|
1069
|
+
// Comma lists split Class.method PER ITEM below — the whole-string
|
|
1070
|
+
// splitter mangled "helper,Service.buildUrl" into className
|
|
1071
|
+
// "helper,Service" (fix #252). Handles contain no commas.
|
|
1072
|
+
if (p.name.includes(',')) applyHandleSyntax(p);
|
|
1073
|
+
else applyClassMethodSyntax(p);
|
|
894
1074
|
const fileErr = checkFilePatternMatch(index, p.file);
|
|
895
1075
|
if (fileErr) return { ok: false, error: fileErr };
|
|
896
1076
|
if (p.className) {
|
|
@@ -910,23 +1090,54 @@ const HANDLERS = {
|
|
|
910
1090
|
const fnSplit = splitClassMethod(fnName);
|
|
911
1091
|
const actualName = fnSplit ? fnSplit.methodName : fnName;
|
|
912
1092
|
const fnClassName = fnSplit ? fnSplit.className : p.className;
|
|
913
|
-
|
|
1093
|
+
let matches = index.find(actualName, { file: p.file, className: fnClassName, skipCounts: true })
|
|
914
1094
|
.filter(m => m.type === 'function' || m.params !== undefined);
|
|
915
1095
|
|
|
1096
|
+
// Line pin from a stable handle or explicit line= (fix #249:
|
|
1097
|
+
// `fn both.js:5:run` returned the OTHER same-name def — the
|
|
1098
|
+
// pin's whole point is exactness, so a non-matching line errors
|
|
1099
|
+
// instead of falling back).
|
|
1100
|
+
const pinLine = num(p.line, null);
|
|
1101
|
+
if (pinLine != null && matches.length > 0) {
|
|
1102
|
+
const atLine = matches.filter(m => m.startLine === pinLine || m.nameLine === pinLine);
|
|
1103
|
+
if (atLine.length === 0) {
|
|
1104
|
+
notes.push(`No definition of "${fnName}" at line ${pinLine}. Found: ${matches.map(m => `${m.relativePath}:${m.startLine}`).join(', ')}.`);
|
|
1105
|
+
continue;
|
|
1106
|
+
}
|
|
1107
|
+
matches = atLine;
|
|
1108
|
+
}
|
|
1109
|
+
|
|
916
1110
|
if (matches.length === 0) {
|
|
917
1111
|
// Check if it's a class — suggest `class` command instead
|
|
918
|
-
const CLASS_TYPES = ['class', 'interface', 'type', 'enum', 'struct', 'trait'];
|
|
919
1112
|
const classMatches = index.find(actualName, { file: p.file, className: fnClassName, skipCounts: true })
|
|
920
|
-
.filter(m =>
|
|
1113
|
+
.filter(m => CLASS_KIND_TYPES.includes(m.type));
|
|
921
1114
|
if (classMatches.length > 0) {
|
|
922
|
-
|
|
1115
|
+
{
|
|
1116
|
+
const kind = classMatches[0].type;
|
|
1117
|
+
const article = /^[aeiou]/.test(kind) ? 'an' : 'a';
|
|
1118
|
+
notes.push(`"${fnName}" is ${article} ${kind}, not a function. Use \`class ${fnName}\` instead.`);
|
|
1119
|
+
}
|
|
1120
|
+
} else if ((index.symbols.get(actualName) || []).some(s => s.type === 'macro')) {
|
|
1121
|
+
notes.push(`"${fnName}" is a macro. fn/class extract functions and classes — use \`lines <file>:<start>-<end>\` for macro bodies.`);
|
|
923
1122
|
} else {
|
|
924
1123
|
notes.push(`Function "${fnName}" not found.`);
|
|
925
1124
|
}
|
|
926
1125
|
continue;
|
|
927
1126
|
}
|
|
928
1127
|
|
|
929
|
-
|
|
1128
|
+
// Fuzzy substitution is never silent (fix #248: `fn run`
|
|
1129
|
+
// returned runAll with no indication).
|
|
1130
|
+
const isFuzzy = !matches.some(m => m.name === actualName);
|
|
1131
|
+
if (isFuzzy) {
|
|
1132
|
+
const shown = [...new Set(matches.map(m => m.name))].join('", "');
|
|
1133
|
+
notes.push(`No exact match for "${fnName}" — showing closest match: "${shown}".`);
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
// --all and the disambiguation note apply whenever the pattern
|
|
1137
|
+
// matched several definitions — a --file filter narrows the set
|
|
1138
|
+
// but does not make it single (fix #248: with --file, extra
|
|
1139
|
+
// in-file matches were silently dropped in three languages).
|
|
1140
|
+
if (matches.length > 1 && p.all) {
|
|
930
1141
|
for (const m of matches) {
|
|
931
1142
|
const code = readAndExtract(m);
|
|
932
1143
|
entries.push({ match: m, code });
|
|
@@ -934,14 +1145,15 @@ const HANDLERS = {
|
|
|
934
1145
|
continue;
|
|
935
1146
|
}
|
|
936
1147
|
|
|
937
|
-
const match = matches.length > 1
|
|
1148
|
+
const match = matches.length > 1
|
|
938
1149
|
? pickBestDefinition(matches)
|
|
939
1150
|
: matches[0];
|
|
940
1151
|
|
|
941
|
-
if (matches.length > 1
|
|
1152
|
+
if (matches.length > 1) {
|
|
942
1153
|
const others = matches.filter(m => m !== match)
|
|
943
1154
|
.map(m => `${m.relativePath}:${m.startLine}`).join(', ');
|
|
944
|
-
|
|
1155
|
+
const what = isFuzzy ? 'fuzzy matches' : 'definitions';
|
|
1156
|
+
notes.push(`Found ${matches.length} ${what} for "${fnName}". Showing ${match.relativePath}:${match.startLine}. Also in: ${others}. ${disambiguationHint(matches, match, p.file)}`);
|
|
945
1157
|
}
|
|
946
1158
|
|
|
947
1159
|
const code = readAndExtract(match);
|
|
@@ -957,14 +1169,32 @@ const HANDLERS = {
|
|
|
957
1169
|
class: (index, p) => {
|
|
958
1170
|
const err = requireName(p.name);
|
|
959
1171
|
if (err) return { ok: false, error: err };
|
|
1172
|
+
// Stable handles roundtrip here too (fix #249: `class svc.js:7:Service`
|
|
1173
|
+
// errored "not found" — find→class was the one broken hop).
|
|
1174
|
+
applyHandleSyntax(p);
|
|
960
1175
|
const fileErr = checkFilePatternMatch(index, p.file);
|
|
961
1176
|
if (fileErr) return { ok: false, error: fileErr };
|
|
962
1177
|
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
1178
|
+
let matches = index.find(p.name, { file: p.file, skipCounts: true })
|
|
1179
|
+
.filter(m => CLASS_KIND_TYPES.includes(m.type));
|
|
1180
|
+
|
|
1181
|
+
const classPinLine = num(p.line, null);
|
|
1182
|
+
if (classPinLine != null && matches.length > 0) {
|
|
1183
|
+
const atLine = matches.filter(m => m.startLine === classPinLine || m.nameLine === classPinLine);
|
|
1184
|
+
if (atLine.length === 0) {
|
|
1185
|
+
return { ok: false, error: `No definition of "${p.name}" at line ${classPinLine}. Found: ${matches.map(m => `${m.relativePath}:${m.startLine}`).join(', ')}.` };
|
|
1186
|
+
}
|
|
1187
|
+
matches = atLine;
|
|
1188
|
+
}
|
|
966
1189
|
|
|
967
1190
|
if (matches.length === 0) {
|
|
1191
|
+
// Cross-kind hint parity with fn (fix #252): a function by this
|
|
1192
|
+
// name deserves a redirect, not a bare "not found".
|
|
1193
|
+
const fnMatches = index.find(p.name, { file: p.file, skipCounts: true })
|
|
1194
|
+
.filter(m => m.type === 'function' || (m.params !== undefined && !CLASS_KIND_TYPES.includes(m.type)));
|
|
1195
|
+
if (fnMatches.length > 0) {
|
|
1196
|
+
return { ok: false, error: `Class "${p.name}" not found — "${p.name}" is a ${fnMatches[0].type}. Use \`fn ${p.name}\` instead.` };
|
|
1197
|
+
}
|
|
968
1198
|
return { ok: false, error: `Class "${p.name}" not found.` };
|
|
969
1199
|
}
|
|
970
1200
|
|
|
@@ -976,7 +1206,14 @@ const HANDLERS = {
|
|
|
976
1206
|
return { ok: false, error: '--max-lines must be a positive integer.' };
|
|
977
1207
|
}
|
|
978
1208
|
|
|
979
|
-
|
|
1209
|
+
// Fuzzy substitution is never silent (fix #248).
|
|
1210
|
+
const classFuzzy = !matches.some(m => m.name === p.name);
|
|
1211
|
+
if (classFuzzy) {
|
|
1212
|
+
const shown = [...new Set(matches.map(m => m.name))].join('", "');
|
|
1213
|
+
notes.push(`No exact match for "${p.name}" — showing closest match: "${shown}".`);
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1216
|
+
if (matches.length > 1 && p.all) {
|
|
980
1217
|
for (const m of matches) {
|
|
981
1218
|
const code = readAndExtract(m);
|
|
982
1219
|
const totalLines = m.endLine - m.startLine + 1;
|
|
@@ -985,14 +1222,15 @@ const HANDLERS = {
|
|
|
985
1222
|
return { ok: true, result: { entries }, note: notes.length ? notes.map(n => 'Note: ' + n).join('\n') : undefined };
|
|
986
1223
|
}
|
|
987
1224
|
|
|
988
|
-
const match = matches.length > 1
|
|
1225
|
+
const match = matches.length > 1
|
|
989
1226
|
? pickBestDefinition(matches)
|
|
990
1227
|
: matches[0];
|
|
991
1228
|
|
|
992
|
-
if (matches.length > 1
|
|
1229
|
+
if (matches.length > 1) {
|
|
993
1230
|
const others = matches.filter(m => m !== match)
|
|
994
1231
|
.map(m => `${m.relativePath}:${m.startLine}`).join(', ');
|
|
995
|
-
|
|
1232
|
+
const what = classFuzzy ? 'fuzzy matches' : 'definitions';
|
|
1233
|
+
notes.push(`Found ${matches.length} ${what} for "${p.name}". Showing ${match.relativePath}:${match.startLine}. Also in: ${others}. ${disambiguationHint(matches, match, p.file)}`);
|
|
996
1234
|
}
|
|
997
1235
|
|
|
998
1236
|
const totalLines = match.endLine - match.startLine + 1;
|
|
@@ -1043,13 +1281,22 @@ const HANDLERS = {
|
|
|
1043
1281
|
const startLine = Math.min(rawStart, rawEnd);
|
|
1044
1282
|
const endLine = Math.max(rawStart, rawEnd);
|
|
1045
1283
|
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1284
|
+
// Ambiguity-aware resolution (fix #248: an ambiguous basename
|
|
1285
|
+
// reported "File not found" — factually wrong; imports/exporters
|
|
1286
|
+
// list the candidates for the same input).
|
|
1287
|
+
const resolved = index.resolveFilePathForQuery(p.file);
|
|
1288
|
+
const resolveErr = checkFileError(typeof resolved === 'string' ? null : resolved, p.file);
|
|
1289
|
+
if (resolveErr) return { ok: false, error: resolveErr };
|
|
1290
|
+
const filePath = resolved;
|
|
1050
1291
|
|
|
1051
1292
|
const content = fs.readFileSync(filePath, 'utf-8');
|
|
1052
1293
|
const fileLines = content.split('\n');
|
|
1294
|
+
// A trailing newline is a line TERMINATOR, not a phantom last line
|
|
1295
|
+
// (fix #248: a 279-line file counted as 280 — the empty split tail
|
|
1296
|
+
// was retrievable and skewed the bounds error).
|
|
1297
|
+
if (fileLines.length > 1 && fileLines[fileLines.length - 1] === '') {
|
|
1298
|
+
fileLines.pop();
|
|
1299
|
+
}
|
|
1053
1300
|
|
|
1054
1301
|
if (startLine > fileLines.length) {
|
|
1055
1302
|
return { ok: false, error: `Line ${startLine} is out of bounds. File has ${fileLines.length} lines.` };
|
|
@@ -1118,6 +1365,8 @@ const HANDLERS = {
|
|
|
1118
1365
|
},
|
|
1119
1366
|
|
|
1120
1367
|
circularDeps: (index, p) => {
|
|
1368
|
+
const fileErr = checkFilePatternMatch(index, p.file);
|
|
1369
|
+
if (fileErr) return { ok: false, error: fileErr };
|
|
1121
1370
|
const result = index.circularDeps({
|
|
1122
1371
|
file: p.file,
|
|
1123
1372
|
exclude: toExcludeArray(p.exclude),
|
|
@@ -1135,9 +1384,16 @@ const HANDLERS = {
|
|
|
1135
1384
|
if (fileErr) return { ok: false, error: fileErr };
|
|
1136
1385
|
const classErr = validateClassName(index, p.name, p.className);
|
|
1137
1386
|
if (classErr) return { ok: false, error: classErr };
|
|
1387
|
+
const pinErr = checkDefinitionPin(index, p);
|
|
1388
|
+
if (pinErr) return { ok: false, error: pinErr };
|
|
1138
1389
|
const result = index.verify(p.name, {
|
|
1139
1390
|
file: p.file,
|
|
1140
1391
|
className: p.className,
|
|
1392
|
+
// Pin to an exact definition (stable-handle roundtrip: `verify
|
|
1393
|
+
// lib.js:4:save`). resolveSymbol filters by startLine — without
|
|
1394
|
+
// this passthrough the pin was silently dropped and scoring picked
|
|
1395
|
+
// among same-name defs (fix #227).
|
|
1396
|
+
...(p.line && { line: p.line }),
|
|
1141
1397
|
// BUG-H3: pass through user-supplied flags. Verify defaults to including
|
|
1142
1398
|
// method calls (current behavior) so call-arity checks reach all forms,
|
|
1143
1399
|
// including obj.method() invocations. User can disable with
|
|
@@ -1145,6 +1401,9 @@ const HANDLERS = {
|
|
|
1145
1401
|
...(p.includeMethods !== undefined && { includeMethods: p.includeMethods }),
|
|
1146
1402
|
...(p.includeUncertain !== undefined && { includeUncertain: p.includeUncertain }),
|
|
1147
1403
|
});
|
|
1404
|
+
if (result && result.found === false) {
|
|
1405
|
+
return { ok: false, error: `Function "${p.name}" not found.` };
|
|
1406
|
+
}
|
|
1148
1407
|
return { ok: true, result };
|
|
1149
1408
|
},
|
|
1150
1409
|
|
|
@@ -1156,6 +1415,8 @@ const HANDLERS = {
|
|
|
1156
1415
|
if (fileErr) return { ok: false, error: fileErr };
|
|
1157
1416
|
const classErr = validateClassName(index, p.name, p.className);
|
|
1158
1417
|
if (classErr) return { ok: false, error: classErr };
|
|
1418
|
+
const pinErr = checkDefinitionPin(index, p);
|
|
1419
|
+
if (pinErr) return { ok: false, error: pinErr };
|
|
1159
1420
|
if (!p.addParam && !p.removeParam && !p.renameTo) {
|
|
1160
1421
|
return { ok: false, error: 'Plan requires an operation: add_param, remove_param, or rename_to.' };
|
|
1161
1422
|
}
|
|
@@ -1166,7 +1427,12 @@ const HANDLERS = {
|
|
|
1166
1427
|
defaultValue: p.defaultValue,
|
|
1167
1428
|
file: p.file,
|
|
1168
1429
|
className: p.className,
|
|
1430
|
+
// Exact-definition pin (stable-handle roundtrip) — see verify.
|
|
1431
|
+
...(p.line && { line: p.line }),
|
|
1169
1432
|
});
|
|
1433
|
+
if (result && result.found === false) {
|
|
1434
|
+
return { ok: false, error: `Function "${p.name}" not found.` };
|
|
1435
|
+
}
|
|
1170
1436
|
return { ok: true, result };
|
|
1171
1437
|
},
|
|
1172
1438
|
|
|
@@ -1222,7 +1488,15 @@ const HANDLERS = {
|
|
|
1222
1488
|
let note;
|
|
1223
1489
|
if (limit && limit > 0 && Array.isArray(result)) {
|
|
1224
1490
|
const { items, total, limited } = applyLimit(result, limit);
|
|
1225
|
-
if (limited)
|
|
1491
|
+
if (limited) {
|
|
1492
|
+
note = limitNote(limit, total);
|
|
1493
|
+
// Full-set size travels with the payload for --json
|
|
1494
|
+
// (fix #251 — the deadcode/entrypoints #242/#247 shape).
|
|
1495
|
+
Object.defineProperty(items, 'limitInfo', {
|
|
1496
|
+
value: { total, shown: limit },
|
|
1497
|
+
enumerable: false, writable: true, configurable: true,
|
|
1498
|
+
});
|
|
1499
|
+
}
|
|
1226
1500
|
result = items;
|
|
1227
1501
|
}
|
|
1228
1502
|
return { ok: true, result, note };
|
|
@@ -1265,6 +1539,12 @@ const HANDLERS = {
|
|
|
1265
1539
|
top = n;
|
|
1266
1540
|
}
|
|
1267
1541
|
}
|
|
1542
|
+
// --top only shapes the --hot leaderboard (fix #251: validated then
|
|
1543
|
+
// silently ignored without it).
|
|
1544
|
+
if (p.top != null && !p.hot) {
|
|
1545
|
+
const depNote = 'Note: --top applies to the --hot leaderboard — add --hot to rank functions by call count.';
|
|
1546
|
+
note = note ? `${note}\n${depNote}` : depNote;
|
|
1547
|
+
}
|
|
1268
1548
|
const result = index.getStats({
|
|
1269
1549
|
functions: p.functions || false,
|
|
1270
1550
|
hot: p.hot || false,
|
|
@@ -1330,6 +1610,13 @@ function execute(index, command, params = {}) {
|
|
|
1330
1610
|
if (!handler) {
|
|
1331
1611
|
return { ok: false, error: `Unknown command: ${command}` };
|
|
1332
1612
|
}
|
|
1613
|
+
// Handlers normalize params in place (handle syntax, Class.method
|
|
1614
|
+
// splitting) — dispatch on a shallow copy so the caller's object
|
|
1615
|
+
// survives reuse (fix #255: `execute(index, 'fn', p)` rewrote p.name
|
|
1616
|
+
// 'Service.buildUrl' → 'buildUrl' + className, corrupting a reused or
|
|
1617
|
+
// logged params object; a frozen object silently no-oped the
|
|
1618
|
+
// normalizers and returned wrong not-found answers).
|
|
1619
|
+
params = { ...params };
|
|
1333
1620
|
try {
|
|
1334
1621
|
// Resolve name-less handles (e.g. `lib.js:42`) via index lookup before dispatch.
|
|
1335
1622
|
// Handles WITH a name suffix are handled later by applyClassMethodSyntax.
|