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.
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 +27 -16
  6. package/cli/index.js +45 -14
  7. package/core/accessors.js +118 -3
  8. package/core/account.js +62 -11
  9. package/core/analysis.js +517 -266
  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 +529 -31
  16. package/core/callers.js +12228 -921
  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 +472 -66
  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 +3026 -0
  62. package/core/rust-modules.js +203 -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 +1185 -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 +2265 -112
  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')
@@ -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
- const calleeFiles = index.getCalleeFiles(del.name);
2466
- if (calleeFiles) {
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). Go/Java/Rust have async machinery but audit-async is scoped to
2755
- // JS/TS/Python per spec.
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 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).
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
- // 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'));
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
- 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);
2930
+ if (defs.some(def => producers.isCallableDef(def) && producers.isDefAsync(def))) {
2931
+ asyncCandidateNames.add(name);
2910
2932
  }
2911
2933
  }
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;
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
- // 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;
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
- 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;
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
- 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.
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
- 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
- }
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
- const enclosing = nearestAsyncEnclosing(node);
3131
- if (enclosing) {
3412
+ let enclosing;
3413
+ {
3132
3414
  // Get the callee name + skip if not async.
3133
- const funcNode = node.childForFieldName('function') ||
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
- calleeName = prop ? prop.text : null;
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
- 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
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
- 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;
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
- const storedMisuse = !awaited && langTraits(language)?.storedPromises
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
- ...(storedMisuse || {}),
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
  }