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.
- package/.claude/skills/ucn/SKILL.md +114 -11
- package/README.md +152 -156
- package/cli/index.js +363 -37
- package/core/analysis.js +960 -37
- package/core/bridge.js +1111 -0
- package/core/brief.js +408 -0
- package/core/cache.js +213 -59
- package/core/callers.js +117 -41
- package/core/check.js +200 -0
- package/core/deadcode.js +31 -2
- package/core/discovery.js +57 -34
- package/core/entrypoints.js +638 -4
- package/core/execute.js +304 -5
- package/core/git-enrich.js +130 -0
- package/core/graph-build.js +4 -4
- package/core/graph.js +31 -12
- package/core/output/analysis.js +157 -25
- package/core/output/brief.js +100 -0
- package/core/output/check.js +79 -0
- package/core/output/doctor.js +85 -0
- package/core/output/endpoints.js +239 -0
- package/core/output/extraction.js +2 -0
- package/core/output/find.js +126 -39
- package/core/output/graph.js +48 -15
- package/core/output/refactoring.js +103 -5
- package/core/output/reporting.js +63 -23
- package/core/output/search.js +110 -17
- package/core/output/shared.js +56 -2
- package/core/output.js +4 -0
- package/core/parallel-build.js +10 -7
- package/core/parser.js +8 -2
- package/core/project.js +147 -41
- package/core/registry.js +30 -14
- package/core/reporting.js +465 -2
- package/core/search.js +139 -15
- package/core/shared.js +101 -5
- package/core/tracing.js +31 -12
- package/core/verify.js +982 -95
- package/languages/go.js +91 -6
- package/languages/html.js +10 -0
- package/languages/java.js +151 -35
- package/languages/javascript.js +290 -33
- package/languages/python.js +78 -11
- package/languages/rust.js +267 -12
- package/languages/utils.js +315 -3
- package/mcp/server.js +91 -16
- 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
|
-
|
|
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
|
-
|
|
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:
|
|
453
|
-
includeUncertain:
|
|
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:
|
|
595
|
-
includeUncertain:
|
|
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
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
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
|
-
|
|
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
|
-
|
|
793
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
912
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
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:
|
|
1113
|
-
//
|
|
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
|
|
1117
|
-
|
|
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
|
};
|