@nudojs/service 0.2.0 → 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 (72) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/README.md +27 -0
  3. package/dist/index.d.ts +638 -5
  4. package/dist/index.js +4439 -208
  5. package/package.json +17 -4
  6. package/src/__tests__/abs-bindings.test.ts +53 -0
  7. package/src/__tests__/abs-call-records.test.ts +33 -0
  8. package/src/__tests__/abs-calls.test.ts +59 -0
  9. package/src/__tests__/abs-module-cache.test.ts +121 -0
  10. package/src/__tests__/abs-modules-graph.test.ts +100 -0
  11. package/src/__tests__/abs-typevalue-diff.test.ts +111 -0
  12. package/src/__tests__/analyzer.test.ts +745 -2
  13. package/src/__tests__/as-replace.test.ts +165 -0
  14. package/src/__tests__/bpath-arg-provenance.test.ts +104 -0
  15. package/src/__tests__/bpath-as.test.ts +54 -0
  16. package/src/__tests__/bpath-cache-key.test.ts +33 -0
  17. package/src/__tests__/bpath-calls.test.ts +55 -0
  18. package/src/__tests__/bpath-diagnostics.test.ts +78 -0
  19. package/src/__tests__/bpath-env-modules.test.ts +30 -0
  20. package/src/__tests__/bpath-env.test.ts +80 -0
  21. package/src/__tests__/bpath-hosted-skip.test.ts +71 -0
  22. package/src/__tests__/bpath-import-default-ns.test.ts +106 -0
  23. package/src/__tests__/bpath-method-dedupe.test.ts +65 -0
  24. package/src/__tests__/bpath-method-diag.test.ts +45 -0
  25. package/src/__tests__/bpath-module-diags.test.ts +101 -0
  26. package/src/__tests__/bpath-path-env.test.ts +46 -0
  27. package/src/__tests__/bpath-reexport.test.ts +131 -0
  28. package/src/__tests__/bpath-replace.test.ts +52 -0
  29. package/src/__tests__/bpath-require.test.ts +66 -0
  30. package/src/__tests__/bpath-run.test.ts +109 -0
  31. package/src/__tests__/case-emitter.test.ts +636 -0
  32. package/src/__tests__/cjs-require.test.ts +54 -0
  33. package/src/__tests__/diagnostics.test.ts +60 -0
  34. package/src/__tests__/dts-generator.test.ts +290 -6
  35. package/src/__tests__/fn-cache-evict.test.ts +56 -0
  36. package/src/__tests__/fn-sig-impl.test.ts +247 -0
  37. package/src/__tests__/guard-generator.test.ts +29 -0
  38. package/src/__tests__/harvest-auto.test.ts +43 -0
  39. package/src/__tests__/harvest-node.test.ts +24 -0
  40. package/src/__tests__/harvest-package.test.ts +38 -0
  41. package/src/__tests__/harvest-to-abs.test.ts +46 -0
  42. package/src/__tests__/hover-intension.test.ts +39 -0
  43. package/src/__tests__/infer-json.test.ts +35 -0
  44. package/src/__tests__/integration.test.ts +421 -0
  45. package/src/__tests__/m3-intension.test.ts +28 -0
  46. package/src/__tests__/mock-seed-fingerprint.test.ts +64 -0
  47. package/src/__tests__/schema-generator.test.ts +38 -0
  48. package/src/__tests__/session-cache-evict.test.ts +90 -0
  49. package/src/__tests__/static-imports.test.ts +44 -0
  50. package/src/abs-modules-graph.ts +387 -0
  51. package/src/analysis-file-cache.ts +51 -0
  52. package/src/analyzer.ts +2569 -94
  53. package/src/bpath-diagnostics.ts +283 -0
  54. package/src/bpath-run.ts +409 -0
  55. package/src/case-emitter.ts +477 -0
  56. package/src/dts-generator.ts +220 -24
  57. package/src/env-to-abs.ts +57 -0
  58. package/src/fn-analysis-cache.ts +119 -0
  59. package/src/guard-generator.ts +57 -0
  60. package/src/harvest-auto.ts +86 -0
  61. package/src/harvest-node.ts +48 -0
  62. package/src/harvest-package.ts +204 -0
  63. package/src/harvest-to-abs.ts +129 -0
  64. package/src/index.ts +128 -0
  65. package/src/infer-json.ts +137 -0
  66. package/src/load-module.ts +34 -0
  67. package/src/mock-abs.ts +275 -0
  68. package/src/schema-generator.ts +43 -0
  69. package/src/semantic-tokens.ts +216 -0
  70. package/src/session-cache.ts +50 -0
  71. package/src/static-imports.ts +241 -0
  72. package/src/target-path.ts +17 -0
package/src/analyzer.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { readFileSync, existsSync } from "node:fs";
1
+ import { readFileSync, existsSync, statSync, realpathSync } from "node:fs";
2
2
  import { resolve, dirname } from "node:path";
3
3
  import type { Node } from "@babel/types";
4
4
  import traverse from "@babel/traverse";
@@ -7,16 +7,41 @@ import {
7
7
  T,
8
8
  typeValueToString,
9
9
  simplifyUnion,
10
+ widenLiteral,
11
+ collapseLiteralUnion,
10
12
  createEnvironment,
11
13
  isSubtypeOf,
12
14
  type Environment,
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,
13
35
  } from "@nudojs/core";
14
- import { parse, extractDirectives, parseTypeValueExpr } from "@nudojs/parser";
15
- import type { FunctionWithDirectives } from "@nudojs/parser";
36
+ import { parse, extractDirectives, extractFileDirectives, parseTypeValueExpr } from "@nudojs/parser";
37
+ import type { FunctionWithDirectives, SinonExpression } from "@nudojs/parser";
16
38
  import {
17
39
  evaluate,
18
40
  evaluateFunction,
19
41
  evaluateFunctionFull,
42
+ memberMayExistOn,
43
+ setUsageSiteTag,
44
+ USAGE_SITE_MODULE,
20
45
  evaluateProgram,
21
46
  setModuleResolver,
22
47
  setCurrentFileDir,
@@ -24,8 +49,41 @@ import {
24
49
  getUnreachableRanges,
25
50
  resetUnreachableRanges,
26
51
  setNodeTypeCollector,
52
+ setCallCollector,
53
+ type CallRecord,
54
+ setUnknownCollector,
55
+ setProvenanceTracking,
56
+ type UnknownRecord,
27
57
  setSampleCount,
58
+ setUnknownBuiltinHandler,
59
+ setEnvModules,
60
+ resetEnvModules,
61
+ setMockModules,
62
+ resetMockModules,
63
+ setCurrentSource,
64
+ loadEnvs,
65
+ preloadPathEnvs,
66
+ findProjectConfig,
67
+ resolveNpmNudo,
68
+ BUILTIN_PROTOTYPE_METHOD_APPROXIMATIONS,
28
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";
29
87
 
30
88
  export type SourceLocation = {
31
89
  start: { line: number; column: number };
@@ -41,6 +99,11 @@ export type Diagnostic = {
41
99
  severity: DiagnosticSeverity;
42
100
  message: string;
43
101
  tags?: DiagnosticTag[];
102
+ code?: string;
103
+ suggestions?: string[];
104
+ data?: unknown;
105
+ /** provenance of the receiver value (callsite argument that flowed into the error) */
106
+ origin?: { line: number; column: number };
44
107
  };
45
108
 
46
109
  export type CaseResult = {
@@ -49,14 +112,46 @@ export type CaseResult = {
49
112
  result: TypeValue;
50
113
  throws: TypeValue;
51
114
  throwLoc?: SourceLocation;
115
+ source?: "directive" | "callsite";
116
+ /** `@nudo:case "name" (…) => expected` — presence means the case is a test assertion */
117
+ expected?: TypeValue;
118
+ /** number of additional call sites folded into a symbolic case */
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;
52
136
  };
53
137
 
54
138
  export type FunctionAnalysis = {
55
139
  name: string;
56
140
  loc: SourceLocation;
141
+ paramNames: string[];
57
142
  cases: CaseResult[];
58
143
  combined?: TypeValue;
59
- assertionErrors?: string[];
144
+ entryOnly?: boolean;
145
+ skipped?: boolean;
146
+ /**
147
+ * True for functions collected from CJS-style bindings/assignments
148
+ * (`exports.X = fn`, `module.exports = fn`, `const f = fn`): their name has
149
+ * no declaration-level stability, so .d.ts generation skips them while
150
+ * infer/JSON output still reports them.
151
+ */
152
+ noDeclaration?: boolean;
153
+ /** absolute path of the module this function is imported from (externalFunctions only) */
154
+ fromModule?: string;
60
155
  };
61
156
 
62
157
  export type BindingInfo = {
@@ -74,8 +169,16 @@ export type AnalysisResult = {
74
169
  functions: FunctionAnalysis[];
75
170
  diagnostics: Diagnostic[];
76
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
+ */
77
178
  nodeTypeMap: Map<Node, TypeValue>;
78
179
  caseHints: CaseHint[];
180
+ /** functions imported from other modules, synthesized from cross-file call sites observed while analyzing this file */
181
+ externalFunctions?: FunctionAnalysis[];
79
182
  };
80
183
 
81
184
  export type CompletionItem = {
@@ -84,27 +187,231 @@ export type CompletionItem = {
84
187
  detail?: string;
85
188
  };
86
189
 
87
- function resolveModule(source: string, fromDir: string): { ast: ReturnType<typeof parse>; filePath: string } | null {
190
+ export type SymbolInfo = {
191
+ name: string;
192
+ kind: "function" | "variable" | "class" | "parameter";
193
+ loc: SourceLocation;
194
+ uri?: string;
195
+ };
196
+
197
+ export type ReferenceInfo = {
198
+ name: string;
199
+ loc: SourceLocation;
200
+ uri?: string;
201
+ };
202
+
203
+ export type SymbolTable = {
204
+ definitions: Map<string, SymbolInfo>;
205
+ references: ReferenceInfo[];
206
+ };
207
+
208
+ export function resolveModule(source: string, fromDir: string): { ast: ReturnType<typeof parse>; filePath: string; json?: unknown } | null {
88
209
  const extensions = [".js", ".ts", ".mjs"];
210
+
211
+ const nudoPath = resolveNpmNudo(source, fromDir);
212
+ if (nudoPath) {
213
+ const src = readFileSync(nudoPath, "utf-8");
214
+ return { ast: parse(src), filePath: nudoPath };
215
+ }
216
+
89
217
  const basePath = resolve(fromDir, source);
90
218
  for (const ext of ["", ...extensions]) {
91
219
  const candidate = basePath + ext;
92
- if (existsSync(candidate)) {
93
- const src = readFileSync(candidate, "utf-8");
94
- return { ast: parse(src), filePath: candidate };
220
+ if (!existsSync(candidate)) continue;
221
+ // 目录:按 package.json main / index.js 解析(require('..') 模式)
222
+ if (statSync(candidate).isDirectory()) {
223
+ let entry: string | null = null;
224
+ const pkgPath = resolve(candidate, "package.json");
225
+ if (existsSync(pkgPath)) {
226
+ try {
227
+ const main = JSON.parse(readFileSync(pkgPath, "utf-8")).main;
228
+ if (typeof main === "string") {
229
+ for (const e of ["", ...extensions]) {
230
+ const p = resolve(candidate, main + e);
231
+ if (existsSync(p) && statSync(p).isFile()) { entry = p; break; }
232
+ }
233
+ }
234
+ } catch { /* 无效 package.json → fallback index */ }
235
+ }
236
+ if (!entry) {
237
+ for (const e of ["", ...extensions]) {
238
+ const p = resolve(candidate, "index" + e);
239
+ if (existsSync(p) && statSync(p).isFile()) { entry = p; break; }
240
+ }
241
+ }
242
+ if (!entry) return null;
243
+ const src = readFileSync(entry, "utf-8");
244
+ return { ast: parse(src), filePath: entry };
245
+ }
246
+ // .json 模块:require('../package.json') 等模式——按 JSON 求值而非 JS parse
247
+ if (candidate.endsWith(".json")) {
248
+ try {
249
+ return { ast: parse("module.exports = undefined;"), filePath: candidate, json: JSON.parse(readFileSync(candidate, "utf-8")) };
250
+ } catch {
251
+ return null;
252
+ }
95
253
  }
254
+ const src = readFileSync(candidate, "utf-8");
255
+ return { ast: parse(src), filePath: candidate };
96
256
  }
97
257
  return null;
98
258
  }
99
259
 
260
+ /** Resolve a relative import specifier to an existing file (extension rules identical to CLI resolveModule: ''/'.js'/'.ts'/'.mjs'); null when unresolvable. */
261
+ function resolveImportPath(specifier: string, fromDir: string): string | null {
262
+ const basePath = resolve(fromDir, specifier);
263
+ for (const ext of ["", ".js", ".ts", ".mjs"]) {
264
+ const candidate = basePath + ext;
265
+ if (existsSync(candidate)) return candidate;
266
+ }
267
+ return null;
268
+ }
269
+
270
+ /** mtime 边缓存:key 为文件路径,edges 为已抽取的相对 import 边(与 buildModuleGraph 返回语义一致)。 */
271
+ export type ModuleGraphCache = Map<string, { mtimeMs: number; size: number; edges: string[] }>;
272
+
273
+ /** Statically extract each file's relative import edges (extension resolution identical to CLI resolveModule: ''/'.js'/'.ts'/'.mjs'; bare npm specifiers skipped). */
274
+ export function buildModuleGraph(
275
+ files: string[],
276
+ cache?: ModuleGraphCache,
277
+ ): {
278
+ imports: Map<string, Set<string>>;
279
+ dependents: Map<string, Set<string>>;
280
+ } {
281
+ const imports = new Map<string, Set<string>>();
282
+ const dependents = new Map<string, Set<string>>();
283
+ const edge = (from: string, to: string) => {
284
+ if (!imports.has(from)) imports.set(from, new Set());
285
+ if (!dependents.has(to)) dependents.set(to, new Set());
286
+ imports.get(from)!.add(to);
287
+ dependents.get(to)!.add(from);
288
+ };
289
+ for (const file of files) {
290
+ if (!imports.has(file)) imports.set(file, new Set());
291
+ if (!dependents.has(file)) dependents.set(file, new Set());
292
+ let edges: string[];
293
+ if (cache) {
294
+ // stat 仅取元数据不读内容;mtimeMs+size 均一致视为命中,复用边并跳过磁盘读取与解析
295
+ let stat: ReturnType<typeof statSync> | null = null;
296
+ try {
297
+ stat = statSync(file);
298
+ } catch {
299
+ /* stat 失败(文件被删等)按未命中处理,走直读兜底 */
300
+ }
301
+ const cached = stat ? cache.get(file) : undefined;
302
+ if (stat && cached && cached.mtimeMs === stat.mtimeMs && cached.size === stat.size) {
303
+ edges = cached.edges;
304
+ } else {
305
+ edges = extractImportEdges(file);
306
+ if (stat) cache.set(file, { mtimeMs: stat.mtimeMs, size: stat.size, edges });
307
+ }
308
+ } else {
309
+ edges = extractImportEdges(file);
310
+ }
311
+ for (const to of edges) edge(file, to);
312
+ }
313
+ return { imports, dependents };
314
+ }
315
+
316
+ /** 磁盘直读并解析单个文件,抽取其相对 import 边(读取/解析失败返回空数组)。 */
317
+ function extractImportEdges(file: string): string[] {
318
+ const edges: string[] = [];
319
+ let ast: ReturnType<typeof parse>;
320
+ try {
321
+ ast = parse(readFileSync(file, "utf-8"));
322
+ } catch {
323
+ return edges;
324
+ }
325
+ for (const stmt of ast.program.body) {
326
+ if (stmt.type !== "ImportDeclaration") continue;
327
+ const specifier = stmt.source.value;
328
+ if (!specifier.startsWith(".") && !specifier.startsWith("/")) continue;
329
+ const resolved = resolveImportPath(specifier, dirname(file));
330
+ if (resolved) edges.push(resolved);
331
+ }
332
+ return edges;
333
+ }
334
+
335
+ /** changed plus its transitive dependents (reverse-edge BFS); cycle-safe via visited. */
336
+ export function computeDirtySet(dependents: Map<string, Set<string>>, changedFile: string): string[] {
337
+ const dirty: string[] = [];
338
+ const visited = new Set<string>();
339
+ const queue = [changedFile];
340
+ while (queue.length > 0) {
341
+ const file = queue.shift()!;
342
+ if (visited.has(file)) continue;
343
+ visited.add(file);
344
+ dirty.push(file);
345
+ for (const dep of dependents.get(file) ?? []) {
346
+ if (!visited.has(dep)) queue.push(dep);
347
+ }
348
+ }
349
+ return dirty;
350
+ }
351
+
352
+ /** Topological order with dependencies before dependents (only imports edges internal to dirty; cycles tolerated — remaining files appended in arbitrary order). */
353
+ export function topoSortDirty(imports: Map<string, Set<string>>, dirty: string[]): string[] {
354
+ const inSet = new Set(dirty);
355
+ const pending = new Map<string, number>();
356
+ for (const file of dirty) {
357
+ let count = 0;
358
+ for (const dep of imports.get(file) ?? []) {
359
+ if (inSet.has(dep)) count++;
360
+ }
361
+ pending.set(file, count);
362
+ }
363
+ const ordered: string[] = [];
364
+ const ready = dirty.filter((f) => pending.get(f) === 0);
365
+ while (ready.length > 0) {
366
+ const file = ready.shift()!;
367
+ ordered.push(file);
368
+ for (const other of dirty) {
369
+ if (pending.get(other) === undefined) continue;
370
+ if ((imports.get(other) ?? new Set<string>()).has(file)) {
371
+ const next = pending.get(other)! - 1;
372
+ pending.set(other, next);
373
+ if (next === 0) ready.push(other);
374
+ }
375
+ }
376
+ }
377
+ for (const file of dirty) {
378
+ if (pending.has(file) && pending.get(file)! > 0) ordered.push(file);
379
+ }
380
+ return ordered;
381
+ }
382
+
100
383
  function applyMocks(
101
384
  directives: FunctionWithDirectives["directives"],
102
385
  env: Environment,
103
386
  filePath: string,
387
+ diagnostics: Diagnostic[],
104
388
  ): void {
105
389
  for (const d of directives) {
106
390
  if (d.kind !== "mock") continue;
107
- if (d.expression) {
391
+ if (d.arrowFn) {
392
+ const fnType = T.fn(d.arrowFn.params, d.arrowFn.body, env);
393
+ (fnType as any)._paramPatterns = d.arrowFn.paramPatterns;
394
+ env.bind(d.name, fnType);
395
+ } else if (d.nudoMock) {
396
+ const typeVal = mockHelperToTypeValue(d.nudoMock, env);
397
+ env.bind(d.name, typeVal);
398
+ } else if (d.sinonExpr) {
399
+ const sinonType = createSinonTypeValue(d.sinonExpr, env);
400
+ env.bind(d.name, sinonType);
401
+ } else if (d.expression) {
402
+ const expr = d.expression.trim();
403
+ if (expr.includes("(") && expr.includes(")") && !expr.startsWith("T.")) {
404
+ diagnostics.push({
405
+ range: { start: { line: 0, column: 0 }, end: { line: 0, column: 0 } },
406
+ severity: "warning",
407
+ message: `Mock expression "${expr}" could not be parsed as a known pattern`,
408
+ code: "nudo:mock-invalid",
409
+ suggestions: [
410
+ "Supported formats: stub(), stub().returns(value), spy(), mock()",
411
+ "Arrow functions: (args) => expression or (args) => { statements; return value; }",
412
+ ],
413
+ });
414
+ }
108
415
  env.bind(d.name, parseTypeValueExpr(d.expression));
109
416
  } else if (d.fromPath) {
110
417
  const mockPath = resolve(dirname(filePath), d.fromPath);
@@ -118,6 +425,31 @@ function applyMocks(
118
425
  }
119
426
  }
120
427
 
428
+ function createSinonTypeValue(sinonExpr: SinonExpression, env: Environment): TypeValue {
429
+ // For stub and spy, create a function that returns the specified value
430
+ if (sinonExpr.type === "stub" || sinonExpr.type === "spy") {
431
+ const body = { type: "BlockStatement", body: [] } as any;
432
+ const fn = T.fn(["...args"], body, env);
433
+
434
+ if (sinonExpr.returnValue) {
435
+ // Store the return value directly on the function
436
+ (fn as any)._directReturn = sinonExpr.returnValue;
437
+ } else if (sinonExpr.resolvedValue) {
438
+ // Store as promise
439
+ (fn as any)._directReturn = T.promise(sinonExpr.resolvedValue);
440
+ } else if (sinonExpr.rejectedValue) {
441
+ // Store as never (rejected promise)
442
+ (fn as any)._directReturn = T.never;
443
+ } else {
444
+ // Default: return unknown
445
+ (fn as any)._directReturn = T.unknown;
446
+ }
447
+ return fn;
448
+ }
449
+ // For mock, return unknown for now
450
+ return T.unknown;
451
+ }
452
+
121
453
  function rangeKey(r: SourceLocation): string {
122
454
  return `${r.start.line}:${r.start.column}-${r.end.line}:${r.end.column}`;
123
455
  }
@@ -148,7 +480,732 @@ function locFromNode(node: Node): SourceLocation {
148
480
  };
149
481
  }
150
482
 
151
- export function analyzeFile(filePath: string, source: string, activeCases?: Map<string, number>): AnalysisResult {
483
+ function extractParamNames(node: Node): string[] {
484
+ const fn = node.type === "ExportDefaultDeclaration" ? node.declaration : node;
485
+ if (fn.type === "FunctionDeclaration" || fn.type === "FunctionExpression" || fn.type === "ArrowFunctionExpression") {
486
+ return fn.params.map((p: any) => {
487
+ if (p.type === "Identifier") return p.name;
488
+ if (p.type === "AssignmentPattern" && p.left.type === "Identifier") return p.left.name;
489
+ if (p.type === "RestElement" && p.argument.type === "Identifier") return `...${p.argument.name}`;
490
+ return "_";
491
+ });
492
+ }
493
+ if (fn.type === "VariableDeclaration") {
494
+ const decl = fn.declarations[0];
495
+ if (decl.init?.type === "FunctionExpression" || decl.init?.type === "ArrowFunctionExpression") {
496
+ return decl.init.params.map((p: any) => {
497
+ if (p.type === "Identifier") return p.name;
498
+ if (p.type === "AssignmentPattern" && p.left.type === "Identifier") return p.left.name;
499
+ if (p.type === "RestElement" && p.argument.type === "Identifier") return `...${p.argument.name}`;
500
+ return "_";
501
+ });
502
+ }
503
+ }
504
+ return [];
505
+ }
506
+
507
+ function resolveFunctionNode(node: Node): Node {
508
+ if (node.type === "ExportNamedDeclaration" && node.declaration) return resolveFunctionNode(node.declaration);
509
+ if (node.type === "ExportDefaultDeclaration") return resolveFunctionNode(node.declaration);
510
+ if (node.type === "VariableDeclaration") {
511
+ const init = (node as any).declarations[0]?.init;
512
+ if (init && (init.type === "ArrowFunctionExpression" || init.type === "FunctionExpression")) return init;
513
+ }
514
+ return node;
515
+ }
516
+
517
+ function isFnExprValue(node: Node | null | undefined): node is Node {
518
+ return !!node && (node.type === "FunctionExpression" || node.type === "ArrowFunctionExpression");
519
+ }
520
+
521
+ function namedFnExprId(node: Node): string | null {
522
+ return node.type === "FunctionExpression" && node.id ? node.id.name : null;
523
+ }
524
+
525
+ /** `module.exports` — the only assignment target carrying no stable name of its own. */
526
+ function isModuleExportsTarget(node: Node): boolean {
527
+ return (
528
+ node.type === "MemberExpression" &&
529
+ !node.computed &&
530
+ (node as any).object?.type === "Identifier" &&
531
+ (node as any).object.name === "module" &&
532
+ (node as any).property?.type === "Identifier" &&
533
+ (node as any).property.name === "exports"
534
+ );
535
+ }
536
+
537
+ function memberPropertyKey(member: Node): string | null {
538
+ if (member.type !== "MemberExpression" || member.computed) return null;
539
+ const prop = (member as any).property;
540
+ if (prop?.type === "Identifier") return prop.name;
541
+ if (prop?.type === "StringLiteral") return String(prop.value);
542
+ return null;
543
+ }
544
+
545
+ /** Rightmost value of a chained assignment (`a = b = fn` → fn). */
546
+ function deepestAssignValue(expr: Node): Node | null {
547
+ let cur: any = expr;
548
+ while (cur?.type === "AssignmentExpression") cur = cur.right;
549
+ return cur ?? null;
550
+ }
551
+
552
+ /**
553
+ * First stable name on an assignment chain, left to right: an identifier
554
+ * binding, or a member property that is not `module.exports` itself. Returns
555
+ * null for chains ending in a bare `module.exports = fn` or in computed
556
+ * members (`exports[k] = fn`) — callers fall back to "default".
557
+ */
558
+ function assignmentChainName(expr: Node): string | null {
559
+ let cur: any = expr;
560
+ while (cur?.type === "AssignmentExpression") {
561
+ const target = cur.left;
562
+ if (target.type === "Identifier") return target.name;
563
+ if (target.type === "MemberExpression") {
564
+ if (isModuleExportsTarget(target)) {
565
+ cur = cur.right;
566
+ continue;
567
+ }
568
+ return memberPropertyKey(target);
569
+ }
570
+ return null;
571
+ }
572
+ return null;
573
+ }
574
+
575
+ /**
576
+ * Top-level functions eligible for case synthesis. Besides FunctionDeclarations
577
+ * this collects CJS-style function bindings:
578
+ *
579
+ * const f = function () {}; // declarator init
580
+ * exports.applyToDefaults = function (a, b) {}; // namespace property
581
+ * module.exports = internals.clone = function () {}; // chained export
582
+ *
583
+ * Naming: identifier bindings win (call sites dispatch on them, and the
584
+ * evaluator's call records key on the callee identifier); a named function
585
+ * expression's own id beats a member property name; a bare
586
+ * `module.exports = fn` becomes "default" (ESM default-export analogue).
587
+ * Collected non-declaration entries carry `noDeclaration` — see FunctionAnalysis.
588
+ *
589
+ * v1 limitation: `internals.clone()`-style member calls go through method
590
+ * dispatch and never produce call records, so such functions fall back to the
591
+ * entry@ evaluation; same-file `X(...)` calls after `exports.X = fn` are not
592
+ * tracked either.
593
+ */
594
+ function collectTopLevelFunctions(ast: Node): { name: string; node: Node; stmt: Node; noDeclaration: boolean }[] {
595
+ const results: { name: string; node: Node; stmt: Node; noDeclaration: boolean }[] = [];
596
+ if (ast.type !== "File") return results;
597
+ for (const stmt of (ast as any).program.body) {
598
+ const decl = resolveFunctionNode(stmt);
599
+ if (decl.type === "FunctionDeclaration" && decl.id) {
600
+ results.push({ name: decl.id.name, node: decl, stmt, noDeclaration: false });
601
+ continue;
602
+ }
603
+ if (stmt.type === "VariableDeclaration") {
604
+ for (const declarator of (stmt as any).declarations) {
605
+ if (declarator.id?.type === "Identifier" && isFnExprValue(declarator.init)) {
606
+ results.push({ name: declarator.id.name, node: declarator.init, stmt, noDeclaration: true });
607
+ }
608
+ }
609
+ continue;
610
+ }
611
+ if (stmt.type === "ExpressionStatement" && (stmt as any).expression?.type === "AssignmentExpression") {
612
+ const expr = (stmt as any).expression as Node;
613
+ const fn = deepestAssignValue(expr);
614
+ if (isFnExprValue(fn)) {
615
+ const name = namedFnExprId(fn) ?? assignmentChainName(expr) ?? "default";
616
+ results.push({ name, node: fn, stmt, noDeclaration: true });
617
+ }
618
+ }
619
+ }
620
+ return results;
621
+ }
622
+
623
+ /** The function value of the file's single `module.exports = <function>`
624
+ * top-level assignment, when there is exactly one. Assigning module.exports
625
+ * replaces the whole exports object, so such a file exports exactly that one
626
+ * function and usage-site records may reach it under any re-export name —
627
+ * matching by targetModule alone is then unambiguous. Returns null for
628
+ * multi-export shapes (no such assignment, several, or `module.exports =
629
+ * {...}`), where a name/alias match is still required so sibling functions
630
+ * don't get misattributed. */
631
+ function findSingleModuleExportsFunction(ast: Node): Node | null {
632
+ if (ast.type !== "File") return null;
633
+ let found: Node | null = null;
634
+ for (const stmt of (ast as any).program.body) {
635
+ if (stmt.type !== "ExpressionStatement") continue;
636
+ const expr = (stmt as any).expression;
637
+ if (expr?.type !== "AssignmentExpression") continue;
638
+ const fn = deepestAssignValue(expr);
639
+ if (!isFnExprValue(fn)) continue;
640
+ let targetsModuleExports = false;
641
+ let cur: any = expr;
642
+ while (cur?.type === "AssignmentExpression") {
643
+ if (isModuleExportsTarget(cur.left)) {
644
+ targetsModuleExports = true;
645
+ break;
646
+ }
647
+ cur = cur.right;
648
+ }
649
+ if (!targetsModuleExports) continue;
650
+ if (found) return null; // a second `module.exports = fn` → ambiguous
651
+ found = fn;
652
+ }
653
+ return found;
654
+ }
655
+
656
+ function typeStructureKey(tv: TypeValue): string {
657
+ // Self-referential structures (x.y = x surviving a clone) would recurse
658
+ // infinitely: a seen-set renders revisits as a cycle token.
659
+ return typeStructureKeyUncached(tv, new Set());
660
+ }
661
+
662
+ function typeStructureKeyUncached(tv: TypeValue, seen: Set<object>): string {
663
+ // Path-scoped seen-set (backtracked after each expansion): a node renders
664
+ // as a cycle token only while its own expansion is on the stack, so
665
+ // shared singletons keep their full key.
666
+ const enter = (inner: TypeValue): string => {
667
+ if (inner && typeof inner === "object") {
668
+ if (seen.has(inner)) return "«cycle»";
669
+ seen.add(inner);
670
+ const out = typeStructureKeyUncached(inner, seen);
671
+ seen.delete(inner);
672
+ return out;
673
+ }
674
+ return typeStructureKeyUncached(inner, seen);
675
+ };
676
+ switch (tv.kind) {
677
+ case "literal":
678
+ return `lit(${typeof tv.value}:${String(tv.value)})`;
679
+ case "primitive":
680
+ return `prim(${tv.type})`;
681
+ case "array":
682
+ return `arr(${enter(tv.element)})`;
683
+ case "tuple":
684
+ return `tup(${tv.elements.map(enter).join(",")})`;
685
+ case "object":
686
+ return `obj(${Object.keys(tv.properties).sort().map((k) => `${k}:${enter(tv.properties[k])}`).join(",")})`;
687
+ case "function":
688
+ return `fn(${tv.params.join(",")})`;
689
+ case "promise":
690
+ return `prom(${enter(tv.value)})`;
691
+ case "instance":
692
+ return `inst(${tv.className})`;
693
+ case "refined":
694
+ return `ref(${enter(tv.base)})`;
695
+ case "union":
696
+ return `uni(${tv.members.map(enter).sort().join("|")})`;
697
+ default:
698
+ return tv.kind;
699
+ }
700
+ }
701
+
702
+ const MAX_PRECISE_CALLSITE_CASES = 3;
703
+ const COLLAPSE_LITERAL_THRESHOLD = 4;
704
+
705
+ /** widenLiteral for unions: widen each member, then dedupe (1|2|…|20 → number). */
706
+ function widenType(tv: TypeValue): TypeValue {
707
+ if (tv.kind === "union") return simplifyUnion(tv.members.map(widenLiteral));
708
+ return widenLiteral(tv);
709
+ }
710
+
711
+ function dedupeCallRecords(records: CallRecord[]): CallRecord[] {
712
+ const seen = new Set<string>();
713
+ const out: CallRecord[] = [];
714
+ for (const rec of records) {
715
+ // 类型值是重共享的 DAG(同一 JSON fixture 字面量流入多个参数/记录),
716
+ // 下方树形 key 会随共享度指数膨胀。超大记录与 resultType=never 一样
717
+ // 没有逐调用信息量(其 widen 后的形态才有),在 key 计算前统一丢弃。
718
+ if (isOversizedRecord(rec)) continue;
719
+ // key 必须含结果形态:同实参形状但不同结果(错误路径 never+throws vs
720
+ // 成功路径 Promise<...>)是不同的 case,只按实参去重会把成功记录吞进
721
+ // 首条错误记录里(parseChunked 的 Promise 记录曾被 L203 的 throw 吞掉)。
722
+ const key =
723
+ rec.argTypes.map(typeStructureKey).join(",") +
724
+ "=>" +
725
+ typeStructureKey(rec.resultType) +
726
+ "!" +
727
+ typeStructureKey(rec.throws);
728
+ if (seen.has(key)) continue;
729
+ seen.add(key);
730
+ out.push(rec);
731
+ }
732
+ return out;
733
+ }
734
+
735
+ /** DAG node budget for a usable call record; tree-shaped renderings of a
736
+ * type (the dedup key, the synthesized case output) grow exponentially with
737
+ * substructure sharing, so records whose argument/result DAGs exceed the
738
+ * budget are dropped rather than blown up. */
739
+ const MAX_RECORD_TYPE_NODES = 2000;
740
+
741
+ /** Linear-in-DAG-size node count (a `seen` set makes shared subtrees count
742
+ * once, so this stays cheap where a naive tree walk is exponential). */
743
+ function typeNodeCount(tv: TypeValue, seen: Set<object>): number {
744
+ if (tv === null || typeof tv !== "object") return 1;
745
+ if (seen.has(tv)) return 0;
746
+ seen.add(tv);
747
+ switch (tv.kind) {
748
+ case "array":
749
+ return 1 + typeNodeCount(tv.element, seen);
750
+ case "tuple":
751
+ return 1 + tv.elements.reduce((acc, e) => acc + typeNodeCount(e, seen), 0);
752
+ case "object": {
753
+ let n = 1;
754
+ for (const v of Object.values(tv.properties)) n += typeNodeCount(v, seen);
755
+ return n;
756
+ }
757
+ case "promise":
758
+ return 1 + typeNodeCount(tv.value, seen);
759
+ case "union":
760
+ return 1 + tv.members.reduce((acc, e) => acc + typeNodeCount(e, seen), 0);
761
+ case "refined":
762
+ return 1 + typeNodeCount(tv.base, seen);
763
+ default:
764
+ return 1;
765
+ }
766
+ }
767
+
768
+ function isOversizedRecord(rec: CallRecord): boolean {
769
+ const seen = new Set<object>();
770
+ for (const a of rec.argTypes) {
771
+ if (typeNodeCount(a, seen) > MAX_RECORD_TYPE_NODES) return true;
772
+ }
773
+ return typeNodeCount(rec.resultType, seen) > MAX_RECORD_TYPE_NODES;
774
+ }
775
+
776
+ function locFromCallLoc(loc: { line: number; column: number } | undefined): SourceLocation {
777
+ const p = loc ?? { line: 0, column: 0 };
778
+ return { start: { line: p.line, column: p.column }, end: { line: p.line, column: p.column } };
779
+ }
780
+
781
+ /**
782
+ * Cross-file call-site aggregation: call records whose callee is a function
783
+ * exported by another module (tagged by the evaluator's export side table)
784
+ * are grouped by (targetModule, targetExport) and synthesized directly into
785
+ * FunctionAnalysis entries — no re-evaluation needed, each CallRecord already
786
+ * carries the resultType/throws computed when this file was evaluated.
787
+ *
788
+ * v1 limitation: only named-import direct calls are recorded by the
789
+ * evaluator; `import * as ns` member calls go through the method path and
790
+ * never reach this synthesis.
791
+ */
792
+ function synthesizeExternalFunctions(records: CallRecord[], currentFile: string): FunctionAnalysis[] {
793
+ const groups = new Map<string, { module: string; exportName: string; records: CallRecord[] }>();
794
+ for (const rec of records) {
795
+ if (!rec.targetModule || !rec.targetExport) continue;
796
+ if (rec.targetModule === currentFile) continue;
797
+ const key = `${rec.targetModule}\0${rec.targetExport}`;
798
+ let group = groups.get(key);
799
+ if (!group) {
800
+ group = { module: rec.targetModule, exportName: rec.targetExport, records: [] };
801
+ groups.set(key, group);
802
+ }
803
+ group.records.push(rec);
804
+ }
805
+
806
+ const out: FunctionAnalysis[] = [];
807
+ for (const { module, exportName, records } of groups.values()) {
808
+ const deduped = dedupeCallRecords(records);
809
+ const arity = Math.max(...deduped.map((r) => r.argTypes.length));
810
+ const analysis: FunctionAnalysis = {
811
+ name: exportName,
812
+ loc: locFromCallLoc(deduped[0].callLoc),
813
+ paramNames: Array.from({ length: arity }, (_, i) => `arg${i}`),
814
+ cases: [],
815
+ fromModule: module,
816
+ };
817
+
818
+ // Same capping as local synthesis: at most MAX_PRECISE_CALLSITE_CASES
819
+ // precise cases. The symbolic aggregate cannot re-evaluate the foreign
820
+ // function (its AST belongs to another file's analysis), so it unions the
821
+ // observed argument/result/throws types of the remaining records instead.
822
+ const precise = deduped.slice(0, MAX_PRECISE_CALLSITE_CASES);
823
+ for (const rec of precise) {
824
+ analysis.cases.push({
825
+ name: `call@L${rec.callLoc?.line ?? 0}`,
826
+ args: rec.argTypes,
827
+ result: rec.resultType,
828
+ throws: rec.throws,
829
+ source: "callsite",
830
+ });
831
+ }
832
+ const remaining = deduped.slice(MAX_PRECISE_CALLSITE_CASES);
833
+ if (remaining.length > 0) {
834
+ analysis.cases.push({
835
+ name: "call@symbolic",
836
+ args: Array.from({ length: arity }, (_, i) =>
837
+ // 缺参按真实 JS 语义 widen 成 undefined 而非 unknown——可选参守卫
838
+ // (target || [] 等)对 unknown 全塌,对 undefined 正常走默认分支
839
+ widenType(simplifyUnion(remaining.map((rec) => rec.argTypes[i] ?? T.undefined))),
840
+ ),
841
+ result: collapseLiteralUnion(simplifyUnion(remaining.map((r) => r.resultType)), COLLAPSE_LITERAL_THRESHOLD),
842
+ throws: simplifyUnion(remaining.map((r) => r.throws)),
843
+ source: "callsite",
844
+ aggregatedFrom: remaining.length,
845
+ });
846
+ }
847
+
848
+ if (deduped.length > 1) {
849
+ analysis.combined = collapseLiteralUnion(
850
+ simplifyUnion(deduped.map((r) => r.resultType)),
851
+ COLLAPSE_LITERAL_THRESHOLD,
852
+ );
853
+ } else {
854
+ analysis.combined = deduped[0].resultType;
855
+ }
856
+ out.push(analysis);
857
+ }
858
+ return out;
859
+ }
860
+
861
+ function receiverTypeToDisplay(tv: TypeValue): string {
862
+ switch (tv.kind) {
863
+ case "literal": {
864
+ const v = tv.value;
865
+ if (v === null) return "null";
866
+ if (v === undefined) return "undefined";
867
+ return typeof v;
868
+ }
869
+ case "union":
870
+ return tv.members.map(receiverTypeToDisplay).join(" | ");
871
+ default:
872
+ return typeValueToString(tv);
873
+ }
874
+ }
875
+
876
+ function receiverIsConcrete(tv: TypeValue): boolean {
877
+ if (tv.kind === "unknown") return false;
878
+ if (tv.kind === "union") return tv.members.every((m) => m.kind !== "unknown");
879
+ return true;
880
+ }
881
+
882
+ function unknownRecordsToDiagnostics(records: UnknownRecord[]): Diagnostic[] {
883
+ const out: Diagnostic[] = [];
884
+ const seen = new Set<string>();
885
+ for (const r of records) {
886
+ const line = r.loc?.line ?? 0;
887
+ const column = r.loc?.column ?? 0;
888
+ const key = `${line}:${column}:${r.name}`;
889
+ if (seen.has(key)) continue;
890
+ seen.add(key);
891
+
892
+ const range = {
893
+ start: { line, column },
894
+ end: { line, column: column + r.name.length },
895
+ };
896
+
897
+ if (r.kind === "global") {
898
+ // 递归截断记录(name 形如 "recursion:fnName")来自求值预算,非未知全局
899
+ if (r.name.startsWith("recursion:")) {
900
+ out.push({
901
+ range,
902
+ severity: "warning",
903
+ message: `Recursive evaluation of '${r.name.slice("recursion:".length)}' was truncated (depth/size budget); result widened to unknown`,
904
+ code: "nudo:recursion-truncated",
905
+ });
906
+ continue;
907
+ }
908
+ // 模块加载守卫记录(evaluator.ts loadModuleEnv / mock-module 路径):
909
+ // reason 自带环链 / 链深 / 完整候选路径,直接作为诊断文案。
910
+ if (r.name.startsWith("module-cycle:")) {
911
+ out.push({
912
+ range,
913
+ severity: "warning",
914
+ message: r.reason ?? "Circular module load detected",
915
+ code: "nudo:module-cycle",
916
+ });
917
+ continue;
918
+ }
919
+ if (r.name.startsWith("module-depth:")) {
920
+ out.push({
921
+ range,
922
+ severity: "warning",
923
+ message: r.reason ?? "Module load chain too deep",
924
+ code: "nudo:module-depth",
925
+ });
926
+ continue;
927
+ }
928
+ if (r.name.startsWith("module-missing:")) {
929
+ out.push({
930
+ range,
931
+ severity: "error",
932
+ message: r.reason ?? "Module file not found",
933
+ code: "nudo:module-missing",
934
+ });
935
+ continue;
936
+ }
937
+ out.push({
938
+ range,
939
+ severity: "warning",
940
+ message: `Unknown global identifier '${r.name}'`,
941
+ code: "nudo:unknown-global",
942
+ });
943
+ continue;
944
+ }
945
+
946
+ const receiver = r.receiverType;
947
+ if (receiver && receiverIsConcrete(receiver)) {
948
+ // instance 类型的方法集是声明的近似(未列出 ≠ 运行时不存在),
949
+ // 只有基类型(number/string 等)上的方法缺失才是确定错误。
950
+ // union 混合(Set | [] | {})同理:部分 member 可能有该方法,
951
+ // 缺失不确定 → warning。
952
+ const isApprox = (() => {
953
+ const members =
954
+ receiver.kind === "union" ? receiver.members : [receiver];
955
+ return members.some(
956
+ (m) =>
957
+ m.kind === "instance" ||
958
+ m.kind === "function" ||
959
+ m.kind === "tuple" ||
960
+ m.kind === "array" ||
961
+ m.kind === "object" ||
962
+ m.kind === "refined" ||
963
+ // wrapper/instance 近似表上可能持有该成员('x'.charCodeAt 于
964
+ // number|string:string 侧存在,number 侧缺失 → 不确定 → warning)
965
+ memberMayExistOn(m, r.name),
966
+ );
967
+ })();
968
+ const kindLabel = r.kind === "method" ? "Method" : "Property";
969
+ out.push({
970
+ range,
971
+ severity: isApprox ? "warning" : "error",
972
+ message: `${kindLabel} '${r.name}' does not exist on type '${receiverTypeToDisplay(receiver)}'`,
973
+ code: "nudo:no-method",
974
+ ...(r.origin ? { origin: r.origin } : {}),
975
+ });
976
+ } else {
977
+ out.push({
978
+ range,
979
+ severity: "warning",
980
+ message: `Cannot resolve '${r.name}' on unknown value`,
981
+ code: "nudo:unknown-recv",
982
+ ...(r.origin ? { origin: r.origin } : {}),
983
+ });
984
+ }
985
+ }
986
+ return out;
987
+ }
988
+
989
+ function collectEnvNames(filePath: string, source: string, includeProject: boolean): string[] {
990
+ const ast = parse(source);
991
+ const fileDirectives = extractFileDirectives(ast);
992
+ const fileEnvNames = fileDirectives
993
+ .filter((d) => d.kind === "env")
994
+ .flatMap((d) => d.envs);
995
+ if (!includeProject) return fileEnvNames;
996
+ const projectConfig = findProjectConfig(dirname(filePath));
997
+ const projectEnvNames = projectConfig?.config.env ?? [];
998
+ return [...new Set([...projectEnvNames, ...fileEnvNames])];
999
+ }
1000
+
1001
+ /**
1002
+ * Async entry to analyzeFile: preloads path-based env files
1003
+ * (`/// @nudo:env ./nudo-harvest-node.ts`) via dynamic import — impossible
1004
+ * synchronously in ESM — then runs the sync analysis, which picks the
1005
+ * preloaded factories up from the env-loader cache. The sync analyzeFile
1006
+ * signature is unchanged for existing consumers (LSP, MCP, vite-plugin).
1007
+ */
1008
+ export async function analyzeFileAsync(
1009
+ filePath: string,
1010
+ source: string,
1011
+ activeCases?: Map<string, number>,
1012
+ externalCallRecords?: CallRecord[],
1013
+ ): Promise<AnalysisResult> {
1014
+ const envNames = collectEnvNames(filePath, source, true);
1015
+ if (envNames.length > 0) {
1016
+ await preloadPathEnvs(envNames, dirname(filePath));
1017
+ }
1018
+ return analyzeFile(filePath, source, activeCases, externalCallRecords);
1019
+ }
1020
+
1021
+ /**
1022
+ * 调用点发现(阶段一):在"使用现场"文件(测试 / 上层应用)中求值
1023
+ * 顶层代码,收集它对(外部模块导出的)函数的调用记录。每条记录带
1024
+ * 真实的实参类型与结果类型——后续 analyzeFile 将其注入合成 case,
1025
+ * 使被使用方从 entry-only(参数全 unknown)升级为真实调用形态。
1026
+ *
1027
+ * 只做求值与记录,不产出诊断;求值异常不抛出(使用现场文件可能
1028
+ * 依赖未 mock 的全局,收集不到就收集不到,不能拖垮主分析)。
1029
+ */
1030
+ export function collectCallRecords(filePath: string, source: string): CallRecord[] {
1031
+ const records: CallRecord[] = [];
1032
+ // 使用现场可能是老 CJS(八进制字面量等历史语法)——宽松恢复模式,
1033
+ // 收集尽力而为;主分析的 parse 不受影响
1034
+ const ast = parse(source, { errorRecovery: true });
1035
+ resetMemo();
1036
+ resetUnreachableRanges();
1037
+ resetEnvModules();
1038
+ resetMockModules();
1039
+ setModuleResolver(resolveModule);
1040
+ setCurrentFileDir(dirname(filePath));
1041
+ setCurrentSource(source);
1042
+ setCallCollector((record) => records.push(record));
1043
+ try {
1044
+ const env = createEnvironment();
1045
+ evaluateProgram(ast, env);
1046
+ // 测试框架语义近似:it/describe/test 的回调在顶层求值中不会执行,
1047
+ // 但它们的函数体正是真实调用点所在。以 unknown 参数手动执行每个
1048
+ // 回调体;describe 回调体内嵌的 it(...) 继续展开(测试常嵌套)。
1049
+ const runCallbacks = (statements: Node[]): void => {
1050
+ for (const stmt of statements) {
1051
+ if (stmt.type !== "ExpressionStatement" || !("expression" in stmt)) continue;
1052
+ const expr = (stmt as { expression: Node }).expression;
1053
+ if (expr.type !== "CallExpression") continue;
1054
+ const callee = (expr as Node & { callee: Node }).callee;
1055
+ if (callee.type !== "MemberExpression" && callee.type !== "Identifier") continue;
1056
+ const name =
1057
+ callee.type === "Identifier"
1058
+ ? callee.name
1059
+ : callee.property.type === "Identifier"
1060
+ ? callee.property.name
1061
+ : null;
1062
+ if (!name || !TEST_CALLBACK_NAMES.has(name)) continue;
1063
+ const args = (expr as Node & { arguments: Node[] }).arguments;
1064
+ const cb = args.find((a) => a.type === "ArrowFunctionExpression" || a.type === "FunctionExpression") as
1065
+ | (Node & { body: Node })
1066
+ | undefined;
1067
+ if (!cb) continue;
1068
+ try {
1069
+ // 以 unknown 参数执行回调体(evaluate 只构造函数类型不执行)
1070
+ const params = (cb as unknown as { params?: Node[] }).params ?? [];
1071
+ evaluateFunction(cb, params.map(() => T.unknown), env);
1072
+ } catch {
1073
+ /* 单个回调失败不影响其余 */
1074
+ }
1075
+ // describe 回调体是语句列表——递归展开嵌套的 it/describe
1076
+ if (name === "describe" && cb.body.type === "BlockStatement") {
1077
+ runCallbacks((cb.body as unknown as { body: Node[] }).body);
1078
+ }
1079
+ }
1080
+ };
1081
+ runCallbacks(ast.program.body as unknown as Node[]);
1082
+ } catch {
1083
+ /* 收集尽力而为 */
1084
+ } finally {
1085
+ setCallCollector(null);
1086
+ setModuleResolver(null);
1087
+ setUnknownBuiltinHandler(null);
1088
+ }
1089
+ return records;
1090
+ }
1091
+
1092
+ /** 测试框架的回调注册函数:回调体里是真实调用点 */
1093
+ const TEST_CALLBACK_NAMES = new Set(["it", "test", "describe"]);
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
+ */
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 {
1204
+ // 外部实参里的闭包在使用现场文件定义——先打 usage-site 标记再进入任何
1205
+ // 求值(case 合成重求值会执行它们,泄漏的错误记录靠此标记丢弃)。
1206
+ for (const rec of externalCallRecords ?? []) {
1207
+ for (const a of rec.argTypes) setUsageSiteTag(a);
1208
+ }
152
1209
  const ast = parse(source);
153
1210
  const functions = extractDirectives(ast);
154
1211
  const diagnostics: Diagnostic[] = [];
@@ -159,35 +1216,349 @@ export function analyzeFile(filePath: string, source: string, activeCases?: Map<
159
1216
 
160
1217
  resetMemo();
161
1218
  resetUnreachableRanges();
1219
+ resetEnvModules();
1220
+ resetMockModules();
162
1221
  setModuleResolver(resolveModule);
163
1222
  setCurrentFileDir(dirname(filePath));
1223
+ setCurrentSource(source);
1224
+
1225
+ const fileDirectives = extractFileDirectives(ast);
1226
+ const fileEnvNames = fileDirectives
1227
+ .filter((d) => d.kind === "env")
1228
+ .flatMap((d) => d.envs);
1229
+
1230
+ const projectConfig = findProjectConfig(dirname(filePath));
1231
+ const projectEnvNames = projectConfig?.config.env ?? [];
1232
+ const envNames = [...new Set([...projectEnvNames, ...fileEnvNames])];
1233
+
1234
+ setUnknownBuiltinHandler((name, loc) => {
1235
+ // B 路径可分析时由静态收集器接管,避免双报
1236
+ if (isBPathCapable(source, envNames)) return;
1237
+ diagnostics.push({
1238
+ range: loc ? { start: loc.start, end: loc.end } : { start: { line: 0, column: 0 }, end: { line: 0, column: 0 } },
1239
+ severity: "warning",
1240
+ message: `Built-in API "${name}" is not covered by Nudo's type inference`,
1241
+ code: "nudo:builtin-unknown",
1242
+ suggestions: [
1243
+ `Use @nudo:mock to define the type: @nudo:mock ${name} = stub().returns(...)`,
1244
+ `Or use @nudo:refine return <constraint> to declare the return contract`,
1245
+ ],
1246
+ });
1247
+ });
164
1248
 
165
1249
  const globalEnv = createEnvironment();
166
- evaluateProgram(ast, globalEnv);
167
1250
 
168
- const unreachableRanges = getUnreachableRanges();
169
- for (const ur of unreachableRanges) {
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);
1256
+ }
1257
+
1258
+ const mocks = new Map<string, { fromPath: string; names?: string[] }>();
1259
+
1260
+ if (projectConfig?.config.mocks) {
1261
+ for (const [source, mockPath] of Object.entries(projectConfig.config.mocks)) {
1262
+ mocks.set(source, { fromPath: resolve(projectConfig.projectDir, mockPath) });
1263
+ }
1264
+ }
1265
+
1266
+ const mockModuleDirectives = fileDirectives.filter((d) => d.kind === "mock-module");
1267
+ for (const d of mockModuleDirectives) {
1268
+ mocks.set(d.source, { fromPath: d.fromPath, names: d.names });
1269
+ }
1270
+
1271
+ if (mocks.size > 0) {
1272
+ setMockModules(mocks);
1273
+ }
1274
+
1275
+ const callRecords: CallRecord[] = [];
1276
+ setCallCollector((record) => callRecords.push(record));
1277
+
1278
+ const unknownRecords: UnknownRecord[] = [];
1279
+ setUnknownCollector((r) => unknownRecords.push(r));
1280
+
1281
+ setProvenanceTracking(true);
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
+ }
170
1305
  diagnostics.push({
171
- range: ur,
172
- severity: "info",
173
- message: "Unreachable code",
174
- tags: ["unnecessary"],
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 } : {}),
175
1320
  });
1321
+ };
1322
+
1323
+ // @nudo:mock 绑定必须先于全程序求值:顶层调用点(如 mockHof([1,2,3]))
1324
+ // 在 evaluateProgram 中执行并由 callCollector 记录 call@ case 的
1325
+ // resultType——mock 晚于此绑定会让「mock 函数值作为回调实参」在该路径
1326
+ // 整体降级 unknown(@nudo:case 求值发生在下方循环内,此前不受影响)。
1327
+ for (const fn of functions) {
1328
+ applyMocks(fn.directives, globalEnv, filePath, diagnostics);
1329
+ }
1330
+
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
+ };
1364
+
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),
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
+ }
176
1515
  }
177
1516
 
178
1517
  collectBindings(ast, globalEnv, bindings);
179
1518
 
180
- for (const fn of functions) {
181
- applyMocks(fn.directives, globalEnv, filePath);
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
+
1540
+ const synthCandidates: { name: string; node: Node; analysis: FunctionAnalysis }[] = [];
182
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
+
1552
+ for (const fn of functions) {
183
1553
  const isPure = fn.directives.some((d) => d.kind === "pure");
184
1554
  const skipDirective = fn.directives.find((d) => d.kind === "skip");
185
- const returnsDirective = fn.directives.find((d) => d.kind === "returns");
186
1555
 
187
1556
  const fnLoc = locFromNode(fn.node);
188
- const analysis: FunctionAnalysis = { name: fn.name, loc: fnLoc, cases: [] };
1557
+ const paramNames = extractParamNames(fn.node);
1558
+ const analysis: FunctionAnalysis = { name: fn.name, loc: fnLoc, paramNames, cases: [] };
189
1559
 
190
1560
  if (skipDirective && skipDirective.kind === "skip") {
1561
+ analysis.skipped = true;
191
1562
  if (skipDirective.returns) {
192
1563
  analysis.combined = skipDirective.returns;
193
1564
  }
@@ -195,6 +1566,65 @@ export function analyzeFile(filePath: string, source: string, activeCases?: Map<
195
1566
  continue;
196
1567
  }
197
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
+
198
1628
  if (isPure) {
199
1629
  const fnVal = globalEnv.has(fn.name) ? globalEnv.lookup(fn.name) : null;
200
1630
  if (fnVal && fnVal.kind === "function") {
@@ -209,42 +1639,134 @@ export function analyzeFile(filePath: string, source: string, activeCases?: Map<
209
1639
  setSampleCount(3);
210
1640
  }
211
1641
 
212
- const caseDirectives = fn.directives.filter((d) => d.kind === "case");
213
- const activeCaseIdx = activeCases?.get(fn.name) ?? 0;
1642
+ if (caseDirectives.length === 0) {
1643
+ synthCandidates.push({ name: fn.name, node: fn.node, analysis });
1644
+ }
214
1645
 
215
1646
  for (let ci = 0; ci < caseDirectives.length; ci++) {
216
1647
  const directive = caseDirectives[ci];
217
1648
  resetUnreachableRanges();
218
- const fullResult = evaluateFunctionFull(fn.node, directive.args, globalEnv);
219
- const caseUnreachable = [...getUnreachableRanges()];
220
1649
 
221
- 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 = {
222
1742
  name: directive.name,
223
1743
  args: directive.args,
224
- result: fullResult.value,
225
- throws: fullResult.throws,
1744
+ result: tv,
1745
+ throws: throwsTv,
226
1746
  throwLoc: fullResult.throwLoc,
227
- });
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);
228
1753
 
229
1754
  if (directive.commentLine) {
230
- const hasThrow = fullResult.throws.kind !== "never";
231
- const resultStr = fullResult.value.kind !== "never"
232
- ? typeValueToString(fullResult.value)
233
- : "";
234
- const throwStr = hasThrow
235
- ? `throws ${typeValueToString(fullResult.throws)}`
236
- : "";
1755
+ const hasThrow = throwsTv.kind !== "never";
1756
+ const resultStr = tv.kind !== "never" ? typeValueToString(tv) : "";
1757
+ const throwStr = hasThrow ? `throws ${typeValueToString(throwsTv)}` : "";
237
1758
  const label = [resultStr, throwStr].filter(Boolean).join(" ");
238
1759
  const hintLabel = `=> ${label}`;
239
1760
 
240
1761
  let ok = true;
241
1762
  if (directive.expected) {
242
- ok = isSubtypeOf(fullResult.value, directive.expected);
1763
+ ok = isSubtypeOf(tv, directive.expected);
243
1764
  if (!ok) {
244
1765
  diagnostics.push({
245
1766
  range: { start: { line: directive.commentLine, column: 0 }, end: { line: directive.commentLine, column: 999 } },
246
1767
  severity: "error",
247
- message: `Case "${directive.name}": expected ${typeValueToString(directive.expected)}, got ${typeValueToString(fullResult.value)}`,
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",
248
1770
  });
249
1771
  }
250
1772
  }
@@ -255,55 +1777,346 @@ export function analyzeFile(filePath: string, source: string, activeCases?: Map<
255
1777
  const isActive = ci === Math.min(activeCaseIdx, caseDirectives.length - 1);
256
1778
 
257
1779
  if (isActive) {
258
- if (fullResult.throws.kind !== "never") {
259
- const throwRange = fullResult.throwLoc ?? fnLoc;
1780
+ if (throwsTv.kind !== "never") {
1781
+ const throwRange = fullResult?.throwLoc ?? fnLoc;
260
1782
  diagnostics.push({
261
1783
  range: throwRange,
262
1784
  severity: "warning",
263
- message: `Function "${fn.name}" case "${directive.name}" may throw: ${typeValueToString(fullResult.throws)}`,
1785
+ message: `Function "${fn.name}" case "${directive.name}" may throw: ${typeValueToString(throwsTv)}. Consider adding a try-catch block or using @nudo:refine return <constraint>`,
1786
+ code: "nudo-may-throw",
264
1787
  });
265
1788
  }
266
1789
 
267
- for (const ur of caseUnreachable) {
268
- diagnostics.push({
269
- range: ur,
270
- severity: "info",
271
- message: "Unreachable code",
272
- tags: ["unnecessary"],
273
- });
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
+ }
274
1802
  }
275
1803
  }
276
1804
  }
277
1805
 
278
1806
  if (analysis.cases.length > 1) {
279
- analysis.combined = simplifyUnion(analysis.cases.map((c) => c.result));
1807
+ analysis.combined = collapseLiteralUnion(simplifyUnion(analysis.cases.map((c) => c.result)), COLLAPSE_LITERAL_THRESHOLD);
280
1808
  } else if (analysis.cases.length === 1) {
281
1809
  analysis.combined = analysis.cases[0].result;
282
1810
  }
283
1811
 
284
- if (returnsDirective && returnsDirective.kind === "returns") {
285
- analysis.assertionErrors = [];
286
- for (const directive of caseDirectives) {
287
- const result = evaluateFunction(fn.node, directive.args, globalEnv);
288
- const matches = isSubtypeOf(result, returnsDirective.expected);
289
- if (!matches) {
290
- const msg = `@nudo:returns assertion failed for case "${directive.name}": expected ${typeValueToString(returnsDirective.expected)}, got ${typeValueToString(result)}`;
291
- analysis.assertionErrors.push(msg);
292
- diagnostics.push({
293
- range: fnLoc,
294
- severity: "error",
295
- message: msg,
296
- });
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
+ });
1819
+ }
1820
+
1821
+ functionResults.push(analysis);
1822
+ }
1823
+
1824
+ // Whole-program call inference: functions without @nudo:case directives
1825
+ // get cases synthesized from observed call sites; functions with no call
1826
+ // sites at all get a single entry evaluation with unknown parameters.
1827
+ const directiveFnNames = new Set(functions.map((f) => f.name));
1828
+ const directiveFnStmts = new Set(functions.map((f) => f.node));
1829
+ for (const { name, node, stmt, noDeclaration } of collectTopLevelFunctions(ast)) {
1830
+ if (directiveFnNames.has(name)) continue;
1831
+ // A statement carrying @nudo directives is already analyzed through the
1832
+ // directive path above (possibly under its "<anonymous>" name).
1833
+ if (directiveFnStmts.has(stmt)) continue;
1834
+ const analysis: FunctionAnalysis = { name, loc: locFromNode(node), paramNames: extractParamNames(node), cases: [] };
1835
+ if (noDeclaration) analysis.noDeclaration = true;
1836
+ functionResults.push(analysis);
1837
+ synthCandidates.push({ name, node, analysis });
1838
+ }
1839
+
1840
+ // 模块路匹配的前置量:本文件绝对路径(realpath 对齐符号链接后再比,
1841
+ // 双侧同一归一化函数,避免单侧 realpath 造成不一致),以及
1842
+ // `module.exports = function` 单导出形态的目标函数。
1843
+ const modulePathCache = new Map<string, string>();
1844
+ const normalizeModulePath = (p: string): string => {
1845
+ let n = modulePathCache.get(p);
1846
+ if (n === undefined) {
1847
+ try {
1848
+ n = realpathSync(p);
1849
+ } catch {
1850
+ n = p;
1851
+ }
1852
+ modulePathCache.set(p, n);
1853
+ }
1854
+ return n;
1855
+ };
1856
+ const currentModulePath = normalizeModulePath(resolve(filePath));
1857
+ const singleExportFn = findSingleModuleExportsFunction(ast);
1858
+
1859
+ for (const candidate of synthCandidates) {
1860
+ // 调用点来源有两路:本文件求值中观察到的调用,以及外部注入的
1861
+ // (使用现场文件——如测试——对本文导出函数的真实调用,CLI 经
1862
+ // --callsites 收集后传入)。带 targetModule 的记录先判归属:只有
1863
+ // 指向本文件的记录才允许参与匹配——导出名/别名离开模块单独无意义
1864
+ // (单导出文件的 targetExport 全是 "default",不判模块会跨文件误染,
1865
+ // 如 clone.js 的记录命中 applyToDefaults.js 的 "default" candidate)。
1866
+ // 归属本文件(或无模块信息的本地记录)后再走三路:
1867
+ // 1. 名字路:fnName(调用处可见名)或 targetExport(定义处导出名)
1868
+ // 2. 别名路:re-export 链上后来出现的导出名(evaluator 的
1869
+ // targetAliases,如 barrel / CJS 转发 shim 下的属性名)
1870
+ // 3. 模块路:本文件以 `module.exports = function` 单导出时直接收
1871
+ // ——使用方可能以任意转发名调用它;多导出文件不走模块路,
1872
+ // 避免同文件多函数误染。
1873
+ const singleExportHit = singleExportFn !== null && candidate.node === singleExportFn;
1874
+ const nameRoutes = (r: CallRecord): boolean =>
1875
+ r.fnName === candidate.name ||
1876
+ r.targetExport === candidate.name ||
1877
+ (r.targetAliases?.includes(candidate.name) ?? false) ||
1878
+ (singleExportHit && (r.fnModule !== undefined || r.targetModule !== undefined));
1879
+ // 本地记录:来自本文件求值。带 targetModule 的(本文件导出被调用)
1880
+ // 判归属;无 tag 的内部调用按名字匹配(一直以来的行为)。
1881
+ const matchingLocal = (r: CallRecord): boolean => {
1882
+ const targetsThisFile =
1883
+ r.targetModule === undefined || normalizeModulePath(r.targetModule) === currentModulePath;
1884
+ if (!targetsThisFile) return false;
1885
+ return nameRoutes(r);
1886
+ };
1887
+ // 外部记录(使用现场收集)必须可归因到本文件:fnModule(定义位点——
1888
+ // require 传递求值中库内部调用的记录)或 targetModule(导出 tag)。
1889
+ // 无归因的记录是测试本地函数或裸内置名,按名字撞库属跨文件污染
1890
+ // (实测:测试局部 compare() 撞 contain.js internals.compare)。
1891
+ const matchingExternal = (r: CallRecord): boolean => {
1892
+ const attributed =
1893
+ (r.fnModule !== undefined && normalizeModulePath(r.fnModule) === currentModulePath) ||
1894
+ (r.targetModule !== undefined && normalizeModulePath(r.targetModule) === currentModulePath);
1895
+ if (!attributed) return false;
1896
+ return nameRoutes(r);
1897
+ };
1898
+ // resultType=never 且 throws=never 是求值中断的信号泄漏(如
1899
+ // `new Promise(async …)` 高阶 async 中 await 切断求值),无信息量,
1900
+ // 注入会产出误导 case;本地与注入记录一致跳过,全部被跳过的
1901
+ // candidate 自然落入下方 entry@ 回退。resultType=never 但 throws≠never
1902
+ // 是真实的抛出调用(argTypes + throws 都有信息),保留。
1903
+ const records = dedupeCallRecords(
1904
+ [
1905
+ ...callRecords.filter(matchingLocal),
1906
+ ...(externalCallRecords ?? []).filter(matchingExternal),
1907
+ ].filter((r) => !(r.resultType.kind === "never" && r.throws.kind === "never")),
1908
+ );
1909
+ if (records.length > 0) {
1910
+ // 案例选择偏好:结果有信息量的记录优先(精确/字面量/结构化),
1911
+ // unknown 结果的排后——收集顺序里错误路径或 undefined 形态的测试
1912
+ // 常排在前面,slice 截断会把 concrete-precise 记录挤掉(hoek clone
1913
+ // 的 682 条记录曾由 3 条 undefined 形态占满前 3 席)。
1914
+ const informativeness = (r: CallRecord): number => {
1915
+ if (r.resultType.kind === "unknown") return 2;
1916
+ if (r.resultType.kind === "never") return 1;
1917
+ return 0;
1918
+ };
1919
+ const ordered = records
1920
+ .map((r, i) => ({ r, i }))
1921
+ .sort((a, b) => informativeness(a.r) - informativeness(b.r) || a.i - b.i)
1922
+ .map(({ r }) => r);
1923
+ const precise = ordered.slice(0, MAX_PRECISE_CALLSITE_CASES);
1924
+ for (const rec of precise) {
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 = {
1936
+ name: `call@L${rec.callLoc?.line ?? candidate.analysis.loc.start.line}`,
1937
+ args: rec.argTypes,
1938
+ result: absResult ?? rec.resultType,
1939
+ throws: rec.throws,
1940
+ source: "callsite",
1941
+ };
1942
+ tryAttachIntension(caseResult, source, candidate.name);
1943
+ if (absRaw) attachAbsToIntension(caseResult, absRaw, candidate.name);
1944
+ candidate.analysis.cases.push(caseResult);
1945
+ }
1946
+ // symbolic 聚合只用全已知实参的记录:含 unknown 分量的记录不可重求值
1947
+ // (unknown 吸收整个 union,一条循环引用 fixture 的记录就能毒化全部
1948
+ // 剩余聚合——clone 704 条中的 53 条 unknown 实参曾拖垮其余 651 条)。
1949
+ // 排除不声明覆盖,sound;全部不可求值时不产 symbolic case(诚实)。
1950
+ const remaining = ordered
1951
+ .slice(MAX_PRECISE_CALLSITE_CASES)
1952
+ .filter((rec) => !rec.argTypes.some((a) => a.kind === "unknown"));
1953
+ if (remaining.length > 0) {
1954
+ const fnNode = resolveFunctionNode(candidate.node);
1955
+ const paramCount = extractParamNames(fnNode).length;
1956
+ const widenedArgs = Array.from({ length: paramCount }, (_, i) =>
1957
+ // 缺参按真实 JS 语义 widen 成 undefined 而非 unknown——可选参守卫
1958
+ // (target || [] 等)对 unknown 全塌,对 undefined 正常走默认分支
1959
+ widenType(simplifyUnion(remaining.map((rec) => rec.argTypes[i] ?? T.undefined))),
1960
+ );
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;
297
1988
  }
1989
+ const symCase: CaseResult = {
1990
+ name: "call@symbolic",
1991
+ args: widenedArgs,
1992
+ result: symValue ?? T.unknown,
1993
+ throws: symThrows,
1994
+ throwLoc: symLoc,
1995
+ source: "callsite",
1996
+ aggregatedFrom: remaining.length,
1997
+ };
1998
+ tryAttachIntension(symCase, source, candidate.name);
1999
+ if (symAbs) attachAbsToIntension(symCase, symAbs, candidate.name);
2000
+ candidate.analysis.cases.push(symCase);
298
2001
  }
2002
+ // Combined covers every observed call site (not just the retained
2003
+ // cases), so a large set of same-base literal results collapses to
2004
+ // the widened base type instead of a 20-literal union.
2005
+ candidate.analysis.combined = collapseLiteralUnion(
2006
+ simplifyUnion(records.map((r) => r.resultType)),
2007
+ COLLAPSE_LITERAL_THRESHOLD,
2008
+ );
2009
+ continue;
299
2010
  }
300
2011
 
301
- functionResults.push(analysis);
2012
+ const fnNode = resolveFunctionNode(candidate.node);
2013
+ const args = extractParamNames(fnNode).map(() => T.unknown);
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 = {
2053
+ name: `entry@L${candidate.analysis.loc.start.line}`,
2054
+ args,
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);
2063
+ candidate.analysis.entryOnly = true;
2064
+ candidate.analysis.combined = collapseLiteralUnion(entryValue, COLLAPSE_LITERAL_THRESHOLD);
302
2065
  }
303
2066
 
304
- buildNodeTypeMap(ast, globalEnv, nodeTypeMap);
2067
+ if (!bHostedEval) {
2068
+ buildNodeTypeMap(ast, globalEnv, nodeTypeMap);
2069
+ }
305
2070
 
2071
+ setUnknownBuiltinHandler(null);
2072
+ setCallCollector(null);
2073
+ setUnknownCollector(null);
2074
+ setProvenanceTracking(false);
306
2075
  setModuleResolver(null);
2076
+ resetEnvModules();
2077
+ resetMockModules();
2078
+
2079
+ // usage-site 执行泄漏守卫:case 合成重求值会执行注入实参携带的使用现场
2080
+ // 闭包体(测试回调),其内部错误记录属于使用现场文件——不能记在本文件
2081
+ // 名下(wait.js 曾背着 test/index.js:2407 的 no-method;json-ext 的
2082
+ // slices.map 行号落在本文件行数内骗过纯行数守卫)。标记在 analyzeFile
2083
+ // 入口处打;行数上限保留兜底(防无标记路径)。
2084
+ const maxLine = source.split("\n").length;
2085
+ diagnostics.push(
2086
+ ...unknownRecordsToDiagnostics(
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
+ }),
2116
+ ),
2117
+ );
2118
+
2119
+ const externalFunctions = synthesizeExternalFunctions(callRecords, filePath);
307
2120
 
308
2121
  return {
309
2122
  functions: functionResults,
@@ -311,6 +2124,7 @@ export function analyzeFile(filePath: string, source: string, activeCases?: Map<
311
2124
  bindings,
312
2125
  nodeTypeMap,
313
2126
  caseHints,
2127
+ ...(externalFunctions.length > 0 ? { externalFunctions } : {}),
314
2128
  };
315
2129
  }
316
2130
 
@@ -395,6 +2209,21 @@ export function getCasesForFile(filePath: string, source: string): { functionNam
395
2209
  });
396
2210
  }
397
2211
 
2212
+ /** Async entry to getTypeAtPosition with path-env preloading (see analyzeFileAsync). */
2213
+ export async function getTypeAtPositionAsync(
2214
+ filePath: string,
2215
+ source: string,
2216
+ line: number,
2217
+ column: number,
2218
+ activeCases?: Map<string, number>,
2219
+ ): Promise<TypeValue | null> {
2220
+ const envNames = collectEnvNames(filePath, source, false);
2221
+ if (envNames.length > 0) {
2222
+ await preloadPathEnvs(envNames, dirname(filePath));
2223
+ }
2224
+ return getTypeAtPosition(filePath, source, line, column, activeCases);
2225
+ }
2226
+
398
2227
  export function getTypeAtPosition(
399
2228
  filePath: string,
400
2229
  source: string,
@@ -403,11 +2232,53 @@ export function getTypeAtPosition(
403
2232
  activeCases?: Map<string, number>,
404
2233
  ): TypeValue | null {
405
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
+
406
2267
  resetMemo();
2268
+ resetEnvModules();
407
2269
  setModuleResolver(resolveModule);
408
2270
  setCurrentFileDir(dirname(filePath));
2271
+ setCurrentSource(source);
409
2272
 
410
2273
  const globalEnv = createEnvironment();
2274
+
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);
2280
+ }
2281
+
411
2282
  evaluateProgram(ast, globalEnv);
412
2283
 
413
2284
  const nodeTypeMap = new Map<Node, TypeValue>();
@@ -434,9 +2305,222 @@ export function getTypeAtPosition(
434
2305
  }
435
2306
 
436
2307
  setModuleResolver(null);
2308
+ resetEnvModules();
2309
+ resetMockModules();
437
2310
  return findBestTypeAtPosition(nodeTypeMap, globalEnv, ast, line, column);
438
2311
  }
439
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
+
440
2524
  function findEnclosingFunction(
441
2525
  functions: FunctionWithDirectives[],
442
2526
  line: number,
@@ -590,6 +2674,127 @@ function getVariableCompletions(filePath: string, source: string): CompletionIte
590
2674
  return completions;
591
2675
  }
592
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
+
593
2798
  function getCompletionsForType(tv: TypeValue): CompletionItem[] {
594
2799
  const completions: CompletionItem[] = [];
595
2800
 
@@ -615,44 +2820,314 @@ function getCompletionsForType(tv: TypeValue): CompletionItem[] {
615
2820
  return completions;
616
2821
  }
617
2822
 
2823
+ if (tv.kind === "union") {
2824
+ return getUnionCompletions(tv);
2825
+ }
2826
+
618
2827
  if (tv.kind === "array" || tv.kind === "tuple") {
619
- const arrayMethods = [
620
- { label: "map", detail: "map(callback)" },
621
- { label: "filter", detail: "filter(callback)" },
622
- { label: "reduce", detail: "reduce(callback, init)" },
623
- { label: "find", detail: "find(callback)" },
624
- { label: "some", detail: "some(callback)" },
625
- { label: "every", detail: "every(callback)" },
626
- { label: "forEach", detail: "forEach(callback)" },
627
- { label: "flatMap", detail: "flatMap(callback)" },
628
- { label: "includes", detail: "includes(value)" },
629
- { label: "indexOf", detail: "indexOf(value)" },
630
- { label: "join", detail: "join(separator)" },
631
- { label: "slice", detail: "slice(start, end)" },
632
- { label: "concat", detail: "concat(other)" },
633
- { label: "push", detail: "push(value)" },
634
- { label: "length", detail: tv.kind === "tuple" ? `${tv.elements.length}` : "number" },
635
- ];
636
- for (const m of arrayMethods) {
637
- completions.push({ label: m.label, kind: "method", detail: m.detail });
638
- }
639
- return completions;
2828
+ return getArrayCompletions(tv);
640
2829
  }
641
2830
 
642
2831
  if (tv.kind === "promise") {
643
- completions.push({ label: "then", kind: "method", detail: "then(callback)" });
644
- completions.push({ label: "catch", kind: "method", detail: "catch(callback)" });
645
- completions.push({ label: "finally", kind: "method", detail: "finally(callback)" });
646
- return completions;
2832
+ return getPromiseCompletions();
647
2833
  }
648
2834
 
649
2835
  if (tv.kind === "primitive" && tv.type === "string") {
650
- const stringMethods = ["toUpperCase", "toLowerCase", "trim", "split", "slice", "includes", "indexOf", "replace", "startsWith", "endsWith", "charAt", "length"];
651
- for (const m of stringMethods) {
652
- completions.push({ label: m, kind: "method", detail: `string.${m}` });
653
- }
654
- return completions;
2836
+ return getStringCompletions();
655
2837
  }
656
2838
 
657
2839
  return completions;
658
2840
  }
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
+
3133
+ export type { CallRecord } from "@nudojs/cli/evaluator";