ucn 4.0.1 → 4.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/.claude/skills/ucn/SKILL.md +31 -7
  2. package/README.md +89 -50
  3. package/cli/index.js +199 -94
  4. package/core/account.js +3 -1
  5. package/core/analysis.js +334 -316
  6. package/core/bridge.js +13 -8
  7. package/core/cache.js +109 -19
  8. package/core/callers.js +969 -77
  9. package/core/check.js +19 -8
  10. package/core/deadcode.js +368 -40
  11. package/core/discovery.js +31 -11
  12. package/core/entrypoints.js +149 -17
  13. package/core/execute.js +330 -43
  14. package/core/graph-build.js +61 -10
  15. package/core/graph.js +282 -61
  16. package/core/imports.js +72 -3
  17. package/core/output/analysis-ext.js +70 -10
  18. package/core/output/analysis.js +67 -33
  19. package/core/output/check.js +4 -1
  20. package/core/output/doctor.js +13 -3
  21. package/core/output/endpoints.js +8 -3
  22. package/core/output/extraction.js +12 -1
  23. package/core/output/find.js +23 -9
  24. package/core/output/graph.js +32 -9
  25. package/core/output/refactoring.js +147 -49
  26. package/core/output/reporting.js +104 -5
  27. package/core/output/search.js +30 -3
  28. package/core/output/shared.js +31 -4
  29. package/core/output/tracing.js +22 -11
  30. package/core/parser.js +8 -6
  31. package/core/project.js +167 -7
  32. package/core/registry.js +20 -16
  33. package/core/reporting.js +220 -84
  34. package/core/search.js +270 -55
  35. package/core/shared.js +285 -1
  36. package/core/stacktrace.js +23 -1
  37. package/core/tracing.js +278 -36
  38. package/core/verify.js +352 -349
  39. package/languages/go.js +29 -17
  40. package/languages/index.js +56 -0
  41. package/languages/java.js +133 -16
  42. package/languages/javascript.js +215 -27
  43. package/languages/python.js +113 -47
  44. package/languages/rust.js +89 -26
  45. package/languages/utils.js +35 -7
  46. package/mcp/server.js +72 -31
  47. package/package.json +4 -1
@@ -11,9 +11,12 @@ function formatImports(imports, filePath) {
11
11
  if (imports?.error) return formatFileError(imports, filePath);
12
12
  const lines = [`Imports in ${filePath}:\n`];
13
13
 
14
- const internal = imports.filter(i => !i.isExternal && !i.isDynamic);
15
- const external = imports.filter(i => i.isExternal && !i.isDynamic);
16
- const dynamic = imports.filter(i => i.isDynamic);
14
+ // A string-literal dynamic import that resolved is a project-internal
15
+ // dependency (isDynamic marks the MECHANISM); only unresolved dynamic
16
+ // imports belong in the DYNAMIC (unresolved) section.
17
+ const internal = imports.filter(i => i.resolved);
18
+ const external = imports.filter(i => !i.resolved && !i.isDynamic);
19
+ const dynamic = imports.filter(i => !i.resolved && i.isDynamic);
17
20
 
18
21
  if (internal.length > 0) {
19
22
  lines.push('INTERNAL:');
@@ -50,6 +53,10 @@ function formatImports(imports, filePath) {
50
53
  }
51
54
  }
52
55
 
56
+ if (internal.length === 0 && external.length === 0 && dynamic.length === 0) {
57
+ lines.push(' (none)');
58
+ }
59
+
53
60
  return lines.join('\n');
54
61
  }
55
62
 
@@ -66,7 +73,8 @@ function formatImportsJson(imports, filePath) {
66
73
  names: i.names,
67
74
  type: i.type,
68
75
  resolved: i.resolved || null,
69
- isDynamic: !!i.isDynamic
76
+ isDynamic: !!i.isDynamic,
77
+ line: i.line ?? null
70
78
  }))
71
79
  }, null, 2);
72
80
  }
@@ -196,8 +204,15 @@ function formatApi(symbols, filePath) {
196
204
  function formatApiJson(symbols, filePath) {
197
205
  const { formatSymbolHandle } = require('../shared');
198
206
  const deduped = dedupExportSymbols(symbols);
207
+ // Under --limit the handler slices and attaches the full-set size
208
+ // (fix #251, the #242/#247 shape) — meta.total describes the FULL set.
209
+ const li = symbols.limitInfo;
199
210
  return JSON.stringify({
200
- meta: { command: 'api', count: deduped.length },
211
+ meta: {
212
+ command: 'api',
213
+ count: deduped.length,
214
+ ...(li && { total: li.total, truncated: true }),
215
+ },
201
216
  ...(filePath && { file: filePath }),
202
217
  data: {
203
218
  exportCount: deduped.length,
@@ -247,7 +262,11 @@ function formatGraph(graph, options = {}) {
247
262
 
248
263
  const showAll = options.showAll || false;
249
264
  const maxChildren = showAll ? Infinity : 8;
250
- const maxDepth = options.maxDepth !== undefined ? options.maxDepth : Infinity;
265
+ const maxDepth = options.maxDepth !== undefined ? options.maxDepth
266
+ : (graph.maxDepth !== undefined ? graph.maxDepth : Infinity);
267
+ // The engine stops traversal at ITS maxDepth, so the data never reaches
268
+ // the formatter's own depth check — the engine reports the cut instead.
269
+ const engineTruncated = !!graph.depthTruncated;
251
270
 
252
271
  function printTree(nodes, edges, rootFile) {
253
272
  const visited = new Set(); // all nodes ever printed (for diamond dep detection)
@@ -310,7 +329,7 @@ function formatGraph(graph, options = {}) {
310
329
  let totalTruncated = 0;
311
330
  let anyDepthLimited = false;
312
331
 
313
- lines.push(`\nIMPORTS (what this file depends on): ${importCount} files`);
332
+ lines.push(`\nIMPORTS (what this file depends on): ${importCount} file${importCount === 1 ? '' : 's'}`);
314
333
  if (importCount > 0) {
315
334
  const r = printTree(graph.imports.nodes, graph.imports.edges, graph.root);
316
335
  totalTruncated += r.truncatedNodes;
@@ -319,7 +338,7 @@ function formatGraph(graph, options = {}) {
319
338
  lines.push(' (none)');
320
339
  }
321
340
 
322
- lines.push(`\nIMPORTERS (what depends on this file): ${importerCount} files`);
341
+ lines.push(`\nIMPORTERS (what depends on this file): ${importerCount} file${importerCount === 1 ? '' : 's'}`);
323
342
  if (importerCount > 0) {
324
343
  const r = printTree(graph.importers.nodes, graph.importers.edges, graph.root);
325
344
  totalTruncated += r.truncatedNodes;
@@ -328,6 +347,7 @@ function formatGraph(graph, options = {}) {
328
347
  lines.push(' (none)');
329
348
  }
330
349
 
350
+ anyDepthLimited = anyDepthLimited || engineTruncated;
331
351
  if (anyDepthLimited || totalTruncated > 0) {
332
352
  lines.push('\n' + '─'.repeat(60));
333
353
  if (anyDepthLimited) {
@@ -343,7 +363,8 @@ function formatGraph(graph, options = {}) {
343
363
  lines.push(`Dependency graph for ${rootRelPath}`);
344
364
  lines.push('═'.repeat(60));
345
365
 
346
- const { truncatedNodes, depthLimited } = printTree(graph.nodes, graph.edges, graph.root);
366
+ const { truncatedNodes, depthLimited: fmtDepthLimited } = printTree(graph.nodes, graph.edges, graph.root);
367
+ const depthLimited = fmtDepthLimited || engineTruncated;
347
368
 
348
369
  if (depthLimited || truncatedNodes > 0) {
349
370
  lines.push('\n' + '─'.repeat(60));
@@ -372,6 +393,8 @@ function formatGraphJson(graph) {
372
393
  nodes: graph.nodes,
373
394
  edges: graph.edges
374
395
  };
396
+ if (graph.maxDepth !== undefined) result.maxDepth = graph.maxDepth;
397
+ if (graph.depthTruncated) result.depthTruncated = true;
375
398
  if (graph.imports) result.imports = graph.imports;
376
399
  if (graph.importers) result.importers = graph.importers;
377
400
  return JSON.stringify(result, null, 2);
@@ -2,6 +2,30 @@
2
2
  * core/output/refactoring.js - Verify/plan/stacktrace formatters
3
3
  */
4
4
 
5
+ const { unverifiedReasonLabel, advisoryLine } = require('./shared');
6
+ const { formatAccountLines } = require('./analysis');
7
+
8
+ /**
9
+ * Render the v4 unverified band shared by verify and plan: capped one-liners
10
+ * with reasons. Returns [] when the band is empty.
11
+ */
12
+ function _unverifiedBandLines(sites, cap = 10) {
13
+ if (!sites || sites.length === 0) return [];
14
+ const lines = [];
15
+ lines.push('');
16
+ lines.push(`UNVERIFIED CALL SITES (${sites.length}) — call syntax, no binding/receiver evidence; review manually:`);
17
+ for (const u of sites.slice(0, cap)) {
18
+ const caller = u.callerName ? ` [${u.callerName}]` : '';
19
+ const reason = u.reason ? ` (${unverifiedReasonLabel(u)})` : '';
20
+ const expr = u.expression ? `: ${u.expression.replace(/\s+/g, ' ').slice(0, 100)}` : '';
21
+ lines.push(` ${u.file}:${u.line}${caller}${expr}${reason}`);
22
+ }
23
+ if (sites.length > cap) {
24
+ lines.push(` (+${sites.length - cap} more unverified)`);
25
+ }
26
+ return lines;
27
+ }
28
+
5
29
  /**
6
30
  * Format plan command output - text
7
31
  * Shows before/after signatures and all changes needed
@@ -14,7 +38,12 @@ function formatPlan(plan, options = {}) {
14
38
  return `Function "${plan.function}" not found.`;
15
39
  }
16
40
  if (plan.error) {
17
- return `Error: ${plan.error}\nCurrent parameters: ${plan.currentParams?.join(', ') || 'none'}`;
41
+ // Only show the parameter list when the error result carries one
42
+ // unrelated errors (multi-op rejection) don't, and "none" would be
43
+ // wrong for functions that have parameters.
44
+ return plan.currentParams
45
+ ? `Error: ${plan.error}\nCurrent parameters: ${plan.currentParams.join(', ') || 'none'}`
46
+ : `Error: ${plan.error}`;
18
47
  }
19
48
 
20
49
  const lines = [];
@@ -58,6 +87,15 @@ function formatPlan(plan, options = {}) {
58
87
  }
59
88
  }
60
89
 
90
+ // v4 tiered contract: candidates without evidence are not planned but
91
+ // stay visible — a rename that misses one of these breaks at runtime.
92
+ lines.push(..._unverifiedBandLines(plan.unverifiedSites));
93
+ const planAccountLines = formatAccountLines(plan.account);
94
+ if (planAccountLines.length > 0) {
95
+ lines.push('');
96
+ lines.push(...planAccountLines);
97
+ }
98
+
61
99
  return lines.join('\n');
62
100
  }
63
101
 
@@ -83,22 +121,34 @@ function formatPlanJson(plan) {
83
121
  }, null, 2);
84
122
  }
85
123
 
124
+ // Standard {meta, data} envelope (fix #230 — plan was one of three
125
+ // commands still emitting a bare result object).
86
126
  return JSON.stringify({
87
- found: true,
88
- function: plan.function,
89
- file: plan.file,
90
- startLine: plan.startLine,
91
- operation: plan.operation,
92
- before: { signature: plan.before.signature },
93
- after: { signature: plan.after.signature },
94
- totalChanges: plan.totalChanges,
95
- filesAffected: plan.filesAffected,
96
- changes: plan.changes.map(c => ({
97
- file: c.file,
98
- line: c.line,
99
- expression: c.expression,
100
- suggestion: c.suggestion
101
- }))
127
+ meta: {
128
+ complete: (plan.unverifiedCount || 0) === 0,
129
+ unverified: plan.unverifiedCount || 0,
130
+ ...(plan.account && { account: plan.account }),
131
+ },
132
+ data: {
133
+ found: true,
134
+ function: plan.function,
135
+ file: plan.file,
136
+ startLine: plan.startLine,
137
+ operation: plan.operation,
138
+ before: { signature: plan.before.signature },
139
+ after: { signature: plan.after.signature },
140
+ totalChanges: plan.totalChanges,
141
+ filesAffected: plan.filesAffected,
142
+ changes: plan.changes.map(c => ({
143
+ file: c.file,
144
+ line: c.line,
145
+ expression: c.expression,
146
+ suggestion: c.suggestion
147
+ })),
148
+ // v4 tiered contract passthrough
149
+ unverifiedCount: plan.unverifiedCount,
150
+ unverifiedSites: plan.unverifiedSites,
151
+ },
102
152
  }, null, 2);
103
153
  }
104
154
 
@@ -139,9 +189,9 @@ function formatVerify(result, options = {}) {
139
189
  lines.push(result.signature);
140
190
  lines.push('');
141
191
 
142
- // Expected args
192
+ // Expected args (max null = unbounded rest param)
143
193
  const { min, max } = result.expectedArgs;
144
- const expectedStr = min === max ? `${min}` : `${min}-${max}`;
194
+ const expectedStr = max == null ? `${min}+` : (min === max ? `${min}` : `${min}-${max}`);
145
195
  lines.push(`Expected arguments: ${expectedStr}`);
146
196
  lines.push('');
147
197
 
@@ -164,6 +214,9 @@ function formatVerify(result, options = {}) {
164
214
  lines.push(` Valid: ${result.valid}`);
165
215
  lines.push(` Mismatches: ${result.mismatches}`);
166
216
  lines.push(` Uncertain: ${result.uncertain}`);
217
+ if (result.unverifiedCount > 0) {
218
+ lines.push(` Unverified (not arg-checked): ${result.unverifiedCount}`);
219
+ }
167
220
  if (result.scopeWarning) {
168
221
  lines.push(` Note: ${result.scopeWarning.hint}`);
169
222
  }
@@ -206,6 +259,15 @@ function formatVerify(result, options = {}) {
206
259
  }
207
260
  }
208
261
 
262
+ // v4 tiered contract: unverified band (distinct from UNCERTAIN above,
263
+ // which is arg-parse uncertainty among CONFIRMED sites).
264
+ lines.push(..._unverifiedBandLines(result.unverifiedSites));
265
+ const accountLines = formatAccountLines(result.account);
266
+ if (accountLines.length > 0) {
267
+ lines.push('');
268
+ lines.push(...accountLines);
269
+ }
270
+
209
271
  return lines.join('\n');
210
272
  }
211
273
 
@@ -220,35 +282,49 @@ function formatVerifyJson(result) {
220
282
  return JSON.stringify({ found: false, error: `Function "${result.function}" not found.` }, null, 2);
221
283
  }
222
284
 
285
+ // Standard {meta, data} envelope (fix #230 — verify was one of three
286
+ // commands still emitting a bare result object). Completeness signals
287
+ // and the account live in meta, like context/impact/about.
223
288
  return JSON.stringify({
224
- found: true,
225
- function: result.function,
226
- file: result.file,
227
- startLine: result.startLine,
228
- signature: result.signature,
229
- expectedArgs: result.expectedArgs,
230
- totalCalls: result.totalCalls,
231
- valid: result.valid,
232
- mismatches: result.mismatches,
233
- uncertain: result.uncertain,
234
- // Feature A/B: surface aggregate patterns and per-site flags.
235
- patterns: result.patterns,
236
- mismatchDetails: result.mismatchDetails.map(m => ({
237
- file: m.file,
238
- line: m.line,
239
- expression: m.expression,
240
- expected: m.expected,
241
- actual: m.actual,
242
- args: m.args || [],
243
- patterns: m.patterns,
244
- })),
245
- uncertainDetails: result.uncertainDetails.map(u => ({
246
- file: u.file,
247
- line: u.line,
248
- expression: u.expression,
249
- reason: u.reason,
250
- patterns: u.patterns,
251
- }))
289
+ meta: {
290
+ complete: result.uncertain === 0 && (result.unverifiedCount || 0) === 0,
291
+ uncertain: result.uncertain,
292
+ unverified: result.unverifiedCount || 0,
293
+ ...(result.account && { account: result.account }),
294
+ },
295
+ data: {
296
+ found: true,
297
+ function: result.function,
298
+ file: result.file,
299
+ startLine: result.startLine,
300
+ signature: result.signature,
301
+ expectedArgs: result.expectedArgs,
302
+ totalCalls: result.totalCalls,
303
+ valid: result.valid,
304
+ mismatches: result.mismatches,
305
+ uncertain: result.uncertain,
306
+ // Feature A/B: surface aggregate patterns and per-site flags.
307
+ patterns: result.patterns,
308
+ mismatchDetails: result.mismatchDetails.map(m => ({
309
+ file: m.file,
310
+ line: m.line,
311
+ expression: m.expression,
312
+ expected: m.expected,
313
+ actual: m.actual,
314
+ args: m.args || [],
315
+ patterns: m.patterns,
316
+ })),
317
+ uncertainDetails: result.uncertainDetails.map(u => ({
318
+ file: u.file,
319
+ line: u.line,
320
+ expression: u.expression,
321
+ reason: u.reason,
322
+ patterns: u.patterns,
323
+ })),
324
+ // v4 tiered contract passthrough
325
+ unverifiedCount: result.unverifiedCount,
326
+ unverifiedSites: result.unverifiedSites,
327
+ },
252
328
  }, null, 2);
253
329
  }
254
330
 
@@ -262,8 +338,13 @@ function formatStackTrace(result) {
262
338
  }
263
339
 
264
340
  const lines = [];
265
- lines.push(`Stack trace: ${result.frameCount} frames`);
341
+ lines.push(`Stack trace: ${result.frameCount} frame${result.frameCount === 1 ? '' : 's'}`);
266
342
  lines.push('═'.repeat(60));
343
+ const stAdvisory = advisoryLine(result.advisory);
344
+ if (stAdvisory) lines.push(stAdvisory);
345
+ if (result.skippedFrames > 0) {
346
+ lines.push(`(${result.skippedFrames} frame(s) without file:line — Unknown Source, native — skipped)`);
347
+ }
267
348
 
268
349
  for (let i = 0; i < result.frames.length; i++) {
269
350
  const frame = result.frames[i];
@@ -289,6 +370,12 @@ function formatStackTrace(result) {
289
370
  lines.push('');
290
371
  lines.push(` In: ${frame.functionInfo.name}(${frame.functionInfo.params || ''})`);
291
372
  lines.push(` Range: ${frame.functionInfo.startLine}-${frame.functionInfo.endLine}`);
373
+ // The engine computed this and both surfaces dropped it —
374
+ // an out-of-range line got an unqualified attribution
375
+ // (fix #251).
376
+ if (frame.functionInfo.lineMismatch) {
377
+ lines.push(` ⚠ line ${frame.line} is outside this range — nearest same-name definition shown`);
378
+ }
292
379
  }
293
380
  } else {
294
381
  lines.push(` ${frame.file}:${frame.line} (file not found in project)`);
@@ -304,16 +391,26 @@ function formatStackTrace(result) {
304
391
  */
305
392
  function formatStackTraceJson(result) {
306
393
  if (!result || result.frameCount === 0) {
307
- return JSON.stringify({ frameCount: 0, frames: [] }, null, 2);
394
+ return JSON.stringify({
395
+ frameCount: 0,
396
+ frames: [],
397
+ ...(result && result.advisory && { advisory: result.advisory }),
398
+ }, null, 2);
308
399
  }
309
400
 
310
401
  return JSON.stringify({
311
402
  frameCount: result.frameCount,
403
+ // The v4 two-tier surface: advisory commands self-label in JSON too
404
+ // (fix #251 — the result carried this and the text rendered it, but
405
+ // the JSON rebuild dropped it in every language cell).
406
+ ...(result.advisory && { advisory: result.advisory }),
407
+ ...(result.skippedFrames > 0 && { skippedFrames: result.skippedFrames }),
312
408
  frames: result.frames.map(f => ({
313
409
  function: f.function || null,
314
410
  file: f.file,
315
411
  line: f.line,
316
412
  found: !!f.found,
413
+ ...(f.confidence !== undefined && { confidence: f.confidence }),
317
414
  ...(f.resolvedFile && { resolvedFile: f.resolvedFile }),
318
415
  ...(f.context && { context: f.context.map(c => ({
319
416
  line: c.line,
@@ -324,7 +421,8 @@ function formatStackTraceJson(result) {
324
421
  name: f.functionInfo.name,
325
422
  params: f.functionInfo.params || null,
326
423
  startLine: f.functionInfo.startLine,
327
- endLine: f.functionInfo.endLine
424
+ endLine: f.functionInfo.endLine,
425
+ ...(f.functionInfo.lineMismatch && { lineMismatch: true }),
328
426
  } }),
329
427
  ...(f.raw && { raw: f.raw })
330
428
  }))
@@ -236,7 +236,10 @@ function formatDeadcode(results, options = {}) {
236
236
  const extStr = item.externalContract
237
237
  ? ' [reachable via out-of-tree base — external contract, not dead]'
238
238
  : '';
239
- lines.push(` ${lineRange(item.startLine, item.endLine)} ${item.name} (${item.type})${exported}${hintStr}${declStr}${extStr}`);
239
+ // The only references are the symbol's own recursion (fix #253c).
240
+ const recStr = item.selfRecursive ? ' [only self-references — recursive]' : '';
241
+ const displayName = item.className ? `${item.className}.${item.name}` : item.name;
242
+ lines.push(` ${lineRange(item.startLine, item.endLine)} ${displayName} (${item.type})${exported}${hintStr}${declStr}${extStr}${recStr}`);
240
243
  }
241
244
 
242
245
  if (hidden > 0) {
@@ -252,7 +255,7 @@ function formatDeadcode(results, options = {}) {
252
255
  lines.push(`\n${decoratedHint}`);
253
256
  }
254
257
  if (results.excludedExported > 0) {
255
- const exportedHint = options.exportedHint || `${results.excludedExported} exported symbol(s) excluded (all have callers). Use --include-exported to audit them.`;
258
+ const exportedHint = options.exportedHint || `${results.excludedExported} exported symbol(s) excluded from the audit (public API may have external callers). Use --include-exported to audit them.`;
256
259
  lines.push(`\n${exportedHint}`);
257
260
  }
258
261
  if (results.excludedExternalContract > 0) {
@@ -272,10 +275,18 @@ function formatDeadcode(results, options = {}) {
272
275
  */
273
276
  function formatDeadcodeJson(results) {
274
277
  const { formatSymbolHandle } = require('../shared');
278
+ // Under --limit the handler slices the array and attaches the full-set
279
+ // size (fix #242) — the payload itself must say it is truncated.
280
+ const li = results.limitInfo;
275
281
  return JSON.stringify({
276
- meta: { command: 'deadcode', count: results.length },
282
+ meta: {
283
+ command: 'deadcode',
284
+ count: results.length,
285
+ ...(li && { total: li.total, truncated: true }),
286
+ },
277
287
  data: {
278
288
  count: results.length,
289
+ ...(li && { total: li.total, truncated: true }),
279
290
  ...(results.excludedExported > 0 && { excludedExported: results.excludedExported }),
280
291
  ...(results.excludedDecorated > 0 && { excludedDecorated: results.excludedDecorated }),
281
292
  ...(results.excludedExternalContract > 0 && { excludedExternalContract: results.excludedExternalContract }),
@@ -288,12 +299,14 @@ function formatDeadcodeJson(results) {
288
299
  file: item.file,
289
300
  startLine: item.startLine,
290
301
  endLine: item.endLine,
302
+ ...(item.className && { className: item.className }),
291
303
  ...(handle && { handle }),
292
304
  ...(item.isExported && { isExported: true }),
293
305
  ...(item.decorators && item.decorators.length > 0 && { decorators: item.decorators }),
294
306
  ...(item.annotations && item.annotations.length > 0 && { annotations: item.annotations }),
295
307
  ...(item.declaredOn && { declaredOn: item.declaredOn }),
296
- ...(item.externalContract && { externalContract: true })
308
+ ...(item.externalContract && { externalContract: true }),
309
+ ...(item.selfRecursive && { selfRecursive: true })
297
310
  };
298
311
  }),
299
312
  },
@@ -354,8 +367,16 @@ function formatEntrypoints(results, options = {}) {
354
367
  * Format entrypoints command output (JSON)
355
368
  */
356
369
  function formatEntrypointsJson(results) {
370
+ // Under --limit the handler slices the array and attaches the full-set
371
+ // size (fix #247, the deadcode #242 shape) — meta.total must describe
372
+ // the FULL set, with `truncated` saying the list below is a prefix.
373
+ const li = results.limitInfo;
357
374
  return JSON.stringify({
358
- meta: { total: results.length },
375
+ meta: {
376
+ count: results.length,
377
+ total: li ? li.total : results.length,
378
+ ...(li && { truncated: true }),
379
+ },
359
380
  data: {
360
381
  entrypoints: results.map(ep => {
361
382
  const handle = ep.line && ep.name ? `${ep.file}:${ep.line}:${ep.name}` : null;
@@ -368,6 +389,7 @@ function formatEntrypointsJson(results) {
368
389
  framework: ep.framework,
369
390
  patternId: ep.patternId,
370
391
  evidence: ep.evidence,
392
+ ...(ep.registeredAt && { registeredAt: ep.registeredAt }),
371
393
  confidence: ep.confidence,
372
394
  };
373
395
  }),
@@ -375,9 +397,86 @@ function formatEntrypointsJson(results) {
375
397
  }, null, 2);
376
398
  }
377
399
 
400
+ /**
401
+ * formatOrient — one-screen cold-repo orientation.
402
+ */
403
+ function formatOrient(result) {
404
+ const lines = [];
405
+ lines.push(`PROJECT ORIENTATION — ${result.root}`);
406
+ lines.push('═'.repeat(60));
407
+
408
+ // Size + language mix (percent by symbols, largest first)
409
+ const langs = Object.entries(result.byLanguage || {})
410
+ .map(([lang, v]) => ({ lang, symbols: v.symbols || 0 }))
411
+ .sort((a, b) => b.symbols - a.symbols || (a.lang < b.lang ? -1 : a.lang > b.lang ? 1 : 0));
412
+ const totalSym = result.symbols || 1;
413
+ const langStr = langs
414
+ .map(l => `${l.lang} ${Math.round((l.symbols / totalSym) * 100)}%`)
415
+ .join(', ');
416
+ lines.push(`${result.files} files · ${result.symbols} symbols · ${langStr}`);
417
+ lines.push('');
418
+
419
+ if (result.dirs?.length) {
420
+ lines.push('TOP DIRS (by symbols):');
421
+ const width = Math.max(...result.dirs.map(d => d.dir.length));
422
+ for (const d of result.dirs) {
423
+ lines.push(` ${d.dir.padEnd(width)} ${d.symbols} symbols · ${d.files} file(s)`);
424
+ }
425
+ lines.push('');
426
+ }
427
+
428
+ if (result.hot?.items?.length) {
429
+ const scope = result.hot.production ? 'production functions' : 'functions';
430
+ lines.push(`HOT (most-called ${scope}, top ${result.hot.items.length} of ${result.hot.total}):`);
431
+ for (const h of result.hot.items) {
432
+ const label = h.className ? `${h.className}.${h.name}` : h.name;
433
+ lines.push(` ${label} — ${h.callCount} call(s) · ${h.file}:${h.line}`);
434
+ }
435
+ lines.push('');
436
+ }
437
+
438
+ if (result.entrypoints) {
439
+ const byType = result.entrypoints.byType
440
+ .map(t => `${t.type} ${t.count}`).join(', ');
441
+ lines.push(`ENTRY POINTS: ${result.entrypoints.total} — ${byType}`);
442
+ } else {
443
+ lines.push('ENTRY POINTS: (detection unavailable)');
444
+ }
445
+
446
+ const bs = result.trust?.blindSpots || {};
447
+ const bsParts = [];
448
+ if (bs.dynamicImports) bsParts.push(`${bs.dynamicImports} dynamic import(s)`);
449
+ if (bs.evalCalls) bsParts.push(`${bs.evalCalls} eval`);
450
+ if (bs.reflection) bsParts.push(`${bs.reflection} reflection`);
451
+ 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)`);
453
+ lines.push('');
454
+
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');
458
+ lines.push(`Next: ${next.join(' · ')}`);
459
+
460
+ return lines.join('\n');
461
+ }
462
+
463
+ function formatOrientJson(result) {
464
+ return JSON.stringify({
465
+ meta: {
466
+ command: 'orient',
467
+ files: result.files,
468
+ symbols: result.symbols,
469
+ trust: result.trust?.level ?? null,
470
+ },
471
+ data: result,
472
+ }, null, 2);
473
+ }
474
+
378
475
  module.exports = {
379
476
  formatToc,
380
477
  formatTocJson,
478
+ formatOrient,
479
+ formatOrientJson,
381
480
  formatStats,
382
481
  formatStatsJson,
383
482
  formatDeadcode,
@@ -2,7 +2,7 @@
2
2
  * core/output/search.js - Text search, structural search, example, typedef, tests formatters
3
3
  */
4
4
 
5
- const { detectDoubleEscaping } = require('./shared');
5
+ const { detectDoubleEscaping, advisoryLine } = require('./shared');
6
6
 
7
7
  /**
8
8
  * Format search command output
@@ -170,6 +170,8 @@ function formatExample(result, name) {
170
170
  lines.push(`Best example of "${name}":`);
171
171
  lines.push('═'.repeat(60));
172
172
  lines.push(`${best.relativePath}:${best.line}`);
173
+ const exAdvisory = advisoryLine(result.advisory);
174
+ if (exAdvisory) lines.push(exAdvisory);
173
175
  lines.push('');
174
176
 
175
177
  if (best.before) {
@@ -206,6 +208,8 @@ function formatExampleDiverse(result, name) {
206
208
  const total = result.totalClusters || result.clusters.length;
207
209
  lines.push(`Diverse examples of "${name}" — ${result.clusters.length} of ${total} cluster(s), ${result.totalCalls} total calls:`);
208
210
  lines.push('═'.repeat(60));
211
+ const divAdvisory = advisoryLine(result.advisory);
212
+ if (divAdvisory) lines.push(divAdvisory);
209
213
 
210
214
  for (let i = 0; i < result.clusters.length; i++) {
211
215
  const c = result.clusters[i];
@@ -249,13 +253,23 @@ function formatExampleDiverse(result, name) {
249
253
  */
250
254
  function formatExampleJson(result, name) {
251
255
  if (!result || !result.best) {
252
- return JSON.stringify({ found: false, query: name, error: `No call examples found for "${name}"` }, null, 2);
256
+ // Surface the excluded-test-usages count in JSON too (fix #237 the
257
+ // handler note that used to carry it duplicated the text body).
258
+ const excluded = result?.excludedTestCalls || 0;
259
+ return JSON.stringify({
260
+ found: false, query: name,
261
+ ...(excluded > 0 && { excludedTestCalls: excluded }),
262
+ error: excluded > 0
263
+ ? `No call examples found for "${name}" (excluded ${excluded} test-file usage${excluded === 1 ? '' : 's'} — pass --include-tests to include them)`
264
+ : `No call examples found for "${name}"`,
265
+ }, null, 2);
253
266
  }
254
267
 
255
268
  const best = result.best;
256
269
  const env = {
257
270
  found: true,
258
271
  query: name,
272
+ ...(result.advisory && { advisory: result.advisory }),
259
273
  totalCalls: result.totalCalls,
260
274
  best: {
261
275
  file: best.relativePath || best.file,
@@ -302,7 +316,20 @@ function formatTypedef(types, name) {
302
316
  } else {
303
317
  for (const t of types) {
304
318
  lines.push(`${t.relativePath}:${t.startLine} ${t.type} ${t.name}`);
305
- if (t.usageCount !== undefined) {
319
+ // Honest breakdown (fix #251): the fast-path count sees calls/
320
+ // defs/imports only — for TYPE symbols, reference-kind usage
321
+ // dominates and is NOT counted here; say what the number is.
322
+ if (t.usageCounts !== undefined) {
323
+ const c = t.usageCounts;
324
+ const parts = [];
325
+ if (c.calls > 0) parts.push(`${c.calls} calls`);
326
+ if (c.definitions > 0) parts.push(`${c.definitions} def`);
327
+ if (c.imports > 0) parts.push(`${c.imports} imports`);
328
+ if (c.references > 0) parts.push(`${c.references} refs`);
329
+ lines.push(parts.length > 0
330
+ ? ` (${c.total} usages: ${parts.join(', ')} — type references not counted; use \`usages ${t.name}\`)`
331
+ : ` (${c.total} usages)`);
332
+ } else if (t.usageCount !== undefined) {
306
333
  lines.push(` (${t.usageCount} usages)`);
307
334
  }
308
335
  if (t.code) {