ucn 3.8.22 → 3.8.25

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/.claude/skills/ucn/SKILL.md +114 -11
  2. package/README.md +152 -156
  3. package/cli/index.js +363 -37
  4. package/core/analysis.js +960 -37
  5. package/core/bridge.js +1111 -0
  6. package/core/brief.js +408 -0
  7. package/core/cache.js +213 -59
  8. package/core/callers.js +117 -41
  9. package/core/check.js +200 -0
  10. package/core/deadcode.js +31 -2
  11. package/core/discovery.js +57 -34
  12. package/core/entrypoints.js +638 -4
  13. package/core/execute.js +304 -5
  14. package/core/git-enrich.js +130 -0
  15. package/core/graph-build.js +4 -4
  16. package/core/graph.js +31 -12
  17. package/core/output/analysis.js +157 -25
  18. package/core/output/brief.js +100 -0
  19. package/core/output/check.js +79 -0
  20. package/core/output/doctor.js +85 -0
  21. package/core/output/endpoints.js +239 -0
  22. package/core/output/extraction.js +2 -0
  23. package/core/output/find.js +126 -39
  24. package/core/output/graph.js +48 -15
  25. package/core/output/refactoring.js +103 -5
  26. package/core/output/reporting.js +63 -23
  27. package/core/output/search.js +110 -17
  28. package/core/output/shared.js +56 -2
  29. package/core/output.js +4 -0
  30. package/core/parallel-build.js +10 -7
  31. package/core/parser.js +8 -2
  32. package/core/project.js +147 -41
  33. package/core/registry.js +30 -14
  34. package/core/reporting.js +465 -2
  35. package/core/search.js +139 -15
  36. package/core/shared.js +101 -5
  37. package/core/tracing.js +31 -12
  38. package/core/verify.js +982 -95
  39. package/languages/go.js +91 -6
  40. package/languages/html.js +10 -0
  41. package/languages/java.js +151 -35
  42. package/languages/javascript.js +290 -33
  43. package/languages/python.js +78 -11
  44. package/languages/rust.js +267 -12
  45. package/languages/utils.js +315 -3
  46. package/mcp/server.js +91 -16
  47. package/package.json +10 -2
package/core/analysis.js CHANGED
@@ -13,6 +13,190 @@ const { execFileSync } = require('child_process');
13
13
  const { parse } = require('./parser');
14
14
  const { detectLanguage, langTraits } = require('../languages');
15
15
  const { NON_CALLABLE_TYPES, addTestExclusions } = require('./shared');
16
+ const { computeReachability, symbolKey } = require('./entrypoints');
17
+ const { getLanguageModule } = require('../languages');
18
+
19
+ // JS/TS test framework helpers — calls to these bracket a test case.
20
+ // Used to flag call sites whose enclosing function is an arrow callback
21
+ // passed to one of these (the common pattern in mocha/jest/vitest).
22
+ const _JS_TEST_FRAMEWORK_CALLS = new Set(['describe', 'it', 'test', 'spec', 'context', 'suite']);
23
+
24
+ /**
25
+ * Tag each call site with `inTestCase` based on its enclosing function's
26
+ * entry-point classification. Uses each language's `getEntryPointKind`
27
+ * predicate (kind === 'test') so results match `affectedTests`/etc.
28
+ *
29
+ * For JS/TS/HTML, function-level entry-point classification only covers
30
+ * framework lifecycle methods. Test bodies are framework callbacks
31
+ * (`it('name', () => {...})`), so we additionally tag a site as inTestCase
32
+ * when its file has a `describe`/`it`/`test` framework call that brackets
33
+ * the enclosing function — mirroring `_addAffectedTestCases` in tracing.js.
34
+ *
35
+ * Mutates each site in place (sets `site.inTestCase = boolean`).
36
+ */
37
+ function tagInTestCase(index, sites) {
38
+ if (!Array.isArray(sites) || sites.length === 0) return;
39
+ // Per-file cache: language module + JS framework call ranges.
40
+ const fileMeta = new Map();
41
+ function getFileMeta(filePath) {
42
+ if (!filePath) return null;
43
+ if (fileMeta.has(filePath)) return fileMeta.get(filePath);
44
+ const fe = index.files.get(filePath);
45
+ if (!fe) { fileMeta.set(filePath, null); return null; }
46
+ let langModule = null;
47
+ try { langModule = getLanguageModule(fe.language); } catch (_) { /* ignore */ }
48
+ const meta = { fileEntry: fe, langModule, language: fe.language, jsTestRanges: null };
49
+ // For JS-family files, build line ranges of describe/it/test framework calls.
50
+ if (langModule && (fe.language === 'javascript' || fe.language === 'typescript' ||
51
+ fe.language === 'tsx' || fe.language === 'html')) {
52
+ try {
53
+ const calls = index.getCachedCalls ? index.getCachedCalls(filePath) : null;
54
+ if (Array.isArray(calls)) {
55
+ const ranges = [];
56
+ for (const call of calls) {
57
+ if (!_JS_TEST_FRAMEWORK_CALLS.has(call.name)) continue;
58
+ // Get the enclosing test-block end via existing fn-bound estimate.
59
+ // Without a proper bracket scan we use the next-fn-boundary or +200 lines.
60
+ let endLine = call.line + 200;
61
+ // Try using the enclosing-symbol range as an upper bound when possible.
62
+ if (fe.symbols) {
63
+ for (const sym of fe.symbols) {
64
+ if (sym.startLine <= call.line && (sym.endLine || 0) >= call.line) {
65
+ endLine = Math.min(endLine, sym.endLine || endLine);
66
+ }
67
+ }
68
+ }
69
+ ranges.push({ start: call.line, end: endLine });
70
+ }
71
+ meta.jsTestRanges = ranges;
72
+ }
73
+ } catch (_) { /* ignore */ }
74
+ }
75
+ fileMeta.set(filePath, meta);
76
+ return meta;
77
+ }
78
+ for (const site of sites) {
79
+ site.inTestCase = false;
80
+ const meta = getFileMeta(site.callerFile);
81
+ if (!meta || !meta.langModule) continue;
82
+ // First: kinded entry-point predicate (Python/Go/Java/Rust + JS lifecycle).
83
+ const classify = meta.langModule.getEntryPointKind;
84
+ if (classify && site.callerFile && site.callerStartLine != null) {
85
+ const encl = index.findEnclosingFunction
86
+ ? index.findEnclosingFunction(site.callerFile, site.line, true)
87
+ : null;
88
+ if (encl && classify(encl) === 'test') {
89
+ site.inTestCase = true;
90
+ continue;
91
+ }
92
+ }
93
+ // Second: JS/TS framework-call brackets (it/test/describe). The site is
94
+ // inside a test case when its line falls within a describe/it block.
95
+ if (meta.jsTestRanges && meta.jsTestRanges.length > 0 && site.line != null) {
96
+ for (const r of meta.jsTestRanges) {
97
+ if (site.line >= r.start && site.line <= r.end) {
98
+ site.inTestCase = true;
99
+ break;
100
+ }
101
+ }
102
+ }
103
+ }
104
+ }
105
+
106
+ // ============================================================================
107
+ // TRUST SIGNALS: HISTOGRAM + REACHABILITY
108
+ // ============================================================================
109
+
110
+ /**
111
+ * Bucket a confidence score into 'high' / 'medium' / 'low'.
112
+ * Boundaries are inclusive at the lower end:
113
+ * confidence > 0.8 → high
114
+ * 0.5 <= c <= 0.8 → medium
115
+ * c < 0.5 → low
116
+ *
117
+ * @param {number} c - Confidence score (0.0-1.0)
118
+ * @returns {'high'|'medium'|'low'}
119
+ */
120
+ function bucketConfidence(c) {
121
+ if (c == null) return 'low';
122
+ if (c > 0.8) return 'high';
123
+ if (c >= 0.5) return 'medium';
124
+ return 'low';
125
+ }
126
+
127
+ /**
128
+ * Build a confidence histogram from an array of edges (callers/callees).
129
+ * Returns null when there are no edges (caller drops the section entirely).
130
+ *
131
+ * @param {Array} edges - Array of objects with `confidence` field
132
+ * @returns {{ high: number, medium: number, low: number, total: number }|null}
133
+ */
134
+ function buildHistogram(edges) {
135
+ if (!edges || edges.length === 0) return null;
136
+ const h = { high: 0, medium: 0, low: 0, total: edges.length };
137
+ for (const e of edges) {
138
+ h[bucketConfidence(e.confidence)]++;
139
+ }
140
+ return h;
141
+ }
142
+
143
+ /**
144
+ * Tag a list of caller objects with `reachable: boolean`.
145
+ * Uses (callerFile, callerStartLine) to look up the caller symbol's reachability.
146
+ * Module-level callers (no callerStartLine) are treated as unreachable.
147
+ *
148
+ * @param {Array} callers - Caller objects from findCallers
149
+ * @param {Set<string>} reachableSet - Set of reachable symbol keys
150
+ * @returns {Array} Same callers with `reachable` field added (mutated in place)
151
+ */
152
+ function tagCallersReachable(callers, reachableSet) {
153
+ if (!callers) return callers;
154
+ for (const c of callers) {
155
+ if (c.callerFile && c.callerStartLine != null) {
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
+ }
161
+ }
162
+ return callers;
163
+ }
164
+
165
+ /**
166
+ * Tag a list of callee objects with `reachable: boolean`.
167
+ * Callee objects from findCallees have `file` + `startLine` directly.
168
+ *
169
+ * @param {Array} callees - Callee objects from findCallees
170
+ * @param {Set<string>} reachableSet - Set of reachable symbol keys
171
+ * @returns {Array} Same callees with `reachable` field added (mutated in place)
172
+ */
173
+ function tagCalleesReachable(callees, reachableSet) {
174
+ if (!callees) return callees;
175
+ for (const c of callees) {
176
+ if (c.file && c.startLine != null) {
177
+ c.reachable = reachableSet.has(symbolKey(c.file, c.startLine));
178
+ } else {
179
+ c.reachable = false;
180
+ }
181
+ }
182
+ return callees;
183
+ }
184
+
185
+ /**
186
+ * Attach side-effect tags to each callee by AST scan of its body.
187
+ * Tags = ['fs', 'network', 'process', 'global_mutation'] subset.
188
+ * Cached per-symbol on the index so repeat queries are cheap.
189
+ */
190
+ function tagCalleesSideEffects(index, callees) {
191
+ if (!callees || callees.length === 0) return callees;
192
+ const { sideEffectsFor } = require('./brief');
193
+ for (const c of callees) {
194
+ const tags = sideEffectsFor(index, c);
195
+ if (tags && tags.length > 0) c.sideEffects = tags;
196
+ }
197
+ return callees;
198
+ }
199
+
16
200
 
17
201
  /**
18
202
  * Context: quick caller/callee view for a symbol.
@@ -25,7 +209,7 @@ const { NON_CALLABLE_TYPES, addTestExclusions } = require('./shared');
25
209
  function context(index, name, options = {}) {
26
210
  index._beginOp();
27
211
  try {
28
- const resolved = index.resolveSymbol(name, { file: options.file, className: options.className });
212
+ const resolved = index.resolveSymbol(name, { file: options.file, className: options.className, line: options.line });
29
213
  let { def, warnings } = resolved;
30
214
  if (!def) {
31
215
  return null;
@@ -87,6 +271,45 @@ function context(index, name, options = {}) {
87
271
  confidenceFiltered = callerResult.filtered + calleeResult.filtered;
88
272
  }
89
273
 
274
+ // Stable output ordering: callers by (file, line). Callees retain their
275
+ // call-count order from findCallees (most-called first) — that's a value
276
+ // the user expects, not a stability concern, since the secondary sort
277
+ // by line keeps ties deterministic.
278
+ callers = [...callers].sort((a, b) => {
279
+ const fa = a.relativePath || a.file || '';
280
+ const fb = b.relativePath || b.file || '';
281
+ if (fa !== fb) return fa.localeCompare(fb);
282
+ return (a.line || 0) - (b.line || 0);
283
+ });
284
+ callees = [...callees].sort((a, b) => {
285
+ // Primary: callCount desc (preserves "most-called first" UX)
286
+ const ca = a.callCount || 0, cb = b.callCount || 0;
287
+ if (ca !== cb) return cb - ca;
288
+ // Tiebreaker: file then line, for determinism
289
+ const fa = a.relativePath || a.file || '';
290
+ const fb = b.relativePath || b.file || '';
291
+ if (fa !== fb) return fa.localeCompare(fb);
292
+ return (a.startLine || 0) - (b.startLine || 0);
293
+ });
294
+
295
+ // Trust signals: tag each caller/callee with reachability and build confidence histograms.
296
+ // Reachability is computed once per index and cached (see entrypoints.computeReachability).
297
+ const reachableSet = computeReachability(index);
298
+ tagCallersReachable(callers, reachableSet);
299
+ tagCalleesReachable(callees, reachableSet);
300
+
301
+ // Side-effect tags on callees (lazy-cached per symbol on the index)
302
+ tagCalleesSideEffects(index, callees);
303
+
304
+ // Optional: filter to unreachable-only (helps surface dead-path callers/callees)
305
+ if (options.unreachableOnly) {
306
+ callers = callers.filter(c => !c.reachable);
307
+ callees = callees.filter(c => !c.reachable);
308
+ }
309
+
310
+ const callerHistogram = buildHistogram(callers);
311
+ const calleeHistogram = buildHistogram(callees);
312
+
90
313
  const filesInScope = new Set([def.file]);
91
314
  callers.forEach(c => filesInScope.add(c.file));
92
315
  callees.forEach(c => filesInScope.add(c.file));
@@ -105,6 +328,8 @@ function context(index, name, options = {}) {
105
328
  returnType: def.returnType,
106
329
  callers,
107
330
  callees,
331
+ callerHistogram,
332
+ calleeHistogram,
108
333
  meta: {
109
334
  complete: stats.uncertain === 0 && dynamicImports === 0 && confidenceFiltered === 0,
110
335
  skipped: 0,
@@ -139,7 +364,7 @@ function context(index, name, options = {}) {
139
364
  function smart(index, name, options = {}) {
140
365
  index._beginOp();
141
366
  try {
142
- const { def } = index.resolveSymbol(name, { file: options.file, className: options.className });
367
+ const { def } = index.resolveSymbol(name, { file: options.file, className: options.className, line: options.line });
143
368
  if (!def) {
144
369
  return null;
145
370
  }
@@ -291,7 +516,7 @@ function detectCompleteness(index) {
291
516
  function related(index, name, options = {}) {
292
517
  index._beginOp();
293
518
  try {
294
- const { def } = index.resolveSymbol(name, { file: options.file, className: options.className });
519
+ const { def } = index.resolveSymbol(name, { file: options.file, className: options.className, line: options.line });
295
520
  if (!def) {
296
521
  return null;
297
522
  }
@@ -353,13 +578,24 @@ function related(index, name, options = {}) {
353
578
  if (related.similarNames.length > similarLimit) related.similarNames = related.similarNames.slice(0, similarLimit);
354
579
 
355
580
  // 3. Shared callers - functions called by the same callers
356
- const myCallers = new Set(index.findCallers(name).map(c => c.callerName).filter(Boolean));
581
+ // Cap findCallers to avoid O(hundreds × findCallees) on ambiguous names
582
+ const maxSharedCallerScan = options.all ? 50 : 20;
583
+ const myCallersRaw = index.findCallers(name, { maxResults: maxSharedCallerScan * 3 });
584
+ const myCallers = new Set(myCallersRaw.map(c => c.callerName).filter(Boolean));
357
585
  if (myCallers.size > 0) {
358
586
  const callerCounts = new Map();
587
+ const calleeCache = new Map();
588
+ let scannedCallers = 0;
359
589
  for (const callerName of myCallers) {
590
+ if (scannedCallers >= maxSharedCallerScan) break;
360
591
  const callerDef = index.symbols.get(callerName)?.[0];
361
592
  if (callerDef) {
362
- const callees = index.findCallees(callerDef);
593
+ let callees = calleeCache.get(callerName);
594
+ if (!callees) {
595
+ callees = index.findCallees(callerDef);
596
+ calleeCache.set(callerName, callees);
597
+ }
598
+ scannedCallers++;
363
599
  for (const callee of callees) {
364
600
  if (callee.name !== name) {
365
601
  callerCounts.set(callee.name, (callerCounts.get(callee.name) || 0) + 1);
@@ -394,9 +630,14 @@ function related(index, name, options = {}) {
394
630
  const myCalleeNames = new Set(myCallees.map(c => c.name));
395
631
  if (myCalleeNames.size > 0) {
396
632
  const calleeCounts = new Map();
633
+ // Cap callee scan to avoid O(callees × findCallers) explosion
634
+ const maxCalleeScan = options.all ? 30 : 15;
635
+ let scannedCallees = 0;
397
636
  for (const calleeName of myCalleeNames) {
637
+ if (scannedCallees >= maxCalleeScan) break;
638
+ scannedCallees++;
398
639
  // Find other functions that also call this callee
399
- const callers = index.findCallers(calleeName);
640
+ const callers = index.findCallers(calleeName, { maxResults: 50 });
400
641
  for (const caller of callers) {
401
642
  if (caller.callerName && caller.callerName !== name) {
402
643
  calleeCounts.set(caller.callerName, (calleeCounts.get(caller.callerName) || 0) + 1);
@@ -437,20 +678,34 @@ function related(index, name, options = {}) {
437
678
  function impact(index, name, options = {}) {
438
679
  index._beginOp();
439
680
  try {
440
- const { def } = index.resolveSymbol(name, { file: options.file, className: options.className });
681
+ const { def } = index.resolveSymbol(name, { file: options.file, className: options.className, line: options.line });
441
682
  if (!def) {
442
683
  return null;
443
684
  }
444
685
  const defIsMethod = def.isMethod || def.type === 'method' || def.className || def.receiver;
686
+ // RUST-3: type definitions (class/struct/interface/etc.) are callable through
687
+ // constructor invocations (`new ClassName()`) — about() handles them via its
688
+ // CALLABLE_TYPES set since the M3 fix. Route them through the same findCallers
689
+ // path here so `impact ClassName` agrees with `about ClassName` rather than
690
+ // returning 0 because the legacy "function" branch doesn't recognize types.
691
+ const TYPE_DEF_KINDS = new Set(['class', 'struct', 'interface', 'type',
692
+ 'enum', 'trait', 'impl', 'record', 'namespace']);
693
+ const defIsTypeDef = TYPE_DEF_KINDS.has(def.type);
694
+
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;
699
+ const impactIncludeUncertain = options.includeUncertain ?? false;
445
700
 
446
701
  // Use findCallers for className-scoped or method queries (sophisticated binding resolution)
447
702
  // Fall back to usages-based approach for simple function queries (backward compatible)
448
703
  let callSites;
449
- if (options.className || defIsMethod) {
704
+ if (options.className || defIsMethod || defIsTypeDef) {
450
705
  // findCallers has proper method call resolution (self/this, binding IDs, receiver checks)
451
706
  let callerResults = index.findCallers(name, {
452
- includeMethods: true,
453
- includeUncertain: false,
707
+ includeMethods: impactIncludeMethods,
708
+ includeUncertain: impactIncludeUncertain,
454
709
  targetDefinitions: [def],
455
710
  });
456
711
 
@@ -584,15 +839,22 @@ function impact(index, name, options = {}) {
584
839
  line: c.line,
585
840
  expression: c.content.trim(),
586
841
  callerName: c.callerName,
842
+ callerFile: c.callerFile,
843
+ callerStartLine: c.callerStartLine,
844
+ confidence: c.confidence,
845
+ resolution: c.resolution,
587
846
  ...analysis
588
847
  });
589
848
  }
590
849
  index._clearTreeCache();
591
850
  } else {
592
851
  // Use findCallers (benefits from callee index) instead of usages() for speed
852
+ // BUG-H3: respect user-supplied includeMethods (defaults true above).
853
+ // For standalone functions, method-style calls (e.g. `obj.findCallers()`)
854
+ // resolve to the function when the receiver is a project object.
593
855
  const callerResults = index.findCallers(name, {
594
- includeMethods: false,
595
- includeUncertain: false,
856
+ includeMethods: impactIncludeMethods,
857
+ includeUncertain: impactIncludeUncertain,
596
858
  targetDefinitions: [def],
597
859
  });
598
860
  const targetBindingId = def.bindingId;
@@ -604,6 +866,10 @@ function impact(index, name, options = {}) {
604
866
  content: c.content,
605
867
  usageType: 'call',
606
868
  callerName: c.callerName,
869
+ callerFile: c.callerFile,
870
+ callerStartLine: c.callerStartLine,
871
+ confidence: c.confidence,
872
+ resolution: c.resolution,
607
873
  }));
608
874
  // Keep the same binding filter for backward compat (findCallers already handles this,
609
875
  // but cross-check with usages-based binding filter for safety)
@@ -635,10 +901,13 @@ function impact(index, name, options = {}) {
635
901
  const targetDir = defLang === 'go' ? path.basename(path.dirname(def.file)) : null;
636
902
  for (const call of filteredCalls) {
637
903
  const analysis = index.analyzeCallSite(call, name);
904
+ // BUG-H3: when includeMethods is true, keep method-style calls
905
+ // (e.g. obj.findCallers() resolves to standalone findCallers via the
906
+ // bindingId path — findCallers already filters by targetDefinitions).
638
907
  // Skip method calls (obj.parse()) when target is a standalone function (parse())
639
908
  // For Go, allow calls where receiver matches the package directory name
640
909
  // (e.g., controller.FilterActive() where file is in pkg/controller/)
641
- if (analysis.isMethodCall && !defIsMethod) {
910
+ if (analysis.isMethodCall && !defIsMethod && !impactIncludeMethods) {
642
911
  if (targetDir) {
643
912
  // Get receiver from parsed calls cache
644
913
  const parsedCalls = index.getCachedCalls(call.file);
@@ -657,6 +926,10 @@ function impact(index, name, options = {}) {
657
926
  line: call.line,
658
927
  expression: call.content.trim(),
659
928
  callerName: call.callerName || index.findEnclosingFunction(call.file, call.line),
929
+ callerFile: call.callerFile,
930
+ callerStartLine: call.callerStartLine,
931
+ confidence: call.confidence,
932
+ resolution: call.resolution,
660
933
  ...analysis
661
934
  });
662
935
  }
@@ -669,6 +942,21 @@ function impact(index, name, options = {}) {
669
942
  filteredSites = callSites.filter(s => index.matchesFilters(s.file, { exclude: options.exclude }));
670
943
  }
671
944
 
945
+ // Trust signals: tag each call site with reachability, build a confidence histogram.
946
+ // Histogram is computed BEFORE top-N truncation so the trust signal reflects the full scope.
947
+ const impactReachable = computeReachability(index);
948
+ for (const site of filteredSites) {
949
+ if (site.callerFile && site.callerStartLine != null) {
950
+ site.reachable = impactReachable.has(symbolKey(site.callerFile, site.callerStartLine));
951
+ } else {
952
+ site.reachable = false;
953
+ }
954
+ }
955
+ if (options.unreachableOnly) {
956
+ filteredSites = filteredSites.filter(s => !s.reachable);
957
+ }
958
+ const callerHistogram = buildHistogram(filteredSites);
959
+
672
960
  // Apply top limit if specified (limits total call sites shown)
673
961
  const totalBeforeLimit = filteredSites.length;
674
962
  if (options.top && options.top > 0 && filteredSites.length > options.top) {
@@ -684,6 +972,11 @@ function impact(index, name, options = {}) {
684
972
  byFile.get(site.file).push(site);
685
973
  }
686
974
 
975
+ // Feature A: tag each call site with `inTestCase` (whether the enclosing
976
+ // function is a test entry per language's getEntryPointKind predicate).
977
+ // Done BEFORE identifyCallPatterns so the aggregate count is correct.
978
+ tagInTestCase(index, filteredSites);
979
+
687
980
  // Identify patterns
688
981
  const patterns = index.identifyCallPatterns(filteredSites, name);
689
982
 
@@ -714,11 +1007,15 @@ function impact(index, name, options = {}) {
714
1007
  paramsStructured: def.paramsStructured,
715
1008
  totalCallSites: totalBeforeLimit,
716
1009
  shownCallSites: filteredSites.length,
717
- byFile: Array.from(byFile.entries()).map(([file, sites]) => ({
718
- file,
719
- count: sites.length,
720
- sites
721
- })),
1010
+ callerHistogram,
1011
+ // Stable ordering: files alphabetical, sites by line ascending. Documented contract.
1012
+ byFile: Array.from(byFile.entries())
1013
+ .sort((a, b) => a[0].localeCompare(b[0]))
1014
+ .map(([file, sites]) => ({
1015
+ file,
1016
+ count: sites.length,
1017
+ sites: [...sites].sort((s1, s2) => (s1.line || 0) - (s2.line || 0))
1018
+ })),
722
1019
  patterns,
723
1020
  scopeWarning
724
1021
  };
@@ -762,12 +1059,58 @@ function about(index, name, options = {}) {
762
1059
  }
763
1060
 
764
1061
  // Use resolveSymbol for consistent primary selection (prefers non-test files)
765
- const { def: resolved } = index.resolveSymbol(name, { file: options.file, className: options.className });
1062
+ const { def: resolved, warnings: resolveWarnings } = index.resolveSymbol(name, { file: options.file, className: options.className, line: options.line });
766
1063
  const primary = resolved || definitions[0];
767
1064
  const others = definitions.filter(d =>
768
1065
  d.relativePath !== primary.relativePath || d.startLine !== primary.startLine
769
1066
  );
770
1067
 
1068
+ // BUG-M4: signal when about auto-picked a primary among multiple candidates
1069
+ // and the user supplied no --file/--class disambiguator. The resolveSymbol
1070
+ // warnings array already includes an "ambiguous" entry — surface it on the
1071
+ // result so formatters can render the note.
1072
+ //
1073
+ // R3-NEW-4: align the displayed count with `find`'s filtered count by
1074
+ // dropping test-file definitions when --include-tests is not set.
1075
+ // Without this, `find foo` could report 2 matches while `about foo` says
1076
+ // "Found 7 definitions" — same query, divergent counts.
1077
+ const aboutWarnings = [];
1078
+ if (!options.file && !options.className && resolveWarnings && resolveWarnings.length > 0) {
1079
+ const { isTestPath } = require('./shared');
1080
+ const { isTestFile } = require('./discovery');
1081
+ const filterTests = !options.includeTests;
1082
+ for (const w of resolveWarnings) {
1083
+ if (w.type !== 'ambiguous') continue;
1084
+ if (!filterTests) {
1085
+ aboutWarnings.push(w);
1086
+ continue;
1087
+ }
1088
+ // Recompute the count using the same test exclusion that find applies.
1089
+ // Always include `def` (the picked primary) so the message wording
1090
+ // ("Using <def>...") stays consistent with the count.
1091
+ const visible = definitions.filter(d => {
1092
+ if (d === primary || (d.relativePath === primary.relativePath && d.startLine === primary.startLine)) return true;
1093
+ const lang = detectLanguage(d.file);
1094
+ if (isTestFile(d.relativePath, lang) || isTestPath(d.relativePath)) return false;
1095
+ return true;
1096
+ });
1097
+ if (visible.length <= 1) {
1098
+ // After test filtering, no real ambiguity remained — drop the warning.
1099
+ continue;
1100
+ }
1101
+ const visibleOthers = visible.filter(d => d !== primary && (d.relativePath !== primary.relativePath || d.startLine !== primary.startLine));
1102
+ const shown = visibleOthers.slice(0, 5);
1103
+ const extra = visibleOthers.length - shown.length;
1104
+ const alsoIn = shown.map(d => `${d.relativePath}:${d.startLine}`).join(', ');
1105
+ const suffix = extra > 0 ? `, and ${extra} more` : '';
1106
+ aboutWarnings.push({
1107
+ type: 'ambiguous',
1108
+ message: `Found ${visible.length} definitions for "${name}". Using ${primary.relativePath}:${primary.startLine}. Also in: ${alsoIn}${suffix}. Use file= to disambiguate.`,
1109
+ alternatives: visibleOthers.map(d => ({ file: d.relativePath, line: d.startLine })),
1110
+ });
1111
+ }
1112
+ }
1113
+
771
1114
  // Use the actual symbol name (may differ from query if fuzzy matched)
772
1115
  const symbolName = primary.name;
773
1116
 
@@ -787,10 +1130,24 @@ function about(index, name, options = {}) {
787
1130
  let allCallers = null;
788
1131
  let allCallees = null;
789
1132
  let aboutConfFiltered = 0;
790
- if (primary.type === 'function' || primary.params !== undefined) {
1133
+ // BUG-M3: include classes/structs/interfaces `new Foo()` invocations are
1134
+ // tracked as calls in the parser (isConstructor:true) and findCallers resolves
1135
+ // them. Without this, `about ClassName` produced "USAGES: 5 calls" but no
1136
+ // CALLERS section, hiding the actual constructor sites.
1137
+ const CALLABLE_TYPES = new Set(['function', 'method', 'static', 'constructor',
1138
+ 'public', 'abstract', 'classmethod', 'class', 'struct', 'interface',
1139
+ 'type', 'enum', 'trait', 'impl', 'record', 'namespace']);
1140
+ if (CALLABLE_TYPES.has(primary.type) || primary.params !== undefined) {
791
1141
  // Use maxResults to limit file iteration (with buffer for exclude filtering)
792
- const callerCap = maxCallers === Infinity ? undefined : maxCallers * 3;
793
- allCallers = index.findCallers(symbolName, { includeMethods, includeUncertain: options.includeUncertain, targetDefinitions: [primary], maxResults: callerCap });
1142
+ // Reduce buffer for highly ambiguous names (many definitions = more noise, less value per caller)
1143
+ const callerMultiplier = definitions.length > 5 ? 1.5 : 3;
1144
+ const callerCap = maxCallers === Infinity ? undefined : Math.ceil(maxCallers * callerMultiplier);
1145
+ // BUG-H1: pass needsTotal:true so the returned array's `totalCount` reflects the
1146
+ // true pre-truncation candidate count. Without this, `about` would report the
1147
+ // 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 });
1149
+ const shadowCallers = rawCallers.shadowEntries || [];
1150
+ allCallers = rawCallers;
794
1151
  // Apply exclude filter before slicing
795
1152
  if (options.exclude && options.exclude.length > 0) {
796
1153
  allCallers = allCallers.filter(c => index.matchesFilters(c.relativePath, { exclude: options.exclude }));
@@ -802,16 +1159,72 @@ function about(index, name, options = {}) {
802
1159
  allCallers = callerResult.kept;
803
1160
  aboutConfFiltered += callerResult.filtered;
804
1161
  }
1162
+ // BUG-H1: post-filter total — count the un-enriched shadow candidates that
1163
+ // also pass the same filters, so the displayed "showing N of <total>"
1164
+ // matches what `context` (which runs unbounded) would have shown.
1165
+ let shadowSurvivors = shadowCallers;
1166
+ if (options.exclude && options.exclude.length > 0) {
1167
+ shadowSurvivors = shadowSurvivors.filter(c => index.matchesFilters(c.relativePath, { exclude: options.exclude }));
1168
+ }
1169
+ if (options.minConfidence > 0) {
1170
+ shadowSurvivors = shadowSurvivors.filter(c => (c.confidence || 0) >= options.minConfidence);
1171
+ }
1172
+ // Tag reachability on raw caller objects so we can preserve the field on the projection.
1173
+ // Reachability is computed once per index and cached.
1174
+ const aboutReachable = computeReachability(index);
1175
+ tagCallersReachable(allCallers, aboutReachable);
1176
+
1177
+ // Optional: filter to unreachable-only callers
1178
+ if (options.unreachableOnly) {
1179
+ allCallers = allCallers.filter(c => !c.reachable);
1180
+ // Apply same filter to shadows using their callerStartLine/file when available.
1181
+ // Shadows lack callerStartLine, so they're treated as reachable=false (conservative,
1182
+ // matches the historical behavior where un-enriched callers had no reachability info).
1183
+ // We exclude all shadows here since unreachableOnly is a niche flag and the cost of
1184
+ // building a perfect estimate isn't justified.
1185
+ shadowSurvivors = []; // conservative — drop shadows for unreachableOnly mode
1186
+ }
1187
+ // Stash the post-filter total on allCallers so the result builder can use it.
1188
+ Object.defineProperty(allCallers, '__postFilterTotal', {
1189
+ value: allCallers.length + shadowSurvivors.length,
1190
+ enumerable: false,
1191
+ configurable: true,
1192
+ });
1193
+ // R3-NEW-1: stash shadow survivors so the histogram can include them.
1194
+ // Without this the histogram only reflects the enriched (capped) callers,
1195
+ // not the true total reported in `total`.
1196
+ Object.defineProperty(allCallers, '__shadowSurvivors', {
1197
+ value: shadowSurvivors,
1198
+ enumerable: false,
1199
+ configurable: true,
1200
+ });
1201
+
805
1202
  callers = allCallers.slice(0, maxCallers).map(c => ({
806
1203
  file: c.relativePath,
807
1204
  line: c.line,
1205
+ // Stable handle for the *caller function*, not the call site.
1206
+ // Lets the caller copy-paste the handle to drill into who-called-this.
1207
+ ...(c.callerStartLine && c.callerName && {
1208
+ handle: `${c.relativePath}:${c.callerStartLine}:${c.callerName}`
1209
+ }),
808
1210
  expression: c.content.trim(),
809
1211
  callerName: c.callerName,
810
1212
  confidence: c.confidence,
811
1213
  resolution: c.resolution,
1214
+ reachable: c.reachable,
812
1215
  }));
813
1216
 
814
- allCallees = index.findCallees(primary, { includeMethods, includeUncertain: options.includeUncertain });
1217
+ // BUG-M3: classes/structs/interfaces don't have meaningful callees
1218
+ // (their body is methods, not a sequence of calls). Skip findCallees
1219
+ // for type definitions — callers (constructor/instantiation sites)
1220
+ // are the useful signal here.
1221
+ const TYPE_DEF_KINDS = new Set(['class', 'struct', 'interface', 'type',
1222
+ 'enum', 'trait', 'impl', 'record', 'namespace']);
1223
+ if (TYPE_DEF_KINDS.has(primary.type)) {
1224
+ allCallees = [];
1225
+ } else {
1226
+ allCallees = index.findCallees(primary, { includeMethods, includeUncertain: options.includeUncertain });
1227
+ }
815
1228
  // Apply exclude filter before slicing
816
1229
  if (options.exclude && options.exclude.length > 0) {
817
1230
  allCallees = allCallees.filter(c => index.matchesFilters(c.relativePath, { exclude: options.exclude }));
@@ -823,21 +1236,37 @@ function about(index, name, options = {}) {
823
1236
  allCallees = calleeResult.kept;
824
1237
  aboutConfFiltered += calleeResult.filtered;
825
1238
  }
1239
+
1240
+ // Tag callee reachability + optional unreachable-only filter
1241
+ tagCalleesReachable(allCallees, aboutReachable);
1242
+ if (options.unreachableOnly) {
1243
+ allCallees = allCallees.filter(c => !c.reachable);
1244
+ }
1245
+ tagCalleesSideEffects(index, allCallees);
1246
+
826
1247
  callees = allCallees.slice(0, maxCallees).map(c => ({
827
1248
  name: c.name,
828
1249
  file: c.relativePath,
829
1250
  line: c.startLine,
830
1251
  startLine: c.startLine,
831
1252
  endLine: c.endLine,
1253
+ handle: c.startLine ? `${c.relativePath}:${c.startLine}:${c.name}` : undefined,
832
1254
  weight: c.weight,
833
1255
  callCount: c.callCount,
834
1256
  confidence: c.confidence,
835
1257
  resolution: c.resolution,
1258
+ reachable: c.reachable,
1259
+ ...(c.returnType && { returnType: c.returnType }),
1260
+ ...(c.paramTypes && { paramTypes: c.paramTypes }),
1261
+ ...(c.paramsStructured && { paramsStructured: c.paramsStructured }),
1262
+ ...(c.docstring && { docstring: c.docstring }),
1263
+ ...(c.sideEffects && c.sideEffects.length && { sideEffects: c.sideEffects }),
836
1264
  }));
837
1265
  }
838
1266
 
839
1267
  // Find tests — scope to the same file/class as the primary definition
840
- const tests = index.tests(symbolName, {
1268
+ // Skip expensive test search for highly ambiguous names (>10 other definitions)
1269
+ const tests = (others.length > 10 && !options.all) ? [] : index.tests(symbolName, {
841
1270
  file: options.file,
842
1271
  className: options.className || primary.className,
843
1272
  exclude: options.exclude,
@@ -891,6 +1320,16 @@ function about(index, name, options = {}) {
891
1320
  }
892
1321
  }
893
1322
 
1323
+ // Optional git enrichment for the primary symbol's file.
1324
+ // Attached only when options.git is set; skipped silently if not a git repo.
1325
+ // Cheap (single git log invocation, cached per process) and gracefully
1326
+ // degrades — formatters check `git.available` before rendering.
1327
+ let gitInfo = null;
1328
+ if (options.git) {
1329
+ const { getGitInfo } = require('./git-enrich');
1330
+ gitInfo = getGitInfo(index.root, primary.relativePath);
1331
+ }
1332
+
894
1333
  const result = {
895
1334
  found: true,
896
1335
  symbol: {
@@ -899,21 +1338,39 @@ function about(index, name, options = {}) {
899
1338
  file: primary.relativePath,
900
1339
  startLine: primary.startLine,
901
1340
  endLine: primary.endLine,
1341
+ handle: require('./shared').formatSymbolHandle(primary),
902
1342
  params: primary.params,
1343
+ ...(primary.paramsStructured && { paramsStructured: primary.paramsStructured }),
903
1344
  returnType: primary.returnType,
1345
+ ...(primary.paramTypes && { paramTypes: primary.paramTypes }),
1346
+ ...(primary.isAsync && { isAsync: true }),
1347
+ ...(primary.isGenerator && { isGenerator: true }),
1348
+ ...(primary.decorators && primary.decorators.length && { decorators: primary.decorators }),
904
1349
  modifiers: primary.modifiers,
905
1350
  docstring: primary.docstring,
906
1351
  signature: index.formatSignature(primary)
907
1352
  },
1353
+ ...(gitInfo && { git: gitInfo }),
908
1354
  usages: usagesByType,
909
1355
  totalUsages: usagesByType.calls + usagesByType.imports + usagesByType.references,
910
1356
  callers: {
911
- total: allCallers?.length ?? 0,
912
- top: callers
1357
+ // BUG-H1: prefer post-filter total (computed from enriched + shadow candidates).
1358
+ // Falls back to allCallers.length when the post-filter total wasn't computed
1359
+ // (e.g., when primary is not a function and findCallers wasn't called).
1360
+ total: allCallers?.__postFilterTotal ?? allCallers?.length ?? 0,
1361
+ top: callers,
1362
+ // R3-NEW-1: include shadow callers (un-enriched candidates that passed the
1363
+ // same filters) so the histogram counts sum to `total`, not maxResults*3.
1364
+ histogram: buildHistogram(
1365
+ allCallers && allCallers.__shadowSurvivors && allCallers.__shadowSurvivors.length > 0
1366
+ ? [...allCallers, ...allCallers.__shadowSurvivors]
1367
+ : allCallers
1368
+ ),
913
1369
  },
914
1370
  callees: {
915
1371
  total: allCallees?.length ?? 0,
916
- top: callees
1372
+ top: callees,
1373
+ histogram: buildHistogram(allCallees),
917
1374
  },
918
1375
  tests: testSummary,
919
1376
  otherDefinitions: (options.all ? others : others.slice(0, 3)).map(d => ({
@@ -925,6 +1382,9 @@ function about(index, name, options = {}) {
925
1382
  code,
926
1383
  includeMethods,
927
1384
  ...(aboutConfFiltered > 0 && { confidenceFiltered: aboutConfFiltered }),
1385
+ // BUG-M4: surface ambiguous-resolution warnings so formatters can render
1386
+ // a "auto-selected ... pass --file to choose" note.
1387
+ ...(aboutWarnings.length > 0 && { warnings: aboutWarnings }),
928
1388
  completeness: detectCompleteness(index)
929
1389
  };
930
1390
 
@@ -1051,6 +1511,31 @@ function diffImpact(index, options = {}) {
1051
1511
  // Track which functions are affected by added/modified lines
1052
1512
  const affectedSymbols = new Map(); // symbolName -> { symbol, addedLines, deletedLines }
1053
1513
 
1514
+ // Pre-compute old file's symbol identities (BUG-F): use the old AST as the
1515
+ // authoritative source for "did this function exist before?". Avoids the
1516
+ // line-arithmetic guess that was wrong for tightly-packed 1-line functions.
1517
+ // The identity key is `name\0className` (matches deletion-detection below).
1518
+ let oldSymbolIdentities = null; // null = unknown (file untracked or git failed)
1519
+ if (change.deletedLines.length > 0 || change.addedLines.length > 0) {
1520
+ const ref = staged ? 'HEAD' : base;
1521
+ try {
1522
+ const oldContent = execFileSync(
1523
+ 'git', ['show', `${ref}:${change.gitRelativePath}`],
1524
+ { cwd: index.root, encoding: 'utf-8', maxBuffer: 10 * 1024 * 1024, stdio: ['ignore', 'pipe', 'ignore'] }
1525
+ );
1526
+ const fileLang = detectLanguage(change.filePath);
1527
+ if (fileLang) {
1528
+ const oldParsed = parse(oldContent, fileLang);
1529
+ oldSymbolIdentities = new Set();
1530
+ for (const oldFn of extractCallableSymbols(oldParsed)) {
1531
+ oldSymbolIdentities.add(`${oldFn.name}\0${oldFn.className || ''}`);
1532
+ }
1533
+ }
1534
+ } catch (e) {
1535
+ // File didn't exist in base, or git error — leave null (unknown).
1536
+ }
1537
+ }
1538
+
1054
1539
  for (const line of change.addedLines) {
1055
1540
  const symbol = index.findEnclosingFunction(change.filePath, line, true);
1056
1541
  if (symbol) {
@@ -1080,18 +1565,47 @@ function diffImpact(index, options = {}) {
1080
1565
  // since those lines no longer exist. Track as module-level unless they map
1081
1566
  // to a function that still exists (the function was modified, not deleted).
1082
1567
  // We approximate: if a deleted line is within the range of a known symbol, it's a modification.
1083
- let matched = false;
1568
+ // Pick the MOST-SPECIFIC match: prefer exact-contained over tolerance-contained,
1569
+ // and among ties prefer the smallest range (innermost). This avoids an earlier
1570
+ // symbol's expanded ±2 range claiming a line that actually belongs to a later
1571
+ // 1-line function in tightly-packed files (BUG-F).
1572
+ let bestSymbol = null;
1573
+ let bestExact = false;
1574
+ let bestRange = Infinity;
1084
1575
  for (const symbol of fileEntry.symbols) {
1085
1576
  if (NON_CALLABLE_TYPES.has(symbol.type)) continue;
1086
- // Use a generous range deleted lines near a function likely belong to it
1087
- if (line >= symbol.startLine - 2 && line <= symbol.endLine + 2) {
1088
- const key = `${symbol.name}:${symbol.startLine}`;
1577
+ const exact = line >= symbol.startLine && line <= symbol.endLine;
1578
+ const tolerant = line >= symbol.startLine - 2 && line <= symbol.endLine + 2;
1579
+ if (!exact && !tolerant) continue;
1580
+ const range = symbol.endLine - symbol.startLine;
1581
+ // Prefer exact-contained over tolerance-contained; among same kind, smaller range wins.
1582
+ const better = bestSymbol === null
1583
+ || (exact && !bestExact)
1584
+ || (exact === bestExact && range < bestRange);
1585
+ if (better) {
1586
+ bestSymbol = symbol;
1587
+ bestExact = exact;
1588
+ bestRange = range;
1589
+ }
1590
+ }
1591
+ let matched = false;
1592
+ if (bestSymbol) {
1593
+ // Only attribute to a symbol that ALSO existed in the old file. If we
1594
+ // know the old identities and this symbol wasn't there, it's a brand-new
1595
+ // function — its "deleted line" is really a neighboring line that gets
1596
+ // pushed up by the diff hunk header. Treat as module-level so the new
1597
+ // symbol stays cleanly in newFunctions[] (BUG-F).
1598
+ const identityKey = `${bestSymbol.name}\0${bestSymbol.className || ''}`;
1599
+ const existedBefore = oldSymbolIdentities === null
1600
+ ? true
1601
+ : oldSymbolIdentities.has(identityKey);
1602
+ if (existedBefore) {
1603
+ const key = `${bestSymbol.name}:${bestSymbol.startLine}`;
1089
1604
  if (!affectedSymbols.has(key)) {
1090
- affectedSymbols.set(key, { symbol, addedLines: [], deletedLines: [] });
1605
+ affectedSymbols.set(key, { symbol: bestSymbol, addedLines: [], deletedLines: [] });
1091
1606
  }
1092
1607
  affectedSymbols.get(key).deletedLines.push(line);
1093
1608
  matched = true;
1094
- break;
1095
1609
  }
1096
1610
  }
1097
1611
  if (!matched) {
@@ -1109,12 +1623,22 @@ function diffImpact(index, options = {}) {
1109
1623
  }
1110
1624
  }
1111
1625
 
1112
- // Detect new functions: all added lines are within a single function range
1113
- // and the function didn't exist before (approximation: all lines in the function are added)
1626
+ // Detect new functions: a function is new if it didn't exist in the old file
1627
+ // by identity (name + className). This is authoritative no more line-count
1628
+ // heuristics. Falls back to the old line-arithmetic approximation when the
1629
+ // old file is unreachable (e.g. untracked or pre-base).
1114
1630
  for (const [key, data] of affectedSymbols) {
1115
1631
  const { symbol, addedLines } = data;
1116
- const fnLineCount = symbol.endLine - symbol.startLine + 1;
1117
- if (addedLines.length >= fnLineCount * 0.8 && data.deletedLines.length === 0) {
1632
+ const identityKey = `${symbol.name}\0${symbol.className || ''}`;
1633
+ let isNew;
1634
+ if (oldSymbolIdentities !== null) {
1635
+ isNew = !oldSymbolIdentities.has(identityKey);
1636
+ } else {
1637
+ // Fallback: 80% of body lines added and no deletions hit this symbol.
1638
+ const fnLineCount = symbol.endLine - symbol.startLine + 1;
1639
+ isNew = addedLines.length >= fnLineCount * 0.8 && data.deletedLines.length === 0;
1640
+ }
1641
+ if (isNew) {
1118
1642
  newFunctions.push({
1119
1643
  name: symbol.name,
1120
1644
  filePath: change.filePath,
@@ -1390,6 +1914,403 @@ function parseDiff(diffText, root) {
1390
1914
  return changes;
1391
1915
  }
1392
1916
 
1917
+ // ============================================================================
1918
+ // AUDIT-ASYNC (Feature B)
1919
+ // ============================================================================
1920
+
1921
+ // Languages for which audit-async runs (those with async/await keyword we
1922
+ // track). Go/Java/Rust have async machinery but audit-async is scoped to
1923
+ // JS/TS/Python per spec.
1924
+ const _AUDIT_ASYNC_LANGS = new Set(['javascript', 'typescript', 'tsx', 'python', 'html']);
1925
+
1926
+ // Built-in/standard-library callees that return promises and are commonly
1927
+ // missing-awaited. Conservative starter set (rule #9 — generic, not
1928
+ // project-specific). The audit only flags when the caller is async, the
1929
+ // callee is provably async, AND the call isn't awaited; this set covers
1930
+ // callees we can recognize without project-symbol resolution.
1931
+ const _KNOWN_ASYNC_CALLEES = new Set([
1932
+ // JS/TS
1933
+ 'fetch',
1934
+ // Node.js fs.promises etc. are method calls — `fs.readFile` resolves
1935
+ // through the symbol table as a method. We avoid hardcoding receiver
1936
+ // names here. setTimeout/setInterval are fire-and-forget by design.
1937
+ ]);
1938
+
1939
+ // Fire-and-forget patterns — calls inside these contexts are intentionally
1940
+ // unawaited. Used to suppress false positives.
1941
+ // - Promise.all / Promise.allSettled / Promise.race / Promise.any
1942
+ // - void <expr>
1943
+ // - <expr>.then() / .catch() (the call provides its own handler)
1944
+ const _FIRE_AND_FORGET_PROMISE_FNS = new Set(['all', 'allSettled', 'race', 'any']);
1945
+
1946
+ /**
1947
+ * Run an async/await audit across the project.
1948
+ *
1949
+ * Finds call sites that are likely missing an `await`. A site is flagged
1950
+ * when ALL of:
1951
+ * 1. The enclosing function is async (or top-level module code in an
1952
+ * async-context module — JS modules with top-level await).
1953
+ * 2. The callee is provably async (its symbol's `isAsync` is true) OR
1954
+ * the callee is a known async standard function (e.g., `fetch`).
1955
+ * 3. The call is not wrapped in `await` (or its Python equivalent).
1956
+ * 4. The call is not in a known fire-and-forget context (Promise.all
1957
+ * arguments, `void fn()`, `.then(...)`, return statement, assignment
1958
+ * to a variable — these are intentional non-await uses).
1959
+ *
1960
+ * Detection is AST-based per language; the language must support an
1961
+ * `await` keyword (JS/TS/Python). Other languages are skipped.
1962
+ *
1963
+ * @param {object} index - ProjectIndex instance
1964
+ * @param {object} [options] - { file, exclude }
1965
+ * @returns {{issues: Array<{file:string,line:number,callerName:string,calleeName:string,reason?:string}>}}
1966
+ */
1967
+ function auditAsync(index, options = {}) {
1968
+ index._beginOp();
1969
+ try {
1970
+ const { detectLanguage, getParser, getLanguageModule, safeParse } = require('../languages');
1971
+ const issues = [];
1972
+
1973
+ // Build a "is this name provably async" lookup from the symbol table.
1974
+ // We accept a global name only if EVERY callable definition with that
1975
+ // name is async — this avoids flagging ambiguous calls like `Map.get()`
1976
+ // where the project also has a `DataService.get()` async method.
1977
+ //
1978
+ // BUT: we ALSO track per-file async-name resolution. JavaScript/Python
1979
+ // module scope means a same-file definition shadows globals, so when
1980
+ // a caller's file contains an async definition with that name, that
1981
+ // definition wins regardless of what other files contain. This is
1982
+ // critical to avoid silent false-negatives caused by name collisions
1983
+ // across files (HIGH-1 fix).
1984
+ const asyncNames = new Set();
1985
+ const ambiguousNames = new Set(); // any non-async def exists somewhere
1986
+ const callableDefs = (defs) => defs.filter(d => d && (
1987
+ d.type === 'function' || d.type === 'method' ||
1988
+ d.type === 'constructor' || d.type === 'arrow' ||
1989
+ d.params != null || d.paramsStructured != null
1990
+ ));
1991
+ const isDefAsync = (d) => d.isAsync === true ||
1992
+ (Array.isArray(d.modifiers) && d.modifiers.includes('async'));
1993
+ for (const [name, defs] of index.symbols) {
1994
+ const callable = callableDefs(defs);
1995
+ if (callable.length === 0) continue;
1996
+ const allAsync = callable.every(isDefAsync);
1997
+ if (allAsync) {
1998
+ asyncNames.add(name);
1999
+ } else if (callable.some(isDefAsync)) {
2000
+ ambiguousNames.add(name);
2001
+ }
2002
+ }
2003
+
2004
+ // Helper: does the call site (callExpr node) sit in a "fire-and-forget"
2005
+ // context? Walk up at most a few levels and check for known patterns.
2006
+ function isFireAndForget(callNode, language) {
2007
+ let p = callNode.parent;
2008
+ // 1. Direct `void fn()` (JS/TS only)
2009
+ if (p && p.type === 'unary_expression') {
2010
+ const op = p.childForFieldName('operator');
2011
+ if (op && op.text === 'void') return true;
2012
+ // Some grammars expose first child as the operator
2013
+ const first = p.namedChild(0);
2014
+ if (first && first.type === 'void') return true;
2015
+ }
2016
+ // 2. Argument of `Promise.all([...])` / `Promise.allSettled` / etc.
2017
+ // Walk up: arguments > call > selector_expression(member) > 'Promise'.<allSettled>.
2018
+ // The call site is somewhere inside the array; check if the
2019
+ // enclosing call is a Promise.all-style helper.
2020
+ let cur = callNode.parent;
2021
+ let depth = 0;
2022
+ while (cur && depth++ < 6) {
2023
+ if ((cur.type === 'call_expression' || cur.type === 'call') && cur !== callNode) {
2024
+ const fn = cur.childForFieldName('function');
2025
+ if (fn) {
2026
+ if (fn.type === 'member_expression' || fn.type === 'attribute') {
2027
+ const obj = fn.childForFieldName('object') || fn.namedChild(0);
2028
+ const prop = fn.childForFieldName('property') || fn.namedChild(fn.namedChildCount - 1);
2029
+ if (obj && prop) {
2030
+ const objText = obj.text;
2031
+ const propText = prop.text;
2032
+ if ((objText === 'Promise' || objText === 'asyncio') &&
2033
+ _FIRE_AND_FORGET_PROMISE_FNS.has(propText)) {
2034
+ return true;
2035
+ }
2036
+ // .then(...) / .catch(...) — caller is providing a handler;
2037
+ // the inner call is intentional.
2038
+ if (propText === 'then' || propText === 'catch' || propText === 'finally') {
2039
+ // Only flag when callNode is INSIDE the chain target,
2040
+ // not just an argument
2041
+ return true;
2042
+ }
2043
+ }
2044
+ }
2045
+ }
2046
+ // Stop at the first enclosing call — we don't want to leak
2047
+ // analysis past the immediate parent call.
2048
+ break;
2049
+ }
2050
+ cur = cur.parent;
2051
+ }
2052
+ // 3. Right-hand side of an assignment / variable_declarator — the
2053
+ // promise is being captured for later use, not lost. NOT
2054
+ // fire-and-forget but also NOT a missing-await; treat as
2055
+ // intentional.
2056
+ // (We keep this distinct so the "captured" call doesn't get
2057
+ // flagged.)
2058
+ let q = callNode.parent;
2059
+ // Skip await wrappers (already handled by caller)
2060
+ if (q && (q.type === 'await_expression' || q.type === 'await')) {
2061
+ q = q.parent;
2062
+ }
2063
+ if (q) {
2064
+ if (q.type === 'variable_declarator' || q.type === 'assignment_expression') {
2065
+ return true;
2066
+ }
2067
+ if (q.type === 'return_statement') {
2068
+ return true; // returning the promise — caller awaits it
2069
+ }
2070
+ // Yielded as an expression: `yield fn()` — caller awaits / async iterator
2071
+ if (q.type === 'yield_expression' || q.type === 'yield') {
2072
+ return true;
2073
+ }
2074
+ }
2075
+ return false;
2076
+ }
2077
+
2078
+ // Process one file: find async functions, then call sites within them.
2079
+ function processFile(filePath, fileEntry) {
2080
+ if (!fileEntry || !_AUDIT_ASYNC_LANGS.has(fileEntry.language)) return;
2081
+ const language = fileEntry.language;
2082
+
2083
+ // Collect async functions from the file's symbol list.
2084
+ // Also build a per-file set of names that are async in THIS file —
2085
+ // these win over the global "all-or-nothing" check (HIGH-1 fix).
2086
+ // JS/Python module scope means a same-file def shadows imports of
2087
+ // the same name, so a sync def of `helper` elsewhere in the project
2088
+ // shouldn't make `helper()` ambiguous in a file that defines
2089
+ // `async function helper()` locally.
2090
+ const asyncFns = [];
2091
+ const fileAsyncNames = new Set();
2092
+ const fileAnyDefNames = new Set();
2093
+ if (Array.isArray(fileEntry.symbols)) {
2094
+ for (const sym of fileEntry.symbols) {
2095
+ if (!sym || !sym.startLine || !sym.endLine) continue;
2096
+ const isAsync = sym.isAsync === true ||
2097
+ (Array.isArray(sym.modifiers) && sym.modifiers.includes('async'));
2098
+ if (isAsync) asyncFns.push(sym);
2099
+ if (sym.name && (
2100
+ sym.type === 'function' || sym.type === 'method' ||
2101
+ sym.type === 'constructor' || sym.type === 'arrow' ||
2102
+ sym.params != null || sym.paramsStructured != null
2103
+ )) {
2104
+ fileAnyDefNames.add(sym.name);
2105
+ if (isAsync) fileAsyncNames.add(sym.name);
2106
+ }
2107
+ }
2108
+ }
2109
+ if (asyncFns.length === 0) return;
2110
+
2111
+ // Re-parse file to find awaited-vs-not call sites. We use a fresh
2112
+ // parse rather than tree cache because we want to walk every
2113
+ // call_expression in the async function ranges.
2114
+ let parser, content, tree;
2115
+ try {
2116
+ if (language === 'html') {
2117
+ const htmlModule = getLanguageModule('html');
2118
+ const htmlParser = getParser('html');
2119
+ const jsParser = getParser('javascript');
2120
+ if (!htmlParser || !jsParser) return;
2121
+ content = index._readFile(filePath);
2122
+ const blocks = htmlModule.extractScriptBlocks(content, htmlParser);
2123
+ if (blocks.length === 0) return;
2124
+ const virtualJS = htmlModule.buildVirtualJSContent(content, blocks);
2125
+ tree = safeParse(jsParser, virtualJS);
2126
+ } else {
2127
+ parser = getParser(language);
2128
+ if (!parser) return;
2129
+ content = index._readFile(filePath);
2130
+ tree = safeParse(parser, content);
2131
+ }
2132
+ } catch (_) { return; }
2133
+ if (!tree) return;
2134
+
2135
+ // Walk every call_expression within an async function range.
2136
+ const callTypes = new Set(['call_expression', 'call', 'method_invocation', 'object_creation_expression']);
2137
+
2138
+ // Function-boundary nodes per language (used to find the nearest
2139
+ // enclosing function and determine if IT is async — not just any
2140
+ // outer ancestor).
2141
+ const FN_NODE_TYPES = {
2142
+ javascript: new Set(['function_declaration', 'function_expression', 'arrow_function', 'method_definition', 'generator_function', 'generator_function_declaration']),
2143
+ typescript: new Set(['function_declaration', 'function_expression', 'arrow_function', 'method_definition', 'generator_function', 'generator_function_declaration', 'function_signature']),
2144
+ tsx: new Set(['function_declaration', 'function_expression', 'arrow_function', 'method_definition', 'generator_function', 'generator_function_declaration', 'function_signature']),
2145
+ html: new Set(['function_declaration', 'function_expression', 'arrow_function', 'method_definition', 'generator_function', 'generator_function_declaration']),
2146
+ python: new Set(['function_definition', 'async_function_definition', 'lambda']),
2147
+ }[language] || new Set();
2148
+
2149
+ function isAsyncFnNode(node) {
2150
+ if (!node) return false;
2151
+ // Python: async_function_definition is the explicit marker.
2152
+ if (node.type === 'async_function_definition') return true;
2153
+ // JS/TS arrow_function / function_expression / function_declaration:
2154
+ // the `async` keyword is a child of the node OR appears as
2155
+ // first token in node text.
2156
+ const t = node.text || '';
2157
+ if (t.trimStart().startsWith('async ')) return true;
2158
+ // method_definition: scan first child for 'async' identifier.
2159
+ for (let i = 0; i < node.namedChildCount; i++) {
2160
+ const c = node.namedChild(i);
2161
+ if (c.type === 'async') return true;
2162
+ }
2163
+ return false;
2164
+ }
2165
+
2166
+ // Find the nearest enclosing function symbol whose name matches
2167
+ // a top-level async fn. Returns the symbol when the call's
2168
+ // immediate enclosing fn-node is async (so callbacks inside an
2169
+ // async fn aren't misclassified as async themselves).
2170
+ function nearestAsyncEnclosing(callNode) {
2171
+ let cur = callNode.parent;
2172
+ while (cur) {
2173
+ if (FN_NODE_TYPES.has(cur.type)) {
2174
+ if (isAsyncFnNode(cur)) {
2175
+ // Match against asyncFns to get caller name.
2176
+ const startLine = cur.startPosition.row + 1;
2177
+ for (const fn of asyncFns) {
2178
+ if (fn.startLine === startLine) return fn;
2179
+ }
2180
+ // Anonymous async fn — return a synthetic record.
2181
+ return {
2182
+ name: '<anonymous>',
2183
+ startLine,
2184
+ endLine: cur.endPosition.row + 1,
2185
+ };
2186
+ }
2187
+ return null; // Inner non-async fn — stop, don't leak into outer scope.
2188
+ }
2189
+ cur = cur.parent;
2190
+ }
2191
+ return null;
2192
+ }
2193
+
2194
+ function visit(node) {
2195
+ if (!node) return;
2196
+ if (callTypes.has(node.type)) {
2197
+ const line = node.startPosition.row + 1;
2198
+ const enclosing = nearestAsyncEnclosing(node);
2199
+ if (enclosing) {
2200
+ // Get the callee name + skip if not async.
2201
+ const funcNode = node.childForFieldName('function') ||
2202
+ node.childForFieldName('name');
2203
+ if (funcNode) {
2204
+ let calleeName;
2205
+ let isMethodCall = false;
2206
+ if (funcNode.type === 'member_expression' || funcNode.type === 'attribute' ||
2207
+ funcNode.type === 'selector_expression' || funcNode.type === 'field_expression') {
2208
+ const prop = funcNode.childForFieldName('property') ||
2209
+ funcNode.childForFieldName('field') ||
2210
+ funcNode.childForFieldName('attribute');
2211
+ calleeName = prop ? prop.text : null;
2212
+ isMethodCall = true;
2213
+ } else {
2214
+ calleeName = funcNode.text;
2215
+ }
2216
+ if (calleeName) {
2217
+ // Check whether the callee is provably async.
2218
+ // File-local resolution wins (HIGH-1 fix): if
2219
+ // THIS file defines an async function with that
2220
+ // name, the call resolves to it regardless of
2221
+ // what other files contain. This avoids silent
2222
+ // false-negatives caused by name collisions
2223
+ // (e.g., async helper in bad.js + sync helper
2224
+ // in unrelated.js — bad.js's helper() should
2225
+ // still be flagged).
2226
+ let calleeIsAsync;
2227
+ if (fileAsyncNames.has(calleeName)) {
2228
+ calleeIsAsync = true;
2229
+ } else if (fileAnyDefNames.has(calleeName)) {
2230
+ // Same-file def exists and isn't async →
2231
+ // local def shadows globals → not async.
2232
+ calleeIsAsync = false;
2233
+ } else {
2234
+ // No same-file def — fall back to global
2235
+ // all-or-nothing check.
2236
+ calleeIsAsync = asyncNames.has(calleeName) ||
2237
+ _KNOWN_ASYNC_CALLEES.has(calleeName);
2238
+ }
2239
+ if (calleeIsAsync) {
2240
+ // Skip method calls in structural type
2241
+ // systems (JS/TS/Python). Without receiver
2242
+ // type evidence we can't tell `obj.get()`
2243
+ // calling `Map.get` (sync) from a project
2244
+ // class's async `get`. Method-call audits
2245
+ // need a more sophisticated receiver
2246
+ // resolution that we don't have here.
2247
+ if (isMethodCall) {
2248
+ // (Allow only when callee is in the
2249
+ // KNOWN_ASYNC_CALLEES list — those are
2250
+ // standard global functions, not
2251
+ // methods.)
2252
+ if (!_KNOWN_ASYNC_CALLEES.has(calleeName)) {
2253
+ // Continue walking — don't flag.
2254
+ } else {
2255
+ // Fall through to common flag logic
2256
+ }
2257
+ }
2258
+ if (!isMethodCall || _KNOWN_ASYNC_CALLEES.has(calleeName)) {
2259
+ // Check: is the call awaited?
2260
+ let awaited = false;
2261
+ const p = node.parent;
2262
+ if (p && (p.type === 'await_expression' || p.type === 'await')) {
2263
+ awaited = true;
2264
+ }
2265
+ if (!awaited && !isFireAndForget(node, language)) {
2266
+ issues.push({
2267
+ file: fileEntry.relativePath || filePath,
2268
+ line,
2269
+ callerName: enclosing.name,
2270
+ calleeName,
2271
+ });
2272
+ }
2273
+ }
2274
+ }
2275
+ }
2276
+ }
2277
+ }
2278
+ }
2279
+ for (let i = 0; i < node.namedChildCount; i++) {
2280
+ visit(node.namedChild(i));
2281
+ }
2282
+ }
2283
+ visit(tree.rootNode);
2284
+ }
2285
+
2286
+ // Apply file/exclude filters via index.matchesFilters when available.
2287
+ const exclude = Array.isArray(options.exclude) ? options.exclude : [];
2288
+ const fileFilter = options.file || null;
2289
+
2290
+ for (const [filePath, fileEntry] of index.files) {
2291
+ if (exclude.length > 0 && !index.matchesFilters(filePath, { exclude })) continue;
2292
+ if (fileFilter && !(fileEntry.relativePath || '').includes(fileFilter)) continue;
2293
+ processFile(filePath, fileEntry);
2294
+ }
2295
+
2296
+ // Stable ordering (rule #11): sort by (file, line, callerName, calleeName).
2297
+ issues.sort((a, b) => {
2298
+ const fc = String(a.file).localeCompare(String(b.file));
2299
+ if (fc !== 0) return fc;
2300
+ if (a.line !== b.line) return a.line - b.line;
2301
+ const cc = String(a.callerName || '').localeCompare(String(b.callerName || ''));
2302
+ if (cc !== 0) return cc;
2303
+ return String(a.calleeName || '').localeCompare(String(b.calleeName || ''));
2304
+ });
2305
+
2306
+ return {
2307
+ issues,
2308
+ totalIssues: issues.length,
2309
+ filesAffected: new Set(issues.map(i => i.file)).size,
2310
+ };
2311
+ } finally { index._endOp(); }
2312
+ }
2313
+
1393
2314
  module.exports = {
1394
2315
  context,
1395
2316
  smart,
@@ -1401,4 +2322,6 @@ module.exports = {
1401
2322
  parseDiff,
1402
2323
  extractCallableSymbols,
1403
2324
  unquoteDiffPath,
2325
+ auditAsync,
2326
+ tagInTestCase,
1404
2327
  };