ucn 3.8.23 → 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 (44) 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 +936 -32
  5. package/core/bridge.js +1111 -0
  6. package/core/brief.js +408 -0
  7. package/core/cache.js +105 -5
  8. package/core/callers.js +72 -18
  9. package/core/check.js +200 -0
  10. package/core/discovery.js +57 -34
  11. package/core/entrypoints.js +638 -4
  12. package/core/execute.js +304 -5
  13. package/core/git-enrich.js +130 -0
  14. package/core/graph.js +24 -2
  15. package/core/output/analysis.js +157 -25
  16. package/core/output/brief.js +100 -0
  17. package/core/output/check.js +79 -0
  18. package/core/output/doctor.js +85 -0
  19. package/core/output/endpoints.js +239 -0
  20. package/core/output/extraction.js +2 -0
  21. package/core/output/find.js +126 -39
  22. package/core/output/graph.js +48 -15
  23. package/core/output/refactoring.js +103 -5
  24. package/core/output/reporting.js +63 -23
  25. package/core/output/search.js +110 -17
  26. package/core/output/shared.js +56 -2
  27. package/core/output.js +4 -0
  28. package/core/parser.js +8 -2
  29. package/core/project.js +39 -3
  30. package/core/registry.js +30 -14
  31. package/core/reporting.js +465 -2
  32. package/core/search.js +130 -10
  33. package/core/shared.js +101 -5
  34. package/core/tracing.js +16 -6
  35. package/core/verify.js +982 -95
  36. package/languages/go.js +91 -6
  37. package/languages/html.js +10 -0
  38. package/languages/java.js +151 -35
  39. package/languages/javascript.js +290 -33
  40. package/languages/python.js +78 -11
  41. package/languages/rust.js +267 -12
  42. package/languages/utils.js +315 -3
  43. package/mcp/server.js +91 -16
  44. package/package.json +9 -1
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
  }
@@ -453,20 +678,34 @@ function related(index, name, options = {}) {
453
678
  function impact(index, name, options = {}) {
454
679
  index._beginOp();
455
680
  try {
456
- 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 });
457
682
  if (!def) {
458
683
  return null;
459
684
  }
460
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;
461
700
 
462
701
  // Use findCallers for className-scoped or method queries (sophisticated binding resolution)
463
702
  // Fall back to usages-based approach for simple function queries (backward compatible)
464
703
  let callSites;
465
- if (options.className || defIsMethod) {
704
+ if (options.className || defIsMethod || defIsTypeDef) {
466
705
  // findCallers has proper method call resolution (self/this, binding IDs, receiver checks)
467
706
  let callerResults = index.findCallers(name, {
468
- includeMethods: true,
469
- includeUncertain: false,
707
+ includeMethods: impactIncludeMethods,
708
+ includeUncertain: impactIncludeUncertain,
470
709
  targetDefinitions: [def],
471
710
  });
472
711
 
@@ -600,15 +839,22 @@ function impact(index, name, options = {}) {
600
839
  line: c.line,
601
840
  expression: c.content.trim(),
602
841
  callerName: c.callerName,
842
+ callerFile: c.callerFile,
843
+ callerStartLine: c.callerStartLine,
844
+ confidence: c.confidence,
845
+ resolution: c.resolution,
603
846
  ...analysis
604
847
  });
605
848
  }
606
849
  index._clearTreeCache();
607
850
  } else {
608
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.
609
855
  const callerResults = index.findCallers(name, {
610
- includeMethods: false,
611
- includeUncertain: false,
856
+ includeMethods: impactIncludeMethods,
857
+ includeUncertain: impactIncludeUncertain,
612
858
  targetDefinitions: [def],
613
859
  });
614
860
  const targetBindingId = def.bindingId;
@@ -620,6 +866,10 @@ function impact(index, name, options = {}) {
620
866
  content: c.content,
621
867
  usageType: 'call',
622
868
  callerName: c.callerName,
869
+ callerFile: c.callerFile,
870
+ callerStartLine: c.callerStartLine,
871
+ confidence: c.confidence,
872
+ resolution: c.resolution,
623
873
  }));
624
874
  // Keep the same binding filter for backward compat (findCallers already handles this,
625
875
  // but cross-check with usages-based binding filter for safety)
@@ -651,10 +901,13 @@ function impact(index, name, options = {}) {
651
901
  const targetDir = defLang === 'go' ? path.basename(path.dirname(def.file)) : null;
652
902
  for (const call of filteredCalls) {
653
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).
654
907
  // Skip method calls (obj.parse()) when target is a standalone function (parse())
655
908
  // For Go, allow calls where receiver matches the package directory name
656
909
  // (e.g., controller.FilterActive() where file is in pkg/controller/)
657
- if (analysis.isMethodCall && !defIsMethod) {
910
+ if (analysis.isMethodCall && !defIsMethod && !impactIncludeMethods) {
658
911
  if (targetDir) {
659
912
  // Get receiver from parsed calls cache
660
913
  const parsedCalls = index.getCachedCalls(call.file);
@@ -673,6 +926,10 @@ function impact(index, name, options = {}) {
673
926
  line: call.line,
674
927
  expression: call.content.trim(),
675
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,
676
933
  ...analysis
677
934
  });
678
935
  }
@@ -685,6 +942,21 @@ function impact(index, name, options = {}) {
685
942
  filteredSites = callSites.filter(s => index.matchesFilters(s.file, { exclude: options.exclude }));
686
943
  }
687
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
+
688
960
  // Apply top limit if specified (limits total call sites shown)
689
961
  const totalBeforeLimit = filteredSites.length;
690
962
  if (options.top && options.top > 0 && filteredSites.length > options.top) {
@@ -700,6 +972,11 @@ function impact(index, name, options = {}) {
700
972
  byFile.get(site.file).push(site);
701
973
  }
702
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
+
703
980
  // Identify patterns
704
981
  const patterns = index.identifyCallPatterns(filteredSites, name);
705
982
 
@@ -730,11 +1007,15 @@ function impact(index, name, options = {}) {
730
1007
  paramsStructured: def.paramsStructured,
731
1008
  totalCallSites: totalBeforeLimit,
732
1009
  shownCallSites: filteredSites.length,
733
- byFile: Array.from(byFile.entries()).map(([file, sites]) => ({
734
- file,
735
- count: sites.length,
736
- sites
737
- })),
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
+ })),
738
1019
  patterns,
739
1020
  scopeWarning
740
1021
  };
@@ -778,12 +1059,58 @@ function about(index, name, options = {}) {
778
1059
  }
779
1060
 
780
1061
  // Use resolveSymbol for consistent primary selection (prefers non-test files)
781
- 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 });
782
1063
  const primary = resolved || definitions[0];
783
1064
  const others = definitions.filter(d =>
784
1065
  d.relativePath !== primary.relativePath || d.startLine !== primary.startLine
785
1066
  );
786
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
+
787
1114
  // Use the actual symbol name (may differ from query if fuzzy matched)
788
1115
  const symbolName = primary.name;
789
1116
 
@@ -803,12 +1130,24 @@ function about(index, name, options = {}) {
803
1130
  let allCallers = null;
804
1131
  let allCallees = null;
805
1132
  let aboutConfFiltered = 0;
806
- 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) {
807
1141
  // Use maxResults to limit file iteration (with buffer for exclude filtering)
808
1142
  // Reduce buffer for highly ambiguous names (many definitions = more noise, less value per caller)
809
1143
  const callerMultiplier = definitions.length > 5 ? 1.5 : 3;
810
1144
  const callerCap = maxCallers === Infinity ? undefined : Math.ceil(maxCallers * callerMultiplier);
811
- allCallers = index.findCallers(symbolName, { includeMethods, includeUncertain: options.includeUncertain, targetDefinitions: [primary], maxResults: callerCap });
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;
812
1151
  // Apply exclude filter before slicing
813
1152
  if (options.exclude && options.exclude.length > 0) {
814
1153
  allCallers = allCallers.filter(c => index.matchesFilters(c.relativePath, { exclude: options.exclude }));
@@ -820,16 +1159,72 @@ function about(index, name, options = {}) {
820
1159
  allCallers = callerResult.kept;
821
1160
  aboutConfFiltered += callerResult.filtered;
822
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
+
823
1202
  callers = allCallers.slice(0, maxCallers).map(c => ({
824
1203
  file: c.relativePath,
825
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
+ }),
826
1210
  expression: c.content.trim(),
827
1211
  callerName: c.callerName,
828
1212
  confidence: c.confidence,
829
1213
  resolution: c.resolution,
1214
+ reachable: c.reachable,
830
1215
  }));
831
1216
 
832
- 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
+ }
833
1228
  // Apply exclude filter before slicing
834
1229
  if (options.exclude && options.exclude.length > 0) {
835
1230
  allCallees = allCallees.filter(c => index.matchesFilters(c.relativePath, { exclude: options.exclude }));
@@ -841,16 +1236,31 @@ function about(index, name, options = {}) {
841
1236
  allCallees = calleeResult.kept;
842
1237
  aboutConfFiltered += calleeResult.filtered;
843
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
+
844
1247
  callees = allCallees.slice(0, maxCallees).map(c => ({
845
1248
  name: c.name,
846
1249
  file: c.relativePath,
847
1250
  line: c.startLine,
848
1251
  startLine: c.startLine,
849
1252
  endLine: c.endLine,
1253
+ handle: c.startLine ? `${c.relativePath}:${c.startLine}:${c.name}` : undefined,
850
1254
  weight: c.weight,
851
1255
  callCount: c.callCount,
852
1256
  confidence: c.confidence,
853
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 }),
854
1264
  }));
855
1265
  }
856
1266
 
@@ -910,6 +1320,16 @@ function about(index, name, options = {}) {
910
1320
  }
911
1321
  }
912
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
+
913
1333
  const result = {
914
1334
  found: true,
915
1335
  symbol: {
@@ -918,21 +1338,39 @@ function about(index, name, options = {}) {
918
1338
  file: primary.relativePath,
919
1339
  startLine: primary.startLine,
920
1340
  endLine: primary.endLine,
1341
+ handle: require('./shared').formatSymbolHandle(primary),
921
1342
  params: primary.params,
1343
+ ...(primary.paramsStructured && { paramsStructured: primary.paramsStructured }),
922
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 }),
923
1349
  modifiers: primary.modifiers,
924
1350
  docstring: primary.docstring,
925
1351
  signature: index.formatSignature(primary)
926
1352
  },
1353
+ ...(gitInfo && { git: gitInfo }),
927
1354
  usages: usagesByType,
928
1355
  totalUsages: usagesByType.calls + usagesByType.imports + usagesByType.references,
929
1356
  callers: {
930
- total: allCallers?.length ?? 0,
931
- 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
+ ),
932
1369
  },
933
1370
  callees: {
934
1371
  total: allCallees?.length ?? 0,
935
- top: callees
1372
+ top: callees,
1373
+ histogram: buildHistogram(allCallees),
936
1374
  },
937
1375
  tests: testSummary,
938
1376
  otherDefinitions: (options.all ? others : others.slice(0, 3)).map(d => ({
@@ -944,6 +1382,9 @@ function about(index, name, options = {}) {
944
1382
  code,
945
1383
  includeMethods,
946
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 }),
947
1388
  completeness: detectCompleteness(index)
948
1389
  };
949
1390
 
@@ -1070,6 +1511,31 @@ function diffImpact(index, options = {}) {
1070
1511
  // Track which functions are affected by added/modified lines
1071
1512
  const affectedSymbols = new Map(); // symbolName -> { symbol, addedLines, deletedLines }
1072
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
+
1073
1539
  for (const line of change.addedLines) {
1074
1540
  const symbol = index.findEnclosingFunction(change.filePath, line, true);
1075
1541
  if (symbol) {
@@ -1099,18 +1565,47 @@ function diffImpact(index, options = {}) {
1099
1565
  // since those lines no longer exist. Track as module-level unless they map
1100
1566
  // to a function that still exists (the function was modified, not deleted).
1101
1567
  // We approximate: if a deleted line is within the range of a known symbol, it's a modification.
1102
- 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;
1103
1575
  for (const symbol of fileEntry.symbols) {
1104
1576
  if (NON_CALLABLE_TYPES.has(symbol.type)) continue;
1105
- // Use a generous range deleted lines near a function likely belong to it
1106
- if (line >= symbol.startLine - 2 && line <= symbol.endLine + 2) {
1107
- 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}`;
1108
1604
  if (!affectedSymbols.has(key)) {
1109
- affectedSymbols.set(key, { symbol, addedLines: [], deletedLines: [] });
1605
+ affectedSymbols.set(key, { symbol: bestSymbol, addedLines: [], deletedLines: [] });
1110
1606
  }
1111
1607
  affectedSymbols.get(key).deletedLines.push(line);
1112
1608
  matched = true;
1113
- break;
1114
1609
  }
1115
1610
  }
1116
1611
  if (!matched) {
@@ -1128,12 +1623,22 @@ function diffImpact(index, options = {}) {
1128
1623
  }
1129
1624
  }
1130
1625
 
1131
- // Detect new functions: all added lines are within a single function range
1132
- // 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).
1133
1630
  for (const [key, data] of affectedSymbols) {
1134
1631
  const { symbol, addedLines } = data;
1135
- const fnLineCount = symbol.endLine - symbol.startLine + 1;
1136
- 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) {
1137
1642
  newFunctions.push({
1138
1643
  name: symbol.name,
1139
1644
  filePath: change.filePath,
@@ -1409,6 +1914,403 @@ function parseDiff(diffText, root) {
1409
1914
  return changes;
1410
1915
  }
1411
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
+
1412
2314
  module.exports = {
1413
2315
  context,
1414
2316
  smart,
@@ -1420,4 +2322,6 @@ module.exports = {
1420
2322
  parseDiff,
1421
2323
  extractCallableSymbols,
1422
2324
  unquoteDiffPath,
2325
+ auditAsync,
2326
+ tagInTestCase,
1423
2327
  };