@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.
Files changed (64) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/dist/index.d.ts +502 -4
  3. package/dist/index.js +3017 -212
  4. package/package.json +5 -4
  5. package/src/__tests__/abs-bindings.test.ts +53 -0
  6. package/src/__tests__/abs-call-records.test.ts +33 -0
  7. package/src/__tests__/abs-calls.test.ts +59 -0
  8. package/src/__tests__/abs-module-cache.test.ts +121 -0
  9. package/src/__tests__/abs-modules-graph.test.ts +100 -0
  10. package/src/__tests__/abs-typevalue-diff.test.ts +111 -0
  11. package/src/__tests__/analyzer.test.ts +71 -3
  12. package/src/__tests__/bpath-arg-provenance.test.ts +104 -0
  13. package/src/__tests__/bpath-as.test.ts +54 -0
  14. package/src/__tests__/bpath-cache-key.test.ts +33 -0
  15. package/src/__tests__/bpath-calls.test.ts +55 -0
  16. package/src/__tests__/bpath-diagnostics.test.ts +78 -0
  17. package/src/__tests__/bpath-env-modules.test.ts +30 -0
  18. package/src/__tests__/bpath-env.test.ts +80 -0
  19. package/src/__tests__/bpath-hosted-skip.test.ts +71 -0
  20. package/src/__tests__/bpath-import-default-ns.test.ts +106 -0
  21. package/src/__tests__/bpath-method-dedupe.test.ts +65 -0
  22. package/src/__tests__/bpath-method-diag.test.ts +45 -0
  23. package/src/__tests__/bpath-module-diags.test.ts +101 -0
  24. package/src/__tests__/bpath-path-env.test.ts +46 -0
  25. package/src/__tests__/bpath-reexport.test.ts +131 -0
  26. package/src/__tests__/bpath-replace.test.ts +52 -0
  27. package/src/__tests__/bpath-require.test.ts +66 -0
  28. package/src/__tests__/bpath-run.test.ts +109 -0
  29. package/src/__tests__/cjs-require.test.ts +54 -0
  30. package/src/__tests__/diagnostics.test.ts +0 -30
  31. package/src/__tests__/fn-cache-evict.test.ts +56 -0
  32. package/src/__tests__/harvest-auto.test.ts +43 -0
  33. package/src/__tests__/harvest-node.test.ts +24 -0
  34. package/src/__tests__/harvest-package.test.ts +38 -0
  35. package/src/__tests__/harvest-to-abs.test.ts +46 -0
  36. package/src/__tests__/hover-intension.test.ts +39 -0
  37. package/src/__tests__/infer-json.test.ts +35 -0
  38. package/src/__tests__/integration.test.ts +89 -0
  39. package/src/__tests__/m3-intension.test.ts +28 -0
  40. package/src/__tests__/mock-seed-fingerprint.test.ts +64 -0
  41. package/src/__tests__/session-cache-evict.test.ts +90 -0
  42. package/src/__tests__/static-imports.test.ts +44 -0
  43. package/src/abs-modules-graph.ts +387 -0
  44. package/src/analysis-file-cache.ts +51 -0
  45. package/src/analyzer.ts +1416 -124
  46. package/src/bpath-diagnostics.ts +283 -0
  47. package/src/bpath-run.ts +409 -0
  48. package/src/case-emitter.ts +2 -0
  49. package/src/dts-generator.ts +3 -3
  50. package/src/env-to-abs.ts +57 -0
  51. package/src/fn-analysis-cache.ts +119 -0
  52. package/src/guard-generator.ts +8 -1
  53. package/src/harvest-auto.ts +86 -0
  54. package/src/harvest-node.ts +48 -0
  55. package/src/harvest-package.ts +204 -0
  56. package/src/harvest-to-abs.ts +129 -0
  57. package/src/index.ts +104 -1
  58. package/src/infer-json.ts +137 -0
  59. package/src/load-module.ts +34 -0
  60. package/src/mock-abs.ts +275 -0
  61. package/src/semantic-tokens.ts +216 -0
  62. package/src/session-cache.ts +50 -0
  63. package/src/static-imports.ts +241 -0
  64. 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:returns to declare the expected return type`,
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
- if (envNames.length > 0) {
1082
- const loaded = loadEnvs(envNames, globalEnv);
1083
- setEnvModules(loaded.modules);
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
- evaluateProgram(ast, globalEnv);
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 unreachableRanges = getUnreachableRanges();
1122
- for (const ur of unreachableRanges) {
1123
- diagnostics.push({
1124
- range: ur,
1125
- severity: "info",
1126
- message: "Code after return/throw statement is unreachable",
1127
- tags: ["unnecessary"],
1128
- code: "nudo-unreachable",
1129
- suggestions: ["Remove the unreachable code after the return/throw statement"],
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
- analysis.cases.push({
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: fullResult.value,
1186
- throws: fullResult.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 = fullResult.throws.kind !== "never";
1192
- const resultStr = fullResult.value.kind !== "never"
1193
- ? typeValueToString(fullResult.value)
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(fullResult.value, directive.expected);
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(fullResult.value)}. The inferred return type does not match the expected type declared in the @nudo:case directive`,
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 (fullResult.throws.kind !== "never") {
1220
- const throwRange = fullResult.throwLoc ?? fnLoc;
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(fullResult.throws)}. Consider adding a try-catch block or using @nudo:returns to declare expected behavior`,
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
- for (const ur of caseUnreachable) {
1230
- diagnostics.push({
1231
- range: ur,
1232
- severity: "info",
1233
- message: "Code after return/throw statement is unreachable",
1234
- tags: ["unnecessary"],
1235
- code: "nudo-unreachable",
1236
- suggestions: ["Remove the unreachable code after the return/throw statement"],
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 (returnsDirective && returnsDirective.kind === "returns") {
1249
- analysis.assertionErrors = [];
1250
- for (const directive of caseDirectives) {
1251
- const result = evaluateFunction(fn.node, directive.args, globalEnv);
1252
- const matches = isSubtypeOf(result, returnsDirective.expected);
1253
- if (!matches) {
1254
- const msg = `@nudo:returns assertion failed for case "${directive.name}": expected ${typeValueToString(returnsDirective.expected)}, got ${typeValueToString(result)}. Update the @nudo:returns directive to match the inferred type, or fix the function implementation`;
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
- candidate.analysis.cases.push({
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 full = evaluateFunctionFull(fnNode, widenedArgs, globalEnv);
1398
- candidate.analysis.cases.push({
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: full.value,
1402
- throws: full.throws,
1403
- throwLoc: full.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
- const full = evaluateFunctionFull(fnNode, args, globalEnv);
1421
- candidate.analysis.cases.push({
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: full.value,
1425
- throws: full.throws,
1426
- throwLoc: full.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(full.value, COLLAPSE_LITERAL_THRESHOLD);
2064
+ candidate.analysis.combined = collapseLiteralUnion(entryValue, COLLAPSE_LITERAL_THRESHOLD);
1430
2065
  }
1431
2066
 
1432
- buildNodeTypeMap(ast, globalEnv, nodeTypeMap);
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
- (r) => (r.loc?.line ?? 0) <= maxLine && r.originModule !== USAGE_SITE_MODULE,
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
- if (envNames.length > 0) {
1586
- const loaded = loadEnvs(envNames, globalEnv);
1587
- setEnvModules(loaded.modules);
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
- const arrayMethods = [
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
- completions.push({ label: "then", kind: "method", detail: "then(callback)" });
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
- const stringMethods = ["toUpperCase", "toLowerCase", "trim", "split", "slice", "includes", "indexOf", "replace", "startsWith", "endsWith", "charAt", "length"];
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";