ucn 5.4.1 → 5.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/skills/ucn/SKILL.md +58 -236
- package/.claude/skills/ucn/references/commands.md +144 -68
- package/.claude/skills/ucn/references/resolution-rules.md +171 -0
- package/.claude/skills/ucn/references/trust-contract.md +27 -33
- package/README.md +18 -12
- package/cli/index.js +42 -16
- package/core/accessors.js +118 -3
- package/core/account.js +30 -0
- package/core/analysis.js +508 -264
- package/core/ast-analysis.js +76 -117
- package/core/async-producers.js +1729 -0
- package/core/bridge.js +1877 -268
- package/core/brief.js +6 -0
- package/core/build-worker.js +78 -14
- package/core/cache.js +522 -31
- package/core/callers.js +12053 -1047
- package/core/check.js +30 -38
- package/core/class-identity.js +1028 -0
- package/core/command-contracts.js +6 -6
- package/core/configuration-items.js +86 -0
- package/core/contract-membership.js +2262 -0
- package/core/cpp-scope.js +532 -0
- package/core/deadcode.js +171 -49
- package/core/decorator-binding.js +260 -0
- package/core/discovery.js +281 -164
- package/core/errors.js +52 -0
- package/core/execute.js +21 -9
- package/core/external-macros.js +326 -0
- package/core/graph-build.js +277 -8
- package/core/graph.js +35 -2
- package/core/ignore-rules.js +472 -0
- package/core/imports.js +504 -42
- package/core/index-ir.js +94 -11
- package/core/inprocess-clients.js +636 -0
- package/core/ir.js +87 -8
- package/core/macro-expansion.js +1416 -0
- package/core/output/analysis-ext.js +6 -6
- package/core/output/analysis.js +18 -2
- package/core/output/brief.js +3 -0
- package/core/output/doctor.js +4 -0
- package/core/output/endpoints.js +15 -6
- package/core/output/find.js +11 -2
- package/core/output/lines.js +8 -2
- package/core/output/refactoring.js +121 -14
- package/core/output/reporting.js +31 -3
- package/core/output/shared.js +14 -0
- package/core/output-budget.js +316 -122
- package/core/parallel-build.js +124 -47
- package/core/parser.js +3 -2
- package/core/project.js +457 -60
- package/core/provenance-facts.js +156 -12
- package/core/provenance-overload.js +30 -3
- package/core/provenance.js +104 -30
- package/core/pytest-fixtures.js +434 -0
- package/core/python-fixture-flow.js +17 -3
- package/core/python-modules.js +173 -0
- package/core/reflection.js +877 -0
- package/core/registry.js +1 -1
- package/core/reporting.js +234 -52
- package/core/request-helpers.js +561 -0
- package/core/route-graph.js +1922 -0
- package/core/route-lists.js +2414 -0
- package/core/rust-macro-expansion.js +2113 -0
- package/core/rust-modules.js +185 -0
- package/core/search.js +121 -29
- package/core/shared.js +19 -1
- package/core/test-name-bindings.js +256 -0
- package/core/type-denotation.js +1161 -0
- package/core/type-references.js +3229 -0
- package/core/verify.js +3360 -672
- package/languages/adapter.js +31 -5
- package/languages/c-family.js +4061 -212
- package/languages/c-macro-invocations.js +380 -0
- package/languages/c-preprocessor.js +254 -0
- package/languages/csharp.js +1406 -49
- package/languages/go.js +1243 -70
- package/languages/index.js +728 -6
- package/languages/java.js +473 -61
- package/languages/javascript.js +1270 -197
- package/languages/lexical-scope.js +1280 -0
- package/languages/python.js +1557 -72
- package/languages/rust-macro-rules.js +1020 -0
- package/languages/rust-value-flow.js +416 -0
- package/languages/rust.js +2237 -115
- package/languages/utils.js +298 -4
- package/mcp/server.js +25 -11
- package/package.json +1 -1
package/core/analysis.js
CHANGED
|
@@ -10,13 +10,19 @@
|
|
|
10
10
|
const fs = require('fs');
|
|
11
11
|
const path = require('path');
|
|
12
12
|
const { execFileSync } = require('child_process');
|
|
13
|
+
const { UcnError } = require('./errors');
|
|
13
14
|
const { parse } = require('./parser');
|
|
14
15
|
const { detectLanguage, langTraits } = require('../languages');
|
|
15
16
|
const { NON_CALLABLE_TYPES, addTestExclusions, countTextBlindspots,
|
|
16
17
|
codeUnitCompare, formatSymbolHandle } = require('./shared');
|
|
17
18
|
const { isTestFile } = require('./discovery');
|
|
18
|
-
|
|
19
|
+
// Entry-point detection loads on first use (fix #375).
|
|
20
|
+
let entrypointsModule = null;
|
|
21
|
+
const entrypoints = () => (entrypointsModule ||= require('./entrypoints'));
|
|
22
|
+
const computeReachability = index => entrypoints().computeReachability(index);
|
|
23
|
+
const symbolKey = (file, line) => entrypoints().symbolKey(file, line);
|
|
19
24
|
const { getLanguageAdapter } = require('../languages');
|
|
25
|
+
const { parseOpenCallShape } = require('../languages/rust-value-flow');
|
|
20
26
|
const { projectComputedDispatch, declarationSnapshots } = require('./ast-analysis');
|
|
21
27
|
const { findAccessorReferences } = require('./accessors');
|
|
22
28
|
|
|
@@ -360,9 +366,14 @@ function context(index, name, options = {}) {
|
|
|
360
366
|
if (['class', 'struct', 'interface', 'type', 'enum', 'record', 'trait', 'namespace']
|
|
361
367
|
.includes(def.type)) {
|
|
362
368
|
const methods = index.findMethodsForType(name, def);
|
|
369
|
+
const { ownerDefinitionOf, classKeyOf } = require('./class-identity');
|
|
370
|
+
const typeKey = classKeyOf(index, def);
|
|
363
371
|
const members = (index.files.get(def.file)?.symbols || []).filter(symbol =>
|
|
364
372
|
symbol.className === def.name &&
|
|
365
|
-
['field', 'constant', 'state'].includes(symbol.type)
|
|
373
|
+
['field', 'constant', 'state'].includes(symbol.type) &&
|
|
374
|
+
// A member of another same-name type body is not this type's (fix #389).
|
|
375
|
+
(!ownerDefinitionOf(index, symbol) ||
|
|
376
|
+
classKeyOf(index, ownerDefinitionOf(index, symbol)) === typeKey));
|
|
366
377
|
|
|
367
378
|
// Pin caller resolution to the resolved class definition — same as the
|
|
368
379
|
// function path below. Without this, same-name classes in other files
|
|
@@ -1087,6 +1098,19 @@ function findTypeReferences(index, name, def, options = {}) {
|
|
|
1087
1098
|
};
|
|
1088
1099
|
}
|
|
1089
1100
|
|
|
1101
|
+
// The why of an unverified caller (reason, dispatch family, reflection
|
|
1102
|
+
// pattern), carried the same way whether the engine tiered the entry in its
|
|
1103
|
+
// result list or routed it separately: `show` lists both with it.
|
|
1104
|
+
function unverifiedDetail(entry) {
|
|
1105
|
+
return {
|
|
1106
|
+
...(entry.reason && { reason: entry.reason }),
|
|
1107
|
+
...(entry.dispatchVia && { dispatchVia: entry.dispatchVia }),
|
|
1108
|
+
...(entry.dispatchCandidates != null && { dispatchCandidates: entry.dispatchCandidates }),
|
|
1109
|
+
...(entry.reflectionPattern && { reflectionPattern: entry.reflectionPattern }),
|
|
1110
|
+
...(entry.reflectionScope && { reflectionScope: entry.reflectionScope }),
|
|
1111
|
+
};
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1090
1114
|
function impact(index, name, options = {}) {
|
|
1091
1115
|
index._beginOp();
|
|
1092
1116
|
try {
|
|
@@ -1196,6 +1220,7 @@ function impact(index, name, options = {}) {
|
|
|
1196
1220
|
...(c.provenance && { provenance: c.provenance }),
|
|
1197
1221
|
...(c.siteProvenance && { siteProvenance: c.siteProvenance }),
|
|
1198
1222
|
tier: c.tier,
|
|
1223
|
+
...unverifiedDetail(c),
|
|
1199
1224
|
}));
|
|
1200
1225
|
// findCallers already applied binding, receiver, module ownership, and
|
|
1201
1226
|
// target-definition pinning. Do not second-guess it with a file-level
|
|
@@ -1249,6 +1274,7 @@ function impact(index, name, options = {}) {
|
|
|
1249
1274
|
...(call.provenance && { provenance: call.provenance }),
|
|
1250
1275
|
...(call.siteProvenance && { siteProvenance: call.siteProvenance }),
|
|
1251
1276
|
...(call.tier && { tier: call.tier }),
|
|
1277
|
+
...unverifiedDetail(call),
|
|
1252
1278
|
...analysis
|
|
1253
1279
|
});
|
|
1254
1280
|
}
|
|
@@ -1272,9 +1298,7 @@ function impact(index, name, options = {}) {
|
|
|
1272
1298
|
...(u.provenance && { provenance: u.provenance }),
|
|
1273
1299
|
...(u.siteProvenance && { siteProvenance: u.siteProvenance }),
|
|
1274
1300
|
tier: 'unverified',
|
|
1275
|
-
...(u
|
|
1276
|
-
...(u.dispatchVia && { dispatchVia: u.dispatchVia }),
|
|
1277
|
-
...(u.dispatchCandidates != null && { dispatchCandidates: u.dispatchCandidates }),
|
|
1301
|
+
...unverifiedDetail(u),
|
|
1278
1302
|
});
|
|
1279
1303
|
}
|
|
1280
1304
|
unverifiedSites.sort((a, b) => {
|
|
@@ -1969,7 +1993,7 @@ function diffImpact(index, options = {}) {
|
|
|
1969
1993
|
|
|
1970
1994
|
// Validate base ref format to prevent argument injection
|
|
1971
1995
|
if (base && !/^[a-zA-Z0-9._\-~\/^@{}:]+$/.test(base)) { // eslint-disable-line no-useless-escape
|
|
1972
|
-
throw new
|
|
1996
|
+
throw new UcnError(`Invalid git ref format: ${base}`);
|
|
1973
1997
|
}
|
|
1974
1998
|
|
|
1975
1999
|
// Verify git repo
|
|
@@ -1977,7 +2001,7 @@ function diffImpact(index, options = {}) {
|
|
|
1977
2001
|
try {
|
|
1978
2002
|
gitRoot = execFileSync('git', ['rev-parse', '--show-toplevel'], { cwd: index.root, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
|
|
1979
2003
|
} catch (e) {
|
|
1980
|
-
throw new
|
|
2004
|
+
throw new UcnError('Not a git repository. impact without a symbol requires git.', { cause: e });
|
|
1981
2005
|
}
|
|
1982
2006
|
|
|
1983
2007
|
// Build git diff command (use execFileSync to avoid shell expansion)
|
|
@@ -2006,7 +2030,7 @@ function diffImpact(index, options = {}) {
|
|
|
2006
2030
|
diffText = e.stdout;
|
|
2007
2031
|
} else {
|
|
2008
2032
|
const fatal = String(e.stderr || '').split('\n').find(l => l.startsWith('fatal:'));
|
|
2009
|
-
throw new
|
|
2033
|
+
throw new UcnError(fatal
|
|
2010
2034
|
? `git diff failed — ${fatal.replace(/^fatal:\s*/, '')}`
|
|
2011
2035
|
: `git diff failed: ${e.message}`, { cause: e });
|
|
2012
2036
|
}
|
|
@@ -2385,6 +2409,29 @@ function diffImpact(index, options = {}) {
|
|
|
2385
2409
|
includeMethods: true,
|
|
2386
2410
|
collectAccount: true,
|
|
2387
2411
|
});
|
|
2412
|
+
// Call sites the base declaration bound that the changed one's
|
|
2413
|
+
// sweep excludes (fix #394) are the change's likeliest breaks:
|
|
2414
|
+
// unverified, never other-target.
|
|
2415
|
+
const baseDeclaration = oldCallables && targetDefs.length === 1
|
|
2416
|
+
? require('./verify').baseCounterpartOf(fileEntry.symbols, oldCallables, targetDefs[0]) : null;
|
|
2417
|
+
if (baseDeclaration) {
|
|
2418
|
+
const { sitesReboundByChange } = require('./verify');
|
|
2419
|
+
const rebound = sitesReboundByChange(index, symbol.name, targetDefs[0], rawCallers,
|
|
2420
|
+
rawCallers.filter(c => c.tier !== 'unverified'),
|
|
2421
|
+
rawCallers.filter(c => c.tier === 'unverified').concat(rawCallers.unverifiedEntries || []),
|
|
2422
|
+
[{ def: targetDefs[0], before: baseDeclaration }]);
|
|
2423
|
+
// Sites already in the unverified band stay listed as they are.
|
|
2424
|
+
const hidden = rebound.filter(site => !site.stillUnverified);
|
|
2425
|
+
if (hidden.length > 0) {
|
|
2426
|
+
const moved = new Set(hidden.map(site => `${site.file}\0${site.line}`));
|
|
2427
|
+
rawCallers.accountRaw = {
|
|
2428
|
+
...rawCallers.accountRaw,
|
|
2429
|
+
excludedEntries: rawCallers.accountRaw.excludedEntries.filter(entry =>
|
|
2430
|
+
!moved.has(`${entry.file}\0${entry.line}`)),
|
|
2431
|
+
};
|
|
2432
|
+
rawCallers.unverifiedEntries = [...(rawCallers.unverifiedEntries || []), ...hidden];
|
|
2433
|
+
}
|
|
2434
|
+
}
|
|
2388
2435
|
|
|
2389
2436
|
const confirmed = rawCallers.filter(c => c.tier !== 'unverified');
|
|
2390
2437
|
let unverified = rawCallers.filter(c => c.tier === 'unverified')
|
|
@@ -2638,15 +2685,18 @@ function isDiffCallable(symbol) {
|
|
|
2638
2685
|
*/
|
|
2639
2686
|
function extractCallableSymbols(parsed) {
|
|
2640
2687
|
const symbols = [];
|
|
2688
|
+
// Declared parameters travel with each symbol: `check` compares a
|
|
2689
|
+
// changed definition with its base declaration (fix #394).
|
|
2690
|
+
const signature = s => ({ params: s.params, paramsStructured: s.paramsStructured, generics: s.generics });
|
|
2641
2691
|
for (const fn of parsed.functions) {
|
|
2642
2692
|
const className = fn.className || (fn.receiver ? fn.receiver.replace(/^\*/, '') : '');
|
|
2643
|
-
symbols.push({ name: fn.name, className, startLine: fn.startLine, endLine: fn.endLine });
|
|
2693
|
+
symbols.push({ name: fn.name, className, startLine: fn.startLine, endLine: fn.endLine, ...signature(fn) });
|
|
2644
2694
|
}
|
|
2645
2695
|
for (const cls of parsed.classes) {
|
|
2646
2696
|
if (cls.members) {
|
|
2647
2697
|
for (const m of cls.members) {
|
|
2648
2698
|
if (!isDiffCallable(m)) continue;
|
|
2649
|
-
symbols.push({ name: m.name, className: cls.name, startLine: m.startLine, endLine: m.endLine });
|
|
2699
|
+
symbols.push({ name: m.name, className: cls.name, startLine: m.startLine, endLine: m.endLine, ...signature(m) });
|
|
2650
2700
|
}
|
|
2651
2701
|
}
|
|
2652
2702
|
}
|
|
@@ -2751,35 +2801,13 @@ function parseDiff(diffText, root) {
|
|
|
2751
2801
|
// ============================================================================
|
|
2752
2802
|
|
|
2753
2803
|
// Languages for which audit-async runs (those with async/await keyword we
|
|
2754
|
-
// track).
|
|
2755
|
-
//
|
|
2804
|
+
// track). Rust futures are audited separately (processRustFile, fix #370);
|
|
2805
|
+
// Go/Java have no awaitable call results.
|
|
2806
|
+
const JS_AUDIT_FAMILY = new Set(['javascript', 'typescript', 'tsx']);
|
|
2756
2807
|
const _AUDIT_ASYNC_LANGS = new Set([
|
|
2757
2808
|
'javascript', 'typescript', 'tsx', 'python', 'html', 'csharp',
|
|
2758
2809
|
]);
|
|
2759
2810
|
|
|
2760
|
-
// Built-in/standard-library callees that return promises and are commonly
|
|
2761
|
-
// missing-awaited. Conservative starter set (rule #9 — generic, not
|
|
2762
|
-
// project-specific). The audit only flags when the caller is async, the
|
|
2763
|
-
// callee is provably async, AND the call isn't awaited; this set covers
|
|
2764
|
-
// callees we can recognize without project-symbol resolution.
|
|
2765
|
-
const _KNOWN_ASYNC_CALLEES = new Set([
|
|
2766
|
-
// JS/TS
|
|
2767
|
-
'fetch',
|
|
2768
|
-
// Node.js fs.promises etc. are method calls — `fs.readFile` resolves
|
|
2769
|
-
// through the symbol table as a method. We avoid hardcoding receiver
|
|
2770
|
-
// names here. setTimeout/setInterval are fire-and-forget by design.
|
|
2771
|
-
]);
|
|
2772
|
-
|
|
2773
|
-
// Fire-and-forget patterns — calls inside these contexts are intentionally
|
|
2774
|
-
// unawaited. Used to suppress false positives.
|
|
2775
|
-
// - Promise.all / Promise.allSettled / Promise.race / Promise.any
|
|
2776
|
-
// - void <expr>
|
|
2777
|
-
// - <expr>.then() / .catch() (the call provides its own handler)
|
|
2778
|
-
const _FIRE_AND_FORGET_PROMISE_FNS = new Set(['all', 'allSettled', 'race', 'any']);
|
|
2779
|
-
const _ASYNCIO_CONSUMER_FNS = new Set([
|
|
2780
|
-
'gather', 'create_task', 'ensure_future', 'wait', 'as_completed',
|
|
2781
|
-
]);
|
|
2782
|
-
|
|
2783
2811
|
// Follow a captured JS promise within its lexical scope, looking only for
|
|
2784
2812
|
// operations that require its resolved value. Passing/returning/awaiting the
|
|
2785
2813
|
// promise and its own methods are valid. Reassignment stops the inference;
|
|
@@ -2862,9 +2890,10 @@ function storedPromiseMisuse(call, functionNodes) {
|
|
|
2862
2890
|
* 2. The callee is provably async (its symbol's `isAsync` is true) OR
|
|
2863
2891
|
* the callee is a known async standard function (e.g., `fetch`).
|
|
2864
2892
|
* 3. The call is not wrapped in `await` (or its Python equivalent).
|
|
2865
|
-
* 4. The
|
|
2866
|
-
*
|
|
2867
|
-
*
|
|
2893
|
+
* 4. The value is lost or misused: discarded as a bare statement, stored
|
|
2894
|
+
* in a local that is never read, or used as if it were the resolved
|
|
2895
|
+
* value. A value that flows on (argument, collection, spread, return,
|
|
2896
|
+
* conditional branch, stored field) hands the awaitable to other code.
|
|
2868
2897
|
*
|
|
2869
2898
|
* Detection is AST-based per language; the language must support an
|
|
2870
2899
|
* `await` keyword (JS/TS/Python/C#). Other languages are skipped.
|
|
@@ -2879,148 +2908,293 @@ function auditAsync(index, options = {}) {
|
|
|
2879
2908
|
const { getParser, getLanguageAdapter, safeParse } = require('../languages');
|
|
2880
2909
|
const issues = [];
|
|
2881
2910
|
|
|
2882
|
-
//
|
|
2883
|
-
//
|
|
2884
|
-
//
|
|
2885
|
-
//
|
|
2886
|
-
//
|
|
2887
|
-
//
|
|
2888
|
-
//
|
|
2889
|
-
|
|
2890
|
-
|
|
2891
|
-
|
|
2892
|
-
|
|
2893
|
-
const asyncNames = new Set();
|
|
2894
|
-
const ambiguousNames = new Set(); // any non-async def exists somewhere
|
|
2895
|
-
const callableDefs = (defs) => defs.filter(d => d && (
|
|
2896
|
-
d.type === 'function' || d.type === 'method' ||
|
|
2897
|
-
d.type === 'constructor' || d.type === 'arrow' ||
|
|
2898
|
-
d.params != null || d.paramsStructured != null
|
|
2899
|
-
));
|
|
2900
|
-
const isDefAsync = (d) => d.isAsync === true ||
|
|
2901
|
-
(Array.isArray(d.modifiers) && d.modifiers.includes('async'));
|
|
2911
|
+
// Names select candidate sites. Structural languages then require
|
|
2912
|
+
// lexical or import identity; an unrelated namesake proves nothing.
|
|
2913
|
+
// What each async definition's call RETURNS decides the audit
|
|
2914
|
+
// (fix #364): coroutines must be awaited; async iterators and async
|
|
2915
|
+
// context managers are consumed by `async for` / `async with` and
|
|
2916
|
+
// only a discarded call is a defect; unknown producers (unresolved
|
|
2917
|
+
// decorators, disagreeing same-name definitions) are not audited.
|
|
2918
|
+
const producers = require('./async-producers');
|
|
2919
|
+
const isAsyncDef = def => producers.isDefAsync(def);
|
|
2920
|
+
const kindMemo = new Map();
|
|
2921
|
+
const asyncCandidateNames = new Set();
|
|
2902
2922
|
for (const [name, defs] of index.symbols) {
|
|
2903
|
-
|
|
2904
|
-
|
|
2905
|
-
const allAsync = callable.every(isDefAsync);
|
|
2906
|
-
if (allAsync) {
|
|
2907
|
-
asyncNames.add(name);
|
|
2908
|
-
} else if (callable.some(isDefAsync)) {
|
|
2909
|
-
ambiguousNames.add(name);
|
|
2923
|
+
if (defs.some(def => producers.isCallableDef(def) && producers.isDefAsync(def))) {
|
|
2924
|
+
asyncCandidateNames.add(name);
|
|
2910
2925
|
}
|
|
2911
2926
|
}
|
|
2912
|
-
|
|
2913
|
-
//
|
|
2914
|
-
//
|
|
2915
|
-
|
|
2916
|
-
|
|
2917
|
-
|
|
2918
|
-
|
|
2919
|
-
|
|
2920
|
-
|
|
2921
|
-
|
|
2922
|
-
|
|
2923
|
-
|
|
2927
|
+
// Class names (null for free functions) owning an async definition
|
|
2928
|
+
// of a name in a language family (a Python call never reaches a JS
|
|
2929
|
+
// definition), and whether a class name's ancestry or descendants
|
|
2930
|
+
// reach one of them (name level: a prefilter, never identity).
|
|
2931
|
+
const auditFamily = language => (JS_AUDIT_FAMILY.has(language) || language === 'html' ? 'js' : language);
|
|
2932
|
+
const asyncOwners = new Map();
|
|
2933
|
+
const asyncOwnersOf = (name, family) => {
|
|
2934
|
+
const key = `${family}\0${name}`;
|
|
2935
|
+
let owners = asyncOwners.get(key);
|
|
2936
|
+
if (!owners) {
|
|
2937
|
+
owners = new Set((index.symbols.get(name) || [])
|
|
2938
|
+
.filter(def => producers.isCallableDef(def) && producers.isDefAsync(def) &&
|
|
2939
|
+
auditFamily(index.files.get(def.file)?.language) === family)
|
|
2940
|
+
.map(def => def.className || null));
|
|
2941
|
+
asyncOwners.set(key, owners);
|
|
2924
2942
|
}
|
|
2925
|
-
|
|
2926
|
-
|
|
2927
|
-
|
|
2928
|
-
|
|
2929
|
-
let
|
|
2930
|
-
|
|
2931
|
-
|
|
2932
|
-
|
|
2933
|
-
|
|
2934
|
-
|
|
2935
|
-
|
|
2936
|
-
const obj = fn.childForFieldName('object') || fn.namedChild(0);
|
|
2937
|
-
const prop = fn.childForFieldName('property') || fn.namedChild(fn.namedChildCount - 1);
|
|
2938
|
-
if (obj && prop) {
|
|
2939
|
-
const objText = obj.text;
|
|
2940
|
-
const propText = prop.text;
|
|
2941
|
-
if ((objText === 'Promise' &&
|
|
2942
|
-
_FIRE_AND_FORGET_PROMISE_FNS.has(propText)) ||
|
|
2943
|
-
(objText === 'asyncio' &&
|
|
2944
|
-
_ASYNCIO_CONSUMER_FNS.has(propText))) {
|
|
2945
|
-
return true;
|
|
2946
|
-
}
|
|
2947
|
-
// .then(...) / .catch(...) — caller is providing a handler;
|
|
2948
|
-
// the inner call is intentional.
|
|
2949
|
-
if (propText === 'then' || propText === 'catch' || propText === 'finally') {
|
|
2950
|
-
// Only flag when callNode is INSIDE the chain target,
|
|
2951
|
-
// not just an argument
|
|
2952
|
-
return true;
|
|
2953
|
-
}
|
|
2954
|
-
}
|
|
2955
|
-
}
|
|
2956
|
-
}
|
|
2957
|
-
// Stop at the first enclosing call — we don't want to leak
|
|
2958
|
-
// analysis past the immediate parent call.
|
|
2959
|
-
break;
|
|
2943
|
+
return owners;
|
|
2944
|
+
};
|
|
2945
|
+
const familyMemo = new Map();
|
|
2946
|
+
const ownerFamily = owner => {
|
|
2947
|
+
let family = familyMemo.get(owner);
|
|
2948
|
+
if (family) return family;
|
|
2949
|
+
family = new Set([owner]);
|
|
2950
|
+
for (const queue = [owner]; queue.length > 0 && family.size < 256;) {
|
|
2951
|
+
for (const parent of index._getInheritanceParents?.(queue.shift(), null) || []) {
|
|
2952
|
+
const parentName = typeof parent === 'string' ? parent : parent?.name;
|
|
2953
|
+
if (parentName && !family.has(parentName)) { family.add(parentName); queue.push(parentName); }
|
|
2960
2954
|
}
|
|
2961
|
-
cur = cur.parent;
|
|
2962
2955
|
}
|
|
2963
|
-
|
|
2964
|
-
|
|
2965
|
-
|
|
2966
|
-
|
|
2967
|
-
// (We keep this distinct so the "captured" call doesn't get
|
|
2968
|
-
// flagged.)
|
|
2969
|
-
let q = callNode.parent;
|
|
2970
|
-
// Skip await wrappers (already handled by caller)
|
|
2971
|
-
if (q && (q.type === 'await_expression' || q.type === 'await')) {
|
|
2972
|
-
q = q.parent;
|
|
2973
|
-
}
|
|
2974
|
-
if (q) {
|
|
2975
|
-
if (q.type === 'variable_declarator' || q.type === 'assignment_expression') {
|
|
2976
|
-
return true;
|
|
2977
|
-
}
|
|
2978
|
-
if (q.type === 'return_statement') {
|
|
2979
|
-
return true; // returning the promise — caller awaits it
|
|
2980
|
-
}
|
|
2981
|
-
// Yielded as an expression: `yield fn()` — caller awaits / async iterator
|
|
2982
|
-
if (q.type === 'yield_expression' || q.type === 'yield') {
|
|
2983
|
-
return true;
|
|
2956
|
+
for (const queue = [owner]; queue.length > 0 && family.size < 512;) {
|
|
2957
|
+
for (const child of index.extendedByGraph?.get(queue.shift()) || []) {
|
|
2958
|
+
const childName = typeof child === 'string' ? child : child?.name;
|
|
2959
|
+
if (childName && !family.has(childName)) { family.add(childName); queue.push(childName); }
|
|
2984
2960
|
}
|
|
2985
2961
|
}
|
|
2962
|
+
familyMemo.set(owner, family);
|
|
2963
|
+
return family;
|
|
2964
|
+
};
|
|
2965
|
+
const ownerFamilyMayOwn = (owner, owners) => {
|
|
2966
|
+
const family = ownerFamily(String(owner).replace(/<.*$/s, '').split('.').pop());
|
|
2967
|
+
for (const candidate of owners) if (candidate && family.has(candidate)) return true;
|
|
2986
2968
|
return false;
|
|
2969
|
+
};
|
|
2970
|
+
// Project-wide lookup: a sync definition anywhere keeps the name
|
|
2971
|
+
// ambiguous (`Map.get()` vs an async `DataService.get()`).
|
|
2972
|
+
const globalKinds = new Map();
|
|
2973
|
+
const globalKind = (name) => {
|
|
2974
|
+
if (!globalKinds.has(name)) {
|
|
2975
|
+
globalKinds.set(name, producers.collapseKinds(index, index.symbols.get(name) || [], kindMemo, true));
|
|
2976
|
+
}
|
|
2977
|
+
return globalKinds.get(name);
|
|
2978
|
+
};
|
|
2979
|
+
let skippedUnknown = 0;
|
|
2980
|
+
// Module scopes of imported Python modules, read once per audit.
|
|
2981
|
+
const moduleScopes = new Map();
|
|
2982
|
+
const knownAsyncGlobalNames = new Set();
|
|
2983
|
+
for (const fileEntry of index.files.values()) {
|
|
2984
|
+
for (const name of langTraits(fileEntry.language)?.knownAsyncGlobals || []) knownAsyncGlobalNames.add(name);
|
|
2985
|
+
}
|
|
2986
|
+
|
|
2987
|
+
// Rust futures (fix #370): producers are definitions whose call
|
|
2988
|
+
// returns a future; every call site is audited, sync fns included.
|
|
2989
|
+
let rustContext = null;
|
|
2990
|
+
const skippedFutures = { count: 0 };
|
|
2991
|
+
const rustProducers = () => {
|
|
2992
|
+
if (!rustContext) {
|
|
2993
|
+
const futureTypes = producers.rustProjectFutureTypes(index);
|
|
2994
|
+
const candidateNames = new Set();
|
|
2995
|
+
for (const [name, defs] of index.symbols) {
|
|
2996
|
+
if (defs.some(def => isCallableDef(def) && producers.rustDefFuture(def, futureTypes))) {
|
|
2997
|
+
candidateNames.add(name);
|
|
2998
|
+
}
|
|
2999
|
+
}
|
|
3000
|
+
rustContext = { futureTypes, candidateNames,
|
|
3001
|
+
futureDefs: producers.rustFutureDefIndex(index, futureTypes) };
|
|
3002
|
+
}
|
|
3003
|
+
return rustContext;
|
|
3004
|
+
};
|
|
3005
|
+
const isCallableDef = producers.isCallableDef;
|
|
3006
|
+
function processRustFile(filePath, fileEntry) {
|
|
3007
|
+
const { futureTypes, candidateNames, futureDefs } = rustProducers();
|
|
3008
|
+
// Local future closures (`let f = || async {..}`) are a parser
|
|
3009
|
+
// fact of the file; with neither those nor any project producer
|
|
3010
|
+
// there is nothing to audit.
|
|
3011
|
+
const asyncClosures = new Set(fileEntry.asyncClosureNames || []);
|
|
3012
|
+
if (futureDefs.size === 0 && asyncClosures.size === 0) return;
|
|
3013
|
+
// Build-time fact (fix #371/#372): the shapes of calls whose value
|
|
3014
|
+
// is not consumed where produced. Unless one of them can reach a
|
|
3015
|
+
// visible future producer (the same record-level tests, on the
|
|
3016
|
+
// fields the shape keeps), the file's call records are never
|
|
3017
|
+
// loaded.
|
|
3018
|
+
// A file whose macro expansions' calls are not derived yet may
|
|
3019
|
+
// hold a lost future among them (fix #374): read its calls.
|
|
3020
|
+
if (asyncClosures.size === 0 && Array.isArray(fileEntry.openCalls) &&
|
|
3021
|
+
!(fileEntry.rustMacroExpansion?.callsPending &&
|
|
3022
|
+
(fileEntry.rustMacroExpansion.calleeNames || []).some(name => futureDefs.has(name))) &&
|
|
3023
|
+
!fileEntry.openCalls.some(shape => {
|
|
3024
|
+
const record = parseOpenCallShape(shape);
|
|
3025
|
+
return futureDefs.has(record.name) &&
|
|
3026
|
+
!producers.rustRecordValueConsumed(index, futureDefs, futureTypes, record) &&
|
|
3027
|
+
producers.rustRecordMayReachFuture(record, futureDefs, index, filePath);
|
|
3028
|
+
})) return;
|
|
3029
|
+
const calls = index.getCachedCalls(filePath);
|
|
3030
|
+
if (!Array.isArray(calls)) return;
|
|
3031
|
+
// Only files with a call shape that can reach a future producer
|
|
3032
|
+
// or a local future closure, and whose value is not consumed
|
|
3033
|
+
// right away (`.await`ed or passed on, a parser fact), are
|
|
3034
|
+
// re-parsed (fix #371).
|
|
3035
|
+
const candidates = calls.filter(call => call?.name && !call.inMacro &&
|
|
3036
|
+
Number.isInteger(call.callStart) && Number.isInteger(call.callEnd) &&
|
|
3037
|
+
!producers.rustRecordValueConsumed(index, futureDefs, futureTypes, call) &&
|
|
3038
|
+
((call.localShadow && asyncClosures.has(call.name)) ||
|
|
3039
|
+
producers.rustRecordMayReachFuture(call, futureDefs, index, filePath)));
|
|
3040
|
+
if (candidates.length === 0) return;
|
|
3041
|
+
// Resolve before reading (fix #371): a file whose candidates reach
|
|
3042
|
+
// no future producer through the engine is never parsed. Local
|
|
3043
|
+
// future closures are file facts and always need the tree.
|
|
3044
|
+
let calleeMemo;
|
|
3045
|
+
if (!candidates.some(call => call.localShadow && asyncClosures.has(call.name))) {
|
|
3046
|
+
const resolved = producers.rustResolveCandidates(
|
|
3047
|
+
{ index, futureTypes, fileEntry, filePath }, candidates);
|
|
3048
|
+
if (!resolved.needsTree) return;
|
|
3049
|
+
calleeMemo = resolved.calleeMemo;
|
|
3050
|
+
}
|
|
3051
|
+
let tree;
|
|
3052
|
+
try {
|
|
3053
|
+
const parser = getParser('rust');
|
|
3054
|
+
if (!parser) return;
|
|
3055
|
+
tree = safeParse(parser, index._readFile(filePath));
|
|
3056
|
+
} catch (_) { return; }
|
|
3057
|
+
if (!tree) return;
|
|
3058
|
+
for (const issue of producers.auditRustFile({
|
|
3059
|
+
index, futureTypes, candidateNames, futureDefs, fileEntry, filePath, tree, calls,
|
|
3060
|
+
asyncClosures, skipped: skippedFutures, calleeMemo,
|
|
3061
|
+
})) issues.push(issue);
|
|
2987
3062
|
}
|
|
2988
3063
|
|
|
2989
3064
|
// Process one file: find async functions, then call sites within them.
|
|
2990
3065
|
function processFile(filePath, fileEntry) {
|
|
3066
|
+
if (fileEntry?.language === 'rust') return processRustFile(filePath, fileEntry);
|
|
2991
3067
|
if (!fileEntry || !_AUDIT_ASYNC_LANGS.has(fileEntry.language)) return;
|
|
2992
3068
|
const language = fileEntry.language;
|
|
2993
|
-
|
|
2994
|
-
|
|
2995
|
-
: [];
|
|
2996
|
-
|
|
2997
|
-
// Collect async functions from the file's symbol list.
|
|
2998
|
-
// Also build a per-file set of names that are async in THIS file —
|
|
2999
|
-
// these win over the global "all-or-nothing" check (HIGH-1 fix).
|
|
3000
|
-
// JS/Python module scope means a same-file def shadows imports of
|
|
3001
|
-
// the same name, so a sync def of `helper` elsewhere in the project
|
|
3002
|
-
// shouldn't make `helper()` ambiguous in a file that defines
|
|
3003
|
-
// `async function helper()` locally.
|
|
3069
|
+
|
|
3070
|
+
// Collect async functions for enclosing-scope attribution.
|
|
3004
3071
|
const asyncFns = [];
|
|
3005
|
-
const fileAsyncNames = new Set();
|
|
3006
|
-
const fileAnyDefNames = new Set();
|
|
3007
3072
|
if (Array.isArray(fileEntry.symbols)) {
|
|
3008
3073
|
for (const sym of fileEntry.symbols) {
|
|
3009
3074
|
if (!sym || !sym.startLine || !sym.endLine) continue;
|
|
3010
|
-
|
|
3011
|
-
(Array.isArray(sym.modifiers) && sym.modifiers.includes('async'));
|
|
3012
|
-
if (isAsync) asyncFns.push(sym);
|
|
3013
|
-
if (sym.name && (
|
|
3014
|
-
sym.type === 'function' || sym.type === 'method' ||
|
|
3015
|
-
sym.type === 'constructor' || sym.type === 'arrow' ||
|
|
3016
|
-
sym.params != null || sym.paramsStructured != null
|
|
3017
|
-
)) {
|
|
3018
|
-
fileAnyDefNames.add(sym.name);
|
|
3019
|
-
if (isAsync) fileAsyncNames.add(sym.name);
|
|
3020
|
-
}
|
|
3075
|
+
if (producers.isDefAsync(sym)) asyncFns.push(sym);
|
|
3021
3076
|
}
|
|
3022
3077
|
}
|
|
3023
3078
|
if (asyncFns.length === 0 && language !== 'python') return;
|
|
3079
|
+
const cachedCalls = index.getCachedCalls(filePath);
|
|
3080
|
+
const indexedCalls = Array.isArray(cachedCalls) ? cachedCalls : [];
|
|
3081
|
+
|
|
3082
|
+
// Which calls can reach an async producer. C# resolves member
|
|
3083
|
+
// calls per receiver type, so any async definition's name
|
|
3084
|
+
// counts. A structural bare call reaches one only through this
|
|
3085
|
+
// file's own definitions, an import (by its source name; a
|
|
3086
|
+
// default import by the name its project module exports as
|
|
3087
|
+
// default), a Python star import, or a runtime global.
|
|
3088
|
+
// Structural method calls are never audited (no receiver
|
|
3089
|
+
// evidence).
|
|
3090
|
+
let candidateName;
|
|
3091
|
+
if (language === 'csharp') {
|
|
3092
|
+
candidateName = name => asyncCandidateNames.has(name) || knownAsyncGlobalNames.has(name);
|
|
3093
|
+
} else {
|
|
3094
|
+
// A default import names the definition its project module
|
|
3095
|
+
// exports as default; a module outside the project names none.
|
|
3096
|
+
const defaultExportMayBeAsync = binding => {
|
|
3097
|
+
const relative = fileEntry.moduleResolved?.[binding.module];
|
|
3098
|
+
const source = relative ? index.files.get(path.resolve(index.root, relative)) : null;
|
|
3099
|
+
return (source?.exportDetails || []).some(entry =>
|
|
3100
|
+
entry.type === 'default' && asyncCandidateNames.has(entry.name));
|
|
3101
|
+
};
|
|
3102
|
+
// A JS/TS import read through the module's re-exports to the
|
|
3103
|
+
// function it declares (fix #402): `export { default } from`,
|
|
3104
|
+
// `export { f as g } from`, `export * from`.
|
|
3105
|
+
const exportedAsync = binding => {
|
|
3106
|
+
if (!JS_AUDIT_FAMILY.has(language)) return false;
|
|
3107
|
+
const relative = fileEntry.moduleResolved?.[binding.module];
|
|
3108
|
+
if (!relative) return false;
|
|
3109
|
+
return producers.jsModuleExportReaches(index, path.resolve(index.root, relative),
|
|
3110
|
+
binding.kind === 'default' ? 'default' : binding.name, isAsyncDef, moduleScopes);
|
|
3111
|
+
};
|
|
3112
|
+
// Decided per local name on first use: only names this file
|
|
3113
|
+
// calls pay for reading re-exports.
|
|
3114
|
+
let bindingsByLocal = null;
|
|
3115
|
+
const importedProducers = new Map();
|
|
3116
|
+
const importedProducerName = name => {
|
|
3117
|
+
if (importedProducers.has(name)) return importedProducers.get(name);
|
|
3118
|
+
if (!bindingsByLocal) {
|
|
3119
|
+
bindingsByLocal = new Map();
|
|
3120
|
+
for (const binding of fileEntry.importBindings || []) {
|
|
3121
|
+
const local = binding.alias || binding.name;
|
|
3122
|
+
if (!bindingsByLocal.has(local)) bindingsByLocal.set(local, []);
|
|
3123
|
+
bindingsByLocal.get(local).push(binding);
|
|
3124
|
+
}
|
|
3125
|
+
}
|
|
3126
|
+
const producer = (bindingsByLocal.get(name) || []).some(binding =>
|
|
3127
|
+
(binding.kind === 'default' ? defaultExportMayBeAsync(binding)
|
|
3128
|
+
: asyncCandidateNames.has(binding.name)) || exportedAsync(binding));
|
|
3129
|
+
importedProducers.set(name, producer);
|
|
3130
|
+
return producer;
|
|
3131
|
+
};
|
|
3132
|
+
const fileAsyncNames = new Set(asyncFns.map(fn => fn.name));
|
|
3133
|
+
// Text prefilter only: the AST decides what a star import binds.
|
|
3134
|
+
let starImport;
|
|
3135
|
+
const hasStarImport = () => {
|
|
3136
|
+
if (starImport === undefined) {
|
|
3137
|
+
try {
|
|
3138
|
+
starImport = language === 'python' && /\bimport\s+\*/.test(index._readFile(filePath));
|
|
3139
|
+
} catch (_) { starImport = false; }
|
|
3140
|
+
}
|
|
3141
|
+
return starImport;
|
|
3142
|
+
};
|
|
3143
|
+
candidateName = name => knownAsyncGlobalNames.has(name) || importedProducerName(name) ||
|
|
3144
|
+
(asyncCandidateNames.has(name) && (fileAsyncNames.has(name) || hasStarImport()));
|
|
3145
|
+
}
|
|
3146
|
+
// A structural method call is audited when its receiver carries
|
|
3147
|
+
// evidence the engine can resolve (fix #402): a constructed,
|
|
3148
|
+
// annotated or flow-typed receiver, an imported module, or the
|
|
3149
|
+
// enclosing instance (`this.m()`, `self.m()`); the engine's
|
|
3150
|
+
// callee at that site then decides the producer.
|
|
3151
|
+
const selfNames = langTraits(language)?.selfParam || [];
|
|
3152
|
+
// Name-level prefilter (the engine decides identity): an async
|
|
3153
|
+
// definition of the name owned by the receiver's class family, a
|
|
3154
|
+
// module function for a module receiver.
|
|
3155
|
+
let classByFunction = null;
|
|
3156
|
+
const enclosingClassOf = call => {
|
|
3157
|
+
const start = call.enclosingFunction?.startLine;
|
|
3158
|
+
if (!Number.isInteger(start)) return null;
|
|
3159
|
+
if (!classByFunction) {
|
|
3160
|
+
classByFunction = new Map();
|
|
3161
|
+
for (const symbol of fileEntry.symbols || []) {
|
|
3162
|
+
const key = `${symbol.startLine}\0${symbol.name}`;
|
|
3163
|
+
if (!classByFunction.has(key)) classByFunction.set(key, symbol.className || null);
|
|
3164
|
+
}
|
|
3165
|
+
}
|
|
3166
|
+
return classByFunction.get(`${start}\0${call.enclosingFunction.name}`) || null;
|
|
3167
|
+
};
|
|
3168
|
+
const typedMethodCall = call => {
|
|
3169
|
+
if (language === 'csharp' || !call.isMethod || call.isPotentialCallback || call.awaited ||
|
|
3170
|
+
call.passedAsArgument || !asyncCandidateNames.has(call.name)) return false;
|
|
3171
|
+
const owners = asyncOwnersOf(call.name, auditFamily(language));
|
|
3172
|
+
if (owners.size === 0) return false;
|
|
3173
|
+
if (call.receiverIsModule) return owners.has(null);
|
|
3174
|
+
// A field hop (`self.conn.send()`) names no type the record
|
|
3175
|
+
// keeps; it stays unaudited.
|
|
3176
|
+
if (call.selfAttribute || call.receiverField || call.receiverRoot) return false;
|
|
3177
|
+
const owner = call.receiverType || (selfNames.includes(call.receiver) ? enclosingClassOf(call) : null);
|
|
3178
|
+
return !!owner && ownerFamilyMayOwn(owner, owners);
|
|
3179
|
+
};
|
|
3180
|
+
const typedCalls = new Set(indexedCalls.filter(typedMethodCall));
|
|
3181
|
+
const candidateCall = call => !!call?.name && (((language === 'csharp' || !call.isMethod) &&
|
|
3182
|
+
candidateName(call.name)) || typedCalls.has(call));
|
|
3183
|
+
const typedMethodStarts = new Set([...typedCalls]
|
|
3184
|
+
.map(call => call.callStart ?? call.callSite?.start).filter(Number.isInteger));
|
|
3185
|
+
// The record start of a typed method call node: its own start, or
|
|
3186
|
+
// its method name token's (records that keep only the token).
|
|
3187
|
+
const typedMethodStart = callNode => {
|
|
3188
|
+
if (typedMethodStarts.size === 0) return null;
|
|
3189
|
+
if (typedMethodStarts.has(callNode.startIndex)) return callNode.startIndex;
|
|
3190
|
+
const fn = callNode.childForFieldName('function');
|
|
3191
|
+
const token = fn?.childForFieldName('property') || fn?.childForFieldName('attribute');
|
|
3192
|
+
return token && typedMethodStarts.has(token.startIndex) ? token.startIndex : null;
|
|
3193
|
+
};
|
|
3194
|
+
// A file none of whose indexed calls can reach an async producer
|
|
3195
|
+
// has nothing to audit: skip the re-parse (dominant cost on large
|
|
3196
|
+
// repos). Files without call records are parsed as before.
|
|
3197
|
+
if (language !== 'html' && Array.isArray(cachedCalls) && !cachedCalls.some(candidateCall)) return;
|
|
3024
3198
|
|
|
3025
3199
|
// Re-parse file to find awaited-vs-not call sites. We use a fresh
|
|
3026
3200
|
// parse rather than tree cache because we want to walk every
|
|
@@ -3045,6 +3219,16 @@ function auditAsync(index, options = {}) {
|
|
|
3045
3219
|
}
|
|
3046
3220
|
} catch (_) { return; }
|
|
3047
3221
|
if (!tree) return;
|
|
3222
|
+
const resolveLexical = producers.createLexicalResolver(fileEntry, language);
|
|
3223
|
+
// Runtime globals that return promises (`fetch`), bare calls only.
|
|
3224
|
+
const knownAsyncGlobals = new Set(langTraits(language)?.knownAsyncGlobals || []);
|
|
3225
|
+
// Imports the imported module's own scope cannot settle resolve
|
|
3226
|
+
// through the engine, once per enclosing callable and only for
|
|
3227
|
+
// candidate sites. A globally unique name is a search candidate,
|
|
3228
|
+
// never producer identity.
|
|
3229
|
+
const producerCallees = producers.createImportedProducerResolver(index, filePath,
|
|
3230
|
+
fileEntry, indexedCalls.filter(candidateCall));
|
|
3231
|
+
let moduleImports = null;
|
|
3048
3232
|
|
|
3049
3233
|
// Walk every call_expression within an async function range.
|
|
3050
3234
|
const callTypes = new Set([
|
|
@@ -3111,7 +3295,7 @@ function auditAsync(index, options = {}) {
|
|
|
3111
3295
|
endLine: cur.endPosition.row + 1,
|
|
3112
3296
|
};
|
|
3113
3297
|
}
|
|
3114
|
-
if (discardedPython) return {
|
|
3298
|
+
if (discardedPython || langTraits(language)?.storedCoroutines) return {
|
|
3115
3299
|
name: cur.childForFieldName('name')?.text || '<anonymous>',
|
|
3116
3300
|
startLine: cur.startPosition.row + 1,
|
|
3117
3301
|
endLine: cur.endPosition.row + 1,
|
|
@@ -3123,137 +3307,195 @@ function auditAsync(index, options = {}) {
|
|
|
3123
3307
|
return discardedPython ? { name: '<module>' } : null;
|
|
3124
3308
|
}
|
|
3125
3309
|
|
|
3310
|
+
// Which definition a call reaches, and what calling it returns.
|
|
3311
|
+
// Lexical scope first, then import identity. Unrelated project
|
|
3312
|
+
// definitions cannot turn a builtin or an unbound call async.
|
|
3313
|
+
function calleeProducerKind(callNode, calleeName, isMethodCall, line) {
|
|
3314
|
+
if (language === 'csharp') {
|
|
3315
|
+
// C# method identity is nominal. Prefer the indexed
|
|
3316
|
+
// receiver type at this call site; fall back only when
|
|
3317
|
+
// every project definition with the name is async.
|
|
3318
|
+
const indexed = indexedCalls.find(call =>
|
|
3319
|
+
call.name === calleeName && call.line === line &&
|
|
3320
|
+
call.isMethod === isMethodCall);
|
|
3321
|
+
let receiverType = indexed?.receiverType || null;
|
|
3322
|
+
if (!receiverType && indexed?.receiverField && indexed?.receiverRootType) {
|
|
3323
|
+
const field = (fileEntry.symbols || []).find(symbol =>
|
|
3324
|
+
symbol.type === 'field' &&
|
|
3325
|
+
symbol.className === indexed.receiverRootType &&
|
|
3326
|
+
symbol.name === indexed.receiverField);
|
|
3327
|
+
receiverType = field?.fieldType || null;
|
|
3328
|
+
}
|
|
3329
|
+
// The receiver's type and its project ancestors own the
|
|
3330
|
+
// member (fix #395: `s.SaveAsync()`, `_svc.SaveAsync()`);
|
|
3331
|
+
// a typed receiver owning none of the async definitions
|
|
3332
|
+
// (an external type's own method) is not audited.
|
|
3333
|
+
const owners = new Set();
|
|
3334
|
+
const typeName = receiverType ? String(receiverType).replace(/<.*$/s, '').replace(/\?$/, '')
|
|
3335
|
+
.split('.').pop() : null;
|
|
3336
|
+
for (const queue = typeName ? [typeName] : []; queue.length > 0 && owners.size < 64;) {
|
|
3337
|
+
const owner = queue.shift();
|
|
3338
|
+
if (!owner || owners.has(owner)) continue;
|
|
3339
|
+
owners.add(owner);
|
|
3340
|
+
for (const parent of index._getInheritanceParents?.(owner, filePath) || []) {
|
|
3341
|
+
queue.push(typeof parent === 'string' ? parent : parent?.name);
|
|
3342
|
+
}
|
|
3343
|
+
}
|
|
3344
|
+
const receiverDefs = owners.size > 0
|
|
3345
|
+
? (index.symbols.get(calleeName) || []).filter(def =>
|
|
3346
|
+
producers.isCallableDef(def) && owners.has(def.className))
|
|
3347
|
+
: [];
|
|
3348
|
+
if (receiverDefs.length > 0) return producers.collapseKinds(index, receiverDefs, kindMemo, true);
|
|
3349
|
+
return typeName && isMethodCall && indexed?.receiver !== 'this' ? null : globalKind(calleeName);
|
|
3350
|
+
}
|
|
3351
|
+
let lexical;
|
|
3352
|
+
if (!isMethodCall && resolveLexical) {
|
|
3353
|
+
lexical = resolveLexical(callNode, calleeName);
|
|
3354
|
+
if (lexical?.shadowed) return null;
|
|
3355
|
+
if (lexical?.defs) return producers.collapseKinds(index, lexical.defs, kindMemo, true);
|
|
3356
|
+
}
|
|
3357
|
+
// An import is read in the imported module itself when that
|
|
3358
|
+
// proves the definition; otherwise the engine must establish
|
|
3359
|
+
// the call's target.
|
|
3360
|
+
if (lexical?.imports || lexical?.star) {
|
|
3361
|
+
moduleImports ||= producers.createModuleImportResolver(index, fileEntry, moduleScopes);
|
|
3362
|
+
const defs = lexical.star ? moduleImports.star(calleeName, lexical.star)
|
|
3363
|
+
: moduleImports.fromImports(lexical.imports);
|
|
3364
|
+
if (defs) return producers.collapseKinds(index, defs, kindMemo, true);
|
|
3365
|
+
if (lexical.star) return null;
|
|
3366
|
+
}
|
|
3367
|
+
const typedStart = isMethodCall ? typedMethodStart(callNode) : null;
|
|
3368
|
+
const targets = lexical?.external || typedStart != null
|
|
3369
|
+
? producerCallees(callNode, typedStart ?? callNode.startIndex) : [];
|
|
3370
|
+
if (targets.length > 0) return producers.collapseKinds(index, targets, kindMemo, true);
|
|
3371
|
+
return !isMethodCall && !lexical?.external && knownAsyncGlobals.has(calleeName)
|
|
3372
|
+
? 'coroutine' : null;
|
|
3373
|
+
}
|
|
3374
|
+
|
|
3375
|
+
function isAwaited(callNode) {
|
|
3376
|
+
let current = callNode.parent;
|
|
3377
|
+
let depth = 0;
|
|
3378
|
+
while (current && depth++ < 5) {
|
|
3379
|
+
if (current.type === 'parenthesized_expression') {
|
|
3380
|
+
current = current.parent;
|
|
3381
|
+
continue;
|
|
3382
|
+
}
|
|
3383
|
+
if (current.type === 'await_expression' || current.type === 'await') return true;
|
|
3384
|
+
// C#'s canonical `await Task().ConfigureAwait(false)`
|
|
3385
|
+
// wraps the original invocation in a member-access +
|
|
3386
|
+
// invocation chain before the await node.
|
|
3387
|
+
if (language === 'csharp' && [
|
|
3388
|
+
'member_access_expression',
|
|
3389
|
+
'invocation_expression',
|
|
3390
|
+
'conditional_access_expression',
|
|
3391
|
+
'member_binding_expression',
|
|
3392
|
+
].includes(current.type)) {
|
|
3393
|
+
current = current.parent;
|
|
3394
|
+
continue;
|
|
3395
|
+
}
|
|
3396
|
+
return false;
|
|
3397
|
+
}
|
|
3398
|
+
return false;
|
|
3399
|
+
}
|
|
3400
|
+
|
|
3126
3401
|
function visit(node) {
|
|
3127
3402
|
if (!node) return;
|
|
3128
3403
|
if (callTypes.has(node.type)) {
|
|
3129
3404
|
const line = node.startPosition.row + 1;
|
|
3130
|
-
|
|
3131
|
-
|
|
3405
|
+
let enclosing;
|
|
3406
|
+
{
|
|
3132
3407
|
// Get the callee name + skip if not async.
|
|
3133
|
-
|
|
3408
|
+
let funcNode = node.childForFieldName('function') ||
|
|
3134
3409
|
node.childForFieldName('name');
|
|
3410
|
+
// C# `obj?.DoAsync()`: the member binding names it (fix #395).
|
|
3411
|
+
if (funcNode?.type === 'conditional_access_expression') {
|
|
3412
|
+
funcNode = (funcNode.namedChildren || []).find(child =>
|
|
3413
|
+
child.type === 'member_binding_expression') || funcNode;
|
|
3414
|
+
}
|
|
3135
3415
|
if (funcNode) {
|
|
3136
3416
|
let calleeName;
|
|
3137
3417
|
let isMethodCall = false;
|
|
3138
3418
|
if (funcNode.type === 'member_expression' || funcNode.type === 'attribute' ||
|
|
3139
|
-
funcNode.type === 'selector_expression' || funcNode.type === 'field_expression'
|
|
3419
|
+
funcNode.type === 'selector_expression' || funcNode.type === 'field_expression' ||
|
|
3420
|
+
// C# `obj.DoAsync()`, `obj?.DoAsync()` (fix #395).
|
|
3421
|
+
funcNode.type === 'member_access_expression' ||
|
|
3422
|
+
funcNode.type === 'member_binding_expression') {
|
|
3140
3423
|
const prop = funcNode.childForFieldName('property') ||
|
|
3141
3424
|
funcNode.childForFieldName('field') ||
|
|
3142
3425
|
funcNode.childForFieldName('attribute') ||
|
|
3143
3426
|
funcNode.childForFieldName('name');
|
|
3144
|
-
|
|
3427
|
+
// `obj.Save<T>()` names the method Save.
|
|
3428
|
+
calleeName = prop ? (prop.type === 'generic_name'
|
|
3429
|
+
? (prop.namedChild(0)?.text || null) : prop.text) : null;
|
|
3145
3430
|
isMethodCall = true;
|
|
3431
|
+
} else if (funcNode.type === 'generic_name') {
|
|
3432
|
+
calleeName = funcNode.namedChild(0)?.text || null;
|
|
3146
3433
|
} else {
|
|
3147
3434
|
calleeName = funcNode.text;
|
|
3148
3435
|
}
|
|
3149
|
-
|
|
3150
|
-
|
|
3151
|
-
|
|
3152
|
-
|
|
3153
|
-
|
|
3154
|
-
|
|
3155
|
-
|
|
3156
|
-
|
|
3157
|
-
|
|
3158
|
-
|
|
3159
|
-
|
|
3160
|
-
|
|
3161
|
-
|
|
3162
|
-
|
|
3163
|
-
|
|
3164
|
-
|
|
3165
|
-
const
|
|
3166
|
-
|
|
3167
|
-
|
|
3168
|
-
|
|
3169
|
-
|
|
3170
|
-
|
|
3171
|
-
|
|
3172
|
-
|
|
3173
|
-
|
|
3174
|
-
|
|
3175
|
-
|
|
3176
|
-
|
|
3177
|
-
|
|
3178
|
-
|
|
3179
|
-
|
|
3180
|
-
|
|
3181
|
-
|
|
3182
|
-
? receiverDefs.every(isDefAsync)
|
|
3183
|
-
: asyncNames.has(calleeName);
|
|
3184
|
-
} else if (fileAsyncNames.has(calleeName)) {
|
|
3185
|
-
calleeIsAsync = true;
|
|
3186
|
-
} else if (fileAnyDefNames.has(calleeName)) {
|
|
3187
|
-
// Same-file def exists and isn't async →
|
|
3188
|
-
// local def shadows globals → not async.
|
|
3189
|
-
calleeIsAsync = false;
|
|
3190
|
-
} else {
|
|
3191
|
-
// No same-file def — fall back to global
|
|
3192
|
-
// all-or-nothing check.
|
|
3193
|
-
calleeIsAsync = asyncNames.has(calleeName) ||
|
|
3194
|
-
_KNOWN_ASYNC_CALLEES.has(calleeName);
|
|
3195
|
-
}
|
|
3196
|
-
if (calleeIsAsync) {
|
|
3197
|
-
// Skip method calls in structural type
|
|
3198
|
-
// systems (JS/TS/Python). Without receiver
|
|
3199
|
-
// type evidence we can't tell `obj.get()`
|
|
3200
|
-
// calling `Map.get` (sync) from a project
|
|
3201
|
-
// class's async `get`. Method-call audits
|
|
3202
|
-
// need a more sophisticated receiver
|
|
3203
|
-
// resolution that we don't have here.
|
|
3204
|
-
if (isMethodCall && language !== 'csharp') {
|
|
3205
|
-
// (Allow only when callee is in the
|
|
3206
|
-
// KNOWN_ASYNC_CALLEES list — those are
|
|
3207
|
-
// standard global functions, not
|
|
3208
|
-
// methods.)
|
|
3209
|
-
if (!_KNOWN_ASYNC_CALLEES.has(calleeName)) {
|
|
3210
|
-
// Continue walking — don't flag.
|
|
3211
|
-
} else {
|
|
3212
|
-
// Fall through to common flag logic
|
|
3436
|
+
// Structural method calls (`obj.get()`) need
|
|
3437
|
+
// receiver evidence: the engine's callee at a
|
|
3438
|
+
// typed receiver (fix #402).
|
|
3439
|
+
const methodBlocked = isMethodCall && language !== 'csharp' &&
|
|
3440
|
+
typedMethodStart(node) == null;
|
|
3441
|
+
// An awaited or consumed call reports nothing
|
|
3442
|
+
// whatever its producer (nor counts as skipped):
|
|
3443
|
+
// its producer is never resolved.
|
|
3444
|
+
const typedMember = isMethodCall && language !== 'csharp';
|
|
3445
|
+
if (calleeName && !methodBlocked &&
|
|
3446
|
+
(candidateName(calleeName) || typedMember) &&
|
|
3447
|
+
!isAwaited(node) && !producers.asyncConsumerRole(node) &&
|
|
3448
|
+
(enclosing = nearestAsyncEnclosing(node))) {
|
|
3449
|
+
const kind = calleeProducerKind(node, calleeName, isMethodCall, line);
|
|
3450
|
+
if (kind && kind !== 'void' &&
|
|
3451
|
+
!producers.asyncConsumerRole(node)) {
|
|
3452
|
+
const awaited = isAwaited(node);
|
|
3453
|
+
if (kind === 'unknown') {
|
|
3454
|
+
if (!awaited) skippedUnknown++;
|
|
3455
|
+
} else if (kind === 'iterator' || kind === 'context-manager') {
|
|
3456
|
+
// Not awaitable: iterated / entered /
|
|
3457
|
+
// passed on. Only a discarded call
|
|
3458
|
+
// is lost.
|
|
3459
|
+
if (producers.isDiscardedCall(node)) {
|
|
3460
|
+
issues.push({
|
|
3461
|
+
file: fileEntry.relativePath || filePath,
|
|
3462
|
+
line,
|
|
3463
|
+
callerName: enclosing.name,
|
|
3464
|
+
calleeName,
|
|
3465
|
+
reason: kind === 'iterator'
|
|
3466
|
+
? 'async-iterator-discarded'
|
|
3467
|
+
: 'async-context-manager-discarded',
|
|
3468
|
+
});
|
|
3213
3469
|
}
|
|
3214
|
-
}
|
|
3215
|
-
|
|
3216
|
-
|
|
3217
|
-
//
|
|
3218
|
-
|
|
3219
|
-
let
|
|
3220
|
-
|
|
3221
|
-
|
|
3222
|
-
if (
|
|
3223
|
-
|
|
3224
|
-
|
|
3225
|
-
|
|
3226
|
-
if (
|
|
3227
|
-
|
|
3228
|
-
|
|
3229
|
-
|
|
3230
|
-
|
|
3231
|
-
|
|
3232
|
-
|
|
3233
|
-
|
|
3234
|
-
// a member-access + invocation
|
|
3235
|
-
// chain before the await node.
|
|
3236
|
-
if (language === 'csharp' && [
|
|
3237
|
-
'member_access_expression',
|
|
3238
|
-
'invocation_expression',
|
|
3239
|
-
'conditional_access_expression',
|
|
3240
|
-
'member_binding_expression',
|
|
3241
|
-
'parenthesized_expression',
|
|
3242
|
-
].includes(current.type)) {
|
|
3243
|
-
current = current.parent;
|
|
3244
|
-
continue;
|
|
3470
|
+
} else {
|
|
3471
|
+
// fix #367c: an awaitable that flows on
|
|
3472
|
+
// (argument, collection, return, branch)
|
|
3473
|
+
// is not a missing await; a lost value or
|
|
3474
|
+
// one used as if resolved is.
|
|
3475
|
+
let finding = null;
|
|
3476
|
+
if (!awaited) {
|
|
3477
|
+
const flow = producers.valueFlow(node, language);
|
|
3478
|
+
if (flow.kind === 'discarded') {
|
|
3479
|
+
finding = {};
|
|
3480
|
+
} else if (flow.kind === 'used-as-value') {
|
|
3481
|
+
finding = { reason: 'async-result-used-as-value' };
|
|
3482
|
+
} else if (flow.kind === 'stored') {
|
|
3483
|
+
finding = (langTraits(language)?.storedPromises
|
|
3484
|
+
? storedPromiseMisuse(node, FN_NODE_TYPES) : null) ||
|
|
3485
|
+
(langTraits(language)?.storedCoroutines
|
|
3486
|
+
? producers.storedCoroutineMisuse(node, flow.holder, flow.binding,
|
|
3487
|
+
FN_NODE_TYPES, language) : null) ||
|
|
3488
|
+
(producers.storedValueRead(flow.holder, flow.binding, FN_NODE_TYPES)
|
|
3489
|
+
? null : {});
|
|
3245
3490
|
}
|
|
3246
|
-
break;
|
|
3247
3491
|
}
|
|
3248
|
-
|
|
3249
|
-
? storedPromiseMisuse(node, FN_NODE_TYPES) : null;
|
|
3250
|
-
if (storedMisuse || (!awaited && !isFireAndForget(node, language))) {
|
|
3492
|
+
if (finding) {
|
|
3251
3493
|
issues.push({
|
|
3252
3494
|
file: fileEntry.relativePath || filePath,
|
|
3253
3495
|
line,
|
|
3254
3496
|
callerName: enclosing.name,
|
|
3255
3497
|
calleeName,
|
|
3256
|
-
...
|
|
3498
|
+
...finding,
|
|
3257
3499
|
});
|
|
3258
3500
|
}
|
|
3259
3501
|
}
|
|
@@ -3293,6 +3535,8 @@ function auditAsync(index, options = {}) {
|
|
|
3293
3535
|
issues,
|
|
3294
3536
|
totalIssues: issues.length,
|
|
3295
3537
|
filesAffected: new Set(issues.map(i => i.file)).size,
|
|
3538
|
+
skippedUnknown,
|
|
3539
|
+
...(skippedFutures.count > 0 && { skippedFutures: skippedFutures.count }),
|
|
3296
3540
|
};
|
|
3297
3541
|
} finally { index._endOp(); }
|
|
3298
3542
|
}
|