ucn 3.8.23 → 3.8.25

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 (44) hide show
  1. package/.claude/skills/ucn/SKILL.md +114 -11
  2. package/README.md +152 -156
  3. package/cli/index.js +363 -37
  4. package/core/analysis.js +936 -32
  5. package/core/bridge.js +1111 -0
  6. package/core/brief.js +408 -0
  7. package/core/cache.js +105 -5
  8. package/core/callers.js +72 -18
  9. package/core/check.js +200 -0
  10. package/core/discovery.js +57 -34
  11. package/core/entrypoints.js +638 -4
  12. package/core/execute.js +304 -5
  13. package/core/git-enrich.js +130 -0
  14. package/core/graph.js +24 -2
  15. package/core/output/analysis.js +157 -25
  16. package/core/output/brief.js +100 -0
  17. package/core/output/check.js +79 -0
  18. package/core/output/doctor.js +85 -0
  19. package/core/output/endpoints.js +239 -0
  20. package/core/output/extraction.js +2 -0
  21. package/core/output/find.js +126 -39
  22. package/core/output/graph.js +48 -15
  23. package/core/output/refactoring.js +103 -5
  24. package/core/output/reporting.js +63 -23
  25. package/core/output/search.js +110 -17
  26. package/core/output/shared.js +56 -2
  27. package/core/output.js +4 -0
  28. package/core/parser.js +8 -2
  29. package/core/project.js +39 -3
  30. package/core/registry.js +30 -14
  31. package/core/reporting.js +465 -2
  32. package/core/search.js +130 -10
  33. package/core/shared.js +101 -5
  34. package/core/tracing.js +16 -6
  35. package/core/verify.js +982 -95
  36. package/languages/go.js +91 -6
  37. package/languages/html.js +10 -0
  38. package/languages/java.js +151 -35
  39. package/languages/javascript.js +290 -33
  40. package/languages/python.js +78 -11
  41. package/languages/rust.js +267 -12
  42. package/languages/utils.js +315 -3
  43. package/mcp/server.js +91 -16
  44. package/package.json +9 -1
@@ -4,7 +4,44 @@
4
4
  const fs = require('fs');
5
5
  const path = require('path');
6
6
  const { langTraits } = require('../../languages');
7
- const { dynamicImportsNote } = require('./shared');
7
+ const { dynamicImportsNote, formatGitLine } = require('./shared');
8
+
9
+ /**
10
+ * One short sentence (~80 chars) of a docstring, suitable for inline display
11
+ * in caller/callee listings.
12
+ */
13
+ function calleeDocstringSnippet(text) {
14
+ if (!text) return null;
15
+ const trimmed = text.trim();
16
+ const m = trimmed.match(/^(.+?[.!?])(?:\s|$)/);
17
+ let s = m ? m[1] : trimmed;
18
+ if (s.length > 80) s = s.slice(0, 77) + '...';
19
+ return s;
20
+ }
21
+
22
+ /**
23
+ * Render a single-line confidence histogram for caller/callee sections.
24
+ * Returns null when there are <= 1 edges (not informative).
25
+ *
26
+ * @param {{high:number, medium:number, low:number, total:number}|null} h
27
+ * @returns {string|null}
28
+ */
29
+ function formatHistogramLine(h) {
30
+ if (!h || h.total <= 1) return null;
31
+ return ` confidence: ${h.high} high (>0.8), ${h.medium} medium (0.5-0.8), ${h.low} low (<0.5)`;
32
+ }
33
+
34
+ /**
35
+ * Decide whether the formatter should print reachability markers per item.
36
+ * To reduce noise, markers only appear when at least one item is unreachable.
37
+ *
38
+ * @param {Array} items - Caller or callee objects with `reachable` field
39
+ * @returns {boolean}
40
+ */
41
+ function shouldShowReachability(items) {
42
+ if (!items || items.length === 0) return false;
43
+ return items.some(c => c.reachable === false);
44
+ }
8
45
 
9
46
  /** Format context (callers + callees) as JSON */
10
47
  function formatContextJson(context) {
@@ -52,12 +89,15 @@ function formatContextJson(context) {
52
89
  file: context.file,
53
90
  callerCount: callers.length,
54
91
  calleeCount: callees.length,
92
+ callerHistogram: context.callerHistogram || null,
93
+ calleeHistogram: context.calleeHistogram || null,
55
94
  callers: callers.map(c => ({
56
95
  file: c.relativePath || c.file,
57
96
  line: c.line,
58
97
  expression: c.content, // FULL expression
59
98
  callerName: c.callerName,
60
99
  ...(c.confidence != null && { confidence: c.confidence, resolution: c.resolution }),
100
+ ...(c.reachable !== undefined && { reachable: c.reachable }),
61
101
  })),
62
102
  callees: callees.map(c => ({
63
103
  name: c.name,
@@ -67,6 +107,7 @@ function formatContextJson(context) {
67
107
  params: c.params, // FULL params
68
108
  weight: c.weight || 'normal', // Dependency weight: core, setup, utility
69
109
  ...(c.confidence != null && { confidence: c.confidence, resolution: c.resolution }),
110
+ ...(c.reachable !== undefined && { reachable: c.reachable }),
70
111
  })),
71
112
  ...(context.warnings && { warnings: context.warnings })
72
113
  }
@@ -143,8 +184,13 @@ function formatContext(ctx, options = {}) {
143
184
  }
144
185
 
145
186
  // Standard function/method context
146
- lines.push(`Context for ${ctx.function}:`);
147
- lines.push('═'.repeat(60));
187
+ const compact = !!options.compact;
188
+ if (compact) {
189
+ lines.push(`Context: ${ctx.function}`);
190
+ } else {
191
+ lines.push(`Context for ${ctx.function}:`);
192
+ lines.push('═'.repeat(60));
193
+ }
148
194
 
149
195
  if (ctx.meta) {
150
196
  const notes = [];
@@ -169,14 +215,26 @@ function formatContext(ctx, options = {}) {
169
215
 
170
216
  const showConf = options.showConfidence || false;
171
217
  const callers = ctx.callers || [];
172
- lines.push(`\nCALLERS (${callers.length}):`);
218
+ lines.push(`${compact ? '' : '\n'}CALLERS (${callers.length}):`);
219
+ const callerHistLine = showConf ? formatHistogramLine(ctx.callerHistogram) : null;
220
+ if (callerHistLine) lines.push(callerHistLine);
221
+ const showCallerReach = shouldShowReachability(callers);
173
222
  for (const c of callers) {
174
223
  const callerName = c.callerName ? ` [${c.callerName}]` : '';
175
- lines.push(` [${itemNum}] ${c.relativePath}:${c.line}${callerName}`);
176
- lines.push(` ${c.content.trim()}`);
177
- if (showConf && c.confidence != null) {
224
+ if (compact) {
225
+ // One line per caller: "[N] file:line [callerName]: expression"
226
+ const expr = c.content ? c.content.trim().replace(/\s+/g, ' ').slice(0, 100) : '';
227
+ lines.push(` [${itemNum}] ${c.relativePath}:${c.line}${callerName}: ${expr}`);
228
+ } else {
229
+ lines.push(` [${itemNum}] ${c.relativePath}:${c.line}${callerName}`);
230
+ lines.push(` ${c.content.trim()}`);
231
+ }
232
+ if (showConf && c.confidence != null && !compact) {
178
233
  lines.push(` confidence: ${c.confidence.toFixed(2)} (${c.resolution})`);
179
234
  }
235
+ if (showCallerReach && c.reachable === false && !compact) {
236
+ lines.push(' (unreachable from any entry point)');
237
+ }
180
238
  expandable.push({
181
239
  num: itemNum++,
182
240
  type: 'caller',
@@ -196,13 +254,31 @@ function formatContext(ctx, options = {}) {
196
254
  }
197
255
 
198
256
  const callees = ctx.callees || [];
199
- lines.push(`\nCALLEES (${callees.length}):`);
257
+ lines.push(`${compact ? '' : '\n'}CALLEES (${callees.length}):`);
258
+ const calleeHistLine = showConf ? formatHistogramLine(ctx.calleeHistogram) : null;
259
+ if (calleeHistLine && !compact) lines.push(calleeHistLine);
260
+ const showCalleeReach = shouldShowReachability(callees);
200
261
  for (const c of callees) {
201
262
  const weight = c.weight && c.weight !== 'normal' ? ` [${c.weight}]` : '';
202
- lines.push(` [${itemNum}] ${c.name}${weight} - ${c.relativePath}:${c.startLine}`);
203
- if (showConf && c.confidence != null) {
263
+ const returnSuffix = c.returnType ? ` → ${c.returnType}` : '';
264
+ const sideEffects = (c.sideEffects && c.sideEffects.length) ? ` {${c.sideEffects.join(',')}}` : '';
265
+ if (compact) {
266
+ const snip = c.docstring ? calleeDocstringSnippet(c.docstring) : '';
267
+ const docPart = snip ? `: ${snip}` : '';
268
+ lines.push(` [${itemNum}] ${c.name}${returnSuffix}${sideEffects} - ${c.relativePath}:${c.startLine}${docPart}`);
269
+ } else {
270
+ lines.push(` [${itemNum}] ${c.name}${weight}${returnSuffix}${sideEffects} - ${c.relativePath}:${c.startLine}`);
271
+ if (c.docstring) {
272
+ const snip = calleeDocstringSnippet(c.docstring);
273
+ if (snip) lines.push(` "${snip}"`);
274
+ }
275
+ }
276
+ if (showConf && c.confidence != null && !compact) {
204
277
  lines.push(` confidence: ${c.confidence.toFixed(2)} (${c.resolution})`);
205
278
  }
279
+ if (showCalleeReach && c.reachable === false && !compact) {
280
+ lines.push(' (unreachable from any entry point)');
281
+ }
206
282
  expandable.push({
207
283
  num: itemNum++,
208
284
  type: 'callee',
@@ -227,14 +303,15 @@ function formatImpact(impact, options = {}) {
227
303
  return 'Function not found.';
228
304
  }
229
305
 
306
+ const compact = !!options.compact;
230
307
  const lines = [];
231
308
 
232
309
  // Header
233
310
  lines.push(`Impact analysis for ${impact.function}`);
234
- lines.push('═'.repeat(60));
311
+ if (!compact) lines.push('═'.repeat(60));
235
312
  lines.push(`${impact.file}:${impact.startLine}`);
236
- lines.push(impact.signature);
237
- lines.push('');
313
+ if (!compact) lines.push(impact.signature);
314
+ if (!compact) lines.push('');
238
315
 
239
316
  // Summary
240
317
  if (impact.shownCallSites !== undefined && impact.shownCallSites < impact.totalCallSites) {
@@ -245,14 +322,21 @@ function formatImpact(impact, options = {}) {
245
322
  lines.push(` Files affected: ${impact.byFile.length}`);
246
323
 
247
324
  // Patterns
325
+ // BUG-1: also surface structural classification counts (inLoop / inTry /
326
+ // inCallback / inTestCase) — they're already in the JSON shape and in
327
+ // verify text, but were dropped from impact text.
248
328
  const p = impact.patterns;
249
- if (p) {
329
+ if (p && !compact) {
250
330
  const patternParts = [];
251
331
  if (p.constantArgs > 0) patternParts.push(`${p.constantArgs} with literals`);
252
332
  if (p.variableArgs > 0) patternParts.push(`${p.variableArgs} with variables`);
253
333
  if (p.awaitedCalls > 0) patternParts.push(`${p.awaitedCalls} awaited`);
254
334
  if (p.chainedCalls > 0) patternParts.push(`${p.chainedCalls} chained`);
255
335
  if (p.spreadCalls > 0) patternParts.push(`${p.spreadCalls} with spread`);
336
+ if (p.inLoop > 0) patternParts.push(`${p.inLoop} in loop`);
337
+ if (p.inTry > 0) patternParts.push(`${p.inTry} in try`);
338
+ if (p.inCallback > 0) patternParts.push(`${p.inCallback} in callback`);
339
+ if (p.inTestCase > 0) patternParts.push(`${p.inTestCase} in test`);
256
340
  if (patternParts.length > 0) {
257
341
  lines.push(` Patterns: ${patternParts.join(', ')}`);
258
342
  }
@@ -264,16 +348,38 @@ function formatImpact(impact, options = {}) {
264
348
  }
265
349
 
266
350
  // By file
267
- lines.push('');
351
+ if (!compact) lines.push('');
268
352
  lines.push('BY FILE:');
353
+
354
+ // Histogram (over the trust signals collected before truncation)
355
+ const impactHistLine = formatHistogramLine(impact.callerHistogram);
356
+ if (impactHistLine) lines.push(impactHistLine);
357
+
358
+ // Compute reachability marker visibility across ALL sites (not per-file)
359
+ const allSites = impact.byFile.flatMap(g => g.sites);
360
+ const showImpactReach = shouldShowReachability(allSites);
361
+
269
362
  for (const fileGroup of impact.byFile) {
270
- lines.push(`\n${fileGroup.file} (${fileGroup.count} calls)`);
271
- for (const site of fileGroup.sites) {
272
- const caller = site.callerName ? `[${site.callerName}]` : '';
273
- lines.push(` :${site.line} ${caller}`);
274
- lines.push(` ${site.expression}`);
275
- if (site.args && site.args.length > 0) {
276
- lines.push(` args: ${site.args.join(', ')}`);
363
+ if (compact) {
364
+ // One line per call site, prefixed with file: "file (N) line [caller]: expr"
365
+ for (const site of fileGroup.sites) {
366
+ const caller = site.callerName ? ` [${site.callerName}]` : '';
367
+ const expr = site.expression ? site.expression.replace(/\s+/g, ' ').slice(0, 100) : '';
368
+ const reach = (showImpactReach && site.reachable === false) ? ' (unreachable)' : '';
369
+ lines.push(` ${fileGroup.file}:${site.line}${caller}${reach}: ${expr}`);
370
+ }
371
+ } else {
372
+ lines.push(`\n${fileGroup.file} (${fileGroup.count} calls)`);
373
+ for (const site of fileGroup.sites) {
374
+ const caller = site.callerName ? `[${site.callerName}]` : '';
375
+ lines.push(` :${site.line} ${caller}`);
376
+ lines.push(` ${site.expression}`);
377
+ if (site.args && site.args.length > 0) {
378
+ lines.push(` args: ${site.args.join(', ')}`);
379
+ }
380
+ if (showImpactReach && site.reachable === false) {
381
+ lines.push(' (unreachable from any entry point)');
382
+ }
277
383
  }
278
384
  }
279
385
  }
@@ -325,10 +431,12 @@ function formatAbout(about, options = {}) {
325
431
  return lines.join('\n');
326
432
  }
327
433
 
434
+ const compact = !!options.compact;
435
+
328
436
  // Header with signature
329
437
  lines.push(`${sym.name} (${sym.type})`);
330
- lines.push('═'.repeat(60));
331
- lines.push(`${sym.file}:${sym.startLine}-${sym.endLine}`);
438
+ if (!compact) lines.push('═'.repeat(60));
439
+ lines.push(`${sym.file}:${sym.startLine}-${sym.endLine}${sym.handle ? ' → ' + sym.handle : ''}`);
332
440
  if (sym.signature) {
333
441
  lines.push(sym.signature);
334
442
  }
@@ -336,6 +444,12 @@ function formatAbout(about, options = {}) {
336
444
  lines.push(`"${sym.docstring}"`);
337
445
  }
338
446
 
447
+ // Git enrichment (opt-in via --git). Only render when available — non-git
448
+ // dirs and untracked files are skipped silently.
449
+ if (about.git && about.git.available) {
450
+ lines.push(formatGitLine(about.git));
451
+ }
452
+
339
453
  // Warnings (show early for visibility)
340
454
  if (about.warnings && about.warnings.length > 0) {
341
455
  for (const w of about.warnings) {
@@ -362,6 +476,9 @@ function formatAbout(about, options = {}) {
362
476
  } else {
363
477
  lines.push(`CALLERS (${about.callers.total}):`);
364
478
  }
479
+ const aboutCallerHist = showConf ? formatHistogramLine(about.callers.histogram) : null;
480
+ if (aboutCallerHist) lines.push(aboutCallerHist);
481
+ const showAboutCallerReach = shouldShowReachability(about.callers.top);
365
482
  for (const c of about.callers.top) {
366
483
  const caller = c.callerName ? `[${c.callerName}]` : '';
367
484
  lines.push(` ${c.file}:${c.line} ${caller}`);
@@ -369,6 +486,9 @@ function formatAbout(about, options = {}) {
369
486
  if (showConf && c.confidence != null) {
370
487
  lines.push(` confidence: ${c.confidence.toFixed(2)} (${c.resolution})`);
371
488
  }
489
+ if (showAboutCallerReach && c.reachable === false) {
490
+ lines.push(' (unreachable from any entry point)');
491
+ }
372
492
  }
373
493
  }
374
494
 
@@ -381,12 +501,24 @@ function formatAbout(about, options = {}) {
381
501
  } else {
382
502
  lines.push(`CALLEES (${about.callees.total}):`);
383
503
  }
504
+ const aboutCalleeHist = showConf ? formatHistogramLine(about.callees.histogram) : null;
505
+ if (aboutCalleeHist) lines.push(aboutCalleeHist);
506
+ const showAboutCalleeReach = shouldShowReachability(about.callees.top);
384
507
  for (const c of about.callees.top) {
385
508
  const weight = c.weight && c.weight !== 'normal' ? ` [${c.weight}]` : '';
386
- lines.push(` ${c.name}${weight} - ${c.file}:${c.line} (${c.callCount}x)`);
509
+ const returnSuffix = c.returnType ? ` → ${c.returnType}` : '';
510
+ const sideEffects = (c.sideEffects && c.sideEffects.length) ? ` {${c.sideEffects.join(',')}}` : '';
511
+ lines.push(` ${c.name}${weight}${returnSuffix}${sideEffects} - ${c.file}:${c.line} (${c.callCount}x)`);
512
+ if (c.docstring) {
513
+ const snip = calleeDocstringSnippet(c.docstring);
514
+ if (snip) lines.push(` "${snip}"`);
515
+ }
387
516
  if (showConf && c.confidence != null) {
388
517
  lines.push(` confidence: ${c.confidence.toFixed(2)} (${c.resolution})`);
389
518
  }
519
+ if (showAboutCalleeReach && c.reachable === false) {
520
+ lines.push(' (unreachable from any entry point)');
521
+ }
390
522
 
391
523
  // Inline expansion: show first 3 lines of callee code
392
524
  if (expand && root && c.file && c.startLine) {
@@ -0,0 +1,100 @@
1
+ /**
2
+ * core/output/brief.js — Formatter for `brief`.
3
+ */
4
+
5
+ 'use strict';
6
+
7
+ const { renderTypedParams, formatGitLine } = require('./shared');
8
+
9
+ /**
10
+ * Build a typed signature line for a callable symbol.
11
+ * Falls back to the raw `params` string when no type info is available.
12
+ */
13
+ function signatureLine(sym) {
14
+ const parts = [];
15
+ if (sym.modifiers && sym.modifiers.length) parts.push(sym.modifiers.join(' '));
16
+ let sig = sym.name;
17
+ const typed = renderTypedParams(sym);
18
+ // If we have a structured-params array of length 0, the function has no params.
19
+ // Render `()` rather than the legacy `(...)` placeholder.
20
+ const noParams = Array.isArray(sym.paramsStructured) && sym.paramsStructured.length === 0;
21
+ let paramText;
22
+ if (typed != null) paramText = typed;
23
+ else if (noParams) paramText = '';
24
+ else if (sym.params != null && sym.params !== '...') paramText = sym.params;
25
+ else paramText = '...';
26
+ sig += `(${paramText})`;
27
+ if (sym.returnType) sig += `: ${sym.returnType}`;
28
+ parts.push(sig);
29
+ return parts.join(' ');
30
+ }
31
+
32
+ /**
33
+ * Format a brief result for human/agent consumption.
34
+ *
35
+ * Example output:
36
+ * resolveSymbol(name: string, options: object = {}): {def, definitions, warnings}
37
+ * core/project.js:808-936 (129 lines)
38
+ * "Resolve a symbol name to the best matching definition."
39
+ * async: no, side_effects: [fs], complexity: branches=12, depth=3
40
+ */
41
+ function formatBrief(result) {
42
+ if (!result) return 'Symbol not found.';
43
+ const lines = [];
44
+ const sym = result.symbol || {};
45
+
46
+ if (result.kind === 'type') {
47
+ lines.push(`${sym.type} ${sym.name}`);
48
+ const lineLabel = `${result.lineCount} line${result.lineCount === 1 ? '' : 's'}`;
49
+ const memberPart = result.memberCount > 0
50
+ ? `, ${result.memberCount} member${result.memberCount === 1 ? '' : 's'}`
51
+ : '';
52
+ lines.push(` ${sym.file}:${sym.startLine}-${sym.endLine} (${lineLabel}${memberPart})`);
53
+ if (sym.handle) lines.push(` handle: ${sym.handle}`);
54
+ if (sym.docstring) lines.push(` "${sym.docstring}"`);
55
+ const gitLineType = formatGitLine(result.git);
56
+ if (gitLineType) lines.push(` ${gitLineType}`);
57
+ return lines.join('\n');
58
+ }
59
+
60
+ // Header line: signature
61
+ lines.push(signatureLine(sym));
62
+ // Location + line count
63
+ lines.push(` ${sym.file}:${sym.startLine}-${sym.endLine} (${result.lineCount || 0} line${result.lineCount === 1 ? '' : 's'})`);
64
+ if (sym.handle) lines.push(` handle: ${sym.handle}`);
65
+ if (sym.docstring) lines.push(` "${sym.docstring}"`);
66
+ if (sym.className) lines.push(` in class ${sym.className}`);
67
+ const gitLineFn = formatGitLine(result.git);
68
+ if (gitLineFn) lines.push(` ${gitLineFn}`);
69
+
70
+ // Async/generator/decorators
71
+ const flags = [];
72
+ flags.push(`async: ${result.isAsync ? 'yes' : 'no'}`);
73
+ if (result.isGenerator) flags.push('generator: yes');
74
+ if (sym.decorators && sym.decorators.length) flags.push(`decorators: [${sym.decorators.join(', ')}]`);
75
+
76
+ // Side effects
77
+ const se = (result.sideEffects && result.sideEffects.length) ? result.sideEffects : ['none'];
78
+ flags.push(`side_effects: [${se.join(', ')}]`);
79
+
80
+ // Complexity
81
+ const c = result.complexity || {};
82
+ const cParts = [];
83
+ if (c.branches != null) cParts.push(`branches=${c.branches}`);
84
+ if (c.maxDepth != null) cParts.push(`depth=${c.maxDepth}`);
85
+ flags.push(`complexity: ${cParts.join(', ')}`);
86
+
87
+ lines.push(' ' + flags.join(' | '));
88
+ if (result.error) lines.push(` Note: ${result.error}`);
89
+ return lines.join('\n');
90
+ }
91
+
92
+ function formatBriefJson(result) {
93
+ if (!result) return JSON.stringify({ found: false, error: 'Symbol not found' }, null, 2);
94
+ return JSON.stringify({ found: true, ...result }, null, 2);
95
+ }
96
+
97
+ module.exports = {
98
+ formatBrief,
99
+ formatBriefJson,
100
+ };
@@ -0,0 +1,79 @@
1
+ /**
2
+ * core/output/check.js — Pre-commit check formatter.
3
+ */
4
+
5
+ 'use strict';
6
+
7
+ function formatCheck(result) {
8
+ if (!result) return 'No check result.';
9
+ if (result.empty) {
10
+ return `Pre-commit Check (${result.base}${result.staged ? ', staged' : ''})\n${'═'.repeat(60)}\nNo changes to analyze${result.reason ? ` (${result.reason})` : ''}.`;
11
+ }
12
+
13
+ const lines = [];
14
+ lines.push(`Pre-commit Check vs ${result.base}${result.staged ? ' (staged)' : ''}`);
15
+ lines.push('═'.repeat(60));
16
+
17
+ // Changed functions section
18
+ const items = result.changed || [];
19
+ if (result.truncated) {
20
+ lines.push(`Changed: ${items.length} of ${result.totalChanged} functions`);
21
+ } else {
22
+ lines.push(`Changed: ${items.length} function${items.length === 1 ? '' : 's'}`);
23
+ }
24
+ if (items.length === 0) {
25
+ lines.push(' (none — only non-function changes)');
26
+ } else {
27
+ for (const it of items) {
28
+ const tags = [];
29
+ if (it.kind && it.kind !== 'changed') tags.push(it.kind.toUpperCase());
30
+ if (it.signatureMismatches > 0) tags.push(`SIG-DRIFT(${it.signatureMismatches})`);
31
+ if (it.orphan) tags.push('ORPHAN');
32
+ const tagStr = tags.length ? ' [' + tags.join(', ') + ']' : '';
33
+ const callers = it.callerCount != null ? `${it.callerCount} caller${it.callerCount === 1 ? '' : 's'}` : '';
34
+ lines.push(` ${it.name} (${it.file}:${it.line})${tagStr} ${callers}`);
35
+ if (it.mismatches && it.mismatches.length > 0) {
36
+ for (const m of it.mismatches.slice(0, 3)) {
37
+ const where = m.file ? ` at ${m.file}:${m.line}` : '';
38
+ const reason = m.expected
39
+ ? `expected ${m.expected}, got ${m.actual}`
40
+ : (m.reason || 'arity mismatch');
41
+ lines.push(` ↳ ${reason}${where}`);
42
+ }
43
+ }
44
+ }
45
+ }
46
+
47
+ // Tests
48
+ lines.push('');
49
+ if (result.testFiles && result.testFiles.length > 0) {
50
+ lines.push(`Tests potentially affected: ${result.totalTests || result.testFiles.length} in ${result.testFiles.length} file${result.testFiles.length === 1 ? '' : 's'}`);
51
+ for (const tf of result.testFiles.slice(0, 8)) {
52
+ const cnt = tf.testCount ? ` (${tf.testCount})` : '';
53
+ lines.push(` ${tf.file}${cnt}`);
54
+ }
55
+ if (result.testFiles.length > 8) {
56
+ lines.push(` ... and ${result.testFiles.length - 8} more`);
57
+ }
58
+ } else {
59
+ lines.push('Tests: none detected for changed functions');
60
+ }
61
+
62
+ // Action items
63
+ if (result.actions && result.actions.length > 0) {
64
+ lines.push('');
65
+ lines.push('Action items:');
66
+ for (const a of result.actions) {
67
+ const marker = a.severity === 'warn' ? '⚠' : a.severity === 'error' ? '✖' : '·';
68
+ lines.push(` ${marker} ${a.message}`);
69
+ }
70
+ }
71
+
72
+ return lines.join('\n');
73
+ }
74
+
75
+ function formatCheckJson(result) {
76
+ return JSON.stringify(result, null, 2);
77
+ }
78
+
79
+ module.exports = { formatCheck, formatCheckJson };
@@ -0,0 +1,85 @@
1
+ /**
2
+ * core/output/doctor.js — Project trust report formatter.
3
+ */
4
+
5
+ 'use strict';
6
+
7
+ function pad(n, width = 4) {
8
+ return String(n).padStart(width);
9
+ }
10
+
11
+ function formatDoctor(result) {
12
+ if (!result) return 'No project to analyze.';
13
+ const lines = [];
14
+ lines.push(`UCN Trust Report — ${result.root}`);
15
+ lines.push('═'.repeat(60));
16
+ lines.push(`Index: ${result.files.scanned} file${result.files.scanned === 1 ? '' : 's'}, ${result.symbols} symbol${result.symbols === 1 ? '' : 's'}`);
17
+
18
+ if (result.filter) lines.push(`Filter: ${result.filter}`);
19
+
20
+ // Languages
21
+ const langEntries = Object.entries(result.languages || {}).sort((a, b) => b[1].files - a[1].files);
22
+ if (langEntries.length) {
23
+ const totalFiles = langEntries.reduce((s, [, v]) => s + v.files, 0) || 1;
24
+ const langStr = langEntries.map(([name, v]) => `${name} (${Math.round(v.files / totalFiles * 100)}%)`).join(', ');
25
+ lines.push(`Languages: ${langStr}`);
26
+ }
27
+
28
+ // Cache state
29
+ if (result.cache) {
30
+ const state = result.cache.fresh === true ? 'fresh' : result.cache.fresh === false ? 'stale' : 'unknown';
31
+ const buildHint = result.cache.buildMs ? `, ${result.cache.buildMs}ms build` : '';
32
+ lines.push(`Cache: ${state}${buildHint}`);
33
+ }
34
+
35
+ // Coverage (if computed)
36
+ if (result.coverage && result.coverage.total > 0) {
37
+ lines.push('');
38
+ lines.push('Resolution coverage (sampled):');
39
+ const c = result.coverage;
40
+ const total = c.total || 1;
41
+ lines.push(` High confidence (>0.8): ${c.high} (${(c.high / total * 100).toFixed(1)}%)`);
42
+ lines.push(` Medium (0.5-0.8): ${c.medium} (${(c.medium / total * 100).toFixed(1)}%)`);
43
+ lines.push(` Low (<0.5): ${c.low} (${(c.low / total * 100).toFixed(1)}%)`);
44
+ lines.push(` Sampled ${c.sampled} symbols → ${c.total} edges examined`);
45
+ } else if (result.coverage) {
46
+ lines.push('');
47
+ lines.push('Resolution coverage: no edges in sample — likely a small or isolated project.');
48
+ } else {
49
+ lines.push('');
50
+ lines.push('Resolution coverage: not computed (use --deep for sampled analysis)');
51
+ }
52
+
53
+ // Blind spots
54
+ lines.push('');
55
+ lines.push('Blind spots:');
56
+ const bs = result.blindSpots || {};
57
+ const bsLines = [
58
+ ['Dynamic imports', bs.dynamicImports],
59
+ ['Eval/exec calls', bs.evalCalls],
60
+ ['Reflection', bs.reflection],
61
+ ['Parse failures', bs.parseFailures],
62
+ ];
63
+ let anyBlindSpot = false;
64
+ for (const [label, info] of bsLines) {
65
+ if (info && info.count > 0) {
66
+ anyBlindSpot = true;
67
+ const sample = info.files.slice(0, 3).map(f => ` - ${f}`).join('\n');
68
+ const more = info.files.length > 3 ? `\n ... and ${info.files.length - 3} more` : '';
69
+ lines.push(` ${label}: ${info.count} in ${info.files.length} file${info.files.length === 1 ? '' : 's'}`);
70
+ if (sample) lines.push(sample + more);
71
+ }
72
+ }
73
+ if (!anyBlindSpot) lines.push(' (none detected)');
74
+
75
+ // Verdict
76
+ lines.push('');
77
+ lines.push(`Trust level: ${result.trust}${result.trustReason ? ' — ' + result.trustReason : ''}`);
78
+ return lines.join('\n');
79
+ }
80
+
81
+ function formatDoctorJson(result) {
82
+ return JSON.stringify(result, null, 2);
83
+ }
84
+
85
+ module.exports = { formatDoctor, formatDoctorJson };