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
package/core/imports.js CHANGED
@@ -303,8 +303,21 @@ function findCargoRoot(startDir) {
303
303
  while (dir !== path.dirname(dir)) {
304
304
  const cargoPath = path.join(dir, 'Cargo.toml');
305
305
  if (fs.existsSync(cargoPath)) {
306
+ // Flat-layout crates (lib.rs/main.rs next to Cargo.toml, no src/)
307
+ // root their module tree at the Cargo.toml directory — requiring
308
+ // src/ left every crate:: path in such crates unresolved.
306
309
  const srcDir = path.join(dir, 'src');
307
- const result = fs.existsSync(srcDir) ? { root: dir, srcDir } : null;
310
+ // The [package] name is the crate's import identity for its OWN
311
+ // integration tests/benches/examples (`use mypkg::...` in
312
+ // tests/*.rs — fix #246); `-` normalizes to `_` in code.
313
+ let packageName = null;
314
+ try {
315
+ const toml = fs.readFileSync(cargoPath, 'utf-8');
316
+ const pkgSection = toml.split(/^\s*\[/m).find(s => s.startsWith('package]'));
317
+ const m = pkgSection && pkgSection.match(/^\s*name\s*=\s*"([^"]+)"/m);
318
+ if (m) packageName = m[1].replace(/-/g, '_');
319
+ } catch { /* unreadable Cargo.toml — no package identity */ }
320
+ const result = { root: dir, srcDir: fs.existsSync(srcDir) ? srcDir : dir, packageName };
308
321
  cargoCache.set(startDir, result);
309
322
  return result;
310
323
  }
@@ -315,6 +328,31 @@ function findCargoRoot(startDir) {
315
328
  return null;
316
329
  }
317
330
 
331
+ /**
332
+ * Resolve the FILE that owns the module rooted at `dir`: dir/mod.rs (2015
333
+ * layout), <dir>.rs (2018 layout — the module file sits beside its directory),
334
+ * or the crate root lib.rs/main.rs. Used when a use-path names an ITEM
335
+ * declared directly in that module file (e.g. `use super::CONFIG`) — there is
336
+ * no <item>.rs to find, the import points at the module file itself.
337
+ * @param {string} dir - Module directory
338
+ * @param {string} [fromFile] - Importing file, never returned as its own target
339
+ * @returns {string|null}
340
+ */
341
+ function rustModuleOwnFile(dir, fromFile) {
342
+ const candidates = [
343
+ path.join(dir, 'mod.rs'),
344
+ dir + '.rs',
345
+ path.join(dir, 'lib.rs'),
346
+ path.join(dir, 'main.rs'),
347
+ ];
348
+ for (const c of candidates) {
349
+ if (c !== fromFile && fs.existsSync(c) && fs.statSync(c).isFile()) {
350
+ return c;
351
+ }
352
+ }
353
+ return null;
354
+ }
355
+
318
356
  /**
319
357
  * Try to resolve a Rust module path to a file
320
358
  * Checks both <path>.rs and <path>/mod.rs
@@ -358,7 +396,34 @@ function resolveRustImport(importPath, fromFile, projectRoot) {
358
396
 
359
397
  const rest = importPath.slice('crate::'.length);
360
398
  const segments = rest.split('::');
361
- return resolveRustModulePath(cargo.srcDir, segments);
399
+ // `use crate::ITEM` where ITEM is declared in the crate root file has
400
+ // no ITEM.rs — the import points at lib.rs/main.rs itself.
401
+ return resolveRustModulePath(cargo.srcDir, segments) ||
402
+ rustModuleOwnFile(cargo.srcDir, fromFile);
403
+ }
404
+
405
+ // Own-package-name paths (fix #246): integration tests, benches, and
406
+ // examples are separate crates that import the lib target by its Cargo
407
+ // [package] name — `use mypkg::helper;` in tests/*.rs is the crate under
408
+ // test, resolved exactly like crate:: into the package's source tree.
409
+ // Only fires for files OUTSIDE the package's own module tree (inside
410
+ // src/, a path starting with the package name is a 2015-edition CHILD
411
+ // module, never the crate itself). Cross-crate workspace imports keep
412
+ // their own package names and never match this file's Cargo.toml.
413
+ {
414
+ const firstSeg = importPath.split('::')[0].replace(/-/g, '_');
415
+ const cargo = findCargoRoot(fromDir);
416
+ if (cargo && cargo.packageName && firstSeg === cargo.packageName) {
417
+ const topDir = path.relative(cargo.root, fromFile).split(path.sep)[0];
418
+ const externalTarget = topDir === 'tests' || topDir === 'benches' || topDir === 'examples' ||
419
+ !fromFile.startsWith(cargo.srcDir + path.sep);
420
+ if (externalTarget) {
421
+ const restSegs = importPath.split('::').slice(1);
422
+ const resolved = restSegs.length > 0 ? resolveRustModulePath(cargo.srcDir, restSegs) : null;
423
+ const fallback = resolved || rustModuleOwnFile(cargo.srcDir, fromFile);
424
+ if (fallback) return fallback;
425
+ }
426
+ }
362
427
  }
363
428
 
364
429
  // super:: paths - resolve relative to parent module
@@ -380,7 +445,11 @@ function resolveRustImport(importPath, fromFile, projectRoot) {
380
445
  dir = path.dirname(dir);
381
446
  }
382
447
  const segments = rest.split('::');
383
- return resolveRustModulePath(dir, segments);
448
+ // `use super::ITEM` where ITEM is declared in the parent module FILE
449
+ // (mod.rs / <dir>.rs / crate root) — or in an inline `mod {}` there —
450
+ // has no ITEM.rs to find; the import points at the parent file itself.
451
+ return resolveRustModulePath(dir, segments) ||
452
+ rustModuleOwnFile(dir, fromFile);
384
453
  }
385
454
 
386
455
  // self:: paths - resolve within current module directory
@@ -2,7 +2,8 @@
2
2
  * core/output/analysis-ext.js - Extended analysis formatters (related, smart, diffImpact)
3
3
  */
4
4
 
5
- const { dynamicImportsNote, formatLineRanges } = require('./shared');
5
+ const { dynamicImportsNote, formatLineRanges, unverifiedReasonLabel, advisoryLine } = require('./shared');
6
+ const { formatAccountLines, formatCalleeAccountLine, unverifiedCalleeLines } = require('./analysis');
6
7
 
7
8
  /**
8
9
  * Format related command output - text
@@ -18,20 +19,25 @@ function formatRelated(related, options = {}) {
18
19
  lines.push(`Related to ${related.target.name}`);
19
20
  lines.push('═'.repeat(60));
20
21
  lines.push(`${related.target.file}:${related.target.line}`);
22
+ const relAdvisory = advisoryLine(related.advisory);
23
+ if (relAdvisory) lines.push(relAdvisory);
21
24
  lines.push('');
22
25
 
23
- // Same file
26
+ // Same file (result is already capped by --top at the source; total in
27
+ // sameFileTotal — fix #230)
24
28
  let relatedTruncated = false;
25
29
  if (related.sameFile.length > 0) {
26
30
  const maxSameFile = options.top || (options.all ? Infinity : 8);
27
- lines.push(`SAME FILE (${related.sameFile.length}):`);
31
+ const sameFileTotal = related.sameFileTotal || related.sameFile.length;
32
+ lines.push(`SAME FILE (${sameFileTotal}):`);
28
33
  for (const f of related.sameFile.slice(0, maxSameFile)) {
29
34
  const params = f.params ? `(${f.params})` : '';
30
35
  lines.push(` :${f.line} ${f.name}${params}`);
31
36
  }
32
- if (related.sameFile.length > maxSameFile) {
37
+ const shown = Math.min(related.sameFile.length, maxSameFile);
38
+ if (sameFileTotal > shown) {
33
39
  relatedTruncated = true;
34
- lines.push(` ... and ${related.sameFile.length - maxSameFile} more`);
40
+ lines.push(` ... and ${sameFileTotal - shown} more`);
35
41
  }
36
42
  lines.push('');
37
43
  }
@@ -103,7 +109,9 @@ function formatSmart(smart, options = {}) {
103
109
  if (!smart) return 'Function not found.';
104
110
 
105
111
  const lines = [];
106
- lines.push(`${smart.target.name} (${smart.target.file}:${smart.target.startLine})`);
112
+ // Project-relative path in the header, like every other command (fix #230
113
+ // — the absolute def.file leaked here).
114
+ lines.push(`${smart.target.name} (${smart.target.relativePath || smart.target.file}:${smart.target.startLine})`);
107
115
  lines.push('═'.repeat(60));
108
116
 
109
117
  if (smart.meta) {
@@ -135,6 +143,12 @@ function formatSmart(smart, options = {}) {
135
143
  }
136
144
  }
137
145
 
146
+ // v4 callee contract: unresolved calls inside the target — visible with
147
+ // reasons (they may be dependencies smart could not inline).
148
+ lines.push(...unverifiedCalleeLines(smart.unverifiedCallees, false));
149
+ const smartCalleeAcct = formatCalleeAccountLine(smart.meta && smart.meta.calleeAccount);
150
+ if (smartCalleeAcct) lines.push(`\n${smartCalleeAcct}`);
151
+
138
152
  return lines.join('\n');
139
153
  }
140
154
 
@@ -168,7 +182,10 @@ function formatSmartJson(result) {
168
182
  callCount: d.callCount,
169
183
  code: d.code
170
184
  })),
171
- types: result.types || []
185
+ types: result.types || [],
186
+ // v4 callee contract: visible unresolved-call entries, reconciled
187
+ // by meta.calleeAccount.
188
+ unverifiedCallees: result.unverifiedCallees || []
172
189
  }
173
190
  });
174
191
  }
@@ -191,6 +208,7 @@ function formatDiffImpact(result, options = {}) {
191
208
  if (s.deletedFunctions > 0) parts.push(`${s.deletedFunctions} deleted`);
192
209
  if (s.newFunctions > 0) parts.push(`${s.newFunctions} new`);
193
210
  parts.push(`${s.totalCallSites || 0} call sites across ${s.affectedFiles || 0} files`);
211
+ if (s.unverifiedCallSites > 0) parts.push(`${s.unverifiedCallSites} unverified`);
194
212
  lines.push(parts.join(', '));
195
213
  lines.push('');
196
214
 
@@ -221,7 +239,28 @@ function formatDiffImpact(result, options = {}) {
221
239
  lines.push(` ... ${truncated} more callers (use file= to scope diff to specific files, or use impact with class_name= for type-filtered results)`);
222
240
  }
223
241
  } else {
224
- lines.push(' Callers: none found');
242
+ lines.push(' Callers: none confirmed');
243
+ }
244
+
245
+ // Unverified tier: visible one-liners with reasons (never silently dropped)
246
+ const unverified = fn.unverifiedCallers || [];
247
+ if (unverified.length > 0) {
248
+ const cap = options.all ? Infinity : 10;
249
+ lines.push(` Unverified call sites (${unverified.length}) — call syntax, no binding/receiver evidence:`);
250
+ for (const u of unverified.slice(0, cap)) {
251
+ const caller = u.callerName ? ` [${u.callerName}]` : '';
252
+ const reason = u.reason ? ` (${unverifiedReasonLabel(u)})` : '';
253
+ const expr = u.content ? `: ${u.content.replace(/\s+/g, ' ').slice(0, 100)}` : '';
254
+ lines.push(` ${u.relativePath}:${u.line}${caller}${expr}${reason}`);
255
+ }
256
+ if (unverified.length > cap) {
257
+ lines.push(` (+${unverified.length - cap} more unverified)`);
258
+ }
259
+ }
260
+
261
+ // Conservation contract lines, indented inside the function block
262
+ for (const al of formatAccountLines(fn.account)) {
263
+ lines.push(` ${al}`);
225
264
  }
226
265
  }
227
266
  }
@@ -235,11 +274,22 @@ function formatDiffImpact(result, options = {}) {
235
274
  }
236
275
  }
237
276
 
238
- // Deleted functions
277
+ // Deleted functions — with any call sites that still reference the name
278
+ // (likely breaks; name-level candidates, the definition is gone)
239
279
  if (result.deletedFunctions.length > 0) {
240
280
  lines.push('\nDELETED FUNCTIONS:');
241
281
  for (const fn of result.deletedFunctions) {
242
282
  lines.push(` ${fn.name} — ${fn.relativePath}:${fn.startLine}`);
283
+ const remaining = fn.remainingCallSites || [];
284
+ if (remaining.length > 0) {
285
+ lines.push(` ⚠ still called from ${remaining.length} site(s) — name-level matches:`);
286
+ for (const site of remaining.slice(0, 10)) {
287
+ lines.push(` ${site.relativePath}:${site.line} ${site.content}`);
288
+ }
289
+ if (remaining.length > 10) {
290
+ lines.push(` ... and ${remaining.length - 10} more`);
291
+ }
292
+ }
243
293
  }
244
294
  }
245
295
 
@@ -258,7 +308,17 @@ function formatDiffImpact(result, options = {}) {
258
308
  }
259
309
 
260
310
  function formatDiffImpactJson(result) {
261
- return JSON.stringify(result, null, 2);
311
+ // Standard {meta, data} envelope (fix #230 — diff-impact was one of
312
+ // three commands still emitting a bare result object). Per-symbol
313
+ // accounts stay on each entry inside data; meta carries the aggregate
314
+ // completeness signal.
315
+ return JSON.stringify({
316
+ meta: {
317
+ complete: (result?.summary?.unverifiedCallSites || 0) === 0,
318
+ unverified: result?.summary?.unverifiedCallSites || 0,
319
+ },
320
+ data: result,
321
+ }, null, 2);
262
322
  }
263
323
 
264
324
  module.exports = {
@@ -19,30 +19,6 @@ function calleeDocstringSnippet(text) {
19
19
  return s;
20
20
  }
21
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
- }
45
-
46
22
  /**
47
23
  * Reachability display policy for a section: per-line [unreachable] markers
48
24
  * ONLY when reachability is mixed (they distinguish which); when ALL items are
@@ -137,6 +113,36 @@ function formatAccountLines(account) {
137
113
  return lines;
138
114
  }
139
115
 
116
+ /**
117
+ * Single-node CALLEE ACCOUNT line (context/smart — the per-def callee
118
+ * contract from #223). Matches the tree rollup's arithmetic phrasing.
119
+ */
120
+ function formatCalleeAccountLine(acct) {
121
+ if (!acct) return null;
122
+ let line = `CALLEE ACCOUNT: ${acct.totalSites} call site${acct.totalSites === 1 ? '' : 's'} = ` +
123
+ `${acct.confirmed} confirmed + ${acct.unverified} unverified + ` +
124
+ `${acct.external.count} external/builtin + ${acct.excluded.total} excluded`;
125
+ if (acct.filtered.count > 0) line += ` + ${acct.filtered.count} filtered`;
126
+ if (!acct.conserved) line += ` — ${acct.unaccounted} UNACCOUNTED`;
127
+ return line;
128
+ }
129
+
130
+ /**
131
+ * Render unverified callee entries (aggregated name+reason from findCallees
132
+ * collectAccount) as one-liners. Returns [] when the band is empty.
133
+ */
134
+ function unverifiedCalleeLines(entries, compact) {
135
+ if (!entries || entries.length === 0) return [];
136
+ const lines = [];
137
+ lines.push(`${compact ? '' : '\n'}CALLEES — UNVERIFIED (${entries.length}) — call syntax, receiver/binding unresolved:`);
138
+ for (const u of entries) {
139
+ const owners = u.ownerCount > 1 ? ` (${u.ownerCount} owners)` : '';
140
+ const sites = u.sites && u.sites.length > 0 ? ` L${u.sites.join(',L')}` : '';
141
+ lines.push(` ${u.name} ×${u.callCount} — ${u.reason}${owners}${sites}`);
142
+ }
143
+ return lines;
144
+ }
145
+
140
146
  /** "NON-CALL OCCURRENCES" summary line from the account. */
141
147
  function formatNonCallLine(account, hintName) {
142
148
  if (!account || !account.nonCall || account.nonCall.total === 0) return null;
@@ -244,8 +250,14 @@ function formatContextJson(context) {
244
250
  params: c.params, // FULL params
245
251
  weight: c.weight || 'normal', // Dependency weight: core, setup, utility
246
252
  ...(c.confidence != null && { confidence: c.confidence, resolution: c.resolution }),
253
+ ...(c.tier && { tier: c.tier }),
254
+ ...(c.sites && { sites: c.sites }),
255
+ ...(c.functionReference && { functionReference: true }),
247
256
  ...(c.reachable !== undefined && { reachable: c.reachable }),
248
257
  })),
258
+ // v4 callee contract: visible unresolved-call entries (aggregated
259
+ // name+reason), reconciled by meta.calleeAccount.
260
+ unverifiedCallees: context.unverifiedCallees || [],
249
261
  ...(context.warnings && { warnings: context.warnings })
250
262
  }
251
263
  });
@@ -388,7 +400,7 @@ function formatContext(ctx, options = {}) {
388
400
  ? `CALLERS — CONFIRMED (${callers.length}, ${prodCallers.length} prod + ${testCallers.length} test):`
389
401
  : `CALLERS — CONFIRMED (${callers.length}):`;
390
402
  lines.push(`${compact ? '' : '\n'}${tierHeader}`);
391
- const callerEvidence = formatEvidenceLine(callers);
403
+ const callerEvidence = options.showConfidence !== false ? formatEvidenceLine(callers) : null;
392
404
  if (callerEvidence && !compact) lines.push(callerEvidence);
393
405
  const callerReach = reachabilityDisplay(callers, hasEntrypoints, 'caller');
394
406
  const renderCaller = (c) => {
@@ -420,11 +432,9 @@ function formatContext(ctx, options = {}) {
420
432
  }
421
433
  if (callerReach.note && !compact) lines.push(callerReach.note);
422
434
 
423
- // Structural hint: class methods may have callers through constructed/injected instances
424
- // that static analysis can't track. Only show when caller count is low (≤3) to avoid noise.
425
- if (ctx.meta && (ctx.meta.isMethod || ctx.meta.className || ctx.meta.receiver) && callers.length <= 3) {
426
- lines.push(` Note: ${ctx.function} is a class/struct method — additional callers through constructed or injected instances are not tracked by static analysis.`);
427
- }
435
+ // No hedge note here: under the tiered contract, instance-dispatch caller
436
+ // candidates ARE analyzed they render in the unverified band with
437
+ // reasons, and the ACCOUNT line reconciles every text occurrence.
428
438
 
429
439
  // UNVERIFIED tier: call-syntax matches without binding/receiver evidence.
430
440
  // Always visible (the contract: never silently hide an occurrence), capped
@@ -459,7 +469,7 @@ function formatContext(ctx, options = {}) {
459
469
 
460
470
  const callees = ctx.callees || [];
461
471
  lines.push(`${compact ? '' : '\n'}CALLEES (${callees.length}):`);
462
- const calleeEvidence = formatEvidenceLine(callees);
472
+ const calleeEvidence = options.showConfidence !== false ? formatEvidenceLine(callees) : null;
463
473
  if (calleeEvidence && !compact) lines.push(calleeEvidence);
464
474
  const calleeReach = reachabilityDisplay(callees, hasEntrypoints, 'callee');
465
475
  for (const c of callees) {
@@ -490,6 +500,10 @@ function formatContext(ctx, options = {}) {
490
500
  }
491
501
  if (calleeReach.note && !compact) lines.push(calleeReach.note);
492
502
 
503
+ // Unverified callee band (v4): aggregated name+reason entries — visible,
504
+ // never silently dropped. Not expandable (no definition resolved).
505
+ lines.push(...unverifiedCalleeLines(ctx.unverifiedCallees, compact));
506
+
493
507
  // Conservation contract lines: non-call summary + ACCOUNT/WARNING/FILTERED
494
508
  const account = ctx.meta && ctx.meta.account;
495
509
  if (account) {
@@ -501,6 +515,8 @@ function formatContext(ctx, options = {}) {
501
515
  lines.push(...accountLines);
502
516
  }
503
517
  }
518
+ const ctxCalleeAcct = formatCalleeAccountLine(ctx.meta && ctx.meta.calleeAccount);
519
+ if (ctxCalleeAcct) lines.push(ctxCalleeAcct);
504
520
 
505
521
  if (expandable.length > 0) {
506
522
  lines.push(`\n${expandHint}`);
@@ -717,7 +733,7 @@ function formatAbout(about, options = {}) {
717
733
  } else {
718
734
  lines.push(`CALLERS — CONFIRMED (${about.callers.total}${testTop.length > 0 ? `, ${prodTop.length} prod + ${testTop.length} test` : ''}):`);
719
735
  }
720
- const callerEvidence = formatEvidenceLine(top);
736
+ const callerEvidence = options.showConfidence !== false ? formatEvidenceLine(top) : null;
721
737
  if (callerEvidence) lines.push(callerEvidence);
722
738
  const aboutCallerReach = reachabilityDisplay(top, hasEntrypoints, 'caller');
723
739
  const renderAboutCaller = (c) => {
@@ -732,6 +748,21 @@ function formatAbout(about, options = {}) {
732
748
  for (const c of testTop) renderAboutCaller(c);
733
749
  }
734
750
  if (aboutCallerReach.note) lines.push(aboutCallerReach.note);
751
+
752
+ // Field-report #5: when every CONFIRMED caller is a test and the
753
+ // production call sites are method-style (landed in UNVERIFIED as
754
+ // method-ambiguous — e.g. a module function sharing a name with a
755
+ // method), the bare "0 prod" count reads like dead code. Flag it so the
756
+ // empty prod count isn't misread; the real calls are listed below.
757
+ if (prodTop.length === 0 && testTop.length > 0) {
758
+ const uv = about.callers.unverified;
759
+ const methodStyle = uv && uv.top
760
+ ? uv.top.some(u => u.reason === 'method-ambiguous' || u.reason === 'possible-dispatch')
761
+ : false;
762
+ if (uv && uv.total > 0 && methodStyle) {
763
+ lines.push(` Note: 0 production callers CONFIRMED — the ${uv.total} call site(s) under UNVERIFIED below include method-style calls that may bind to this or a same-name method, so this is not dead code.`);
764
+ }
765
+ }
735
766
  }
736
767
 
737
768
  // Callers — UNVERIFIED tier (always visible; the contract forbids hiding)
@@ -759,7 +790,7 @@ function formatAbout(about, options = {}) {
759
790
  } else {
760
791
  lines.push(`CALLEES (${about.callees.total}):`);
761
792
  }
762
- const calleeEvidence = formatEvidenceLine(about.callees.top);
793
+ const calleeEvidence = options.showConfidence !== false ? formatEvidenceLine(about.callees.top) : null;
763
794
  if (calleeEvidence) lines.push(calleeEvidence);
764
795
  const aboutCalleeReach = reachabilityDisplay(about.callees.top, hasEntrypoints, 'callee');
765
796
  for (const c of about.callees.top) {
@@ -882,4 +913,7 @@ module.exports = {
882
913
  // Shared with output/tracing.js: the tree commands render the same
883
914
  // root-hop ACCOUNT/WARNING/FILTERED lines as context/impact.
884
915
  formatAccountLines,
916
+ // Shared with output/analysis-ext.js (smart): single-node callee contract.
917
+ formatCalleeAccountLine,
918
+ unverifiedCalleeLines,
885
919
  };
@@ -30,7 +30,10 @@ function formatCheck(result) {
30
30
  if (it.signatureMismatches > 0) tags.push(`SIG-DRIFT(${it.signatureMismatches})`);
31
31
  if (it.orphan) tags.push('ORPHAN');
32
32
  const tagStr = tags.length ? ' [' + tags.join(', ') + ']' : '';
33
- const callers = it.callerCount != null ? `${it.callerCount} caller${it.callerCount === 1 ? '' : 's'}` : '';
33
+ let callers = it.callerCount != null ? `${it.callerCount} caller${it.callerCount === 1 ? '' : 's'}` : '';
34
+ if (it.unverifiedCallerCount > 0) {
35
+ callers += ` (+${it.unverifiedCallerCount} unverified)`;
36
+ }
34
37
  lines.push(` ${it.name} (${it.file}:${it.line})${tagStr} ${callers}`);
35
38
  if (it.mismatches && it.mismatches.length > 0) {
36
39
  for (const m of it.mismatches.slice(0, 3)) {
@@ -13,6 +13,7 @@ function formatDoctor(result) {
13
13
  const lines = [];
14
14
  lines.push(`UCN Trust Report — ${result.root}`);
15
15
  lines.push('═'.repeat(60));
16
+ if (result.version) lines.push(`Version: ucn ${result.version}`);
16
17
  lines.push(`Index: ${result.files.scanned} file${result.files.scanned === 1 ? '' : 's'}, ${result.symbols} symbol${result.symbols === 1 ? '' : 's'}`);
17
18
 
18
19
  if (result.filter) lines.push(`Filter: ${result.filter}`);
@@ -60,13 +61,22 @@ function formatDoctor(result) {
60
61
  ['Reflection', bs.reflection],
61
62
  ['Parse failures', bs.parseFailures],
62
63
  ];
64
+ const unitFor = { 'Dynamic imports': 'import', 'Eval/exec calls': 'use', 'Reflection': 'use', 'Parse failures': 'failure' };
63
65
  let anyBlindSpot = false;
64
66
  for (const [label, info] of bsLines) {
65
67
  if (info && info.count > 0) {
66
68
  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'}`);
69
+ // fileCount is the TRUE (uncapped) number of files; info.files is a
70
+ // capped display sample. Show "N use(s) in M file(s)" and, when the
71
+ // sample is truncated, "... and K more file(s)" against the true M
72
+ // never present the display cap as the population (field-report #2).
73
+ const fileCount = info.fileCount != null ? info.fileCount : info.files.length;
74
+ const unit = unitFor[label] || 'use';
75
+ lines.push(` ${label}: ${info.count} ${unit}${info.count === 1 ? '' : 's'} in ${fileCount} file${fileCount === 1 ? '' : 's'}`);
76
+ const shownFiles = info.files.slice(0, 3);
77
+ const sample = shownFiles.map(f => ` - ${f}`).join('\n');
78
+ const moreFiles = fileCount - shownFiles.length;
79
+ const more = moreFiles > 0 ? `\n ... and ${moreFiles} more file${moreFiles === 1 ? '' : 's'}` : '';
70
80
  if (sample) lines.push(sample + more);
71
81
  }
72
82
  }
@@ -12,6 +12,9 @@
12
12
 
13
13
  'use strict';
14
14
 
15
+ const { advisoryLine } = require('./shared');
16
+ const { codeUnitCompare } = require('../shared');
17
+
15
18
  const SEP_TIER = { exact: 'EXACT', partial: 'PARTIAL', uncertain: 'UNCERTAIN' };
16
19
 
17
20
  function pad(s, n) {
@@ -27,7 +30,7 @@ function formatEndpoints(result, options = {}) {
27
30
  if (!showBridge) {
28
31
  return formatRoutesAndRequests(routes, requests, meta, options);
29
32
  }
30
- return formatBridges(bridges, unmatchedRoutes, unmatchedRequests, meta, options);
33
+ return formatBridges(bridges, unmatchedRoutes, unmatchedRequests, meta, options, result.advisory);
31
34
  }
32
35
 
33
36
  /**
@@ -111,13 +114,15 @@ function formatRoutesAndRequests(routes, requests, meta, options) {
111
114
  return lines.join('\n').trimEnd();
112
115
  }
113
116
 
114
- function formatBridges(bridges, unmatchedRoutes, unmatchedRequests, meta, options = {}) {
117
+ function formatBridges(bridges, unmatchedRoutes, unmatchedRequests, meta, options = {}, advisory = null) {
115
118
  const lines = [];
116
119
  const matched = bridges.length;
117
120
  const unmatchedOnly = !!options.unmatched;
118
121
 
119
122
  lines.push(`Endpoint Bridges`);
120
123
  lines.push(`================`);
124
+ const brAdvisory = advisoryLine(advisory);
125
+ if (brAdvisory) lines.push(brAdvisory);
121
126
  lines.push(`Server routes: ${meta.totalRoutes} Client requests: ${meta.totalRequests}`);
122
127
  // HIGH-3: percentage = unique matched client requests / total client
123
128
  // requests. Counting bridges directly inflates >100% on many-to-many
@@ -138,7 +143,7 @@ function formatBridges(bridges, unmatchedRoutes, unmatchedRequests, meta, option
138
143
  }
139
144
  // Sort routes alphabetically
140
145
  const sorted = [...byRoute.values()].sort((a, b) => {
141
- if (a.route.file !== b.route.file) return a.route.file.localeCompare(b.route.file);
146
+ if (a.route.file !== b.route.file) return codeUnitCompare(a.route.file, b.route.file);
142
147
  return a.route.line - b.route.line;
143
148
  });
144
149
 
@@ -15,7 +15,13 @@ const {
15
15
  function formatFn(match, fnCode) {
16
16
  const lines = [];
17
17
  lines.push(`${match.relativePath}:${match.startLine}`);
18
- lines.push(`${lineRange(match.startLine, match.endLine)} ${formatFunctionSignature(match)}`);
18
+ // Class attribution: three same-name `clear` methods under --all were
19
+ // indistinguishable without their owning class (fix #248).
20
+ const sig = formatFunctionSignature(match);
21
+ const attributed = match.className && !sig.includes(`${match.className}.`)
22
+ ? `${match.className}.${sig}`
23
+ : sig;
24
+ lines.push(`${lineRange(match.startLine, match.endLine)} ${attributed}`);
19
25
  lines.push('─'.repeat(60));
20
26
  lines.push(fnCode);
21
27
  return lines.join('\n');
@@ -43,6 +49,10 @@ function formatFunctionJson(fn, code) {
43
49
  paramsStructured: fn.paramsStructured || [],
44
50
  startLine: fn.startLine,
45
51
  endLine: fn.endLine,
52
+ // Location + class attribution (fix #248: single-entry fn --json
53
+ // said neither WHERE the function lives nor WHOSE method it is).
54
+ file: fn.relativePath || fn.file,
55
+ ...(fn.className && { className: fn.className }),
46
56
  modifiers: fn.modifiers || [],
47
57
  ...(fn.returnType && { returnType: fn.returnType }),
48
58
  ...(fn.paramTypes && { paramTypes: fn.paramTypes }),
@@ -81,6 +91,7 @@ function formatFnResultJson(result) {
81
91
  paramsStructured: match.paramsStructured || [],
82
92
  startLine: match.startLine,
83
93
  endLine: match.endLine,
94
+ ...(match.className && { className: match.className }),
84
95
  modifiers: match.modifiers || [],
85
96
  ...(match.returnType && { returnType: match.returnType }),
86
97
  ...(match.paramTypes && { paramTypes: match.paramTypes }),
@@ -100,7 +100,10 @@ function formatFindJson(items) {
100
100
  * @param {object} options - { depth, top, all }
101
101
  */
102
102
  function formatFindDetailed(symbols, query, options = {}) {
103
- const { depth, top, all, compact } = options;
103
+ const { top, all, compact } = options;
104
+ // Surfaces pass validated NUMBERS; the string comparisons below made
105
+ // --depth 0/2 dead code everywhere (fix #250). Normalize once.
106
+ const depth = options.depth != null ? String(options.depth) : undefined;
104
107
  const DEFAULT_LIMIT = 5;
105
108
 
106
109
  if (symbols.length === 0) {
@@ -242,7 +245,11 @@ function formatUsagesJson(usages, name) {
242
245
 
243
246
  const calls = refs.filter(u => u.usageType === 'call');
244
247
  const imports = refs.filter(u => u.usageType === 'import');
245
- const references = refs.filter(u => u.usageType === 'reference');
248
+ // Exhaustive complement (fix #241): a non-definition record that is
249
+ // neither call nor import lands in references — same-name definer sites
250
+ // (usageType 'definition', isDefinition false: shadowing locals, other
251
+ // defs of the name) used to inflate totals while rendering in NO band.
252
+ const references = refs.filter(u => u.usageType !== 'call' && u.usageType !== 'import');
246
253
 
247
254
  // Each usage record points at a call site. We emit a per-occurrence handle
248
255
  // pointing at the SITE itself in the form "relativePath:line:callerName"
@@ -268,15 +275,17 @@ function formatUsagesJson(usages, name) {
268
275
  };
269
276
  };
270
277
 
278
+ // Full-set counts under --limit (fix #237) — listed entries stay truncated.
279
+ const sc = usages.summaryCounts;
271
280
  return JSON.stringify({
272
281
  meta: { complete: true, skipped: 0, dynamicImports: 0, uncertain: 0 },
273
282
  data: {
274
283
  symbol: name,
275
- definitionCount: definitions.length,
276
- callCount: calls.length,
277
- importCount: imports.length,
278
- referenceCount: references.length,
279
- totalUsages: refs.length,
284
+ definitionCount: sc ? sc.definitions : definitions.length,
285
+ callCount: sc ? sc.calls : calls.length,
286
+ importCount: sc ? sc.imports : imports.length,
287
+ referenceCount: sc ? sc.references : references.length,
288
+ totalUsages: sc ? (sc.calls + sc.imports + sc.references) : refs.length,
280
289
  definitions: definitions.map(d => {
281
290
  const handle = formatSymbolHandle({ ...d, name: d.name || name });
282
291
  return {
@@ -306,10 +315,15 @@ function formatUsages(usages, name, options = {}) {
306
315
  const defs = usages.filter(u => u.isDefinition);
307
316
  const calls = usages.filter(u => u.usageType === 'call');
308
317
  const imports = usages.filter(u => u.usageType === 'import');
309
- const refs = usages.filter(u => !u.isDefinition && u.usageType === 'reference');
318
+ // Exhaustive complement (fix #241) see formatUsagesJson.
319
+ const refs = usages.filter(u => !u.isDefinition && u.usageType !== 'call' && u.usageType !== 'import');
310
320
 
321
+ // Under --limit the listed entries are truncated but the summary must
322
+ // describe the FULL result set (fix #237) — the handler attaches the
323
+ // full counts as a non-enumerable property.
324
+ const sc = usages.summaryCounts;
311
325
  const lines = [];
312
- lines.push(`Usages of "${name}": ${defs.length} definitions, ${calls.length} calls, ${imports.length} imports, ${refs.length} references`);
326
+ lines.push(`Usages of "${name}": ${sc ? sc.definitions : defs.length} definitions, ${sc ? sc.calls : calls.length} calls, ${sc ? sc.imports : imports.length} imports, ${sc ? sc.references : refs.length} references`);
313
327
  if (!compact) lines.push('═'.repeat(60));
314
328
 
315
329
  function renderContextLines(usage) {