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
@@ -9,6 +9,15 @@ const {
9
9
  formatClassSignature,
10
10
  } = require('./shared');
11
11
 
12
+ const LIST_TEXT_LIMIT = 240;
13
+
14
+ function elideListText(value, limit = LIST_TEXT_LIMIT) {
15
+ const text = String(value ?? '');
16
+ if (text.length <= limit) return text;
17
+ const kept = limit - 18;
18
+ return `${text.slice(0, kept)}… [${text.length} chars]`;
19
+ }
20
+
12
21
  /**
13
22
  * Format toc command output
14
23
  * @param {object} toc - TOC data
@@ -63,10 +72,10 @@ function formatToc(toc, options = {}) {
63
72
  lines.push(`\n${file.file} (${parts.join(', ')})`);
64
73
  if (file.symbols) {
65
74
  for (const fn of file.symbols.functions) {
66
- lines.push(` ${lineRange(fn.startLine, fn.endLine)} ${formatFunctionSignature(fn)}`);
75
+ lines.push(` ${lineRange(fn.startLine, fn.endLine)} ${elideListText(formatFunctionSignature(fn))}`);
67
76
  }
68
77
  for (const cls of file.symbols.classes) {
69
- lines.push(` ${lineRange(cls.startLine, cls.endLine)} ${formatClassSignature(cls)}`);
78
+ lines.push(` ${lineRange(cls.startLine, cls.endLine)} ${elideListText(formatClassSignature(cls))}`);
70
79
  }
71
80
  }
72
81
  } else {
@@ -143,23 +152,26 @@ function formatStats(stats, options = {}) {
143
152
  lines.push(`\nFunctions by line count (top ${shown.length} of ${stats.functions.length}):`);
144
153
  for (const fn of shown) {
145
154
  const loc = `${fn.file}:${fn.startLine}`;
146
- lines.push(` ${String(fn.lines).padStart(5)} lines ${fn.name} (${loc})`);
155
+ lines.push(` ${String(fn.lines).padStart(5)} lines ${elideListText(fn.name, 100)} (${loc})`);
147
156
  }
148
157
  if (stats.functions.length > top) {
149
- lines.push(` ... ${stats.functions.length - top} more (use --top=N to show more)`);
158
+ lines.push(` ... ${stats.functions.length - top} more (${options.topHint || 'use --top=N to show more'})`);
150
159
  }
151
160
  }
152
161
 
153
162
  if (stats.hot) {
154
163
  const items = stats.hot.items || [];
155
164
  const total = stats.hot.total || items.length;
156
- lines.push(`\nHottest functions (top ${items.length} of ${total} called):`);
165
+ const population = stats.hot.totalKind === 'raw-call-candidates'
166
+ ? `${total} raw candidates`
167
+ : `${total} called`;
168
+ lines.push(`\nHottest functions (top ${items.length} of ${population}):`);
157
169
  if (items.length === 0) {
158
170
  lines.push(' (no inbound calls detected)');
159
171
  } else {
160
172
  for (const fn of items) {
161
173
  const loc = `${fn.file}:${fn.startLine}`;
162
- lines.push(` ${String(fn.callCount).padStart(5)} calls ${fn.name} (${loc})`);
174
+ lines.push(` ${String(fn.callCount).padStart(5)} calls ${elideListText(fn.name, 100)} (${loc})`);
163
175
  // MEDIUM-6: when the same name has multiple definitions across
164
176
  // files (e.g. test helpers vs. test fixtures both named `tmp`),
165
177
  // list the additional locations indented so the user knows
@@ -172,7 +184,7 @@ function formatStats(stats, options = {}) {
172
184
  }
173
185
  }
174
186
  if (total > items.length) {
175
- lines.push(` ... ${total - items.length} more (use --top=N to show more)`);
187
+ lines.push(` ... ${total - items.length} more (${options.topHint || 'use --top=N to show more'})`);
176
188
  }
177
189
  }
178
190
  }
@@ -194,7 +206,11 @@ function formatStatsJson(stats) {
194
206
  * @param {string} [options.exportedHint] - Hint about exported symbols exclusion
195
207
  */
196
208
  function formatDeadcode(results, options = {}) {
197
- if (results.length === 0 && !results.excludedDecorated && !results.excludedExported && !results.excludedExternalContract) {
209
+ if (results.length === 0 && !results.excludedDecorated &&
210
+ !results.excludedExported && !results.excludedExternalContract &&
211
+ !results.excludedRuntimeContract && !results.pythonImplicitExportFiles &&
212
+ !results.excludedDynamicDispatch && !results.computedDispatch?.count &&
213
+ results.coverage?.complete !== false) {
198
214
  return 'No dead code found.';
199
215
  }
200
216
 
@@ -238,12 +254,16 @@ function formatDeadcode(results, options = {}) {
238
254
  : '';
239
255
  // The only references are the symbol's own recursion (fix #253c).
240
256
  const recStr = item.selfRecursive ? ' [only self-references — recursive]' : '';
241
- const displayName = item.className ? `${item.className}.${item.name}` : item.name;
257
+ const displayName = elideListText(
258
+ item.className ? `${item.className}.${item.name}` : item.name,
259
+ 120,
260
+ );
242
261
  lines.push(` ${lineRange(item.startLine, item.endLine)} ${displayName} (${item.type})${exported}${hintStr}${declStr}${extStr}${recStr}`);
243
262
  }
244
263
 
245
264
  if (hidden > 0) {
246
- lines.push(`\n${hidden} more result(s) not shown. Use --top=${results.length} or --all to see all.`);
265
+ lines.push(`\n${hidden} more result(s) not shown. ${options.topHint ||
266
+ `Use --top=${results.length} or --all to see all.`}`);
247
267
  }
248
268
 
249
269
  // Show counts of excluded items with expansion hints
@@ -262,6 +282,29 @@ function formatDeadcode(results, options = {}) {
262
282
  const extHint = options.externalContractHint || `${results.excludedExternalContract} symbol(s) hidden (override an out-of-tree base class — reachable via external contract, not dead). Use --include-exported to include them.`;
263
283
  lines.push(`\n${extHint}`);
264
284
  }
285
+ if (results.excludedRuntimeContract > 0) {
286
+ lines.push(`\n${results.excludedRuntimeContract} Java serialization callback(s) hidden (JVM runtime contract, not dead).`);
287
+ }
288
+ if (results.pythonImplicitExportFiles > 0) {
289
+ lines.push(`\nPython public-surface rule active in ${results.pythonImplicitExportFiles} file(s) without __all__: top-level non-underscore names are treated as externally reachable.`);
290
+ }
291
+ if (results.excludedDynamicDispatch > 0) {
292
+ lines.push(`\n${results.excludedDynamicDispatch} registry member(s) hidden because a matching computed dispatch (registry[key]()) may invoke them.`);
293
+ }
294
+ if (results.computedDispatch?.count > 0) {
295
+ lines.push(`\nWARNING: ${results.computedDispatch.count} computed dispatch call(s) in ${results.computedDispatch.fileCount} file(s). Dead-code results are review candidates; runtime-selected members may not have a named static edge.`);
296
+ }
297
+ if (results.coverage?.complete === false) {
298
+ const c = results.coverage;
299
+ const reasonText = Object.entries(c.reasons || {})
300
+ .map(([reason, count]) => `${count} ${reason}`)
301
+ .join(', ');
302
+ if (c.claimsWithdrawn) {
303
+ lines.push(`\nWARNING: dead-code claims withdrawn because source coverage is incomplete (${reasonText || 'unknown gap'}).`);
304
+ } else {
305
+ lines.push(`\nWARNING: source coverage is incomplete (${reasonText || 'unknown gap'}); ${c.suppressedMatched || 0} candidate name(s) found in skipped source were suppressed.`);
306
+ }
307
+ }
265
308
 
266
309
  if (lines.length === 0) {
267
310
  return 'No dead code found.';
@@ -290,6 +333,11 @@ function formatDeadcodeJson(results) {
290
333
  ...(results.excludedExported > 0 && { excludedExported: results.excludedExported }),
291
334
  ...(results.excludedDecorated > 0 && { excludedDecorated: results.excludedDecorated }),
292
335
  ...(results.excludedExternalContract > 0 && { excludedExternalContract: results.excludedExternalContract }),
336
+ ...(results.excludedRuntimeContract > 0 && { excludedRuntimeContract: results.excludedRuntimeContract }),
337
+ ...(results.pythonImplicitExportFiles > 0 && { pythonImplicitExportFiles: results.pythonImplicitExportFiles }),
338
+ ...(results.excludedDynamicDispatch > 0 && { excludedDynamicDispatch: results.excludedDynamicDispatch }),
339
+ ...(results.computedDispatch?.count > 0 && { computedDispatch: results.computedDispatch }),
340
+ ...(results.coverage?.complete === false && { coverage: results.coverage }),
293
341
  symbols: results.map(item => {
294
342
  const handleSym = { ...item, relativePath: item.relativePath || item.file };
295
343
  const handle = formatSymbolHandle(handleSym);
@@ -400,9 +448,9 @@ function formatEntrypointsJson(results) {
400
448
  /**
401
449
  * formatOrient — one-screen cold-repo orientation.
402
450
  */
403
- function formatOrient(result) {
451
+ function formatOrient(result, options = {}) {
404
452
  const lines = [];
405
- lines.push(`PROJECT ORIENTATION — ${result.root}`);
453
+ lines.push(`PROJECT ORIENTATION — ${result.root}${result.scope ? ` (scoped to ${result.scope})` : ''}`);
406
454
  lines.push('═'.repeat(60));
407
455
 
408
456
  // Size + language mix (percent by symbols, largest first)
@@ -413,7 +461,8 @@ function formatOrient(result) {
413
461
  const langStr = langs
414
462
  .map(l => `${l.lang} ${Math.round((l.symbols / totalSym) * 100)}%`)
415
463
  .join(', ');
416
- lines.push(`${result.files} files · ${result.symbols} symbols · ${langStr}`);
464
+ lines.push(`${result.files} files · ${result.symbols} symbols · ` +
465
+ `language mix by symbols: ${langStr || 'none'}`);
417
466
  lines.push('');
418
467
 
419
468
  if (result.dirs?.length) {
@@ -427,7 +476,10 @@ function formatOrient(result) {
427
476
 
428
477
  if (result.hot?.items?.length) {
429
478
  const scope = result.hot.production ? 'production functions' : 'functions';
430
- lines.push(`HOT (most-called ${scope}, top ${result.hot.items.length} of ${result.hot.total}):`);
479
+ const population = result.hot.totalKind === 'raw-call-candidates'
480
+ ? `${result.hot.total} raw candidates`
481
+ : result.hot.total;
482
+ lines.push(`HOT (most-called ${scope}, top ${result.hot.items.length} of ${population}):`);
431
483
  for (const h of result.hot.items) {
432
484
  const label = h.className ? `${h.className}.${h.name}` : h.name;
433
485
  lines.push(` ${label} — ${h.callCount} call(s) · ${h.file}:${h.line}`);
@@ -438,23 +490,48 @@ function formatOrient(result) {
438
490
  if (result.entrypoints) {
439
491
  const byType = result.entrypoints.byType
440
492
  .map(t => `${t.type} ${t.count}`).join(', ');
441
- lines.push(`ENTRY POINTS: ${result.entrypoints.total} — ${byType}`);
493
+ lines.push(`ENTRY POINTS: ${result.entrypoints.total}${byType ? ` — ${byType}` : ''}`);
442
494
  } else {
443
495
  lines.push('ENTRY POINTS: (detection unavailable)');
444
496
  }
445
497
 
446
498
  const bs = result.trust?.blindSpots || {};
447
499
  const bsParts = [];
448
- if (bs.dynamicImports) bsParts.push(`${bs.dynamicImports} dynamic import(s)`);
500
+ if (bs.dynamicImports) {
501
+ const note = dynamicImportsNote(bs.dynamicImports, {
502
+ projectLanguage: result.projectLanguage || result.trust?.projectLanguage,
503
+ });
504
+ if (note) bsParts.push(note);
505
+ }
449
506
  if (bs.evalCalls) bsParts.push(`${bs.evalCalls} eval`);
450
507
  if (bs.reflection) bsParts.push(`${bs.reflection} reflection`);
508
+ if (bs.computedDispatch) bsParts.push(`${bs.computedDispatch} computed dispatch`);
451
509
  if (bs.parseFailures) bsParts.push(`${bs.parseFailures} parse failure(s)`);
452
- lines.push(`TRUST: ${result.trust?.level || 'UNKNOWN'}${bsParts.length ? ' — ' + bsParts.join(', ') : ''} (ucn doctor for detail)`);
510
+ if (bs.parseRecoveries) bsParts.push(`${bs.parseRecoveries} parser-recovery file(s)`);
511
+ if (bs.unsupportedSources) bsParts.push(`${bs.unsupportedSources} unsupported source file(s)`);
512
+ if (bs.skippedSources) bsParts.push(`${bs.skippedSources} skipped source path(s)`);
513
+ lines.push(`TRUST: ${result.trust?.level || 'UNKNOWN'}${bsParts.length ? ' — ' + bsParts.join(', ') : ''} (${options.healthHint || 'ucn repo --sections=health --deep for detail'})`);
514
+ if (result.unsupportedSources?.count > 0) {
515
+ const languages = Object.entries(result.unsupportedSources.languages || {})
516
+ .map(([language, count]) => `${language} ${count}`)
517
+ .join(', ');
518
+ lines.push(`SKIPPED SOURCE: ${result.unsupportedSources.count} file(s)${languages ? ` (${languages})` : ''} — use grep/ripgrep plus a language-native analyzer.`);
519
+ }
520
+ if (result.skippedSources?.count > 0) {
521
+ const reasons = Object.entries(result.skippedSources.reasons || {})
522
+ .map(([reason, count]) => `${reason} ${count}`).join(', ');
523
+ lines.push(`SKIPPED SOURCE: ${result.skippedSources.count} path(s) were not indexed${reasons ? ` (${reasons})` : ''} — completeness claims are withdrawn.`);
524
+ }
453
525
  lines.push('');
454
526
 
455
- const next = [];
456
- if (result.suggest) next.push(`ucn about ${result.suggest}`);
457
- next.push('ucn toc --detailed', 'ucn stats --hot --top=20', 'ucn doctor --deep');
527
+ const next = options.nextHints
528
+ ? options.nextHints(result)
529
+ : [
530
+ ...(result.suggest ? [`ucn show ${result.suggest}`] : []),
531
+ 'ucn repo --sections=files --detailed',
532
+ 'ucn repo --sections=stats --hot --top=20',
533
+ 'ucn repo --sections=health --deep',
534
+ ];
458
535
  lines.push(`Next: ${next.join(' · ')}`);
459
536
 
460
537
  return lines.join('\n');
@@ -7,28 +7,27 @@ const { detectDoubleEscaping, advisoryLine } = require('./shared');
7
7
  /**
8
8
  * Format search command output
9
9
  */
10
- function formatSearch(results, term) {
10
+ function formatSearch(results, term, options = {}) {
11
11
  const meta = results.meta;
12
- const fallbackNote = meta && meta.regexFallback
13
- ? `\nNote: Invalid regex (${meta.regexFallback}). Fell back to plain text search.`
14
- : '';
15
12
 
16
- const totalMatches = results.reduce((sum, r) => sum + r.matches.length, 0);
13
+ const shownMatches = results.reduce((sum, r) => sum + r.matches.length, 0);
14
+ const totalMatches = meta?.totalMatches ?? shownMatches;
17
15
  if (totalMatches === 0) {
18
16
  if (meta) {
19
17
  const scope = meta.filesSkipped > 0
20
18
  ? `Searched ${meta.filesScanned} of ${meta.totalFiles} file${meta.totalFiles === 1 ? '' : 's'} (${meta.filesSkipped} excluded by filters).`
21
19
  : `Searched ${meta.filesScanned} file${meta.filesScanned === 1 ? '' : 's'}.`;
22
20
  const escapingHint = detectDoubleEscaping(term);
23
- return `No matches found for "${term}". ${scope}${fallbackNote}${escapingHint}`;
21
+ return `No matches found for "${term}". ${scope}${escapingHint}`;
24
22
  }
25
- return `No matches found for "${term}"${fallbackNote}`;
23
+ return `No matches found for "${term}"`;
26
24
  }
27
25
 
28
26
  const lines = [];
29
- const fileWord = results.length === 1 ? 'file' : 'files';
30
- lines.push(`Found ${totalMatches} match${totalMatches === 1 ? '' : 'es'} for "${term}" in ${results.length} ${fileWord}:`);
31
- if (fallbackNote) lines.push(fallbackNote.trim());
27
+ const totalFiles = meta?.matchedFiles ?? meta?.totalMatchedFiles ?? results.length;
28
+ const fileWord = totalFiles === 1 ? 'file' : 'files';
29
+ const showing = shownMatches < totalMatches ? ` (showing ${shownMatches})` : '';
30
+ lines.push(`Found ${totalMatches} match${totalMatches === 1 ? '' : 'es'} for "${term}" in ${totalFiles} ${fileWord}${showing}:`);
32
31
  lines.push('═'.repeat(60));
33
32
 
34
33
  for (const result of results) {
@@ -49,11 +48,11 @@ function formatSearch(results, term) {
49
48
  }
50
49
 
51
50
  if (meta && meta.truncatedMatches > 0) {
52
- lines.push(`\n${results.reduce((s, r) => s + r.matches.length, 0)} shown of ${meta.totalMatches} total matches. Use top= to see more.`);
51
+ lines.push(`\n${shownMatches} shown of ${meta.totalMatches} total matches. ${options.topHint || 'Use --top=N to see more.'}`);
53
52
  }
54
53
 
55
54
  if (meta && meta.testsExcluded && meta.filesSkipped > 0) {
56
- lines.push(`\nNote: ${meta.filesSkipped} test file${meta.filesSkipped === 1 ? '' : 's'} hidden by default (use include_tests=true to include).`);
55
+ lines.push(`\nNote: ${meta.filesSkipped} test file${meta.filesSkipped === 1 ? '' : 's'} hidden by default (${options.includeTestsHint || 'use --include-tests to include'}).`);
57
56
  }
58
57
 
59
58
  return lines.join('\n');
@@ -80,7 +79,6 @@ function formatSearchJson(results, term) {
80
79
  obj.filesScanned = meta.filesScanned;
81
80
  obj.filesSkipped = meta.filesSkipped;
82
81
  obj.totalFiles = meta.totalFiles;
83
- if (meta.regexFallback) obj.regexFallback = meta.regexFallback;
84
82
  if (meta.truncatedMatches > 0) obj.truncatedMatches = meta.truncatedMatches;
85
83
  }
86
84
  return JSON.stringify(obj, null, 2);
@@ -89,7 +87,7 @@ function formatSearchJson(results, term) {
89
87
  /**
90
88
  * Format structural search results (index-based queries)
91
89
  */
92
- function formatStructuralSearch(result) {
90
+ function formatStructuralSearch(result, options = {}) {
93
91
  const { results, meta } = result;
94
92
  const lines = [];
95
93
 
@@ -107,6 +105,10 @@ function formatStructuralSearch(result) {
107
105
 
108
106
  lines.push(`Structural search: ${queryStr}`);
109
107
  lines.push('═'.repeat(60));
108
+ if (meta.query.unused) {
109
+ lines.push(`NOTE: ${options.unusedFlag || '--unused'} lists callable symbols with no resolved call edge; it does not assess type/field/reference liveness and is not safe-delete proof. Confirm with deadcode and usages.`);
110
+ lines.push('');
111
+ }
110
112
 
111
113
  if (results.length === 0) {
112
114
  lines.push('No matches found.');
@@ -137,7 +139,7 @@ function formatStructuralSearch(result) {
137
139
  }
138
140
 
139
141
  if (meta.shown < meta.totalMatched) {
140
- lines.push(`\n${meta.shown} of ${meta.totalMatched} shown. Use top= to see more.`);
142
+ lines.push(`\n${meta.shown} of ${meta.totalMatched} shown. ${options.topHint || 'Use --top=N to see more.'}`);
141
143
  }
142
144
 
143
145
  return lines.join('\n');
@@ -396,8 +398,13 @@ function formatTypedefJson(types, name) {
396
398
  function formatTests(tests, name) {
397
399
  const lines = [`Tests for "${name}":\n`];
398
400
 
401
+ for (const warning of tests?.warnings || []) {
402
+ lines.push(`Note: ${warning.message}`);
403
+ }
404
+ if (tests?.warnings?.length > 0) lines.push('');
405
+
399
406
  if (!tests || !Array.isArray(tests) || tests.length === 0) {
400
- lines.push(' (no tests found)');
407
+ lines.push(' (no statically linked tests found)');
401
408
  } else {
402
409
  const totalMatches = tests.reduce((sum, t) => sum + t.matches.length, 0);
403
410
  lines.push(`Found ${totalMatches} matches in ${tests.length} test file(s):\n`);
@@ -432,6 +439,7 @@ function formatTestsJson(tests, name) {
432
439
  const safe = Array.isArray(tests) ? tests : [];
433
440
  return JSON.stringify({
434
441
  query: name,
442
+ ...(safe.warnings?.length > 0 && { warnings: safe.warnings }),
435
443
  testFileCount: safe.length,
436
444
  totalMatches: safe.reduce((sum, t) => sum + (t.matches?.length || 0), 0),
437
445
  testFiles: safe
@@ -97,9 +97,28 @@ function formatFunctionSignature(fn) {
97
97
  // Generator marker
98
98
  if (fn.isGenerator) prefix.push('*');
99
99
 
100
+ // Data members are not callables. Their declared type is stronger and
101
+ // more useful than the unknown-parameter sentinel (UCN5-171).
102
+ const dataMember = fn.type === 'field' || fn.type === 'state' ||
103
+ fn.memberType === 'field' || fn.memberType === 'property';
104
+ if (dataMember) {
105
+ let dataSig = fn.name;
106
+ const declaredType = fn.fieldType || fn.returnType;
107
+ if (declaredType) {
108
+ dataSig += `: ${String(declaredType).replace(/\s+/g, ' ').trim()}`;
109
+ }
110
+ return prefix.length > 0 ? `${prefix.join(' ')} ${dataSig}` : dataSig;
111
+ }
112
+
113
+ // Object-like macros expand as values, not calls. Function-like macros
114
+ // retain their real parameter list, including an empty `()` (UCN5-170).
115
+ if (fn.type === 'macro' && fn.functionLike === false) {
116
+ return prefix.length > 0 ? `${prefix.join(' ')} ${fn.name}` : fn.name;
117
+ }
118
+
100
119
  // Name + generics + params (concatenated without spaces)
101
120
  let sig = fn.name;
102
- if (fn.generics) sig += fn.generics;
121
+ if (fn.generics) sig += String(fn.generics).replace(/\s+/g, ' ').trim();
103
122
  // If paramsStructured + paramTypes are available, render typed params
104
123
  const typed = renderTypedParams(fn);
105
124
  // When paramsStructured is an empty array, the function has zero params —
@@ -353,6 +372,8 @@ const ADVISORY_LABELS = {
353
372
  'scored-selection': 'examples picked by usage-quality scoring',
354
373
  'best-effort-frame-matching': 'frames matched by path similarity',
355
374
  'heuristic-route-matching': 'route↔request matches are heuristic (see per-match EXACT/PARTIAL/UNCERTAIN tiers)',
375
+ 'incomplete-endpoint-inventory': 'AST inventory covers known framework patterns; absence is not proof that no endpoint exists',
376
+ 'heuristic-route-matching-and-incomplete-inventory': 'AST inventory covers known framework patterns and route↔request matches are heuristic; absence is not proof that no endpoint exists',
356
377
  };
357
378
 
358
379
  /** Render the advisory line for a result's `advisory` field, or null. */
@@ -20,7 +20,7 @@ function renderFrontier(lines, frontier, options = {}, expanded = false) {
20
20
  if (!frontier || frontier.length === 0) return false;
21
21
  lines.push('');
22
22
  const suffix = expanded
23
- ? 'followed (--expand-unverified); downstream nodes are possible impact'
23
+ ? `followed (${options.expandUnverifiedHint || '--expand-unverified'}); downstream nodes are possible impact`
24
24
  : 'not expanded; possible additional impact';
25
25
  lines.push(`UNVERIFIED EDGES (${frontier.length}) — call syntax, no binding/receiver evidence; ${suffix}:`);
26
26
  const cap = options.all ? Infinity : 20;
@@ -34,7 +34,7 @@ function renderFrontier(lines, frontier, options = {}, expanded = false) {
34
34
  shown++;
35
35
  }
36
36
  if (frontier.length > shown) {
37
- lines.push(` (+${frontier.length - shown} more unverified — use --all)`);
37
+ lines.push(` (+${frontier.length - shown} more unverified — ${options.allHint || 'use --all'})`);
38
38
  }
39
39
  return true;
40
40
  }
@@ -66,8 +66,14 @@ function treeAccountLine(ta) {
66
66
  `${ta.confirmedEdges} confirmed edge${ta.confirmedEdges === 1 ? '' : 's'} · ` +
67
67
  `${ta.unverifiedEdges} unverified${reasons ? ` (${reasons})` : ''} · ` +
68
68
  `${ta.excludedTotal} excluded${excludedReasons ? ` (${excludedReasons})` : ''}`;
69
+ if (ta.recursiveEdges > 0) {
70
+ line += ` · ${ta.recursiveEdges} self-recursive edge${ta.recursiveEdges === 1 ? '' : 's'} (not external impact)`;
71
+ }
69
72
  if (ta.filteredEdges > 0) line += ` · ${ta.filteredEdges} hidden by --exclude`;
70
73
  if (ta.depthLimitNodes > 0) line += ` · ${ta.depthLimitNodes} node${ta.depthLimitNodes === 1 ? '' : 's'} at depth limit (callers not searched)`;
74
+ if (ta.truncatedChildren > 0) {
75
+ line += ` · ${ta.truncatedChildren} child branch${ta.truncatedChildren === 1 ? '' : 'es'} not expanded (account covers the displayed subtree only)`;
76
+ }
71
77
  return line;
72
78
  }
73
79
 
@@ -317,13 +323,15 @@ function formatBlast(blast, options = {}) {
317
323
  if (unverifiedEdges > 0) {
318
324
  s += blast.expandUnverified
319
325
  ? ` · ${unverifiedEdges} unverified edge${unverifiedEdges !== 1 ? 's' : ''} followed (${possiblyAffected || 0} possibly affected)`
320
- : ` · ${unverifiedEdges} unverified edge${unverifiedEdges !== 1 ? 's' : ''} (--expand-unverified to follow them)`;
326
+ : ` · ${unverifiedEdges} unverified edge${unverifiedEdges !== 1 ? 's' : ''} (${options.expandUnverifiedHint || '--expand-unverified'} to follow them)`;
321
327
  }
322
328
  lines.push(s);
323
329
  } else if (unverifiedEdges > 0) {
324
330
  lines.push(blast.expandUnverified
325
331
  ? `Summary: no confirmed callers · ${unverifiedEdges} unverified edge${unverifiedEdges !== 1 ? 's' : ''} followed (${possiblyAffected || 0} possibly affected)`
326
- : `Summary: no confirmed callers · ${unverifiedEdges} unverified edge${unverifiedEdges !== 1 ? 's' : ''} (--expand-unverified to follow them)`);
332
+ : `Summary: no confirmed callers · ${unverifiedEdges} unverified edge${unverifiedEdges !== 1 ? 's' : ''} (${options.expandUnverifiedHint || '--expand-unverified'} to follow them)`);
333
+ } else if (blast.summary.selfRecursive) {
334
+ lines.push('Summary: No external callers found — this function is self-recursive; entry-point status is not established by the self-edge.');
327
335
  } else {
328
336
  lines.push('Summary: No callers found — this function is a root/entry point.');
329
337
  }
@@ -345,7 +353,7 @@ function formatBlast(blast, options = {}) {
345
353
 
346
354
  const blastFiltered = (blast.treeAccount?.filteredEdges ?? 0);
347
355
  if (blast.includeMethods === false && blastFiltered > 0) {
348
- lines.push(`\nNote: ${blastFiltered} obj.method() caller edge(s) hidden (counted as filtered in the account). Use --include-methods to show them.`);
356
+ lines.push(`\nNote: ${blastFiltered} obj.method() caller edge(s) hidden (counted as filtered in the account). ${options.includeMethodsHint || 'Use --include-methods to show them.'}`);
349
357
  }
350
358
 
351
359
  return lines.join('\n');
@@ -406,6 +414,8 @@ function formatReverseTrace(result, options = {}) {
406
414
  }
407
415
  if (node.entryPoint) {
408
416
  label += ' ★ entry point';
417
+ } else if (node.selfRecursive && (!node.children || node.children.length === 0)) {
418
+ label += ' ↻ self-recursive (no external caller proven)';
409
419
  } else if (node.unverifiedCallerCount > 0 && (!node.children || node.children.length === 0)) {
410
420
  label += ` ⚠ no confirmed callers — ${node.unverifiedCallerCount} unverified`;
411
421
  }
@@ -432,6 +442,8 @@ function formatReverseTrace(result, options = {}) {
432
442
  let rootLabel = result.root;
433
443
  if (result.tree && result.tree.entryPoint) {
434
444
  rootLabel += ' ★ entry point (no callers)';
445
+ } else if (result.tree && result.tree.selfRecursive && result.tree.children.length === 0) {
446
+ rootLabel += ' ↻ self-recursive (no external caller proven)';
435
447
  } else if (result.tree && result.tree.unverifiedCallerCount > 0 && result.tree.children.length === 0) {
436
448
  rootLabel += ` ⚠ no confirmed callers — ${result.tree.unverifiedCallerCount} unverified`;
437
449
  }
@@ -474,6 +486,8 @@ function formatReverseTrace(result, options = {}) {
474
486
  s = `Summary: ${totalEntryPoints} entry point${totalEntryPoints !== 1 ? 's' : ''} reach${totalEntryPoints === 1 ? 'es' : ''} ${result.root}${intermediates > 0 ? ` through ${intermediates} intermediate function${intermediates !== 1 ? 's' : ''}` : ' directly'}`;
475
487
  } else if (unverifiedEdges > 0) {
476
488
  s = `Summary: no confirmed callers — ${unverifiedEdges} unverified edge${unverifiedEdges !== 1 ? 's' : ''} (not an entry-point claim)`;
489
+ } else if (result.summary.selfRecursive) {
490
+ s = 'Summary: No external callers found — this function is self-recursive; no entry point is proven.';
477
491
  } else {
478
492
  s = 'Summary: No callers found — this function is itself an entry point.';
479
493
  }
@@ -499,7 +513,7 @@ function formatReverseTrace(result, options = {}) {
499
513
 
500
514
  const rtFiltered = (result.treeAccount?.filteredEdges ?? 0);
501
515
  if (result.includeMethods === false && rtFiltered > 0) {
502
- lines.push(`\nNote: ${rtFiltered} obj.method() caller edge(s) hidden (counted as filtered in the account). Use --include-methods to show them.`);
516
+ lines.push(`\nNote: ${rtFiltered} obj.method() caller edge(s) hidden (counted as filtered in the account). ${options.includeMethodsHint || 'Use --include-methods to show them.'}`);
503
517
  }
504
518
 
505
519
  return lines.join('\n');
@@ -531,7 +545,7 @@ function formatAffectedTests(result, options = {}) {
531
545
  lines.push('');
532
546
 
533
547
  if (result.testFiles.length === 0) {
534
- lines.push('No test files found for any affected function.');
548
+ lines.push('No confirmed static test links found for any affected function.');
535
549
  } else {
536
550
  const MAX_TEST_FILES = options.all ? Infinity : 30;
537
551
  const displayFiles = result.testFiles.slice(0, MAX_TEST_FILES);
@@ -539,7 +553,7 @@ function formatAffectedTests(result, options = {}) {
539
553
  lines.push(`Test files to run (${summary.totalTestFiles}):`);
540
554
  lines.push('');
541
555
  for (const tf of displayFiles) {
542
- lines.push(` ${tf.file} (covers: ${tf.coveredFunctions.join(', ')})`);
556
+ lines.push(` ${tf.file} (links: ${tf.linkedFunctions.join(', ')})`);
543
557
  // Show up to 5 key matches per file
544
558
  const keyMatches = tf.matches
545
559
  .filter(m => m.matchType === 'call' || m.matchType === 'test-case')
@@ -567,25 +581,26 @@ function formatAffectedTests(result, options = {}) {
567
581
  lines.push(` Additional test files (${pat.length}):`);
568
582
  const MAX_POSSIBLE = options.all ? Infinity : 10;
569
583
  for (const tf of pat.slice(0, MAX_POSSIBLE)) {
570
- lines.push(` ${tf.file} (covers: ${tf.coveredFunctions.join(', ')})`);
584
+ lines.push(` ${tf.file} (links: ${tf.linkedFunctions.join(', ')})`);
571
585
  }
572
586
  if (pat.length > MAX_POSSIBLE) {
573
- lines.push(` ... ${pat.length - MAX_POSSIBLE} more (use --all)`);
587
+ lines.push(` ... ${pat.length - MAX_POSSIBLE} more (${options.allHint || 'use --all'})`);
574
588
  }
575
589
  }
576
590
  }
577
591
 
578
- if (result.uncovered.length > 0) {
592
+ const notStaticallyLinked = result.notStaticallyLinked || [];
593
+ if (notStaticallyLinked.length > 0) {
579
594
  lines.push('');
580
- lines.push(`Uncovered (${result.uncovered.length}): ${result.uncovered.join(', ')}`);
581
- lines.push(' ⚠ These affected functions have no test references');
595
+ lines.push(`Not statically linked (${notStaticallyLinked.length}): ${notStaticallyLinked.join(', ')}`);
596
+ lines.push(' ⚠ No indexed test call/reference path was found; this is not runtime coverage data');
582
597
  }
583
598
 
584
599
  lines.push('');
585
600
  const pct = summary.totalAffected > 0
586
- ? Math.round(summary.coveredFunctions / summary.totalAffected * 100)
601
+ ? Math.round(summary.staticallyLinkedFunctions / summary.totalAffected * 100)
587
602
  : 0;
588
- let summaryLine = `Summary: ${summary.totalAffected} affected → ${summary.totalTestFiles} test files, ${summary.coveredFunctions}/${summary.totalAffected} functions covered (${pct}%)`;
603
+ let summaryLine = `Summary: ${summary.totalAffected} affected → ${summary.totalTestFiles} statically linked test files, ${summary.staticallyLinkedFunctions}/${summary.totalAffected} functions linked (${pct}%)`;
589
604
  if (summary.possiblyAffected > 0) {
590
605
  summaryLine += ` · ${summary.possiblyAffected} possibly affected (unverified chains)`;
591
606
  }