ucn 3.8.23 → 3.8.26
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 +127 -12
- package/README.md +152 -156
- package/cli/index.js +363 -37
- package/core/analysis.js +936 -32
- package/core/bridge.js +1095 -0
- package/core/brief.js +408 -0
- package/core/cache.js +105 -5
- package/core/callers.js +72 -18
- package/core/check.js +200 -0
- 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.js +24 -2
- 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/parser.js +8 -2
- package/core/project.js +39 -3
- package/core/registry.js +30 -14
- package/core/reporting.js +465 -2
- package/core/search.js +130 -52
- package/core/shared.js +101 -5
- package/core/tracing.js +16 -6
- 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 +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:
|
|
469
|
-
includeUncertain:
|
|
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:
|
|
611
|
-
includeUncertain:
|
|
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
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
931
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
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:
|
|
1132
|
-
//
|
|
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
|
|
1136
|
-
|
|
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
|
};
|