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.
Files changed (46) hide show
  1. package/.claude/skills/ucn/SKILL.md +31 -7
  2. package/README.md +89 -50
  3. package/cli/index.js +199 -94
  4. package/core/account.js +3 -1
  5. package/core/analysis.js +322 -304
  6. package/core/bridge.js +13 -8
  7. package/core/cache.js +109 -19
  8. package/core/callers.js +969 -77
  9. package/core/check.js +19 -8
  10. package/core/deadcode.js +368 -40
  11. package/core/discovery.js +31 -11
  12. package/core/entrypoints.js +149 -17
  13. package/core/execute.js +330 -43
  14. package/core/graph-build.js +61 -10
  15. package/core/graph.js +282 -61
  16. package/core/imports.js +72 -3
  17. package/core/output/analysis-ext.js +70 -10
  18. package/core/output/analysis.js +52 -33
  19. package/core/output/check.js +4 -1
  20. package/core/output/endpoints.js +8 -3
  21. package/core/output/extraction.js +12 -1
  22. package/core/output/find.js +23 -9
  23. package/core/output/graph.js +32 -9
  24. package/core/output/refactoring.js +147 -49
  25. package/core/output/reporting.js +104 -5
  26. package/core/output/search.js +30 -3
  27. package/core/output/shared.js +31 -4
  28. package/core/output/tracing.js +22 -11
  29. package/core/parser.js +8 -6
  30. package/core/project.js +167 -7
  31. package/core/registry.js +20 -16
  32. package/core/reporting.js +131 -13
  33. package/core/search.js +270 -55
  34. package/core/shared.js +240 -1
  35. package/core/stacktrace.js +23 -1
  36. package/core/tracing.js +278 -36
  37. package/core/verify.js +352 -349
  38. package/languages/go.js +29 -17
  39. package/languages/index.js +56 -0
  40. package/languages/java.js +133 -16
  41. package/languages/javascript.js +215 -27
  42. package/languages/python.js +113 -47
  43. package/languages/rust.js +89 -26
  44. package/languages/utils.js +35 -7
  45. package/mcp/server.js +69 -30
  46. 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
- return exclude.split(',').map(s => s.trim()).filter(Boolean);
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
- if (result?.warnings?.length > 0) {
194
- for (const w of result.warnings) parts.push(w.message || w);
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, return success with a note so the user knows to retry.
431
- if (!result.best && result.excludedTestCalls > 0) {
432
- return {
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
- const result = brief(index, p.name, { file: p.file, className: p.className, git: !!p.git });
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
- const result = index.usages(p.name, {
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
- note = limitNote(limit, result.length);
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
- exclude: p.exclude,
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
- applyClassMethodSyntax(p);
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
- result = result.slice(0, limit);
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
- applyClassMethodSyntax(p);
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
- const matches = index.find(actualName, { file: p.file, className: fnClassName, skipCounts: true })
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 => CLASS_TYPES.includes(m.type));
1113
+ .filter(m => CLASS_KIND_TYPES.includes(m.type));
921
1114
  if (classMatches.length > 0) {
922
- notes.push(`"${fnName}" is a ${classMatches[0].type}, not a function. Use \`class ${fnName}\` instead.`);
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
- if (matches.length > 1 && !p.file && p.all) {
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 && !p.file
1148
+ const match = matches.length > 1
938
1149
  ? pickBestDefinition(matches)
939
1150
  : matches[0];
940
1151
 
941
- if (matches.length > 1 && !p.file) {
1152
+ if (matches.length > 1) {
942
1153
  const others = matches.filter(m => m !== match)
943
1154
  .map(m => `${m.relativePath}:${m.startLine}`).join(', ');
944
- notes.push(`Found ${matches.length} definitions for "${fnName}". Showing ${match.relativePath}:${match.startLine}. Also in: ${others}. Use --file to disambiguate or --all to show all.`);
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
- const CLASS_TYPES = ['class', 'interface', 'type', 'enum', 'struct', 'trait'];
964
- const matches = index.find(p.name, { file: p.file, skipCounts: true })
965
- .filter(m => CLASS_TYPES.includes(m.type));
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
- if (matches.length > 1 && !p.file && p.all) {
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 && !p.file
1225
+ const match = matches.length > 1
989
1226
  ? pickBestDefinition(matches)
990
1227
  : matches[0];
991
1228
 
992
- if (matches.length > 1 && !p.file) {
1229
+ if (matches.length > 1) {
993
1230
  const others = matches.filter(m => m !== match)
994
1231
  .map(m => `${m.relativePath}:${m.startLine}`).join(', ');
995
- notes.push(`Found ${matches.length} definitions for "${p.name}". Showing ${match.relativePath}:${match.startLine}. Also in: ${others}. Use --file to disambiguate or --all to show all.`);
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
- const filePath = index.findFile(p.file);
1047
- if (!filePath) {
1048
- return { ok: false, error: `File not found in project: ${p.file}` };
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) note = limitNote(limit, total);
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.