@nudojs/service 0.2.1 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +30 -0
- package/dist/index.d.ts +502 -4
- package/dist/index.js +3017 -212
- package/package.json +5 -4
- package/src/__tests__/abs-bindings.test.ts +53 -0
- package/src/__tests__/abs-call-records.test.ts +33 -0
- package/src/__tests__/abs-calls.test.ts +59 -0
- package/src/__tests__/abs-module-cache.test.ts +121 -0
- package/src/__tests__/abs-modules-graph.test.ts +100 -0
- package/src/__tests__/abs-typevalue-diff.test.ts +111 -0
- package/src/__tests__/analyzer.test.ts +71 -3
- package/src/__tests__/bpath-arg-provenance.test.ts +104 -0
- package/src/__tests__/bpath-as.test.ts +54 -0
- package/src/__tests__/bpath-cache-key.test.ts +33 -0
- package/src/__tests__/bpath-calls.test.ts +55 -0
- package/src/__tests__/bpath-diagnostics.test.ts +78 -0
- package/src/__tests__/bpath-env-modules.test.ts +30 -0
- package/src/__tests__/bpath-env.test.ts +80 -0
- package/src/__tests__/bpath-hosted-skip.test.ts +71 -0
- package/src/__tests__/bpath-import-default-ns.test.ts +106 -0
- package/src/__tests__/bpath-method-dedupe.test.ts +65 -0
- package/src/__tests__/bpath-method-diag.test.ts +45 -0
- package/src/__tests__/bpath-module-diags.test.ts +101 -0
- package/src/__tests__/bpath-path-env.test.ts +46 -0
- package/src/__tests__/bpath-reexport.test.ts +131 -0
- package/src/__tests__/bpath-replace.test.ts +52 -0
- package/src/__tests__/bpath-require.test.ts +66 -0
- package/src/__tests__/bpath-run.test.ts +109 -0
- package/src/__tests__/cjs-require.test.ts +54 -0
- package/src/__tests__/diagnostics.test.ts +0 -30
- package/src/__tests__/fn-cache-evict.test.ts +56 -0
- package/src/__tests__/harvest-auto.test.ts +43 -0
- package/src/__tests__/harvest-node.test.ts +24 -0
- package/src/__tests__/harvest-package.test.ts +38 -0
- package/src/__tests__/harvest-to-abs.test.ts +46 -0
- package/src/__tests__/hover-intension.test.ts +39 -0
- package/src/__tests__/infer-json.test.ts +35 -0
- package/src/__tests__/integration.test.ts +89 -0
- package/src/__tests__/m3-intension.test.ts +28 -0
- package/src/__tests__/mock-seed-fingerprint.test.ts +64 -0
- package/src/__tests__/session-cache-evict.test.ts +90 -0
- package/src/__tests__/static-imports.test.ts +44 -0
- package/src/abs-modules-graph.ts +387 -0
- package/src/analysis-file-cache.ts +51 -0
- package/src/analyzer.ts +1416 -124
- package/src/bpath-diagnostics.ts +283 -0
- package/src/bpath-run.ts +409 -0
- package/src/case-emitter.ts +2 -0
- package/src/dts-generator.ts +3 -3
- package/src/env-to-abs.ts +57 -0
- package/src/fn-analysis-cache.ts +119 -0
- package/src/guard-generator.ts +8 -1
- package/src/harvest-auto.ts +86 -0
- package/src/harvest-node.ts +48 -0
- package/src/harvest-package.ts +204 -0
- package/src/harvest-to-abs.ts +129 -0
- package/src/index.ts +104 -1
- package/src/infer-json.ts +137 -0
- package/src/load-module.ts +34 -0
- package/src/mock-abs.ts +275 -0
- package/src/semantic-tokens.ts +216 -0
- package/src/session-cache.ts +50 -0
- package/src/static-imports.ts +241 -0
- package/src/target-path.ts +17 -0
package/src/analyzer.ts
CHANGED
|
@@ -13,6 +13,25 @@ import {
|
|
|
13
13
|
isSubtypeOf,
|
|
14
14
|
type Environment,
|
|
15
15
|
mockHelperToTypeValue,
|
|
16
|
+
getFnSig,
|
|
17
|
+
generalizeFromAst,
|
|
18
|
+
termToString,
|
|
19
|
+
predToString,
|
|
20
|
+
analyzeFn,
|
|
21
|
+
evalProgramAbs,
|
|
22
|
+
setAbsCallCollector,
|
|
23
|
+
setAbsNodeCollector,
|
|
24
|
+
absFunction,
|
|
25
|
+
type AbsCallRecord,
|
|
26
|
+
collectAbsNodeTypes,
|
|
27
|
+
findAbsAtPosition,
|
|
28
|
+
typeValueToAbs,
|
|
29
|
+
absToTypeValue,
|
|
30
|
+
formatAbs,
|
|
31
|
+
formatAbsMultiline,
|
|
32
|
+
type Abs,
|
|
33
|
+
stableAnalyzeKeySource,
|
|
34
|
+
fnFingerprints,
|
|
16
35
|
} from "@nudojs/core";
|
|
17
36
|
import { parse, extractDirectives, extractFileDirectives, parseTypeValueExpr } from "@nudojs/parser";
|
|
18
37
|
import type { FunctionWithDirectives, SinonExpression } from "@nudojs/parser";
|
|
@@ -46,7 +65,25 @@ import {
|
|
|
46
65
|
preloadPathEnvs,
|
|
47
66
|
findProjectConfig,
|
|
48
67
|
resolveNpmNudo,
|
|
68
|
+
BUILTIN_PROTOTYPE_METHOD_APPROXIMATIONS,
|
|
49
69
|
} from "@nudojs/cli/evaluator";
|
|
70
|
+
import { mockDirectivesToAbsSeeds, mockSeedsToAbsMocks } from "./mock-abs.ts";
|
|
71
|
+
import { autoHarvestModules } from "./harvest-auto.ts";
|
|
72
|
+
import { evalAbsModuleGraph, collectAbsBindingsFromGraph, evalProgramAbsWithModules } from "./abs-modules-graph.ts";
|
|
73
|
+
import { tryBPathCall, tryBPathCallFull, tryRunBPath, isBPathCapable, mockSeedFingerprint, collectEnvGlobals } from "./bpath-run.ts";
|
|
74
|
+
import { collectBPathDiagnostics } from "./bpath-diagnostics.ts";
|
|
75
|
+
import { setAbsTruncationCollector } from "@nudojs/core";
|
|
76
|
+
import {
|
|
77
|
+
analysisCacheGet,
|
|
78
|
+
analysisCacheSet,
|
|
79
|
+
} from "./analysis-file-cache.ts";
|
|
80
|
+
import {
|
|
81
|
+
fnAnalysisCacheGet,
|
|
82
|
+
fnAnalysisCacheSet,
|
|
83
|
+
caseDirectiveKey,
|
|
84
|
+
type CachedFnAnalysis,
|
|
85
|
+
} from "./fn-analysis-cache.ts";
|
|
86
|
+
export { clearFnAnalysisCache } from "./fn-analysis-cache.ts";
|
|
50
87
|
|
|
51
88
|
export type SourceLocation = {
|
|
52
89
|
start: { line: number; column: number };
|
|
@@ -76,8 +113,26 @@ export type CaseResult = {
|
|
|
76
113
|
throws: TypeValue;
|
|
77
114
|
throwLoc?: SourceLocation;
|
|
78
115
|
source?: "directive" | "callsite";
|
|
116
|
+
/** `@nudo:case "name" (…) => expected` — presence means the case is a test assertion */
|
|
117
|
+
expected?: TypeValue;
|
|
79
118
|
/** number of additional call sites folded into a symbolic case */
|
|
80
119
|
aggregatedFrom?: number;
|
|
120
|
+
/**
|
|
121
|
+
* 内涵摘要(代数 generalize):无损 Abs + term/pred/conf。
|
|
122
|
+
* 仅在 entry@ / call@(Abs 路径)填充;外延 TypeValue 不变。
|
|
123
|
+
*/
|
|
124
|
+
intension?: {
|
|
125
|
+
display?: string;
|
|
126
|
+
term?: string;
|
|
127
|
+
pred?: string;
|
|
128
|
+
conf?: string;
|
|
129
|
+
/** 无损 Abs 单行(formatAbs) */
|
|
130
|
+
abs?: string;
|
|
131
|
+
/** 无损 Abs 多行(formatAbsMultiline) */
|
|
132
|
+
absMultiline?: string;
|
|
133
|
+
};
|
|
134
|
+
/** 无损 Abs(denote/守卫用);仅 Abs 路径 case 填充 */
|
|
135
|
+
abs?: Abs;
|
|
81
136
|
};
|
|
82
137
|
|
|
83
138
|
export type FunctionAnalysis = {
|
|
@@ -86,7 +141,6 @@ export type FunctionAnalysis = {
|
|
|
86
141
|
paramNames: string[];
|
|
87
142
|
cases: CaseResult[];
|
|
88
143
|
combined?: TypeValue;
|
|
89
|
-
assertionErrors?: string[];
|
|
90
144
|
entryOnly?: boolean;
|
|
91
145
|
skipped?: boolean;
|
|
92
146
|
/**
|
|
@@ -115,6 +169,12 @@ export type AnalysisResult = {
|
|
|
115
169
|
functions: FunctionAnalysis[];
|
|
116
170
|
diagnostics: Diagnostic[];
|
|
117
171
|
bindings: Map<string, BindingInfo>;
|
|
172
|
+
/**
|
|
173
|
+
* Node identity is from the analysis-time parse. A later re-parse of the
|
|
174
|
+
* same source returns a *different* File (new Nodes) if the AST LRU evicted
|
|
175
|
+
* the original — lookups must use the File that produced this result, not a
|
|
176
|
+
* freshly parsed one. getTypeAtPosition rebuilds its own map and is unaffected.
|
|
177
|
+
*/
|
|
118
178
|
nodeTypeMap: Map<Node, TypeValue>;
|
|
119
179
|
caseHints: CaseHint[];
|
|
120
180
|
/** functions imported from other modules, synthesized from cross-file call sites observed while analyzing this file */
|
|
@@ -1032,7 +1092,115 @@ export function collectCallRecords(filePath: string, source: string): CallRecord
|
|
|
1032
1092
|
/** 测试框架的回调注册函数:回调体里是真实调用点 */
|
|
1033
1093
|
const TEST_CALLBACK_NAMES = new Set(["it", "test", "describe"]);
|
|
1034
1094
|
|
|
1095
|
+
// --- 整文件 AnalysisResult memo(warm analyzeFile / LSP 重复文档) ---
|
|
1096
|
+
|
|
1097
|
+
const externalRecordIds = new WeakMap<object, number>();
|
|
1098
|
+
let nextExternalRecordId = 1;
|
|
1099
|
+
|
|
1100
|
+
function analysisFileCacheKey(
|
|
1101
|
+
filePath: string,
|
|
1102
|
+
source: string,
|
|
1103
|
+
activeCases?: Map<string, number>,
|
|
1104
|
+
externalCallRecords?: CallRecord[],
|
|
1105
|
+
): { filePath: string; source: string; auxKey: string } {
|
|
1106
|
+
let cases = "-";
|
|
1107
|
+
if (activeCases && activeCases.size > 0) {
|
|
1108
|
+
cases = [...activeCases.entries()]
|
|
1109
|
+
.sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0))
|
|
1110
|
+
.map(([k, v]) => `${k}=${v}`)
|
|
1111
|
+
.join(",");
|
|
1112
|
+
}
|
|
1113
|
+
let ext = "-";
|
|
1114
|
+
if (externalCallRecords && externalCallRecords.length > 0) {
|
|
1115
|
+
let id = externalRecordIds.get(externalCallRecords);
|
|
1116
|
+
if (id === undefined) {
|
|
1117
|
+
id = nextExternalRecordId++;
|
|
1118
|
+
externalRecordIds.set(externalCallRecords, id);
|
|
1119
|
+
}
|
|
1120
|
+
ext = `n${externalCallRecords.length}#id${id}`;
|
|
1121
|
+
}
|
|
1122
|
+
return {
|
|
1123
|
+
filePath,
|
|
1124
|
+
// 尾部无 @nudo 注释/空行不进键:comment-only 编辑命中 AnalysisResult
|
|
1125
|
+
source: stableAnalyzeKeySource(source),
|
|
1126
|
+
auxKey: `${cases}\0${ext}`,
|
|
1127
|
+
};
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
function cloneAnalysisResult(r: AnalysisResult): AnalysisResult {
|
|
1131
|
+
return {
|
|
1132
|
+
functions: r.functions.map(cloneFunctionAnalysis),
|
|
1133
|
+
diagnostics: r.diagnostics.map((d) => ({ ...d })),
|
|
1134
|
+
bindings: new Map(r.bindings),
|
|
1135
|
+
// Node 键与 AST LRU 共享身份;Map 浅拷贝即可
|
|
1136
|
+
nodeTypeMap: new Map(r.nodeTypeMap),
|
|
1137
|
+
caseHints: r.caseHints.map((h) => ({ ...h })),
|
|
1138
|
+
...(r.externalFunctions
|
|
1139
|
+
? { externalFunctions: r.externalFunctions.map(cloneFunctionAnalysis) }
|
|
1140
|
+
: {}),
|
|
1141
|
+
};
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
// --- 函数级 FunctionAnalysis 缓存(body-edit:只重算脏函数及其调用者) ---
|
|
1145
|
+
|
|
1146
|
+
function cloneFunctionAnalysis(a: FunctionAnalysis): FunctionAnalysis {
|
|
1147
|
+
return {
|
|
1148
|
+
...a,
|
|
1149
|
+
paramNames: [...a.paramNames],
|
|
1150
|
+
cases: a.cases.map((c) => ({
|
|
1151
|
+
...c,
|
|
1152
|
+
args: [...c.args],
|
|
1153
|
+
...(c.intension ? { intension: { ...c.intension } } : {}),
|
|
1154
|
+
})),
|
|
1155
|
+
loc: { start: { ...a.loc.start }, end: { ...a.loc.end } },
|
|
1156
|
+
};
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
function shiftSourceLoc(loc: SourceLocation, lineDelta: number): SourceLocation {
|
|
1160
|
+
return {
|
|
1161
|
+
start: { line: loc.start.line + lineDelta, column: loc.start.column },
|
|
1162
|
+
end: { line: loc.end.line + lineDelta, column: loc.end.column },
|
|
1163
|
+
};
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
function shiftDiagnosticLines(d: Diagnostic, lineDelta: number): Diagnostic {
|
|
1167
|
+
if (lineDelta === 0) return { ...d };
|
|
1168
|
+
const out: Diagnostic = { ...d, range: shiftSourceLoc(d.range, lineDelta) };
|
|
1169
|
+
if (d.origin) {
|
|
1170
|
+
out.origin = { line: d.origin.line + lineDelta, column: d.origin.column };
|
|
1171
|
+
}
|
|
1172
|
+
return out;
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
function shiftCallRecordLines(r: CallRecord, lineDelta: number): CallRecord {
|
|
1176
|
+
if (lineDelta === 0 || !r.callLoc) return { ...r };
|
|
1177
|
+
return {
|
|
1178
|
+
...r,
|
|
1179
|
+
callLoc: { line: r.callLoc.line + lineDelta, column: r.callLoc.column },
|
|
1180
|
+
};
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
/**
|
|
1184
|
+
* 整文件分析。同 (path, source, cases, external) 命中 memo → O(1)。
|
|
1185
|
+
* 不再每次 clearBPathCache:B 路径按本文件 source 键控。
|
|
1186
|
+
*
|
|
1187
|
+
* 宿主契约:入口 source 未变但依赖模块内容变了时,必须调用
|
|
1188
|
+
* `evictBPathCacheForFiles` / `evictAnalysisFileCacheForFiles` /
|
|
1189
|
+
* `evictFnAnalysisCacheForFiles`(LSP 已接好)。非 LSP 宿主
|
|
1190
|
+
* (CLI watch / vite-plugin)在 dep 变更时应 `clearBPathCache()` 或上述逐出。
|
|
1191
|
+
*/
|
|
1035
1192
|
export function analyzeFile(filePath: string, source: string, activeCases?: Map<string, number>, externalCallRecords?: CallRecord[]): AnalysisResult {
|
|
1193
|
+
const k = analysisFileCacheKey(filePath, source, activeCases, externalCallRecords);
|
|
1194
|
+
const hit = analysisCacheGet<AnalysisResult>(k.filePath, k.source, k.auxKey);
|
|
1195
|
+
if (hit !== undefined) {
|
|
1196
|
+
return cloneAnalysisResult(hit);
|
|
1197
|
+
}
|
|
1198
|
+
const result = analyzeFileUncached(filePath, source, activeCases, externalCallRecords);
|
|
1199
|
+
analysisCacheSet(k.filePath, k.source, k.auxKey, result);
|
|
1200
|
+
return cloneAnalysisResult(result);
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
function analyzeFileUncached(filePath: string, source: string, activeCases?: Map<string, number>, externalCallRecords?: CallRecord[]): AnalysisResult {
|
|
1036
1204
|
// 外部实参里的闭包在使用现场文件定义——先打 usage-site 标记再进入任何
|
|
1037
1205
|
// 求值(case 合成重求值会执行它们,泄漏的错误记录靠此标记丢弃)。
|
|
1038
1206
|
for (const rec of externalCallRecords ?? []) {
|
|
@@ -1064,6 +1232,8 @@ export function analyzeFile(filePath: string, source: string, activeCases?: Map<
|
|
|
1064
1232
|
const envNames = [...new Set([...projectEnvNames, ...fileEnvNames])];
|
|
1065
1233
|
|
|
1066
1234
|
setUnknownBuiltinHandler((name, loc) => {
|
|
1235
|
+
// B 路径可分析时由静态收集器接管,避免双报
|
|
1236
|
+
if (isBPathCapable(source, envNames)) return;
|
|
1067
1237
|
diagnostics.push({
|
|
1068
1238
|
range: loc ? { start: loc.start, end: loc.end } : { start: { line: 0, column: 0 }, end: { line: 0, column: 0 } },
|
|
1069
1239
|
severity: "warning",
|
|
@@ -1071,16 +1241,18 @@ export function analyzeFile(filePath: string, source: string, activeCases?: Map<
|
|
|
1071
1241
|
code: "nudo:builtin-unknown",
|
|
1072
1242
|
suggestions: [
|
|
1073
1243
|
`Use @nudo:mock to define the type: @nudo:mock ${name} = stub().returns(...)`,
|
|
1074
|
-
`Or use @nudo:
|
|
1244
|
+
`Or use @nudo:refine return <constraint> to declare the return contract`,
|
|
1075
1245
|
],
|
|
1076
1246
|
});
|
|
1077
1247
|
});
|
|
1078
1248
|
|
|
1079
1249
|
const globalEnv = createEnvironment();
|
|
1080
1250
|
|
|
1081
|
-
|
|
1082
|
-
const loaded = loadEnvs(envNames, globalEnv);
|
|
1083
|
-
|
|
1251
|
+
{
|
|
1252
|
+
const loaded = envNames.length > 0 ? loadEnvs(envNames, globalEnv) : { modules: {} };
|
|
1253
|
+
const auto = autoHarvestModules(source, dirname(filePath));
|
|
1254
|
+
const modules = { ...loaded.modules, ...auto };
|
|
1255
|
+
if (Object.keys(modules).length > 0) setEnvModules(modules);
|
|
1084
1256
|
}
|
|
1085
1257
|
|
|
1086
1258
|
const mocks = new Map<string, { fromPath: string; names?: string[] }>();
|
|
@@ -1108,6 +1280,46 @@ export function analyzeFile(filePath: string, source: string, activeCases?: Map<
|
|
|
1108
1280
|
|
|
1109
1281
|
setProvenanceTracking(true);
|
|
1110
1282
|
|
|
1283
|
+
/** B 路径已报告的 method/property 名(避免 TypeValue 双报) */
|
|
1284
|
+
const bMemberDiagNames = new Set<string>();
|
|
1285
|
+
const bMemberDiagSeen = new Set<string>();
|
|
1286
|
+
const pushBMemberDiag = (d: { kind: string; name: string; receiver: string; line?: number; column?: number; origin?: { line: number; column: number } }, fallbackLine: number) => {
|
|
1287
|
+
bMemberDiagNames.add(d.name);
|
|
1288
|
+
const key = `${d.kind}:${d.name}:${d.receiver}:${d.line ?? fallbackLine}:${d.column ?? 0}`;
|
|
1289
|
+
if (bMemberDiagSeen.has(key)) return;
|
|
1290
|
+
bMemberDiagSeen.add(key);
|
|
1291
|
+
// unknown 接收者 → unknown-recv(与 TypeValue 口径一致,warning)
|
|
1292
|
+
if (d.receiver === "unknown") {
|
|
1293
|
+
diagnostics.push({
|
|
1294
|
+
range: {
|
|
1295
|
+
start: { line: d.line ?? fallbackLine, column: d.column ?? 0 },
|
|
1296
|
+
end: { line: d.line ?? fallbackLine, column: (d.column ?? 0) + d.name.length },
|
|
1297
|
+
},
|
|
1298
|
+
severity: "warning",
|
|
1299
|
+
message: `Cannot resolve '${d.name}' on unknown value`,
|
|
1300
|
+
code: "nudo:unknown-recv",
|
|
1301
|
+
...(d.origin ? { origin: d.origin } : {}),
|
|
1302
|
+
});
|
|
1303
|
+
return;
|
|
1304
|
+
}
|
|
1305
|
+
diagnostics.push({
|
|
1306
|
+
range: {
|
|
1307
|
+
start: { line: d.line ?? fallbackLine, column: d.column ?? 0 },
|
|
1308
|
+
end: { line: d.line ?? fallbackLine, column: (d.column ?? 0) + d.name.length },
|
|
1309
|
+
},
|
|
1310
|
+
severity:
|
|
1311
|
+
d.receiver === "number" || d.receiver === "boolean" || d.receiver === "bigint" || d.receiver === "symbol"
|
|
1312
|
+
? "error"
|
|
1313
|
+
: "warning",
|
|
1314
|
+
message:
|
|
1315
|
+
d.kind === "method"
|
|
1316
|
+
? `Method '${d.name}' does not exist on type '${d.receiver}'`
|
|
1317
|
+
: `Property '${d.name}' does not exist on type '${d.receiver}'`,
|
|
1318
|
+
code: "nudo:no-method",
|
|
1319
|
+
...(d.origin ? { origin: d.origin } : {}),
|
|
1320
|
+
});
|
|
1321
|
+
};
|
|
1322
|
+
|
|
1111
1323
|
// @nudo:mock 绑定必须先于全程序求值:顶层调用点(如 mockHof([1,2,3]))
|
|
1112
1324
|
// 在 evaluateProgram 中执行并由 callCollector 记录 call@ case 的
|
|
1113
1325
|
// resultType——mock 晚于此绑定会让「mock 函数值作为回调实参」在该路径
|
|
@@ -1116,28 +1328,230 @@ export function analyzeFile(filePath: string, source: string, activeCases?: Map<
|
|
|
1116
1328
|
applyMocks(fn.directives, globalEnv, filePath, diagnostics);
|
|
1117
1329
|
}
|
|
1118
1330
|
|
|
1119
|
-
|
|
1331
|
+
// @nudo:mock 已编译为 Abs seed 注入;env/require 强制 TypeValue 路径
|
|
1332
|
+
const selfContained = isSelfContainedSource(source, envNames);
|
|
1333
|
+
const canAbsModules = absModulesOk(source, envNames);
|
|
1334
|
+
const bCapable = isBPathCapable(source, envNames);
|
|
1335
|
+
|
|
1336
|
+
/** B 已上报的模块加载问题种类 + 递归截断函数名(压 TypeValue 叠报) */
|
|
1337
|
+
const bModuleIssueKinds = new Set<"cycle" | "depth" | "missing">();
|
|
1338
|
+
const bTruncatedFns = new Set<string>();
|
|
1339
|
+
/** B 静态 builtin-unknown 名(压 TypeValue unknown-global 叠报) */
|
|
1340
|
+
const bBuiltinUnknownNames = new Set<string>();
|
|
1341
|
+
/** B 成功跑通本文件 → TypeValue method/property 诊断整类让位 */
|
|
1342
|
+
let bHostedEval = false;
|
|
1343
|
+
|
|
1344
|
+
const pushBModuleIssues = (
|
|
1345
|
+
issues: Array<{ kind: "cycle" | "depth" | "missing"; label: string; reason: string }> | undefined,
|
|
1346
|
+
) => {
|
|
1347
|
+
if (!issues) return;
|
|
1348
|
+
for (const iss of issues) {
|
|
1349
|
+
bModuleIssueKinds.add(iss.kind);
|
|
1350
|
+
const code =
|
|
1351
|
+
iss.kind === "cycle"
|
|
1352
|
+
? "nudo:module-cycle"
|
|
1353
|
+
: iss.kind === "depth"
|
|
1354
|
+
? "nudo:module-depth"
|
|
1355
|
+
: "nudo:module-missing";
|
|
1356
|
+
diagnostics.push({
|
|
1357
|
+
range: { start: { line: 1, column: 0 }, end: { line: 1, column: 0 } },
|
|
1358
|
+
severity: iss.kind === "missing" ? "error" : "warning",
|
|
1359
|
+
message: iss.reason,
|
|
1360
|
+
code,
|
|
1361
|
+
});
|
|
1362
|
+
}
|
|
1363
|
+
};
|
|
1120
1364
|
|
|
1121
|
-
const
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1365
|
+
const seeds = mockDirectivesToAbsSeeds(functions);
|
|
1366
|
+
let absCallRecords: CallRecord[] = [];
|
|
1367
|
+
/** B 顶层 $callNamed 记录(call@ 合成;TypeValue skip 后的主源) */
|
|
1368
|
+
let bTopCallRecords: CallRecord[] = [];
|
|
1369
|
+
/** 一次模块图 + 一次 evalProgramAbs 的共享产物(避免 B 路径 4+ 次重求值) */
|
|
1370
|
+
let absGraphModules: Record<string, import("@nudojs/core").AbsModuleExports> | undefined;
|
|
1371
|
+
let absBindsShared: Map<string, Abs> | undefined;
|
|
1372
|
+
let absNodesShared: Map<Node, Abs> | undefined;
|
|
1373
|
+
|
|
1374
|
+
// B 模块图:cycle/depth/missing + 顶层 memberDiags(注入 @nudo:mock,
|
|
1375
|
+
// 避免缩进 const 调到真 fetch;$callNamed 实参 loc 提供参数级 provenance)
|
|
1376
|
+
if (bCapable && filePath) {
|
|
1377
|
+
try {
|
|
1378
|
+
const g = evalAbsModuleGraph(source, filePath, {
|
|
1379
|
+
seedVars: seeds.seedVars,
|
|
1380
|
+
seedFns: seeds.seedFns as never,
|
|
1381
|
+
});
|
|
1382
|
+
absGraphModules = g.modules;
|
|
1383
|
+
pushBModuleIssues(g.issues);
|
|
1384
|
+
} catch {
|
|
1385
|
+
/* 模块图失败交还 TypeValue */
|
|
1386
|
+
}
|
|
1387
|
+
const bRun = tryRunBPath(source, filePath, {
|
|
1388
|
+
envNames,
|
|
1389
|
+
mocks: mockSeedsToAbsMocks(seeds),
|
|
1130
1390
|
});
|
|
1391
|
+
if (bRun) {
|
|
1392
|
+
bHostedEval = true;
|
|
1393
|
+
if (bRun.memberDiags?.length) {
|
|
1394
|
+
for (const d of bRun.memberDiags) {
|
|
1395
|
+
pushBMemberDiag(d, 1);
|
|
1396
|
+
}
|
|
1397
|
+
}
|
|
1398
|
+
if (bRun.truncatedFns) {
|
|
1399
|
+
for (const fn of bRun.truncatedFns) bTruncatedFns.add(fn);
|
|
1400
|
+
}
|
|
1401
|
+
if (bRun.calls?.length) {
|
|
1402
|
+
const impMap = buildAbsImportLocalMap(source, filePath);
|
|
1403
|
+
for (const c of bRun.calls) {
|
|
1404
|
+
const safeAbs = (a: Abs | undefined): TypeValue => {
|
|
1405
|
+
if (!a || typeof a !== "object" || !("shape" in a) || !a.shape) {
|
|
1406
|
+
return T.unknown;
|
|
1407
|
+
}
|
|
1408
|
+
try {
|
|
1409
|
+
return absToTypeValue(a);
|
|
1410
|
+
} catch {
|
|
1411
|
+
return T.unknown;
|
|
1412
|
+
}
|
|
1413
|
+
};
|
|
1414
|
+
const rec: CallRecord = {
|
|
1415
|
+
fnName: c.fnName,
|
|
1416
|
+
argTypes: c.args.map(safeAbs),
|
|
1417
|
+
resultType: c.threw ? T.never : safeAbs(c.result),
|
|
1418
|
+
throws: c.threw ? safeAbs(c.result) : T.never,
|
|
1419
|
+
callLoc: c.callLoc,
|
|
1420
|
+
};
|
|
1421
|
+
const imp = impMap.get(c.fnName);
|
|
1422
|
+
if (imp) {
|
|
1423
|
+
rec.targetModule = imp.modulePath;
|
|
1424
|
+
rec.targetExport = imp.exportName;
|
|
1425
|
+
}
|
|
1426
|
+
bTopCallRecords.push(rec);
|
|
1427
|
+
}
|
|
1428
|
+
}
|
|
1429
|
+
}
|
|
1430
|
+
}
|
|
1431
|
+
if (selfContained || canAbsModules) {
|
|
1432
|
+
// Abs 程序求值的递归截断(call@ 记录路径);modules 已预计算时不再重跑模块图
|
|
1433
|
+
setAbsTruncationCollector((label) => bTruncatedFns.add(label));
|
|
1434
|
+
try {
|
|
1435
|
+
absCallRecords = collectAbsCallRecords(source, seeds, filePath, absGraphModules);
|
|
1436
|
+
} finally {
|
|
1437
|
+
setAbsTruncationCollector(null);
|
|
1438
|
+
}
|
|
1439
|
+
}
|
|
1440
|
+
|
|
1441
|
+
// B hosted:跳过 TypeValue evaluateProgram。一次 eval 同时产出 bindings + nodeTypes。
|
|
1442
|
+
if (bHostedEval) {
|
|
1443
|
+
try {
|
|
1444
|
+
const collected = collectAbsBindsAndNodes(source, seeds, absGraphModules);
|
|
1445
|
+
absBindsShared = collected.binds;
|
|
1446
|
+
absNodesShared = collected.nodes;
|
|
1447
|
+
for (const [name, absVal] of absBindsShared) {
|
|
1448
|
+
if (absVal?.shape?.k === "fn") continue;
|
|
1449
|
+
if (!globalEnv.has(name)) {
|
|
1450
|
+
globalEnv.bind(name, absToTypeValue(absVal));
|
|
1451
|
+
}
|
|
1452
|
+
}
|
|
1453
|
+
for (const [node, absVal] of absNodesShared) {
|
|
1454
|
+
nodeTypeMap.set(node, absToTypeValue(absVal));
|
|
1455
|
+
}
|
|
1456
|
+
} catch {
|
|
1457
|
+
/* Abs 补齐失败仍以 B 诊断为准 */
|
|
1458
|
+
}
|
|
1459
|
+
} else {
|
|
1460
|
+
evaluateProgram(ast, globalEnv);
|
|
1461
|
+
}
|
|
1462
|
+
|
|
1463
|
+
// Abs / B 顶层调用记录优先;再空才保留 TypeValue
|
|
1464
|
+
if ((selfContained || canAbsModules) && absCallRecords.length > 0) {
|
|
1465
|
+
callRecords.length = 0;
|
|
1466
|
+
callRecords.push(...absCallRecords);
|
|
1467
|
+
} else if (bTopCallRecords.length > 0) {
|
|
1468
|
+
callRecords.length = 0;
|
|
1469
|
+
callRecords.push(...bTopCallRecords);
|
|
1470
|
+
}
|
|
1471
|
+
|
|
1472
|
+
const unreachableRanges = bCapable ? [] : getUnreachableRanges();
|
|
1473
|
+
if (bCapable) {
|
|
1474
|
+
// B 路径静态诊断接管 unreachable + builtin-unknown
|
|
1475
|
+
// env/mock 已覆盖的全局不在 builtin-unknown 之列(B 注入后不再是裸原生调用)
|
|
1476
|
+
const bKnownGlobals = new Set<string>([
|
|
1477
|
+
...Object.keys(collectEnvGlobals(envNames)),
|
|
1478
|
+
...Object.keys(mockSeedsToAbsMocks(seeds)),
|
|
1479
|
+
]);
|
|
1480
|
+
const bDiag = collectBPathDiagnostics(source, bKnownGlobals);
|
|
1481
|
+
for (const ur of bDiag.unreachable) {
|
|
1482
|
+
diagnostics.push({
|
|
1483
|
+
range: ur.range,
|
|
1484
|
+
severity: "info",
|
|
1485
|
+
message: "Code after return/throw statement is unreachable",
|
|
1486
|
+
tags: ["unnecessary"],
|
|
1487
|
+
code: "nudo-unreachable",
|
|
1488
|
+
suggestions: ["Remove the unreachable code after the return/throw statement"],
|
|
1489
|
+
});
|
|
1490
|
+
}
|
|
1491
|
+
for (const b of bDiag.builtinUnknown) {
|
|
1492
|
+
bBuiltinUnknownNames.add(b.name);
|
|
1493
|
+
diagnostics.push({
|
|
1494
|
+
range: b.range,
|
|
1495
|
+
severity: "warning",
|
|
1496
|
+
message: `Built-in API "${b.name}" is not covered by Nudo's type inference`,
|
|
1497
|
+
code: "nudo:builtin-unknown",
|
|
1498
|
+
suggestions: [
|
|
1499
|
+
`Use @nudo:mock to define the type: @nudo:mock ${b.name} = stub().returns(...)`,
|
|
1500
|
+
`Or use @nudo:refine return <constraint> to declare the return contract`,
|
|
1501
|
+
],
|
|
1502
|
+
});
|
|
1503
|
+
}
|
|
1504
|
+
} else {
|
|
1505
|
+
for (const ur of unreachableRanges) {
|
|
1506
|
+
diagnostics.push({
|
|
1507
|
+
range: ur,
|
|
1508
|
+
severity: "info",
|
|
1509
|
+
message: "Code after return/throw statement is unreachable",
|
|
1510
|
+
tags: ["unnecessary"],
|
|
1511
|
+
code: "nudo-unreachable",
|
|
1512
|
+
suggestions: ["Remove the unreachable code after the return/throw statement"],
|
|
1513
|
+
});
|
|
1514
|
+
}
|
|
1131
1515
|
}
|
|
1132
1516
|
|
|
1133
1517
|
collectBindings(ast, globalEnv, bindings);
|
|
1134
1518
|
|
|
1519
|
+
// B 路径可分析:用 Abs 模块图补全/覆盖绑定(含相对 import)
|
|
1520
|
+
if (isBPathCapable(source, envNames)) {
|
|
1521
|
+
try {
|
|
1522
|
+
const absBinds =
|
|
1523
|
+
absBindsShared ??
|
|
1524
|
+
collectAbsBindingsFromGraph(source, filePath, {
|
|
1525
|
+
seedVars: seeds.seedVars,
|
|
1526
|
+
seedFns: seeds.seedFns as never,
|
|
1527
|
+
});
|
|
1528
|
+
for (const [name, absVal] of absBinds) {
|
|
1529
|
+
const prev = bindings.get(name);
|
|
1530
|
+
bindings.set(name, {
|
|
1531
|
+
type: absToTypeValue(absVal),
|
|
1532
|
+
loc: prev?.loc,
|
|
1533
|
+
});
|
|
1534
|
+
}
|
|
1535
|
+
} catch {
|
|
1536
|
+
/* keep TypeValue bindings */
|
|
1537
|
+
}
|
|
1538
|
+
}
|
|
1539
|
+
|
|
1135
1540
|
const synthCandidates: { name: string; node: Node; analysis: FunctionAnalysis }[] = [];
|
|
1136
1541
|
|
|
1542
|
+
// 函数级指纹:body-edit 时未引用的兄弟函数可命中缓存
|
|
1543
|
+
let fnFpMap: Map<string, { own: string; deps: string }> | undefined;
|
|
1544
|
+
try {
|
|
1545
|
+
fnFpMap = fnFingerprints(source, ast as never);
|
|
1546
|
+
} catch {
|
|
1547
|
+
fnFpMap = undefined;
|
|
1548
|
+
}
|
|
1549
|
+
const envKeyFn = envNames.join(",");
|
|
1550
|
+
const mockKeyFn = mockSeedFingerprint(seeds.seedVars, seeds.seedFns);
|
|
1551
|
+
|
|
1137
1552
|
for (const fn of functions) {
|
|
1138
1553
|
const isPure = fn.directives.some((d) => d.kind === "pure");
|
|
1139
1554
|
const skipDirective = fn.directives.find((d) => d.kind === "skip");
|
|
1140
|
-
const returnsDirective = fn.directives.find((d) => d.kind === "returns");
|
|
1141
1555
|
|
|
1142
1556
|
const fnLoc = locFromNode(fn.node);
|
|
1143
1557
|
const paramNames = extractParamNames(fn.node);
|
|
@@ -1152,6 +1566,65 @@ export function analyzeFile(filePath: string, source: string, activeCases?: Map<
|
|
|
1152
1566
|
continue;
|
|
1153
1567
|
}
|
|
1154
1568
|
|
|
1569
|
+
const caseDirectives = fn.directives.filter((d) => d.kind === "case");
|
|
1570
|
+
const activeCaseIdx = activeCases?.get(fn.name) ?? 0;
|
|
1571
|
+
|
|
1572
|
+
// Per-fn cache is intentionally case-scoped: synthesized cases (no
|
|
1573
|
+
// @nudo:case) are built from whole-file call records observed while
|
|
1574
|
+
// evaluating *other* functions — own/deps fingerprints cannot see those
|
|
1575
|
+
// call sites, so caching a synthesis result under own/deps would be
|
|
1576
|
+
// unsound (sibling body-edit that changes a call to this fn would miss
|
|
1577
|
+
// the fingerprint). Case-directive results are self-contained.
|
|
1578
|
+
const fp = fnFpMap?.get(fn.name);
|
|
1579
|
+
const fnCacheKey =
|
|
1580
|
+
fp && caseDirectives.length > 0
|
|
1581
|
+
? [
|
|
1582
|
+
filePath,
|
|
1583
|
+
fp.own,
|
|
1584
|
+
fp.deps,
|
|
1585
|
+
String(activeCaseIdx),
|
|
1586
|
+
caseDirectiveKey(caseDirectives, typeValueToString),
|
|
1587
|
+
envKeyFn,
|
|
1588
|
+
mockKeyFn,
|
|
1589
|
+
].join("\0")
|
|
1590
|
+
: undefined;
|
|
1591
|
+
const dLen0 = diagnostics.length;
|
|
1592
|
+
const hLen0 = caseHints.length;
|
|
1593
|
+
const cLen0 = callRecords.length;
|
|
1594
|
+
|
|
1595
|
+
if (fnCacheKey) {
|
|
1596
|
+
const hitFn = fnAnalysisCacheGet(fnCacheKey);
|
|
1597
|
+
if (hitFn) {
|
|
1598
|
+
const cached = cloneFunctionAnalysis(hitFn.analysis as FunctionAnalysis);
|
|
1599
|
+
// own-hash is position-free: sibling inserts shift lines. Rewrite loc
|
|
1600
|
+
// onto the current AST and shift any cached line-relative fields.
|
|
1601
|
+
const lineDelta = fnLoc.start.line - cached.loc.start.line;
|
|
1602
|
+
cached.loc = fnLoc;
|
|
1603
|
+
if (lineDelta !== 0) {
|
|
1604
|
+
for (const c of cached.cases) {
|
|
1605
|
+
if (c.throwLoc) c.throwLoc = shiftSourceLoc(c.throwLoc, lineDelta);
|
|
1606
|
+
}
|
|
1607
|
+
}
|
|
1608
|
+
if (lineDelta === 0) {
|
|
1609
|
+
diagnostics.push(...(hitFn.diagnostics as Diagnostic[]));
|
|
1610
|
+
caseHints.push(...(hitFn.caseHints as CaseHint[]));
|
|
1611
|
+
callRecords.push(...(hitFn.callRecords as CallRecord[]));
|
|
1612
|
+
} else {
|
|
1613
|
+
for (const d of hitFn.diagnostics as Diagnostic[]) {
|
|
1614
|
+
diagnostics.push(shiftDiagnosticLines(d, lineDelta));
|
|
1615
|
+
}
|
|
1616
|
+
for (const h of hitFn.caseHints as CaseHint[]) {
|
|
1617
|
+
caseHints.push({ ...h, line: h.line + lineDelta });
|
|
1618
|
+
}
|
|
1619
|
+
for (const r of hitFn.callRecords as CallRecord[]) {
|
|
1620
|
+
callRecords.push(shiftCallRecordLines(r, lineDelta));
|
|
1621
|
+
}
|
|
1622
|
+
}
|
|
1623
|
+
functionResults.push(cached);
|
|
1624
|
+
continue;
|
|
1625
|
+
}
|
|
1626
|
+
}
|
|
1627
|
+
|
|
1155
1628
|
if (isPure) {
|
|
1156
1629
|
const fnVal = globalEnv.has(fn.name) ? globalEnv.lookup(fn.name) : null;
|
|
1157
1630
|
if (fnVal && fnVal.kind === "function") {
|
|
@@ -1166,9 +1639,6 @@ export function analyzeFile(filePath: string, source: string, activeCases?: Map<
|
|
|
1166
1639
|
setSampleCount(3);
|
|
1167
1640
|
}
|
|
1168
1641
|
|
|
1169
|
-
const caseDirectives = fn.directives.filter((d) => d.kind === "case");
|
|
1170
|
-
const activeCaseIdx = activeCases?.get(fn.name) ?? 0;
|
|
1171
|
-
|
|
1172
1642
|
if (caseDirectives.length === 0) {
|
|
1173
1643
|
synthCandidates.push({ name: fn.name, node: fn.node, analysis });
|
|
1174
1644
|
}
|
|
@@ -1176,36 +1646,127 @@ export function analyzeFile(filePath: string, source: string, activeCases?: Map<
|
|
|
1176
1646
|
for (let ci = 0; ci < caseDirectives.length; ci++) {
|
|
1177
1647
|
const directive = caseDirectives[ci];
|
|
1178
1648
|
resetUnreachableRanges();
|
|
1179
|
-
const fullResult = evaluateFunctionFull(fn.node, directive.args, globalEnv);
|
|
1180
|
-
const caseUnreachable = [...getUnreachableRanges()];
|
|
1181
1649
|
|
|
1182
|
-
|
|
1650
|
+
// B 路径主求值(capable):analyze 模式 + $callNamed 采集。
|
|
1651
|
+
// 成功则跳过 evaluateFunctionFull;失败再落 TypeValue。
|
|
1652
|
+
let caseValue: TypeValue | undefined;
|
|
1653
|
+
let caseAbs: Abs | undefined;
|
|
1654
|
+
let caseThrows: TypeValue | undefined;
|
|
1655
|
+
let fullResult: {
|
|
1656
|
+
value: TypeValue;
|
|
1657
|
+
throws: TypeValue;
|
|
1658
|
+
throwLoc?: SourceLocation;
|
|
1659
|
+
} | null = null;
|
|
1660
|
+
let caseUnreachable: SourceLocation[] = [];
|
|
1661
|
+
|
|
1662
|
+
const bCapable = isBPathCapable(source, envNames);
|
|
1663
|
+
// env fnSig impl 已在 B 注入中保留,B 可作主路径
|
|
1664
|
+
const bPrimary = bCapable;
|
|
1665
|
+
if (bCapable && filePath) {
|
|
1666
|
+
const bFull = tryBPathCallFull(
|
|
1667
|
+
source,
|
|
1668
|
+
filePath,
|
|
1669
|
+
fn.name,
|
|
1670
|
+
directive.args.map((a) => typeValueToAbs(a)),
|
|
1671
|
+
{ collectCalls: true, envNames, mocks: mockSeedsToAbsMocks(seeds) },
|
|
1672
|
+
);
|
|
1673
|
+
const res = bFull?.result;
|
|
1674
|
+
const weakUnknown =
|
|
1675
|
+
!!res &&
|
|
1676
|
+
res.shape.k === "unknown" &&
|
|
1677
|
+
(!res.term || (res.term.op === "lit" && res.term.value === undefined));
|
|
1678
|
+
const bOk = !!res && (bHostedEval || (!weakUnknown && res.conf !== "opaque"));
|
|
1679
|
+
if (bOk && bFull && bPrimary) {
|
|
1680
|
+
caseAbs = bFull.result;
|
|
1681
|
+
caseValue = absToTypeValue(bFull.result);
|
|
1682
|
+
caseThrows =
|
|
1683
|
+
bFull.throws.shape.k !== "never" ? absToTypeValue(bFull.throws) : T.never;
|
|
1684
|
+
fullResult = { value: caseValue, throws: caseThrows };
|
|
1685
|
+
// B 执行期 method-missing 诊断(唯一权威来源;TypeValue 按名去重)
|
|
1686
|
+
for (const d of bFull.memberDiags ?? []) {
|
|
1687
|
+
pushBMemberDiag(d, fnLoc.start.line);
|
|
1688
|
+
}
|
|
1689
|
+
if (bFull.calls?.length) {
|
|
1690
|
+
const impMap = buildAbsImportLocalMap(source, filePath);
|
|
1691
|
+
const safeAbs = (a: Abs | undefined): TypeValue => {
|
|
1692
|
+
if (!a || typeof a !== "object" || !("shape" in a) || !a.shape) {
|
|
1693
|
+
return T.unknown;
|
|
1694
|
+
}
|
|
1695
|
+
try {
|
|
1696
|
+
return absToTypeValue(a);
|
|
1697
|
+
} catch {
|
|
1698
|
+
return T.unknown;
|
|
1699
|
+
}
|
|
1700
|
+
};
|
|
1701
|
+
for (const c of bFull.calls) {
|
|
1702
|
+
const rec: CallRecord = {
|
|
1703
|
+
fnName: c.fnName,
|
|
1704
|
+
argTypes: c.args.map(safeAbs),
|
|
1705
|
+
resultType: c.threw ? T.never : safeAbs(c.result),
|
|
1706
|
+
throws: c.threw ? safeAbs(c.result) : T.never,
|
|
1707
|
+
callLoc: c.callLoc,
|
|
1708
|
+
};
|
|
1709
|
+
const imp = impMap.get(c.fnName);
|
|
1710
|
+
if (imp) {
|
|
1711
|
+
rec.targetModule = imp.modulePath;
|
|
1712
|
+
rec.targetExport = imp.exportName;
|
|
1713
|
+
}
|
|
1714
|
+
callRecords.push(rec);
|
|
1715
|
+
}
|
|
1716
|
+
}
|
|
1717
|
+
}
|
|
1718
|
+
}
|
|
1719
|
+
|
|
1720
|
+
if (!fullResult) {
|
|
1721
|
+
if (bHostedEval) {
|
|
1722
|
+
fullResult = { value: T.unknown, throws: T.never };
|
|
1723
|
+
caseValue = T.unknown;
|
|
1724
|
+
} else {
|
|
1725
|
+
fullResult = evaluateFunctionFull(fn.node, directive.args, globalEnv);
|
|
1726
|
+
caseUnreachable = [...getUnreachableRanges()];
|
|
1727
|
+
caseValue = fullResult.value;
|
|
1728
|
+
if ((selfContained || canAbsModules) && fullResult.value.kind !== "never") {
|
|
1729
|
+
caseAbs = tryEvalAbsRaw(source, fn.name, directive.args, filePath, mockSeedsToAbsMocks(seeds));
|
|
1730
|
+
if (caseAbs) {
|
|
1731
|
+
const projected = absToTypeValue(caseAbs);
|
|
1732
|
+
if (absIsBetter(projected, fullResult.value)) caseValue = projected;
|
|
1733
|
+
}
|
|
1734
|
+
}
|
|
1735
|
+
}
|
|
1736
|
+
}
|
|
1737
|
+
|
|
1738
|
+
const tv = caseValue!;
|
|
1739
|
+
const throwsTv = caseThrows ?? fullResult.throws;
|
|
1740
|
+
|
|
1741
|
+
const caseEntry: CaseResult = {
|
|
1183
1742
|
name: directive.name,
|
|
1184
1743
|
args: directive.args,
|
|
1185
|
-
result:
|
|
1186
|
-
throws:
|
|
1744
|
+
result: tv,
|
|
1745
|
+
throws: throwsTv,
|
|
1187
1746
|
throwLoc: fullResult.throwLoc,
|
|
1188
|
-
|
|
1747
|
+
expected: directive.expected,
|
|
1748
|
+
source: "directive",
|
|
1749
|
+
};
|
|
1750
|
+
tryAttachIntension(caseEntry, source, fn.name);
|
|
1751
|
+
if (caseAbs) attachAbsToIntension(caseEntry, caseAbs, fn.name);
|
|
1752
|
+
analysis.cases.push(caseEntry);
|
|
1189
1753
|
|
|
1190
1754
|
if (directive.commentLine) {
|
|
1191
|
-
const hasThrow =
|
|
1192
|
-
const resultStr =
|
|
1193
|
-
|
|
1194
|
-
: "";
|
|
1195
|
-
const throwStr = hasThrow
|
|
1196
|
-
? `throws ${typeValueToString(fullResult.throws)}`
|
|
1197
|
-
: "";
|
|
1755
|
+
const hasThrow = throwsTv.kind !== "never";
|
|
1756
|
+
const resultStr = tv.kind !== "never" ? typeValueToString(tv) : "";
|
|
1757
|
+
const throwStr = hasThrow ? `throws ${typeValueToString(throwsTv)}` : "";
|
|
1198
1758
|
const label = [resultStr, throwStr].filter(Boolean).join(" ");
|
|
1199
1759
|
const hintLabel = `=> ${label}`;
|
|
1200
1760
|
|
|
1201
1761
|
let ok = true;
|
|
1202
1762
|
if (directive.expected) {
|
|
1203
|
-
ok = isSubtypeOf(
|
|
1763
|
+
ok = isSubtypeOf(tv, directive.expected);
|
|
1204
1764
|
if (!ok) {
|
|
1205
1765
|
diagnostics.push({
|
|
1206
1766
|
range: { start: { line: directive.commentLine, column: 0 }, end: { line: directive.commentLine, column: 999 } },
|
|
1207
1767
|
severity: "error",
|
|
1208
|
-
message: `Case "${directive.name}": expected ${typeValueToString(directive.expected)}, got ${typeValueToString(
|
|
1768
|
+
message: `Case "${directive.name}": expected ${typeValueToString(directive.expected)}, got ${typeValueToString(tv)}. The inferred return type does not match the expected type declared in the @nudo:case directive`,
|
|
1769
|
+
code: "nudo:case-expected",
|
|
1209
1770
|
});
|
|
1210
1771
|
}
|
|
1211
1772
|
}
|
|
@@ -1216,25 +1777,28 @@ export function analyzeFile(filePath: string, source: string, activeCases?: Map<
|
|
|
1216
1777
|
const isActive = ci === Math.min(activeCaseIdx, caseDirectives.length - 1);
|
|
1217
1778
|
|
|
1218
1779
|
if (isActive) {
|
|
1219
|
-
if (
|
|
1220
|
-
const throwRange = fullResult
|
|
1780
|
+
if (throwsTv.kind !== "never") {
|
|
1781
|
+
const throwRange = fullResult?.throwLoc ?? fnLoc;
|
|
1221
1782
|
diagnostics.push({
|
|
1222
1783
|
range: throwRange,
|
|
1223
1784
|
severity: "warning",
|
|
1224
|
-
message: `Function "${fn.name}" case "${directive.name}" may throw: ${typeValueToString(
|
|
1785
|
+
message: `Function "${fn.name}" case "${directive.name}" may throw: ${typeValueToString(throwsTv)}. Consider adding a try-catch block or using @nudo:refine return <constraint>`,
|
|
1225
1786
|
code: "nudo-may-throw",
|
|
1226
1787
|
});
|
|
1227
1788
|
}
|
|
1228
1789
|
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1790
|
+
// B 路径可分析时文件级静态收集已报 unreachable,跳过 case 级
|
|
1791
|
+
if (!isBPathCapable(source, envNames)) {
|
|
1792
|
+
for (const ur of caseUnreachable) {
|
|
1793
|
+
diagnostics.push({
|
|
1794
|
+
range: ur,
|
|
1795
|
+
severity: "info",
|
|
1796
|
+
message: "Code after return/throw statement is unreachable",
|
|
1797
|
+
tags: ["unnecessary"],
|
|
1798
|
+
code: "nudo-unreachable",
|
|
1799
|
+
suggestions: ["Remove the unreachable code after the return/throw statement"],
|
|
1800
|
+
});
|
|
1801
|
+
}
|
|
1238
1802
|
}
|
|
1239
1803
|
}
|
|
1240
1804
|
}
|
|
@@ -1245,26 +1809,13 @@ export function analyzeFile(filePath: string, source: string, activeCases?: Map<
|
|
|
1245
1809
|
analysis.combined = analysis.cases[0].result;
|
|
1246
1810
|
}
|
|
1247
1811
|
|
|
1248
|
-
if (
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
analysis.assertionErrors.push(msg);
|
|
1256
|
-
diagnostics.push({
|
|
1257
|
-
range: fnLoc,
|
|
1258
|
-
severity: "error",
|
|
1259
|
-
message: msg,
|
|
1260
|
-
code: "nudo-assertion-failed",
|
|
1261
|
-
suggestions: [
|
|
1262
|
-
"Update @nudo:returns to match the inferred type",
|
|
1263
|
-
"Fix the function to return the expected type",
|
|
1264
|
-
],
|
|
1265
|
-
});
|
|
1266
|
-
}
|
|
1267
|
-
}
|
|
1812
|
+
if (fnCacheKey) {
|
|
1813
|
+
fnAnalysisCacheSet(fnCacheKey, {
|
|
1814
|
+
analysis: cloneFunctionAnalysis(analysis),
|
|
1815
|
+
diagnostics: diagnostics.slice(dLen0),
|
|
1816
|
+
caseHints: caseHints.slice(hLen0),
|
|
1817
|
+
callRecords: callRecords.slice(cLen0),
|
|
1818
|
+
});
|
|
1268
1819
|
}
|
|
1269
1820
|
|
|
1270
1821
|
functionResults.push(analysis);
|
|
@@ -1371,13 +1922,26 @@ export function analyzeFile(filePath: string, source: string, activeCases?: Map<
|
|
|
1371
1922
|
.map(({ r }) => r);
|
|
1372
1923
|
const precise = ordered.slice(0, MAX_PRECISE_CALLSITE_CASES);
|
|
1373
1924
|
for (const rec of precise) {
|
|
1374
|
-
|
|
1925
|
+
// Abs 重求值仅在更有信息量时覆盖(不破坏 mock/callsite 精确结构)
|
|
1926
|
+
let absRaw: Abs | undefined;
|
|
1927
|
+
let absResult: TypeValue | undefined;
|
|
1928
|
+
if (rec.resultType.kind !== "never" && rec.argTypes.length > 0) {
|
|
1929
|
+
absRaw = tryEvalAbsRaw(source, candidate.name, rec.argTypes, filePath, mockSeedsToAbsMocks(seeds));
|
|
1930
|
+
if (absRaw) {
|
|
1931
|
+
const projected = absToTypeValue(absRaw);
|
|
1932
|
+
if (absIsBetter(projected, rec.resultType)) absResult = projected;
|
|
1933
|
+
}
|
|
1934
|
+
}
|
|
1935
|
+
const caseResult: CaseResult = {
|
|
1375
1936
|
name: `call@L${rec.callLoc?.line ?? candidate.analysis.loc.start.line}`,
|
|
1376
1937
|
args: rec.argTypes,
|
|
1377
|
-
result: rec.resultType,
|
|
1938
|
+
result: absResult ?? rec.resultType,
|
|
1378
1939
|
throws: rec.throws,
|
|
1379
1940
|
source: "callsite",
|
|
1380
|
-
}
|
|
1941
|
+
};
|
|
1942
|
+
tryAttachIntension(caseResult, source, candidate.name);
|
|
1943
|
+
if (absRaw) attachAbsToIntension(caseResult, absRaw, candidate.name);
|
|
1944
|
+
candidate.analysis.cases.push(caseResult);
|
|
1381
1945
|
}
|
|
1382
1946
|
// symbolic 聚合只用全已知实参的记录:含 unknown 分量的记录不可重求值
|
|
1383
1947
|
// (unknown 吸收整个 union,一条循环引用 fixture 的记录就能毒化全部
|
|
@@ -1394,16 +1958,46 @@ export function analyzeFile(filePath: string, source: string, activeCases?: Map<
|
|
|
1394
1958
|
// (target || [] 等)对 unknown 全塌,对 undefined 正常走默认分支
|
|
1395
1959
|
widenType(simplifyUnion(remaining.map((rec) => rec.argTypes[i] ?? T.undefined))),
|
|
1396
1960
|
);
|
|
1397
|
-
const
|
|
1398
|
-
|
|
1961
|
+
const absSymRaw = tryEvalAbs(source, candidate.name, widenedArgs, filePath, mockSeedsToAbsMocks(seeds));
|
|
1962
|
+
const absSym = absSymRaw && absIsBetter(absSymRaw, /* 无先验:仅 unknown 时 */ { kind: "unknown" })
|
|
1963
|
+
? absSymRaw
|
|
1964
|
+
: undefined;
|
|
1965
|
+
// B 路径优先(capable);否则 TypeValue,Abs 仅在 unknown 时补
|
|
1966
|
+
let symValue: TypeValue | undefined;
|
|
1967
|
+
let symThrows: TypeValue = T.never as TypeValue;
|
|
1968
|
+
let symLoc: SourceLocation | undefined;
|
|
1969
|
+
let symAbs: Abs | undefined;
|
|
1970
|
+
if (isBPathCapable(source, envNames) && filePath) {
|
|
1971
|
+
const bSym = tryBPathCall(
|
|
1972
|
+
source,
|
|
1973
|
+
filePath,
|
|
1974
|
+
candidate.name,
|
|
1975
|
+
widenedArgs.map((a) => typeValueToAbs(a)),
|
|
1976
|
+
{ envNames, mocks: mockSeedsToAbsMocks(seeds) },
|
|
1977
|
+
);
|
|
1978
|
+
if (bSym && (bHostedEval || !(bSym.shape.k === "unknown" && !bSym.term))) {
|
|
1979
|
+
symAbs = bSym;
|
|
1980
|
+
symValue = absToTypeValue(bSym);
|
|
1981
|
+
}
|
|
1982
|
+
}
|
|
1983
|
+
if (!symAbs && !bHostedEval) {
|
|
1984
|
+
const full = evaluateFunctionFull(fnNode, widenedArgs, globalEnv);
|
|
1985
|
+
symValue = full.value.kind === "unknown" && absSym ? absSym : full.value;
|
|
1986
|
+
symThrows = full.throws;
|
|
1987
|
+
symLoc = full.throwLoc;
|
|
1988
|
+
}
|
|
1989
|
+
const symCase: CaseResult = {
|
|
1399
1990
|
name: "call@symbolic",
|
|
1400
1991
|
args: widenedArgs,
|
|
1401
|
-
result:
|
|
1402
|
-
throws:
|
|
1403
|
-
throwLoc:
|
|
1992
|
+
result: symValue ?? T.unknown,
|
|
1993
|
+
throws: symThrows,
|
|
1994
|
+
throwLoc: symLoc,
|
|
1404
1995
|
source: "callsite",
|
|
1405
1996
|
aggregatedFrom: remaining.length,
|
|
1406
|
-
}
|
|
1997
|
+
};
|
|
1998
|
+
tryAttachIntension(symCase, source, candidate.name);
|
|
1999
|
+
if (symAbs) attachAbsToIntension(symCase, symAbs, candidate.name);
|
|
2000
|
+
candidate.analysis.cases.push(symCase);
|
|
1407
2001
|
}
|
|
1408
2002
|
// Combined covers every observed call site (not just the retained
|
|
1409
2003
|
// cases), so a large set of same-base literal results collapses to
|
|
@@ -1417,19 +2011,62 @@ export function analyzeFile(filePath: string, source: string, activeCases?: Map<
|
|
|
1417
2011
|
|
|
1418
2012
|
const fnNode = resolveFunctionNode(candidate.node);
|
|
1419
2013
|
const args = extractParamNames(fnNode).map(() => T.unknown);
|
|
1420
|
-
|
|
1421
|
-
|
|
2014
|
+
// B 路径主求值(capable);失败再 Abs ast-eval / TypeValue
|
|
2015
|
+
let entryValue: TypeValue | undefined;
|
|
2016
|
+
let entryThrows: TypeValue = T.never as TypeValue;
|
|
2017
|
+
let entryLoc: SourceLocation | undefined;
|
|
2018
|
+
let entryAbs: Abs | undefined;
|
|
2019
|
+
if (isBPathCapable(source, envNames) && filePath) {
|
|
2020
|
+
const bEntryFull = tryBPathCallFull(
|
|
2021
|
+
source,
|
|
2022
|
+
filePath,
|
|
2023
|
+
candidate.analysis.name,
|
|
2024
|
+
args.map((a) => typeValueToAbs(a)),
|
|
2025
|
+
{ envNames, mocks: mockSeedsToAbsMocks(seeds), collectMemberDiags: true },
|
|
2026
|
+
);
|
|
2027
|
+
if (bEntryFull?.memberDiags?.length) {
|
|
2028
|
+
for (const d of bEntryFull.memberDiags) {
|
|
2029
|
+
pushBMemberDiag(d, candidate.analysis.loc.start.line);
|
|
2030
|
+
}
|
|
2031
|
+
}
|
|
2032
|
+
const bEntry = bEntryFull?.result;
|
|
2033
|
+
if (bEntry && (bHostedEval || !(bEntry.shape.k === "unknown" && !bEntry.term))) {
|
|
2034
|
+
entryAbs = bEntry;
|
|
2035
|
+
entryValue = absToTypeValue(bEntry);
|
|
2036
|
+
}
|
|
2037
|
+
}
|
|
2038
|
+
if (!entryValue) {
|
|
2039
|
+
if (bHostedEval) {
|
|
2040
|
+
entryValue = T.unknown;
|
|
2041
|
+
entryThrows = T.never as TypeValue;
|
|
2042
|
+
} else {
|
|
2043
|
+
const absEntry = tryEvalEntryAbs(source, candidate.analysis.name, args, filePath, seeds.seedVars);
|
|
2044
|
+
const full = absEntry
|
|
2045
|
+
? { value: absEntry, throws: T.never as TypeValue, throwLoc: undefined }
|
|
2046
|
+
: evaluateFunctionFull(fnNode, args, globalEnv);
|
|
2047
|
+
entryValue = full.value;
|
|
2048
|
+
entryThrows = full.throws;
|
|
2049
|
+
entryLoc = full.throwLoc;
|
|
2050
|
+
}
|
|
2051
|
+
}
|
|
2052
|
+
const caseResult: CaseResult = {
|
|
1422
2053
|
name: `entry@L${candidate.analysis.loc.start.line}`,
|
|
1423
2054
|
args,
|
|
1424
|
-
result:
|
|
1425
|
-
throws:
|
|
1426
|
-
throwLoc:
|
|
1427
|
-
}
|
|
2055
|
+
result: entryValue,
|
|
2056
|
+
throws: entryThrows,
|
|
2057
|
+
throwLoc: entryLoc,
|
|
2058
|
+
};
|
|
2059
|
+
// display 来自 generalize;attachAbs 补无损 abs 字段(后写覆盖 abs/conf)
|
|
2060
|
+
tryAttachIntension(caseResult, source, candidate.analysis.name);
|
|
2061
|
+
if (entryAbs) attachAbsToIntension(caseResult, entryAbs, candidate.analysis.name);
|
|
2062
|
+
candidate.analysis.cases.push(caseResult);
|
|
1428
2063
|
candidate.analysis.entryOnly = true;
|
|
1429
|
-
candidate.analysis.combined = collapseLiteralUnion(
|
|
2064
|
+
candidate.analysis.combined = collapseLiteralUnion(entryValue, COLLAPSE_LITERAL_THRESHOLD);
|
|
1430
2065
|
}
|
|
1431
2066
|
|
|
1432
|
-
|
|
2067
|
+
if (!bHostedEval) {
|
|
2068
|
+
buildNodeTypeMap(ast, globalEnv, nodeTypeMap);
|
|
2069
|
+
}
|
|
1433
2070
|
|
|
1434
2071
|
setUnknownBuiltinHandler(null);
|
|
1435
2072
|
setCallCollector(null);
|
|
@@ -1447,9 +2084,35 @@ export function analyzeFile(filePath: string, source: string, activeCases?: Map<
|
|
|
1447
2084
|
const maxLine = source.split("\n").length;
|
|
1448
2085
|
diagnostics.push(
|
|
1449
2086
|
...unknownRecordsToDiagnostics(
|
|
1450
|
-
unknownRecords.filter(
|
|
1451
|
-
|
|
1452
|
-
|
|
2087
|
+
unknownRecords.filter((r) => {
|
|
2088
|
+
if ((r.loc?.line ?? 0) > maxLine) return false;
|
|
2089
|
+
if (r.originModule === USAGE_SITE_MODULE) return false;
|
|
2090
|
+
// B 成功宿主本文件:TypeValue method/property 整类让位
|
|
2091
|
+
//(transpile + ast-eval 成员分派均记 memberDiags)
|
|
2092
|
+
if (bHostedEval && (r.kind === "method" || r.kind === "property")) {
|
|
2093
|
+
return false;
|
|
2094
|
+
}
|
|
2095
|
+
// B 执行期已报的名(B 未 hosted 时兜底去重)
|
|
2096
|
+
if ((r.kind === "method" || r.kind === "property") && bMemberDiagNames.has(r.name)) {
|
|
2097
|
+
return false;
|
|
2098
|
+
}
|
|
2099
|
+
// B 模块图已报的 cycle/depth/missing:TypeValue loadModuleEnv 不再叠报
|
|
2100
|
+
if (r.kind === "global") {
|
|
2101
|
+
if (r.name.startsWith("module-cycle:") && bModuleIssueKinds.has("cycle")) return false;
|
|
2102
|
+
if (r.name.startsWith("module-depth:") && bModuleIssueKinds.has("depth")) return false;
|
|
2103
|
+
if (r.name.startsWith("module-missing:") && bModuleIssueKinds.has("missing")) return false;
|
|
2104
|
+
// Abs 调用预算已截断的递归:不再由 TypeValue 记 recursion:*
|
|
2105
|
+
if (r.name.startsWith("recursion:")) {
|
|
2106
|
+
const fn = r.name.slice("recursion:".length);
|
|
2107
|
+
if (bTruncatedFns.has(fn) || bTruncatedFns.has(fn.split(".").pop() ?? fn)) {
|
|
2108
|
+
return false;
|
|
2109
|
+
}
|
|
2110
|
+
}
|
|
2111
|
+
// B 静态 builtin-unknown 已报的裸标识符:不再 unknown-global 叠报
|
|
2112
|
+
if (bBuiltinUnknownNames.has(r.name)) return false;
|
|
2113
|
+
}
|
|
2114
|
+
return true;
|
|
2115
|
+
}),
|
|
1453
2116
|
),
|
|
1454
2117
|
);
|
|
1455
2118
|
|
|
@@ -1569,22 +2232,51 @@ export function getTypeAtPosition(
|
|
|
1569
2232
|
activeCases?: Map<string, number>,
|
|
1570
2233
|
): TypeValue | null {
|
|
1571
2234
|
const ast = parse(source);
|
|
2235
|
+
const fileDirectives = extractFileDirectives(ast);
|
|
2236
|
+
const envNames = fileDirectives
|
|
2237
|
+
.filter((d) => d.kind === "env")
|
|
2238
|
+
.flatMap((d) => d.envs);
|
|
2239
|
+
|
|
2240
|
+
// B 路径 capable:节点表来自 evalProgramAbs(模块图注入),不跑 TypeValue evaluateProgram
|
|
2241
|
+
if (isBPathCapable(source, envNames)) {
|
|
2242
|
+
try {
|
|
2243
|
+
const seeds = mockDirectivesToAbsSeeds(extractDirectives(ast));
|
|
2244
|
+
const { modules } = evalAbsModuleGraph(source, filePath);
|
|
2245
|
+
const absNodes = collectAbsNodeTypes(source, {
|
|
2246
|
+
...seeds,
|
|
2247
|
+
modules,
|
|
2248
|
+
file: ast as never,
|
|
2249
|
+
});
|
|
2250
|
+
const absAt = findAbsAtPosition(absNodes, line, column);
|
|
2251
|
+
if (absAt) return absToTypeValue(absAt);
|
|
2252
|
+
// 标识符绑定兜底
|
|
2253
|
+
const ident = findIdentNameAtPosition(source, line, column, ast);
|
|
2254
|
+
if (ident) {
|
|
2255
|
+
const binds = collectAbsBindingsFromGraph(source, filePath, {
|
|
2256
|
+
seedVars: seeds.seedVars,
|
|
2257
|
+
seedFns: seeds.seedFns as never,
|
|
2258
|
+
});
|
|
2259
|
+
const bound = binds.get(ident);
|
|
2260
|
+
if (bound) return absToTypeValue(bound);
|
|
2261
|
+
}
|
|
2262
|
+
} catch {
|
|
2263
|
+
/* fall through to TypeValue */
|
|
2264
|
+
}
|
|
2265
|
+
}
|
|
2266
|
+
|
|
1572
2267
|
resetMemo();
|
|
1573
2268
|
resetEnvModules();
|
|
1574
2269
|
setModuleResolver(resolveModule);
|
|
1575
2270
|
setCurrentFileDir(dirname(filePath));
|
|
1576
2271
|
setCurrentSource(source);
|
|
1577
2272
|
|
|
1578
|
-
const fileDirectives = extractFileDirectives(ast);
|
|
1579
|
-
const envNames = fileDirectives
|
|
1580
|
-
.filter((d) => d.kind === "env")
|
|
1581
|
-
.flatMap((d) => d.envs);
|
|
1582
|
-
|
|
1583
2273
|
const globalEnv = createEnvironment();
|
|
1584
2274
|
|
|
1585
|
-
|
|
1586
|
-
const loaded = loadEnvs(envNames, globalEnv);
|
|
1587
|
-
|
|
2275
|
+
{
|
|
2276
|
+
const loaded = envNames.length > 0 ? loadEnvs(envNames, globalEnv) : { modules: {} };
|
|
2277
|
+
const auto = autoHarvestModules(source, dirname(filePath));
|
|
2278
|
+
const modules = { ...loaded.modules, ...auto };
|
|
2279
|
+
if (Object.keys(modules).length > 0) setEnvModules(modules);
|
|
1588
2280
|
}
|
|
1589
2281
|
|
|
1590
2282
|
evaluateProgram(ast, globalEnv);
|
|
@@ -1618,6 +2310,217 @@ export function getTypeAtPosition(
|
|
|
1618
2310
|
return findBestTypeAtPosition(nodeTypeMap, globalEnv, ast, line, column);
|
|
1619
2311
|
}
|
|
1620
2312
|
|
|
2313
|
+
export type HoverInfo = {
|
|
2314
|
+
/** 外延 TypeValue 展示(bridge 有损,仅兜底) */
|
|
2315
|
+
typeText: string;
|
|
2316
|
+
/** 内涵签名(代数 generalize) */
|
|
2317
|
+
intension?: string;
|
|
2318
|
+
/** 无损 Abs 单行展示(shape / term / pred / conf) */
|
|
2319
|
+
abs?: string;
|
|
2320
|
+
/** 无损 Abs 多行展示 */
|
|
2321
|
+
absMultiline?: string;
|
|
2322
|
+
};
|
|
2323
|
+
|
|
2324
|
+
/**
|
|
2325
|
+
* LSP hover:优先无损 Abs(类型即计算本体),TypeValue 仅作外延对照。
|
|
2326
|
+
* 节点表也是 Abs(collectAbsNodeTypes),不经 bridge。
|
|
2327
|
+
*/
|
|
2328
|
+
export function getHoverAtPosition(
|
|
2329
|
+
filePath: string,
|
|
2330
|
+
source: string,
|
|
2331
|
+
line: number,
|
|
2332
|
+
column: number,
|
|
2333
|
+
activeCases?: Map<string, number>,
|
|
2334
|
+
): HoverInfo | null {
|
|
2335
|
+
let file: ReturnType<typeof parse> | undefined;
|
|
2336
|
+
try {
|
|
2337
|
+
file = parse(source);
|
|
2338
|
+
} catch {
|
|
2339
|
+
file = undefined;
|
|
2340
|
+
}
|
|
2341
|
+
const envNames = collectEnvNames(filePath, source, false);
|
|
2342
|
+
|
|
2343
|
+
// B 路径:优先 Abs 节点表 / 标识符绑定,不经 TypeValue evaluateProgram
|
|
2344
|
+
if (isBPathCapable(source, envNames)) {
|
|
2345
|
+
try {
|
|
2346
|
+
const seeds = mockDirectivesToAbsSeeds(extractDirectives(file ?? parse(source)));
|
|
2347
|
+
const { modules } = evalAbsModuleGraph(source, filePath);
|
|
2348
|
+
const absNodes = collectAbsNodeTypes(source, {
|
|
2349
|
+
...seeds,
|
|
2350
|
+
modules,
|
|
2351
|
+
...(file ? { file: file as never } : {}),
|
|
2352
|
+
});
|
|
2353
|
+
const absAt = findAbsAtPosition(absNodes, line, column);
|
|
2354
|
+
const ident = findIdentNameAtPosition(source, line, column, file);
|
|
2355
|
+
if (ident && !findFunctionNameAtPosition(source, line, column, file)) {
|
|
2356
|
+
const binds = collectAbsBindingsFromGraph(source, filePath, {
|
|
2357
|
+
seedVars: seeds.seedVars,
|
|
2358
|
+
seedFns: seeds.seedFns as never,
|
|
2359
|
+
});
|
|
2360
|
+
const bound = binds.get(ident);
|
|
2361
|
+
if (bound) {
|
|
2362
|
+
const absLine = formatAbs(bound);
|
|
2363
|
+
const absMulti = formatAbsMultiline(bound, ident);
|
|
2364
|
+
return { typeText: absLine, abs: absLine, absMultiline: absMulti };
|
|
2365
|
+
}
|
|
2366
|
+
}
|
|
2367
|
+
if (absAt) {
|
|
2368
|
+
const absLine = formatAbs(absAt);
|
|
2369
|
+
const absMulti = formatAbsMultiline(absAt, undefined);
|
|
2370
|
+
return { typeText: absLine, abs: absLine, absMultiline: absMulti };
|
|
2371
|
+
}
|
|
2372
|
+
} catch {
|
|
2373
|
+
/* fall through */
|
|
2374
|
+
}
|
|
2375
|
+
}
|
|
2376
|
+
|
|
2377
|
+
const tv = getTypeAtPosition(filePath, source, line, column, activeCases);
|
|
2378
|
+
const info: HoverInfo | null = tv ? { typeText: typeValueToString(tv) } : null;
|
|
2379
|
+
|
|
2380
|
+
const fnName = findFunctionNameAtPosition(source, line, column, file);
|
|
2381
|
+
if (fnName) {
|
|
2382
|
+
try {
|
|
2383
|
+
const g = generalizeFromAst(fnName, source, file ? { file } : {});
|
|
2384
|
+
if (g) {
|
|
2385
|
+
if (info) {
|
|
2386
|
+
info.intension = g.display;
|
|
2387
|
+
info.abs = formatAbs(g.symbolic);
|
|
2388
|
+
info.absMultiline = formatAbsMultiline(g.symbolic, fnName);
|
|
2389
|
+
} else {
|
|
2390
|
+
return {
|
|
2391
|
+
typeText: g.display,
|
|
2392
|
+
intension: g.display,
|
|
2393
|
+
abs: formatAbs(g.symbolic),
|
|
2394
|
+
absMultiline: formatAbsMultiline(g.symbolic, fnName),
|
|
2395
|
+
};
|
|
2396
|
+
}
|
|
2397
|
+
}
|
|
2398
|
+
} catch {
|
|
2399
|
+
// ignore
|
|
2400
|
+
}
|
|
2401
|
+
}
|
|
2402
|
+
|
|
2403
|
+
// 标识符绑定优先(比粗粒度节点表更准)
|
|
2404
|
+
const ident = findIdentNameAtPosition(source, line, column, file);
|
|
2405
|
+
if (ident && !fnName) {
|
|
2406
|
+
try {
|
|
2407
|
+
// 经模块图(相对 + 裸包)求 Abs 绑定
|
|
2408
|
+
if (isBPathCapable(source, []) || !/\brequire\s*\(/.test(source)) {
|
|
2409
|
+
const seeds = mockDirectivesToAbsSeeds(extractDirectives(file ?? parse(source)));
|
|
2410
|
+
const absBinds = collectAbsBindingsFromGraph(source, filePath, {
|
|
2411
|
+
seedVars: seeds.seedVars,
|
|
2412
|
+
seedFns: seeds.seedFns as never,
|
|
2413
|
+
});
|
|
2414
|
+
const absBound = absBinds.get(ident);
|
|
2415
|
+
if (absBound) {
|
|
2416
|
+
const absLine = formatAbs(absBound);
|
|
2417
|
+
const absMulti = formatAbsMultiline(absBound, ident);
|
|
2418
|
+
if (info) {
|
|
2419
|
+
info.abs = absLine;
|
|
2420
|
+
info.absMultiline = absMulti;
|
|
2421
|
+
return info;
|
|
2422
|
+
}
|
|
2423
|
+
return { typeText: absLine, abs: absLine, absMultiline: absMulti };
|
|
2424
|
+
}
|
|
2425
|
+
}
|
|
2426
|
+
} catch {
|
|
2427
|
+
// ignore
|
|
2428
|
+
}
|
|
2429
|
+
}
|
|
2430
|
+
|
|
2431
|
+
// 任意表达式:Abs 节点表(无损)
|
|
2432
|
+
try {
|
|
2433
|
+
if (isBPathCapable(source, []) || !/\brequire\s*\(|\bimport\s*[{'"*]/.test(source)) {
|
|
2434
|
+
const seeds = mockDirectivesToAbsSeeds(
|
|
2435
|
+
extractDirectives(file ?? parse(source)),
|
|
2436
|
+
);
|
|
2437
|
+
const { modules } = evalAbsModuleGraph(source, filePath);
|
|
2438
|
+
const nodeTypes = collectAbsNodeTypes(source, {
|
|
2439
|
+
...seeds,
|
|
2440
|
+
modules,
|
|
2441
|
+
...(file ? { file } : {}),
|
|
2442
|
+
});
|
|
2443
|
+
const absAt = findAbsAtPosition(nodeTypes, line, column);
|
|
2444
|
+
if (absAt) {
|
|
2445
|
+
const absLine = formatAbs(absAt);
|
|
2446
|
+
const absMulti = formatAbsMultiline(absAt, undefined);
|
|
2447
|
+
if (info) {
|
|
2448
|
+
info.abs = absLine;
|
|
2449
|
+
info.absMultiline = absMulti;
|
|
2450
|
+
} else {
|
|
2451
|
+
return { typeText: absLine, abs: absLine, absMultiline: absMulti };
|
|
2452
|
+
}
|
|
2453
|
+
}
|
|
2454
|
+
}
|
|
2455
|
+
} catch {
|
|
2456
|
+
// ignore
|
|
2457
|
+
}
|
|
2458
|
+
|
|
2459
|
+
return info;
|
|
2460
|
+
}
|
|
2461
|
+
|
|
2462
|
+
/** 光标处任意标识符(绑定 hover) */
|
|
2463
|
+
function findIdentNameAtPosition(
|
|
2464
|
+
source: string,
|
|
2465
|
+
line: number,
|
|
2466
|
+
column: number,
|
|
2467
|
+
fileAst?: ReturnType<typeof parse>,
|
|
2468
|
+
): string | undefined {
|
|
2469
|
+
try {
|
|
2470
|
+
const ast = fileAst ?? parse(source);
|
|
2471
|
+
let found: string | undefined;
|
|
2472
|
+
traverse(ast, {
|
|
2473
|
+
Identifier(path) {
|
|
2474
|
+
const loc = path.node.loc;
|
|
2475
|
+
if (!loc) return;
|
|
2476
|
+
if (loc.start.line !== line) return;
|
|
2477
|
+
if (column < loc.start.column || column > loc.end.column) return;
|
|
2478
|
+
found = path.node.name;
|
|
2479
|
+
},
|
|
2480
|
+
});
|
|
2481
|
+
return found;
|
|
2482
|
+
} catch {
|
|
2483
|
+
return undefined;
|
|
2484
|
+
}
|
|
2485
|
+
}
|
|
2486
|
+
|
|
2487
|
+
/** 光标处标识符是否是顶层/导出函数名 */
|
|
2488
|
+
function findFunctionNameAtPosition(
|
|
2489
|
+
source: string,
|
|
2490
|
+
line: number,
|
|
2491
|
+
column: number,
|
|
2492
|
+
fileAst?: ReturnType<typeof parse>,
|
|
2493
|
+
): string | undefined {
|
|
2494
|
+
try {
|
|
2495
|
+
const ast = fileAst ?? parse(source);
|
|
2496
|
+
let found: string | undefined;
|
|
2497
|
+
traverse(ast, {
|
|
2498
|
+
Identifier(path) {
|
|
2499
|
+
const loc = path.node.loc;
|
|
2500
|
+
if (!loc) return;
|
|
2501
|
+
if (loc.start.line !== line) return;
|
|
2502
|
+
if (column < loc.start.column || column > loc.end.column) return;
|
|
2503
|
+
// 仅函数声明 id / 调用 callee;const x = 1 的 id 归绑定路径
|
|
2504
|
+
const parent = path.parent;
|
|
2505
|
+
if (
|
|
2506
|
+
parent.type === "FunctionDeclaration" &&
|
|
2507
|
+
parent.id === path.node
|
|
2508
|
+
) {
|
|
2509
|
+
found = path.node.name;
|
|
2510
|
+
} else if (
|
|
2511
|
+
parent.type === "CallExpression" &&
|
|
2512
|
+
parent.callee === path.node
|
|
2513
|
+
) {
|
|
2514
|
+
found = path.node.name;
|
|
2515
|
+
}
|
|
2516
|
+
},
|
|
2517
|
+
});
|
|
2518
|
+
return found;
|
|
2519
|
+
} catch {
|
|
2520
|
+
return undefined;
|
|
2521
|
+
}
|
|
2522
|
+
}
|
|
2523
|
+
|
|
1621
2524
|
function findEnclosingFunction(
|
|
1622
2525
|
functions: FunctionWithDirectives[],
|
|
1623
2526
|
line: number,
|
|
@@ -1771,6 +2674,127 @@ function getVariableCompletions(filePath: string, source: string): CompletionIte
|
|
|
1771
2674
|
return completions;
|
|
1772
2675
|
}
|
|
1773
2676
|
|
|
2677
|
+
/**
|
|
2678
|
+
* 内置成员的真实签名:把「<类>.prototype.<成员>」交给 evaluator 微求值。
|
|
2679
|
+
* 这是唯一真值来源(与诊断/求值同表——BUILTIN_PROTOTYPE_METHOD_APPROXIMATIONS),
|
|
2680
|
+
* 不在补全侧另建平行类型系统。evaluate 直接返回 TypeValue(或 return/throw
|
|
2681
|
+
* 控制标记,此处到不了);无 kind 的结果一律视为不可用返回 null 由调用方回退。
|
|
2682
|
+
* 微求值受 evaluator 全局态(当前 env、resolver)影响,仅用于补全展示。
|
|
2683
|
+
*/
|
|
2684
|
+
function builtinMemberType(memberExpr: string): TypeValue | null {
|
|
2685
|
+
try {
|
|
2686
|
+
const result: unknown = evaluate(parse(`${memberExpr};`), createEnvironment());
|
|
2687
|
+
if (!result || typeof result !== "object" || !("kind" in result)) return null;
|
|
2688
|
+
return result as TypeValue;
|
|
2689
|
+
} catch {
|
|
2690
|
+
return null;
|
|
2691
|
+
}
|
|
2692
|
+
}
|
|
2693
|
+
|
|
2694
|
+
/**
|
|
2695
|
+
* 内置成员补全的唯一真值来源:求值器原型近似表
|
|
2696
|
+
* (BUILTIN_PROTOTYPE_METHOD_APPROXIMATIONS)。表内新增建模的方法
|
|
2697
|
+
* (如 flatMap)自动进入补全,不再手工同步平行名单;表外方法未建模,
|
|
2698
|
+
* 微求值拿 undefined、列出只会得到回退文案,故不派生。
|
|
2699
|
+
*/
|
|
2700
|
+
function builtinProtoMembers(className: string): string[] {
|
|
2701
|
+
const table = BUILTIN_PROTOTYPE_METHOD_APPROXIMATIONS[className];
|
|
2702
|
+
return table ? Object.keys(table) : [];
|
|
2703
|
+
}
|
|
2704
|
+
|
|
2705
|
+
/**
|
|
2706
|
+
* 成员 detail 的展示形态:内置方法优先取 evaluator 的真实 fnSig
|
|
2707
|
+
* (typeValueToString 渲染为 `(a: string) => boolean` 形式);无签名
|
|
2708
|
+
* (未建模或求值异常)时退回 `成员名(…)@<类>` 概要。取舍:不伪造平行签名,
|
|
2709
|
+
* 缺席就明示缺席——detail 永远可解释、与求值结果一致。
|
|
2710
|
+
*/
|
|
2711
|
+
function describeMember(label: string, tv: TypeValue | null, fallbackClass: string): string {
|
|
2712
|
+
if (tv) {
|
|
2713
|
+
const sig = getFnSig(tv);
|
|
2714
|
+
if (sig) {
|
|
2715
|
+
const paramNames = tv.kind === "function" ? tv.params : [];
|
|
2716
|
+
const params = sig.paramTypes.map((p, i) => `${paramNames[i] ?? `arg${i}`}: ${typeValueToString(p)}`).join(", ");
|
|
2717
|
+
return `(${params}) => ${typeValueToString(sig.returnType)}`;
|
|
2718
|
+
}
|
|
2719
|
+
if (tv.kind !== "function") return typeValueToString(tv);
|
|
2720
|
+
}
|
|
2721
|
+
return `${label}(…)@${fallbackClass}`;
|
|
2722
|
+
}
|
|
2723
|
+
|
|
2724
|
+
function getArrayCompletions(tv: TypeValue): CompletionItem[] {
|
|
2725
|
+
const completions: CompletionItem[] = [];
|
|
2726
|
+
for (const m of builtinProtoMembers("Array")) {
|
|
2727
|
+
const detail = describeMember(m, builtinMemberType(`Array.prototype.${m}`), "Array");
|
|
2728
|
+
completions.push({ label: m, kind: "method", detail });
|
|
2729
|
+
}
|
|
2730
|
+
completions.push({
|
|
2731
|
+
label: "length",
|
|
2732
|
+
kind: "property",
|
|
2733
|
+
// tuple 长度是精确字面量;array 是 number(保持原展示语义)
|
|
2734
|
+
detail: tv.kind === "tuple" ? `${tv.elements.length}` : "number",
|
|
2735
|
+
});
|
|
2736
|
+
return completions;
|
|
2737
|
+
}
|
|
2738
|
+
|
|
2739
|
+
function getPromiseCompletions(): CompletionItem[] {
|
|
2740
|
+
return builtinProtoMembers("Promise").map((m) => ({
|
|
2741
|
+
label: m,
|
|
2742
|
+
kind: "method" as const,
|
|
2743
|
+
detail: describeMember(m, builtinMemberType(`Promise.prototype.${m}`), "Promise"),
|
|
2744
|
+
}));
|
|
2745
|
+
}
|
|
2746
|
+
|
|
2747
|
+
function getStringCompletions(): CompletionItem[] {
|
|
2748
|
+
const completions: CompletionItem[] = [];
|
|
2749
|
+
for (const m of builtinProtoMembers("String")) {
|
|
2750
|
+
completions.push({
|
|
2751
|
+
label: m,
|
|
2752
|
+
kind: "method",
|
|
2753
|
+
detail: describeMember(m, builtinMemberType(`"s".${m}`), "String"),
|
|
2754
|
+
});
|
|
2755
|
+
}
|
|
2756
|
+
completions.push({ label: "length", kind: "property", detail: "number" });
|
|
2757
|
+
return completions;
|
|
2758
|
+
}
|
|
2759
|
+
|
|
2760
|
+
/**
|
|
2761
|
+
* union 接收者:各成员补全取交集(对成员全部「可能存在」的公共键),
|
|
2762
|
+
* detail 为各成员该键类型字符串的并集渲染。键序取首个含该键的成员序,
|
|
2763
|
+
* 稳定且与成员书写顺序一致。无公共键返回空——打点补全只展示确定可用
|
|
2764
|
+
* 的成员,不做「部分成员才有」的投机提示。
|
|
2765
|
+
*/
|
|
2766
|
+
function getUnionCompletions(tv: TypeValue & { kind: "union" }): CompletionItem[] {
|
|
2767
|
+
const members = tv.members;
|
|
2768
|
+
if (members.length === 0) return [];
|
|
2769
|
+
|
|
2770
|
+
const labelsByMember = members.map((m) => getCompletionsForType(m));
|
|
2771
|
+
// 首个非空成员集的键序作基准;对空集成员(无任何已知成员,如 unknown)
|
|
2772
|
+
// 视为「任何键都可能存在」——跳过其过滤而非让交集归零
|
|
2773
|
+
const baseIdx = labelsByMember.findIndex((labels) => labels.length > 0);
|
|
2774
|
+
if (baseIdx === -1) return [];
|
|
2775
|
+
|
|
2776
|
+
const common: CompletionItem[] = [];
|
|
2777
|
+
for (const base of labelsByMember[baseIdx]) {
|
|
2778
|
+
let allPresent = true;
|
|
2779
|
+
const memberTypes: string[] = [base.detail ?? base.label];
|
|
2780
|
+
for (let i = 0; i < members.length; i++) {
|
|
2781
|
+
if (i === baseIdx) continue;
|
|
2782
|
+
const labels = labelsByMember[i];
|
|
2783
|
+
if (labels.length === 0) continue; // 该成员无已知成员集 → 不约束交集
|
|
2784
|
+
const hit = labels.find((l) => l.label === base.label);
|
|
2785
|
+
if (!hit) {
|
|
2786
|
+
allPresent = false;
|
|
2787
|
+
break;
|
|
2788
|
+
}
|
|
2789
|
+
memberTypes.push(hit.detail ?? hit.label);
|
|
2790
|
+
}
|
|
2791
|
+
if (allPresent) {
|
|
2792
|
+
common.push({ ...base, detail: memberTypes.join(" | ") });
|
|
2793
|
+
}
|
|
2794
|
+
}
|
|
2795
|
+
return common;
|
|
2796
|
+
}
|
|
2797
|
+
|
|
1774
2798
|
function getCompletionsForType(tv: TypeValue): CompletionItem[] {
|
|
1775
2799
|
const completions: CompletionItem[] = [];
|
|
1776
2800
|
|
|
@@ -1796,46 +2820,314 @@ function getCompletionsForType(tv: TypeValue): CompletionItem[] {
|
|
|
1796
2820
|
return completions;
|
|
1797
2821
|
}
|
|
1798
2822
|
|
|
2823
|
+
if (tv.kind === "union") {
|
|
2824
|
+
return getUnionCompletions(tv);
|
|
2825
|
+
}
|
|
2826
|
+
|
|
1799
2827
|
if (tv.kind === "array" || tv.kind === "tuple") {
|
|
1800
|
-
|
|
1801
|
-
{ label: "map", detail: "map(callback)" },
|
|
1802
|
-
{ label: "filter", detail: "filter(callback)" },
|
|
1803
|
-
{ label: "reduce", detail: "reduce(callback, init)" },
|
|
1804
|
-
{ label: "find", detail: "find(callback)" },
|
|
1805
|
-
{ label: "some", detail: "some(callback)" },
|
|
1806
|
-
{ label: "every", detail: "every(callback)" },
|
|
1807
|
-
{ label: "forEach", detail: "forEach(callback)" },
|
|
1808
|
-
{ label: "flatMap", detail: "flatMap(callback)" },
|
|
1809
|
-
{ label: "includes", detail: "includes(value)" },
|
|
1810
|
-
{ label: "indexOf", detail: "indexOf(value)" },
|
|
1811
|
-
{ label: "join", detail: "join(separator)" },
|
|
1812
|
-
{ label: "slice", detail: "slice(start, end)" },
|
|
1813
|
-
{ label: "concat", detail: "concat(other)" },
|
|
1814
|
-
{ label: "push", detail: "push(value)" },
|
|
1815
|
-
{ label: "length", detail: tv.kind === "tuple" ? `${tv.elements.length}` : "number" },
|
|
1816
|
-
];
|
|
1817
|
-
for (const m of arrayMethods) {
|
|
1818
|
-
completions.push({ label: m.label, kind: "method", detail: m.detail });
|
|
1819
|
-
}
|
|
1820
|
-
return completions;
|
|
2828
|
+
return getArrayCompletions(tv);
|
|
1821
2829
|
}
|
|
1822
2830
|
|
|
1823
2831
|
if (tv.kind === "promise") {
|
|
1824
|
-
|
|
1825
|
-
completions.push({ label: "catch", kind: "method", detail: "catch(callback)" });
|
|
1826
|
-
completions.push({ label: "finally", kind: "method", detail: "finally(callback)" });
|
|
1827
|
-
return completions;
|
|
2832
|
+
return getPromiseCompletions();
|
|
1828
2833
|
}
|
|
1829
2834
|
|
|
1830
2835
|
if (tv.kind === "primitive" && tv.type === "string") {
|
|
1831
|
-
|
|
1832
|
-
for (const m of stringMethods) {
|
|
1833
|
-
completions.push({ label: m, kind: "method", detail: `string.${m}` });
|
|
1834
|
-
}
|
|
1835
|
-
return completions;
|
|
2836
|
+
return getStringCompletions();
|
|
1836
2837
|
}
|
|
1837
2838
|
|
|
1838
2839
|
return completions;
|
|
1839
2840
|
}
|
|
1840
2841
|
|
|
2842
|
+
/**
|
|
2843
|
+
* Abs 是否比 TypeValue 求值结果更有信息量。
|
|
2844
|
+
* 仅在 TypeValue 侧 unknown / 裸 number 且 Abs 带约束时替换,
|
|
2845
|
+
* 避免用 Abs 的粗结果盖掉 mock/callsite 已精确投影的结构。
|
|
2846
|
+
*/
|
|
2847
|
+
function absIsBetter(absTv: TypeValue, prev: TypeValue): boolean {
|
|
2848
|
+
if (prev.kind === "unknown") return absTv.kind !== "unknown";
|
|
2849
|
+
if (absTv.kind === "unknown" || absTv.kind === "never") return false;
|
|
2850
|
+
// 保留结构化结果(object/array/tuple/union/literal)
|
|
2851
|
+
if (
|
|
2852
|
+
prev.kind === "literal" ||
|
|
2853
|
+
prev.kind === "object" ||
|
|
2854
|
+
prev.kind === "array" ||
|
|
2855
|
+
prev.kind === "tuple" ||
|
|
2856
|
+
prev.kind === "union" ||
|
|
2857
|
+
prev.kind === "function" ||
|
|
2858
|
+
prev.kind === "instance" ||
|
|
2859
|
+
prev.kind === "promise"
|
|
2860
|
+
) {
|
|
2861
|
+
return false;
|
|
2862
|
+
}
|
|
2863
|
+
// Abs refined vs 裸 primitive:约束更有信息
|
|
2864
|
+
if (absTv.kind === "refined" && prev.kind === "primitive") return true;
|
|
2865
|
+
return false;
|
|
2866
|
+
}
|
|
2867
|
+
/**
|
|
2868
|
+
* 自包含 = 无 import/require、无 @nudo:env。
|
|
2869
|
+
* @nudo:mock 不阻断 Abs:已编译为 seedVars/seedFns 注入 evalProgramAbs。
|
|
2870
|
+
* 相对 import 经 Abs 模块图注入后,也不再阻断 Abs 路径。
|
|
2871
|
+
*/
|
|
2872
|
+
function isSelfContainedSource(source: string, envNames: string[]): boolean {
|
|
2873
|
+
if (envNames.length > 0) return false;
|
|
2874
|
+
return !/\brequire\s*\(|\bimport\s*[{'"*]/.test(source);
|
|
2875
|
+
}
|
|
2876
|
+
|
|
2877
|
+
/** Abs 模块图可处理:env 由 loadEnvs 处理(内置 + 预加载路径型) */
|
|
2878
|
+
function absModulesOk(source: string, envNames: string[]): boolean {
|
|
2879
|
+
void source;
|
|
2880
|
+
void envNames;
|
|
2881
|
+
return true;
|
|
2882
|
+
}
|
|
2883
|
+
|
|
2884
|
+
/**
|
|
2885
|
+
* Abs 程序级求值收集调用记录(类型即计算)。
|
|
2886
|
+
* filePath 存在时经模块图注入相对 import / 裸包 harvest;
|
|
2887
|
+
* import 局部名 → targetModule/targetExport(供 externalFunctions)。
|
|
2888
|
+
*/
|
|
2889
|
+
function collectAbsCallRecords(
|
|
2890
|
+
source: string,
|
|
2891
|
+
seeds?: {
|
|
2892
|
+
seedVars?: Record<string, Abs>;
|
|
2893
|
+
seedFns?: Record<string, { params: string[]; body: Node; async?: boolean }>;
|
|
2894
|
+
},
|
|
2895
|
+
filePath?: string,
|
|
2896
|
+
precomputedModules?: Record<string, import("@nudojs/core").AbsModuleExports>,
|
|
2897
|
+
): CallRecord[] {
|
|
2898
|
+
const absCalls: AbsCallRecord[] = [];
|
|
2899
|
+
let modules: Record<string, import("@nudojs/core").AbsModuleExports> | undefined =
|
|
2900
|
+
precomputedModules;
|
|
2901
|
+
let importLocals = new Map<string, { modulePath: string; exportName: string }>();
|
|
2902
|
+
if (filePath && absModulesOk(source, [])) {
|
|
2903
|
+
if (!modules) {
|
|
2904
|
+
try {
|
|
2905
|
+
const graph = evalAbsModuleGraph(source, filePath, {
|
|
2906
|
+
seedVars: seeds?.seedVars,
|
|
2907
|
+
seedFns: seeds?.seedFns,
|
|
2908
|
+
});
|
|
2909
|
+
modules = graph.modules;
|
|
2910
|
+
} catch {
|
|
2911
|
+
modules = undefined;
|
|
2912
|
+
}
|
|
2913
|
+
}
|
|
2914
|
+
try {
|
|
2915
|
+
importLocals = buildAbsImportLocalMap(source, filePath);
|
|
2916
|
+
} catch {
|
|
2917
|
+
importLocals = new Map();
|
|
2918
|
+
}
|
|
2919
|
+
}
|
|
2920
|
+
setAbsCallCollector((r) => absCalls.push(r));
|
|
2921
|
+
try {
|
|
2922
|
+
evalProgramAbs(source, { ...seeds, modules });
|
|
2923
|
+
} catch {
|
|
2924
|
+
// Abs 求值失败:交还 TypeValue 路径
|
|
2925
|
+
} finally {
|
|
2926
|
+
setAbsCallCollector(null);
|
|
2927
|
+
}
|
|
2928
|
+
return absCalls.map((r): CallRecord => {
|
|
2929
|
+
const rec: CallRecord = {
|
|
2930
|
+
fnName: r.fnName,
|
|
2931
|
+
argTypes: r.args.map((a) => absToTypeValue(a)),
|
|
2932
|
+
resultType: r.threw ? T.never : absToTypeValue(r.result),
|
|
2933
|
+
throws: r.threw ? absToTypeValue(r.result) : T.never,
|
|
2934
|
+
callLoc: r.callLoc,
|
|
2935
|
+
};
|
|
2936
|
+
const imp = importLocals.get(r.fnName);
|
|
2937
|
+
if (imp) {
|
|
2938
|
+
rec.targetModule = imp.modulePath;
|
|
2939
|
+
rec.targetExport = imp.exportName;
|
|
2940
|
+
}
|
|
2941
|
+
return rec;
|
|
2942
|
+
});
|
|
2943
|
+
}
|
|
2944
|
+
|
|
2945
|
+
/**
|
|
2946
|
+
* 一次 evalProgramAbs 同时收集顶层绑定 + 节点 Abs(B-hosted 补齐 hover/bindings)。
|
|
2947
|
+
* modules 可预计算,避免再跑一遍模块图。
|
|
2948
|
+
*/
|
|
2949
|
+
function collectAbsBindsAndNodes(
|
|
2950
|
+
source: string,
|
|
2951
|
+
seeds?: {
|
|
2952
|
+
seedVars?: Record<string, Abs>;
|
|
2953
|
+
seedFns?: Record<string, { params: string[]; body: Node; async?: boolean }>;
|
|
2954
|
+
},
|
|
2955
|
+
modules?: Record<string, import("@nudojs/core").AbsModuleExports>,
|
|
2956
|
+
): { binds: Map<string, Abs>; nodes: Map<Node, Abs> } {
|
|
2957
|
+
const binds = new Map<string, Abs>();
|
|
2958
|
+
const nodes = new Map<Node, Abs>();
|
|
2959
|
+
setAbsNodeCollector((node, value) => {
|
|
2960
|
+
nodes.set(node, value);
|
|
2961
|
+
});
|
|
2962
|
+
try {
|
|
2963
|
+
const { env } = evalProgramAbs(source, {
|
|
2964
|
+
...seeds,
|
|
2965
|
+
modules,
|
|
2966
|
+
});
|
|
2967
|
+
for (const [k, v] of env.vars) binds.set(k, v);
|
|
2968
|
+
for (const [name, impl] of env.fns) {
|
|
2969
|
+
if (!binds.has(name)) {
|
|
2970
|
+
binds.set(
|
|
2971
|
+
name,
|
|
2972
|
+
absFunction(impl.params, { body: impl.body, async: impl.async, env }),
|
|
2973
|
+
);
|
|
2974
|
+
}
|
|
2975
|
+
}
|
|
2976
|
+
} catch {
|
|
2977
|
+
/* ignore */
|
|
2978
|
+
} finally {
|
|
2979
|
+
setAbsNodeCollector(null);
|
|
2980
|
+
}
|
|
2981
|
+
return { binds, nodes };
|
|
2982
|
+
}
|
|
2983
|
+
|
|
2984
|
+
/** 入口 import 局部绑定 → 解析后的模块路径 + 导出名 */
|
|
2985
|
+
function buildAbsImportLocalMap(
|
|
2986
|
+
source: string,
|
|
2987
|
+
fromFile: string,
|
|
2988
|
+
): Map<string, { modulePath: string; exportName: string }> {
|
|
2989
|
+
const out = new Map<string, { modulePath: string; exportName: string }>();
|
|
2990
|
+
try {
|
|
2991
|
+
const file = parse(source);
|
|
2992
|
+
for (const stmt of file.program.body) {
|
|
2993
|
+
if (stmt.type !== "ImportDeclaration") continue;
|
|
2994
|
+
const spec = stmt.source.value;
|
|
2995
|
+
let modulePath: string | null = null;
|
|
2996
|
+
if (spec.startsWith(".") || spec.startsWith("/")) {
|
|
2997
|
+
modulePath = resolveImportAbs(spec, fromFile);
|
|
2998
|
+
} else if (!spec.startsWith("node:")) {
|
|
2999
|
+
// 裸包:用说明符本身作 module 标(externalFunctions 可显示)
|
|
3000
|
+
modulePath = spec;
|
|
3001
|
+
}
|
|
3002
|
+
if (!modulePath) continue;
|
|
3003
|
+
for (const s of stmt.specifiers) {
|
|
3004
|
+
if (s.type === "ImportSpecifier") {
|
|
3005
|
+
const imported =
|
|
3006
|
+
s.imported.type === "Identifier" ? s.imported.name : String(s.imported);
|
|
3007
|
+
out.set(s.local.name, { modulePath, exportName: imported });
|
|
3008
|
+
} else if (s.type === "ImportDefaultSpecifier") {
|
|
3009
|
+
out.set(s.local.name, { modulePath, exportName: "default" });
|
|
3010
|
+
}
|
|
3011
|
+
}
|
|
3012
|
+
}
|
|
3013
|
+
} catch {
|
|
3014
|
+
/* ignore */
|
|
3015
|
+
}
|
|
3016
|
+
return out;
|
|
3017
|
+
}
|
|
3018
|
+
|
|
3019
|
+
function resolveImportAbs(spec: string, fromFile: string): string | null {
|
|
3020
|
+
const base = dirname(resolve(fromFile));
|
|
3021
|
+
const p = resolve(base, spec);
|
|
3022
|
+
for (const cand of [p, `${p}.js`, `${p}.mjs`, `${p}.ts`, resolve(p, "index.js")]) {
|
|
3023
|
+
if (existsSync(cand) && !statSync(cand).isDirectory()) return cand;
|
|
3024
|
+
}
|
|
3025
|
+
return null;
|
|
3026
|
+
}
|
|
3027
|
+
|
|
3028
|
+
function tryEvalAbs(
|
|
3029
|
+
source: string,
|
|
3030
|
+
fnName: string,
|
|
3031
|
+
args: TypeValue[],
|
|
3032
|
+
filePath?: string,
|
|
3033
|
+
mocks?: Record<string, Abs>,
|
|
3034
|
+
): TypeValue | undefined {
|
|
3035
|
+
const raw = tryEvalAbsRaw(source, fnName, args, filePath, mocks);
|
|
3036
|
+
return raw ? absToTypeValue(raw) : undefined;
|
|
3037
|
+
}
|
|
3038
|
+
|
|
3039
|
+
/** Abs 原生重求值(无损);B 路径 transpile+exec 优先,失败回退 ast-eval */
|
|
3040
|
+
function tryEvalAbsRaw(
|
|
3041
|
+
source: string,
|
|
3042
|
+
fnName: string,
|
|
3043
|
+
args: TypeValue[],
|
|
3044
|
+
filePath?: string,
|
|
3045
|
+
mocks?: Record<string, Abs>,
|
|
3046
|
+
): Abs | undefined {
|
|
3047
|
+
// require / env 不走 Abs
|
|
3048
|
+
if (/\brequire\s*\(/.test(source)) return undefined;
|
|
3049
|
+
try {
|
|
3050
|
+
const absArgs: Abs[] = args.map((a) => typeValueToAbs(a));
|
|
3051
|
+
|
|
3052
|
+
// B 路径:transpile → Node new Function(进程内);注入 @nudo:mock
|
|
3053
|
+
if (filePath) {
|
|
3054
|
+
const viaB = tryBPathCall(source, filePath, fnName, absArgs, { mocks });
|
|
3055
|
+
if (viaB) return viaB;
|
|
3056
|
+
}
|
|
3057
|
+
|
|
3058
|
+
let modules: Record<string, import("@nudojs/core").AbsModuleExports> | undefined;
|
|
3059
|
+
if (filePath && /\bimport\s*[{'"*]/.test(source)) {
|
|
3060
|
+
modules = evalAbsModuleGraph(source, filePath).modules;
|
|
3061
|
+
}
|
|
3062
|
+
const result = analyzeFn(source, fnName, absArgs, undefined, undefined, undefined, modules);
|
|
3063
|
+
if (result.shape.k === "unknown" && !result.term) {
|
|
3064
|
+
return undefined;
|
|
3065
|
+
}
|
|
3066
|
+
return result;
|
|
3067
|
+
} catch {
|
|
3068
|
+
return undefined;
|
|
3069
|
+
}
|
|
3070
|
+
}
|
|
3071
|
+
|
|
3072
|
+
/**
|
|
3073
|
+
* entry@ 的 Abs 原生路径:自包含源码用 ast-eval(类型即计算),
|
|
3074
|
+
* TypeValue 只在出口投影。含 import/require 或求值失败时返回 undefined。
|
|
3075
|
+
*/
|
|
3076
|
+
function tryEvalEntryAbs(
|
|
3077
|
+
source: string,
|
|
3078
|
+
fnName: string,
|
|
3079
|
+
args: TypeValue[],
|
|
3080
|
+
filePath?: string,
|
|
3081
|
+
mocks?: Record<string, Abs>,
|
|
3082
|
+
): TypeValue | undefined {
|
|
3083
|
+
return tryEvalAbs(source, fnName, args, filePath, mocks);
|
|
3084
|
+
}
|
|
3085
|
+
|
|
3086
|
+
/**
|
|
3087
|
+
* entry@ 附加内涵摘要(代数 generalize + 无损 Abs)。
|
|
3088
|
+
* 失败静默——不改变外延 TypeValue。
|
|
3089
|
+
*/
|
|
3090
|
+
function tryAttachIntension(
|
|
3091
|
+
caseResult: CaseResult,
|
|
3092
|
+
source: string,
|
|
3093
|
+
fnName: string,
|
|
3094
|
+
): void {
|
|
3095
|
+
try {
|
|
3096
|
+
const g = generalizeFromAst(fnName, source);
|
|
3097
|
+
if (!g) return;
|
|
3098
|
+
const sym = g.symbolic;
|
|
3099
|
+
const intension: NonNullable<CaseResult["intension"]> = {
|
|
3100
|
+
display: g.display,
|
|
3101
|
+
abs: formatAbs(sym),
|
|
3102
|
+
absMultiline: formatAbsMultiline(sym, fnName),
|
|
3103
|
+
};
|
|
3104
|
+
if (sym.term && sym.term.op !== "lit") {
|
|
3105
|
+
intension.term = termToString(sym.term);
|
|
3106
|
+
}
|
|
3107
|
+
if (sym.pred && sym.pred.op !== "true") {
|
|
3108
|
+
intension.pred = predToString(sym.pred);
|
|
3109
|
+
}
|
|
3110
|
+
intension.conf = sym.conf;
|
|
3111
|
+
caseResult.intension = intension;
|
|
3112
|
+
} catch {
|
|
3113
|
+
// ignore
|
|
3114
|
+
}
|
|
3115
|
+
}
|
|
3116
|
+
|
|
3117
|
+
/** 把一次 Abs 求值结果挂到 case 的 intension(无损) */
|
|
3118
|
+
function attachAbsToIntension(
|
|
3119
|
+
caseResult: CaseResult,
|
|
3120
|
+
absVal: Abs,
|
|
3121
|
+
label?: string,
|
|
3122
|
+
): void {
|
|
3123
|
+
const prev = caseResult.intension ?? {};
|
|
3124
|
+
caseResult.abs = absVal;
|
|
3125
|
+
caseResult.intension = {
|
|
3126
|
+
...prev,
|
|
3127
|
+
abs: formatAbs(absVal),
|
|
3128
|
+
absMultiline: formatAbsMultiline(absVal, label ?? caseResult.name),
|
|
3129
|
+
conf: absVal.conf,
|
|
3130
|
+
};
|
|
3131
|
+
}
|
|
3132
|
+
|
|
1841
3133
|
export type { CallRecord } from "@nudojs/cli/evaluator";
|