ucn 4.0.2 → 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 (46) 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 +322 -304
  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 +52 -33
  19. package/core/output/check.js +4 -1
  20. package/core/output/endpoints.js +8 -3
  21. package/core/output/extraction.js +12 -1
  22. package/core/output/find.js +23 -9
  23. package/core/output/graph.js +32 -9
  24. package/core/output/refactoring.js +147 -49
  25. package/core/output/reporting.js +104 -5
  26. package/core/output/search.js +30 -3
  27. package/core/output/shared.js +31 -4
  28. package/core/output/tracing.js +22 -11
  29. package/core/parser.js +8 -6
  30. package/core/project.js +167 -7
  31. package/core/registry.js +20 -16
  32. package/core/reporting.js +131 -13
  33. package/core/search.js +270 -55
  34. package/core/shared.js +240 -1
  35. package/core/stacktrace.js +23 -1
  36. package/core/tracing.js +278 -36
  37. package/core/verify.js +352 -349
  38. package/languages/go.js +29 -17
  39. package/languages/index.js +56 -0
  40. package/languages/java.js +133 -16
  41. package/languages/javascript.js +215 -27
  42. package/languages/python.js +113 -47
  43. package/languages/rust.js +89 -26
  44. package/languages/utils.js +35 -7
  45. package/mcp/server.js +69 -30
  46. package/package.json +4 -1
package/core/analysis.js CHANGED
@@ -12,7 +12,7 @@ const path = require('path');
12
12
  const { execFileSync } = require('child_process');
13
13
  const { parse } = require('./parser');
14
14
  const { detectLanguage, langTraits } = require('../languages');
15
- const { NON_CALLABLE_TYPES, addTestExclusions, countTextBlindspots } = require('./shared');
15
+ const { NON_CALLABLE_TYPES, addTestExclusions, countTextBlindspots, codeUnitCompare } = require('./shared');
16
16
  const { computeReachability, symbolKey } = require('./entrypoints');
17
17
  const { getLanguageModule } = require('../languages');
18
18
 
@@ -154,10 +154,12 @@ function tagCallersReachable(callers, reachableSet) {
154
154
  for (const c of callers) {
155
155
  if (c.callerFile && c.callerStartLine != null) {
156
156
  c.reachable = reachableSet.has(symbolKey(c.callerFile, c.callerStartLine));
157
- } else {
158
- // Module-level / unknown caller — treat as unreachable (no enclosing function)
159
- c.reachable = false;
160
157
  }
158
+ // Module-level callers get NO verdict (fix #256, rich-measured:
159
+ // every examples/*.py module-level `console.print(table)` rendered
160
+ // [unreachable]): the site executes at IMPORT time — function-graph
161
+ // reachability cannot speak for it, and "unreachable" invites
162
+ // deleting live code. Formatters mark only `reachable === false`.
161
163
  }
162
164
  return callers;
163
165
  }
@@ -255,7 +257,7 @@ function callNotResolvedEntries(index, account, options = {}) {
255
257
  return entries.map(e => {
256
258
  let content = '';
257
259
  try {
258
- content = (index._readFile(e.file).split('\n')[e.line - 1] || '').trim();
260
+ content = (index._getFileLines(e.file)[e.line - 1] || '').trim();
259
261
  } catch { /* unreadable since scan — keep bare */ }
260
262
  return {
261
263
  file: e.file,
@@ -286,6 +288,13 @@ function context(index, name, options = {}) {
286
288
  return null;
287
289
  }
288
290
 
291
+ // Default includeMethods like about/impact (fix #234, campaign-measured:
292
+ // the same pinned method was confirmed in about but unverified in
293
+ // context — the tier depended on which command asked): true for method
294
+ // targets, where method calls are the primary invocation form.
295
+ const includeMethods = options.includeMethods ??
296
+ !!(def.isMethod || def.type === 'method' || def.className);
297
+
289
298
  // Special handling for class/struct/interface types
290
299
  if (['class', 'struct', 'interface', 'type'].includes(def.type)) {
291
300
  const methods = index.findMethodsForType(name);
@@ -293,7 +302,7 @@ function context(index, name, options = {}) {
293
302
  // Pin caller resolution to the resolved class definition — same as the
294
303
  // function path below. Without this, same-name classes in other files
295
304
  // conflate (their usages attribute to whichever def displays).
296
- let typeCallers = index.findCallers(name, { includeMethods: options.includeMethods, includeUncertain: options.includeUncertain, collectAccount: true, targetDefinitions: [def] });
305
+ let typeCallers = index.findCallers(name, { includeMethods, includeUncertain: options.includeUncertain, collectAccount: true, targetDefinitions: [def] });
297
306
  const rawTypeCallers = typeCallers;
298
307
  const typeFilteredByFlag = { exclude: 0, minConfidence: 0, unreachableOnly: 0 };
299
308
  // Tier partition — same contract as the function path: constructor/usage
@@ -313,7 +322,7 @@ function context(index, name, options = {}) {
313
322
  const byFileLine = (a, b) => {
314
323
  const fa = a.relativePath || a.file || '';
315
324
  const fb = b.relativePath || b.file || '';
316
- if (fa !== fb) return fa.localeCompare(fb);
325
+ if (fa !== fb) return codeUnitCompare(fa, fb);
317
326
  return (a.line || 0) - (b.line || 0);
318
327
  };
319
328
  const typeAccount = composeAccount(index, name, rawTypeCallers,
@@ -352,7 +361,7 @@ function context(index, name, options = {}) {
352
361
 
353
362
  const stats = { uncertain: 0 };
354
363
  let callers = index.findCallers(name, {
355
- includeMethods: options.includeMethods,
364
+ includeMethods,
356
365
  includeUncertain: options.includeUncertain,
357
366
  stats,
358
367
  targetDefinitions: [def],
@@ -360,7 +369,11 @@ function context(index, name, options = {}) {
360
369
  // --all lifts the unverified enrichment cap (content + caller lookup)
361
370
  unverifiedEnrichLimit: options.all ? Infinity : undefined,
362
371
  });
363
- let callees = index.findCallees(def, { includeMethods: options.includeMethods, includeUncertain: options.includeUncertain, stats });
372
+ // Callee side runs the contract too (v4): uncertain/unresolved calls in
373
+ // the def's scope become VISIBLE unverifiedCallees entries + a conserved
374
+ // per-node calleeAccount instead of silently dropping (#223 machinery).
375
+ let callees = index.findCallees(def, { includeMethods, includeUncertain: options.includeUncertain, stats, collectAccount: true });
376
+ const rawCallees = callees;
364
377
  // Pre-display-filter result for the conservation account (filters below
365
378
  // build new arrays and would lose the non-enumerable accountRaw).
366
379
  const rawCallers = callers;
@@ -405,7 +418,7 @@ function context(index, name, options = {}) {
405
418
  const byFileLine = (a, b) => {
406
419
  const fa = a.relativePath || a.file || '';
407
420
  const fb = b.relativePath || b.file || '';
408
- if (fa !== fb) return fa.localeCompare(fb);
421
+ if (fa !== fb) return codeUnitCompare(fa, fb);
409
422
  return (a.line || 0) - (b.line || 0);
410
423
  };
411
424
  callers = [...callers].sort(byFileLine);
@@ -417,7 +430,7 @@ function context(index, name, options = {}) {
417
430
  // Tiebreaker: file then line, for determinism
418
431
  const fa = a.relativePath || a.file || '';
419
432
  const fb = b.relativePath || b.file || '';
420
- if (fa !== fb) return fa.localeCompare(fb);
433
+ if (fa !== fb) return codeUnitCompare(fa, fb);
421
434
  return (a.startLine || 0) - (b.startLine || 0);
422
435
  });
423
436
 
@@ -433,9 +446,9 @@ function context(index, name, options = {}) {
433
446
  // Optional: filter to unreachable-only (helps surface dead-path callers/callees)
434
447
  if (options.unreachableOnly) {
435
448
  const before = callers.length;
436
- callers = callers.filter(c => !c.reachable);
449
+ callers = callers.filter(c => c.reachable === false);
437
450
  filteredByFlag.unreachableOnly = before - callers.length;
438
- callees = callees.filter(c => !c.reachable);
451
+ callees = callees.filter(c => c.reachable === false);
439
452
  }
440
453
 
441
454
  // Conservation account: reconciles the caller answer against the text
@@ -470,6 +483,7 @@ function context(index, name, options = {}) {
470
483
  callers,
471
484
  unverifiedCallers,
472
485
  callees,
486
+ unverifiedCallees: rawCallees.unverifiedCallees || [],
473
487
  callerHistogram,
474
488
  calleeHistogram,
475
489
  meta: {
@@ -478,9 +492,10 @@ function context(index, name, options = {}) {
478
492
  dynamicImports,
479
493
  uncertain: stats.uncertain,
480
494
  confidenceFiltered,
481
- includeMethods: !!options.includeMethods,
495
+ includeMethods: !!includeMethods,
482
496
  projectLanguage: index._getPredominantLanguage(),
483
497
  account,
498
+ calleeAccount: rawCallees.calleeAccount,
484
499
  // No detected entry points (e.g. library code) — reachability
485
500
  // markers are meaningless and suppressed by formatters.
486
501
  hasEntrypoints: reachableSet.size > 0,
@@ -492,6 +507,19 @@ function context(index, name, options = {}) {
492
507
  }
493
508
  };
494
509
 
510
+ // Constructor pins surface their invocation sites under the CLASS pin
511
+ // (`new Widget()` indexes under 'Widget' — the fix #239 liveness rule;
512
+ // fix #254 makes it discoverable): a bare empty answer sent readers
513
+ // hunting for callers the class pin already has.
514
+ if (def.className && callers.length === 0 &&
515
+ (def.type === 'constructor' || name === 'constructor' || name === '__init__') &&
516
+ index.getCalleeFiles(def.className)) {
517
+ warnings.push({
518
+ type: 'hint',
519
+ message: `Constructor invocations (\`new ${def.className}(...)\` / \`${def.className}(...)\`) are indexed under the class name — query \`${def.className}\` for the call sites.`,
520
+ });
521
+ }
522
+
495
523
  if (warnings.length > 0) {
496
524
  result.warnings = warnings;
497
525
  }
@@ -517,7 +545,12 @@ function smart(index, name, options = {}) {
517
545
  }
518
546
  const code = index.extractCode(def);
519
547
  const stats = { uncertain: 0 };
520
- const callees = index.findCallees(def, { includeMethods: options.includeMethods, includeUncertain: options.includeUncertain, stats });
548
+ // Method-target default like about/impact/context (fix #234).
549
+ const includeMethods = options.includeMethods ??
550
+ !!(def.isMethod || def.type === 'method' || def.className);
551
+ // Callee contract (v4): uncertain/unresolved calls surface as visible
552
+ // unverifiedCallees + a conserved calleeAccount, never a silent drop.
553
+ const callees = index.findCallees(def, { includeMethods, includeUncertain: options.includeUncertain, stats, collectAccount: true });
521
554
 
522
555
  const filesInScope = new Set([def.file]);
523
556
  callees.forEach(c => filesInScope.add(c.file));
@@ -557,6 +590,7 @@ function smart(index, name, options = {}) {
557
590
  }
558
591
  }
559
592
 
593
+ const smartUnverified = callees.unverifiedCallees || [];
560
594
  return {
561
595
  target: {
562
596
  ...def,
@@ -564,12 +598,14 @@ function smart(index, name, options = {}) {
564
598
  },
565
599
  dependencies,
566
600
  types,
601
+ unverifiedCallees: smartUnverified,
567
602
  meta: {
568
- complete: stats.uncertain === 0 && dynamicImports === 0,
603
+ complete: stats.uncertain === 0 && dynamicImports === 0 && smartUnverified.length === 0,
569
604
  skipped: 0,
570
605
  dynamicImports,
571
606
  uncertain: stats.uncertain,
572
- projectLanguage: index._getPredominantLanguage()
607
+ projectLanguage: index._getPredominantLanguage(),
608
+ calleeAccount: callees.calleeAccount
573
609
  }
574
610
  };
575
611
  } finally { index._endOp(); }
@@ -668,6 +704,10 @@ function related(index, name, options = {}) {
668
704
  return null;
669
705
  }
670
706
  const related = {
707
+ // Advisory command (v4 two-tier surface): ranked similarity
708
+ // heuristics, not a verified claim — formatters print the label,
709
+ // JSON consumers read the field.
710
+ advisory: 'similarity-heuristics',
671
711
  target: {
672
712
  name: def.name,
673
713
  file: def.relativePath,
@@ -696,6 +736,12 @@ function related(index, name, options = {}) {
696
736
  related.sameFile.sort((a, b) =>
697
737
  Math.abs(a.line - def.startLine) - Math.abs(b.line - def.startLine)
698
738
  );
739
+ // --top caps every section the same way (fix #230 — sameFile ignored
740
+ // it, so JSON consumers got the full list while similarNames et al.
741
+ // were capped).
742
+ const sameFileLimit = options.top || (options.all ? Infinity : 10);
743
+ related.sameFileTotal = related.sameFile.length;
744
+ if (related.sameFile.length > sameFileLimit) related.sameFile = related.sameFile.slice(0, sameFileLimit);
699
745
  }
700
746
 
701
747
  // 2. Similar names (shared prefix/suffix, camelCase similarity)
@@ -868,144 +914,15 @@ function impact(index, name, options = {}) {
868
914
  impactAccountRaw = callerResults.accountRaw;
869
915
  impactRoutedUnverified = callerResults.unverifiedEntries || [];
870
916
 
871
- // When the target definition has a className (including Go/Rust methods which
872
- // now get className from receiver), filter out method calls whose receiver
873
- // clearly belongs to a different type. This helps with common method names
874
- // like .close(), .get() etc. where many types have the same method.
875
- if (def.className) {
876
- const targetClassName = def.className;
877
- // Pre-compute how many types share this method name
878
- const _impMethodDefs = index.symbols.get(name);
879
- const _impClassNames = new Set();
880
- if (_impMethodDefs) {
881
- for (const d of _impMethodDefs) {
882
- if (d.className) _impClassNames.add(d.className);
883
- else if (d.receiver) _impClassNames.add(d.receiver.replace(/^\*/, ''));
884
- }
885
- }
886
- const keepForTargetClass = (c) => {
887
- // Keep non-method calls and self/this/cls calls (already resolved by findCallers)
888
- if (!c.isMethod) return true;
889
- const r = c.receiver;
890
- if (r && ['self', 'cls', 'this', 'super'].includes(r)) return true;
891
- // Use receiverType from findCallers when available (Go/Java/Rust type inference)
892
- if (c.receiverType) {
893
- return c.receiverType === targetClassName ? 'strong' : false;
894
- }
895
- // No receiver (chained/complex expression): only include if method is
896
- // unique or rare across types — otherwise too many false positives
897
- if (!r) {
898
- return _impClassNames.size <= 1;
899
- }
900
- // Check if receiver matches the target class name (case-insensitive camelCase convention)
901
- if (r.toLowerCase().includes(targetClassName.toLowerCase())) return true;
902
- // Check if receiver is an instance of the target class using local variable type inference
903
- if (c.callerFile) {
904
- const callerDef = c.callerStartLine ? { file: c.callerFile, startLine: c.callerStartLine, endLine: c.callerEndLine } : null;
905
- if (callerDef) {
906
- const callerCalls = index.getCachedCalls(c.callerFile);
907
- if (callerCalls && Array.isArray(callerCalls)) {
908
- const localTypes = new Map();
909
- for (const call of callerCalls) {
910
- if (call.line >= callerDef.startLine && call.line <= callerDef.endLine) {
911
- if (!call.isMethod && !call.receiver) {
912
- const syms = index.symbols.get(call.name);
913
- if (syms && syms.some(s => s.type === 'class')) {
914
- // Found a constructor call — check for assignment pattern
915
- const fileEntry = index.files.get(c.callerFile);
916
- if (fileEntry) {
917
- const content = index._readFile(c.callerFile);
918
- const lines = content.split('\n');
919
- const line = lines[call.line - 1] || '';
920
- // Match "var = ClassName(...)" or "var = new ClassName(...)" or "Type var = new ClassName<>(...)"
921
- const m = line.match(/(\w+)\s*=\s*(?:await\s+)?(?:new\s+)?(\w+)\s*(?:<[^>]*>)?\s*\(/);
922
- if (m && m[2] === call.name) {
923
- localTypes.set(m[1], call.name);
924
- }
925
- }
926
- }
927
- }
928
- }
929
- }
930
- const receiverType = localTypes.get(r);
931
- if (receiverType) {
932
- return receiverType === targetClassName ? 'strong' : false;
933
- }
934
- }
935
- }
936
- }
937
- // Check class field declarations for receiver type: private DataService service
938
- if (c.callerFile) {
939
- const callerEnclosing = index.findEnclosingFunction(c.callerFile, c.line, true);
940
- if (callerEnclosing?.className) {
941
- const classSyms = index.symbols.get(callerEnclosing.className);
942
- if (classSyms) {
943
- const classDef = classSyms.find(s => s.type === 'class' || s.type === 'struct' || s.type === 'interface');
944
- if (classDef) {
945
- const content = index._readFile(c.callerFile);
946
- const lines = content.split('\n');
947
- // Scan class body for field declarations matching the receiver
948
- for (let li = classDef.startLine - 1; li < (classDef.endLine || classDef.startLine + 50) && li < lines.length; li++) {
949
- const line = lines[li];
950
- // Match Java/TS field: [modifiers] TypeName<...> receiverName [= ...]
951
- const fieldMatch = line.match(new RegExp(`\\b(\\w+)(?:<[^>]*>)?\\s+${r.replace(/[.*+?^${}()|[\]\\]/g, '\\\\$&')}\\s*[;=]`));
952
- if (fieldMatch) {
953
- const fieldType = fieldMatch[1];
954
- if (fieldType === targetClassName) return 'strong';
955
- break;
956
- }
957
- }
958
- }
959
- }
960
- }
961
- }
962
- // Check parameter type annotations: def foo(tracker: SourceTracker) → tracker.record()
963
- if (c.callerFile && c.callerStartLine) {
964
- const callerSymbol = index.findEnclosingFunction(c.callerFile, c.line, true);
965
- if (callerSymbol && callerSymbol.paramsStructured) {
966
- for (const param of callerSymbol.paramsStructured) {
967
- if (param.name === r && param.type) {
968
- // Check if the type annotation contains the target class name
969
- const typeMatches = param.type.match(/\b([A-Za-z_]\w*)\b/g);
970
- if (typeMatches && typeMatches.some(t => t === targetClassName)) {
971
- return 'strong';
972
- }
973
- // Type annotation exists but doesn't match target class — filter out
974
- return false;
975
- }
976
- }
977
- }
978
- }
979
- // Unique method heuristic: if the called method exists on exactly one class/type
980
- // and it matches the target, include the call (no other class could match)
981
- if (_impClassNames.size === 1 && _impClassNames.has(targetClassName)) {
982
- return true;
983
- }
984
- // Type-scoped query but receiver type unknown — filter it out.
985
- // Unknown receivers are likely unrelated.
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;
1005
- });
1006
- }
917
+ // No post-hoc receiver filter here (fix #228, the #225 precedent):
918
+ // the engine's receiver physics (#198/#202/#204/#206/#219/#220) decide
919
+ // tier and exclusion inside findCallers with the pinned definition.
920
+ // The old hand-rolled filter re-derived receiver types with regexes
921
+ // and DROPPED confirmed field-hop/embedding callers that context and
922
+ // about kept — impact answered differently from its siblings on the
923
+ // same pin.
1007
924
 
1008
- callSites = [];
925
+ callSites = []; callSites = [];
1009
926
  for (const c of callerResults) {
1010
927
  const analysis = index.analyzeCallSite(
1011
928
  { file: c.file, relativePath: c.relativePath, line: c.line, content: c.content },
@@ -1143,7 +1060,7 @@ function impact(index, name, options = {}) {
1143
1060
  });
1144
1061
  }
1145
1062
  unverifiedSites.sort((a, b) => {
1146
- if (a.file !== b.file) return a.file.localeCompare(b.file);
1063
+ if (a.file !== b.file) return codeUnitCompare(a.file, b.file);
1147
1064
  return (a.line || 0) - (b.line || 0);
1148
1065
  });
1149
1066
 
@@ -1164,13 +1081,12 @@ function impact(index, name, options = {}) {
1164
1081
  for (const site of filteredSites) {
1165
1082
  if (site.callerFile && site.callerStartLine != null) {
1166
1083
  site.reachable = impactReachable.has(symbolKey(site.callerFile, site.callerStartLine));
1167
- } else {
1168
- site.reachable = false;
1169
1084
  }
1085
+ // Module-level sites: no verdict — import-time execution (fix #256).
1170
1086
  }
1171
1087
  if (options.unreachableOnly) {
1172
1088
  const before = filteredSites.length;
1173
- filteredSites = filteredSites.filter(s => !s.reachable);
1089
+ filteredSites = filteredSites.filter(s => s.reachable === false);
1174
1090
  impactFilteredByFlag.unreachableOnly = before - filteredSites.length;
1175
1091
  }
1176
1092
  const callerHistogram = buildHistogram(filteredSites);
@@ -1205,7 +1121,7 @@ function impact(index, name, options = {}) {
1205
1121
  }));
1206
1122
  if (impactNotResolved.length > 0) {
1207
1123
  unverifiedSites = [...unverifiedSites, ...impactNotResolved].sort((a, b) => {
1208
- if (a.file !== b.file) return a.file.localeCompare(b.file);
1124
+ if (a.file !== b.file) return codeUnitCompare(a.file, b.file);
1209
1125
  return (a.line || 0) - (b.line || 0);
1210
1126
  });
1211
1127
  }
@@ -1266,7 +1182,7 @@ function impact(index, name, options = {}) {
1266
1182
  callerHistogram,
1267
1183
  // Stable ordering: files alphabetical, sites by line ascending. Documented contract.
1268
1184
  byFile: Array.from(byFile.entries())
1269
- .sort((a, b) => a[0].localeCompare(b[0]))
1185
+ .sort((a, b) => codeUnitCompare(a[0], b[0]))
1270
1186
  .map(([file, sites]) => ({
1271
1187
  file,
1272
1188
  count: sites.length,
@@ -1359,9 +1275,14 @@ function about(index, name, options = {}) {
1359
1275
  const extra = visibleOthers.length - shown.length;
1360
1276
  const alsoIn = shown.map(d => `${d.relativePath}:${d.startLine}`).join(', ');
1361
1277
  const suffix = extra > 0 ? `, and ${extra} more` : '';
1278
+ // Same-file collisions can't be resolved with file= (fix #246).
1279
+ const allSameFile = visibleOthers.every(d => d.relativePath === primary.relativePath);
1280
+ const hint = allSameFile
1281
+ ? 'Use line= or class_name= to disambiguate.'
1282
+ : 'Use file= to disambiguate.';
1362
1283
  aboutWarnings.push({
1363
1284
  type: 'ambiguous',
1364
- message: `Found ${visible.length} definitions for "${name}". Using ${primary.relativePath}:${primary.startLine}. Also in: ${alsoIn}${suffix}. Use file= to disambiguate.`,
1285
+ message: `Found ${visible.length} definitions for "${name}". Using ${primary.relativePath}:${primary.startLine}. Also in: ${alsoIn}${suffix}. ${hint}`,
1365
1286
  alternatives: visibleOthers.map(d => ({ file: d.relativePath, line: d.startLine })),
1366
1287
  });
1367
1288
  }
@@ -1453,7 +1374,7 @@ function about(index, name, options = {}) {
1453
1374
  // Optional: filter to unreachable-only callers
1454
1375
  if (options.unreachableOnly) {
1455
1376
  const before = allCallers.length;
1456
- allCallers = allCallers.filter(c => !c.reachable);
1377
+ allCallers = allCallers.filter(c => c.reachable === false);
1457
1378
  aboutFilteredByFlag.unreachableOnly = before - allCallers.length;
1458
1379
  // Apply same filter to shadows using their callerStartLine/file when available.
1459
1380
  // Shadows lack callerStartLine, so they're treated as reachable=false (conservative,
@@ -1468,6 +1389,17 @@ function about(index, name, options = {}) {
1468
1389
  aboutAccount = composeAccount(index, symbolName, rawCallers,
1469
1390
  aboutFilteredTotal > 0 ? { total: aboutFilteredTotal, byFlag: aboutFilteredByFlag } : undefined);
1470
1391
  unverifiedPool.push(...callNotResolvedEntries(index, aboutAccount, options));
1392
+ // The USAGES fast path hardcodes references:0 (counting them needs a
1393
+ // text scan) — but the account just DID that scan and classified the
1394
+ // reference lines. Reconcile so the USAGES header never contradicts
1395
+ // the ACCOUNT line rendered below it (fix #237: `about with_logging`
1396
+ // said "0 references" while the account counted the @with_logging
1397
+ // decorator application).
1398
+ if (usagesByType && usagesByType.references === 0 &&
1399
+ aboutAccount?.nonCall?.references > 0) {
1400
+ usagesByType.references = aboutAccount.nonCall.references;
1401
+ usagesByType.total += aboutAccount.nonCall.references;
1402
+ }
1471
1403
  // Stash the post-filter total on allCallers so the result builder can use it.
1472
1404
  Object.defineProperty(allCallers, '__postFilterTotal', {
1473
1405
  value: allCallers.length + shadowSurvivors.length,
@@ -1502,7 +1434,7 @@ function about(index, name, options = {}) {
1502
1434
  unverifiedPool.sort((a, b) => {
1503
1435
  const fa = a.relativePath || '';
1504
1436
  const fb = b.relativePath || '';
1505
- if (fa !== fb) return fa.localeCompare(fb);
1437
+ if (fa !== fb) return codeUnitCompare(fa, fb);
1506
1438
  return (a.line || 0) - (b.line || 0);
1507
1439
  });
1508
1440
  aboutUnverified = {
@@ -1701,6 +1633,17 @@ function about(index, name, options = {}) {
1701
1633
  completeness: detectCompleteness(index)
1702
1634
  };
1703
1635
 
1636
+ // Constructor pins surface their invocation sites under the CLASS pin
1637
+ // (`new Widget()` indexes under 'Widget' — fix #254, same hint as context).
1638
+ if (primary.className && (callers?.length ?? 0) === 0 &&
1639
+ (primary.type === 'constructor' || symbolName === 'constructor' || symbolName === '__init__') &&
1640
+ index.getCalleeFiles(primary.className)) {
1641
+ result.warnings = [...(result.warnings || []), {
1642
+ type: 'hint',
1643
+ message: `Constructor invocations (\`new ${primary.className}(...)\` / \`${primary.className}(...)\`) are indexed under the class name — query \`${primary.className}\` for the call sites.`,
1644
+ }];
1645
+ }
1646
+
1704
1647
  return result;
1705
1648
  } finally { index._endOp(); }
1706
1649
  }
@@ -1743,13 +1686,22 @@ function diffImpact(index, options = {}) {
1743
1686
 
1744
1687
  let diffText;
1745
1688
  try {
1746
- diffText = execFileSync('git', diffArgs, { cwd: index.root, encoding: 'utf-8', maxBuffer: 10 * 1024 * 1024 });
1689
+ // Capture stderr (fix #230): an invalid ref used to leak git's raw
1690
+ // "fatal: ambiguous argument" straight to the terminal AND repeat it
1691
+ // inside the error message.
1692
+ diffText = execFileSync('git', diffArgs, {
1693
+ cwd: index.root, encoding: 'utf-8', maxBuffer: 10 * 1024 * 1024,
1694
+ stdio: ['ignore', 'pipe', 'pipe'],
1695
+ });
1747
1696
  } catch (e) {
1748
1697
  // git diff exits non-zero when there are diff errors, but also for invalid refs
1749
1698
  if (e.stdout) {
1750
1699
  diffText = e.stdout;
1751
1700
  } else {
1752
- throw new Error(`git diff failed: ${e.message}`, { cause: e });
1701
+ const fatal = String(e.stderr || '').split('\n').find(l => l.startsWith('fatal:'));
1702
+ throw new Error(fatal
1703
+ ? `git diff failed — ${fatal.replace(/^fatal:\s*/, '')}`
1704
+ : `git diff failed: ${e.message}`, { cause: e });
1753
1705
  }
1754
1706
  }
1755
1707
 
@@ -1760,7 +1712,7 @@ function diffImpact(index, options = {}) {
1760
1712
  moduleLevelChanges: [],
1761
1713
  newFunctions: [],
1762
1714
  deletedFunctions: [],
1763
- summary: { modifiedFunctions: 0, deletedFunctions: 0, newFunctions: 0, totalCallSites: 0, affectedFiles: 0 }
1715
+ summary: { modifiedFunctions: 0, deletedFunctions: 0, newFunctions: 0, totalCallSites: 0, unverifiedCallSites: 0, affectedFiles: 0 }
1764
1716
  };
1765
1717
  }
1766
1718
 
@@ -1789,6 +1741,7 @@ function diffImpact(index, options = {}) {
1789
1741
  const deletedFunctions = [];
1790
1742
  const callerFileSet = new Set();
1791
1743
  let totalCallSites = 0;
1744
+ let totalUnverifiedSites = 0;
1792
1745
 
1793
1746
  for (const change of changes) {
1794
1747
  const lang = detectLanguage(change.filePath);
@@ -1829,6 +1782,7 @@ function diffImpact(index, options = {}) {
1829
1782
  // line-arithmetic guess that was wrong for tightly-packed 1-line functions.
1830
1783
  // The identity key is `name\0className` (matches deletion-detection below).
1831
1784
  let oldSymbolIdentities = null; // null = unknown (file untracked or git failed)
1785
+ let oldCallables = null; // old symbols WITH ranges — deleted lines are old-file coordinates
1832
1786
  if (change.deletedLines.length > 0 || change.addedLines.length > 0) {
1833
1787
  const ref = staged ? 'HEAD' : base;
1834
1788
  try {
@@ -1840,7 +1794,8 @@ function diffImpact(index, options = {}) {
1840
1794
  if (fileLang) {
1841
1795
  const oldParsed = parse(oldContent, fileLang);
1842
1796
  oldSymbolIdentities = new Set();
1843
- for (const oldFn of extractCallableSymbols(oldParsed)) {
1797
+ oldCallables = extractCallableSymbols(oldParsed);
1798
+ for (const oldFn of oldCallables) {
1844
1799
  oldSymbolIdentities.add(`${oldFn.name}\0${oldFn.className || ''}`);
1845
1800
  }
1846
1801
  }
@@ -1873,46 +1828,81 @@ function diffImpact(index, options = {}) {
1873
1828
  }
1874
1829
  }
1875
1830
 
1831
+ // Current callable symbols by identity — used to map an OLD symbol that
1832
+ // still exists to its CURRENT definition for modification attribution.
1833
+ const currentByIdentity = new Map();
1834
+ for (const s of fileEntry.symbols) {
1835
+ if (NON_CALLABLE_TYPES.has(s.type)) continue;
1836
+ const k = `${s.name}\0${s.className || ''}`;
1837
+ if (!currentByIdentity.has(k)) currentByIdentity.set(k, []);
1838
+ currentByIdentity.get(k).push(s);
1839
+ }
1840
+
1876
1841
  for (const line of change.deletedLines) {
1877
- // For deleted lines, we can't use findEnclosingFunction on the current file
1878
- // since those lines no longer exist. Track as module-level unless they map
1879
- // to a function that still exists (the function was modified, not deleted).
1880
- // We approximate: if a deleted line is within the range of a known symbol, it's a modification.
1881
- // Pick the MOST-SPECIFIC match: prefer exact-contained over tolerance-contained,
1882
- // and among ties prefer the smallest range (innermost). This avoids an earlier
1883
- // symbol's expanded ±2 range claiming a line that actually belongs to a later
1884
- // 1-line function in tightly-packed files (BUG-F).
1885
- let bestSymbol = null;
1886
- let bestExact = false;
1887
- let bestRange = Infinity;
1888
- for (const symbol of fileEntry.symbols) {
1889
- if (NON_CALLABLE_TYPES.has(symbol.type)) continue;
1890
- const exact = line >= symbol.startLine && line <= symbol.endLine;
1891
- const tolerant = line >= symbol.startLine - 2 && line <= symbol.endLine + 2;
1892
- if (!exact && !tolerant) continue;
1893
- const range = symbol.endLine - symbol.startLine;
1894
- // Prefer exact-contained over tolerance-contained; among same kind, smaller range wins.
1895
- const better = bestSymbol === null
1896
- || (exact && !bestExact)
1897
- || (exact === bestExact && range < bestRange);
1898
- if (better) {
1899
- bestSymbol = symbol;
1900
- bestExact = exact;
1901
- bestRange = range;
1902
- }
1903
- }
1842
+ // Deleted line numbers are OLD-file coordinates. Matching them
1843
+ // against CURRENT symbol ranges mis-attributes whenever the file
1844
+ // shifted (an untouched function reported modified because it now
1845
+ // occupies a deleted function's old lines). When the old parse is
1846
+ // available, attribute by the OLD symbol's range and map its
1847
+ // identity to the current definition; fall back to the old
1848
+ // current-range heuristic only when git couldn't produce the base.
1904
1849
  let matched = false;
1905
- if (bestSymbol) {
1906
- // Only attribute to a symbol that ALSO existed in the old file. If we
1907
- // know the old identities and this symbol wasn't there, it's a brand-new
1908
- // function its "deleted line" is really a neighboring line that gets
1909
- // pushed up by the diff hunk header. Treat as module-level so the new
1910
- // symbol stays cleanly in newFunctions[] (BUG-F).
1911
- const identityKey = `${bestSymbol.name}\0${bestSymbol.className || ''}`;
1912
- const existedBefore = oldSymbolIdentities === null
1913
- ? true
1914
- : oldSymbolIdentities.has(identityKey);
1915
- if (existedBefore) {
1850
+ if (oldCallables !== null) {
1851
+ let bestOld = null;
1852
+ let bestRange = Infinity;
1853
+ for (const oldFn of oldCallables) {
1854
+ const end = oldFn.endLine || oldFn.startLine;
1855
+ if (line < oldFn.startLine || line > end) continue;
1856
+ const range = end - oldFn.startLine;
1857
+ if (bestOld === null || range < bestRange) {
1858
+ bestOld = oldFn;
1859
+ bestRange = range;
1860
+ }
1861
+ }
1862
+ if (bestOld) {
1863
+ const identityKey = `${bestOld.name}\0${bestOld.className || ''}`;
1864
+ const candidates = currentByIdentity.get(identityKey);
1865
+ if (candidates && candidates.length > 0) {
1866
+ // Function still exists — a modification. Nearest
1867
+ // startLine disambiguates same-identity overloads.
1868
+ let cur = candidates[0];
1869
+ for (const c of candidates) {
1870
+ if (Math.abs(c.startLine - bestOld.startLine) < Math.abs(cur.startLine - bestOld.startLine)) cur = c;
1871
+ }
1872
+ const key = `${cur.name}:${cur.startLine}`;
1873
+ if (!affectedSymbols.has(key)) {
1874
+ affectedSymbols.set(key, { symbol: cur, addedLines: [], deletedLines: [] });
1875
+ }
1876
+ affectedSymbols.get(key).deletedLines.push(line);
1877
+ }
1878
+ // Identity gone → the whole function was deleted; the
1879
+ // deletion detection below reports it. Its body lines are
1880
+ // neither a modification nor a module-level change.
1881
+ matched = true;
1882
+ }
1883
+ } else {
1884
+ // Fallback (old file unreachable): current-range heuristic.
1885
+ // Pick the MOST-SPECIFIC match: prefer exact-contained over
1886
+ // tolerance-contained, then smallest range (BUG-F).
1887
+ let bestSymbol = null;
1888
+ let bestExact = false;
1889
+ let bestRange = Infinity;
1890
+ for (const symbol of fileEntry.symbols) {
1891
+ if (NON_CALLABLE_TYPES.has(symbol.type)) continue;
1892
+ const exact = line >= symbol.startLine && line <= symbol.endLine;
1893
+ const tolerant = line >= symbol.startLine - 2 && line <= symbol.endLine + 2;
1894
+ if (!exact && !tolerant) continue;
1895
+ const range = symbol.endLine - symbol.startLine;
1896
+ const better = bestSymbol === null
1897
+ || (exact && !bestExact)
1898
+ || (exact === bestExact && range < bestRange);
1899
+ if (better) {
1900
+ bestSymbol = symbol;
1901
+ bestExact = exact;
1902
+ bestRange = range;
1903
+ }
1904
+ }
1905
+ if (bestSymbol) {
1916
1906
  const key = `${bestSymbol.name}:${bestSymbol.startLine}`;
1917
1907
  if (!affectedSymbols.has(key)) {
1918
1908
  affectedSymbols.set(key, { symbol: bestSymbol, addedLines: [], deletedLines: [] });
@@ -1966,55 +1956,47 @@ function diffImpact(index, options = {}) {
1966
1956
 
1967
1957
  // Detect deleted functions: compare old file symbols with current by identity.
1968
1958
  // Uses name+className counts to handle overloads (e.g. Java method overloading).
1969
- if (change.deletedLines.length > 0) {
1970
- const ref = staged ? 'HEAD' : base;
1971
- try {
1972
- const oldContent = execFileSync(
1973
- 'git', ['show', `${ref}:${change.gitRelativePath}`],
1974
- { cwd: index.root, encoding: 'utf-8', maxBuffer: 10 * 1024 * 1024, stdio: ['ignore', 'pipe', 'ignore'] }
1975
- );
1976
- const fileLang = detectLanguage(change.filePath);
1977
- if (fileLang) {
1978
- const oldParsed = parse(oldContent, fileLang);
1979
- // Count current symbols by identity (name + className)
1980
- const currentCounts = new Map();
1981
- for (const s of fileEntry.symbols) {
1982
- if (NON_CALLABLE_TYPES.has(s.type)) continue;
1983
- const key = `${s.name}\0${s.className || ''}`;
1984
- currentCounts.set(key, (currentCounts.get(key) || 0) + 1);
1985
- }
1986
- // Count old symbols by identity and detect deletions
1987
- const oldCounts = new Map();
1988
- const oldSymbols = extractCallableSymbols(oldParsed);
1989
- for (const oldFn of oldSymbols) {
1990
- const key = `${oldFn.name}\0${oldFn.className || ''}`;
1991
- oldCounts.set(key, (oldCounts.get(key) || 0) + 1);
1992
- }
1993
- // For each identity, if old count > current count, the difference are deletions
1994
- for (const [key, oldCount] of oldCounts) {
1995
- const curCount = currentCounts.get(key) || 0;
1996
- if (oldCount > curCount) {
1997
- // Find the specific old symbols with this identity that were deleted
1998
- const matching = oldSymbols.filter(s => `${s.name}\0${s.className || ''}` === key);
1999
- // Report the extra ones (by startLine descending — later ones more likely deleted)
2000
- const toReport = matching.slice(curCount);
2001
- for (const oldFn of toReport) {
2002
- deletedFunctions.push({
2003
- name: oldFn.name,
2004
- filePath: change.filePath,
2005
- relativePath: change.relativePath,
2006
- startLine: oldFn.startLine
2007
- });
2008
- }
2009
- }
1959
+ // oldCallables was parsed from the same base revision above.
1960
+ if (change.deletedLines.length > 0 && oldCallables !== null) {
1961
+ const currentCounts = new Map();
1962
+ for (const s of fileEntry.symbols) {
1963
+ if (NON_CALLABLE_TYPES.has(s.type)) continue;
1964
+ const key = `${s.name}\0${s.className || ''}`;
1965
+ currentCounts.set(key, (currentCounts.get(key) || 0) + 1);
1966
+ }
1967
+ // Count old symbols by identity and detect deletions
1968
+ const oldCounts = new Map();
1969
+ for (const oldFn of oldCallables) {
1970
+ const key = `${oldFn.name}\0${oldFn.className || ''}`;
1971
+ oldCounts.set(key, (oldCounts.get(key) || 0) + 1);
1972
+ }
1973
+ // For each identity, if old count > current count, the difference are deletions
1974
+ for (const [key, oldCount] of oldCounts) {
1975
+ const curCount = currentCounts.get(key) || 0;
1976
+ if (oldCount > curCount) {
1977
+ // Find the specific old symbols with this identity that were deleted
1978
+ const matching = oldCallables.filter(s => `${s.name}\0${s.className || ''}` === key);
1979
+ // Report the extra ones (by startLine descending — later ones more likely deleted)
1980
+ const toReport = matching.slice(curCount);
1981
+ for (const oldFn of toReport) {
1982
+ deletedFunctions.push({
1983
+ name: oldFn.name,
1984
+ filePath: change.filePath,
1985
+ relativePath: change.relativePath,
1986
+ startLine: oldFn.startLine
1987
+ });
2010
1988
  }
2011
1989
  }
2012
- } catch (e) {
2013
- // File didn't exist in base, or git error — skip
2014
1990
  }
2015
1991
  }
2016
1992
 
2017
- // For each affected function, find callers
1993
+ // For each affected function, find callers under the tiered caller
1994
+ // contract (v4): confirmed edges populate `callers`; evidence-less
1995
+ // candidates render in `unverifiedCallers` with reasons; positively
1996
+ // excluded sites reconcile in the per-symbol conservation account.
1997
+ // The pre-v4 hand-rolled nominal receiver filter is gone — the engine's
1998
+ // receiver physics (#198/#202/#204/#206/#220) decide tier and exclusion,
1999
+ // and its same-dir heuristic could silently drop true callers.
2018
2000
  for (const [, data] of affectedSymbols) {
2019
2001
  const { symbol, addedLines: aLines, deletedLines: dLines } = data;
2020
2002
 
@@ -2022,59 +2004,34 @@ function diffImpact(index, options = {}) {
2022
2004
  const allDefs = index.symbols.get(symbol.name) || [];
2023
2005
  const targetDefs = allDefs.filter(d => d.file === change.filePath && d.startLine === symbol.startLine);
2024
2006
 
2025
- let callers = index.findCallers(symbol.name, {
2007
+ const rawCallers = index.findCallers(symbol.name, {
2026
2008
  targetDefinitions: targetDefs.length > 0 ? targetDefs : undefined,
2027
2009
  includeMethods: true,
2028
- includeUncertain: false,
2010
+ collectAccount: true,
2029
2011
  });
2030
2012
 
2031
- // For Go/Java/Rust methods with a className, filter callers whose
2032
- // receiver clearly belongs to a different type (same logic as impact()).
2033
- const targetDef = targetDefs[0] || symbol;
2034
- if (targetDef.className && langTraits(lang)?.typeSystem === 'nominal') {
2035
- const targetClassName = targetDef.className;
2036
- // Pre-compute how many types share this method name
2037
- const methodDefs = index.symbols.get(symbol.name);
2038
- const classNames = new Set();
2039
- if (methodDefs) {
2040
- for (const d of methodDefs) {
2041
- if (d.className) classNames.add(d.className);
2042
- else if (d.receiver) classNames.add(d.receiver.replace(/^\*/, ''));
2043
- }
2044
- }
2045
- const isWidelyShared = classNames.size > 3;
2046
- callers = callers.filter(c => {
2047
- if (!c.isMethod) return true;
2048
- const r = c.receiver;
2049
- if (r && ['self', 'cls', 'this', 'super'].includes(r)) return true;
2050
- // No receiver (chained/complex expression): only include if method is
2051
- // unique or rare across types — otherwise too many false positives
2052
- if (!r) {
2053
- return classNames.size <= 1;
2054
- }
2055
- // Use receiverType from findCallers when available
2056
- if (c.receiverType) {
2057
- return c.receiverType === targetClassName ||
2058
- c.receiverType === targetDef.receiver?.replace(/^\*/, '');
2059
- }
2060
- // Unique method heuristic: if the method exists on exactly one class/type, include
2061
- if (classNames.size === 1 && classNames.has(targetClassName)) return true;
2062
- // For widely shared method names (Get, Set, Run, etc.), require same-package
2063
- // evidence when receiver type is unknown
2064
- if (isWidelyShared) {
2065
- const callerFile = c.file || '';
2066
- const targetDir = path.dirname(change.filePath);
2067
- return path.dirname(callerFile) === targetDir;
2068
- }
2069
- // Unknown receiver + multiple classes with this method → filter out
2070
- return false;
2071
- });
2072
- }
2013
+ const confirmed = rawCallers.filter(c => c.tier !== 'unverified');
2014
+ let unverified = rawCallers.filter(c => c.tier === 'unverified')
2015
+ .concat(rawCallers.unverifiedEntries || []);
2016
+
2017
+ // Per-symbol conservation account over the text ground set (same
2018
+ // ring as impact every changed symbol is a root the user touched
2019
+ // directly). Ground call-lines no candidate claimed become visible
2020
+ // one-liners (call-not-resolved), never silent gaps.
2021
+ const account = composeAccount(index, symbol.name, rawCallers);
2022
+ for (const e of callNotResolvedEntries(index, account)) unverified.push(e);
2023
+ unverified.sort((a, b) => {
2024
+ const ap = a.relativePath || '';
2025
+ const bp = b.relativePath || '';
2026
+ if (ap !== bp) return codeUnitCompare(ap, bp);
2027
+ return (a.line || 0) - (b.line || 0);
2028
+ });
2073
2029
 
2074
- for (const c of callers) {
2030
+ for (const c of confirmed) {
2075
2031
  callerFileSet.add(c.file);
2076
2032
  }
2077
- totalCallSites += callers.length;
2033
+ totalCallSites += confirmed.length;
2034
+ totalUnverifiedSites += unverified.length;
2078
2035
 
2079
2036
  functions.push({
2080
2037
  name: symbol.name,
@@ -2085,17 +2042,68 @@ function diffImpact(index, options = {}) {
2085
2042
  signature: index.formatSignature(symbol),
2086
2043
  addedLines: aLines,
2087
2044
  deletedLines: dLines,
2088
- callers: callers.map(c => ({
2045
+ callers: confirmed.map(c => ({
2089
2046
  file: c.file,
2090
2047
  relativePath: c.relativePath,
2091
2048
  line: c.line,
2092
2049
  callerName: c.callerName,
2093
- content: c.content.trim()
2094
- }))
2050
+ content: (c.content || '').trim(),
2051
+ confidence: c.confidence,
2052
+ resolution: c.resolution,
2053
+ ...(c.tier && { tier: c.tier }),
2054
+ })),
2055
+ unverifiedCallers: unverified.map(u => ({
2056
+ file: u.file,
2057
+ relativePath: u.relativePath,
2058
+ line: u.line,
2059
+ callerName: u.callerName ?? null,
2060
+ content: (u.content || '').trim(),
2061
+ tier: 'unverified',
2062
+ ...(u.reason && { reason: u.reason }),
2063
+ ...(u.dispatchVia && { dispatchVia: u.dispatchVia }),
2064
+ ...(u.dispatchCandidates != null && { dispatchCandidates: u.dispatchCandidates }),
2065
+ })),
2066
+ account,
2095
2067
  });
2096
2068
  }
2097
2069
  }
2098
2070
 
2071
+ // Deleted-but-still-called: a deleted function whose NAME still has call
2072
+ // sites in the current tree is a likely runtime/compile break. The def is
2073
+ // gone, so no receiver physics can pin these — they are name-level
2074
+ // candidates and are labeled as such (same epistemic tier as `usages`).
2075
+ if (deletedFunctions.length > 0) {
2076
+ const { getCachedCalls } = require('./callers');
2077
+ for (const del of deletedFunctions) {
2078
+ const remaining = [];
2079
+ const calleeFiles = index.getCalleeFiles(del.name);
2080
+ if (calleeFiles) {
2081
+ for (const f of calleeFiles) {
2082
+ const calls = getCachedCalls(index, f);
2083
+ if (!Array.isArray(calls)) continue;
2084
+ const fe = index.files.get(f);
2085
+ let lines = null;
2086
+ for (const c of calls) {
2087
+ if (c.name !== del.name && c.resolvedName !== del.name) continue;
2088
+ if (lines === null) {
2089
+ try { lines = index._getFileLines(f); } catch { lines = []; }
2090
+ }
2091
+ remaining.push({
2092
+ file: f,
2093
+ relativePath: fe ? fe.relativePath : path.relative(index.root, f),
2094
+ line: c.line,
2095
+ content: (lines[c.line - 1] || '').trim(),
2096
+ });
2097
+ }
2098
+ }
2099
+ }
2100
+ remaining.sort((a, b) => a.relativePath === b.relativePath
2101
+ ? a.line - b.line
2102
+ : codeUnitCompare(a.relativePath, b.relativePath));
2103
+ del.remainingCallSites = remaining;
2104
+ }
2105
+ }
2106
+
2099
2107
  return {
2100
2108
  base: staged ? '(staged)' : base,
2101
2109
  functions,
@@ -2107,6 +2115,7 @@ function diffImpact(index, options = {}) {
2107
2115
  deletedFunctions: deletedFunctions.length,
2108
2116
  newFunctions: newFunctions.length,
2109
2117
  totalCallSites,
2118
+ unverifiedCallSites: totalUnverifiedSites,
2110
2119
  affectedFiles: callerFileSet.size
2111
2120
  }
2112
2121
  };
@@ -2119,19 +2128,28 @@ function diffImpact(index, options = {}) {
2119
2128
 
2120
2129
  /**
2121
2130
  * Extract all callable symbols (functions + class methods) from a parse result,
2122
- * matching how indexFile builds the symbol list. Methods get className added.
2131
+ * matching how indexFile builds the symbol list the identity keys
2132
+ * (`name\0className`) derived here are compared against INDEXED symbols, so
2133
+ * both normalizations indexFile applies must happen here too:
2134
+ * - Go/Rust methods are standalone functions with a `receiver`; indexFile
2135
+ * derives className from it. Without this, every modified method's identity
2136
+ * misses the old set and it reads as new + phantom-deleted.
2137
+ * - Class members include FIELDS; the indexed side filters NON_CALLABLE_TYPES,
2138
+ * so the old side must too or every field in a changed file reads deleted.
2123
2139
  * @param {object} parsed - Result from parse()
2124
2140
  * @returns {Array<{name, className, startLine}>}
2125
2141
  */
2126
2142
  function extractCallableSymbols(parsed) {
2127
2143
  const symbols = [];
2128
2144
  for (const fn of parsed.functions) {
2129
- symbols.push({ name: fn.name, className: fn.className || '', startLine: fn.startLine });
2145
+ const className = fn.className || (fn.receiver ? fn.receiver.replace(/^\*/, '') : '');
2146
+ symbols.push({ name: fn.name, className, startLine: fn.startLine, endLine: fn.endLine });
2130
2147
  }
2131
2148
  for (const cls of parsed.classes) {
2132
2149
  if (cls.members) {
2133
2150
  for (const m of cls.members) {
2134
- symbols.push({ name: m.name, className: cls.name, startLine: m.startLine });
2151
+ if (NON_CALLABLE_TYPES.has(m.memberType || 'method')) continue;
2152
+ symbols.push({ name: m.name, className: cls.name, startLine: m.startLine, endLine: m.endLine });
2135
2153
  }
2136
2154
  }
2137
2155
  }
@@ -2608,12 +2626,12 @@ function auditAsync(index, options = {}) {
2608
2626
 
2609
2627
  // Stable ordering (rule #11): sort by (file, line, callerName, calleeName).
2610
2628
  issues.sort((a, b) => {
2611
- const fc = String(a.file).localeCompare(String(b.file));
2629
+ const fc = codeUnitCompare(String(a.file), String(b.file));
2612
2630
  if (fc !== 0) return fc;
2613
2631
  if (a.line !== b.line) return a.line - b.line;
2614
- const cc = String(a.callerName || '').localeCompare(String(b.callerName || ''));
2632
+ const cc = codeUnitCompare(String(a.callerName || ''), String(b.callerName || ''));
2615
2633
  if (cc !== 0) return cc;
2616
- return String(a.calleeName || '').localeCompare(String(b.calleeName || ''));
2634
+ return codeUnitCompare(String(a.calleeName || ''), String(b.calleeName || ''));
2617
2635
  });
2618
2636
 
2619
2637
  return {