ucn 3.8.26 → 4.0.1

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.
package/core/analysis.js CHANGED
@@ -198,6 +198,77 @@ function tagCalleesSideEffects(index, callees) {
198
198
  }
199
199
 
200
200
 
201
+ /**
202
+ * Compose the conservation account for a caller query (the tiered
203
+ * caller contract). Claims come from the PRE-display-filter findCallers result —
204
+ * the account reconciles pre-display truth; display filters are reported
205
+ * separately in account.filtered.
206
+ *
207
+ * @param {object} index - ProjectIndex instance
208
+ * @param {string} name - Symbol name
209
+ * @param {Array} rawCallers - findCallers result BEFORE display filters
210
+ * (carries non-enumerable accountRaw/shadowEntries from collectAccount)
211
+ * @param {object} [filtered] - display-level hide counts { total, byFlag }
212
+ * @returns {object} account (see core/account.js)
213
+ */
214
+ function composeAccount(index, name, rawCallers, filtered) {
215
+ const { computeGroundSet, buildAccount } = require('./account');
216
+ const groundSet = computeGroundSet(index, name);
217
+ const accountRaw = rawCallers.accountRaw || { unverifiedLines: [], excludedEntries: [] };
218
+
219
+ const confirmedEntries = [];
220
+ const unverifiedEntries = [...accountRaw.unverifiedLines];
221
+ const claimByTier = (entry) => {
222
+ if (entry.tier === 'unverified') unverifiedEntries.push({ file: entry.file, line: entry.line });
223
+ else confirmedEntries.push({ file: entry.file, line: entry.line });
224
+ };
225
+ for (const c of rawCallers) claimByTier(c);
226
+ for (const s of (rawCallers.shadowEntries || [])) claimByTier(s);
227
+ // Retained unverified-tier entries (routed drops — Phase 3 engine retention)
228
+ for (const u of (rawCallers.unverifiedEntries || [])) {
229
+ unverifiedEntries.push({ file: u.file, line: u.line });
230
+ }
231
+
232
+ return buildAccount(index, name, {
233
+ groundSet,
234
+ confirmedEntries,
235
+ unverifiedEntries,
236
+ excludedEntries: accountRaw.excludedEntries,
237
+ filtered,
238
+ });
239
+ }
240
+
241
+ /**
242
+ * Visible entries for ground call-lines no engine candidate claimed
243
+ * (reason `call-not-resolved`). The account already COUNTS them in its
244
+ * unverified total (core/account.js buildAccount); listing them closes the
245
+ * last visible-bucket gap — a real call edge at such a line was conserved
246
+ * in the arithmetic but rendered nowhere (cursive-measured: 24 oracle call
247
+ * edges sat invisible in reportedNonCall). Bare one-liners: file:line +
248
+ * source text, no caller enrichment (the engine had no candidate to enrich).
249
+ */
250
+ function callNotResolvedEntries(index, account, options = {}) {
251
+ let entries = (account && account.callNotResolved) || [];
252
+ if (options.exclude && options.exclude.length > 0) {
253
+ entries = entries.filter(e => index.matchesFilters(e.relativePath, { exclude: options.exclude }));
254
+ }
255
+ return entries.map(e => {
256
+ let content = '';
257
+ try {
258
+ content = (index._readFile(e.file).split('\n')[e.line - 1] || '').trim();
259
+ } catch { /* unreadable since scan — keep bare */ }
260
+ return {
261
+ file: e.file,
262
+ relativePath: e.relativePath,
263
+ line: e.line,
264
+ content,
265
+ callerName: null,
266
+ tier: 'unverified',
267
+ reason: 'call-not-resolved',
268
+ };
269
+ });
270
+ }
271
+
201
272
  /**
202
273
  * Context: quick caller/callee view for a symbol.
203
274
  *
@@ -219,11 +290,38 @@ function context(index, name, options = {}) {
219
290
  if (['class', 'struct', 'interface', 'type'].includes(def.type)) {
220
291
  const methods = index.findMethodsForType(name);
221
292
 
222
- let typeCallers = index.findCallers(name, { includeMethods: options.includeMethods, includeUncertain: options.includeUncertain });
293
+ // Pin caller resolution to the resolved class definition — same as the
294
+ // function path below. Without this, same-name classes in other files
295
+ // conflate (their usages attribute to whichever def displays).
296
+ let typeCallers = index.findCallers(name, { includeMethods: options.includeMethods, includeUncertain: options.includeUncertain, collectAccount: true, targetDefinitions: [def] });
297
+ const rawTypeCallers = typeCallers;
298
+ const typeFilteredByFlag = { exclude: 0, minConfidence: 0, unreachableOnly: 0 };
299
+ // Tier partition — same contract as the function path: constructor/usage
300
+ // sites without evidence are visible as unverified, never hidden.
301
+ let typeUnverified = [
302
+ ...typeCallers.filter(c => c.tier === 'unverified'),
303
+ ...(rawTypeCallers.unverifiedEntries || []),
304
+ ];
305
+ typeCallers = typeCallers.filter(c => c.tier !== 'unverified');
223
306
  // Apply exclude filter
224
307
  if (options.exclude && options.exclude.length > 0) {
308
+ const before = typeCallers.length + typeUnverified.length;
225
309
  typeCallers = typeCallers.filter(c => index.matchesFilters(c.relativePath, { exclude: options.exclude }));
310
+ typeUnverified = typeUnverified.filter(c => index.matchesFilters(c.relativePath, { exclude: options.exclude }));
311
+ typeFilteredByFlag.exclude = before - typeCallers.length - typeUnverified.length;
226
312
  }
313
+ const byFileLine = (a, b) => {
314
+ const fa = a.relativePath || a.file || '';
315
+ const fb = b.relativePath || b.file || '';
316
+ if (fa !== fb) return fa.localeCompare(fb);
317
+ return (a.line || 0) - (b.line || 0);
318
+ };
319
+ const typeAccount = composeAccount(index, name, rawTypeCallers,
320
+ typeFilteredByFlag.exclude > 0 ? { total: typeFilteredByFlag.exclude, byFlag: typeFilteredByFlag } : undefined);
321
+ typeUnverified.push(...callNotResolvedEntries(index, typeAccount, options));
322
+
323
+ typeCallers = [...typeCallers].sort(byFileLine);
324
+ typeUnverified = [...typeUnverified].sort(byFileLine);
227
325
 
228
326
  const result = {
229
327
  type: def.type,
@@ -240,7 +338,9 @@ function context(index, name, options = {}) {
240
338
  receiver: m.receiver
241
339
  })),
242
340
  // Also include places where the type is used in function parameters/returns
243
- callers: typeCallers
341
+ callers: typeCallers,
342
+ unverifiedCallers: typeUnverified,
343
+ meta: { account: typeAccount }
244
344
  };
245
345
 
246
346
  if (warnings.length > 0) {
@@ -251,12 +351,38 @@ function context(index, name, options = {}) {
251
351
  }
252
352
 
253
353
  const stats = { uncertain: 0 };
254
- let callers = index.findCallers(name, { includeMethods: options.includeMethods, includeUncertain: options.includeUncertain, stats, targetDefinitions: [def] });
354
+ let callers = index.findCallers(name, {
355
+ includeMethods: options.includeMethods,
356
+ includeUncertain: options.includeUncertain,
357
+ stats,
358
+ targetDefinitions: [def],
359
+ collectAccount: true,
360
+ // --all lifts the unverified enrichment cap (content + caller lookup)
361
+ unverifiedEnrichLimit: options.all ? Infinity : undefined,
362
+ });
255
363
  let callees = index.findCallees(def, { includeMethods: options.includeMethods, includeUncertain: options.includeUncertain, stats });
364
+ // Pre-display-filter result for the conservation account (filters below
365
+ // build new arrays and would lose the non-enumerable accountRaw).
366
+ const rawCallers = callers;
367
+ const filteredByFlag = { exclude: 0, minConfidence: 0, unreachableOnly: 0 };
368
+
369
+ // Tier partition (tiered caller contract): `callers` = confirmed tier
370
+ // only; unverified-tier entries (name match without binding/receiver
371
+ // evidence) render in their own section — visible, never silently hidden.
372
+ let unverifiedCallers = [
373
+ ...callers.filter(c => c.tier === 'unverified'),
374
+ ...(rawCallers.unverifiedEntries || []),
375
+ ];
376
+ callers = callers.filter(c => c.tier !== 'unverified');
256
377
 
257
378
  // Apply exclude filter
258
379
  if (options.exclude && options.exclude.length > 0) {
380
+ const before = callers.length;
259
381
  callers = callers.filter(c => index.matchesFilters(c.relativePath, { exclude: options.exclude }));
382
+ filteredByFlag.exclude = before - callers.length;
383
+ const beforeUnverified = unverifiedCallers.length;
384
+ unverifiedCallers = unverifiedCallers.filter(c => index.matchesFilters(c.relativePath, { exclude: options.exclude }));
385
+ filteredByFlag.exclude += beforeUnverified - unverifiedCallers.length;
260
386
  callees = callees.filter(c => index.matchesFilters(c.relativePath, { exclude: options.exclude }));
261
387
  }
262
388
 
@@ -269,18 +395,21 @@ function context(index, name, options = {}) {
269
395
  callers = callerResult.kept;
270
396
  callees = calleeResult.kept;
271
397
  confidenceFiltered = callerResult.filtered + calleeResult.filtered;
398
+ filteredByFlag.minConfidence = callerResult.filtered;
272
399
  }
273
400
 
274
401
  // Stable output ordering: callers by (file, line). Callees retain their
275
402
  // call-count order from findCallees (most-called first) — that's a value
276
403
  // the user expects, not a stability concern, since the secondary sort
277
404
  // by line keeps ties deterministic.
278
- callers = [...callers].sort((a, b) => {
405
+ const byFileLine = (a, b) => {
279
406
  const fa = a.relativePath || a.file || '';
280
407
  const fb = b.relativePath || b.file || '';
281
408
  if (fa !== fb) return fa.localeCompare(fb);
282
409
  return (a.line || 0) - (b.line || 0);
283
- });
410
+ };
411
+ callers = [...callers].sort(byFileLine);
412
+ unverifiedCallers = [...unverifiedCallers].sort(byFileLine);
284
413
  callees = [...callees].sort((a, b) => {
285
414
  // Primary: callCount desc (preserves "most-called first" UX)
286
415
  const ca = a.callCount || 0, cb = b.callCount || 0;
@@ -303,10 +432,22 @@ function context(index, name, options = {}) {
303
432
 
304
433
  // Optional: filter to unreachable-only (helps surface dead-path callers/callees)
305
434
  if (options.unreachableOnly) {
435
+ const before = callers.length;
306
436
  callers = callers.filter(c => !c.reachable);
437
+ filteredByFlag.unreachableOnly = before - callers.length;
307
438
  callees = callees.filter(c => !c.reachable);
308
439
  }
309
440
 
441
+ // Conservation account: reconciles the caller answer against the text
442
+ // ground set (pre-display truth; display-filter hides reported separately).
443
+ const filteredTotal = filteredByFlag.exclude + filteredByFlag.minConfidence + filteredByFlag.unreachableOnly;
444
+ const account = composeAccount(index, name, rawCallers,
445
+ filteredTotal > 0 ? { total: filteredTotal, byFlag: filteredByFlag } : undefined);
446
+ const ctxNotResolved = callNotResolvedEntries(index, account, options);
447
+ if (ctxNotResolved.length > 0) {
448
+ unverifiedCallers = [...unverifiedCallers, ...ctxNotResolved].sort(byFileLine);
449
+ }
450
+
310
451
  const callerHistogram = buildHistogram(callers);
311
452
  const calleeHistogram = buildHistogram(callees);
312
453
 
@@ -327,6 +468,7 @@ function context(index, name, options = {}) {
327
468
  params: def.params,
328
469
  returnType: def.returnType,
329
470
  callers,
471
+ unverifiedCallers,
330
472
  callees,
331
473
  callerHistogram,
332
474
  calleeHistogram,
@@ -338,6 +480,11 @@ function context(index, name, options = {}) {
338
480
  confidenceFiltered,
339
481
  includeMethods: !!options.includeMethods,
340
482
  projectLanguage: index._getPredominantLanguage(),
483
+ account,
484
+ // No detected entry points (e.g. library code) — reachability
485
+ // markers are meaningless and suppressed by formatters.
486
+ hasEntrypoints: reachableSet.size > 0,
487
+ ...(options.all && { all: true }),
341
488
  // Structural facts for reliability hints
342
489
  ...(def.isMethod && { isMethod: true }),
343
490
  ...(def.className && { className: def.className }),
@@ -692,22 +839,34 @@ function impact(index, name, options = {}) {
692
839
  'enum', 'trait', 'impl', 'record', 'namespace']);
693
840
  const defIsTypeDef = TYPE_DEF_KINDS.has(def.type);
694
841
 
695
- // BUG-H3: default includeMethods:true for impact ("what breaks if I change this"
696
- // should include every callable site including obj.method() invocations).
697
- // User can disable with --no-include-methods to scope down.
698
- const impactIncludeMethods = options.includeMethods ?? true;
842
+ // BUG-H3 + tiered contract: impact always analyzes every callable site
843
+ // method calls included unconditionally, tiered by receiver evidence.
844
+ // --no-include-methods is a deprecated no-op (evidence-less method sites
845
+ // land in the unverified tier instead of disappearing).
846
+ const impactIncludeMethods = true;
699
847
  const impactIncludeUncertain = options.includeUncertain ?? false;
700
848
 
701
849
  // Use findCallers for className-scoped or method queries (sophisticated binding resolution)
702
850
  // Fall back to usages-based approach for simple function queries (backward compatible)
703
851
  let callSites;
852
+ // Conservation accounting: engine-recorded drops + post-engine drops in
853
+ // this function (className filter, binding cross-check, method skips).
854
+ // Claims use ABSOLUTE paths (ground set is keyed by absolute file path).
855
+ let impactAccountRaw = null;
856
+ let impactRoutedUnverified = []; // engine-routed retained drops (unverifiedEntries)
857
+ const impactClaims = [];
858
+ const impactPostHocExcluded = [];
859
+ const impactPostHocUnverified = [];
704
860
  if (options.className || defIsMethod || defIsTypeDef) {
705
861
  // findCallers has proper method call resolution (self/this, binding IDs, receiver checks)
706
862
  let callerResults = index.findCallers(name, {
707
863
  includeMethods: impactIncludeMethods,
708
864
  includeUncertain: impactIncludeUncertain,
709
865
  targetDefinitions: [def],
866
+ collectAccount: true,
710
867
  });
868
+ impactAccountRaw = callerResults.accountRaw;
869
+ impactRoutedUnverified = callerResults.unverifiedEntries || [];
711
870
 
712
871
  // When the target definition has a className (including Go/Rust methods which
713
872
  // now get className from receiver), filter out method calls whose receiver
@@ -724,14 +883,14 @@ function impact(index, name, options = {}) {
724
883
  else if (d.receiver) _impClassNames.add(d.receiver.replace(/^\*/, ''));
725
884
  }
726
885
  }
727
- callerResults = callerResults.filter(c => {
886
+ const keepForTargetClass = (c) => {
728
887
  // Keep non-method calls and self/this/cls calls (already resolved by findCallers)
729
888
  if (!c.isMethod) return true;
730
889
  const r = c.receiver;
731
890
  if (r && ['self', 'cls', 'this', 'super'].includes(r)) return true;
732
891
  // Use receiverType from findCallers when available (Go/Java/Rust type inference)
733
892
  if (c.receiverType) {
734
- return c.receiverType === targetClassName;
893
+ return c.receiverType === targetClassName ? 'strong' : false;
735
894
  }
736
895
  // No receiver (chained/complex expression): only include if method is
737
896
  // unique or rare across types — otherwise too many false positives
@@ -770,7 +929,7 @@ function impact(index, name, options = {}) {
770
929
  }
771
930
  const receiverType = localTypes.get(r);
772
931
  if (receiverType) {
773
- return receiverType === targetClassName;
932
+ return receiverType === targetClassName ? 'strong' : false;
774
933
  }
775
934
  }
776
935
  }
@@ -792,7 +951,7 @@ function impact(index, name, options = {}) {
792
951
  const fieldMatch = line.match(new RegExp(`\\b(\\w+)(?:<[^>]*>)?\\s+${r.replace(/[.*+?^${}()|[\]\\]/g, '\\\\$&')}\\s*[;=]`));
793
952
  if (fieldMatch) {
794
953
  const fieldType = fieldMatch[1];
795
- if (fieldType === targetClassName) return true;
954
+ if (fieldType === targetClassName) return 'strong';
796
955
  break;
797
956
  }
798
957
  }
@@ -809,7 +968,7 @@ function impact(index, name, options = {}) {
809
968
  // Check if the type annotation contains the target class name
810
969
  const typeMatches = param.type.match(/\b([A-Za-z_]\w*)\b/g);
811
970
  if (typeMatches && typeMatches.some(t => t === targetClassName)) {
812
- return true;
971
+ return 'strong';
813
972
  }
814
973
  // Type annotation exists but doesn't match target class — filter out
815
974
  return false;
@@ -825,6 +984,24 @@ function impact(index, name, options = {}) {
825
984
  // Type-scoped query but receiver type unknown — filter it out.
826
985
  // Unknown receivers are likely unrelated.
827
986
  return false;
987
+ };
988
+ // Conservation: post-hoc rejects are positive type-mismatch evidence —
989
+ // they MOVE into the excluded bucket instead of vanishing. Survivors
990
+ // verified by STRONG evidence (receiverType / local constructor /
991
+ // field declaration / param annotation match) are upgraded to the
992
+ // confirmed tier — the filter just proved the receiver's type.
993
+ callerResults = callerResults.filter(c => {
994
+ const keep = keepForTargetClass(c);
995
+ if (!keep) {
996
+ impactPostHocExcluded.push({ file: c.file, line: c.line, reason: 'receiver-type-mismatch' });
997
+ return false;
998
+ }
999
+ if (keep === 'strong' && c.tier === 'unverified') {
1000
+ c.tier = 'confirmed';
1001
+ c.resolution = 'receiver-hint';
1002
+ c.confidence = 0.80;
1003
+ }
1004
+ return true;
828
1005
  });
829
1006
  }
830
1007
 
@@ -834,6 +1011,7 @@ function impact(index, name, options = {}) {
834
1011
  { file: c.file, relativePath: c.relativePath, line: c.line, content: c.content },
835
1012
  name
836
1013
  );
1014
+ impactClaims.push({ file: c.file, line: c.line, tier: c.tier });
837
1015
  callSites.push({
838
1016
  file: c.relativePath,
839
1017
  line: c.line,
@@ -843,6 +1021,7 @@ function impact(index, name, options = {}) {
843
1021
  callerStartLine: c.callerStartLine,
844
1022
  confidence: c.confidence,
845
1023
  resolution: c.resolution,
1024
+ ...(c.tier && { tier: c.tier }),
846
1025
  ...analysis
847
1026
  });
848
1027
  }
@@ -856,7 +1035,10 @@ function impact(index, name, options = {}) {
856
1035
  includeMethods: impactIncludeMethods,
857
1036
  includeUncertain: impactIncludeUncertain,
858
1037
  targetDefinitions: [def],
1038
+ collectAccount: true,
859
1039
  });
1040
+ impactAccountRaw = callerResults.accountRaw;
1041
+ impactRoutedUnverified = callerResults.unverifiedEntries || [];
860
1042
  const targetBindingId = def.bindingId;
861
1043
  // Convert findCallers results to the format expected by analyzeCallSite
862
1044
  const calls = callerResults.map(c => ({
@@ -870,6 +1052,7 @@ function impact(index, name, options = {}) {
870
1052
  callerStartLine: c.callerStartLine,
871
1053
  confidence: c.confidence,
872
1054
  resolution: c.resolution,
1055
+ tier: c.tier,
873
1056
  }));
874
1057
  // Keep the same binding filter for backward compat (findCallers already handles this,
875
1058
  // but cross-check with usages-based binding filter for safety)
@@ -887,6 +1070,7 @@ function impact(index, name, options = {}) {
887
1070
  }
888
1071
  }
889
1072
  if (localBindings.length > 0 && !localBindings.some(b => b.id === targetBindingId)) {
1073
+ impactPostHocExcluded.push({ file: u.file, line: u.line, reason: 'other-definition' });
890
1074
  return false;
891
1075
  }
892
1076
  }
@@ -915,12 +1099,15 @@ function impact(index, name, options = {}) {
915
1099
  if (matchedCall?.receiver === targetDir) {
916
1100
  // Receiver matches package directory — keep it
917
1101
  } else {
1102
+ impactPostHocUnverified.push({ file: call.file, line: call.line, reason: 'method-no-evidence' });
918
1103
  continue;
919
1104
  }
920
1105
  } else {
1106
+ impactPostHocUnverified.push({ file: call.file, line: call.line, reason: 'method-no-evidence' });
921
1107
  continue;
922
1108
  }
923
1109
  }
1110
+ impactClaims.push({ file: call.file, line: call.line, tier: call.tier });
924
1111
  callSites.push({
925
1112
  file: call.relativePath,
926
1113
  line: call.line,
@@ -930,16 +1117,45 @@ function impact(index, name, options = {}) {
930
1117
  callerStartLine: call.callerStartLine,
931
1118
  confidence: call.confidence,
932
1119
  resolution: call.resolution,
1120
+ ...(call.tier && { tier: call.tier }),
933
1121
  ...analysis
934
1122
  });
935
1123
  }
936
1124
  index._clearTreeCache();
937
1125
  }
938
1126
 
1127
+ // Tier partition: confirmed sites stay in callSites; unverified-tier sites
1128
+ // (incl. engine-routed retained drops) render in their own section.
1129
+ let unverifiedSites = callSites.filter(s => s.tier === 'unverified');
1130
+ callSites = callSites.filter(s => s.tier !== 'unverified');
1131
+ for (const u of impactRoutedUnverified) {
1132
+ unverifiedSites.push({
1133
+ file: u.relativePath,
1134
+ line: u.line,
1135
+ expression: (u.content || '').trim(),
1136
+ callerName: u.callerName ?? null,
1137
+ confidence: u.confidence,
1138
+ resolution: u.resolution,
1139
+ tier: 'unverified',
1140
+ ...(u.reason && { reason: u.reason }),
1141
+ ...(u.dispatchVia && { dispatchVia: u.dispatchVia }),
1142
+ ...(u.dispatchCandidates != null && { dispatchCandidates: u.dispatchCandidates }),
1143
+ });
1144
+ }
1145
+ unverifiedSites.sort((a, b) => {
1146
+ if (a.file !== b.file) return a.file.localeCompare(b.file);
1147
+ return (a.line || 0) - (b.line || 0);
1148
+ });
1149
+
939
1150
  // Apply exclude filter
1151
+ const impactFilteredByFlag = { exclude: 0, unreachableOnly: 0 };
940
1152
  let filteredSites = callSites;
941
1153
  if (options.exclude && options.exclude.length > 0) {
942
1154
  filteredSites = callSites.filter(s => index.matchesFilters(s.file, { exclude: options.exclude }));
1155
+ impactFilteredByFlag.exclude = callSites.length - filteredSites.length;
1156
+ const beforeUnverified = unverifiedSites.length;
1157
+ unverifiedSites = unverifiedSites.filter(s => index.matchesFilters(s.file, { exclude: options.exclude }));
1158
+ impactFilteredByFlag.exclude += beforeUnverified - unverifiedSites.length;
943
1159
  }
944
1160
 
945
1161
  // Trust signals: tag each call site with reachability, build a confidence histogram.
@@ -953,10 +1169,47 @@ function impact(index, name, options = {}) {
953
1169
  }
954
1170
  }
955
1171
  if (options.unreachableOnly) {
1172
+ const before = filteredSites.length;
956
1173
  filteredSites = filteredSites.filter(s => !s.reachable);
1174
+ impactFilteredByFlag.unreachableOnly = before - filteredSites.length;
957
1175
  }
958
1176
  const callerHistogram = buildHistogram(filteredSites);
959
1177
 
1178
+ // Conservation account: claims from ALL call sites (pre-display filters,
1179
+ // pre-top truncation) plus engine-recorded and post-hoc drops.
1180
+ const impactAccount = (() => {
1181
+ const { computeGroundSet, buildAccount } = require('./account');
1182
+ const groundSet = computeGroundSet(index, name);
1183
+ const confirmedEntries = [];
1184
+ const unverifiedEntries = [...(impactAccountRaw?.unverifiedLines || []), ...impactPostHocUnverified];
1185
+ for (const u of impactRoutedUnverified) unverifiedEntries.push({ file: u.file, line: u.line });
1186
+ for (const cl of impactClaims) {
1187
+ if (cl.tier === 'unverified') unverifiedEntries.push(cl);
1188
+ else confirmedEntries.push(cl);
1189
+ }
1190
+ const excludedEntries = [...(impactAccountRaw?.excludedEntries || []), ...impactPostHocExcluded];
1191
+ const filteredTotal = impactFilteredByFlag.exclude + impactFilteredByFlag.unreachableOnly;
1192
+ return buildAccount(index, name, {
1193
+ groundSet,
1194
+ confirmedEntries,
1195
+ unverifiedEntries,
1196
+ excludedEntries,
1197
+ filtered: filteredTotal > 0 ? { total: filteredTotal, byFlag: impactFilteredByFlag } : undefined,
1198
+ });
1199
+ })();
1200
+ // Impact's unverified sites carry relative paths in `file`
1201
+ const impactNotResolved = callNotResolvedEntries(index, impactAccount, options)
1202
+ .map(e => ({
1203
+ file: e.relativePath, line: e.line, expression: e.content,
1204
+ callerName: null, tier: 'unverified', reason: e.reason,
1205
+ }));
1206
+ if (impactNotResolved.length > 0) {
1207
+ unverifiedSites = [...unverifiedSites, ...impactNotResolved].sort((a, b) => {
1208
+ if (a.file !== b.file) return a.file.localeCompare(b.file);
1209
+ return (a.line || 0) - (b.line || 0);
1210
+ });
1211
+ }
1212
+
960
1213
  // Apply top limit if specified (limits total call sites shown)
961
1214
  const totalBeforeLimit = filteredSites.length;
962
1215
  if (options.top && options.top > 0 && filteredSites.length > options.top) {
@@ -1007,6 +1260,9 @@ function impact(index, name, options = {}) {
1007
1260
  paramsStructured: def.paramsStructured,
1008
1261
  totalCallSites: totalBeforeLimit,
1009
1262
  shownCallSites: filteredSites.length,
1263
+ unverifiedSites,
1264
+ account: impactAccount,
1265
+ hasEntrypoints: impactReachable.size > 0,
1010
1266
  callerHistogram,
1011
1267
  // Stable ordering: files alphabetical, sites by line ascending. Documented contract.
1012
1268
  byFile: Array.from(byFile.entries())
@@ -1130,6 +1386,8 @@ function about(index, name, options = {}) {
1130
1386
  let allCallers = null;
1131
1387
  let allCallees = null;
1132
1388
  let aboutConfFiltered = 0;
1389
+ let aboutAccount = null;
1390
+ let aboutUnverified = { total: 0, top: [] };
1133
1391
  // BUG-M3: include classes/structs/interfaces — `new Foo()` invocations are
1134
1392
  // tracked as calls in the parser (isConstructor:true) and findCallers resolves
1135
1393
  // them. Without this, `about ClassName` produced "USAGES: 5 calls" but no
@@ -1145,12 +1403,25 @@ function about(index, name, options = {}) {
1145
1403
  // BUG-H1: pass needsTotal:true so the returned array's `totalCount` reflects the
1146
1404
  // true pre-truncation candidate count. Without this, `about` would report the
1147
1405
  // capped count as the total (e.g. "showing 10 of 30" when there are actually 153).
1148
- const rawCallers = index.findCallers(symbolName, { includeMethods, includeUncertain: options.includeUncertain, targetDefinitions: [primary], maxResults: callerCap, needsTotal: true });
1406
+ const rawCallers = index.findCallers(symbolName, { includeMethods, includeUncertain: options.includeUncertain, targetDefinitions: [primary], maxResults: callerCap, needsTotal: true, collectAccount: true });
1149
1407
  const shadowCallers = rawCallers.shadowEntries || [];
1150
1408
  allCallers = rawCallers;
1409
+ const aboutFilteredByFlag = { exclude: 0, minConfidence: 0, unreachableOnly: 0 };
1410
+ // Tier partition: confirmed callers stay in allCallers; unverified-tier
1411
+ // entries (incl. engine-routed retained drops) get their own section.
1412
+ let unverifiedPool = [
1413
+ ...allCallers.filter(c => c.tier === 'unverified'),
1414
+ ...(rawCallers.unverifiedEntries || []),
1415
+ ];
1416
+ allCallers = allCallers.filter(c => c.tier !== 'unverified');
1151
1417
  // Apply exclude filter before slicing
1152
1418
  if (options.exclude && options.exclude.length > 0) {
1419
+ const before = allCallers.length;
1153
1420
  allCallers = allCallers.filter(c => index.matchesFilters(c.relativePath, { exclude: options.exclude }));
1421
+ aboutFilteredByFlag.exclude = before - allCallers.length;
1422
+ const beforeUnverified = unverifiedPool.length;
1423
+ unverifiedPool = unverifiedPool.filter(c => index.matchesFilters(c.relativePath, { exclude: options.exclude }));
1424
+ aboutFilteredByFlag.exclude += beforeUnverified - unverifiedPool.length;
1154
1425
  }
1155
1426
  // Apply confidence filtering before slicing
1156
1427
  if (options.minConfidence > 0) {
@@ -1158,10 +1429,13 @@ function about(index, name, options = {}) {
1158
1429
  const callerResult = filterByConfidence(allCallers, options.minConfidence);
1159
1430
  allCallers = callerResult.kept;
1160
1431
  aboutConfFiltered += callerResult.filtered;
1432
+ aboutFilteredByFlag.minConfidence = callerResult.filtered;
1161
1433
  }
1162
1434
  // BUG-H1: post-filter total — count the un-enriched shadow candidates that
1163
1435
  // also pass the same filters, so the displayed "showing N of <total>"
1164
1436
  // matches what `context` (which runs unbounded) would have shown.
1437
+ // Per-tier: unverified-tier shadows count toward the unverified total,
1438
+ // never toward the confirmed total.
1165
1439
  let shadowSurvivors = shadowCallers;
1166
1440
  if (options.exclude && options.exclude.length > 0) {
1167
1441
  shadowSurvivors = shadowSurvivors.filter(c => index.matchesFilters(c.relativePath, { exclude: options.exclude }));
@@ -1169,6 +1443,8 @@ function about(index, name, options = {}) {
1169
1443
  if (options.minConfidence > 0) {
1170
1444
  shadowSurvivors = shadowSurvivors.filter(c => (c.confidence || 0) >= options.minConfidence);
1171
1445
  }
1446
+ const unverifiedShadowCount = shadowSurvivors.filter(s => s.tier === 'unverified').length;
1447
+ shadowSurvivors = shadowSurvivors.filter(s => s.tier !== 'unverified');
1172
1448
  // Tag reachability on raw caller objects so we can preserve the field on the projection.
1173
1449
  // Reachability is computed once per index and cached.
1174
1450
  const aboutReachable = computeReachability(index);
@@ -1176,7 +1452,9 @@ function about(index, name, options = {}) {
1176
1452
 
1177
1453
  // Optional: filter to unreachable-only callers
1178
1454
  if (options.unreachableOnly) {
1455
+ const before = allCallers.length;
1179
1456
  allCallers = allCallers.filter(c => !c.reachable);
1457
+ aboutFilteredByFlag.unreachableOnly = before - allCallers.length;
1180
1458
  // Apply same filter to shadows using their callerStartLine/file when available.
1181
1459
  // Shadows lack callerStartLine, so they're treated as reachable=false (conservative,
1182
1460
  // matches the historical behavior where un-enriched callers had no reachability info).
@@ -1184,6 +1462,12 @@ function about(index, name, options = {}) {
1184
1462
  // building a perfect estimate isn't justified.
1185
1463
  shadowSurvivors = []; // conservative — drop shadows for unreachableOnly mode
1186
1464
  }
1465
+ // Conservation account: claims from the PRE-filter rawCallers (+shadows);
1466
+ // display-filter hides are explanatory metadata, outside the invariant.
1467
+ const aboutFilteredTotal = aboutFilteredByFlag.exclude + aboutFilteredByFlag.minConfidence + aboutFilteredByFlag.unreachableOnly;
1468
+ aboutAccount = composeAccount(index, symbolName, rawCallers,
1469
+ aboutFilteredTotal > 0 ? { total: aboutFilteredTotal, byFlag: aboutFilteredByFlag } : undefined);
1470
+ unverifiedPool.push(...callNotResolvedEntries(index, aboutAccount, options));
1187
1471
  // Stash the post-filter total on allCallers so the result builder can use it.
1188
1472
  Object.defineProperty(allCallers, '__postFilterTotal', {
1189
1473
  value: allCallers.length + shadowSurvivors.length,
@@ -1214,6 +1498,31 @@ function about(index, name, options = {}) {
1214
1498
  reachable: c.reachable,
1215
1499
  }));
1216
1500
 
1501
+ // Unverified tier projection: visible, capped, with the drop reason.
1502
+ unverifiedPool.sort((a, b) => {
1503
+ const fa = a.relativePath || '';
1504
+ const fb = b.relativePath || '';
1505
+ if (fa !== fb) return fa.localeCompare(fb);
1506
+ return (a.line || 0) - (b.line || 0);
1507
+ });
1508
+ aboutUnverified = {
1509
+ total: unverifiedPool.length + unverifiedShadowCount,
1510
+ top: unverifiedPool.slice(0, 10).map(c => ({
1511
+ file: c.relativePath,
1512
+ line: c.line,
1513
+ ...(c.callerStartLine && c.callerName && {
1514
+ handle: `${c.relativePath}:${c.callerStartLine}:${c.callerName}`
1515
+ }),
1516
+ expression: (c.content || '').trim(),
1517
+ callerName: c.callerName ?? null,
1518
+ confidence: c.confidence,
1519
+ resolution: c.resolution,
1520
+ ...(c.reason && { reason: c.reason }),
1521
+ ...(c.dispatchVia && { dispatchVia: c.dispatchVia }),
1522
+ ...(c.dispatchCandidates != null && { dispatchCandidates: c.dispatchCandidates }),
1523
+ })),
1524
+ };
1525
+
1217
1526
  // BUG-M3: classes/structs/interfaces don't have meaningful callees
1218
1527
  // (their body is methods, not a sequence of calls). Skip findCallees
1219
1528
  // for type definitions — callers (constructor/instantiation sites)
@@ -1357,8 +1666,10 @@ function about(index, name, options = {}) {
1357
1666
  // BUG-H1: prefer post-filter total (computed from enriched + shadow candidates).
1358
1667
  // Falls back to allCallers.length when the post-filter total wasn't computed
1359
1668
  // (e.g., when primary is not a function and findCallers wasn't called).
1669
+ // Since the tier partition, this total counts CONFIRMED callers only.
1360
1670
  total: allCallers?.__postFilterTotal ?? allCallers?.length ?? 0,
1361
1671
  top: callers,
1672
+ unverified: aboutUnverified,
1362
1673
  // R3-NEW-1: include shadow callers (un-enriched candidates that passed the
1363
1674
  // same filters) so the histogram counts sum to `total`, not maxResults*3.
1364
1675
  histogram: buildHistogram(
@@ -1381,6 +1692,8 @@ function about(index, name, options = {}) {
1381
1692
  types,
1382
1693
  code,
1383
1694
  includeMethods,
1695
+ ...(aboutAccount && { account: aboutAccount }),
1696
+ ...(allCallers && { hasEntrypoints: computeReachability(index).size > 0 }),
1384
1697
  ...(aboutConfFiltered > 0 && { confidenceFiltered: aboutConfFiltered }),
1385
1698
  // BUG-M4: surface ambiguous-resolution warnings so formatters can render
1386
1699
  // a "auto-selected ... pass --file to choose" note.
@@ -2324,4 +2637,11 @@ module.exports = {
2324
2637
  unquoteDiffPath,
2325
2638
  auditAsync,
2326
2639
  tagInTestCase,
2640
+ // Exported for tests: with the callback-gate fix every natural producer
2641
+ // of call-not-resolved is closed — the listing contract (counted ⇒
2642
+ // listed) is pinned directly since no fixture can reach it end-to-end.
2643
+ callNotResolvedEntries,
2644
+ // Shared with tracing.js (trace/blast/reverseTrace root accounts) — the
2645
+ // tree commands compose the same text-ground account at their root hop.
2646
+ composeAccount,
2327
2647
  };