@nudojs/service 0.1.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +24 -0
- package/LICENSE +21 -0
- package/README.md +27 -0
- package/dist/index.d.ts +137 -2
- package/dist/index.js +1479 -37
- package/package.json +26 -4
- package/src/__tests__/analyzer.test.ts +677 -2
- package/src/__tests__/as-replace.test.ts +165 -0
- package/src/__tests__/case-emitter.test.ts +636 -0
- package/src/__tests__/diagnostics.test.ts +90 -0
- package/src/__tests__/dts-generator.test.ts +290 -6
- package/src/__tests__/fn-sig-impl.test.ts +247 -0
- package/src/__tests__/guard-generator.test.ts +29 -0
- package/src/__tests__/integration.test.ts +332 -0
- package/src/__tests__/schema-generator.test.ts +38 -0
- package/src/analyzer.ts +1209 -18
- package/src/case-emitter.ts +475 -0
- package/src/dts-generator.ts +230 -24
- package/src/guard-generator.ts +50 -0
- package/src/index.ts +25 -0
- package/src/schema-generator.ts +43 -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,22 @@ 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,
|
|
13
16
|
} from "@nudojs/core";
|
|
14
|
-
import { parse, extractDirectives, parseTypeValueExpr } from "@nudojs/parser";
|
|
15
|
-
import type { FunctionWithDirectives } from "@nudojs/parser";
|
|
17
|
+
import { parse, extractDirectives, extractFileDirectives, parseTypeValueExpr } from "@nudojs/parser";
|
|
18
|
+
import type { FunctionWithDirectives, SinonExpression } from "@nudojs/parser";
|
|
16
19
|
import {
|
|
17
20
|
evaluate,
|
|
18
21
|
evaluateFunction,
|
|
19
22
|
evaluateFunctionFull,
|
|
23
|
+
memberMayExistOn,
|
|
24
|
+
setUsageSiteTag,
|
|
25
|
+
USAGE_SITE_MODULE,
|
|
20
26
|
evaluateProgram,
|
|
21
27
|
setModuleResolver,
|
|
22
28
|
setCurrentFileDir,
|
|
@@ -24,6 +30,22 @@ import {
|
|
|
24
30
|
getUnreachableRanges,
|
|
25
31
|
resetUnreachableRanges,
|
|
26
32
|
setNodeTypeCollector,
|
|
33
|
+
setCallCollector,
|
|
34
|
+
type CallRecord,
|
|
35
|
+
setUnknownCollector,
|
|
36
|
+
setProvenanceTracking,
|
|
37
|
+
type UnknownRecord,
|
|
38
|
+
setSampleCount,
|
|
39
|
+
setUnknownBuiltinHandler,
|
|
40
|
+
setEnvModules,
|
|
41
|
+
resetEnvModules,
|
|
42
|
+
setMockModules,
|
|
43
|
+
resetMockModules,
|
|
44
|
+
setCurrentSource,
|
|
45
|
+
loadEnvs,
|
|
46
|
+
preloadPathEnvs,
|
|
47
|
+
findProjectConfig,
|
|
48
|
+
resolveNpmNudo,
|
|
27
49
|
} from "@nudojs/cli/evaluator";
|
|
28
50
|
|
|
29
51
|
export type SourceLocation = {
|
|
@@ -40,6 +62,11 @@ export type Diagnostic = {
|
|
|
40
62
|
severity: DiagnosticSeverity;
|
|
41
63
|
message: string;
|
|
42
64
|
tags?: DiagnosticTag[];
|
|
65
|
+
code?: string;
|
|
66
|
+
suggestions?: string[];
|
|
67
|
+
data?: unknown;
|
|
68
|
+
/** provenance of the receiver value (callsite argument that flowed into the error) */
|
|
69
|
+
origin?: { line: number; column: number };
|
|
43
70
|
};
|
|
44
71
|
|
|
45
72
|
export type CaseResult = {
|
|
@@ -48,14 +75,29 @@ export type CaseResult = {
|
|
|
48
75
|
result: TypeValue;
|
|
49
76
|
throws: TypeValue;
|
|
50
77
|
throwLoc?: SourceLocation;
|
|
78
|
+
source?: "directive" | "callsite";
|
|
79
|
+
/** number of additional call sites folded into a symbolic case */
|
|
80
|
+
aggregatedFrom?: number;
|
|
51
81
|
};
|
|
52
82
|
|
|
53
83
|
export type FunctionAnalysis = {
|
|
54
84
|
name: string;
|
|
55
85
|
loc: SourceLocation;
|
|
86
|
+
paramNames: string[];
|
|
56
87
|
cases: CaseResult[];
|
|
57
88
|
combined?: TypeValue;
|
|
58
89
|
assertionErrors?: string[];
|
|
90
|
+
entryOnly?: boolean;
|
|
91
|
+
skipped?: boolean;
|
|
92
|
+
/**
|
|
93
|
+
* True for functions collected from CJS-style bindings/assignments
|
|
94
|
+
* (`exports.X = fn`, `module.exports = fn`, `const f = fn`): their name has
|
|
95
|
+
* no declaration-level stability, so .d.ts generation skips them while
|
|
96
|
+
* infer/JSON output still reports them.
|
|
97
|
+
*/
|
|
98
|
+
noDeclaration?: boolean;
|
|
99
|
+
/** absolute path of the module this function is imported from (externalFunctions only) */
|
|
100
|
+
fromModule?: string;
|
|
59
101
|
};
|
|
60
102
|
|
|
61
103
|
export type BindingInfo = {
|
|
@@ -75,6 +117,8 @@ export type AnalysisResult = {
|
|
|
75
117
|
bindings: Map<string, BindingInfo>;
|
|
76
118
|
nodeTypeMap: Map<Node, TypeValue>;
|
|
77
119
|
caseHints: CaseHint[];
|
|
120
|
+
/** functions imported from other modules, synthesized from cross-file call sites observed while analyzing this file */
|
|
121
|
+
externalFunctions?: FunctionAnalysis[];
|
|
78
122
|
};
|
|
79
123
|
|
|
80
124
|
export type CompletionItem = {
|
|
@@ -83,27 +127,231 @@ export type CompletionItem = {
|
|
|
83
127
|
detail?: string;
|
|
84
128
|
};
|
|
85
129
|
|
|
86
|
-
|
|
130
|
+
export type SymbolInfo = {
|
|
131
|
+
name: string;
|
|
132
|
+
kind: "function" | "variable" | "class" | "parameter";
|
|
133
|
+
loc: SourceLocation;
|
|
134
|
+
uri?: string;
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
export type ReferenceInfo = {
|
|
138
|
+
name: string;
|
|
139
|
+
loc: SourceLocation;
|
|
140
|
+
uri?: string;
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
export type SymbolTable = {
|
|
144
|
+
definitions: Map<string, SymbolInfo>;
|
|
145
|
+
references: ReferenceInfo[];
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
export function resolveModule(source: string, fromDir: string): { ast: ReturnType<typeof parse>; filePath: string; json?: unknown } | null {
|
|
87
149
|
const extensions = [".js", ".ts", ".mjs"];
|
|
150
|
+
|
|
151
|
+
const nudoPath = resolveNpmNudo(source, fromDir);
|
|
152
|
+
if (nudoPath) {
|
|
153
|
+
const src = readFileSync(nudoPath, "utf-8");
|
|
154
|
+
return { ast: parse(src), filePath: nudoPath };
|
|
155
|
+
}
|
|
156
|
+
|
|
88
157
|
const basePath = resolve(fromDir, source);
|
|
89
158
|
for (const ext of ["", ...extensions]) {
|
|
90
159
|
const candidate = basePath + ext;
|
|
91
|
-
if (existsSync(candidate))
|
|
92
|
-
|
|
93
|
-
|
|
160
|
+
if (!existsSync(candidate)) continue;
|
|
161
|
+
// 目录:按 package.json main / index.js 解析(require('..') 模式)
|
|
162
|
+
if (statSync(candidate).isDirectory()) {
|
|
163
|
+
let entry: string | null = null;
|
|
164
|
+
const pkgPath = resolve(candidate, "package.json");
|
|
165
|
+
if (existsSync(pkgPath)) {
|
|
166
|
+
try {
|
|
167
|
+
const main = JSON.parse(readFileSync(pkgPath, "utf-8")).main;
|
|
168
|
+
if (typeof main === "string") {
|
|
169
|
+
for (const e of ["", ...extensions]) {
|
|
170
|
+
const p = resolve(candidate, main + e);
|
|
171
|
+
if (existsSync(p) && statSync(p).isFile()) { entry = p; break; }
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
} catch { /* 无效 package.json → fallback index */ }
|
|
175
|
+
}
|
|
176
|
+
if (!entry) {
|
|
177
|
+
for (const e of ["", ...extensions]) {
|
|
178
|
+
const p = resolve(candidate, "index" + e);
|
|
179
|
+
if (existsSync(p) && statSync(p).isFile()) { entry = p; break; }
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
if (!entry) return null;
|
|
183
|
+
const src = readFileSync(entry, "utf-8");
|
|
184
|
+
return { ast: parse(src), filePath: entry };
|
|
185
|
+
}
|
|
186
|
+
// .json 模块:require('../package.json') 等模式——按 JSON 求值而非 JS parse
|
|
187
|
+
if (candidate.endsWith(".json")) {
|
|
188
|
+
try {
|
|
189
|
+
return { ast: parse("module.exports = undefined;"), filePath: candidate, json: JSON.parse(readFileSync(candidate, "utf-8")) };
|
|
190
|
+
} catch {
|
|
191
|
+
return null;
|
|
192
|
+
}
|
|
94
193
|
}
|
|
194
|
+
const src = readFileSync(candidate, "utf-8");
|
|
195
|
+
return { ast: parse(src), filePath: candidate };
|
|
95
196
|
}
|
|
96
197
|
return null;
|
|
97
198
|
}
|
|
98
199
|
|
|
200
|
+
/** Resolve a relative import specifier to an existing file (extension rules identical to CLI resolveModule: ''/'.js'/'.ts'/'.mjs'); null when unresolvable. */
|
|
201
|
+
function resolveImportPath(specifier: string, fromDir: string): string | null {
|
|
202
|
+
const basePath = resolve(fromDir, specifier);
|
|
203
|
+
for (const ext of ["", ".js", ".ts", ".mjs"]) {
|
|
204
|
+
const candidate = basePath + ext;
|
|
205
|
+
if (existsSync(candidate)) return candidate;
|
|
206
|
+
}
|
|
207
|
+
return null;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** mtime 边缓存:key 为文件路径,edges 为已抽取的相对 import 边(与 buildModuleGraph 返回语义一致)。 */
|
|
211
|
+
export type ModuleGraphCache = Map<string, { mtimeMs: number; size: number; edges: string[] }>;
|
|
212
|
+
|
|
213
|
+
/** Statically extract each file's relative import edges (extension resolution identical to CLI resolveModule: ''/'.js'/'.ts'/'.mjs'; bare npm specifiers skipped). */
|
|
214
|
+
export function buildModuleGraph(
|
|
215
|
+
files: string[],
|
|
216
|
+
cache?: ModuleGraphCache,
|
|
217
|
+
): {
|
|
218
|
+
imports: Map<string, Set<string>>;
|
|
219
|
+
dependents: Map<string, Set<string>>;
|
|
220
|
+
} {
|
|
221
|
+
const imports = new Map<string, Set<string>>();
|
|
222
|
+
const dependents = new Map<string, Set<string>>();
|
|
223
|
+
const edge = (from: string, to: string) => {
|
|
224
|
+
if (!imports.has(from)) imports.set(from, new Set());
|
|
225
|
+
if (!dependents.has(to)) dependents.set(to, new Set());
|
|
226
|
+
imports.get(from)!.add(to);
|
|
227
|
+
dependents.get(to)!.add(from);
|
|
228
|
+
};
|
|
229
|
+
for (const file of files) {
|
|
230
|
+
if (!imports.has(file)) imports.set(file, new Set());
|
|
231
|
+
if (!dependents.has(file)) dependents.set(file, new Set());
|
|
232
|
+
let edges: string[];
|
|
233
|
+
if (cache) {
|
|
234
|
+
// stat 仅取元数据不读内容;mtimeMs+size 均一致视为命中,复用边并跳过磁盘读取与解析
|
|
235
|
+
let stat: ReturnType<typeof statSync> | null = null;
|
|
236
|
+
try {
|
|
237
|
+
stat = statSync(file);
|
|
238
|
+
} catch {
|
|
239
|
+
/* stat 失败(文件被删等)按未命中处理,走直读兜底 */
|
|
240
|
+
}
|
|
241
|
+
const cached = stat ? cache.get(file) : undefined;
|
|
242
|
+
if (stat && cached && cached.mtimeMs === stat.mtimeMs && cached.size === stat.size) {
|
|
243
|
+
edges = cached.edges;
|
|
244
|
+
} else {
|
|
245
|
+
edges = extractImportEdges(file);
|
|
246
|
+
if (stat) cache.set(file, { mtimeMs: stat.mtimeMs, size: stat.size, edges });
|
|
247
|
+
}
|
|
248
|
+
} else {
|
|
249
|
+
edges = extractImportEdges(file);
|
|
250
|
+
}
|
|
251
|
+
for (const to of edges) edge(file, to);
|
|
252
|
+
}
|
|
253
|
+
return { imports, dependents };
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** 磁盘直读并解析单个文件,抽取其相对 import 边(读取/解析失败返回空数组)。 */
|
|
257
|
+
function extractImportEdges(file: string): string[] {
|
|
258
|
+
const edges: string[] = [];
|
|
259
|
+
let ast: ReturnType<typeof parse>;
|
|
260
|
+
try {
|
|
261
|
+
ast = parse(readFileSync(file, "utf-8"));
|
|
262
|
+
} catch {
|
|
263
|
+
return edges;
|
|
264
|
+
}
|
|
265
|
+
for (const stmt of ast.program.body) {
|
|
266
|
+
if (stmt.type !== "ImportDeclaration") continue;
|
|
267
|
+
const specifier = stmt.source.value;
|
|
268
|
+
if (!specifier.startsWith(".") && !specifier.startsWith("/")) continue;
|
|
269
|
+
const resolved = resolveImportPath(specifier, dirname(file));
|
|
270
|
+
if (resolved) edges.push(resolved);
|
|
271
|
+
}
|
|
272
|
+
return edges;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** changed plus its transitive dependents (reverse-edge BFS); cycle-safe via visited. */
|
|
276
|
+
export function computeDirtySet(dependents: Map<string, Set<string>>, changedFile: string): string[] {
|
|
277
|
+
const dirty: string[] = [];
|
|
278
|
+
const visited = new Set<string>();
|
|
279
|
+
const queue = [changedFile];
|
|
280
|
+
while (queue.length > 0) {
|
|
281
|
+
const file = queue.shift()!;
|
|
282
|
+
if (visited.has(file)) continue;
|
|
283
|
+
visited.add(file);
|
|
284
|
+
dirty.push(file);
|
|
285
|
+
for (const dep of dependents.get(file) ?? []) {
|
|
286
|
+
if (!visited.has(dep)) queue.push(dep);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
return dirty;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** Topological order with dependencies before dependents (only imports edges internal to dirty; cycles tolerated — remaining files appended in arbitrary order). */
|
|
293
|
+
export function topoSortDirty(imports: Map<string, Set<string>>, dirty: string[]): string[] {
|
|
294
|
+
const inSet = new Set(dirty);
|
|
295
|
+
const pending = new Map<string, number>();
|
|
296
|
+
for (const file of dirty) {
|
|
297
|
+
let count = 0;
|
|
298
|
+
for (const dep of imports.get(file) ?? []) {
|
|
299
|
+
if (inSet.has(dep)) count++;
|
|
300
|
+
}
|
|
301
|
+
pending.set(file, count);
|
|
302
|
+
}
|
|
303
|
+
const ordered: string[] = [];
|
|
304
|
+
const ready = dirty.filter((f) => pending.get(f) === 0);
|
|
305
|
+
while (ready.length > 0) {
|
|
306
|
+
const file = ready.shift()!;
|
|
307
|
+
ordered.push(file);
|
|
308
|
+
for (const other of dirty) {
|
|
309
|
+
if (pending.get(other) === undefined) continue;
|
|
310
|
+
if ((imports.get(other) ?? new Set<string>()).has(file)) {
|
|
311
|
+
const next = pending.get(other)! - 1;
|
|
312
|
+
pending.set(other, next);
|
|
313
|
+
if (next === 0) ready.push(other);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
for (const file of dirty) {
|
|
318
|
+
if (pending.has(file) && pending.get(file)! > 0) ordered.push(file);
|
|
319
|
+
}
|
|
320
|
+
return ordered;
|
|
321
|
+
}
|
|
322
|
+
|
|
99
323
|
function applyMocks(
|
|
100
324
|
directives: FunctionWithDirectives["directives"],
|
|
101
325
|
env: Environment,
|
|
102
326
|
filePath: string,
|
|
327
|
+
diagnostics: Diagnostic[],
|
|
103
328
|
): void {
|
|
104
329
|
for (const d of directives) {
|
|
105
330
|
if (d.kind !== "mock") continue;
|
|
106
|
-
if (d.
|
|
331
|
+
if (d.arrowFn) {
|
|
332
|
+
const fnType = T.fn(d.arrowFn.params, d.arrowFn.body, env);
|
|
333
|
+
(fnType as any)._paramPatterns = d.arrowFn.paramPatterns;
|
|
334
|
+
env.bind(d.name, fnType);
|
|
335
|
+
} else if (d.nudoMock) {
|
|
336
|
+
const typeVal = mockHelperToTypeValue(d.nudoMock, env);
|
|
337
|
+
env.bind(d.name, typeVal);
|
|
338
|
+
} else if (d.sinonExpr) {
|
|
339
|
+
const sinonType = createSinonTypeValue(d.sinonExpr, env);
|
|
340
|
+
env.bind(d.name, sinonType);
|
|
341
|
+
} else if (d.expression) {
|
|
342
|
+
const expr = d.expression.trim();
|
|
343
|
+
if (expr.includes("(") && expr.includes(")") && !expr.startsWith("T.")) {
|
|
344
|
+
diagnostics.push({
|
|
345
|
+
range: { start: { line: 0, column: 0 }, end: { line: 0, column: 0 } },
|
|
346
|
+
severity: "warning",
|
|
347
|
+
message: `Mock expression "${expr}" could not be parsed as a known pattern`,
|
|
348
|
+
code: "nudo:mock-invalid",
|
|
349
|
+
suggestions: [
|
|
350
|
+
"Supported formats: stub(), stub().returns(value), spy(), mock()",
|
|
351
|
+
"Arrow functions: (args) => expression or (args) => { statements; return value; }",
|
|
352
|
+
],
|
|
353
|
+
});
|
|
354
|
+
}
|
|
107
355
|
env.bind(d.name, parseTypeValueExpr(d.expression));
|
|
108
356
|
} else if (d.fromPath) {
|
|
109
357
|
const mockPath = resolve(dirname(filePath), d.fromPath);
|
|
@@ -117,6 +365,31 @@ function applyMocks(
|
|
|
117
365
|
}
|
|
118
366
|
}
|
|
119
367
|
|
|
368
|
+
function createSinonTypeValue(sinonExpr: SinonExpression, env: Environment): TypeValue {
|
|
369
|
+
// For stub and spy, create a function that returns the specified value
|
|
370
|
+
if (sinonExpr.type === "stub" || sinonExpr.type === "spy") {
|
|
371
|
+
const body = { type: "BlockStatement", body: [] } as any;
|
|
372
|
+
const fn = T.fn(["...args"], body, env);
|
|
373
|
+
|
|
374
|
+
if (sinonExpr.returnValue) {
|
|
375
|
+
// Store the return value directly on the function
|
|
376
|
+
(fn as any)._directReturn = sinonExpr.returnValue;
|
|
377
|
+
} else if (sinonExpr.resolvedValue) {
|
|
378
|
+
// Store as promise
|
|
379
|
+
(fn as any)._directReturn = T.promise(sinonExpr.resolvedValue);
|
|
380
|
+
} else if (sinonExpr.rejectedValue) {
|
|
381
|
+
// Store as never (rejected promise)
|
|
382
|
+
(fn as any)._directReturn = T.never;
|
|
383
|
+
} else {
|
|
384
|
+
// Default: return unknown
|
|
385
|
+
(fn as any)._directReturn = T.unknown;
|
|
386
|
+
}
|
|
387
|
+
return fn;
|
|
388
|
+
}
|
|
389
|
+
// For mock, return unknown for now
|
|
390
|
+
return T.unknown;
|
|
391
|
+
}
|
|
392
|
+
|
|
120
393
|
function rangeKey(r: SourceLocation): string {
|
|
121
394
|
return `${r.start.line}:${r.start.column}-${r.end.line}:${r.end.column}`;
|
|
122
395
|
}
|
|
@@ -147,7 +420,624 @@ function locFromNode(node: Node): SourceLocation {
|
|
|
147
420
|
};
|
|
148
421
|
}
|
|
149
422
|
|
|
150
|
-
|
|
423
|
+
function extractParamNames(node: Node): string[] {
|
|
424
|
+
const fn = node.type === "ExportDefaultDeclaration" ? node.declaration : node;
|
|
425
|
+
if (fn.type === "FunctionDeclaration" || fn.type === "FunctionExpression" || fn.type === "ArrowFunctionExpression") {
|
|
426
|
+
return fn.params.map((p: any) => {
|
|
427
|
+
if (p.type === "Identifier") return p.name;
|
|
428
|
+
if (p.type === "AssignmentPattern" && p.left.type === "Identifier") return p.left.name;
|
|
429
|
+
if (p.type === "RestElement" && p.argument.type === "Identifier") return `...${p.argument.name}`;
|
|
430
|
+
return "_";
|
|
431
|
+
});
|
|
432
|
+
}
|
|
433
|
+
if (fn.type === "VariableDeclaration") {
|
|
434
|
+
const decl = fn.declarations[0];
|
|
435
|
+
if (decl.init?.type === "FunctionExpression" || decl.init?.type === "ArrowFunctionExpression") {
|
|
436
|
+
return decl.init.params.map((p: any) => {
|
|
437
|
+
if (p.type === "Identifier") return p.name;
|
|
438
|
+
if (p.type === "AssignmentPattern" && p.left.type === "Identifier") return p.left.name;
|
|
439
|
+
if (p.type === "RestElement" && p.argument.type === "Identifier") return `...${p.argument.name}`;
|
|
440
|
+
return "_";
|
|
441
|
+
});
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
return [];
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
function resolveFunctionNode(node: Node): Node {
|
|
448
|
+
if (node.type === "ExportNamedDeclaration" && node.declaration) return resolveFunctionNode(node.declaration);
|
|
449
|
+
if (node.type === "ExportDefaultDeclaration") return resolveFunctionNode(node.declaration);
|
|
450
|
+
if (node.type === "VariableDeclaration") {
|
|
451
|
+
const init = (node as any).declarations[0]?.init;
|
|
452
|
+
if (init && (init.type === "ArrowFunctionExpression" || init.type === "FunctionExpression")) return init;
|
|
453
|
+
}
|
|
454
|
+
return node;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
function isFnExprValue(node: Node | null | undefined): node is Node {
|
|
458
|
+
return !!node && (node.type === "FunctionExpression" || node.type === "ArrowFunctionExpression");
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
function namedFnExprId(node: Node): string | null {
|
|
462
|
+
return node.type === "FunctionExpression" && node.id ? node.id.name : null;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/** `module.exports` — the only assignment target carrying no stable name of its own. */
|
|
466
|
+
function isModuleExportsTarget(node: Node): boolean {
|
|
467
|
+
return (
|
|
468
|
+
node.type === "MemberExpression" &&
|
|
469
|
+
!node.computed &&
|
|
470
|
+
(node as any).object?.type === "Identifier" &&
|
|
471
|
+
(node as any).object.name === "module" &&
|
|
472
|
+
(node as any).property?.type === "Identifier" &&
|
|
473
|
+
(node as any).property.name === "exports"
|
|
474
|
+
);
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
function memberPropertyKey(member: Node): string | null {
|
|
478
|
+
if (member.type !== "MemberExpression" || member.computed) return null;
|
|
479
|
+
const prop = (member as any).property;
|
|
480
|
+
if (prop?.type === "Identifier") return prop.name;
|
|
481
|
+
if (prop?.type === "StringLiteral") return String(prop.value);
|
|
482
|
+
return null;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/** Rightmost value of a chained assignment (`a = b = fn` → fn). */
|
|
486
|
+
function deepestAssignValue(expr: Node): Node | null {
|
|
487
|
+
let cur: any = expr;
|
|
488
|
+
while (cur?.type === "AssignmentExpression") cur = cur.right;
|
|
489
|
+
return cur ?? null;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
/**
|
|
493
|
+
* First stable name on an assignment chain, left to right: an identifier
|
|
494
|
+
* binding, or a member property that is not `module.exports` itself. Returns
|
|
495
|
+
* null for chains ending in a bare `module.exports = fn` or in computed
|
|
496
|
+
* members (`exports[k] = fn`) — callers fall back to "default".
|
|
497
|
+
*/
|
|
498
|
+
function assignmentChainName(expr: Node): string | null {
|
|
499
|
+
let cur: any = expr;
|
|
500
|
+
while (cur?.type === "AssignmentExpression") {
|
|
501
|
+
const target = cur.left;
|
|
502
|
+
if (target.type === "Identifier") return target.name;
|
|
503
|
+
if (target.type === "MemberExpression") {
|
|
504
|
+
if (isModuleExportsTarget(target)) {
|
|
505
|
+
cur = cur.right;
|
|
506
|
+
continue;
|
|
507
|
+
}
|
|
508
|
+
return memberPropertyKey(target);
|
|
509
|
+
}
|
|
510
|
+
return null;
|
|
511
|
+
}
|
|
512
|
+
return null;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
/**
|
|
516
|
+
* Top-level functions eligible for case synthesis. Besides FunctionDeclarations
|
|
517
|
+
* this collects CJS-style function bindings:
|
|
518
|
+
*
|
|
519
|
+
* const f = function () {}; // declarator init
|
|
520
|
+
* exports.applyToDefaults = function (a, b) {}; // namespace property
|
|
521
|
+
* module.exports = internals.clone = function () {}; // chained export
|
|
522
|
+
*
|
|
523
|
+
* Naming: identifier bindings win (call sites dispatch on them, and the
|
|
524
|
+
* evaluator's call records key on the callee identifier); a named function
|
|
525
|
+
* expression's own id beats a member property name; a bare
|
|
526
|
+
* `module.exports = fn` becomes "default" (ESM default-export analogue).
|
|
527
|
+
* Collected non-declaration entries carry `noDeclaration` — see FunctionAnalysis.
|
|
528
|
+
*
|
|
529
|
+
* v1 limitation: `internals.clone()`-style member calls go through method
|
|
530
|
+
* dispatch and never produce call records, so such functions fall back to the
|
|
531
|
+
* entry@ evaluation; same-file `X(...)` calls after `exports.X = fn` are not
|
|
532
|
+
* tracked either.
|
|
533
|
+
*/
|
|
534
|
+
function collectTopLevelFunctions(ast: Node): { name: string; node: Node; stmt: Node; noDeclaration: boolean }[] {
|
|
535
|
+
const results: { name: string; node: Node; stmt: Node; noDeclaration: boolean }[] = [];
|
|
536
|
+
if (ast.type !== "File") return results;
|
|
537
|
+
for (const stmt of (ast as any).program.body) {
|
|
538
|
+
const decl = resolveFunctionNode(stmt);
|
|
539
|
+
if (decl.type === "FunctionDeclaration" && decl.id) {
|
|
540
|
+
results.push({ name: decl.id.name, node: decl, stmt, noDeclaration: false });
|
|
541
|
+
continue;
|
|
542
|
+
}
|
|
543
|
+
if (stmt.type === "VariableDeclaration") {
|
|
544
|
+
for (const declarator of (stmt as any).declarations) {
|
|
545
|
+
if (declarator.id?.type === "Identifier" && isFnExprValue(declarator.init)) {
|
|
546
|
+
results.push({ name: declarator.id.name, node: declarator.init, stmt, noDeclaration: true });
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
continue;
|
|
550
|
+
}
|
|
551
|
+
if (stmt.type === "ExpressionStatement" && (stmt as any).expression?.type === "AssignmentExpression") {
|
|
552
|
+
const expr = (stmt as any).expression as Node;
|
|
553
|
+
const fn = deepestAssignValue(expr);
|
|
554
|
+
if (isFnExprValue(fn)) {
|
|
555
|
+
const name = namedFnExprId(fn) ?? assignmentChainName(expr) ?? "default";
|
|
556
|
+
results.push({ name, node: fn, stmt, noDeclaration: true });
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
return results;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
/** The function value of the file's single `module.exports = <function>`
|
|
564
|
+
* top-level assignment, when there is exactly one. Assigning module.exports
|
|
565
|
+
* replaces the whole exports object, so such a file exports exactly that one
|
|
566
|
+
* function and usage-site records may reach it under any re-export name —
|
|
567
|
+
* matching by targetModule alone is then unambiguous. Returns null for
|
|
568
|
+
* multi-export shapes (no such assignment, several, or `module.exports =
|
|
569
|
+
* {...}`), where a name/alias match is still required so sibling functions
|
|
570
|
+
* don't get misattributed. */
|
|
571
|
+
function findSingleModuleExportsFunction(ast: Node): Node | null {
|
|
572
|
+
if (ast.type !== "File") return null;
|
|
573
|
+
let found: Node | null = null;
|
|
574
|
+
for (const stmt of (ast as any).program.body) {
|
|
575
|
+
if (stmt.type !== "ExpressionStatement") continue;
|
|
576
|
+
const expr = (stmt as any).expression;
|
|
577
|
+
if (expr?.type !== "AssignmentExpression") continue;
|
|
578
|
+
const fn = deepestAssignValue(expr);
|
|
579
|
+
if (!isFnExprValue(fn)) continue;
|
|
580
|
+
let targetsModuleExports = false;
|
|
581
|
+
let cur: any = expr;
|
|
582
|
+
while (cur?.type === "AssignmentExpression") {
|
|
583
|
+
if (isModuleExportsTarget(cur.left)) {
|
|
584
|
+
targetsModuleExports = true;
|
|
585
|
+
break;
|
|
586
|
+
}
|
|
587
|
+
cur = cur.right;
|
|
588
|
+
}
|
|
589
|
+
if (!targetsModuleExports) continue;
|
|
590
|
+
if (found) return null; // a second `module.exports = fn` → ambiguous
|
|
591
|
+
found = fn;
|
|
592
|
+
}
|
|
593
|
+
return found;
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
function typeStructureKey(tv: TypeValue): string {
|
|
597
|
+
// Self-referential structures (x.y = x surviving a clone) would recurse
|
|
598
|
+
// infinitely: a seen-set renders revisits as a cycle token.
|
|
599
|
+
return typeStructureKeyUncached(tv, new Set());
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
function typeStructureKeyUncached(tv: TypeValue, seen: Set<object>): string {
|
|
603
|
+
// Path-scoped seen-set (backtracked after each expansion): a node renders
|
|
604
|
+
// as a cycle token only while its own expansion is on the stack, so
|
|
605
|
+
// shared singletons keep their full key.
|
|
606
|
+
const enter = (inner: TypeValue): string => {
|
|
607
|
+
if (inner && typeof inner === "object") {
|
|
608
|
+
if (seen.has(inner)) return "«cycle»";
|
|
609
|
+
seen.add(inner);
|
|
610
|
+
const out = typeStructureKeyUncached(inner, seen);
|
|
611
|
+
seen.delete(inner);
|
|
612
|
+
return out;
|
|
613
|
+
}
|
|
614
|
+
return typeStructureKeyUncached(inner, seen);
|
|
615
|
+
};
|
|
616
|
+
switch (tv.kind) {
|
|
617
|
+
case "literal":
|
|
618
|
+
return `lit(${typeof tv.value}:${String(tv.value)})`;
|
|
619
|
+
case "primitive":
|
|
620
|
+
return `prim(${tv.type})`;
|
|
621
|
+
case "array":
|
|
622
|
+
return `arr(${enter(tv.element)})`;
|
|
623
|
+
case "tuple":
|
|
624
|
+
return `tup(${tv.elements.map(enter).join(",")})`;
|
|
625
|
+
case "object":
|
|
626
|
+
return `obj(${Object.keys(tv.properties).sort().map((k) => `${k}:${enter(tv.properties[k])}`).join(",")})`;
|
|
627
|
+
case "function":
|
|
628
|
+
return `fn(${tv.params.join(",")})`;
|
|
629
|
+
case "promise":
|
|
630
|
+
return `prom(${enter(tv.value)})`;
|
|
631
|
+
case "instance":
|
|
632
|
+
return `inst(${tv.className})`;
|
|
633
|
+
case "refined":
|
|
634
|
+
return `ref(${enter(tv.base)})`;
|
|
635
|
+
case "union":
|
|
636
|
+
return `uni(${tv.members.map(enter).sort().join("|")})`;
|
|
637
|
+
default:
|
|
638
|
+
return tv.kind;
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
const MAX_PRECISE_CALLSITE_CASES = 3;
|
|
643
|
+
const COLLAPSE_LITERAL_THRESHOLD = 4;
|
|
644
|
+
|
|
645
|
+
/** widenLiteral for unions: widen each member, then dedupe (1|2|…|20 → number). */
|
|
646
|
+
function widenType(tv: TypeValue): TypeValue {
|
|
647
|
+
if (tv.kind === "union") return simplifyUnion(tv.members.map(widenLiteral));
|
|
648
|
+
return widenLiteral(tv);
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
function dedupeCallRecords(records: CallRecord[]): CallRecord[] {
|
|
652
|
+
const seen = new Set<string>();
|
|
653
|
+
const out: CallRecord[] = [];
|
|
654
|
+
for (const rec of records) {
|
|
655
|
+
// 类型值是重共享的 DAG(同一 JSON fixture 字面量流入多个参数/记录),
|
|
656
|
+
// 下方树形 key 会随共享度指数膨胀。超大记录与 resultType=never 一样
|
|
657
|
+
// 没有逐调用信息量(其 widen 后的形态才有),在 key 计算前统一丢弃。
|
|
658
|
+
if (isOversizedRecord(rec)) continue;
|
|
659
|
+
// key 必须含结果形态:同实参形状但不同结果(错误路径 never+throws vs
|
|
660
|
+
// 成功路径 Promise<...>)是不同的 case,只按实参去重会把成功记录吞进
|
|
661
|
+
// 首条错误记录里(parseChunked 的 Promise 记录曾被 L203 的 throw 吞掉)。
|
|
662
|
+
const key =
|
|
663
|
+
rec.argTypes.map(typeStructureKey).join(",") +
|
|
664
|
+
"=>" +
|
|
665
|
+
typeStructureKey(rec.resultType) +
|
|
666
|
+
"!" +
|
|
667
|
+
typeStructureKey(rec.throws);
|
|
668
|
+
if (seen.has(key)) continue;
|
|
669
|
+
seen.add(key);
|
|
670
|
+
out.push(rec);
|
|
671
|
+
}
|
|
672
|
+
return out;
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
/** DAG node budget for a usable call record; tree-shaped renderings of a
|
|
676
|
+
* type (the dedup key, the synthesized case output) grow exponentially with
|
|
677
|
+
* substructure sharing, so records whose argument/result DAGs exceed the
|
|
678
|
+
* budget are dropped rather than blown up. */
|
|
679
|
+
const MAX_RECORD_TYPE_NODES = 2000;
|
|
680
|
+
|
|
681
|
+
/** Linear-in-DAG-size node count (a `seen` set makes shared subtrees count
|
|
682
|
+
* once, so this stays cheap where a naive tree walk is exponential). */
|
|
683
|
+
function typeNodeCount(tv: TypeValue, seen: Set<object>): number {
|
|
684
|
+
if (tv === null || typeof tv !== "object") return 1;
|
|
685
|
+
if (seen.has(tv)) return 0;
|
|
686
|
+
seen.add(tv);
|
|
687
|
+
switch (tv.kind) {
|
|
688
|
+
case "array":
|
|
689
|
+
return 1 + typeNodeCount(tv.element, seen);
|
|
690
|
+
case "tuple":
|
|
691
|
+
return 1 + tv.elements.reduce((acc, e) => acc + typeNodeCount(e, seen), 0);
|
|
692
|
+
case "object": {
|
|
693
|
+
let n = 1;
|
|
694
|
+
for (const v of Object.values(tv.properties)) n += typeNodeCount(v, seen);
|
|
695
|
+
return n;
|
|
696
|
+
}
|
|
697
|
+
case "promise":
|
|
698
|
+
return 1 + typeNodeCount(tv.value, seen);
|
|
699
|
+
case "union":
|
|
700
|
+
return 1 + tv.members.reduce((acc, e) => acc + typeNodeCount(e, seen), 0);
|
|
701
|
+
case "refined":
|
|
702
|
+
return 1 + typeNodeCount(tv.base, seen);
|
|
703
|
+
default:
|
|
704
|
+
return 1;
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
function isOversizedRecord(rec: CallRecord): boolean {
|
|
709
|
+
const seen = new Set<object>();
|
|
710
|
+
for (const a of rec.argTypes) {
|
|
711
|
+
if (typeNodeCount(a, seen) > MAX_RECORD_TYPE_NODES) return true;
|
|
712
|
+
}
|
|
713
|
+
return typeNodeCount(rec.resultType, seen) > MAX_RECORD_TYPE_NODES;
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
function locFromCallLoc(loc: { line: number; column: number } | undefined): SourceLocation {
|
|
717
|
+
const p = loc ?? { line: 0, column: 0 };
|
|
718
|
+
return { start: { line: p.line, column: p.column }, end: { line: p.line, column: p.column } };
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
/**
|
|
722
|
+
* Cross-file call-site aggregation: call records whose callee is a function
|
|
723
|
+
* exported by another module (tagged by the evaluator's export side table)
|
|
724
|
+
* are grouped by (targetModule, targetExport) and synthesized directly into
|
|
725
|
+
* FunctionAnalysis entries — no re-evaluation needed, each CallRecord already
|
|
726
|
+
* carries the resultType/throws computed when this file was evaluated.
|
|
727
|
+
*
|
|
728
|
+
* v1 limitation: only named-import direct calls are recorded by the
|
|
729
|
+
* evaluator; `import * as ns` member calls go through the method path and
|
|
730
|
+
* never reach this synthesis.
|
|
731
|
+
*/
|
|
732
|
+
function synthesizeExternalFunctions(records: CallRecord[], currentFile: string): FunctionAnalysis[] {
|
|
733
|
+
const groups = new Map<string, { module: string; exportName: string; records: CallRecord[] }>();
|
|
734
|
+
for (const rec of records) {
|
|
735
|
+
if (!rec.targetModule || !rec.targetExport) continue;
|
|
736
|
+
if (rec.targetModule === currentFile) continue;
|
|
737
|
+
const key = `${rec.targetModule}\0${rec.targetExport}`;
|
|
738
|
+
let group = groups.get(key);
|
|
739
|
+
if (!group) {
|
|
740
|
+
group = { module: rec.targetModule, exportName: rec.targetExport, records: [] };
|
|
741
|
+
groups.set(key, group);
|
|
742
|
+
}
|
|
743
|
+
group.records.push(rec);
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
const out: FunctionAnalysis[] = [];
|
|
747
|
+
for (const { module, exportName, records } of groups.values()) {
|
|
748
|
+
const deduped = dedupeCallRecords(records);
|
|
749
|
+
const arity = Math.max(...deduped.map((r) => r.argTypes.length));
|
|
750
|
+
const analysis: FunctionAnalysis = {
|
|
751
|
+
name: exportName,
|
|
752
|
+
loc: locFromCallLoc(deduped[0].callLoc),
|
|
753
|
+
paramNames: Array.from({ length: arity }, (_, i) => `arg${i}`),
|
|
754
|
+
cases: [],
|
|
755
|
+
fromModule: module,
|
|
756
|
+
};
|
|
757
|
+
|
|
758
|
+
// Same capping as local synthesis: at most MAX_PRECISE_CALLSITE_CASES
|
|
759
|
+
// precise cases. The symbolic aggregate cannot re-evaluate the foreign
|
|
760
|
+
// function (its AST belongs to another file's analysis), so it unions the
|
|
761
|
+
// observed argument/result/throws types of the remaining records instead.
|
|
762
|
+
const precise = deduped.slice(0, MAX_PRECISE_CALLSITE_CASES);
|
|
763
|
+
for (const rec of precise) {
|
|
764
|
+
analysis.cases.push({
|
|
765
|
+
name: `call@L${rec.callLoc?.line ?? 0}`,
|
|
766
|
+
args: rec.argTypes,
|
|
767
|
+
result: rec.resultType,
|
|
768
|
+
throws: rec.throws,
|
|
769
|
+
source: "callsite",
|
|
770
|
+
});
|
|
771
|
+
}
|
|
772
|
+
const remaining = deduped.slice(MAX_PRECISE_CALLSITE_CASES);
|
|
773
|
+
if (remaining.length > 0) {
|
|
774
|
+
analysis.cases.push({
|
|
775
|
+
name: "call@symbolic",
|
|
776
|
+
args: Array.from({ length: arity }, (_, i) =>
|
|
777
|
+
// 缺参按真实 JS 语义 widen 成 undefined 而非 unknown——可选参守卫
|
|
778
|
+
// (target || [] 等)对 unknown 全塌,对 undefined 正常走默认分支
|
|
779
|
+
widenType(simplifyUnion(remaining.map((rec) => rec.argTypes[i] ?? T.undefined))),
|
|
780
|
+
),
|
|
781
|
+
result: collapseLiteralUnion(simplifyUnion(remaining.map((r) => r.resultType)), COLLAPSE_LITERAL_THRESHOLD),
|
|
782
|
+
throws: simplifyUnion(remaining.map((r) => r.throws)),
|
|
783
|
+
source: "callsite",
|
|
784
|
+
aggregatedFrom: remaining.length,
|
|
785
|
+
});
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
if (deduped.length > 1) {
|
|
789
|
+
analysis.combined = collapseLiteralUnion(
|
|
790
|
+
simplifyUnion(deduped.map((r) => r.resultType)),
|
|
791
|
+
COLLAPSE_LITERAL_THRESHOLD,
|
|
792
|
+
);
|
|
793
|
+
} else {
|
|
794
|
+
analysis.combined = deduped[0].resultType;
|
|
795
|
+
}
|
|
796
|
+
out.push(analysis);
|
|
797
|
+
}
|
|
798
|
+
return out;
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
function receiverTypeToDisplay(tv: TypeValue): string {
|
|
802
|
+
switch (tv.kind) {
|
|
803
|
+
case "literal": {
|
|
804
|
+
const v = tv.value;
|
|
805
|
+
if (v === null) return "null";
|
|
806
|
+
if (v === undefined) return "undefined";
|
|
807
|
+
return typeof v;
|
|
808
|
+
}
|
|
809
|
+
case "union":
|
|
810
|
+
return tv.members.map(receiverTypeToDisplay).join(" | ");
|
|
811
|
+
default:
|
|
812
|
+
return typeValueToString(tv);
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
function receiverIsConcrete(tv: TypeValue): boolean {
|
|
817
|
+
if (tv.kind === "unknown") return false;
|
|
818
|
+
if (tv.kind === "union") return tv.members.every((m) => m.kind !== "unknown");
|
|
819
|
+
return true;
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
function unknownRecordsToDiagnostics(records: UnknownRecord[]): Diagnostic[] {
|
|
823
|
+
const out: Diagnostic[] = [];
|
|
824
|
+
const seen = new Set<string>();
|
|
825
|
+
for (const r of records) {
|
|
826
|
+
const line = r.loc?.line ?? 0;
|
|
827
|
+
const column = r.loc?.column ?? 0;
|
|
828
|
+
const key = `${line}:${column}:${r.name}`;
|
|
829
|
+
if (seen.has(key)) continue;
|
|
830
|
+
seen.add(key);
|
|
831
|
+
|
|
832
|
+
const range = {
|
|
833
|
+
start: { line, column },
|
|
834
|
+
end: { line, column: column + r.name.length },
|
|
835
|
+
};
|
|
836
|
+
|
|
837
|
+
if (r.kind === "global") {
|
|
838
|
+
// 递归截断记录(name 形如 "recursion:fnName")来自求值预算,非未知全局
|
|
839
|
+
if (r.name.startsWith("recursion:")) {
|
|
840
|
+
out.push({
|
|
841
|
+
range,
|
|
842
|
+
severity: "warning",
|
|
843
|
+
message: `Recursive evaluation of '${r.name.slice("recursion:".length)}' was truncated (depth/size budget); result widened to unknown`,
|
|
844
|
+
code: "nudo:recursion-truncated",
|
|
845
|
+
});
|
|
846
|
+
continue;
|
|
847
|
+
}
|
|
848
|
+
// 模块加载守卫记录(evaluator.ts loadModuleEnv / mock-module 路径):
|
|
849
|
+
// reason 自带环链 / 链深 / 完整候选路径,直接作为诊断文案。
|
|
850
|
+
if (r.name.startsWith("module-cycle:")) {
|
|
851
|
+
out.push({
|
|
852
|
+
range,
|
|
853
|
+
severity: "warning",
|
|
854
|
+
message: r.reason ?? "Circular module load detected",
|
|
855
|
+
code: "nudo:module-cycle",
|
|
856
|
+
});
|
|
857
|
+
continue;
|
|
858
|
+
}
|
|
859
|
+
if (r.name.startsWith("module-depth:")) {
|
|
860
|
+
out.push({
|
|
861
|
+
range,
|
|
862
|
+
severity: "warning",
|
|
863
|
+
message: r.reason ?? "Module load chain too deep",
|
|
864
|
+
code: "nudo:module-depth",
|
|
865
|
+
});
|
|
866
|
+
continue;
|
|
867
|
+
}
|
|
868
|
+
if (r.name.startsWith("module-missing:")) {
|
|
869
|
+
out.push({
|
|
870
|
+
range,
|
|
871
|
+
severity: "error",
|
|
872
|
+
message: r.reason ?? "Module file not found",
|
|
873
|
+
code: "nudo:module-missing",
|
|
874
|
+
});
|
|
875
|
+
continue;
|
|
876
|
+
}
|
|
877
|
+
out.push({
|
|
878
|
+
range,
|
|
879
|
+
severity: "warning",
|
|
880
|
+
message: `Unknown global identifier '${r.name}'`,
|
|
881
|
+
code: "nudo:unknown-global",
|
|
882
|
+
});
|
|
883
|
+
continue;
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
const receiver = r.receiverType;
|
|
887
|
+
if (receiver && receiverIsConcrete(receiver)) {
|
|
888
|
+
// instance 类型的方法集是声明的近似(未列出 ≠ 运行时不存在),
|
|
889
|
+
// 只有基类型(number/string 等)上的方法缺失才是确定错误。
|
|
890
|
+
// union 混合(Set | [] | {})同理:部分 member 可能有该方法,
|
|
891
|
+
// 缺失不确定 → warning。
|
|
892
|
+
const isApprox = (() => {
|
|
893
|
+
const members =
|
|
894
|
+
receiver.kind === "union" ? receiver.members : [receiver];
|
|
895
|
+
return members.some(
|
|
896
|
+
(m) =>
|
|
897
|
+
m.kind === "instance" ||
|
|
898
|
+
m.kind === "function" ||
|
|
899
|
+
m.kind === "tuple" ||
|
|
900
|
+
m.kind === "array" ||
|
|
901
|
+
m.kind === "object" ||
|
|
902
|
+
m.kind === "refined" ||
|
|
903
|
+
// wrapper/instance 近似表上可能持有该成员('x'.charCodeAt 于
|
|
904
|
+
// number|string:string 侧存在,number 侧缺失 → 不确定 → warning)
|
|
905
|
+
memberMayExistOn(m, r.name),
|
|
906
|
+
);
|
|
907
|
+
})();
|
|
908
|
+
const kindLabel = r.kind === "method" ? "Method" : "Property";
|
|
909
|
+
out.push({
|
|
910
|
+
range,
|
|
911
|
+
severity: isApprox ? "warning" : "error",
|
|
912
|
+
message: `${kindLabel} '${r.name}' does not exist on type '${receiverTypeToDisplay(receiver)}'`,
|
|
913
|
+
code: "nudo:no-method",
|
|
914
|
+
...(r.origin ? { origin: r.origin } : {}),
|
|
915
|
+
});
|
|
916
|
+
} else {
|
|
917
|
+
out.push({
|
|
918
|
+
range,
|
|
919
|
+
severity: "warning",
|
|
920
|
+
message: `Cannot resolve '${r.name}' on unknown value`,
|
|
921
|
+
code: "nudo:unknown-recv",
|
|
922
|
+
...(r.origin ? { origin: r.origin } : {}),
|
|
923
|
+
});
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
return out;
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
function collectEnvNames(filePath: string, source: string, includeProject: boolean): string[] {
|
|
930
|
+
const ast = parse(source);
|
|
931
|
+
const fileDirectives = extractFileDirectives(ast);
|
|
932
|
+
const fileEnvNames = fileDirectives
|
|
933
|
+
.filter((d) => d.kind === "env")
|
|
934
|
+
.flatMap((d) => d.envs);
|
|
935
|
+
if (!includeProject) return fileEnvNames;
|
|
936
|
+
const projectConfig = findProjectConfig(dirname(filePath));
|
|
937
|
+
const projectEnvNames = projectConfig?.config.env ?? [];
|
|
938
|
+
return [...new Set([...projectEnvNames, ...fileEnvNames])];
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
/**
|
|
942
|
+
* Async entry to analyzeFile: preloads path-based env files
|
|
943
|
+
* (`/// @nudo:env ./nudo-harvest-node.ts`) via dynamic import — impossible
|
|
944
|
+
* synchronously in ESM — then runs the sync analysis, which picks the
|
|
945
|
+
* preloaded factories up from the env-loader cache. The sync analyzeFile
|
|
946
|
+
* signature is unchanged for existing consumers (LSP, MCP, vite-plugin).
|
|
947
|
+
*/
|
|
948
|
+
export async function analyzeFileAsync(
|
|
949
|
+
filePath: string,
|
|
950
|
+
source: string,
|
|
951
|
+
activeCases?: Map<string, number>,
|
|
952
|
+
externalCallRecords?: CallRecord[],
|
|
953
|
+
): Promise<AnalysisResult> {
|
|
954
|
+
const envNames = collectEnvNames(filePath, source, true);
|
|
955
|
+
if (envNames.length > 0) {
|
|
956
|
+
await preloadPathEnvs(envNames, dirname(filePath));
|
|
957
|
+
}
|
|
958
|
+
return analyzeFile(filePath, source, activeCases, externalCallRecords);
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
/**
|
|
962
|
+
* 调用点发现(阶段一):在"使用现场"文件(测试 / 上层应用)中求值
|
|
963
|
+
* 顶层代码,收集它对(外部模块导出的)函数的调用记录。每条记录带
|
|
964
|
+
* 真实的实参类型与结果类型——后续 analyzeFile 将其注入合成 case,
|
|
965
|
+
* 使被使用方从 entry-only(参数全 unknown)升级为真实调用形态。
|
|
966
|
+
*
|
|
967
|
+
* 只做求值与记录,不产出诊断;求值异常不抛出(使用现场文件可能
|
|
968
|
+
* 依赖未 mock 的全局,收集不到就收集不到,不能拖垮主分析)。
|
|
969
|
+
*/
|
|
970
|
+
export function collectCallRecords(filePath: string, source: string): CallRecord[] {
|
|
971
|
+
const records: CallRecord[] = [];
|
|
972
|
+
// 使用现场可能是老 CJS(八进制字面量等历史语法)——宽松恢复模式,
|
|
973
|
+
// 收集尽力而为;主分析的 parse 不受影响
|
|
974
|
+
const ast = parse(source, { errorRecovery: true });
|
|
975
|
+
resetMemo();
|
|
976
|
+
resetUnreachableRanges();
|
|
977
|
+
resetEnvModules();
|
|
978
|
+
resetMockModules();
|
|
979
|
+
setModuleResolver(resolveModule);
|
|
980
|
+
setCurrentFileDir(dirname(filePath));
|
|
981
|
+
setCurrentSource(source);
|
|
982
|
+
setCallCollector((record) => records.push(record));
|
|
983
|
+
try {
|
|
984
|
+
const env = createEnvironment();
|
|
985
|
+
evaluateProgram(ast, env);
|
|
986
|
+
// 测试框架语义近似:it/describe/test 的回调在顶层求值中不会执行,
|
|
987
|
+
// 但它们的函数体正是真实调用点所在。以 unknown 参数手动执行每个
|
|
988
|
+
// 回调体;describe 回调体内嵌的 it(...) 继续展开(测试常嵌套)。
|
|
989
|
+
const runCallbacks = (statements: Node[]): void => {
|
|
990
|
+
for (const stmt of statements) {
|
|
991
|
+
if (stmt.type !== "ExpressionStatement" || !("expression" in stmt)) continue;
|
|
992
|
+
const expr = (stmt as { expression: Node }).expression;
|
|
993
|
+
if (expr.type !== "CallExpression") continue;
|
|
994
|
+
const callee = (expr as Node & { callee: Node }).callee;
|
|
995
|
+
if (callee.type !== "MemberExpression" && callee.type !== "Identifier") continue;
|
|
996
|
+
const name =
|
|
997
|
+
callee.type === "Identifier"
|
|
998
|
+
? callee.name
|
|
999
|
+
: callee.property.type === "Identifier"
|
|
1000
|
+
? callee.property.name
|
|
1001
|
+
: null;
|
|
1002
|
+
if (!name || !TEST_CALLBACK_NAMES.has(name)) continue;
|
|
1003
|
+
const args = (expr as Node & { arguments: Node[] }).arguments;
|
|
1004
|
+
const cb = args.find((a) => a.type === "ArrowFunctionExpression" || a.type === "FunctionExpression") as
|
|
1005
|
+
| (Node & { body: Node })
|
|
1006
|
+
| undefined;
|
|
1007
|
+
if (!cb) continue;
|
|
1008
|
+
try {
|
|
1009
|
+
// 以 unknown 参数执行回调体(evaluate 只构造函数类型不执行)
|
|
1010
|
+
const params = (cb as unknown as { params?: Node[] }).params ?? [];
|
|
1011
|
+
evaluateFunction(cb, params.map(() => T.unknown), env);
|
|
1012
|
+
} catch {
|
|
1013
|
+
/* 单个回调失败不影响其余 */
|
|
1014
|
+
}
|
|
1015
|
+
// describe 回调体是语句列表——递归展开嵌套的 it/describe
|
|
1016
|
+
if (name === "describe" && cb.body.type === "BlockStatement") {
|
|
1017
|
+
runCallbacks((cb.body as unknown as { body: Node[] }).body);
|
|
1018
|
+
}
|
|
1019
|
+
}
|
|
1020
|
+
};
|
|
1021
|
+
runCallbacks(ast.program.body as unknown as Node[]);
|
|
1022
|
+
} catch {
|
|
1023
|
+
/* 收集尽力而为 */
|
|
1024
|
+
} finally {
|
|
1025
|
+
setCallCollector(null);
|
|
1026
|
+
setModuleResolver(null);
|
|
1027
|
+
setUnknownBuiltinHandler(null);
|
|
1028
|
+
}
|
|
1029
|
+
return records;
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
/** 测试框架的回调注册函数:回调体里是真实调用点 */
|
|
1033
|
+
const TEST_CALLBACK_NAMES = new Set(["it", "test", "describe"]);
|
|
1034
|
+
|
|
1035
|
+
export function analyzeFile(filePath: string, source: string, activeCases?: Map<string, number>, externalCallRecords?: CallRecord[]): AnalysisResult {
|
|
1036
|
+
// 外部实参里的闭包在使用现场文件定义——先打 usage-site 标记再进入任何
|
|
1037
|
+
// 求值(case 合成重求值会执行它们,泄漏的错误记录靠此标记丢弃)。
|
|
1038
|
+
for (const rec of externalCallRecords ?? []) {
|
|
1039
|
+
for (const a of rec.argTypes) setUsageSiteTag(a);
|
|
1040
|
+
}
|
|
151
1041
|
const ast = parse(source);
|
|
152
1042
|
const functions = extractDirectives(ast);
|
|
153
1043
|
const diagnostics: Diagnostic[] = [];
|
|
@@ -158,10 +1048,74 @@ export function analyzeFile(filePath: string, source: string, activeCases?: Map<
|
|
|
158
1048
|
|
|
159
1049
|
resetMemo();
|
|
160
1050
|
resetUnreachableRanges();
|
|
1051
|
+
resetEnvModules();
|
|
1052
|
+
resetMockModules();
|
|
161
1053
|
setModuleResolver(resolveModule);
|
|
162
1054
|
setCurrentFileDir(dirname(filePath));
|
|
1055
|
+
setCurrentSource(source);
|
|
1056
|
+
|
|
1057
|
+
const fileDirectives = extractFileDirectives(ast);
|
|
1058
|
+
const fileEnvNames = fileDirectives
|
|
1059
|
+
.filter((d) => d.kind === "env")
|
|
1060
|
+
.flatMap((d) => d.envs);
|
|
1061
|
+
|
|
1062
|
+
const projectConfig = findProjectConfig(dirname(filePath));
|
|
1063
|
+
const projectEnvNames = projectConfig?.config.env ?? [];
|
|
1064
|
+
const envNames = [...new Set([...projectEnvNames, ...fileEnvNames])];
|
|
1065
|
+
|
|
1066
|
+
setUnknownBuiltinHandler((name, loc) => {
|
|
1067
|
+
diagnostics.push({
|
|
1068
|
+
range: loc ? { start: loc.start, end: loc.end } : { start: { line: 0, column: 0 }, end: { line: 0, column: 0 } },
|
|
1069
|
+
severity: "warning",
|
|
1070
|
+
message: `Built-in API "${name}" is not covered by Nudo's type inference`,
|
|
1071
|
+
code: "nudo:builtin-unknown",
|
|
1072
|
+
suggestions: [
|
|
1073
|
+
`Use @nudo:mock to define the type: @nudo:mock ${name} = stub().returns(...)`,
|
|
1074
|
+
`Or use @nudo:returns to declare the expected return type`,
|
|
1075
|
+
],
|
|
1076
|
+
});
|
|
1077
|
+
});
|
|
163
1078
|
|
|
164
1079
|
const globalEnv = createEnvironment();
|
|
1080
|
+
|
|
1081
|
+
if (envNames.length > 0) {
|
|
1082
|
+
const loaded = loadEnvs(envNames, globalEnv);
|
|
1083
|
+
setEnvModules(loaded.modules);
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
const mocks = new Map<string, { fromPath: string; names?: string[] }>();
|
|
1087
|
+
|
|
1088
|
+
if (projectConfig?.config.mocks) {
|
|
1089
|
+
for (const [source, mockPath] of Object.entries(projectConfig.config.mocks)) {
|
|
1090
|
+
mocks.set(source, { fromPath: resolve(projectConfig.projectDir, mockPath) });
|
|
1091
|
+
}
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
const mockModuleDirectives = fileDirectives.filter((d) => d.kind === "mock-module");
|
|
1095
|
+
for (const d of mockModuleDirectives) {
|
|
1096
|
+
mocks.set(d.source, { fromPath: d.fromPath, names: d.names });
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
if (mocks.size > 0) {
|
|
1100
|
+
setMockModules(mocks);
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
const callRecords: CallRecord[] = [];
|
|
1104
|
+
setCallCollector((record) => callRecords.push(record));
|
|
1105
|
+
|
|
1106
|
+
const unknownRecords: UnknownRecord[] = [];
|
|
1107
|
+
setUnknownCollector((r) => unknownRecords.push(r));
|
|
1108
|
+
|
|
1109
|
+
setProvenanceTracking(true);
|
|
1110
|
+
|
|
1111
|
+
// @nudo:mock 绑定必须先于全程序求值:顶层调用点(如 mockHof([1,2,3]))
|
|
1112
|
+
// 在 evaluateProgram 中执行并由 callCollector 记录 call@ case 的
|
|
1113
|
+
// resultType——mock 晚于此绑定会让「mock 函数值作为回调实参」在该路径
|
|
1114
|
+
// 整体降级 unknown(@nudo:case 求值发生在下方循环内,此前不受影响)。
|
|
1115
|
+
for (const fn of functions) {
|
|
1116
|
+
applyMocks(fn.directives, globalEnv, filePath, diagnostics);
|
|
1117
|
+
}
|
|
1118
|
+
|
|
165
1119
|
evaluateProgram(ast, globalEnv);
|
|
166
1120
|
|
|
167
1121
|
const unreachableRanges = getUnreachableRanges();
|
|
@@ -169,24 +1123,28 @@ export function analyzeFile(filePath: string, source: string, activeCases?: Map<
|
|
|
169
1123
|
diagnostics.push({
|
|
170
1124
|
range: ur,
|
|
171
1125
|
severity: "info",
|
|
172
|
-
message: "
|
|
1126
|
+
message: "Code after return/throw statement is unreachable",
|
|
173
1127
|
tags: ["unnecessary"],
|
|
1128
|
+
code: "nudo-unreachable",
|
|
1129
|
+
suggestions: ["Remove the unreachable code after the return/throw statement"],
|
|
174
1130
|
});
|
|
175
1131
|
}
|
|
176
1132
|
|
|
177
1133
|
collectBindings(ast, globalEnv, bindings);
|
|
178
1134
|
|
|
179
|
-
|
|
180
|
-
applyMocks(fn.directives, globalEnv, filePath);
|
|
1135
|
+
const synthCandidates: { name: string; node: Node; analysis: FunctionAnalysis }[] = [];
|
|
181
1136
|
|
|
1137
|
+
for (const fn of functions) {
|
|
182
1138
|
const isPure = fn.directives.some((d) => d.kind === "pure");
|
|
183
1139
|
const skipDirective = fn.directives.find((d) => d.kind === "skip");
|
|
184
1140
|
const returnsDirective = fn.directives.find((d) => d.kind === "returns");
|
|
185
1141
|
|
|
186
1142
|
const fnLoc = locFromNode(fn.node);
|
|
187
|
-
const
|
|
1143
|
+
const paramNames = extractParamNames(fn.node);
|
|
1144
|
+
const analysis: FunctionAnalysis = { name: fn.name, loc: fnLoc, paramNames, cases: [] };
|
|
188
1145
|
|
|
189
1146
|
if (skipDirective && skipDirective.kind === "skip") {
|
|
1147
|
+
analysis.skipped = true;
|
|
190
1148
|
if (skipDirective.returns) {
|
|
191
1149
|
analysis.combined = skipDirective.returns;
|
|
192
1150
|
}
|
|
@@ -201,9 +1159,20 @@ export function analyzeFile(filePath: string, source: string, activeCases?: Map<
|
|
|
201
1159
|
}
|
|
202
1160
|
}
|
|
203
1161
|
|
|
1162
|
+
const sampleDirective = fn.directives.find((d) => d.kind === "sample");
|
|
1163
|
+
if (sampleDirective && sampleDirective.kind === "sample") {
|
|
1164
|
+
setSampleCount(sampleDirective.count);
|
|
1165
|
+
} else {
|
|
1166
|
+
setSampleCount(3);
|
|
1167
|
+
}
|
|
1168
|
+
|
|
204
1169
|
const caseDirectives = fn.directives.filter((d) => d.kind === "case");
|
|
205
1170
|
const activeCaseIdx = activeCases?.get(fn.name) ?? 0;
|
|
206
1171
|
|
|
1172
|
+
if (caseDirectives.length === 0) {
|
|
1173
|
+
synthCandidates.push({ name: fn.name, node: fn.node, analysis });
|
|
1174
|
+
}
|
|
1175
|
+
|
|
207
1176
|
for (let ci = 0; ci < caseDirectives.length; ci++) {
|
|
208
1177
|
const directive = caseDirectives[ci];
|
|
209
1178
|
resetUnreachableRanges();
|
|
@@ -236,7 +1205,7 @@ export function analyzeFile(filePath: string, source: string, activeCases?: Map<
|
|
|
236
1205
|
diagnostics.push({
|
|
237
1206
|
range: { start: { line: directive.commentLine, column: 0 }, end: { line: directive.commentLine, column: 999 } },
|
|
238
1207
|
severity: "error",
|
|
239
|
-
message: `Case "${directive.name}": expected ${typeValueToString(directive.expected)}, got ${typeValueToString(fullResult.value)}`,
|
|
1208
|
+
message: `Case "${directive.name}": expected ${typeValueToString(directive.expected)}, got ${typeValueToString(fullResult.value)}. The inferred return type does not match the expected type declared in the @nudo:case directive`,
|
|
240
1209
|
});
|
|
241
1210
|
}
|
|
242
1211
|
}
|
|
@@ -252,7 +1221,8 @@ export function analyzeFile(filePath: string, source: string, activeCases?: Map<
|
|
|
252
1221
|
diagnostics.push({
|
|
253
1222
|
range: throwRange,
|
|
254
1223
|
severity: "warning",
|
|
255
|
-
message: `Function "${fn.name}" case "${directive.name}" may throw: ${typeValueToString(fullResult.throws)}`,
|
|
1224
|
+
message: `Function "${fn.name}" case "${directive.name}" may throw: ${typeValueToString(fullResult.throws)}. Consider adding a try-catch block or using @nudo:returns to declare expected behavior`,
|
|
1225
|
+
code: "nudo-may-throw",
|
|
256
1226
|
});
|
|
257
1227
|
}
|
|
258
1228
|
|
|
@@ -260,15 +1230,17 @@ export function analyzeFile(filePath: string, source: string, activeCases?: Map<
|
|
|
260
1230
|
diagnostics.push({
|
|
261
1231
|
range: ur,
|
|
262
1232
|
severity: "info",
|
|
263
|
-
message: "
|
|
1233
|
+
message: "Code after return/throw statement is unreachable",
|
|
264
1234
|
tags: ["unnecessary"],
|
|
1235
|
+
code: "nudo-unreachable",
|
|
1236
|
+
suggestions: ["Remove the unreachable code after the return/throw statement"],
|
|
265
1237
|
});
|
|
266
1238
|
}
|
|
267
1239
|
}
|
|
268
1240
|
}
|
|
269
1241
|
|
|
270
1242
|
if (analysis.cases.length > 1) {
|
|
271
|
-
analysis.combined = simplifyUnion(analysis.cases.map((c) => c.result));
|
|
1243
|
+
analysis.combined = collapseLiteralUnion(simplifyUnion(analysis.cases.map((c) => c.result)), COLLAPSE_LITERAL_THRESHOLD);
|
|
272
1244
|
} else if (analysis.cases.length === 1) {
|
|
273
1245
|
analysis.combined = analysis.cases[0].result;
|
|
274
1246
|
}
|
|
@@ -279,12 +1251,17 @@ export function analyzeFile(filePath: string, source: string, activeCases?: Map<
|
|
|
279
1251
|
const result = evaluateFunction(fn.node, directive.args, globalEnv);
|
|
280
1252
|
const matches = isSubtypeOf(result, returnsDirective.expected);
|
|
281
1253
|
if (!matches) {
|
|
282
|
-
const msg = `@nudo:returns assertion failed for case "${directive.name}": expected ${typeValueToString(returnsDirective.expected)}, got ${typeValueToString(result)}`;
|
|
1254
|
+
const msg = `@nudo:returns assertion failed for case "${directive.name}": expected ${typeValueToString(returnsDirective.expected)}, got ${typeValueToString(result)}. Update the @nudo:returns directive to match the inferred type, or fix the function implementation`;
|
|
283
1255
|
analysis.assertionErrors.push(msg);
|
|
284
1256
|
diagnostics.push({
|
|
285
1257
|
range: fnLoc,
|
|
286
1258
|
severity: "error",
|
|
287
1259
|
message: msg,
|
|
1260
|
+
code: "nudo-assertion-failed",
|
|
1261
|
+
suggestions: [
|
|
1262
|
+
"Update @nudo:returns to match the inferred type",
|
|
1263
|
+
"Fix the function to return the expected type",
|
|
1264
|
+
],
|
|
288
1265
|
});
|
|
289
1266
|
}
|
|
290
1267
|
}
|
|
@@ -293,9 +1270,190 @@ export function analyzeFile(filePath: string, source: string, activeCases?: Map<
|
|
|
293
1270
|
functionResults.push(analysis);
|
|
294
1271
|
}
|
|
295
1272
|
|
|
1273
|
+
// Whole-program call inference: functions without @nudo:case directives
|
|
1274
|
+
// get cases synthesized from observed call sites; functions with no call
|
|
1275
|
+
// sites at all get a single entry evaluation with unknown parameters.
|
|
1276
|
+
const directiveFnNames = new Set(functions.map((f) => f.name));
|
|
1277
|
+
const directiveFnStmts = new Set(functions.map((f) => f.node));
|
|
1278
|
+
for (const { name, node, stmt, noDeclaration } of collectTopLevelFunctions(ast)) {
|
|
1279
|
+
if (directiveFnNames.has(name)) continue;
|
|
1280
|
+
// A statement carrying @nudo directives is already analyzed through the
|
|
1281
|
+
// directive path above (possibly under its "<anonymous>" name).
|
|
1282
|
+
if (directiveFnStmts.has(stmt)) continue;
|
|
1283
|
+
const analysis: FunctionAnalysis = { name, loc: locFromNode(node), paramNames: extractParamNames(node), cases: [] };
|
|
1284
|
+
if (noDeclaration) analysis.noDeclaration = true;
|
|
1285
|
+
functionResults.push(analysis);
|
|
1286
|
+
synthCandidates.push({ name, node, analysis });
|
|
1287
|
+
}
|
|
1288
|
+
|
|
1289
|
+
// 模块路匹配的前置量:本文件绝对路径(realpath 对齐符号链接后再比,
|
|
1290
|
+
// 双侧同一归一化函数,避免单侧 realpath 造成不一致),以及
|
|
1291
|
+
// `module.exports = function` 单导出形态的目标函数。
|
|
1292
|
+
const modulePathCache = new Map<string, string>();
|
|
1293
|
+
const normalizeModulePath = (p: string): string => {
|
|
1294
|
+
let n = modulePathCache.get(p);
|
|
1295
|
+
if (n === undefined) {
|
|
1296
|
+
try {
|
|
1297
|
+
n = realpathSync(p);
|
|
1298
|
+
} catch {
|
|
1299
|
+
n = p;
|
|
1300
|
+
}
|
|
1301
|
+
modulePathCache.set(p, n);
|
|
1302
|
+
}
|
|
1303
|
+
return n;
|
|
1304
|
+
};
|
|
1305
|
+
const currentModulePath = normalizeModulePath(resolve(filePath));
|
|
1306
|
+
const singleExportFn = findSingleModuleExportsFunction(ast);
|
|
1307
|
+
|
|
1308
|
+
for (const candidate of synthCandidates) {
|
|
1309
|
+
// 调用点来源有两路:本文件求值中观察到的调用,以及外部注入的
|
|
1310
|
+
// (使用现场文件——如测试——对本文导出函数的真实调用,CLI 经
|
|
1311
|
+
// --callsites 收集后传入)。带 targetModule 的记录先判归属:只有
|
|
1312
|
+
// 指向本文件的记录才允许参与匹配——导出名/别名离开模块单独无意义
|
|
1313
|
+
// (单导出文件的 targetExport 全是 "default",不判模块会跨文件误染,
|
|
1314
|
+
// 如 clone.js 的记录命中 applyToDefaults.js 的 "default" candidate)。
|
|
1315
|
+
// 归属本文件(或无模块信息的本地记录)后再走三路:
|
|
1316
|
+
// 1. 名字路:fnName(调用处可见名)或 targetExport(定义处导出名)
|
|
1317
|
+
// 2. 别名路:re-export 链上后来出现的导出名(evaluator 的
|
|
1318
|
+
// targetAliases,如 barrel / CJS 转发 shim 下的属性名)
|
|
1319
|
+
// 3. 模块路:本文件以 `module.exports = function` 单导出时直接收
|
|
1320
|
+
// ——使用方可能以任意转发名调用它;多导出文件不走模块路,
|
|
1321
|
+
// 避免同文件多函数误染。
|
|
1322
|
+
const singleExportHit = singleExportFn !== null && candidate.node === singleExportFn;
|
|
1323
|
+
const nameRoutes = (r: CallRecord): boolean =>
|
|
1324
|
+
r.fnName === candidate.name ||
|
|
1325
|
+
r.targetExport === candidate.name ||
|
|
1326
|
+
(r.targetAliases?.includes(candidate.name) ?? false) ||
|
|
1327
|
+
(singleExportHit && (r.fnModule !== undefined || r.targetModule !== undefined));
|
|
1328
|
+
// 本地记录:来自本文件求值。带 targetModule 的(本文件导出被调用)
|
|
1329
|
+
// 判归属;无 tag 的内部调用按名字匹配(一直以来的行为)。
|
|
1330
|
+
const matchingLocal = (r: CallRecord): boolean => {
|
|
1331
|
+
const targetsThisFile =
|
|
1332
|
+
r.targetModule === undefined || normalizeModulePath(r.targetModule) === currentModulePath;
|
|
1333
|
+
if (!targetsThisFile) return false;
|
|
1334
|
+
return nameRoutes(r);
|
|
1335
|
+
};
|
|
1336
|
+
// 外部记录(使用现场收集)必须可归因到本文件:fnModule(定义位点——
|
|
1337
|
+
// require 传递求值中库内部调用的记录)或 targetModule(导出 tag)。
|
|
1338
|
+
// 无归因的记录是测试本地函数或裸内置名,按名字撞库属跨文件污染
|
|
1339
|
+
// (实测:测试局部 compare() 撞 contain.js internals.compare)。
|
|
1340
|
+
const matchingExternal = (r: CallRecord): boolean => {
|
|
1341
|
+
const attributed =
|
|
1342
|
+
(r.fnModule !== undefined && normalizeModulePath(r.fnModule) === currentModulePath) ||
|
|
1343
|
+
(r.targetModule !== undefined && normalizeModulePath(r.targetModule) === currentModulePath);
|
|
1344
|
+
if (!attributed) return false;
|
|
1345
|
+
return nameRoutes(r);
|
|
1346
|
+
};
|
|
1347
|
+
// resultType=never 且 throws=never 是求值中断的信号泄漏(如
|
|
1348
|
+
// `new Promise(async …)` 高阶 async 中 await 切断求值),无信息量,
|
|
1349
|
+
// 注入会产出误导 case;本地与注入记录一致跳过,全部被跳过的
|
|
1350
|
+
// candidate 自然落入下方 entry@ 回退。resultType=never 但 throws≠never
|
|
1351
|
+
// 是真实的抛出调用(argTypes + throws 都有信息),保留。
|
|
1352
|
+
const records = dedupeCallRecords(
|
|
1353
|
+
[
|
|
1354
|
+
...callRecords.filter(matchingLocal),
|
|
1355
|
+
...(externalCallRecords ?? []).filter(matchingExternal),
|
|
1356
|
+
].filter((r) => !(r.resultType.kind === "never" && r.throws.kind === "never")),
|
|
1357
|
+
);
|
|
1358
|
+
if (records.length > 0) {
|
|
1359
|
+
// 案例选择偏好:结果有信息量的记录优先(精确/字面量/结构化),
|
|
1360
|
+
// unknown 结果的排后——收集顺序里错误路径或 undefined 形态的测试
|
|
1361
|
+
// 常排在前面,slice 截断会把 concrete-precise 记录挤掉(hoek clone
|
|
1362
|
+
// 的 682 条记录曾由 3 条 undefined 形态占满前 3 席)。
|
|
1363
|
+
const informativeness = (r: CallRecord): number => {
|
|
1364
|
+
if (r.resultType.kind === "unknown") return 2;
|
|
1365
|
+
if (r.resultType.kind === "never") return 1;
|
|
1366
|
+
return 0;
|
|
1367
|
+
};
|
|
1368
|
+
const ordered = records
|
|
1369
|
+
.map((r, i) => ({ r, i }))
|
|
1370
|
+
.sort((a, b) => informativeness(a.r) - informativeness(b.r) || a.i - b.i)
|
|
1371
|
+
.map(({ r }) => r);
|
|
1372
|
+
const precise = ordered.slice(0, MAX_PRECISE_CALLSITE_CASES);
|
|
1373
|
+
for (const rec of precise) {
|
|
1374
|
+
candidate.analysis.cases.push({
|
|
1375
|
+
name: `call@L${rec.callLoc?.line ?? candidate.analysis.loc.start.line}`,
|
|
1376
|
+
args: rec.argTypes,
|
|
1377
|
+
result: rec.resultType,
|
|
1378
|
+
throws: rec.throws,
|
|
1379
|
+
source: "callsite",
|
|
1380
|
+
});
|
|
1381
|
+
}
|
|
1382
|
+
// symbolic 聚合只用全已知实参的记录:含 unknown 分量的记录不可重求值
|
|
1383
|
+
// (unknown 吸收整个 union,一条循环引用 fixture 的记录就能毒化全部
|
|
1384
|
+
// 剩余聚合——clone 704 条中的 53 条 unknown 实参曾拖垮其余 651 条)。
|
|
1385
|
+
// 排除不声明覆盖,sound;全部不可求值时不产 symbolic case(诚实)。
|
|
1386
|
+
const remaining = ordered
|
|
1387
|
+
.slice(MAX_PRECISE_CALLSITE_CASES)
|
|
1388
|
+
.filter((rec) => !rec.argTypes.some((a) => a.kind === "unknown"));
|
|
1389
|
+
if (remaining.length > 0) {
|
|
1390
|
+
const fnNode = resolveFunctionNode(candidate.node);
|
|
1391
|
+
const paramCount = extractParamNames(fnNode).length;
|
|
1392
|
+
const widenedArgs = Array.from({ length: paramCount }, (_, i) =>
|
|
1393
|
+
// 缺参按真实 JS 语义 widen 成 undefined 而非 unknown——可选参守卫
|
|
1394
|
+
// (target || [] 等)对 unknown 全塌,对 undefined 正常走默认分支
|
|
1395
|
+
widenType(simplifyUnion(remaining.map((rec) => rec.argTypes[i] ?? T.undefined))),
|
|
1396
|
+
);
|
|
1397
|
+
const full = evaluateFunctionFull(fnNode, widenedArgs, globalEnv);
|
|
1398
|
+
candidate.analysis.cases.push({
|
|
1399
|
+
name: "call@symbolic",
|
|
1400
|
+
args: widenedArgs,
|
|
1401
|
+
result: full.value,
|
|
1402
|
+
throws: full.throws,
|
|
1403
|
+
throwLoc: full.throwLoc,
|
|
1404
|
+
source: "callsite",
|
|
1405
|
+
aggregatedFrom: remaining.length,
|
|
1406
|
+
});
|
|
1407
|
+
}
|
|
1408
|
+
// Combined covers every observed call site (not just the retained
|
|
1409
|
+
// cases), so a large set of same-base literal results collapses to
|
|
1410
|
+
// the widened base type instead of a 20-literal union.
|
|
1411
|
+
candidate.analysis.combined = collapseLiteralUnion(
|
|
1412
|
+
simplifyUnion(records.map((r) => r.resultType)),
|
|
1413
|
+
COLLAPSE_LITERAL_THRESHOLD,
|
|
1414
|
+
);
|
|
1415
|
+
continue;
|
|
1416
|
+
}
|
|
1417
|
+
|
|
1418
|
+
const fnNode = resolveFunctionNode(candidate.node);
|
|
1419
|
+
const args = extractParamNames(fnNode).map(() => T.unknown);
|
|
1420
|
+
const full = evaluateFunctionFull(fnNode, args, globalEnv);
|
|
1421
|
+
candidate.analysis.cases.push({
|
|
1422
|
+
name: `entry@L${candidate.analysis.loc.start.line}`,
|
|
1423
|
+
args,
|
|
1424
|
+
result: full.value,
|
|
1425
|
+
throws: full.throws,
|
|
1426
|
+
throwLoc: full.throwLoc,
|
|
1427
|
+
});
|
|
1428
|
+
candidate.analysis.entryOnly = true;
|
|
1429
|
+
candidate.analysis.combined = collapseLiteralUnion(full.value, COLLAPSE_LITERAL_THRESHOLD);
|
|
1430
|
+
}
|
|
1431
|
+
|
|
296
1432
|
buildNodeTypeMap(ast, globalEnv, nodeTypeMap);
|
|
297
1433
|
|
|
1434
|
+
setUnknownBuiltinHandler(null);
|
|
1435
|
+
setCallCollector(null);
|
|
1436
|
+
setUnknownCollector(null);
|
|
1437
|
+
setProvenanceTracking(false);
|
|
298
1438
|
setModuleResolver(null);
|
|
1439
|
+
resetEnvModules();
|
|
1440
|
+
resetMockModules();
|
|
1441
|
+
|
|
1442
|
+
// usage-site 执行泄漏守卫:case 合成重求值会执行注入实参携带的使用现场
|
|
1443
|
+
// 闭包体(测试回调),其内部错误记录属于使用现场文件——不能记在本文件
|
|
1444
|
+
// 名下(wait.js 曾背着 test/index.js:2407 的 no-method;json-ext 的
|
|
1445
|
+
// slices.map 行号落在本文件行数内骗过纯行数守卫)。标记在 analyzeFile
|
|
1446
|
+
// 入口处打;行数上限保留兜底(防无标记路径)。
|
|
1447
|
+
const maxLine = source.split("\n").length;
|
|
1448
|
+
diagnostics.push(
|
|
1449
|
+
...unknownRecordsToDiagnostics(
|
|
1450
|
+
unknownRecords.filter(
|
|
1451
|
+
(r) => (r.loc?.line ?? 0) <= maxLine && r.originModule !== USAGE_SITE_MODULE,
|
|
1452
|
+
),
|
|
1453
|
+
),
|
|
1454
|
+
);
|
|
1455
|
+
|
|
1456
|
+
const externalFunctions = synthesizeExternalFunctions(callRecords, filePath);
|
|
299
1457
|
|
|
300
1458
|
return {
|
|
301
1459
|
functions: functionResults,
|
|
@@ -303,6 +1461,7 @@ export function analyzeFile(filePath: string, source: string, activeCases?: Map<
|
|
|
303
1461
|
bindings,
|
|
304
1462
|
nodeTypeMap,
|
|
305
1463
|
caseHints,
|
|
1464
|
+
...(externalFunctions.length > 0 ? { externalFunctions } : {}),
|
|
306
1465
|
};
|
|
307
1466
|
}
|
|
308
1467
|
|
|
@@ -387,6 +1546,21 @@ export function getCasesForFile(filePath: string, source: string): { functionNam
|
|
|
387
1546
|
});
|
|
388
1547
|
}
|
|
389
1548
|
|
|
1549
|
+
/** Async entry to getTypeAtPosition with path-env preloading (see analyzeFileAsync). */
|
|
1550
|
+
export async function getTypeAtPositionAsync(
|
|
1551
|
+
filePath: string,
|
|
1552
|
+
source: string,
|
|
1553
|
+
line: number,
|
|
1554
|
+
column: number,
|
|
1555
|
+
activeCases?: Map<string, number>,
|
|
1556
|
+
): Promise<TypeValue | null> {
|
|
1557
|
+
const envNames = collectEnvNames(filePath, source, false);
|
|
1558
|
+
if (envNames.length > 0) {
|
|
1559
|
+
await preloadPathEnvs(envNames, dirname(filePath));
|
|
1560
|
+
}
|
|
1561
|
+
return getTypeAtPosition(filePath, source, line, column, activeCases);
|
|
1562
|
+
}
|
|
1563
|
+
|
|
390
1564
|
export function getTypeAtPosition(
|
|
391
1565
|
filePath: string,
|
|
392
1566
|
source: string,
|
|
@@ -396,10 +1570,23 @@ export function getTypeAtPosition(
|
|
|
396
1570
|
): TypeValue | null {
|
|
397
1571
|
const ast = parse(source);
|
|
398
1572
|
resetMemo();
|
|
1573
|
+
resetEnvModules();
|
|
399
1574
|
setModuleResolver(resolveModule);
|
|
400
1575
|
setCurrentFileDir(dirname(filePath));
|
|
1576
|
+
setCurrentSource(source);
|
|
1577
|
+
|
|
1578
|
+
const fileDirectives = extractFileDirectives(ast);
|
|
1579
|
+
const envNames = fileDirectives
|
|
1580
|
+
.filter((d) => d.kind === "env")
|
|
1581
|
+
.flatMap((d) => d.envs);
|
|
401
1582
|
|
|
402
1583
|
const globalEnv = createEnvironment();
|
|
1584
|
+
|
|
1585
|
+
if (envNames.length > 0) {
|
|
1586
|
+
const loaded = loadEnvs(envNames, globalEnv);
|
|
1587
|
+
setEnvModules(loaded.modules);
|
|
1588
|
+
}
|
|
1589
|
+
|
|
403
1590
|
evaluateProgram(ast, globalEnv);
|
|
404
1591
|
|
|
405
1592
|
const nodeTypeMap = new Map<Node, TypeValue>();
|
|
@@ -426,6 +1613,8 @@ export function getTypeAtPosition(
|
|
|
426
1613
|
}
|
|
427
1614
|
|
|
428
1615
|
setModuleResolver(null);
|
|
1616
|
+
resetEnvModules();
|
|
1617
|
+
resetMockModules();
|
|
429
1618
|
return findBestTypeAtPosition(nodeTypeMap, globalEnv, ast, line, column);
|
|
430
1619
|
}
|
|
431
1620
|
|
|
@@ -648,3 +1837,5 @@ function getCompletionsForType(tv: TypeValue): CompletionItem[] {
|
|
|
648
1837
|
|
|
649
1838
|
return completions;
|
|
650
1839
|
}
|
|
1840
|
+
|
|
1841
|
+
export type { CallRecord } from "@nudojs/cli/evaluator";
|