ucn 5.4.2 → 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.
Files changed (85) hide show
  1. package/.claude/skills/ucn/SKILL.md +58 -236
  2. package/.claude/skills/ucn/references/commands.md +144 -68
  3. package/.claude/skills/ucn/references/resolution-rules.md +171 -0
  4. package/.claude/skills/ucn/references/trust-contract.md +27 -33
  5. package/README.md +18 -12
  6. package/cli/index.js +40 -14
  7. package/core/accessors.js +118 -3
  8. package/core/account.js +30 -0
  9. package/core/analysis.js +508 -264
  10. package/core/ast-analysis.js +76 -117
  11. package/core/async-producers.js +1729 -0
  12. package/core/bridge.js +1877 -268
  13. package/core/brief.js +6 -0
  14. package/core/build-worker.js +78 -14
  15. package/core/cache.js +522 -31
  16. package/core/callers.js +12053 -1047
  17. package/core/check.js +30 -38
  18. package/core/class-identity.js +1028 -0
  19. package/core/command-contracts.js +6 -6
  20. package/core/configuration-items.js +86 -0
  21. package/core/contract-membership.js +2262 -0
  22. package/core/cpp-scope.js +532 -0
  23. package/core/deadcode.js +171 -49
  24. package/core/decorator-binding.js +260 -0
  25. package/core/discovery.js +281 -164
  26. package/core/errors.js +52 -0
  27. package/core/execute.js +21 -9
  28. package/core/external-macros.js +326 -0
  29. package/core/graph-build.js +277 -8
  30. package/core/graph.js +35 -2
  31. package/core/ignore-rules.js +472 -0
  32. package/core/imports.js +504 -42
  33. package/core/index-ir.js +94 -11
  34. package/core/inprocess-clients.js +636 -0
  35. package/core/ir.js +87 -8
  36. package/core/macro-expansion.js +1416 -0
  37. package/core/output/analysis.js +18 -2
  38. package/core/output/brief.js +3 -0
  39. package/core/output/doctor.js +4 -0
  40. package/core/output/endpoints.js +15 -6
  41. package/core/output/find.js +7 -2
  42. package/core/output/refactoring.js +121 -14
  43. package/core/output/reporting.js +31 -3
  44. package/core/output/shared.js +14 -0
  45. package/core/output-budget.js +305 -122
  46. package/core/parallel-build.js +124 -47
  47. package/core/parser.js +3 -2
  48. package/core/project.js +457 -60
  49. package/core/provenance-facts.js +156 -12
  50. package/core/provenance-overload.js +30 -3
  51. package/core/provenance.js +104 -30
  52. package/core/pytest-fixtures.js +434 -0
  53. package/core/python-fixture-flow.js +17 -3
  54. package/core/python-modules.js +173 -0
  55. package/core/reflection.js +877 -0
  56. package/core/registry.js +1 -1
  57. package/core/reporting.js +234 -52
  58. package/core/request-helpers.js +561 -0
  59. package/core/route-graph.js +1922 -0
  60. package/core/route-lists.js +2414 -0
  61. package/core/rust-macro-expansion.js +2113 -0
  62. package/core/rust-modules.js +185 -0
  63. package/core/search.js +121 -29
  64. package/core/shared.js +19 -1
  65. package/core/test-name-bindings.js +256 -0
  66. package/core/type-denotation.js +1161 -0
  67. package/core/type-references.js +3229 -0
  68. package/core/verify.js +3360 -672
  69. package/languages/adapter.js +31 -5
  70. package/languages/c-family.js +4061 -212
  71. package/languages/c-macro-invocations.js +380 -0
  72. package/languages/c-preprocessor.js +254 -0
  73. package/languages/csharp.js +1406 -49
  74. package/languages/go.js +1243 -70
  75. package/languages/index.js +728 -6
  76. package/languages/java.js +473 -61
  77. package/languages/javascript.js +1270 -197
  78. package/languages/lexical-scope.js +1280 -0
  79. package/languages/python.js +1557 -72
  80. package/languages/rust-macro-rules.js +1020 -0
  81. package/languages/rust-value-flow.js +416 -0
  82. package/languages/rust.js +2237 -115
  83. package/languages/utils.js +298 -4
  84. package/mcp/server.js +22 -8
  85. 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
- const { computeReachability, symbolKey } = require('./entrypoints');
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.reason && { reason: u.reason }),
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 Error(`Invalid git ref format: ${base}`);
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 Error('Not a git repository. impact without a symbol requires git.', { cause: e });
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 Error(fatal
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). Go/Java/Rust have async machinery but audit-async is scoped to
2755
- // JS/TS/Python per spec.
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 call is not in a known fire-and-forget context (Promise.all
2866
- * arguments, `void fn()`, `.then(...)`, return statement, assignment
2867
- * to a variable — these are intentional non-await uses).
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
- // Build a "is this name provably async" lookup from the symbol table.
2883
- // We accept a global name only if EVERY callable definition with that
2884
- // name is async — this avoids flagging ambiguous calls like `Map.get()`
2885
- // where the project also has a `DataService.get()` async method.
2886
- //
2887
- // BUT: we ALSO track per-file async-name resolution. JavaScript/Python
2888
- // module scope means a same-file definition shadows globals, so when
2889
- // a caller's file contains an async definition with that name, that
2890
- // definition wins regardless of what other files contain. This is
2891
- // critical to avoid silent false-negatives caused by name collisions
2892
- // across files (HIGH-1 fix).
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
- const callable = callableDefs(defs);
2904
- if (callable.length === 0) continue;
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
- // Helper: does the call site (callExpr node) sit in a "fire-and-forget"
2914
- // context? Walk up at most a few levels and check for known patterns.
2915
- function isFireAndForget(callNode, language) {
2916
- let p = callNode.parent;
2917
- // 1. Direct `void fn()` (JS/TS only)
2918
- if (p && p.type === 'unary_expression') {
2919
- const op = p.childForFieldName('operator');
2920
- if (op && op.text === 'void') return true;
2921
- // Some grammars expose first child as the operator
2922
- const first = p.namedChild(0);
2923
- if (first && first.type === 'void') return true;
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
- // 2. Argument of `Promise.all([...])` / `Promise.allSettled` / etc.
2926
- // Walk up: arguments > call > selector_expression(member) > 'Promise'.<allSettled>.
2927
- // The call site is somewhere inside the array; check if the
2928
- // enclosing call is a Promise.all-style helper.
2929
- let cur = callNode.parent;
2930
- let depth = 0;
2931
- while (cur && depth++ < 6) {
2932
- if ((cur.type === 'call_expression' || cur.type === 'call') && cur !== callNode) {
2933
- const fn = cur.childForFieldName('function');
2934
- if (fn) {
2935
- if (fn.type === 'member_expression' || fn.type === 'attribute') {
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
- // 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
- }
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
- const indexedCalls = language === 'csharp'
2994
- ? index.getCachedCalls(filePath) || []
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
- const isAsync = sym.isAsync === true ||
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
- const enclosing = nearestAsyncEnclosing(node);
3131
- if (enclosing) {
3405
+ let enclosing;
3406
+ {
3132
3407
  // Get the callee name + skip if not async.
3133
- const funcNode = node.childForFieldName('function') ||
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
- calleeName = prop ? prop.text : null;
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
- if (calleeName) {
3150
- // Check whether the callee is provably async.
3151
- // File-local resolution wins (HIGH-1 fix): if
3152
- // THIS file defines an async function with that
3153
- // name, the call resolves to it regardless of
3154
- // what other files contain. This avoids silent
3155
- // false-negatives caused by name collisions
3156
- // (e.g., async helper in bad.js + sync helper
3157
- // in unrelated.js — bad.js's helper() should
3158
- // still be flagged).
3159
- let calleeIsAsync;
3160
- if (language === 'csharp') {
3161
- // C# method identity is nominal. Prefer the
3162
- // indexed receiver type at this call site;
3163
- // fall back only when every project
3164
- // definition with the name is async.
3165
- const indexed = indexedCalls.find(call =>
3166
- call.name === calleeName && call.line === line &&
3167
- call.isMethod === isMethodCall);
3168
- let receiverType = indexed?.receiverType || null;
3169
- if (!receiverType && indexed?.receiverField &&
3170
- indexed?.receiverRootType) {
3171
- const field = (fileEntry.symbols || []).find(symbol =>
3172
- symbol.type === 'field' &&
3173
- symbol.className === indexed.receiverRootType &&
3174
- symbol.name === indexed.receiverField);
3175
- receiverType = field?.fieldType || null;
3176
- }
3177
- const receiverDefs = receiverType
3178
- ? callableDefs(index.symbols.get(calleeName) || [])
3179
- .filter(def => def.className === receiverType)
3180
- : [];
3181
- calleeIsAsync = receiverDefs.length > 0
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
- if (!isMethodCall || language === 'csharp' ||
3216
- _KNOWN_ASYNC_CALLEES.has(calleeName)) {
3217
- // Check: is the call awaited?
3218
- let awaited = false;
3219
- let current = node.parent;
3220
- let awaitDepth = 0;
3221
- while (current && awaitDepth++ < 5) {
3222
- if (current.type === 'parenthesized_expression') {
3223
- current = current.parent;
3224
- continue;
3225
- }
3226
- if (current.type === 'await_expression' ||
3227
- current.type === 'await') {
3228
- awaited = true;
3229
- break;
3230
- }
3231
- // C#'s canonical
3232
- // `await Task().ConfigureAwait(false)`
3233
- // wraps the original invocation in
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
- const storedMisuse = !awaited && langTraits(language)?.storedPromises
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
- ...(storedMisuse || {}),
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
  }