ucn 4.2.3 → 5.0.2

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 (72) hide show
  1. package/.claude/skills/ucn/SKILL.md +89 -77
  2. package/.claude/skills/ucn/references/commands.md +62 -68
  3. package/.claude/skills/ucn/references/trust-contract.md +31 -6
  4. package/README.md +438 -305
  5. package/assets/demo.svg +31 -0
  6. package/cli/index.js +430 -1385
  7. package/core/account.js +144 -34
  8. package/core/analysis.js +182 -72
  9. package/core/ast-analysis.js +279 -0
  10. package/core/bridge.js +205 -24
  11. package/core/brief.js +27 -58
  12. package/core/build-worker.js +21 -140
  13. package/core/cache.js +513 -11
  14. package/core/callers.js +4920 -456
  15. package/core/check.js +13 -4
  16. package/core/command-contracts.js +402 -0
  17. package/core/compilation-database.js +276 -0
  18. package/core/confidence.js +4 -1
  19. package/core/deadcode.js +397 -19
  20. package/core/discovery.js +359 -46
  21. package/core/entrypoints.js +195 -41
  22. package/core/execute.js +887 -81
  23. package/core/graph-build.js +162 -7
  24. package/core/graph.js +53 -77
  25. package/core/imports.js +65 -6
  26. package/core/index-ir.js +138 -0
  27. package/core/ir.js +195 -0
  28. package/core/output/analysis.js +212 -22
  29. package/core/output/brief.js +23 -0
  30. package/core/output/check.js +4 -0
  31. package/core/output/doctor.js +37 -6
  32. package/core/output/endpoints.js +5 -2
  33. package/core/output/extraction.js +24 -12
  34. package/core/output/find.js +141 -36
  35. package/core/output/graph.js +11 -5
  36. package/core/output/public.js +462 -0
  37. package/core/output/refactoring.js +42 -10
  38. package/core/output/reporting.js +97 -20
  39. package/core/output/search.js +24 -16
  40. package/core/output/shared.js +22 -1
  41. package/core/output/tracing.js +30 -15
  42. package/core/output-budget.js +295 -0
  43. package/core/output.js +1 -0
  44. package/core/parallel-build.js +44 -11
  45. package/core/parser.js +3 -3
  46. package/core/project.js +384 -187
  47. package/core/public-command.js +47 -0
  48. package/core/registry.js +247 -117
  49. package/core/reporting.js +312 -290
  50. package/core/search.js +317 -185
  51. package/core/semantic-provider.js +110 -0
  52. package/core/stacktrace.js +25 -0
  53. package/core/tracing.js +101 -51
  54. package/core/trust-matrix.js +19 -40
  55. package/core/verify.js +534 -37
  56. package/languages/adapter.js +218 -0
  57. package/languages/c-family.js +2791 -0
  58. package/languages/c.js +3 -0
  59. package/languages/cpp.js +3 -0
  60. package/languages/csharp.js +1402 -0
  61. package/languages/go.js +60 -21
  62. package/languages/html.js +2 -2
  63. package/languages/index.js +85 -7
  64. package/languages/java.js +396 -13
  65. package/languages/javascript.js +199 -19
  66. package/languages/python.js +964 -22
  67. package/languages/rust.js +1317 -152
  68. package/languages/utils.js +40 -3
  69. package/mcp/server.js +254 -636
  70. package/package.json +39 -22
  71. package/eslint.config.js +0 -43
  72. package/jsconfig.json +0 -10
package/core/analysis.js CHANGED
@@ -13,8 +13,10 @@ const { execFileSync } = require('child_process');
13
13
  const { parse } = require('./parser');
14
14
  const { detectLanguage, langTraits } = require('../languages');
15
15
  const { NON_CALLABLE_TYPES, addTestExclusions, countTextBlindspots, codeUnitCompare } = require('./shared');
16
+ const { isTestFile } = require('./discovery');
16
17
  const { computeReachability, symbolKey } = require('./entrypoints');
17
- const { getLanguageModule } = require('../languages');
18
+ const { getLanguageAdapter } = require('../languages');
19
+ const { projectComputedDispatch } = require('./ast-analysis');
18
20
 
19
21
  // JS/TS test framework helpers — calls to these bracket a test case.
20
22
  // Used to flag call sites whose enclosing function is an arrow callback
@@ -44,10 +46,11 @@ function tagInTestCase(index, sites) {
44
46
  const fe = index.files.get(filePath);
45
47
  if (!fe) { fileMeta.set(filePath, null); return null; }
46
48
  let langModule = null;
47
- try { langModule = getLanguageModule(fe.language); } catch (_) { /* ignore */ }
49
+ try { langModule = getLanguageAdapter(fe.language); } catch (_) { /* ignore */ }
48
50
  const meta = { fileEntry: fe, langModule, language: fe.language, jsTestRanges: null };
49
51
  // For JS-family files, build line ranges of describe/it/test framework calls.
50
- if (langModule && (fe.language === 'javascript' || fe.language === 'typescript' ||
52
+ if (langModule && isTestFile(fe.relativePath, fe.language) &&
53
+ (fe.language === 'javascript' || fe.language === 'typescript' ||
51
54
  fe.language === 'tsx' || fe.language === 'html')) {
52
55
  try {
53
56
  const calls = index.getCachedCalls ? index.getCachedCalls(filePath) : null;
@@ -296,8 +299,12 @@ function context(index, name, options = {}) {
296
299
  !!(def.isMethod || def.type === 'method' || def.className);
297
300
 
298
301
  // Special handling for class/struct/interface types
299
- if (['class', 'struct', 'interface', 'type'].includes(def.type)) {
300
- const methods = index.findMethodsForType(name);
302
+ if (['class', 'struct', 'interface', 'type', 'enum', 'record', 'trait', 'namespace']
303
+ .includes(def.type)) {
304
+ const methods = index.findMethodsForType(name, def);
305
+ const members = (index.files.get(def.file)?.symbols || []).filter(symbol =>
306
+ symbol.className === def.name &&
307
+ ['field', 'constant', 'state'].includes(symbol.type));
301
308
 
302
309
  // Pin caller resolution to the resolved class definition — same as the
303
310
  // function path below. Without this, same-name classes in other files
@@ -332,6 +339,10 @@ function context(index, name, options = {}) {
332
339
  typeCallers = [...typeCallers].sort(byFileLine);
333
340
  typeUnverified = [...typeUnverified].sort(byFileLine);
334
341
 
342
+ const callerTotal = typeCallers.length;
343
+ if (options.maxCallers && options.maxCallers > 0) {
344
+ typeCallers = typeCallers.slice(0, options.maxCallers);
345
+ }
335
346
  const result = {
336
347
  type: def.type,
337
348
  name: name,
@@ -346,10 +357,16 @@ function context(index, name, options = {}) {
346
357
  returnType: m.returnType,
347
358
  receiver: m.receiver
348
359
  })),
360
+ members: members.map(member => ({
361
+ name: member.name,
362
+ type: member.type,
363
+ file: member.relativePath,
364
+ line: member.startLine,
365
+ })),
349
366
  // Also include places where the type is used in function parameters/returns
350
367
  callers: typeCallers,
351
368
  unverifiedCallers: typeUnverified,
352
- meta: { account: typeAccount }
369
+ meta: { account: typeAccount, callerTotal }
353
370
  };
354
371
 
355
372
  if (warnings.length > 0) {
@@ -434,11 +451,16 @@ function context(index, name, options = {}) {
434
451
  return (a.startLine || 0) - (b.startLine || 0);
435
452
  });
436
453
 
437
- // Trust signals: tag each caller/callee with reachability and build ordinal evidence histograms.
438
- // Reachability is computed once per index and cached (see entrypoints.computeReachability).
439
- const reachableSet = computeReachability(index);
440
- tagCallersReachable(callers, reachableSet);
441
- tagCalleesReachable(callees, reachableSet);
454
+ // Reachability is a whole-project BFS. Keep the default symbol query
455
+ // targeted; compute the enrichment only for the explicit unreachable
456
+ // filter, or validate/reuse an already available cache.
457
+ const reachableSet = (options.unreachableOnly || index._reachableSymbols)
458
+ ? computeReachability(index)
459
+ : null;
460
+ if (reachableSet) {
461
+ tagCallersReachable(callers, reachableSet);
462
+ tagCalleesReachable(callees, reachableSet);
463
+ }
442
464
 
443
465
  // Side-effect tags on callees (lazy-cached per symbol on the index)
444
466
  tagCalleesSideEffects(index, callees);
@@ -463,6 +485,14 @@ function context(index, name, options = {}) {
463
485
 
464
486
  const callerHistogram = buildHistogram(callers);
465
487
  const calleeHistogram = buildHistogram(callees);
488
+ const callerTotal = callers.length;
489
+ const calleeTotal = callees.length;
490
+ if (options.maxCallers && options.maxCallers > 0) {
491
+ callers = callers.slice(0, options.maxCallers);
492
+ }
493
+ if (options.maxCallees && options.maxCallees > 0) {
494
+ callees = callees.slice(0, options.maxCallees);
495
+ }
466
496
 
467
497
  const filesInScope = new Set([def.file]);
468
498
  callers.forEach(c => filesInScope.add(c.file));
@@ -496,9 +526,14 @@ function context(index, name, options = {}) {
496
526
  projectLanguage: index._getPredominantLanguage(),
497
527
  account,
498
528
  calleeAccount: rawCallees.calleeAccount,
529
+ callerTotal,
530
+ calleeTotal,
499
531
  // No detected entry points (e.g. library code) — reachability
500
- // markers are meaningless and suppressed by formatters.
501
- hasEntrypoints: reachableSet.size > 0,
532
+ // markers are meaningless and suppressed by formatters. Omit the
533
+ // field when the optional enrichment was not requested.
534
+ ...(reachableSet && {
535
+ hasEntrypoints: reachableSet.size > 0,
536
+ }),
502
537
  ...(options.all && { all: true }),
503
538
  // Structural facts for reliability hints
504
539
  ...(def.isMethod && { isMethod: true }),
@@ -627,6 +662,8 @@ function detectCompleteness(index) {
627
662
  let dynamicImports = 0;
628
663
  let evalUsage = 0;
629
664
  let reflectionUsage = 0;
665
+ let computedDispatchUsage = 0;
666
+ const computedDispatch = projectComputedDispatch(index);
630
667
 
631
668
  for (const [filePath, fileEntry] of index.files) {
632
669
  // Skip node_modules - we don't care about their patterns
@@ -650,6 +687,7 @@ function detectCompleteness(index) {
650
687
  const bs = countTextBlindspots(content, fileEntry.language);
651
688
  evalUsage += bs.eval;
652
689
  reflectionUsage += bs.reflection;
690
+ computedDispatchUsage += computedDispatch.get(filePath)?.length || 0;
653
691
  } catch (e) {
654
692
  // Skip unreadable files
655
693
  }
@@ -679,6 +717,14 @@ function detectCompleteness(index) {
679
717
  });
680
718
  }
681
719
 
720
+ if (computedDispatchUsage > 0) {
721
+ warnings.push({
722
+ type: 'computed_dispatch',
723
+ count: computedDispatchUsage,
724
+ message: `${computedDispatchUsage} computed dispatch call(s) detected - runtime-selected members are not statically attributable`,
725
+ });
726
+ }
727
+
682
728
  index._completenessCache = {
683
729
  complete: warnings.length === 0,
684
730
  warnings,
@@ -871,7 +917,7 @@ function related(index, name, options = {}) {
871
917
  function impact(index, name, options = {}) {
872
918
  index._beginOp();
873
919
  try {
874
- const { def } = index.resolveSymbol(name, { file: options.file, className: options.className, line: options.line });
920
+ const { def, warnings } = index.resolveSymbol(name, { file: options.file, className: options.className, line: options.line });
875
921
  if (!def) {
876
922
  return null;
877
923
  }
@@ -901,7 +947,6 @@ function impact(index, name, options = {}) {
901
947
  let impactAccountRaw = null;
902
948
  let impactRoutedUnverified = []; // engine-routed retained drops (unverifiedEntries)
903
949
  const impactClaims = [];
904
- const impactPostHocExcluded = [];
905
950
  const impactPostHocUnverified = [];
906
951
  if (options.className || defIsMethod || defIsTypeDef) {
907
952
  // findCallers has proper method call resolution (self/this, binding IDs, receiver checks)
@@ -958,7 +1003,6 @@ function impact(index, name, options = {}) {
958
1003
  });
959
1004
  impactAccountRaw = callerResults.accountRaw;
960
1005
  impactRoutedUnverified = callerResults.unverifiedEntries || [];
961
- const targetBindingId = def.bindingId;
962
1006
  // Convert findCallers results to the format expected by analyzeCallSite
963
1007
  const calls = callerResults.map(c => ({
964
1008
  file: c.file,
@@ -975,36 +1019,20 @@ function impact(index, name, options = {}) {
975
1019
  resolution: c.resolution,
976
1020
  tier: c.tier,
977
1021
  }));
978
- // Keep the same binding filter for backward compat (findCallers already handles this,
979
- // but cross-check with usages-based binding filter for safety)
980
- const filteredCalls = calls.filter(u => {
981
- const fileEntry = index.files.get(u.file);
982
- if (fileEntry && targetBindingId) {
983
- let localBindings = (fileEntry.bindings || []).filter(b => b.name === name);
984
- if (localBindings.length === 0 && langTraits(fileEntry.language)?.packageScope === 'directory') {
985
- const dir = path.dirname(u.file);
986
- for (const [fp, fe] of index.files) {
987
- if (fp !== u.file && path.dirname(fp) === dir) {
988
- const sibling = (fe.bindings || []).filter(b => b.name === name);
989
- localBindings = localBindings.concat(sibling);
990
- }
991
- }
992
- }
993
- if (localBindings.length > 0 && !localBindings.some(b => b.id === targetBindingId)) {
994
- impactPostHocExcluded.push({ file: u.file, line: u.line, reason: 'other-definition' });
995
- return false;
996
- }
997
- }
998
- return true;
999
- });
1000
- // (findCallers already handles binding resolution and scope-aware filtering)
1022
+ // findCallers already applied binding, receiver, module ownership, and
1023
+ // target-definition pinning. Do not second-guess it with a file-level
1024
+ // same-name binding scan: a wrapper method named `usages` can legally
1025
+ // call `searchModule.usages()`, and the receiver proves that this is the
1026
+ // imported standalone function rather than the local method. The old
1027
+ // post-hoc filter dropped that confirmed edge from impact while show
1028
+ // kept it, violating command parity.
1001
1029
 
1002
1030
  // Analyze each call site, filtering out method calls for non-method definitions
1003
1031
  callSites = [];
1004
1032
  const defFileEntry = index.files.get(def.file);
1005
1033
  const defLang = defFileEntry?.language;
1006
1034
  const targetDir = defLang === 'go' ? path.basename(path.dirname(def.file)) : null;
1007
- for (const call of filteredCalls) {
1035
+ for (const call of calls) {
1008
1036
  const analysis = index.analyzeCallSite(call, name);
1009
1037
  // BUG-H3: when includeMethods is true, keep method-style calls
1010
1038
  // (e.g. obj.findCallers() resolves to standalone findCallers via the
@@ -1083,14 +1111,22 @@ function impact(index, name, options = {}) {
1083
1111
  impactFilteredByFlag.exclude += beforeUnverified - unverifiedSites.length;
1084
1112
  }
1085
1113
 
1086
- // Trust signals: tag each call site with reachability and build an ordinal evidence histogram.
1087
- // It is computed BEFORE top-N truncation so the evidence profile reflects the full scope.
1088
- const impactReachable = computeReachability(index);
1089
- for (const site of filteredSites) {
1090
- if (site.callerFile && site.callerStartLine != null) {
1091
- site.reachable = impactReachable.has(symbolKey(site.callerFile, site.callerStartLine));
1114
+ // Reachability is a whole-project BFS and can dominate an otherwise
1115
+ // targeted impact query. Compute it only when the caller requested the
1116
+ // unreachable filter, or validate/reuse an already available cache.
1117
+ // Default impact still returns the complete evidence-tiered call-site
1118
+ // answer; `reachable` remains an optional enrichment.
1119
+ const impactReachable = (options.unreachableOnly || index._reachableSymbols)
1120
+ ? computeReachability(index)
1121
+ : null;
1122
+ if (impactReachable) {
1123
+ for (const site of filteredSites) {
1124
+ if (site.callerFile && site.callerStartLine != null) {
1125
+ site.reachable = impactReachable.has(
1126
+ symbolKey(site.callerFile, site.callerStartLine));
1127
+ }
1128
+ // Module-level sites: no verdict — import-time execution (fix #256).
1092
1129
  }
1093
- // Module-level sites: no verdict — import-time execution (fix #256).
1094
1130
  }
1095
1131
  if (options.unreachableOnly) {
1096
1132
  const before = filteredSites.length;
@@ -1111,7 +1147,7 @@ function impact(index, name, options = {}) {
1111
1147
  if (cl.tier === 'unverified') unverifiedEntries.push(cl);
1112
1148
  else confirmedEntries.push(cl);
1113
1149
  }
1114
- const excludedEntries = [...(impactAccountRaw?.excludedEntries || []), ...impactPostHocExcluded];
1150
+ const excludedEntries = impactAccountRaw?.excludedEntries || [];
1115
1151
  const filteredTotal = impactFilteredByFlag.exclude + impactFilteredByFlag.unreachableOnly;
1116
1152
  return buildAccount(index, name, {
1117
1153
  groundSet,
@@ -1186,7 +1222,7 @@ function impact(index, name, options = {}) {
1186
1222
  shownCallSites: filteredSites.length,
1187
1223
  unverifiedSites,
1188
1224
  account: impactAccount,
1189
- hasEntrypoints: impactReachable.size > 0,
1225
+ hasEntrypoints: !!impactReachable && impactReachable.size > 0,
1190
1226
  callerHistogram,
1191
1227
  // Stable ordering: files alphabetical, sites by line ascending. Documented contract.
1192
1228
  byFile: Array.from(byFile.entries())
@@ -1197,7 +1233,8 @@ function impact(index, name, options = {}) {
1197
1233
  sites: [...sites].sort((s1, s2) => (s1.line || 0) - (s2.line || 0))
1198
1234
  })),
1199
1235
  patterns,
1200
- scopeWarning
1236
+ scopeWarning,
1237
+ ...(warnings.length > 0 && { warnings }),
1201
1238
  };
1202
1239
  } finally { index._endOp(); }
1203
1240
  }
@@ -1316,6 +1353,7 @@ function about(index, name, options = {}) {
1316
1353
  let allCallees = null;
1317
1354
  let aboutConfFiltered = 0;
1318
1355
  let aboutAccount = null;
1356
+ let aboutReachable = null;
1319
1357
  let aboutUnverified = { total: 0, top: [] };
1320
1358
  // BUG-M3: include classes/structs/interfaces — `new Foo()` invocations are
1321
1359
  // tracked as calls in the parser (isConstructor:true) and findCallers resolves
@@ -1374,10 +1412,14 @@ function about(index, name, options = {}) {
1374
1412
  }
1375
1413
  const unverifiedShadowCount = shadowSurvivors.filter(s => s.tier === 'unverified').length;
1376
1414
  shadowSurvivors = shadowSurvivors.filter(s => s.tier !== 'unverified');
1377
- // Tag reachability on raw caller objects so we can preserve the field on the projection.
1378
- // Reachability is computed once per index and cached.
1379
- const aboutReachable = computeReachability(index);
1380
- tagCallersReachable(allCallers, aboutReachable);
1415
+ // Whole-project reachability is optional enrichment. Compute it only
1416
+ // for the explicit filter, or validate/reuse an existing cache.
1417
+ aboutReachable = (options.unreachableOnly || index._reachableSymbols)
1418
+ ? computeReachability(index)
1419
+ : null;
1420
+ if (aboutReachable) {
1421
+ tagCallersReachable(allCallers, aboutReachable);
1422
+ }
1381
1423
 
1382
1424
  // Optional: filter to unreachable-only callers
1383
1425
  if (options.unreachableOnly) {
@@ -1491,7 +1533,9 @@ function about(index, name, options = {}) {
1491
1533
  }
1492
1534
 
1493
1535
  // Tag callee reachability + optional unreachable-only filter
1494
- tagCalleesReachable(allCallees, aboutReachable);
1536
+ if (aboutReachable) {
1537
+ tagCalleesReachable(allCallees, aboutReachable);
1538
+ }
1495
1539
  if (options.unreachableOnly) {
1496
1540
  allCallees = allCallees.filter(c => !c.reachable);
1497
1541
  }
@@ -1639,7 +1683,9 @@ function about(index, name, options = {}) {
1639
1683
  code,
1640
1684
  includeMethods,
1641
1685
  ...(aboutAccount && { account: aboutAccount }),
1642
- ...(allCallers && { hasEntrypoints: computeReachability(index).size > 0 }),
1686
+ ...(allCallers && aboutReachable && {
1687
+ hasEntrypoints: aboutReachable.size > 0,
1688
+ }),
1643
1689
  ...(aboutConfFiltered > 0 && { confidenceFiltered: aboutConfFiltered }),
1644
1690
  // BUG-M4: surface ambiguous-resolution warnings so formatters can render
1645
1691
  // a "auto-selected ... pass --file to choose" note.
@@ -1684,7 +1730,7 @@ function diffImpact(index, options = {}) {
1684
1730
  try {
1685
1731
  gitRoot = execFileSync('git', ['rev-parse', '--show-toplevel'], { cwd: index.root, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
1686
1732
  } catch (e) {
1687
- throw new Error('Not a git repository. diff-impact requires git.', { cause: e });
1733
+ throw new Error('Not a git repository. impact without a symbol requires git.', { cause: e });
1688
1734
  }
1689
1735
 
1690
1736
  // Build git diff command (use execFileSync to avoid shell expansion)
@@ -2272,7 +2318,9 @@ function parseDiff(diffText, root) {
2272
2318
  // Languages for which audit-async runs (those with async/await keyword we
2273
2319
  // track). Go/Java/Rust have async machinery but audit-async is scoped to
2274
2320
  // JS/TS/Python per spec.
2275
- const _AUDIT_ASYNC_LANGS = new Set(['javascript', 'typescript', 'tsx', 'python', 'html']);
2321
+ const _AUDIT_ASYNC_LANGS = new Set([
2322
+ 'javascript', 'typescript', 'tsx', 'python', 'html', 'csharp',
2323
+ ]);
2276
2324
 
2277
2325
  // Built-in/standard-library callees that return promises and are commonly
2278
2326
  // missing-awaited. Conservative starter set (rule #9 — generic, not
@@ -2293,6 +2341,9 @@ const _KNOWN_ASYNC_CALLEES = new Set([
2293
2341
  // - void <expr>
2294
2342
  // - <expr>.then() / .catch() (the call provides its own handler)
2295
2343
  const _FIRE_AND_FORGET_PROMISE_FNS = new Set(['all', 'allSettled', 'race', 'any']);
2344
+ const _ASYNCIO_CONSUMER_FNS = new Set([
2345
+ 'gather', 'create_task', 'ensure_future', 'wait', 'as_completed',
2346
+ ]);
2296
2347
 
2297
2348
  /**
2298
2349
  * Run an async/await audit across the project.
@@ -2309,7 +2360,7 @@ const _FIRE_AND_FORGET_PROMISE_FNS = new Set(['all', 'allSettled', 'race', 'any'
2309
2360
  * to a variable — these are intentional non-await uses).
2310
2361
  *
2311
2362
  * Detection is AST-based per language; the language must support an
2312
- * `await` keyword (JS/TS/Python). Other languages are skipped.
2363
+ * `await` keyword (JS/TS/Python/C#). Other languages are skipped.
2313
2364
  *
2314
2365
  * @param {object} index - ProjectIndex instance
2315
2366
  * @param {object} [options] - { file, exclude }
@@ -2318,7 +2369,7 @@ const _FIRE_AND_FORGET_PROMISE_FNS = new Set(['all', 'allSettled', 'race', 'any'
2318
2369
  function auditAsync(index, options = {}) {
2319
2370
  index._beginOp();
2320
2371
  try {
2321
- const { getParser, getLanguageModule, safeParse } = require('../languages');
2372
+ const { getParser, getLanguageAdapter, safeParse } = require('../languages');
2322
2373
  const issues = [];
2323
2374
 
2324
2375
  // Build a "is this name provably async" lookup from the symbol table.
@@ -2380,8 +2431,10 @@ function auditAsync(index, options = {}) {
2380
2431
  if (obj && prop) {
2381
2432
  const objText = obj.text;
2382
2433
  const propText = prop.text;
2383
- if ((objText === 'Promise' || objText === 'asyncio') &&
2384
- _FIRE_AND_FORGET_PROMISE_FNS.has(propText)) {
2434
+ if ((objText === 'Promise' &&
2435
+ _FIRE_AND_FORGET_PROMISE_FNS.has(propText)) ||
2436
+ (objText === 'asyncio' &&
2437
+ _ASYNCIO_CONSUMER_FNS.has(propText))) {
2385
2438
  return true;
2386
2439
  }
2387
2440
  // .then(...) / .catch(...) — caller is providing a handler;
@@ -2430,6 +2483,9 @@ function auditAsync(index, options = {}) {
2430
2483
  function processFile(filePath, fileEntry) {
2431
2484
  if (!fileEntry || !_AUDIT_ASYNC_LANGS.has(fileEntry.language)) return;
2432
2485
  const language = fileEntry.language;
2486
+ const indexedCalls = language === 'csharp'
2487
+ ? index.getCachedCalls(filePath) || []
2488
+ : [];
2433
2489
 
2434
2490
  // Collect async functions from the file's symbol list.
2435
2491
  // Also build a per-file set of names that are async in THIS file —
@@ -2465,7 +2521,7 @@ function auditAsync(index, options = {}) {
2465
2521
  let parser, content, tree;
2466
2522
  try {
2467
2523
  if (language === 'html') {
2468
- const htmlModule = getLanguageModule('html');
2524
+ const htmlModule = getLanguageAdapter('html');
2469
2525
  const htmlParser = getParser('html');
2470
2526
  const jsParser = getParser('javascript');
2471
2527
  if (!htmlParser || !jsParser) return;
@@ -2484,7 +2540,10 @@ function auditAsync(index, options = {}) {
2484
2540
  if (!tree) return;
2485
2541
 
2486
2542
  // Walk every call_expression within an async function range.
2487
- const callTypes = new Set(['call_expression', 'call', 'method_invocation', 'object_creation_expression']);
2543
+ const callTypes = new Set([
2544
+ 'call_expression', 'call', 'method_invocation',
2545
+ 'invocation_expression', 'object_creation_expression',
2546
+ ]);
2488
2547
 
2489
2548
  // Function-boundary nodes per language (used to find the nearest
2490
2549
  // enclosing function and determine if IT is async — not just any
@@ -2495,6 +2554,8 @@ function auditAsync(index, options = {}) {
2495
2554
  tsx: new Set(['function_declaration', 'function_expression', 'arrow_function', 'method_definition', 'generator_function', 'generator_function_declaration', 'function_signature']),
2496
2555
  html: new Set(['function_declaration', 'function_expression', 'arrow_function', 'method_definition', 'generator_function', 'generator_function_declaration']),
2497
2556
  python: new Set(['function_definition', 'async_function_definition', 'lambda']),
2557
+ csharp: new Set(['method_declaration', 'local_function_statement',
2558
+ 'anonymous_method_expression', 'lambda_expression']),
2498
2559
  }[language] || new Set();
2499
2560
 
2500
2561
  function isAsyncFnNode(node) {
@@ -2509,7 +2570,9 @@ function auditAsync(index, options = {}) {
2509
2570
  // method_definition: scan first child for 'async' identifier.
2510
2571
  for (let i = 0; i < node.namedChildCount; i++) {
2511
2572
  const c = node.namedChild(i);
2512
- if (c.type === 'async') return true;
2573
+ if (c.type === 'async' || (c.type === 'modifier' && c.text === 'async')) {
2574
+ return true;
2575
+ }
2513
2576
  }
2514
2577
  return false;
2515
2578
  }
@@ -2558,7 +2621,8 @@ function auditAsync(index, options = {}) {
2558
2621
  funcNode.type === 'selector_expression' || funcNode.type === 'field_expression') {
2559
2622
  const prop = funcNode.childForFieldName('property') ||
2560
2623
  funcNode.childForFieldName('field') ||
2561
- funcNode.childForFieldName('attribute');
2624
+ funcNode.childForFieldName('attribute') ||
2625
+ funcNode.childForFieldName('name');
2562
2626
  calleeName = prop ? prop.text : null;
2563
2627
  isMethodCall = true;
2564
2628
  } else {
@@ -2575,7 +2639,31 @@ function auditAsync(index, options = {}) {
2575
2639
  // in unrelated.js — bad.js's helper() should
2576
2640
  // still be flagged).
2577
2641
  let calleeIsAsync;
2578
- if (fileAsyncNames.has(calleeName)) {
2642
+ if (language === 'csharp') {
2643
+ // C# method identity is nominal. Prefer the
2644
+ // indexed receiver type at this call site;
2645
+ // fall back only when every project
2646
+ // definition with the name is async.
2647
+ const indexed = indexedCalls.find(call =>
2648
+ call.name === calleeName && call.line === line &&
2649
+ call.isMethod === isMethodCall);
2650
+ let receiverType = indexed?.receiverType || null;
2651
+ if (!receiverType && indexed?.receiverField &&
2652
+ indexed?.receiverRootType) {
2653
+ const field = (fileEntry.symbols || []).find(symbol =>
2654
+ symbol.type === 'field' &&
2655
+ symbol.className === indexed.receiverRootType &&
2656
+ symbol.name === indexed.receiverField);
2657
+ receiverType = field?.fieldType || null;
2658
+ }
2659
+ const receiverDefs = receiverType
2660
+ ? callableDefs(index.symbols.get(calleeName) || [])
2661
+ .filter(def => def.className === receiverType)
2662
+ : [];
2663
+ calleeIsAsync = receiverDefs.length > 0
2664
+ ? receiverDefs.every(isDefAsync)
2665
+ : asyncNames.has(calleeName);
2666
+ } else if (fileAsyncNames.has(calleeName)) {
2579
2667
  calleeIsAsync = true;
2580
2668
  } else if (fileAnyDefNames.has(calleeName)) {
2581
2669
  // Same-file def exists and isn't async →
@@ -2595,7 +2683,7 @@ function auditAsync(index, options = {}) {
2595
2683
  // class's async `get`. Method-call audits
2596
2684
  // need a more sophisticated receiver
2597
2685
  // resolution that we don't have here.
2598
- if (isMethodCall) {
2686
+ if (isMethodCall && language !== 'csharp') {
2599
2687
  // (Allow only when callee is in the
2600
2688
  // KNOWN_ASYNC_CALLEES list — those are
2601
2689
  // standard global functions, not
@@ -2606,12 +2694,34 @@ function auditAsync(index, options = {}) {
2606
2694
  // Fall through to common flag logic
2607
2695
  }
2608
2696
  }
2609
- if (!isMethodCall || _KNOWN_ASYNC_CALLEES.has(calleeName)) {
2697
+ if (!isMethodCall || language === 'csharp' ||
2698
+ _KNOWN_ASYNC_CALLEES.has(calleeName)) {
2610
2699
  // Check: is the call awaited?
2611
2700
  let awaited = false;
2612
- const p = node.parent;
2613
- if (p && (p.type === 'await_expression' || p.type === 'await')) {
2614
- awaited = true;
2701
+ let current = node.parent;
2702
+ let awaitDepth = 0;
2703
+ while (current && awaitDepth++ < 5) {
2704
+ if (current.type === 'await_expression' ||
2705
+ current.type === 'await') {
2706
+ awaited = true;
2707
+ break;
2708
+ }
2709
+ // C#'s canonical
2710
+ // `await Task().ConfigureAwait(false)`
2711
+ // wraps the original invocation in
2712
+ // a member-access + invocation
2713
+ // chain before the await node.
2714
+ if (language === 'csharp' && [
2715
+ 'member_access_expression',
2716
+ 'invocation_expression',
2717
+ 'conditional_access_expression',
2718
+ 'member_binding_expression',
2719
+ 'parenthesized_expression',
2720
+ ].includes(current.type)) {
2721
+ current = current.parent;
2722
+ continue;
2723
+ }
2724
+ break;
2615
2725
  }
2616
2726
  if (!awaited && !isFireAndForget(node, language)) {
2617
2727
  issues.push({