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.
- package/.claude/skills/ucn/SKILL.md +89 -77
- package/.claude/skills/ucn/references/commands.md +62 -68
- package/.claude/skills/ucn/references/trust-contract.md +31 -6
- package/README.md +438 -305
- package/assets/demo.svg +31 -0
- package/cli/index.js +430 -1385
- package/core/account.js +144 -34
- package/core/analysis.js +182 -72
- package/core/ast-analysis.js +279 -0
- package/core/bridge.js +205 -24
- package/core/brief.js +27 -58
- package/core/build-worker.js +21 -140
- package/core/cache.js +513 -11
- package/core/callers.js +4920 -456
- package/core/check.js +13 -4
- package/core/command-contracts.js +402 -0
- package/core/compilation-database.js +276 -0
- package/core/confidence.js +4 -1
- package/core/deadcode.js +397 -19
- package/core/discovery.js +359 -46
- package/core/entrypoints.js +195 -41
- package/core/execute.js +887 -81
- package/core/graph-build.js +162 -7
- package/core/graph.js +53 -77
- package/core/imports.js +65 -6
- package/core/index-ir.js +138 -0
- package/core/ir.js +195 -0
- package/core/output/analysis.js +212 -22
- package/core/output/brief.js +23 -0
- package/core/output/check.js +4 -0
- package/core/output/doctor.js +37 -6
- package/core/output/endpoints.js +5 -2
- package/core/output/extraction.js +24 -12
- package/core/output/find.js +141 -36
- package/core/output/graph.js +11 -5
- package/core/output/public.js +462 -0
- package/core/output/refactoring.js +42 -10
- package/core/output/reporting.js +97 -20
- package/core/output/search.js +24 -16
- package/core/output/shared.js +22 -1
- package/core/output/tracing.js +30 -15
- package/core/output-budget.js +295 -0
- package/core/output.js +1 -0
- package/core/parallel-build.js +44 -11
- package/core/parser.js +3 -3
- package/core/project.js +384 -187
- package/core/public-command.js +47 -0
- package/core/registry.js +247 -117
- package/core/reporting.js +312 -290
- package/core/search.js +317 -185
- package/core/semantic-provider.js +110 -0
- package/core/stacktrace.js +25 -0
- package/core/tracing.js +101 -51
- package/core/trust-matrix.js +19 -40
- package/core/verify.js +534 -37
- package/languages/adapter.js +218 -0
- package/languages/c-family.js +2791 -0
- package/languages/c.js +3 -0
- package/languages/cpp.js +3 -0
- package/languages/csharp.js +1402 -0
- package/languages/go.js +60 -21
- package/languages/html.js +2 -2
- package/languages/index.js +85 -7
- package/languages/java.js +396 -13
- package/languages/javascript.js +199 -19
- package/languages/python.js +964 -22
- package/languages/rust.js +1317 -152
- package/languages/utils.js +40 -3
- package/mcp/server.js +254 -636
- package/package.json +39 -22
- package/eslint.config.js +0 -43
- 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 {
|
|
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 =
|
|
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.
|
|
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']
|
|
300
|
-
|
|
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
|
-
//
|
|
438
|
-
//
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
979
|
-
//
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
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
|
|
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
|
-
//
|
|
1087
|
-
//
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
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 =
|
|
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
|
-
//
|
|
1378
|
-
//
|
|
1379
|
-
|
|
1380
|
-
|
|
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
|
-
|
|
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 &&
|
|
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.
|
|
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([
|
|
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,
|
|
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'
|
|
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 =
|
|
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([
|
|
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')
|
|
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 (
|
|
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 ||
|
|
2697
|
+
if (!isMethodCall || language === 'csharp' ||
|
|
2698
|
+
_KNOWN_ASYNC_CALLEES.has(calleeName)) {
|
|
2610
2699
|
// Check: is the call awaited?
|
|
2611
2700
|
let awaited = false;
|
|
2612
|
-
|
|
2613
|
-
|
|
2614
|
-
|
|
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({
|