ucn 4.2.3 → 5.0.2

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 (72) hide show
  1. package/.claude/skills/ucn/SKILL.md +89 -77
  2. package/.claude/skills/ucn/references/commands.md +62 -68
  3. package/.claude/skills/ucn/references/trust-contract.md +31 -6
  4. package/README.md +438 -305
  5. package/assets/demo.svg +31 -0
  6. package/cli/index.js +430 -1385
  7. package/core/account.js +144 -34
  8. package/core/analysis.js +182 -72
  9. package/core/ast-analysis.js +279 -0
  10. package/core/bridge.js +205 -24
  11. package/core/brief.js +27 -58
  12. package/core/build-worker.js +21 -140
  13. package/core/cache.js +513 -11
  14. package/core/callers.js +4920 -456
  15. package/core/check.js +13 -4
  16. package/core/command-contracts.js +402 -0
  17. package/core/compilation-database.js +276 -0
  18. package/core/confidence.js +4 -1
  19. package/core/deadcode.js +397 -19
  20. package/core/discovery.js +359 -46
  21. package/core/entrypoints.js +195 -41
  22. package/core/execute.js +887 -81
  23. package/core/graph-build.js +162 -7
  24. package/core/graph.js +53 -77
  25. package/core/imports.js +65 -6
  26. package/core/index-ir.js +138 -0
  27. package/core/ir.js +195 -0
  28. package/core/output/analysis.js +212 -22
  29. package/core/output/brief.js +23 -0
  30. package/core/output/check.js +4 -0
  31. package/core/output/doctor.js +37 -6
  32. package/core/output/endpoints.js +5 -2
  33. package/core/output/extraction.js +24 -12
  34. package/core/output/find.js +141 -36
  35. package/core/output/graph.js +11 -5
  36. package/core/output/public.js +462 -0
  37. package/core/output/refactoring.js +42 -10
  38. package/core/output/reporting.js +97 -20
  39. package/core/output/search.js +24 -16
  40. package/core/output/shared.js +22 -1
  41. package/core/output/tracing.js +30 -15
  42. package/core/output-budget.js +295 -0
  43. package/core/output.js +1 -0
  44. package/core/parallel-build.js +44 -11
  45. package/core/parser.js +3 -3
  46. package/core/project.js +384 -187
  47. package/core/public-command.js +47 -0
  48. package/core/registry.js +247 -117
  49. package/core/reporting.js +312 -290
  50. package/core/search.js +317 -185
  51. package/core/semantic-provider.js +110 -0
  52. package/core/stacktrace.js +25 -0
  53. package/core/tracing.js +101 -51
  54. package/core/trust-matrix.js +19 -40
  55. package/core/verify.js +534 -37
  56. package/languages/adapter.js +218 -0
  57. package/languages/c-family.js +2791 -0
  58. package/languages/c.js +3 -0
  59. package/languages/cpp.js +3 -0
  60. package/languages/csharp.js +1402 -0
  61. package/languages/go.js +60 -21
  62. package/languages/html.js +2 -2
  63. package/languages/index.js +85 -7
  64. package/languages/java.js +396 -13
  65. package/languages/javascript.js +199 -19
  66. package/languages/python.js +964 -22
  67. package/languages/rust.js +1317 -152
  68. package/languages/utils.js +40 -3
  69. package/mcp/server.js +254 -636
  70. package/package.json +39 -22
  71. package/eslint.config.js +0 -43
  72. package/jsconfig.json +0 -10
package/core/execute.js CHANGED
@@ -15,11 +15,18 @@ const path = require('path');
15
15
  const { addTestExclusions, pickBestDefinition, parseSymbolHandle, looksLikeHandle } = require('./shared');
16
16
  const { cleanHtmlScriptTags, detectLanguage } = require('./parser');
17
17
  const { renderExpandItem } = require('./expand-cache');
18
+ const { CANONICAL_COMMANDS } = require('./registry');
18
19
 
19
20
  // ============================================================================
20
21
  // HELPERS
21
22
  // ============================================================================
22
23
 
24
+ const NO_STATIC_TEST_LINK_NOTE =
25
+ 'No confirmed static test link was found. This is not runtime coverage evidence: ' +
26
+ 'subprocess/black-box tests, reflection, generated code, and external harnesses can still ' +
27
+ 'exercise the target. Inspect the project test runner and search for the defining module/file ' +
28
+ 'before concluding it is untested.';
29
+
23
30
  function requireName(name) {
24
31
  if (!name || (typeof name === 'string' && !name.trim())) {
25
32
  return 'Symbol name is required.';
@@ -41,6 +48,59 @@ function requireTerm(term) {
41
48
  return null;
42
49
  }
43
50
 
51
+ function editDistance(a, b) {
52
+ const prev = Array.from({ length: b.length + 1 }, (_, i) => i);
53
+ for (let i = 1; i <= a.length; i++) {
54
+ let diagonal = prev[0];
55
+ prev[0] = i;
56
+ for (let j = 1; j <= b.length; j++) {
57
+ const above = prev[j];
58
+ prev[j] = Math.min(prev[j] + 1, prev[j - 1] + 1,
59
+ diagonal + (a[i - 1] === b[j - 1] ? 0 : 1));
60
+ diagonal = above;
61
+ }
62
+ }
63
+ return prev[b.length];
64
+ }
65
+
66
+ function symbolNotFound(index, name, label = 'Symbol') {
67
+ const query = String(name || '');
68
+ const lower = query.toLowerCase();
69
+ let best = null;
70
+ for (const candidate of index.symbols.keys()) {
71
+ const value = String(candidate);
72
+ const candidateLower = value.toLowerCase();
73
+ const distance = editDistance(lower, candidateLower);
74
+ const prefixBonus = candidateLower.startsWith(lower) || lower.startsWith(candidateLower)
75
+ ? -2 : 0;
76
+ const score = distance + prefixBonus;
77
+ if (!best || score < best.score ||
78
+ (score === best.score && value < best.value)) {
79
+ best = { value, score, distance };
80
+ }
81
+ }
82
+ const threshold = Math.max(2, Math.floor(query.length * 0.3));
83
+ let message;
84
+ if (best && best.distance <= threshold) {
85
+ message = `${label} "${query}" not found. Did you mean "${best.value}"? ` +
86
+ 'Use find to confirm the symbol and obtain a stable handle.';
87
+ } else {
88
+ message = `${label} "${query}" not found. Use find with a shorter name, or search for a literal occurrence.`;
89
+ }
90
+ const unsupported = require('./account').scanUnsupportedFiles(index, query);
91
+ if (unsupported.lines > 0) {
92
+ const languages = Object.keys(unsupported.languages).join(', ');
93
+ message += ` ${unsupported.lines} literal match(es) exist in ` +
94
+ `${unsupported.fileCount} unsupported-language file(s)` +
95
+ `${languages ? ` (${languages})` : ''}; this is not a semantic zero. ` +
96
+ 'Use grep/ripgrep plus a language-native analyzer.';
97
+ } else if (index.unsupportedFiles?.length > 0) {
98
+ message += ` UCN skipped ${index.unsupportedFiles.length} unsupported source ` +
99
+ `file(s), so this is not a repository-wide semantic zero; verify with grep/ripgrep.`;
100
+ }
101
+ return message;
102
+ }
103
+
44
104
  /**
45
105
  * Split Class.method syntax into className and methodName.
46
106
  * Returns { className, methodName } or null if not applicable.
@@ -53,10 +113,21 @@ function splitClassMethod(name) {
53
113
  if (dotIndex <= 0 || dotIndex === name.length - 1) return null;
54
114
  // Only split on first dot, and only if there's exactly one dot
55
115
  if (name.indexOf('.', dotIndex + 1) !== -1) return null;
56
- return {
57
- className: name.substring(0, dotIndex),
58
- methodName: name.substring(dotIndex + 1)
59
- };
116
+ const className = name.substring(0, dotIndex);
117
+ const methodName = name.substring(dotIndex + 1);
118
+ // This is authored identifier syntax, not a generic string splitter.
119
+ // Preserve computed names, malformed handles, paths, and source text.
120
+ const identifier = /^[\p{L}_$][\p{L}\p{N}_$]*$/u;
121
+ if (!identifier.test(className) || !identifier.test(methodName)) return null;
122
+ return { className, methodName };
123
+ }
124
+
125
+ function validateInFilter(index, value) {
126
+ if (!value) return null;
127
+ for (const [, fileEntry] of index.files) {
128
+ if (index.matchesFilters(fileEntry.relativePath, { in: value })) return null;
129
+ }
130
+ return `No files matched the 'in' directory filter '${value}'.`;
60
131
  }
61
132
 
62
133
  /**
@@ -88,12 +159,17 @@ function applyHandleSyntax(p) {
88
159
  if (!looksLikeHandle(p.name)) return;
89
160
  const h = parseSymbolHandle(p.name);
90
161
  if (!h) return;
162
+ p._handleFile = h.file;
163
+ p._handleLine = h.line;
91
164
  // Pull name out of handle. If the handle has no name suffix, we need to
92
165
  // recover it from the index — but at this layer we only have params.
93
166
  // The downstream resolveSymbol path will look up by file+line if name is empty.
94
167
  if (h.name) p.name = h.name;
95
168
  // Only override p.file/p.line if they weren't explicitly set by the user
96
- if (h.file && !p.file) p.file = h.file;
169
+ if (h.file && !p.file) {
170
+ p.file = h.file;
171
+ p._fileFromHandle = true;
172
+ }
97
173
  if (h.line && !p.line) p.line = h.line;
98
174
  }
99
175
 
@@ -151,9 +227,21 @@ function disambiguationHint(matches, chosen, fileGiven) {
151
227
  }
152
228
 
153
229
  /** Check if a file-based result has a file error. */
154
- function checkFileError(result, file) {
230
+ function checkFileError(result, file, index = null) {
155
231
  if (!result) return null;
156
232
  if (result.error === 'file-not-found') {
233
+ if (index) {
234
+ const exactUnsupported = (index.unsupportedFiles || []).find(entry =>
235
+ entry.relativePath === file || entry.relativePath.endsWith('/' + file));
236
+ const candidate = path.isAbsolute(file)
237
+ ? path.resolve(file) : path.resolve(index.root, file);
238
+ if (exactUnsupported || fs.existsSync(candidate)) {
239
+ const language = exactUnsupported?.language ||
240
+ exactUnsupported?.extension || 'unsupported/excluded';
241
+ return `File exists but is not indexed by UCN: ${file} (${language}). ` +
242
+ 'Use grep/ripgrep plus a language-native analyzer.';
243
+ }
244
+ }
157
245
  return `File not found in project: ${file}`;
158
246
  }
159
247
  if (result.error === 'file-ambiguous') {
@@ -222,8 +310,12 @@ function treeNote(result) {
222
310
  // result.warnings are NOT copied here — the tree formatters render them
223
311
  // in the body (Note: lines under the header); copying them into the
224
312
  // handler note printed each warning twice (fix #237).
225
- if (result?.tree?.truncatedChildren > 0) {
226
- parts.push(`${result.tree.truncatedChildren} children truncated. Use --depth=N or --all to expand.`);
313
+ const countTruncated = node => !node ? 0 :
314
+ (node.truncatedChildren || 0) +
315
+ (node.children || []).reduce((sum, child) => sum + countTruncated(child), 0);
316
+ const truncatedChildren = countTruncated(result?.tree);
317
+ if (truncatedChildren > 0) {
318
+ parts.push(`${truncatedChildren} children truncated by the per-node cap. Use --all to expand them; --depth controls hops only.`);
227
319
  }
228
320
  if (result?.truncatedCallers > 0) {
229
321
  parts.push(`${result.truncatedCallers} callers truncated. Use --all to expand.`);
@@ -284,7 +376,10 @@ function checkDefinitionPin(index, p) {
284
376
  return list.length > 10 ? `${shown}\n ... and ${list.length - 10} more` : shown;
285
377
  };
286
378
  if (p.file) {
287
- const byFile = candidates.filter(d => d.relativePath && d.relativePath.includes(p.file));
379
+ const resolvedFile = index.resolveFilePathForQuery(p.file);
380
+ const byFile = typeof resolvedFile === 'string'
381
+ ? candidates.filter(d => d.file === resolvedFile)
382
+ : candidates.filter(d => d.relativePath && d.relativePath.includes(p.file));
288
383
  if (byFile.length === 0) {
289
384
  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.`;
290
385
  }
@@ -292,7 +387,8 @@ function checkDefinitionPin(index, p) {
292
387
  }
293
388
  const line = Number(p.line);
294
389
  if (p.line && Number.isFinite(line)) {
295
- const atLine = candidates.filter(d => d.startLine === line);
390
+ const atLine = candidates.filter(d =>
391
+ d.startLine === line || d.nameLine === line);
296
392
  if (atLine.length === 0) {
297
393
  return `No definition of "${p.name}" at line ${line}${p.file ? ` in files matching "${p.file}"` : ''}. Definitions:\n${describe(candidates)}`;
298
394
  }
@@ -308,12 +404,363 @@ function readAndExtract(match) {
308
404
  return cleanHtmlScriptTags(extracted, detectLanguage(match.file)).join('\n');
309
405
  }
310
406
 
407
+ const SHOW_SECTIONS = new Set([
408
+ 'summary', 'callers', 'callees', 'source', 'dependencies',
409
+ 'tests', 'types', 'example', 'related',
410
+ ]);
411
+ const REPO_SECTIONS = new Set(['summary', 'files', 'stats', 'health']);
412
+
413
+ /** Parse a comma-separated/array section selector with validation. */
414
+ function parseSections(value, defaults, allowed, command) {
415
+ const supplied = value != null && value !== '';
416
+ const raw = !supplied
417
+ ? defaults
418
+ : (Array.isArray(value) ? value : String(value).split(','));
419
+ const sections = [...new Set(raw.map(s => String(s).trim().toLowerCase()).filter(Boolean))];
420
+ if (supplied && sections.length === 0) {
421
+ return {
422
+ error: `No valid ${command} sections were provided. Available: ${[...allowed].join(', ')}.`,
423
+ };
424
+ }
425
+ const invalid = sections.filter(s => !allowed.has(s));
426
+ if (invalid.length > 0) {
427
+ return {
428
+ error: `Unknown ${command} section(s): ${invalid.join(', ')}. Available: ${[...allowed].join(', ')}.`,
429
+ };
430
+ }
431
+ return { sections };
432
+ }
433
+
434
+ function addMode(result, mode) {
435
+ if (result && typeof result === 'object') {
436
+ Object.defineProperty(result, '_publicMode', {
437
+ value: mode,
438
+ enumerable: false,
439
+ configurable: true,
440
+ });
441
+ }
442
+ return result;
443
+ }
444
+
445
+ function combineNotes(notes) {
446
+ return notes.filter(Boolean).join('\n') || undefined;
447
+ }
448
+
449
+ const PUBLIC_COMMAND_SET = new Set(CANONICAL_COMMANDS);
450
+
451
+ /**
452
+ * Validate mode-dependent parameter combinations before a handler chooses a
453
+ * branch. This is deliberately inside the engine so CLI, MCP, interactive,
454
+ * glob, file, and programmatic callers receive the same answer.
455
+ */
456
+ function validatePublicParams(command, p) {
457
+ const hasName = typeof p.name === 'string' && p.name.trim() !== '';
458
+ const gitScope = p.staged || (typeof p.base === 'string' && p.base.trim() !== '');
459
+ if ((command === 'impact' || command === 'check') && hasName && gitScope) {
460
+ return `${command} accepts either a symbol target or Git diff scope (base/staged), not both.`;
461
+ }
462
+ if ((command === 'impact' || command === 'check') && p.staged && p.base) {
463
+ return `${command} accepts either staged=true or base, not both.`;
464
+ }
465
+ if (command === 'source' && p.range != null &&
466
+ (!p.file || !String(p.file).trim())) {
467
+ return 'source range mode requires file.';
468
+ }
469
+ if (command === 'source' && p.range != null &&
470
+ typeof p.name === 'string' && p.name.trim()) {
471
+ return 'source accepts either a symbol target or a file range, not both.';
472
+ }
473
+ if (command === 'search' && p.receiver && p.type && p.type !== 'call') {
474
+ return 'search receiver filtering requires type=call (or omit type to select call mode).';
475
+ }
476
+ if (command === 'trace' && p.to === 'entrypoints' &&
477
+ (p.direction || 'callees') !== 'callers') {
478
+ return 'trace --to=entrypoints requires --direction=callers.';
479
+ }
480
+ if (command === 'deps' && p.cycles &&
481
+ (p.file || p.direction || p.depth != null || p.detailed || p.all)) {
482
+ return 'deps cycles mode cannot be combined with file, direction, depth, detailed, or all.';
483
+ }
484
+ if (command === 'tests' && Number(p.depth || 0) > 0 && p.callsOnly) {
485
+ return 'tests callsOnly applies to direct mode and cannot be combined with depth>0.';
486
+ }
487
+ if (command === 'entrypoints' && p.includeTests === true && p.excludeTests === true) {
488
+ return 'entrypoints includeTests and excludeTests cannot both be true.';
489
+ }
490
+ if (command === 'endpoints' && p.serverOnly && p.clientOnly) {
491
+ return 'endpoints serverOnly and clientOnly cannot both be true.';
492
+ }
493
+ if (command === 'plan') {
494
+ const operations = [p.addParam, p.removeParam, p.renameTo]
495
+ .filter(value => value != null && String(value).trim() !== '');
496
+ if (operations.length !== 1) {
497
+ return 'plan requires exactly one operation: addParam, removeParam, or renameTo.';
498
+ }
499
+ if (p.defaultValue != null && !p.addParam) {
500
+ return 'plan defaultValue is valid only with addParam.';
501
+ }
502
+ // Names that land in declarations and call sites must at least be
503
+ // identifier-shaped — "foo bar" or "x-y" in a rename would write
504
+ // syntax errors into every planned edit. Language-specific reserved
505
+ // words are checked after symbol resolution (plan() knows the file).
506
+ const IDENTIFIER_SHAPE = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
507
+ if (p.renameTo != null && String(p.renameTo).trim() !== '' &&
508
+ !IDENTIFIER_SHAPE.test(String(p.renameTo).trim())) {
509
+ return `plan renameTo "${String(p.renameTo).trim()}" is not a valid identifier.`;
510
+ }
511
+ // addParam also accepts a typed spec (`suffix: string`, `opt?: number`)
512
+ // — only the NAME portion must be identifier-shaped.
513
+ const ADD_PARAM_SHAPE = /^[A-Za-z_$][A-Za-z0-9_$]*\??(\s*:\s*\S.*)?$/;
514
+ if (p.addParam != null && String(p.addParam).trim() !== '' &&
515
+ !ADD_PARAM_SHAPE.test(String(p.addParam).trim())) {
516
+ return `plan addParam "${String(p.addParam).trim()}" is not a valid parameter name (use \`name\` or \`name: type\`).`;
517
+ }
518
+ }
519
+ return null;
520
+ }
521
+
311
522
  // ============================================================================
312
523
  // COMMAND HANDLERS
313
524
  // ============================================================================
314
525
 
315
526
  const HANDLERS = {
316
527
 
528
+ // ── Public v5 compositions ─────────────────────────────────────────
529
+
530
+ show: (index, p) => {
531
+ const err = requireName(p.name);
532
+ if (err) return { ok: false, error: err };
533
+ const originalTarget = p.name;
534
+ const query = { ...p };
535
+ applyClassMethodSyntax(query);
536
+ const fileErr = checkFilePatternMatch(index, query.file);
537
+ if (fileErr) return { ok: false, error: fileErr };
538
+ const classErr = validateClassName(index, query.name, query.className);
539
+ if (classErr) return { ok: false, error: classErr };
540
+ const pinErr = checkDefinitionPin(index, query);
541
+ if (pinErr) return { ok: false, error: pinErr };
542
+ const resolved = index.resolveSymbol(query.name, {
543
+ file: query.file,
544
+ className: query.className,
545
+ line: query.line,
546
+ });
547
+ if (!resolved.def) return { ok: false, error: symbolNotFound(index, query.name) };
548
+ // A composed answer must use one exact definition in every section.
549
+ // Let the child handler provide the normal not-found/filter error, but
550
+ // once resolution succeeds pin every composition to the selected file
551
+ // and line. Otherwise brief/context and source can apply different
552
+ // legitimate ranking policies and contradict each other.
553
+ const targetParams = resolved.def ? {
554
+ ...query,
555
+ name: resolved.def.name,
556
+ file: resolved.def.relativePath,
557
+ line: resolved.def.nameLine || resolved.def.startLine,
558
+ ...(resolved.def.className && { className: resolved.def.className }),
559
+ } : query;
560
+ const parsed = parseSections(
561
+ p.sections,
562
+ ['summary', 'callers', 'callees'],
563
+ SHOW_SECTIONS,
564
+ 'show',
565
+ );
566
+ if (parsed.error) return { ok: false, error: parsed.error };
567
+ if (p.withTypes && !parsed.sections.includes('types')) parsed.sections.push('types');
568
+
569
+ const selected = new Set(parsed.sections);
570
+ const result = { target: originalTarget, sections: parsed.sections };
571
+ const notes = (resolved.warnings || []).map(warning => warning.message);
572
+ if (resolved.warnings?.length) result.warnings = resolved.warnings;
573
+ const run = (section, handler, params) => {
574
+ const response = HANDLERS[handler](index, { ...params });
575
+ if (!response.ok) {
576
+ if (!result.unavailableSections) result.unavailableSections = [];
577
+ result.unavailableSections.push({ section, reason: response.error });
578
+ notes.push(`Section "${section}" unavailable: ${response.error}`);
579
+ return null;
580
+ }
581
+ if (response.note) notes.push(response.note);
582
+ return response;
583
+ };
584
+
585
+ if (selected.has('summary')) {
586
+ const response = run('summary', 'brief', targetParams);
587
+ if (response) result.summary = response.result;
588
+ }
589
+ if (selected.has('callers') || selected.has('callees')) {
590
+ const response = run('relationships', 'context', targetParams);
591
+ if (response) {
592
+ // Apply the projection in the engine result so text and JSON
593
+ // expose the same sections. ACCOUNT/CONTRACT metadata stays
594
+ // attached even when one relationship direction is omitted.
595
+ result.context = { ...response.result };
596
+ if (!selected.has('callers')) {
597
+ delete result.context.callers;
598
+ delete result.context.unverifiedCallers;
599
+ }
600
+ if (!selected.has('callees')) {
601
+ delete result.context.callees;
602
+ delete result.context.unverifiedCallees;
603
+ }
604
+ result.context.omittedSections = [
605
+ ...(!selected.has('callers') ? ['callers'] : []),
606
+ ...(!selected.has('callees') ? ['callees'] : []),
607
+ ];
608
+ }
609
+ }
610
+ if (selected.has('source')) {
611
+ const response = run('source', 'source', targetParams);
612
+ if (response) result.source = response.result;
613
+ }
614
+ if (selected.has('dependencies')) {
615
+ const response = run('dependencies', 'smart', targetParams);
616
+ if (response) result.dependencies = response.result;
617
+ }
618
+ if (selected.has('tests')) {
619
+ const response = run('tests', 'tests', { ...targetParams, depth: 0 });
620
+ if (response) result.tests = response.result;
621
+ }
622
+ if (selected.has('types')) {
623
+ const response = run('types', 'about', {
624
+ ...targetParams, withTypes: true, compact: true,
625
+ });
626
+ if (response) {
627
+ result.types = {
628
+ types: response.result.types || [],
629
+ otherDefinitions: response.result.otherDefinitions || [],
630
+ };
631
+ }
632
+ }
633
+ if (selected.has('example')) {
634
+ const response = run('example', 'example', targetParams);
635
+ if (response) result.example = response.result;
636
+ }
637
+ if (selected.has('related')) {
638
+ const response = run('related', 'related', targetParams);
639
+ if (response) result.related = response.result;
640
+ }
641
+
642
+ const note = combineNotes(notes);
643
+ return note ? { ok: true, result, note } : { ok: true, result };
644
+ },
645
+
646
+ source: (index, p) => {
647
+ if (p.range != null) {
648
+ const response = HANDLERS.lines(index, { file: p.file, range: p.range });
649
+ if (response.ok) addMode(response.result, 'lines');
650
+ return response;
651
+ }
652
+ const err = requireName(p.name);
653
+ if (err) return { ok: false, error: 'Symbol name or file range is required.' };
654
+ const query = { ...p };
655
+ applyClassMethodSyntax(query);
656
+ const fileErr = checkFilePatternMatch(index, query.file);
657
+ if (fileErr) return { ok: false, error: fileErr };
658
+ const classErr = validateClassName(index, query.name, query.className);
659
+ if (classErr) return { ok: false, error: classErr };
660
+ const pinErr = checkDefinitionPin(index, query);
661
+ if (pinErr) return { ok: false, error: pinErr };
662
+
663
+ // Use the same ranker as `show`; once selected, pin extraction to the
664
+ // exact definition so a second handler cannot choose differently.
665
+ const resolved = index.resolveSymbol(query.name, {
666
+ file: query.file,
667
+ className: query.className,
668
+ line: query.line,
669
+ });
670
+ if (!resolved.def) return { ok: false, error: symbolNotFound(index, query.name) };
671
+
672
+ const isClass = CLASS_KIND_TYPES.includes(resolved.def.type);
673
+ const targetParams = query.all ? query : {
674
+ ...query,
675
+ name: resolved.def.name,
676
+ file: resolved.def.relativePath,
677
+ line: resolved.def.nameLine || resolved.def.startLine,
678
+ ...(resolved.def.className && { className: resolved.def.className }),
679
+ };
680
+ const response = HANDLERS[isClass ? 'class' : 'fn'](index, targetParams);
681
+ if (!response.ok) return response;
682
+ addMode(response.result, isClass ? 'class' : 'function');
683
+ const notes = [
684
+ ...(resolved.warnings || []).map(warning => warning.message),
685
+ response.note,
686
+ ].filter(Boolean);
687
+ return notes.length > 0
688
+ ? { ...response, note: combineNotes(notes) }
689
+ : response;
690
+ },
691
+
692
+ deps: (index, p) => {
693
+ if (p.cycles) {
694
+ const response = HANDLERS.circularDeps(index, { file: p.file, exclude: p.exclude });
695
+ if (response.ok) addMode(response.result, 'cycles');
696
+ return response;
697
+ }
698
+ const err = requireFile(p.file);
699
+ if (err) return { ok: false, error: err };
700
+ const direction = p.direction || 'both';
701
+ if (!['imports', 'importers', 'both'].includes(direction)) {
702
+ return { ok: false, error: 'deps direction must be imports, importers, or both.' };
703
+ }
704
+ const graphResponse = HANDLERS.graph(index, {
705
+ file: p.file,
706
+ direction,
707
+ depth: p.depth,
708
+ all: p.all,
709
+ });
710
+ if (!graphResponse.ok) return graphResponse;
711
+ const result = {
712
+ file: p.file,
713
+ direction,
714
+ graph: graphResponse.result,
715
+ };
716
+ if (p.detailed && (direction === 'imports' || direction === 'both')) {
717
+ const response = HANDLERS.imports(index, { file: p.file });
718
+ if (!response.ok) return response;
719
+ result.imports = response.result;
720
+ }
721
+ if (p.detailed && (direction === 'importers' || direction === 'both')) {
722
+ const response = HANDLERS.exporters(index, { file: p.file });
723
+ if (!response.ok) return response;
724
+ result.importers = response.result;
725
+ }
726
+ addMode(result, 'graph');
727
+ return { ok: true, result };
728
+ },
729
+
730
+ repo: (index, p) => {
731
+ const fileErr = checkFilePatternMatch(index, p.file);
732
+ if (fileErr) return { ok: false, error: fileErr };
733
+ const inErr = validateInFilter(index, p.in);
734
+ if (inErr) return { ok: false, error: inErr };
735
+ const defaults = p.deep ? ['summary', 'health'] : ['summary'];
736
+ const parsed = parseSections(p.sections, defaults, REPO_SECTIONS, 'repo');
737
+ if (parsed.error) return { ok: false, error: parsed.error };
738
+ const selected = new Set(parsed.sections);
739
+ const result = { sections: parsed.sections };
740
+ const notes = [];
741
+ const collect = (key, handler, params) => {
742
+ const response = HANDLERS[handler](index, { ...params });
743
+ if (!response.ok) return response;
744
+ result[key] = response.result;
745
+ if (response.note) notes.push(response.note);
746
+ return null;
747
+ };
748
+ let failure;
749
+ if (selected.has('summary')) failure = collect('summary', 'orient', p);
750
+ if (!failure && selected.has('files')) failure = collect('files', 'toc', {
751
+ ...p,
752
+ // repo's public limit caps the file result set. Direct toc's
753
+ // legacy symbol-list cap is intentionally not composed here.
754
+ top: p.top || p.limit,
755
+ limit: undefined,
756
+ });
757
+ if (!failure && selected.has('stats')) failure = collect('stats', 'stats', p);
758
+ if (!failure && selected.has('health')) failure = collect('health', 'doctor', p);
759
+ if (failure) return failure;
760
+ const note = combineNotes(notes);
761
+ return note ? { ok: true, result, note } : { ok: true, result };
762
+ },
763
+
317
764
  // ── Understanding Code ──────────────────────────────────────────────
318
765
 
319
766
  about: (index, p) => {
@@ -344,7 +791,7 @@ const HANDLERS = {
344
791
  return { ok: false, error: `Symbol "${p.name}" not found in ${filterDesc}. Found ${allDefs.length} definition(s) elsewhere:\n${locations}${more}\nUse file= with a path fragment from the list above to disambiguate.` };
345
792
  }
346
793
  }
347
- return { ok: false, error: `Symbol "${p.name}" not found.` };
794
+ return { ok: false, error: symbolNotFound(index, p.name) };
348
795
  }
349
796
  const tNote = truncationNote(index);
350
797
  return { ok: true, result, showConfidence: !!p.showConfidence, ...(tNote && { note: tNote }) };
@@ -362,15 +809,24 @@ const HANDLERS = {
362
809
  if (pinErr) return { ok: false, error: pinErr };
363
810
  const result = index.context(p.name, {
364
811
  ...buildCallerOptions(p),
812
+ maxCallers: num(p.top, undefined),
813
+ maxCallees: num(p.top, undefined),
365
814
  unreachableOnly: !!p.unreachableOnly,
366
815
  all: !!p.all,
367
816
  });
368
- if (!result) return { ok: false, error: `Symbol "${p.name}" not found.` };
817
+ if (!result) return { ok: false, error: symbolNotFound(index, p.name) };
369
818
  const tNote = truncationNote(index);
370
819
  return { ok: true, result, showConfidence: !!p.showConfidence, ...(tNote && { note: tNote }) };
371
820
  },
372
821
 
373
822
  impact: (index, p) => {
823
+ // Public v5 overload: no symbol means Git-diff impact. This replaces
824
+ // the separate diff-impact command without changing the symbol path.
825
+ if (!p.name || (typeof p.name === 'string' && !p.name.trim())) {
826
+ const response = HANDLERS.diffImpact(index, { ...p });
827
+ if (response.ok) addMode(response.result, 'diff');
828
+ return response;
829
+ }
374
830
  const err = requireName(p.name);
375
831
  if (err) return { ok: false, error: err };
376
832
  applyClassMethodSyntax(p);
@@ -383,8 +839,9 @@ const HANDLERS = {
383
839
  const result = index.impact(p.name, {
384
840
  file: p.file,
385
841
  className: p.className,
842
+ line: p.line,
386
843
  exclude: toExcludeArray(p.exclude),
387
- top: num(p.top, undefined),
844
+ top: num(p.limit, undefined) || num(p.top, undefined),
388
845
  unreachableOnly: !!p.unreachableOnly,
389
846
  // BUG-H3: pass through user-supplied flags. impact defaults to including
390
847
  // method calls because "what breaks if I change this" should include
@@ -393,8 +850,9 @@ const HANDLERS = {
393
850
  ...(p.includeMethods !== undefined && { includeMethods: p.includeMethods }),
394
851
  ...(p.includeUncertain !== undefined && { includeUncertain: p.includeUncertain }),
395
852
  });
396
- if (!result) return { ok: false, error: `Function "${p.name}" not found.` };
853
+ if (!result) return { ok: false, error: symbolNotFound(index, p.name, 'Function') };
397
854
  const tNote = truncationNote(index);
855
+ addMode(result, 'symbol');
398
856
  return { ok: true, result, ...(tNote && { note: tNote }) };
399
857
  },
400
858
 
@@ -412,10 +870,10 @@ const HANDLERS = {
412
870
  const result = index.blast(p.name, {
413
871
  ...buildCallerOptions(p),
414
872
  depth: depthVal ?? 3,
415
- all: p.all || depthVal !== undefined,
873
+ all: !!p.all,
416
874
  expandUnverified: !!p.expandUnverified,
417
875
  });
418
- if (!result) return { ok: false, error: `Function "${p.name}" not found.` };
876
+ if (!result) return { ok: false, error: symbolNotFound(index, p.name, 'Function') };
419
877
  const note = treeNote(result);
420
878
  const tNote = truncationNote(index);
421
879
  const combined = [note, tNote].filter(Boolean).join('\n') || undefined;
@@ -436,10 +894,10 @@ const HANDLERS = {
436
894
  const result = index.reverseTrace(p.name, {
437
895
  ...buildCallerOptions(p),
438
896
  depth: depthVal ?? 5,
439
- all: p.all || depthVal !== undefined,
897
+ all: !!p.all,
440
898
  expandUnverified: !!p.expandUnverified,
441
899
  });
442
- if (!result) return { ok: false, error: `Function "${p.name}" not found.` };
900
+ if (!result) return { ok: false, error: symbolNotFound(index, p.name, 'Function') };
443
901
  const note = treeNote(result);
444
902
  const tNote = truncationNote(index);
445
903
  const combined = [note, tNote].filter(Boolean).join('\n') || undefined;
@@ -460,12 +918,29 @@ const HANDLERS = {
460
918
  ...buildCallerOptions(p),
461
919
  withTypes: p.withTypes || false,
462
920
  });
463
- if (!result) return { ok: false, error: `Function "${p.name}" not found.` };
921
+ if (!result) return { ok: false, error: symbolNotFound(index, p.name, 'Function') };
464
922
  const tNote = truncationNote(index);
465
923
  return { ok: true, result, ...(tNote && { note: tNote }) };
466
924
  },
467
925
 
468
926
  trace: (index, p) => {
927
+ const direction = p.direction || 'callees';
928
+ if (!['callees', 'callers'].includes(direction)) {
929
+ return { ok: false, error: 'trace direction must be callees or callers.' };
930
+ }
931
+ if (p.to && p.to !== 'entrypoints') {
932
+ return { ok: false, error: 'trace --to currently accepts only entrypoints.' };
933
+ }
934
+ if (direction === 'callers') {
935
+ const response = p.to === 'entrypoints'
936
+ ? HANDLERS.reverseTrace(index, { ...p, direction: undefined, to: undefined })
937
+ : HANDLERS.blast(index, { ...p, direction: undefined, to: undefined });
938
+ if (response.ok) addMode(response.result, p.to === 'entrypoints' ? 'entrypoints' : 'callers');
939
+ return response;
940
+ }
941
+ if (p.to) {
942
+ return { ok: false, error: 'trace --to=entrypoints requires --direction=callers.' };
943
+ }
469
944
  const err = requireName(p.name);
470
945
  if (err) return { ok: false, error: err };
471
946
  applyClassMethodSyntax(p);
@@ -479,13 +954,14 @@ const HANDLERS = {
479
954
  const result = index.trace(p.name, {
480
955
  ...buildCallerOptions(p),
481
956
  depth: depthVal ?? 3,
482
- all: p.all || depthVal !== undefined,
957
+ all: !!p.all,
483
958
  expandUnverified: !!p.expandUnverified,
484
959
  });
485
- if (!result) return { ok: false, error: `Function "${p.name}" not found.` };
960
+ if (!result) return { ok: false, error: symbolNotFound(index, p.name, 'Function') };
486
961
  const note = treeNote(result);
487
962
  const tNote = truncationNote(index);
488
963
  const combined = [note, tNote].filter(Boolean).join('\n') || undefined;
964
+ addMode(result, 'callees');
489
965
  return { ok: true, result, ...(combined && { note: combined }) };
490
966
  },
491
967
 
@@ -537,7 +1013,7 @@ const HANDLERS = {
537
1013
  top: num(p.top, undefined),
538
1014
  all: p.all,
539
1015
  });
540
- if (!result) return { ok: false, error: `Function "${p.name}" not found.` };
1016
+ if (!result) return { ok: false, error: symbolNotFound(index, p.name, 'Function') };
541
1017
  const parts = [];
542
1018
  if (result.similarNamesTotal > result.similarNames.length)
543
1019
  parts.push(`similar names: showing ${result.similarNames.length} of ${result.similarNamesTotal}`);
@@ -565,7 +1041,7 @@ const HANDLERS = {
565
1041
  // Thread the line pin (fix #249: a `lib.js:5:run` handle silently
566
1042
  // resolved the OTHER same-name def — the pin's whole point).
567
1043
  const result = brief(index, p.name, { file: p.file, className: p.className, line: p.line, git: !!p.git });
568
- if (!result) return { ok: false, error: `Symbol "${p.name}" not found.` };
1044
+ if (!result) return { ok: false, error: symbolNotFound(index, p.name) };
569
1045
  return { ok: true, result };
570
1046
  },
571
1047
 
@@ -579,7 +1055,12 @@ const HANDLERS = {
579
1055
  top = Math.min(n, 100);
580
1056
  }
581
1057
  const { orient } = require('./reporting');
582
- const result = orient(index, { top });
1058
+ const result = orient(index, {
1059
+ top,
1060
+ file: p.file,
1061
+ in: p.in,
1062
+ exclude: toExcludeArray(p.exclude),
1063
+ });
583
1064
  return { ok: true, result };
584
1065
  },
585
1066
 
@@ -588,12 +1069,20 @@ const HANDLERS = {
588
1069
  const result = doctor(index, {
589
1070
  in: p.in,
590
1071
  file: p.file,
1072
+ exclude: toExcludeArray(p.exclude),
591
1073
  deep: !!p.deep,
592
1074
  });
593
1075
  return { ok: true, result };
594
1076
  },
595
1077
 
596
1078
  check: (index, p) => {
1079
+ // Public v5 overload: a symbol target performs the former verify
1080
+ // operation; a target-less invocation remains the diff/precommit check.
1081
+ if (p.name && String(p.name).trim()) {
1082
+ const response = HANDLERS.verify(index, { ...p });
1083
+ if (response.ok) addMode(response.result, 'symbol');
1084
+ return response;
1085
+ }
597
1086
  const { check } = require('./check');
598
1087
  try {
599
1088
  const result = check(index, {
@@ -602,6 +1091,7 @@ const HANDLERS = {
602
1091
  file: p.file,
603
1092
  limit: num(p.limit, undefined),
604
1093
  });
1094
+ addMode(result, 'diff');
605
1095
  return { ok: true, result };
606
1096
  } catch (e) {
607
1097
  return { ok: false, error: e && e.message ? e.message : String(e) };
@@ -611,6 +1101,35 @@ const HANDLERS = {
611
1101
  // ── Finding Code ────────────────────────────────────────────────────
612
1102
 
613
1103
  find: (index, p) => {
1104
+ const inErr = validateInFilter(index, p.in);
1105
+ if (inErr) return { ok: false, error: inErr };
1106
+ const validFindTypes = new Set([
1107
+ 'type', 'function', 'class', 'variable', 'state', 'constant',
1108
+ 'field', 'macro', 'method', 'constructor', 'interface', 'enum',
1109
+ 'struct', 'trait', 'record', 'namespace',
1110
+ ]);
1111
+ if (p.type && !validFindTypes.has(p.type)) {
1112
+ return { ok: false, error: `Invalid find type "${p.type}". Valid types: ${[...validFindTypes].join(', ')}.` };
1113
+ }
1114
+ if (p.type === 'type') {
1115
+ const response = HANDLERS.typedef(index, { ...p });
1116
+ if (response.ok) {
1117
+ if (!p.withSource && Array.isArray(response.result)) {
1118
+ response.result = response.result.map(({ code, ...item }) => item);
1119
+ }
1120
+ const limit = num(p.limit, undefined);
1121
+ if (limit && limit > 0 && Array.isArray(response.result)) {
1122
+ const { items, total, limited } = applyLimit(response.result, limit);
1123
+ response.result = items;
1124
+ if (limited) {
1125
+ const note = limitNote(limit, total);
1126
+ response.note = response.note ? `${response.note}\n${note}` : note;
1127
+ }
1128
+ }
1129
+ addMode(response.result, 'type');
1130
+ }
1131
+ return response;
1132
+ }
614
1133
  const err = requireName(p.name);
615
1134
  if (err) return { ok: false, error: err };
616
1135
  applyClassMethodSyntax(p);
@@ -621,12 +1140,15 @@ const HANDLERS = {
621
1140
  const classErr = validateClassName(index, p.name, p.className);
622
1141
  if (classErr) return { ok: false, error: classErr };
623
1142
  }
624
- // Auto-include tests when pattern clearly targets test functions
625
- // But only if the user didn't explicitly set include_tests=false
626
- let includeTests = p.includeTests;
627
- if (includeTests === undefined && p.name && /^test[_*?A-Z]/i.test(p.name)) {
628
- includeTests = true;
1143
+ if (p.line) {
1144
+ const pinErr = checkDefinitionPin(index, p);
1145
+ if (pinErr) return { ok: false, error: pinErr };
629
1146
  }
1147
+ // Symbol lookup is an inventory operation: a definition must not
1148
+ // disappear merely because it lives in a test file. Call-bearing
1149
+ // analysis commands retain their conservative test defaults; find
1150
+ // includes every indexed definition unless explicitly asked not to.
1151
+ const includeTests = p.includeTests !== false;
630
1152
  const exclude = applyTestExclusions(p.exclude, includeTests);
631
1153
  let result = index.find(p.name, {
632
1154
  file: p.file,
@@ -635,11 +1157,49 @@ const HANDLERS = {
635
1157
  exclude,
636
1158
  in: p.in,
637
1159
  });
1160
+ const line = Number(p.line);
1161
+ if (p.line && Number.isFinite(line)) {
1162
+ result = result.filter(item =>
1163
+ item.startLine === line || item.nameLine === line);
1164
+ }
1165
+ if (p.type) {
1166
+ const kindGroups = {
1167
+ function: new Set(['function', 'method', 'constructor', 'get', 'set']),
1168
+ class: new Set(CLASS_KIND_TYPES),
1169
+ variable: new Set(['state', 'constant', 'field']),
1170
+ };
1171
+ const kinds = kindGroups[p.type] || new Set([p.type]);
1172
+ result = result.filter(item => kinds.has(item.type));
1173
+ }
1174
+ if (p.withSource) {
1175
+ result = result.map(item => ({ ...item, code: readAndExtract(item) }));
1176
+ }
1177
+ const fullFindCount = result.length;
1178
+ const nameWideDefinitionCounts = Object.fromEntries(
1179
+ [...new Set(result.map(item => item.name))].map(name => [
1180
+ name,
1181
+ (index.symbols.get(name) || []).length,
1182
+ ]),
1183
+ );
638
1184
  // Warn if exact mode silently disables glob expansion
639
1185
  const notes = [];
640
1186
  if (p.exact && p.name && (p.name.includes('*') || p.name.includes('?'))) {
641
1187
  notes.push(`Note: exact=true treats "${p.name}" as a literal name (glob expansion disabled).`);
642
1188
  }
1189
+ let unsupportedMatches = null;
1190
+ if (fullFindCount === 0 && index.unsupportedFiles?.length > 0) {
1191
+ unsupportedMatches = require('./account').scanUnsupportedFiles(index, p.name);
1192
+ const languages = Object.keys(unsupportedMatches.languages).join(', ');
1193
+ if (unsupportedMatches.lines > 0) {
1194
+ notes.push(`${unsupportedMatches.lines} literal match(es) exist in ` +
1195
+ `${unsupportedMatches.fileCount} unsupported-language file(s)` +
1196
+ `${languages ? ` (${languages})` : ''}; this is not a semantic zero. ` +
1197
+ 'Use grep/ripgrep plus a language-native analyzer.');
1198
+ } else {
1199
+ notes.push(`UCN skipped ${index.unsupportedFiles.length} unsupported source ` +
1200
+ 'file(s), so this is not a repository-wide semantic zero; verify with grep/ripgrep.');
1201
+ }
1202
+ }
643
1203
  // Apply limit
644
1204
  const limit = num(p.limit, undefined);
645
1205
  if (limit && limit > 0) {
@@ -647,6 +1207,20 @@ const HANDLERS = {
647
1207
  if (limited) notes.push(limitNote(limit, total));
648
1208
  result = items;
649
1209
  }
1210
+ Object.defineProperty(result, 'findInfo', {
1211
+ value: {
1212
+ total: fullFindCount,
1213
+ shown: result.length,
1214
+ nameWideDefinitionCounts,
1215
+ },
1216
+ enumerable: false, writable: true, configurable: true,
1217
+ });
1218
+ if (unsupportedMatches) {
1219
+ Object.defineProperty(result, 'unsupportedMatches', {
1220
+ value: unsupportedMatches,
1221
+ enumerable: false, writable: true, configurable: true,
1222
+ });
1223
+ }
650
1224
  const tNote = truncationNote(index);
651
1225
  if (tNote) notes.push(tNote);
652
1226
  return { ok: true, result, note: notes.length ? notes.join('\n') : undefined };
@@ -656,6 +1230,8 @@ const HANDLERS = {
656
1230
  const err = requireName(p.name);
657
1231
  if (err) return { ok: false, error: err };
658
1232
  applyClassMethodSyntax(p);
1233
+ const inErr = validateInFilter(index, p.in);
1234
+ if (inErr) return { ok: false, error: inErr };
659
1235
  const exclude = applyTestExclusions(p.exclude, p.includeTests);
660
1236
  const fileErr = checkFilePatternMatch(index, p.file);
661
1237
  if (fileErr) return { ok: false, error: fileErr };
@@ -675,7 +1251,11 @@ const HANDLERS = {
675
1251
  codeOnly: p.codeOnly || false,
676
1252
  context: num(p.context, 0),
677
1253
  className: p.className,
678
- file: p.file,
1254
+ // A stable handle pins which definition is meant; it must not
1255
+ // silently turn into a same-file-only usage scan. An explicit
1256
+ // --file remains a deliberate scan scope.
1257
+ file: p._fileFromHandle ? undefined : p.file,
1258
+ definitionFile: p._fileFromHandle ? p.file : undefined,
679
1259
  exclude: userExclude,
680
1260
  in: p.in,
681
1261
  });
@@ -705,11 +1285,44 @@ const HANDLERS = {
705
1285
  // record that isn't a call or import is a reference —
706
1286
  // same-name definer sites (usageType 'definition' with
707
1287
  // isDefinition false) used to render in NO band.
708
- references: result.filter(u => !u.isDefinition && u.usageType !== 'call' && u.usageType !== 'import').length,
1288
+ references: result.filter(u => !u.isDefinition &&
1289
+ !['call', 'import', 'text', 'definition'].includes(u.usageType)).length,
1290
+ otherDefinitions: result.filter(u => !u.isDefinition &&
1291
+ u.usageType === 'definition').length,
1292
+ text: result.filter(u => u.usageType === 'text').length,
709
1293
  },
710
1294
  enumerable: false, writable: true, configurable: true,
711
1295
  });
712
1296
  }
1297
+ // Escape-hatch integrity: usages is the raw name-match view, so it
1298
+ // must not silently omit matches in files UCN cannot parse (.rb in a
1299
+ // mixed repo). Counts ride a note + non-enumerable field; the
1300
+ // account-bearing commands list the actual sites.
1301
+ const accountTools = require('./account');
1302
+ const unsupportedMatches = accountTools.scanUnsupportedFiles(index, p.name);
1303
+ if (unsupportedMatches.lines > 0) {
1304
+ const langs = Object.keys(unsupportedMatches.languages).join(', ');
1305
+ notes.push(`${unsupportedMatches.lines} line(s) in ${unsupportedMatches.fileCount} ` +
1306
+ `unsupported-language file(s)${langs ? ` (${langs})` : ''} also match — ` +
1307
+ 'NOT analyzed by UCN; verify with grep/ripgrep.');
1308
+ Object.defineProperty(limited, 'unsupportedMatches', {
1309
+ value: unsupportedMatches,
1310
+ enumerable: false, writable: true, configurable: true,
1311
+ });
1312
+ }
1313
+ const failed = accountTools.scanFailedFiles(index, p.name);
1314
+ if (failed.unparsed.lines > 0) {
1315
+ notes.push(`${failed.unparsed.lines} matching line(s) in ${failed.unparsed.fileCount} unparsed file(s) were not classified: ${failed.unparsed.files.join(', ')}.`);
1316
+ }
1317
+ if (failed.unreadableFiles.length > 0) {
1318
+ notes.push(`${failed.unreadableFiles.length} unreadable file(s) could not be searched: ${failed.unreadableFiles.join(', ')}.`);
1319
+ }
1320
+ if (failed.unparsed.lines > 0 || failed.unreadableFiles.length > 0) {
1321
+ Object.defineProperty(limited, 'analysisGaps', {
1322
+ value: failed,
1323
+ enumerable: false, writable: true, configurable: true,
1324
+ });
1325
+ }
713
1326
  return { ok: true, result: limited, note: notes.length ? notes.join(' ') : undefined };
714
1327
  },
715
1328
 
@@ -749,8 +1362,6 @@ const HANDLERS = {
749
1362
  if (remaining <= 0) {
750
1363
  if (syms.functions) syms.functions = [];
751
1364
  if (syms.classes) syms.classes = [];
752
- f.functions = 0;
753
- f.classes = 0;
754
1365
  continue;
755
1366
  }
756
1367
  const fns = syms.functions?.length || 0;
@@ -761,15 +1372,12 @@ const HANDLERS = {
761
1372
  if (syms.functions && remaining > 0) {
762
1373
  syms.functions = syms.functions.slice(0, remaining);
763
1374
  remaining -= syms.functions.length;
764
- f.functions = syms.functions.length;
765
1375
  }
766
1376
  if (syms.classes && remaining > 0) {
767
1377
  syms.classes = syms.classes.slice(0, remaining);
768
1378
  remaining -= syms.classes.length;
769
- f.classes = syms.classes.length;
770
1379
  } else if (syms.classes) {
771
1380
  syms.classes = [];
772
- f.classes = 0;
773
1381
  }
774
1382
  }
775
1383
  }
@@ -805,15 +1413,30 @@ const HANDLERS = {
805
1413
  top: topVal || 50,
806
1414
  });
807
1415
  if (result.meta.error) return { ok: false, error: result.meta.error };
808
- return { ok: true, result, structural: true };
1416
+ const unsupported = (!p.regex && (p.term || p.name))
1417
+ ? require('./account').scanUnsupportedFiles(index, p.term || p.name)
1418
+ : null;
1419
+ let note;
1420
+ if (unsupported?.lines > 0) {
1421
+ Object.defineProperty(result, 'unsupportedMatches', {
1422
+ value: unsupported,
1423
+ enumerable: false, writable: true, configurable: true,
1424
+ });
1425
+ note = `${unsupported.lines} matching line(s) in ${unsupported.fileCount} unsupported-language file(s) were not structurally analyzed; verify with grep/ripgrep.`;
1426
+ }
1427
+ return { ok: true, result, structural: true, note };
809
1428
  }
810
1429
 
811
1430
  const err = requireTerm(p.term);
812
1431
  if (err) return { ok: false, error: err };
813
1432
  const testsExcluded = !p.includeTests;
814
1433
  const exclude = applyTestExclusions(p.exclude, p.includeTests);
815
- // Use limit as top if top not set
1434
+ // JSON is intentionally structured rather than byte-truncated, so
1435
+ // text search needs a row bound of its own. Keep the documented
1436
+ // default at 500 and retain total/truncated metadata for controlled
1437
+ // expansion with an explicit top/limit.
816
1438
  const topVal = num(p.top, undefined) || num(p.limit, undefined);
1439
+ const effectiveLimit = topVal || 500;
817
1440
  const result = index.search(p.term, {
818
1441
  codeOnly: p.codeOnly || false,
819
1442
  context: num(p.context, 0),
@@ -821,15 +1444,33 @@ const HANDLERS = {
821
1444
  exclude,
822
1445
  in: p.in,
823
1446
  regex: p.regex,
824
- top: topVal,
1447
+ top: effectiveLimit,
825
1448
  file: p.file,
826
1449
  });
827
1450
  if (result.meta) result.meta.testsExcluded = testsExcluded;
1451
+ const notes = [];
1452
+ if (!p.regex) {
1453
+ const unsupported = require('./account').scanUnsupportedFiles(index, p.term);
1454
+ if (unsupported.lines > 0) {
1455
+ Object.defineProperty(result, 'unsupportedMatches', {
1456
+ value: unsupported,
1457
+ enumerable: false, writable: true, configurable: true,
1458
+ });
1459
+ notes.push(`${unsupported.lines} matching line(s) in ${unsupported.fileCount} unsupported-language file(s) were not searched; verify with grep/ripgrep.`);
1460
+ }
1461
+ }
828
1462
  const tNote = truncationNote(index);
829
- return { ok: true, result, ...(tNote && { note: tNote }) };
1463
+ if (tNote) notes.push(tNote);
1464
+ return { ok: true, result, ...(notes.length && { note: notes.join('\n') }) };
830
1465
  },
831
1466
 
832
1467
  tests: (index, p) => {
1468
+ const depth = num(p.depth, 0);
1469
+ if (depth > 0) {
1470
+ const response = HANDLERS.affectedTests(index, { ...p, depth });
1471
+ if (response.ok) addMode(response.result, 'affected');
1472
+ return response;
1473
+ }
833
1474
  const err = requireName(p.name);
834
1475
  if (err) return { ok: false, error: err };
835
1476
  // tests() accepts a FILE PATH as the target ("tests helper.go" — the
@@ -844,8 +1485,12 @@ const HANDLERS = {
844
1485
  if (!testsTargetIsFile) applyClassMethodSyntax(p);
845
1486
  if (testsTargetIsHandle && p.line) {
846
1487
  const line = Number(p.line);
1488
+ const resolvedFile = p.file ? index.resolveFilePathForQuery(p.file) : null;
847
1489
  const pinned = (index.symbols.get(p.name) || []).filter(d =>
848
- d.startLine === line && (!p.file || d.relativePath?.includes(p.file)));
1490
+ (d.startLine === line || d.nameLine === line) &&
1491
+ (!p.file || (typeof resolvedFile === 'string'
1492
+ ? d.file === resolvedFile
1493
+ : d.relativePath?.includes(p.file))));
849
1494
  if (pinned.length === 1 && !p.className && pinned[0].className) {
850
1495
  p.className = pinned[0].className;
851
1496
  }
@@ -863,18 +1508,59 @@ const HANDLERS = {
863
1508
  const files = allDefs.map(d => d.relativePath).join(', ');
864
1509
  return { ok: false, error: `Symbol "${p.name}" not found in files matching "${p.file}". Defined in: ${files}` };
865
1510
  }
866
- return { ok: false, error: `Symbol "${p.name}" not found.` };
1511
+ return { ok: false, error: symbolNotFound(index, p.name) };
867
1512
  }
868
1513
  }
869
1514
  const classErr = validateClassName(index, p.name, p.className);
870
1515
  if (classErr) return { ok: false, error: classErr };
1516
+ let testsResolution = null;
1517
+ if (!testsTargetIsFile) {
1518
+ testsResolution = index.resolveSymbol(p.name, {
1519
+ file: p.file,
1520
+ className: p.className,
1521
+ line: p.line,
1522
+ });
1523
+ if (!testsResolution.def) return { ok: false, error: symbolNotFound(index, p.name) };
1524
+ }
871
1525
  const result = index.tests(p.name, {
872
1526
  callsOnly: p.callsOnly || false,
873
1527
  className: p.className,
874
- file: p.file,
1528
+ // Bare ambiguous names use the same deterministic definition as
1529
+ // the rest of the semantic surface. The warning below discloses
1530
+ // that choice and gives the caller a stable file= escape hatch.
1531
+ file: p.file || testsResolution?.def?.relativePath,
875
1532
  exclude: toExcludeArray(p.exclude),
876
1533
  });
877
- return { ok: true, result };
1534
+ addMode(result, 'direct');
1535
+ if (testsResolution?.warnings?.length > 0) {
1536
+ Object.defineProperty(result, 'warnings', {
1537
+ value: testsResolution.warnings,
1538
+ enumerable: false, writable: true, configurable: true,
1539
+ });
1540
+ }
1541
+ const testNotes = [];
1542
+ if (result.length === 0) testNotes.push(NO_STATIC_TEST_LINK_NOTE);
1543
+ // Mixed-language honesty: test files in unsupported languages (RSpec
1544
+ // for a .rb service, etc.) are invisible to the static scan — say so
1545
+ // whenever the target name occurs in unsupported files at all.
1546
+ if (!testsTargetIsFile) {
1547
+ const unsupportedMatches = require('./account').scanUnsupportedFiles(index, p.name);
1548
+ if (unsupportedMatches.lines > 0) {
1549
+ const langs = Object.keys(unsupportedMatches.languages).join(', ');
1550
+ testNotes.push(`"${p.name}" also occurs on ${unsupportedMatches.lines} line(s) in ` +
1551
+ `${unsupportedMatches.fileCount} unsupported-language file(s)${langs ? ` (${langs})` : ''} — ` +
1552
+ 'their test files are invisible to UCN; verify with grep/ripgrep.');
1553
+ Object.defineProperty(result, 'unsupportedMatches', {
1554
+ value: unsupportedMatches,
1555
+ enumerable: false, writable: true, configurable: true,
1556
+ });
1557
+ }
1558
+ }
1559
+ return {
1560
+ ok: true,
1561
+ result,
1562
+ ...(testNotes.length > 0 && { note: testNotes.join(' ') }),
1563
+ };
878
1564
  },
879
1565
 
880
1566
  affectedTests: (index, p) => {
@@ -892,10 +1578,11 @@ const HANDLERS = {
892
1578
  ...buildCallerOptions(p),
893
1579
  depth: depthVal ?? 3,
894
1580
  });
895
- if (!result) return { ok: false, error: `Function "${p.name}" not found.` };
1581
+ if (!result) return { ok: false, error: symbolNotFound(index, p.name, 'Function') };
896
1582
  const note = treeNote(result);
897
1583
  const tNote = truncationNote(index);
898
- const combined = [note, tNote].filter(Boolean).join('\n') || undefined;
1584
+ const selectionNote = result.testFiles.length === 0 ? NO_STATIC_TEST_LINK_NOTE : null;
1585
+ const combined = [note, tNote, selectionNote].filter(Boolean).join('\n') || undefined;
899
1586
  return { ok: true, result, ...(combined && { note: combined }) };
900
1587
  },
901
1588
 
@@ -929,6 +1616,11 @@ const HANDLERS = {
929
1616
  if (result.excludedExported != null) sliced.excludedExported = result.excludedExported;
930
1617
  if (result.excludedDecorated != null) sliced.excludedDecorated = result.excludedDecorated;
931
1618
  if (result.excludedExternalContract != null) sliced.excludedExternalContract = result.excludedExternalContract;
1619
+ if (result.excludedRuntimeContract != null) sliced.excludedRuntimeContract = result.excludedRuntimeContract;
1620
+ if (result.excludedDynamicDispatch != null) sliced.excludedDynamicDispatch = result.excludedDynamicDispatch;
1621
+ if (result.pythonImplicitExportFiles != null) sliced.pythonImplicitExportFiles = result.pythonImplicitExportFiles;
1622
+ if (result.computedDispatch != null) sliced.computedDispatch = result.computedDispatch;
1623
+ if (result.coverage != null) sliced.coverage = result.coverage;
932
1624
  // Truncation must be visible IN the JSON payload, not only in the
933
1625
  // stderr note (fix #242) — the formatter reads this to emit
934
1626
  // meta.total + truncated.
@@ -951,26 +1643,53 @@ const HANDLERS = {
951
1643
  // Rust #[test], etc.) — show them by default. Previously this command
952
1644
  // applied addTestExclusions() unconditionally, which stripped Java
953
1645
  // *Tests.java entries while letting Rust #[test] through (asymmetric).
954
- // Now consistent: default = include test entries; user opts out via
955
- // --exclude-tests (or --include-tests=false for back-compat).
1646
+ // Tests are excluded by default, matching search/usages/deadcode —
1647
+ // an agent orienting via entrypoints needs the routes that describe
1648
+ // the project, not every fixture route under test/. --include-tests
1649
+ // restores the full universe; --exclude-tests stays accepted as the
1650
+ // explicit spelling of the default. Internal consumers (reachability
1651
+ // seeding, check's orphan detection) call detectEntrypoints directly
1652
+ // and always see the full universe.
956
1653
  const userExclude = Array.isArray(p.exclude)
957
1654
  ? p.exclude
958
1655
  : (p.exclude ? p.exclude.split(',').map(s => s.trim()).filter(Boolean) : []);
959
- const wantsExcludeTests = p.excludeTests === true || p.includeTests === false;
960
- const exclude = wantsExcludeTests ? addTestExclusions(userExclude) : userExclude;
961
- let result = detectEntrypoints(index, {
1656
+ const exclude = p.includeTests === true
1657
+ ? userExclude
1658
+ : addTestExclusions(userExclude);
1659
+ const fullResult = detectEntrypoints(index, {
962
1660
  type: p.type,
963
1661
  framework: p.framework,
964
1662
  file: p.file,
965
- exclude,
1663
+ exclude: userExclude,
966
1664
  });
1665
+ if (fullResult && fullResult.error) {
1666
+ return { ok: false, error: fullResult.message || fullResult.error };
1667
+ }
1668
+ let result = p.includeTests === true
1669
+ ? fullResult
1670
+ : detectEntrypoints(index, {
1671
+ type: p.type,
1672
+ framework: p.framework,
1673
+ file: p.file,
1674
+ exclude,
1675
+ });
967
1676
  if (result && result.error) {
968
1677
  return { ok: false, error: result.message || result.error };
969
1678
  }
1679
+ const hiddenTests = p.includeTests === true || !Array.isArray(fullResult)
1680
+ ? 0
1681
+ : Math.max(0, fullResult.length - result.length);
1682
+ Object.defineProperty(result, 'filterInfo', {
1683
+ value: { hiddenTests, testsIncluded: p.includeTests === true },
1684
+ enumerable: false, writable: true, configurable: true,
1685
+ });
970
1686
  const limit = num(p.limit, undefined);
971
- let note;
1687
+ let note = hiddenTests > 0
1688
+ ? `${hiddenTests} test-path entry point(s) hidden by default. Use --include-tests to include them.`
1689
+ : undefined;
972
1690
  if (limit && limit > 0 && Array.isArray(result) && result.length > limit) {
973
- note = limitNote(limit, result.length);
1691
+ const limitMessage = limitNote(limit, result.length);
1692
+ note = note ? `${note}\n${limitMessage}` : limitMessage;
974
1693
  const sliced = result.slice(0, limit);
975
1694
  // Full-set size travels with the payload so --json can carry
976
1695
  // meta.total + truncated (fix #247 — mirrors deadcode's #242
@@ -979,6 +1698,10 @@ const HANDLERS = {
979
1698
  value: { total: result.length, shown: limit },
980
1699
  enumerable: false, writable: true, configurable: true,
981
1700
  });
1701
+ Object.defineProperty(sliced, 'filterInfo', {
1702
+ value: result.filterInfo,
1703
+ enumerable: false, writable: true, configurable: true,
1704
+ });
982
1705
  result = sliced;
983
1706
  }
984
1707
  return { ok: true, result, note };
@@ -1016,6 +1739,27 @@ const HANDLERS = {
1016
1739
  prefix: p.prefix || null,
1017
1740
  showUncertain: !p.hideUncertain,
1018
1741
  });
1742
+ if (p.framework != null && String(p.framework).trim() !== '') {
1743
+ const framework = String(p.framework).trim().toLowerCase();
1744
+ const known = new Set([
1745
+ 'actix', 'aspnet', 'aspnet-minimal', 'axios', 'axum',
1746
+ 'dotnet-httpclient', 'express', 'fastapi', 'fetch', 'flask',
1747
+ 'go-http', 'jax-rs', 'nestjs', 'nextjs', 'requests',
1748
+ 'reqwest', 'spring', 'spring-client', 'unknown-python',
1749
+ ]);
1750
+ if (!known.has(framework)) {
1751
+ return {
1752
+ ok: false,
1753
+ error: `Invalid --framework value: "${p.framework}". Valid: ${[...known].join(', ')}.`,
1754
+ };
1755
+ }
1756
+ result.routes = result.routes.filter(route => route.framework === framework);
1757
+ result.requests = result.requests.filter(request => request.framework === framework);
1758
+ result.bridges = result.bridges.filter(bridge =>
1759
+ bridge.route.framework === framework || bridge.request.framework === framework);
1760
+ result.unmatchedRoutes = result.unmatchedRoutes.filter(route => route.framework === framework);
1761
+ result.unmatchedRequests = result.unmatchedRequests.filter(request => request.framework === framework);
1762
+ }
1019
1763
  // Apply --file pattern as an additional filter on routes/requests
1020
1764
  if (p.file) {
1021
1765
  const sub = String(p.file);
@@ -1030,9 +1774,7 @@ const HANDLERS = {
1030
1774
  // Apply --exclude patterns to route/request files (deadcode-style boundary matching)
1031
1775
  const exclude = toExcludeArray(p.exclude);
1032
1776
  if (exclude.length > 0) {
1033
- const regexes = exclude.map(pat =>
1034
- new RegExp('(^|[/._-])' + pat + 's?([/._-]|$)', 'i'));
1035
- const matches = (file) => regexes.some(rx => rx.test(file));
1777
+ const matches = (file) => !index.matchesFilters(file, { exclude });
1036
1778
  result.routes = result.routes.filter(r => !matches(r.file));
1037
1779
  result.requests = result.requests.filter(r => !matches(r.file));
1038
1780
  result.bridges = result.bridges.filter(b => !matches(b.route.file) && !matches(b.request.file));
@@ -1074,6 +1816,8 @@ const HANDLERS = {
1074
1816
  // suppress the "Matched" section in unmatched-only mode.)
1075
1817
  result._bridge = wantBridge;
1076
1818
  result._unmatched = wantUnmatched;
1819
+ result._serverOnly = !!p.serverOnly;
1820
+ result._clientOnly = !!p.clientOnly;
1077
1821
  return { ok: true, result, note };
1078
1822
  },
1079
1823
 
@@ -1100,6 +1844,11 @@ const HANDLERS = {
1100
1844
 
1101
1845
  const entries = [];
1102
1846
  const notes = [];
1847
+ const maxLines = num(p.maxLines, null);
1848
+ if (p.maxLines != null &&
1849
+ (maxLines === null || !Number.isInteger(maxLines) || maxLines < 1)) {
1850
+ return { ok: false, error: '--max-lines must be a positive integer.' };
1851
+ }
1103
1852
 
1104
1853
  for (const fnName of fnNames) {
1105
1854
  // For comma-separated names, each may have Class.method syntax
@@ -1131,10 +1880,10 @@ const HANDLERS = {
1131
1880
  {
1132
1881
  const kind = classMatches[0].type;
1133
1882
  const article = /^[aeiou]/.test(kind) ? 'an' : 'a';
1134
- notes.push(`"${fnName}" is ${article} ${kind}, not a function. Use \`class ${fnName}\` instead.`);
1883
+ notes.push(`"${fnName}" is ${article} ${kind}, not a function. Use \`source ${fnName}\` to extract it.`);
1135
1884
  }
1136
1885
  } else if ((index.symbols.get(actualName) || []).some(s => s.type === 'macro')) {
1137
- notes.push(`"${fnName}" is a macro. fn/class extract functions and classes — use \`lines <file>:<start>-<end>\` for macro bodies.`);
1886
+ notes.push(`"${fnName}" is a macro. Use \`source <file>:<start>-<end>\` for its body.`);
1138
1887
  } else {
1139
1888
  notes.push(`Function "${fnName}" not found.`);
1140
1889
  }
@@ -1155,8 +1904,14 @@ const HANDLERS = {
1155
1904
  // in-file matches were silently dropped in three languages).
1156
1905
  if (matches.length > 1 && p.all) {
1157
1906
  for (const m of matches) {
1158
- const code = readAndExtract(m);
1159
- entries.push({ match: m, code });
1907
+ const fullCode = readAndExtract(m);
1908
+ const totalLines = m.endLine - m.startLine + 1;
1909
+ const truncated = !!maxLines && totalLines > maxLines;
1910
+ const code = truncated
1911
+ ? fullCode.split('\n').slice(0, maxLines).join('\n')
1912
+ : fullCode;
1913
+ entries.push({ match: m, code, totalLines, truncated,
1914
+ shownLines: truncated ? maxLines : totalLines });
1160
1915
  }
1161
1916
  continue;
1162
1917
  }
@@ -1172,8 +1927,14 @@ const HANDLERS = {
1172
1927
  notes.push(`Found ${matches.length} ${what} for "${fnName}". Showing ${match.relativePath}:${match.startLine}. Also in: ${others}. ${disambiguationHint(matches, match, p.file)}`);
1173
1928
  }
1174
1929
 
1175
- const code = readAndExtract(match);
1176
- entries.push({ match, code });
1930
+ const fullCode = readAndExtract(match);
1931
+ const totalLines = match.endLine - match.startLine + 1;
1932
+ const truncated = !!maxLines && totalLines > maxLines;
1933
+ const code = truncated
1934
+ ? fullCode.split('\n').slice(0, maxLines).join('\n')
1935
+ : fullCode;
1936
+ entries.push({ match, code, totalLines, truncated,
1937
+ shownLines: truncated ? maxLines : totalLines });
1177
1938
  }
1178
1939
 
1179
1940
  if (entries.length === 0 && notes.length > 0) {
@@ -1209,9 +1970,9 @@ const HANDLERS = {
1209
1970
  const fnMatches = index.find(p.name, { file: p.file, skipCounts: true })
1210
1971
  .filter(m => m.type === 'function' || (m.params !== undefined && !CLASS_KIND_TYPES.includes(m.type)));
1211
1972
  if (fnMatches.length > 0) {
1212
- return { ok: false, error: `Class "${p.name}" not found — "${p.name}" is a ${fnMatches[0].type}. Use \`fn ${p.name}\` instead.` };
1973
+ return { ok: false, error: `Class "${p.name}" not found — "${p.name}" is a ${fnMatches[0].type}. Use \`source ${p.name}\` instead.` };
1213
1974
  }
1214
- return { ok: false, error: `Class "${p.name}" not found.` };
1975
+ return { ok: false, error: symbolNotFound(index, p.name, 'Class') };
1215
1976
  }
1216
1977
 
1217
1978
  const entries = [];
@@ -1253,7 +2014,7 @@ const HANDLERS = {
1253
2014
 
1254
2015
  // Large class summary mode (>200 lines, no maxLines)
1255
2016
  if (totalLines > 200 && !maxLines) {
1256
- const methods = index.findMethodsForType(match.name);
2017
+ const methods = index.findMethodsForType(match.name, match);
1257
2018
  entries.push({ match, code: null, methods, totalLines, summaryMode: true, truncated: false });
1258
2019
  return { ok: true, result: { entries }, note: notes.length ? notes.map(n => 'Note: ' + n).join('\n') : undefined };
1259
2020
  }
@@ -1301,7 +2062,7 @@ const HANDLERS = {
1301
2062
  // reported "File not found" — factually wrong; imports/exporters
1302
2063
  // list the candidates for the same input).
1303
2064
  const resolved = index.resolveFilePathForQuery(p.file);
1304
- const resolveErr = checkFileError(typeof resolved === 'string' ? null : resolved, p.file);
2065
+ const resolveErr = checkFileError(typeof resolved === 'string' ? null : resolved, p.file, index);
1305
2066
  if (resolveErr) return { ok: false, error: resolveErr };
1306
2067
  const filePath = resolved;
1307
2068
 
@@ -1342,7 +2103,7 @@ const HANDLERS = {
1342
2103
  const err = requireFile(p.file);
1343
2104
  if (err) return { ok: false, error: err };
1344
2105
  const result = index.imports(p.file);
1345
- const fileErr = checkFileError(result, p.file);
2106
+ const fileErr = checkFileError(result, p.file, index);
1346
2107
  if (fileErr) return { ok: false, error: fileErr };
1347
2108
  return { ok: true, result };
1348
2109
  },
@@ -1351,7 +2112,7 @@ const HANDLERS = {
1351
2112
  const err = requireFile(p.file);
1352
2113
  if (err) return { ok: false, error: err };
1353
2114
  const result = index.exporters(p.file);
1354
- const fileErr = checkFileError(result, p.file);
2115
+ const fileErr = checkFileError(result, p.file, index);
1355
2116
  if (fileErr) return { ok: false, error: fileErr };
1356
2117
  return { ok: true, result };
1357
2118
  },
@@ -1360,7 +2121,7 @@ const HANDLERS = {
1360
2121
  const err = requireFile(p.file);
1361
2122
  if (err) return { ok: false, error: err };
1362
2123
  const result = index.fileExports(p.file);
1363
- const fileErr = checkFileError(result, p.file);
2124
+ const fileErr = checkFileError(result, p.file, index);
1364
2125
  if (fileErr) return { ok: false, error: fileErr };
1365
2126
  return { ok: true, result };
1366
2127
  },
@@ -1375,7 +2136,7 @@ const HANDLERS = {
1375
2136
  if (result && result.error === 'invalid-direction') {
1376
2137
  return { ok: false, error: result.message };
1377
2138
  }
1378
- const fileErr = checkFileError(result, p.file);
2139
+ const fileErr = checkFileError(result, p.file, index);
1379
2140
  if (fileErr) return { ok: false, error: fileErr };
1380
2141
  return { ok: true, result };
1381
2142
  },
@@ -1418,7 +2179,7 @@ const HANDLERS = {
1418
2179
  ...(p.includeUncertain !== undefined && { includeUncertain: p.includeUncertain }),
1419
2180
  });
1420
2181
  if (result && result.found === false) {
1421
- return { ok: false, error: `Function "${p.name}" not found.` };
2182
+ return { ok: false, error: symbolNotFound(index, p.name, 'Function') };
1422
2183
  }
1423
2184
  return { ok: true, result };
1424
2185
  },
@@ -1447,8 +2208,9 @@ const HANDLERS = {
1447
2208
  ...(p.line && { line: p.line }),
1448
2209
  });
1449
2210
  if (result && result.found === false) {
1450
- return { ok: false, error: `Function "${p.name}" not found.` };
2211
+ return { ok: false, error: symbolNotFound(index, p.name, 'Function') };
1451
2212
  }
2213
+ if (result && result.error) return { ok: false, error: result.error };
1452
2214
  return { ok: true, result };
1453
2215
  },
1454
2216
 
@@ -1460,9 +2222,21 @@ const HANDLERS = {
1460
2222
  });
1461
2223
  const limit = num(p.limit, undefined);
1462
2224
  let note;
1463
- if (limit && limit > 0 && result && result.changed && result.changed.length > limit) {
1464
- note = limitNote(limit, result.changed.length);
1465
- result = { ...result, changed: result.changed.slice(0, limit) };
2225
+ if (limit && limit > 0 && result) {
2226
+ const groups = ['functions', 'moduleLevelChanges', 'newFunctions', 'deletedFunctions'];
2227
+ const total = groups.reduce((sum, key) => sum + (result[key]?.length || 0), 0);
2228
+ if (total > limit) {
2229
+ let remaining = limit;
2230
+ const limited = { ...result };
2231
+ for (const key of groups) {
2232
+ const values = result[key] || [];
2233
+ limited[key] = values.slice(0, Math.max(0, remaining));
2234
+ remaining -= limited[key].length;
2235
+ }
2236
+ limited.limitInfo = { total, shown: limit };
2237
+ result = limited;
2238
+ note = limitNote(limit, total);
2239
+ }
1466
2240
  }
1467
2241
  return { ok: true, result, note };
1468
2242
  },
@@ -1477,7 +2251,13 @@ const HANDLERS = {
1477
2251
  if (fileErr) return { ok: false, error: fileErr };
1478
2252
  const classErr = validateClassName(index, p.name, p.className);
1479
2253
  if (classErr) return { ok: false, error: classErr };
1480
- const result = index.typedef(p.name, { exact: p.exact || false, className: p.className, file: p.file });
2254
+ const result = index.typedef(p.name, {
2255
+ exact: p.exact || false,
2256
+ className: p.className,
2257
+ file: p.file,
2258
+ exclude: applyTestExclusions(p.exclude, p.includeTests),
2259
+ in: p.in,
2260
+ });
1481
2261
  return { ok: true, result };
1482
2262
  },
1483
2263
 
@@ -1494,9 +2274,21 @@ const HANDLERS = {
1494
2274
  const fileErr = checkFilePatternMatch(index, p.file);
1495
2275
  if (fileErr) return { ok: false, error: fileErr };
1496
2276
  }
1497
- let result = index.api(p.file);
2277
+ if (p.in) {
2278
+ let anyIn = false;
2279
+ for (const [, fileEntry] of index.files) {
2280
+ if (index.matchesFilters(fileEntry.relativePath, { in: p.in })) {
2281
+ anyIn = true;
2282
+ break;
2283
+ }
2284
+ }
2285
+ if (!anyIn) {
2286
+ return { ok: false, error: `No files matched the 'in' directory filter '${p.in}'.` };
2287
+ }
2288
+ }
2289
+ let result = index.api(p.file, { in: p.in });
1498
2290
  if (p.file) {
1499
- const fileErr = checkFileError(result, p.file);
2291
+ const fileErr = checkFileError(result, p.file, index);
1500
2292
  if (fileErr) return { ok: false, error: fileErr };
1501
2293
  }
1502
2294
  // Apply limit to api results (api returns an array)
@@ -1504,6 +2296,7 @@ const HANDLERS = {
1504
2296
  let note;
1505
2297
  if (limit && limit > 0 && Array.isArray(result)) {
1506
2298
  const { items, total, limited } = applyLimit(result, limit);
2299
+ const apiInfo = result.apiInfo;
1507
2300
  if (limited) {
1508
2301
  note = limitNote(limit, total);
1509
2302
  // Full-set size travels with the payload for --json
@@ -1513,6 +2306,12 @@ const HANDLERS = {
1513
2306
  enumerable: false, writable: true, configurable: true,
1514
2307
  });
1515
2308
  }
2309
+ if (apiInfo) {
2310
+ Object.defineProperty(items, 'apiInfo', {
2311
+ value: apiInfo,
2312
+ enumerable: false, writable: true, configurable: true,
2313
+ });
2314
+ }
1516
2315
  result = items;
1517
2316
  }
1518
2317
  return { ok: true, result, note };
@@ -1565,6 +2364,9 @@ const HANDLERS = {
1565
2364
  functions: p.functions || false,
1566
2365
  hot: p.hot || false,
1567
2366
  top,
2367
+ file: p.file,
2368
+ in: p.in,
2369
+ exclude: toExcludeArray(p.exclude),
1568
2370
  });
1569
2371
  return note ? { ok: true, result, note } : { ok: true, result };
1570
2372
  },
@@ -1601,7 +2403,7 @@ const HANDLERS = {
1601
2403
  const scopeHint = p.symbolName ? ` (from context for "${p.symbolName}")` : '';
1602
2404
  return { ok: false, error: `Item ${p.itemNum} not found${scopeHint}. Available: 1-${p.itemCount}` };
1603
2405
  }
1604
- return { ok: false, error: 'No expandable items. Run context first.' };
2406
+ return { ok: false, error: 'No expandable items. Run show first.' };
1605
2407
  }
1606
2408
  const rendered = renderExpandItem(p.match, index.root, { validateRoot: p.validateRoot || false });
1607
2409
  if (!rendered.ok) return { ok: false, error: rendered.error };
@@ -1634,6 +2436,10 @@ function execute(index, command, params = {}) {
1634
2436
  // normalizers and returned wrong not-found answers).
1635
2437
  params = { ...params };
1636
2438
  try {
2439
+ if (PUBLIC_COMMAND_SET.has(command)) {
2440
+ const validationError = validatePublicParams(command, params);
2441
+ if (validationError) return { ok: false, error: validationError };
2442
+ }
1637
2443
  // Resolve name-less handles (e.g. `lib.js:42`) via index lookup before dispatch.
1638
2444
  // Handles WITH a name suffix are handled later by applyClassMethodSyntax.
1639
2445
  if (params && params.name && looksLikeHandle(params.name)) {
@@ -1673,4 +2479,4 @@ function lookupByLocation(index, file, line) {
1673
2479
  return null;
1674
2480
  }
1675
2481
 
1676
- module.exports = { execute };
2482
+ module.exports = { execute, validatePublicParams };