ucn 5.4.2 → 5.5.1
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 +27 -16
- package/cli/index.js +45 -14
- package/core/accessors.js +118 -3
- package/core/account.js +62 -11
- package/core/analysis.js +517 -266
- 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 +529 -31
- package/core/callers.js +12228 -921
- 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.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 +7 -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 +305 -122
- package/core/parallel-build.js +124 -47
- package/core/parser.js +3 -2
- package/core/project.js +472 -66
- 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 +3026 -0
- package/core/rust-modules.js +203 -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 +1185 -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 +2265 -112
- package/languages/utils.js +298 -4
- package/mcp/server.js +22 -8
- 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')
|
|
@@ -2462,8 +2509,15 @@ function diffImpact(index, options = {}) {
|
|
|
2462
2509
|
const { getCachedCalls } = require('./callers');
|
|
2463
2510
|
for (const del of deletedFunctions) {
|
|
2464
2511
|
const remaining = [];
|
|
2465
|
-
|
|
2466
|
-
|
|
2512
|
+
// A snapshot: deriving a file's macro-expansion calls re-lists it
|
|
2513
|
+
// in the callee index. Files whose expansions' calls are not
|
|
2514
|
+
// derived yet and whose expanded text holds the name join it
|
|
2515
|
+
// (fix #405).
|
|
2516
|
+
const calleeFiles = new Set(index.getCalleeFiles(del.name) || []);
|
|
2517
|
+
for (const f of require('./rust-macro-expansion').rustPendingFilesBearing(index, [del.name])) {
|
|
2518
|
+
calleeFiles.add(f);
|
|
2519
|
+
}
|
|
2520
|
+
if (calleeFiles.size > 0) {
|
|
2467
2521
|
for (const f of calleeFiles) {
|
|
2468
2522
|
const calls = getCachedCalls(index, f);
|
|
2469
2523
|
if (!Array.isArray(calls)) continue;
|
|
@@ -2638,15 +2692,18 @@ function isDiffCallable(symbol) {
|
|
|
2638
2692
|
*/
|
|
2639
2693
|
function extractCallableSymbols(parsed) {
|
|
2640
2694
|
const symbols = [];
|
|
2695
|
+
// Declared parameters travel with each symbol: `check` compares a
|
|
2696
|
+
// changed definition with its base declaration (fix #394).
|
|
2697
|
+
const signature = s => ({ params: s.params, paramsStructured: s.paramsStructured, generics: s.generics });
|
|
2641
2698
|
for (const fn of parsed.functions) {
|
|
2642
2699
|
const className = fn.className || (fn.receiver ? fn.receiver.replace(/^\*/, '') : '');
|
|
2643
|
-
symbols.push({ name: fn.name, className, startLine: fn.startLine, endLine: fn.endLine });
|
|
2700
|
+
symbols.push({ name: fn.name, className, startLine: fn.startLine, endLine: fn.endLine, ...signature(fn) });
|
|
2644
2701
|
}
|
|
2645
2702
|
for (const cls of parsed.classes) {
|
|
2646
2703
|
if (cls.members) {
|
|
2647
2704
|
for (const m of cls.members) {
|
|
2648
2705
|
if (!isDiffCallable(m)) continue;
|
|
2649
|
-
symbols.push({ name: m.name, className: cls.name, startLine: m.startLine, endLine: m.endLine });
|
|
2706
|
+
symbols.push({ name: m.name, className: cls.name, startLine: m.startLine, endLine: m.endLine, ...signature(m) });
|
|
2650
2707
|
}
|
|
2651
2708
|
}
|
|
2652
2709
|
}
|
|
@@ -2751,35 +2808,13 @@ function parseDiff(diffText, root) {
|
|
|
2751
2808
|
// ============================================================================
|
|
2752
2809
|
|
|
2753
2810
|
// Languages for which audit-async runs (those with async/await keyword we
|
|
2754
|
-
// track).
|
|
2755
|
-
//
|
|
2811
|
+
// track). Rust futures are audited separately (processRustFile, fix #370);
|
|
2812
|
+
// Go/Java have no awaitable call results.
|
|
2813
|
+
const JS_AUDIT_FAMILY = new Set(['javascript', 'typescript', 'tsx']);
|
|
2756
2814
|
const _AUDIT_ASYNC_LANGS = new Set([
|
|
2757
2815
|
'javascript', 'typescript', 'tsx', 'python', 'html', 'csharp',
|
|
2758
2816
|
]);
|
|
2759
2817
|
|
|
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
2818
|
// Follow a captured JS promise within its lexical scope, looking only for
|
|
2784
2819
|
// operations that require its resolved value. Passing/returning/awaiting the
|
|
2785
2820
|
// promise and its own methods are valid. Reassignment stops the inference;
|
|
@@ -2862,9 +2897,10 @@ function storedPromiseMisuse(call, functionNodes) {
|
|
|
2862
2897
|
* 2. The callee is provably async (its symbol's `isAsync` is true) OR
|
|
2863
2898
|
* the callee is a known async standard function (e.g., `fetch`).
|
|
2864
2899
|
* 3. The call is not wrapped in `await` (or its Python equivalent).
|
|
2865
|
-
* 4. The
|
|
2866
|
-
*
|
|
2867
|
-
*
|
|
2900
|
+
* 4. The value is lost or misused: discarded as a bare statement, stored
|
|
2901
|
+
* in a local that is never read, or used as if it were the resolved
|
|
2902
|
+
* value. A value that flows on (argument, collection, spread, return,
|
|
2903
|
+
* conditional branch, stored field) hands the awaitable to other code.
|
|
2868
2904
|
*
|
|
2869
2905
|
* Detection is AST-based per language; the language must support an
|
|
2870
2906
|
* `await` keyword (JS/TS/Python/C#). Other languages are skipped.
|
|
@@ -2879,148 +2915,293 @@ function auditAsync(index, options = {}) {
|
|
|
2879
2915
|
const { getParser, getLanguageAdapter, safeParse } = require('../languages');
|
|
2880
2916
|
const issues = [];
|
|
2881
2917
|
|
|
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'));
|
|
2918
|
+
// Names select candidate sites. Structural languages then require
|
|
2919
|
+
// lexical or import identity; an unrelated namesake proves nothing.
|
|
2920
|
+
// What each async definition's call RETURNS decides the audit
|
|
2921
|
+
// (fix #364): coroutines must be awaited; async iterators and async
|
|
2922
|
+
// context managers are consumed by `async for` / `async with` and
|
|
2923
|
+
// only a discarded call is a defect; unknown producers (unresolved
|
|
2924
|
+
// decorators, disagreeing same-name definitions) are not audited.
|
|
2925
|
+
const producers = require('./async-producers');
|
|
2926
|
+
const isAsyncDef = def => producers.isDefAsync(def);
|
|
2927
|
+
const kindMemo = new Map();
|
|
2928
|
+
const asyncCandidateNames = new Set();
|
|
2902
2929
|
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);
|
|
2930
|
+
if (defs.some(def => producers.isCallableDef(def) && producers.isDefAsync(def))) {
|
|
2931
|
+
asyncCandidateNames.add(name);
|
|
2910
2932
|
}
|
|
2911
2933
|
}
|
|
2912
|
-
|
|
2913
|
-
//
|
|
2914
|
-
//
|
|
2915
|
-
|
|
2916
|
-
|
|
2917
|
-
|
|
2918
|
-
|
|
2919
|
-
|
|
2920
|
-
|
|
2921
|
-
|
|
2922
|
-
|
|
2923
|
-
|
|
2934
|
+
// Class names (null for free functions) owning an async definition
|
|
2935
|
+
// of a name in a language family (a Python call never reaches a JS
|
|
2936
|
+
// definition), and whether a class name's ancestry or descendants
|
|
2937
|
+
// reach one of them (name level: a prefilter, never identity).
|
|
2938
|
+
const auditFamily = language => (JS_AUDIT_FAMILY.has(language) || language === 'html' ? 'js' : language);
|
|
2939
|
+
const asyncOwners = new Map();
|
|
2940
|
+
const asyncOwnersOf = (name, family) => {
|
|
2941
|
+
const key = `${family}\0${name}`;
|
|
2942
|
+
let owners = asyncOwners.get(key);
|
|
2943
|
+
if (!owners) {
|
|
2944
|
+
owners = new Set((index.symbols.get(name) || [])
|
|
2945
|
+
.filter(def => producers.isCallableDef(def) && producers.isDefAsync(def) &&
|
|
2946
|
+
auditFamily(index.files.get(def.file)?.language) === family)
|
|
2947
|
+
.map(def => def.className || null));
|
|
2948
|
+
asyncOwners.set(key, owners);
|
|
2924
2949
|
}
|
|
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;
|
|
2950
|
+
return owners;
|
|
2951
|
+
};
|
|
2952
|
+
const familyMemo = new Map();
|
|
2953
|
+
const ownerFamily = owner => {
|
|
2954
|
+
let family = familyMemo.get(owner);
|
|
2955
|
+
if (family) return family;
|
|
2956
|
+
family = new Set([owner]);
|
|
2957
|
+
for (const queue = [owner]; queue.length > 0 && family.size < 256;) {
|
|
2958
|
+
for (const parent of index._getInheritanceParents?.(queue.shift(), null) || []) {
|
|
2959
|
+
const parentName = typeof parent === 'string' ? parent : parent?.name;
|
|
2960
|
+
if (parentName && !family.has(parentName)) { family.add(parentName); queue.push(parentName); }
|
|
2960
2961
|
}
|
|
2961
|
-
cur = cur.parent;
|
|
2962
|
-
}
|
|
2963
|
-
// 3. Right-hand side of an assignment / variable_declarator — the
|
|
2964
|
-
// promise is being captured for later use, not lost. NOT
|
|
2965
|
-
// fire-and-forget but also NOT a missing-await; treat as
|
|
2966
|
-
// intentional.
|
|
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
2962
|
}
|
|
2974
|
-
|
|
2975
|
-
|
|
2976
|
-
|
|
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;
|
|
2963
|
+
for (const queue = [owner]; queue.length > 0 && family.size < 512;) {
|
|
2964
|
+
for (const child of index.extendedByGraph?.get(queue.shift()) || []) {
|
|
2965
|
+
const childName = typeof child === 'string' ? child : child?.name;
|
|
2966
|
+
if (childName && !family.has(childName)) { family.add(childName); queue.push(childName); }
|
|
2984
2967
|
}
|
|
2985
2968
|
}
|
|
2969
|
+
familyMemo.set(owner, family);
|
|
2970
|
+
return family;
|
|
2971
|
+
};
|
|
2972
|
+
const ownerFamilyMayOwn = (owner, owners) => {
|
|
2973
|
+
const family = ownerFamily(String(owner).replace(/<.*$/s, '').split('.').pop());
|
|
2974
|
+
for (const candidate of owners) if (candidate && family.has(candidate)) return true;
|
|
2986
2975
|
return false;
|
|
2976
|
+
};
|
|
2977
|
+
// Project-wide lookup: a sync definition anywhere keeps the name
|
|
2978
|
+
// ambiguous (`Map.get()` vs an async `DataService.get()`).
|
|
2979
|
+
const globalKinds = new Map();
|
|
2980
|
+
const globalKind = (name) => {
|
|
2981
|
+
if (!globalKinds.has(name)) {
|
|
2982
|
+
globalKinds.set(name, producers.collapseKinds(index, index.symbols.get(name) || [], kindMemo, true));
|
|
2983
|
+
}
|
|
2984
|
+
return globalKinds.get(name);
|
|
2985
|
+
};
|
|
2986
|
+
let skippedUnknown = 0;
|
|
2987
|
+
// Module scopes of imported Python modules, read once per audit.
|
|
2988
|
+
const moduleScopes = new Map();
|
|
2989
|
+
const knownAsyncGlobalNames = new Set();
|
|
2990
|
+
for (const fileEntry of index.files.values()) {
|
|
2991
|
+
for (const name of langTraits(fileEntry.language)?.knownAsyncGlobals || []) knownAsyncGlobalNames.add(name);
|
|
2992
|
+
}
|
|
2993
|
+
|
|
2994
|
+
// Rust futures (fix #370): producers are definitions whose call
|
|
2995
|
+
// returns a future; every call site is audited, sync fns included.
|
|
2996
|
+
let rustContext = null;
|
|
2997
|
+
const skippedFutures = { count: 0 };
|
|
2998
|
+
const rustProducers = () => {
|
|
2999
|
+
if (!rustContext) {
|
|
3000
|
+
const futureTypes = producers.rustProjectFutureTypes(index);
|
|
3001
|
+
const candidateNames = new Set();
|
|
3002
|
+
for (const [name, defs] of index.symbols) {
|
|
3003
|
+
if (defs.some(def => isCallableDef(def) && producers.rustDefFuture(def, futureTypes))) {
|
|
3004
|
+
candidateNames.add(name);
|
|
3005
|
+
}
|
|
3006
|
+
}
|
|
3007
|
+
rustContext = { futureTypes, candidateNames,
|
|
3008
|
+
futureDefs: producers.rustFutureDefIndex(index, futureTypes) };
|
|
3009
|
+
}
|
|
3010
|
+
return rustContext;
|
|
3011
|
+
};
|
|
3012
|
+
const isCallableDef = producers.isCallableDef;
|
|
3013
|
+
function processRustFile(filePath, fileEntry) {
|
|
3014
|
+
const { futureTypes, candidateNames, futureDefs } = rustProducers();
|
|
3015
|
+
// Local future closures (`let f = || async {..}`) are a parser
|
|
3016
|
+
// fact of the file; with neither those nor any project producer
|
|
3017
|
+
// there is nothing to audit.
|
|
3018
|
+
const asyncClosures = new Set(fileEntry.asyncClosureNames || []);
|
|
3019
|
+
if (futureDefs.size === 0 && asyncClosures.size === 0) return;
|
|
3020
|
+
// Build-time fact (fix #371/#372): the shapes of calls whose value
|
|
3021
|
+
// is not consumed where produced. Unless one of them can reach a
|
|
3022
|
+
// visible future producer (the same record-level tests, on the
|
|
3023
|
+
// fields the shape keeps), the file's call records are never
|
|
3024
|
+
// loaded.
|
|
3025
|
+
// A file whose macro expansions' calls are not derived yet may
|
|
3026
|
+
// hold a lost future among them (fix #374): read its calls.
|
|
3027
|
+
if (asyncClosures.size === 0 && Array.isArray(fileEntry.openCalls) &&
|
|
3028
|
+
!(fileEntry.rustMacroExpansion?.callsPending &&
|
|
3029
|
+
(fileEntry.rustMacroExpansion.calleeNames || []).some(name => futureDefs.has(name))) &&
|
|
3030
|
+
!fileEntry.openCalls.some(shape => {
|
|
3031
|
+
const record = parseOpenCallShape(shape);
|
|
3032
|
+
return futureDefs.has(record.name) &&
|
|
3033
|
+
!producers.rustRecordValueConsumed(index, futureDefs, futureTypes, record) &&
|
|
3034
|
+
producers.rustRecordMayReachFuture(record, futureDefs, index, filePath);
|
|
3035
|
+
})) return;
|
|
3036
|
+
const calls = index.getCachedCalls(filePath);
|
|
3037
|
+
if (!Array.isArray(calls)) return;
|
|
3038
|
+
// Only files with a call shape that can reach a future producer
|
|
3039
|
+
// or a local future closure, and whose value is not consumed
|
|
3040
|
+
// right away (`.await`ed or passed on, a parser fact), are
|
|
3041
|
+
// re-parsed (fix #371).
|
|
3042
|
+
const candidates = calls.filter(call => call?.name && !call.inMacro &&
|
|
3043
|
+
Number.isInteger(call.callStart) && Number.isInteger(call.callEnd) &&
|
|
3044
|
+
!producers.rustRecordValueConsumed(index, futureDefs, futureTypes, call) &&
|
|
3045
|
+
((call.localShadow && asyncClosures.has(call.name)) ||
|
|
3046
|
+
producers.rustRecordMayReachFuture(call, futureDefs, index, filePath)));
|
|
3047
|
+
if (candidates.length === 0) return;
|
|
3048
|
+
// Resolve before reading (fix #371): a file whose candidates reach
|
|
3049
|
+
// no future producer through the engine is never parsed. Local
|
|
3050
|
+
// future closures are file facts and always need the tree.
|
|
3051
|
+
let calleeMemo;
|
|
3052
|
+
if (!candidates.some(call => call.localShadow && asyncClosures.has(call.name))) {
|
|
3053
|
+
const resolved = producers.rustResolveCandidates(
|
|
3054
|
+
{ index, futureTypes, fileEntry, filePath }, candidates);
|
|
3055
|
+
if (!resolved.needsTree) return;
|
|
3056
|
+
calleeMemo = resolved.calleeMemo;
|
|
3057
|
+
}
|
|
3058
|
+
let tree;
|
|
3059
|
+
try {
|
|
3060
|
+
const parser = getParser('rust');
|
|
3061
|
+
if (!parser) return;
|
|
3062
|
+
tree = safeParse(parser, index._readFile(filePath));
|
|
3063
|
+
} catch (_) { return; }
|
|
3064
|
+
if (!tree) return;
|
|
3065
|
+
for (const issue of producers.auditRustFile({
|
|
3066
|
+
index, futureTypes, candidateNames, futureDefs, fileEntry, filePath, tree, calls,
|
|
3067
|
+
asyncClosures, skipped: skippedFutures, calleeMemo,
|
|
3068
|
+
})) issues.push(issue);
|
|
2987
3069
|
}
|
|
2988
3070
|
|
|
2989
3071
|
// Process one file: find async functions, then call sites within them.
|
|
2990
3072
|
function processFile(filePath, fileEntry) {
|
|
3073
|
+
if (fileEntry?.language === 'rust') return processRustFile(filePath, fileEntry);
|
|
2991
3074
|
if (!fileEntry || !_AUDIT_ASYNC_LANGS.has(fileEntry.language)) return;
|
|
2992
3075
|
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.
|
|
3076
|
+
|
|
3077
|
+
// Collect async functions for enclosing-scope attribution.
|
|
3004
3078
|
const asyncFns = [];
|
|
3005
|
-
const fileAsyncNames = new Set();
|
|
3006
|
-
const fileAnyDefNames = new Set();
|
|
3007
3079
|
if (Array.isArray(fileEntry.symbols)) {
|
|
3008
3080
|
for (const sym of fileEntry.symbols) {
|
|
3009
3081
|
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
|
-
}
|
|
3082
|
+
if (producers.isDefAsync(sym)) asyncFns.push(sym);
|
|
3021
3083
|
}
|
|
3022
3084
|
}
|
|
3023
3085
|
if (asyncFns.length === 0 && language !== 'python') return;
|
|
3086
|
+
const cachedCalls = index.getCachedCalls(filePath);
|
|
3087
|
+
const indexedCalls = Array.isArray(cachedCalls) ? cachedCalls : [];
|
|
3088
|
+
|
|
3089
|
+
// Which calls can reach an async producer. C# resolves member
|
|
3090
|
+
// calls per receiver type, so any async definition's name
|
|
3091
|
+
// counts. A structural bare call reaches one only through this
|
|
3092
|
+
// file's own definitions, an import (by its source name; a
|
|
3093
|
+
// default import by the name its project module exports as
|
|
3094
|
+
// default), a Python star import, or a runtime global.
|
|
3095
|
+
// Structural method calls are never audited (no receiver
|
|
3096
|
+
// evidence).
|
|
3097
|
+
let candidateName;
|
|
3098
|
+
if (language === 'csharp') {
|
|
3099
|
+
candidateName = name => asyncCandidateNames.has(name) || knownAsyncGlobalNames.has(name);
|
|
3100
|
+
} else {
|
|
3101
|
+
// A default import names the definition its project module
|
|
3102
|
+
// exports as default; a module outside the project names none.
|
|
3103
|
+
const defaultExportMayBeAsync = binding => {
|
|
3104
|
+
const relative = fileEntry.moduleResolved?.[binding.module];
|
|
3105
|
+
const source = relative ? index.files.get(path.resolve(index.root, relative)) : null;
|
|
3106
|
+
return (source?.exportDetails || []).some(entry =>
|
|
3107
|
+
entry.type === 'default' && asyncCandidateNames.has(entry.name));
|
|
3108
|
+
};
|
|
3109
|
+
// A JS/TS import read through the module's re-exports to the
|
|
3110
|
+
// function it declares (fix #402): `export { default } from`,
|
|
3111
|
+
// `export { f as g } from`, `export * from`.
|
|
3112
|
+
const exportedAsync = binding => {
|
|
3113
|
+
if (!JS_AUDIT_FAMILY.has(language)) return false;
|
|
3114
|
+
const relative = fileEntry.moduleResolved?.[binding.module];
|
|
3115
|
+
if (!relative) return false;
|
|
3116
|
+
return producers.jsModuleExportReaches(index, path.resolve(index.root, relative),
|
|
3117
|
+
binding.kind === 'default' ? 'default' : binding.name, isAsyncDef, moduleScopes);
|
|
3118
|
+
};
|
|
3119
|
+
// Decided per local name on first use: only names this file
|
|
3120
|
+
// calls pay for reading re-exports.
|
|
3121
|
+
let bindingsByLocal = null;
|
|
3122
|
+
const importedProducers = new Map();
|
|
3123
|
+
const importedProducerName = name => {
|
|
3124
|
+
if (importedProducers.has(name)) return importedProducers.get(name);
|
|
3125
|
+
if (!bindingsByLocal) {
|
|
3126
|
+
bindingsByLocal = new Map();
|
|
3127
|
+
for (const binding of fileEntry.importBindings || []) {
|
|
3128
|
+
const local = binding.alias || binding.name;
|
|
3129
|
+
if (!bindingsByLocal.has(local)) bindingsByLocal.set(local, []);
|
|
3130
|
+
bindingsByLocal.get(local).push(binding);
|
|
3131
|
+
}
|
|
3132
|
+
}
|
|
3133
|
+
const producer = (bindingsByLocal.get(name) || []).some(binding =>
|
|
3134
|
+
(binding.kind === 'default' ? defaultExportMayBeAsync(binding)
|
|
3135
|
+
: asyncCandidateNames.has(binding.name)) || exportedAsync(binding));
|
|
3136
|
+
importedProducers.set(name, producer);
|
|
3137
|
+
return producer;
|
|
3138
|
+
};
|
|
3139
|
+
const fileAsyncNames = new Set(asyncFns.map(fn => fn.name));
|
|
3140
|
+
// Text prefilter only: the AST decides what a star import binds.
|
|
3141
|
+
let starImport;
|
|
3142
|
+
const hasStarImport = () => {
|
|
3143
|
+
if (starImport === undefined) {
|
|
3144
|
+
try {
|
|
3145
|
+
starImport = language === 'python' && /\bimport\s+\*/.test(index._readFile(filePath));
|
|
3146
|
+
} catch (_) { starImport = false; }
|
|
3147
|
+
}
|
|
3148
|
+
return starImport;
|
|
3149
|
+
};
|
|
3150
|
+
candidateName = name => knownAsyncGlobalNames.has(name) || importedProducerName(name) ||
|
|
3151
|
+
(asyncCandidateNames.has(name) && (fileAsyncNames.has(name) || hasStarImport()));
|
|
3152
|
+
}
|
|
3153
|
+
// A structural method call is audited when its receiver carries
|
|
3154
|
+
// evidence the engine can resolve (fix #402): a constructed,
|
|
3155
|
+
// annotated or flow-typed receiver, an imported module, or the
|
|
3156
|
+
// enclosing instance (`this.m()`, `self.m()`); the engine's
|
|
3157
|
+
// callee at that site then decides the producer.
|
|
3158
|
+
const selfNames = langTraits(language)?.selfParam || [];
|
|
3159
|
+
// Name-level prefilter (the engine decides identity): an async
|
|
3160
|
+
// definition of the name owned by the receiver's class family, a
|
|
3161
|
+
// module function for a module receiver.
|
|
3162
|
+
let classByFunction = null;
|
|
3163
|
+
const enclosingClassOf = call => {
|
|
3164
|
+
const start = call.enclosingFunction?.startLine;
|
|
3165
|
+
if (!Number.isInteger(start)) return null;
|
|
3166
|
+
if (!classByFunction) {
|
|
3167
|
+
classByFunction = new Map();
|
|
3168
|
+
for (const symbol of fileEntry.symbols || []) {
|
|
3169
|
+
const key = `${symbol.startLine}\0${symbol.name}`;
|
|
3170
|
+
if (!classByFunction.has(key)) classByFunction.set(key, symbol.className || null);
|
|
3171
|
+
}
|
|
3172
|
+
}
|
|
3173
|
+
return classByFunction.get(`${start}\0${call.enclosingFunction.name}`) || null;
|
|
3174
|
+
};
|
|
3175
|
+
const typedMethodCall = call => {
|
|
3176
|
+
if (language === 'csharp' || !call.isMethod || call.isPotentialCallback || call.awaited ||
|
|
3177
|
+
call.passedAsArgument || !asyncCandidateNames.has(call.name)) return false;
|
|
3178
|
+
const owners = asyncOwnersOf(call.name, auditFamily(language));
|
|
3179
|
+
if (owners.size === 0) return false;
|
|
3180
|
+
if (call.receiverIsModule) return owners.has(null);
|
|
3181
|
+
// A field hop (`self.conn.send()`) names no type the record
|
|
3182
|
+
// keeps; it stays unaudited.
|
|
3183
|
+
if (call.selfAttribute || call.receiverField || call.receiverRoot) return false;
|
|
3184
|
+
const owner = call.receiverType || (selfNames.includes(call.receiver) ? enclosingClassOf(call) : null);
|
|
3185
|
+
return !!owner && ownerFamilyMayOwn(owner, owners);
|
|
3186
|
+
};
|
|
3187
|
+
const typedCalls = new Set(indexedCalls.filter(typedMethodCall));
|
|
3188
|
+
const candidateCall = call => !!call?.name && (((language === 'csharp' || !call.isMethod) &&
|
|
3189
|
+
candidateName(call.name)) || typedCalls.has(call));
|
|
3190
|
+
const typedMethodStarts = new Set([...typedCalls]
|
|
3191
|
+
.map(call => call.callStart ?? call.callSite?.start).filter(Number.isInteger));
|
|
3192
|
+
// The record start of a typed method call node: its own start, or
|
|
3193
|
+
// its method name token's (records that keep only the token).
|
|
3194
|
+
const typedMethodStart = callNode => {
|
|
3195
|
+
if (typedMethodStarts.size === 0) return null;
|
|
3196
|
+
if (typedMethodStarts.has(callNode.startIndex)) return callNode.startIndex;
|
|
3197
|
+
const fn = callNode.childForFieldName('function');
|
|
3198
|
+
const token = fn?.childForFieldName('property') || fn?.childForFieldName('attribute');
|
|
3199
|
+
return token && typedMethodStarts.has(token.startIndex) ? token.startIndex : null;
|
|
3200
|
+
};
|
|
3201
|
+
// A file none of whose indexed calls can reach an async producer
|
|
3202
|
+
// has nothing to audit: skip the re-parse (dominant cost on large
|
|
3203
|
+
// repos). Files without call records are parsed as before.
|
|
3204
|
+
if (language !== 'html' && Array.isArray(cachedCalls) && !cachedCalls.some(candidateCall)) return;
|
|
3024
3205
|
|
|
3025
3206
|
// Re-parse file to find awaited-vs-not call sites. We use a fresh
|
|
3026
3207
|
// parse rather than tree cache because we want to walk every
|
|
@@ -3045,6 +3226,16 @@ function auditAsync(index, options = {}) {
|
|
|
3045
3226
|
}
|
|
3046
3227
|
} catch (_) { return; }
|
|
3047
3228
|
if (!tree) return;
|
|
3229
|
+
const resolveLexical = producers.createLexicalResolver(fileEntry, language);
|
|
3230
|
+
// Runtime globals that return promises (`fetch`), bare calls only.
|
|
3231
|
+
const knownAsyncGlobals = new Set(langTraits(language)?.knownAsyncGlobals || []);
|
|
3232
|
+
// Imports the imported module's own scope cannot settle resolve
|
|
3233
|
+
// through the engine, once per enclosing callable and only for
|
|
3234
|
+
// candidate sites. A globally unique name is a search candidate,
|
|
3235
|
+
// never producer identity.
|
|
3236
|
+
const producerCallees = producers.createImportedProducerResolver(index, filePath,
|
|
3237
|
+
fileEntry, indexedCalls.filter(candidateCall));
|
|
3238
|
+
let moduleImports = null;
|
|
3048
3239
|
|
|
3049
3240
|
// Walk every call_expression within an async function range.
|
|
3050
3241
|
const callTypes = new Set([
|
|
@@ -3111,7 +3302,7 @@ function auditAsync(index, options = {}) {
|
|
|
3111
3302
|
endLine: cur.endPosition.row + 1,
|
|
3112
3303
|
};
|
|
3113
3304
|
}
|
|
3114
|
-
if (discardedPython) return {
|
|
3305
|
+
if (discardedPython || langTraits(language)?.storedCoroutines) return {
|
|
3115
3306
|
name: cur.childForFieldName('name')?.text || '<anonymous>',
|
|
3116
3307
|
startLine: cur.startPosition.row + 1,
|
|
3117
3308
|
endLine: cur.endPosition.row + 1,
|
|
@@ -3123,137 +3314,195 @@ function auditAsync(index, options = {}) {
|
|
|
3123
3314
|
return discardedPython ? { name: '<module>' } : null;
|
|
3124
3315
|
}
|
|
3125
3316
|
|
|
3317
|
+
// Which definition a call reaches, and what calling it returns.
|
|
3318
|
+
// Lexical scope first, then import identity. Unrelated project
|
|
3319
|
+
// definitions cannot turn a builtin or an unbound call async.
|
|
3320
|
+
function calleeProducerKind(callNode, calleeName, isMethodCall, line) {
|
|
3321
|
+
if (language === 'csharp') {
|
|
3322
|
+
// C# method identity is nominal. Prefer the indexed
|
|
3323
|
+
// receiver type at this call site; fall back only when
|
|
3324
|
+
// every project definition with the name is async.
|
|
3325
|
+
const indexed = indexedCalls.find(call =>
|
|
3326
|
+
call.name === calleeName && call.line === line &&
|
|
3327
|
+
call.isMethod === isMethodCall);
|
|
3328
|
+
let receiverType = indexed?.receiverType || null;
|
|
3329
|
+
if (!receiverType && indexed?.receiverField && indexed?.receiverRootType) {
|
|
3330
|
+
const field = (fileEntry.symbols || []).find(symbol =>
|
|
3331
|
+
symbol.type === 'field' &&
|
|
3332
|
+
symbol.className === indexed.receiverRootType &&
|
|
3333
|
+
symbol.name === indexed.receiverField);
|
|
3334
|
+
receiverType = field?.fieldType || null;
|
|
3335
|
+
}
|
|
3336
|
+
// The receiver's type and its project ancestors own the
|
|
3337
|
+
// member (fix #395: `s.SaveAsync()`, `_svc.SaveAsync()`);
|
|
3338
|
+
// a typed receiver owning none of the async definitions
|
|
3339
|
+
// (an external type's own method) is not audited.
|
|
3340
|
+
const owners = new Set();
|
|
3341
|
+
const typeName = receiverType ? String(receiverType).replace(/<.*$/s, '').replace(/\?$/, '')
|
|
3342
|
+
.split('.').pop() : null;
|
|
3343
|
+
for (const queue = typeName ? [typeName] : []; queue.length > 0 && owners.size < 64;) {
|
|
3344
|
+
const owner = queue.shift();
|
|
3345
|
+
if (!owner || owners.has(owner)) continue;
|
|
3346
|
+
owners.add(owner);
|
|
3347
|
+
for (const parent of index._getInheritanceParents?.(owner, filePath) || []) {
|
|
3348
|
+
queue.push(typeof parent === 'string' ? parent : parent?.name);
|
|
3349
|
+
}
|
|
3350
|
+
}
|
|
3351
|
+
const receiverDefs = owners.size > 0
|
|
3352
|
+
? (index.symbols.get(calleeName) || []).filter(def =>
|
|
3353
|
+
producers.isCallableDef(def) && owners.has(def.className))
|
|
3354
|
+
: [];
|
|
3355
|
+
if (receiverDefs.length > 0) return producers.collapseKinds(index, receiverDefs, kindMemo, true);
|
|
3356
|
+
return typeName && isMethodCall && indexed?.receiver !== 'this' ? null : globalKind(calleeName);
|
|
3357
|
+
}
|
|
3358
|
+
let lexical;
|
|
3359
|
+
if (!isMethodCall && resolveLexical) {
|
|
3360
|
+
lexical = resolveLexical(callNode, calleeName);
|
|
3361
|
+
if (lexical?.shadowed) return null;
|
|
3362
|
+
if (lexical?.defs) return producers.collapseKinds(index, lexical.defs, kindMemo, true);
|
|
3363
|
+
}
|
|
3364
|
+
// An import is read in the imported module itself when that
|
|
3365
|
+
// proves the definition; otherwise the engine must establish
|
|
3366
|
+
// the call's target.
|
|
3367
|
+
if (lexical?.imports || lexical?.star) {
|
|
3368
|
+
moduleImports ||= producers.createModuleImportResolver(index, fileEntry, moduleScopes);
|
|
3369
|
+
const defs = lexical.star ? moduleImports.star(calleeName, lexical.star)
|
|
3370
|
+
: moduleImports.fromImports(lexical.imports);
|
|
3371
|
+
if (defs) return producers.collapseKinds(index, defs, kindMemo, true);
|
|
3372
|
+
if (lexical.star) return null;
|
|
3373
|
+
}
|
|
3374
|
+
const typedStart = isMethodCall ? typedMethodStart(callNode) : null;
|
|
3375
|
+
const targets = lexical?.external || typedStart != null
|
|
3376
|
+
? producerCallees(callNode, typedStart ?? callNode.startIndex) : [];
|
|
3377
|
+
if (targets.length > 0) return producers.collapseKinds(index, targets, kindMemo, true);
|
|
3378
|
+
return !isMethodCall && !lexical?.external && knownAsyncGlobals.has(calleeName)
|
|
3379
|
+
? 'coroutine' : null;
|
|
3380
|
+
}
|
|
3381
|
+
|
|
3382
|
+
function isAwaited(callNode) {
|
|
3383
|
+
let current = callNode.parent;
|
|
3384
|
+
let depth = 0;
|
|
3385
|
+
while (current && depth++ < 5) {
|
|
3386
|
+
if (current.type === 'parenthesized_expression') {
|
|
3387
|
+
current = current.parent;
|
|
3388
|
+
continue;
|
|
3389
|
+
}
|
|
3390
|
+
if (current.type === 'await_expression' || current.type === 'await') return true;
|
|
3391
|
+
// C#'s canonical `await Task().ConfigureAwait(false)`
|
|
3392
|
+
// wraps the original invocation in a member-access +
|
|
3393
|
+
// invocation chain before the await node.
|
|
3394
|
+
if (language === 'csharp' && [
|
|
3395
|
+
'member_access_expression',
|
|
3396
|
+
'invocation_expression',
|
|
3397
|
+
'conditional_access_expression',
|
|
3398
|
+
'member_binding_expression',
|
|
3399
|
+
].includes(current.type)) {
|
|
3400
|
+
current = current.parent;
|
|
3401
|
+
continue;
|
|
3402
|
+
}
|
|
3403
|
+
return false;
|
|
3404
|
+
}
|
|
3405
|
+
return false;
|
|
3406
|
+
}
|
|
3407
|
+
|
|
3126
3408
|
function visit(node) {
|
|
3127
3409
|
if (!node) return;
|
|
3128
3410
|
if (callTypes.has(node.type)) {
|
|
3129
3411
|
const line = node.startPosition.row + 1;
|
|
3130
|
-
|
|
3131
|
-
|
|
3412
|
+
let enclosing;
|
|
3413
|
+
{
|
|
3132
3414
|
// Get the callee name + skip if not async.
|
|
3133
|
-
|
|
3415
|
+
let funcNode = node.childForFieldName('function') ||
|
|
3134
3416
|
node.childForFieldName('name');
|
|
3417
|
+
// C# `obj?.DoAsync()`: the member binding names it (fix #395).
|
|
3418
|
+
if (funcNode?.type === 'conditional_access_expression') {
|
|
3419
|
+
funcNode = (funcNode.namedChildren || []).find(child =>
|
|
3420
|
+
child.type === 'member_binding_expression') || funcNode;
|
|
3421
|
+
}
|
|
3135
3422
|
if (funcNode) {
|
|
3136
3423
|
let calleeName;
|
|
3137
3424
|
let isMethodCall = false;
|
|
3138
3425
|
if (funcNode.type === 'member_expression' || funcNode.type === 'attribute' ||
|
|
3139
|
-
funcNode.type === 'selector_expression' || funcNode.type === 'field_expression'
|
|
3426
|
+
funcNode.type === 'selector_expression' || funcNode.type === 'field_expression' ||
|
|
3427
|
+
// C# `obj.DoAsync()`, `obj?.DoAsync()` (fix #395).
|
|
3428
|
+
funcNode.type === 'member_access_expression' ||
|
|
3429
|
+
funcNode.type === 'member_binding_expression') {
|
|
3140
3430
|
const prop = funcNode.childForFieldName('property') ||
|
|
3141
3431
|
funcNode.childForFieldName('field') ||
|
|
3142
3432
|
funcNode.childForFieldName('attribute') ||
|
|
3143
3433
|
funcNode.childForFieldName('name');
|
|
3144
|
-
|
|
3434
|
+
// `obj.Save<T>()` names the method Save.
|
|
3435
|
+
calleeName = prop ? (prop.type === 'generic_name'
|
|
3436
|
+
? (prop.namedChild(0)?.text || null) : prop.text) : null;
|
|
3145
3437
|
isMethodCall = true;
|
|
3438
|
+
} else if (funcNode.type === 'generic_name') {
|
|
3439
|
+
calleeName = funcNode.namedChild(0)?.text || null;
|
|
3146
3440
|
} else {
|
|
3147
3441
|
calleeName = funcNode.text;
|
|
3148
3442
|
}
|
|
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
|
|
3443
|
+
// Structural method calls (`obj.get()`) need
|
|
3444
|
+
// receiver evidence: the engine's callee at a
|
|
3445
|
+
// typed receiver (fix #402).
|
|
3446
|
+
const methodBlocked = isMethodCall && language !== 'csharp' &&
|
|
3447
|
+
typedMethodStart(node) == null;
|
|
3448
|
+
// An awaited or consumed call reports nothing
|
|
3449
|
+
// whatever its producer (nor counts as skipped):
|
|
3450
|
+
// its producer is never resolved.
|
|
3451
|
+
const typedMember = isMethodCall && language !== 'csharp';
|
|
3452
|
+
if (calleeName && !methodBlocked &&
|
|
3453
|
+
(candidateName(calleeName) || typedMember) &&
|
|
3454
|
+
!isAwaited(node) && !producers.asyncConsumerRole(node) &&
|
|
3455
|
+
(enclosing = nearestAsyncEnclosing(node))) {
|
|
3456
|
+
const kind = calleeProducerKind(node, calleeName, isMethodCall, line);
|
|
3457
|
+
if (kind && kind !== 'void' &&
|
|
3458
|
+
!producers.asyncConsumerRole(node)) {
|
|
3459
|
+
const awaited = isAwaited(node);
|
|
3460
|
+
if (kind === 'unknown') {
|
|
3461
|
+
if (!awaited) skippedUnknown++;
|
|
3462
|
+
} else if (kind === 'iterator' || kind === 'context-manager') {
|
|
3463
|
+
// Not awaitable: iterated / entered /
|
|
3464
|
+
// passed on. Only a discarded call
|
|
3465
|
+
// is lost.
|
|
3466
|
+
if (producers.isDiscardedCall(node)) {
|
|
3467
|
+
issues.push({
|
|
3468
|
+
file: fileEntry.relativePath || filePath,
|
|
3469
|
+
line,
|
|
3470
|
+
callerName: enclosing.name,
|
|
3471
|
+
calleeName,
|
|
3472
|
+
reason: kind === 'iterator'
|
|
3473
|
+
? 'async-iterator-discarded'
|
|
3474
|
+
: 'async-context-manager-discarded',
|
|
3475
|
+
});
|
|
3213
3476
|
}
|
|
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;
|
|
3477
|
+
} else {
|
|
3478
|
+
// fix #367c: an awaitable that flows on
|
|
3479
|
+
// (argument, collection, return, branch)
|
|
3480
|
+
// is not a missing await; a lost value or
|
|
3481
|
+
// one used as if resolved is.
|
|
3482
|
+
let finding = null;
|
|
3483
|
+
if (!awaited) {
|
|
3484
|
+
const flow = producers.valueFlow(node, language);
|
|
3485
|
+
if (flow.kind === 'discarded') {
|
|
3486
|
+
finding = {};
|
|
3487
|
+
} else if (flow.kind === 'used-as-value') {
|
|
3488
|
+
finding = { reason: 'async-result-used-as-value' };
|
|
3489
|
+
} else if (flow.kind === 'stored') {
|
|
3490
|
+
finding = (langTraits(language)?.storedPromises
|
|
3491
|
+
? storedPromiseMisuse(node, FN_NODE_TYPES) : null) ||
|
|
3492
|
+
(langTraits(language)?.storedCoroutines
|
|
3493
|
+
? producers.storedCoroutineMisuse(node, flow.holder, flow.binding,
|
|
3494
|
+
FN_NODE_TYPES, language) : null) ||
|
|
3495
|
+
(producers.storedValueRead(flow.holder, flow.binding, FN_NODE_TYPES)
|
|
3496
|
+
? null : {});
|
|
3245
3497
|
}
|
|
3246
|
-
break;
|
|
3247
3498
|
}
|
|
3248
|
-
|
|
3249
|
-
? storedPromiseMisuse(node, FN_NODE_TYPES) : null;
|
|
3250
|
-
if (storedMisuse || (!awaited && !isFireAndForget(node, language))) {
|
|
3499
|
+
if (finding) {
|
|
3251
3500
|
issues.push({
|
|
3252
3501
|
file: fileEntry.relativePath || filePath,
|
|
3253
3502
|
line,
|
|
3254
3503
|
callerName: enclosing.name,
|
|
3255
3504
|
calleeName,
|
|
3256
|
-
...
|
|
3505
|
+
...finding,
|
|
3257
3506
|
});
|
|
3258
3507
|
}
|
|
3259
3508
|
}
|
|
@@ -3293,6 +3542,8 @@ function auditAsync(index, options = {}) {
|
|
|
3293
3542
|
issues,
|
|
3294
3543
|
totalIssues: issues.length,
|
|
3295
3544
|
filesAffected: new Set(issues.map(i => i.file)).size,
|
|
3545
|
+
skippedUnknown,
|
|
3546
|
+
...(skippedFutures.count > 0 && { skippedFutures: skippedFutures.count }),
|
|
3296
3547
|
};
|
|
3297
3548
|
} finally { index._endOp(); }
|
|
3298
3549
|
}
|