@nudojs/cli 0.2.1 → 0.3.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/dist/{chunk-3HCPXPEW.js → chunk-YDM4HHQ4.js} +1783 -1421
- package/dist/evaluator-api.d.ts +12 -1
- package/dist/evaluator-api.js +52 -2
- package/dist/index.d.ts +1 -2
- package/dist/index.js +366 -3572
- package/package.json +23 -11
- package/CHANGELOG.md +0 -41
- package/examples/real-world.js +0 -66
- package/examples/validate-real-world.ts +0 -160
- package/src/__tests__/builtin-advanced.test.ts +0 -292
- package/src/__tests__/builtin-api.test.ts +0 -308
- package/src/__tests__/builtin-functions.test.ts +0 -313
- package/src/__tests__/builtin-map.test.ts +0 -129
- package/src/__tests__/builtin-promise.test.ts +0 -150
- package/src/__tests__/builtin-regexp.test.ts +0 -133
- package/src/__tests__/builtin-set.test.ts +0 -163
- package/src/__tests__/builtin-url.test.ts +0 -256
- package/src/__tests__/builtin-web.test.ts +0 -340
- package/src/__tests__/combination-scenarios.test.ts +0 -384
- package/src/__tests__/edge-cases.test.ts +0 -749
- package/src/__tests__/evaluator-loops.test.ts +0 -283
- package/src/__tests__/evaluator-nullish-coalescing.test.ts +0 -86
- package/src/__tests__/evaluator-optional-chaining.test.ts +0 -37
- package/src/__tests__/evaluator-phase2.test.ts +0 -564
- package/src/__tests__/evaluator-phase3.test.ts +0 -864
- package/src/__tests__/evaluator-refined.test.ts +0 -121
- package/src/__tests__/evaluator-string-methods.test.ts +0 -151
- package/src/__tests__/evaluator.test.ts +0 -943
- package/src/__tests__/mock-arrow-function.test.ts +0 -169
- package/src/__tests__/mock-comparison.test.ts +0 -221
- package/src/__tests__/mock-comprehensive.test.ts +0 -250
- package/src/__tests__/mock-final-integration.test.ts +0 -263
- package/src/__tests__/mock-helpers.test.ts +0 -174
- package/src/__tests__/mock-realworld.test.ts +0 -191
- package/src/__tests__/mock-rest-debug.test.ts +0 -110
- package/src/__tests__/mock-rest-edge.test.ts +0 -176
- package/src/__tests__/mock-sinon-runtime.test.ts +0 -168
- package/src/__tests__/narrowing-discriminated.test.ts +0 -35
- package/src/__tests__/narrowing-in-operator.test.ts +0 -56
- package/src/__tests__/narrowing-switch.test.ts +0 -56
- package/src/__tests__/narrowing-truthiness.test.ts +0 -52
- package/src/__tests__/narrowing.test.ts +0 -82
- package/src/__tests__/nudo-mock-syntax.test.ts +0 -245
- package/src/__tests__/realworld-patterns.test.ts +0 -430
- package/src/__tests__/syntax-sugar.test.ts +0 -345
- package/src/builtins/builtin-intl.ts +0 -17
- package/src/builtins/builtin-map.ts +0 -174
- package/src/builtins/builtin-promise.ts +0 -116
- package/src/builtins/builtin-reflect.ts +0 -30
- package/src/builtins/builtin-regexp.ts +0 -34
- package/src/builtins/builtin-set.ts +0 -131
- package/src/builtins/builtin-symbol.ts +0 -16
- package/src/builtins/builtin-url.ts +0 -58
- package/src/builtins/builtin-weak.ts +0 -22
- package/src/builtins/builtin-web.ts +0 -111
- package/src/builtins/index.ts +0 -61
- package/src/config.ts +0 -32
- package/src/env-loader.ts +0 -174
- package/src/evaluator-api.ts +0 -36
- package/src/evaluator.ts +0 -5623
- package/src/index.ts +0 -931
- package/src/narrowing.ts +0 -537
- package/src/resolve-npm.ts +0 -63
- package/tsconfig.json +0 -9
package/src/index.ts
DELETED
|
@@ -1,931 +0,0 @@
|
|
|
1
|
-
import { readFileSync, existsSync, watch, readdirSync, statSync, writeFileSync } from "node:fs";
|
|
2
|
-
import { resolve, dirname, relative, join, basename } from "node:path";
|
|
3
|
-
import { Command } from "commander";
|
|
4
|
-
import {
|
|
5
|
-
T,
|
|
6
|
-
typeValueToString,
|
|
7
|
-
createEnvironment,
|
|
8
|
-
mockHelperToTypeValue,
|
|
9
|
-
} from "@nudojs/core";
|
|
10
|
-
import type { TypeValue } from "@nudojs/core";
|
|
11
|
-
import { parse, extractDirectives, parseTypeValueExpr } from "@nudojs/parser";
|
|
12
|
-
import { evaluateFunctionFull, evaluateProgram, setModuleResolver, setCurrentFileDir, resetMemo } from "./evaluator.ts";
|
|
13
|
-
import {
|
|
14
|
-
typeValueToZodSchema,
|
|
15
|
-
generateGuardFunction,
|
|
16
|
-
generateFunctionDtsLines,
|
|
17
|
-
analyzeFileAsync,
|
|
18
|
-
buildModuleGraph,
|
|
19
|
-
computeDirtySet,
|
|
20
|
-
topoSortDirty,
|
|
21
|
-
collectCallRecords,
|
|
22
|
-
stripGeneratedCaseDirectives,
|
|
23
|
-
insertGeneratedCaseDirectives,
|
|
24
|
-
unifiedDiff,
|
|
25
|
-
type CallRecord,
|
|
26
|
-
type CaseResult,
|
|
27
|
-
type FunctionAnalysis,
|
|
28
|
-
type AnalysisResult,
|
|
29
|
-
type EmitResult,
|
|
30
|
-
} from "@nudojs/service";
|
|
31
|
-
import { harvestDts, emitEnvModule } from "@nudojs/harvester";
|
|
32
|
-
import { resolveNpmNudo } from "./resolve-npm.ts";
|
|
33
|
-
|
|
34
|
-
const program = new Command();
|
|
35
|
-
|
|
36
|
-
program
|
|
37
|
-
.name("nudo")
|
|
38
|
-
.description("Nudo type inference engine")
|
|
39
|
-
.version("0.0.1");
|
|
40
|
-
|
|
41
|
-
function applyMocks(
|
|
42
|
-
directives: ReturnType<typeof extractDirectives>[number]["directives"],
|
|
43
|
-
env: ReturnType<typeof createEnvironment>,
|
|
44
|
-
filePath: string,
|
|
45
|
-
): void {
|
|
46
|
-
for (const d of directives) {
|
|
47
|
-
if (d.kind !== "mock") continue;
|
|
48
|
-
if (d.arrowFn) {
|
|
49
|
-
// Create a function TypeValue from the parsed arrow function
|
|
50
|
-
const fnType = T.fn(d.arrowFn.params, d.arrowFn.body, env);
|
|
51
|
-
(fnType as any)._paramPatterns = d.arrowFn.paramPatterns;
|
|
52
|
-
env.bind(d.name, fnType);
|
|
53
|
-
} else if (d.nudoMock) {
|
|
54
|
-
// Handle Nudo mock helpers (stub, spy, mock)
|
|
55
|
-
const typeVal = mockHelperToTypeValue(d.nudoMock, env);
|
|
56
|
-
env.bind(d.name, typeVal);
|
|
57
|
-
} else if (d.sinonExpr) {
|
|
58
|
-
// Handle sinon expressions
|
|
59
|
-
const sinonType = createSinonTypeValue(d.sinonExpr);
|
|
60
|
-
env.bind(d.name, sinonType);
|
|
61
|
-
} else if (d.expression) {
|
|
62
|
-
env.bind(d.name, parseTypeValueExpr(d.expression));
|
|
63
|
-
} else if (d.fromPath) {
|
|
64
|
-
const mockPath = resolve(dirname(filePath), d.fromPath);
|
|
65
|
-
const mockSource = readFileSync(mockPath, "utf-8");
|
|
66
|
-
const mockAst = parse(mockSource);
|
|
67
|
-
const mockEnv = createEnvironment();
|
|
68
|
-
evaluateProgram(mockAst, mockEnv);
|
|
69
|
-
const mockVal = mockEnv.lookup(d.name);
|
|
70
|
-
env.bind(d.name, mockVal);
|
|
71
|
-
}
|
|
72
|
-
}
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
function createSinonTypeValue(sinonExpr: { type: string; returnValue?: TypeValue; resolvedValue?: TypeValue; rejectedValue?: TypeValue }): TypeValue {
|
|
76
|
-
const body = { type: "BlockStatement", body: [] } as any;
|
|
77
|
-
const fn = T.fn(["...args"], body, createEnvironment());
|
|
78
|
-
|
|
79
|
-
if (sinonExpr.returnValue) {
|
|
80
|
-
(fn as any)._directReturn = sinonExpr.returnValue;
|
|
81
|
-
} else if (sinonExpr.resolvedValue) {
|
|
82
|
-
(fn as any)._directReturn = T.promise(sinonExpr.resolvedValue);
|
|
83
|
-
} else if (sinonExpr.rejectedValue) {
|
|
84
|
-
(fn as any)._directReturn = T.never;
|
|
85
|
-
} else {
|
|
86
|
-
(fn as any)._directReturn = T.unknown;
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
return fn;
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
function resolveModule(source: string, fromDir: string): { ast: ReturnType<typeof parse>; filePath: string; json?: unknown } | null {
|
|
93
|
-
const extensions = [".js", ".ts", ".mjs"];
|
|
94
|
-
|
|
95
|
-
const nudoPath = resolveNpmNudo(source, fromDir);
|
|
96
|
-
if (nudoPath) {
|
|
97
|
-
const src = readFileSync(nudoPath, "utf-8");
|
|
98
|
-
return { ast: parse(src), filePath: nudoPath };
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
const basePath = resolve(fromDir, source);
|
|
102
|
-
|
|
103
|
-
for (const ext of ["", ...extensions]) {
|
|
104
|
-
const candidate = basePath + ext;
|
|
105
|
-
if (existsSync(candidate)) {
|
|
106
|
-
// .json 模块:CJS require('../package.json') 等常见模式——按 JSON 求值
|
|
107
|
-
if (candidate.endsWith(".json")) {
|
|
108
|
-
try {
|
|
109
|
-
return { ast: parse("module.exports = undefined;"), filePath: candidate, json: JSON.parse(readFileSync(candidate, "utf-8")) };
|
|
110
|
-
} catch {
|
|
111
|
-
return null;
|
|
112
|
-
}
|
|
113
|
-
}
|
|
114
|
-
const src = readFileSync(candidate, "utf-8");
|
|
115
|
-
return { ast: parse(src), filePath: candidate };
|
|
116
|
-
}
|
|
117
|
-
}
|
|
118
|
-
return null;
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
/** `--emit-cases` 的编排选项:mode 决定 add/update 两条固化路径 */
|
|
122
|
-
type EmitCasesOptions = { mode: "add" | "update"; dryRun: boolean; exitOnDiff: boolean };
|
|
123
|
-
|
|
124
|
-
/**
|
|
125
|
-
* update 固化路径的编排:剥离旧生成指令(call@ 前缀)→ 以剥离后的源码
|
|
126
|
-
* 重新分析(合成 case 反映当前真实调用形状)→ 重新插入生成指令。
|
|
127
|
-
* infer --emit-cases=update 与 doctor 的 drift 判定共用此链路。
|
|
128
|
-
* 注意是否有变化必须比较 emitOut.source 与原始 source(EmitResult.changed
|
|
129
|
-
* 以 stripped 基准恒真);removed 供指令数统计。
|
|
130
|
-
*/
|
|
131
|
-
async function reemitUpdate(
|
|
132
|
-
filePath: string,
|
|
133
|
-
source: string,
|
|
134
|
-
callsites?: CallRecord[],
|
|
135
|
-
): Promise<{ result: AnalysisResult; emitOut: EmitResult; removed: string[] }> {
|
|
136
|
-
const stripped = stripGeneratedCaseDirectives(source);
|
|
137
|
-
const result = await analyzeFileAsync(filePath, stripped.source, undefined, callsites);
|
|
138
|
-
const emitOut = insertGeneratedCaseDirectives(stripped.source, result);
|
|
139
|
-
return { result, emitOut, removed: stripped.removed };
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
/**
|
|
143
|
-
* --callsites 公共采集:路径可为文件或目录(目录递归收 .js),逐文件
|
|
144
|
-
* collectCallRecords 汇总为外部调用记录。找不到的路径报错并置退出码 1
|
|
145
|
-
* (不中断其余路径);一条都收不到时返回 undefined(与未传等价)。
|
|
146
|
-
*/
|
|
147
|
-
function collectExternalRecords(sites: string[]): CallRecord[] | undefined {
|
|
148
|
-
const records: CallRecord[] = [];
|
|
149
|
-
for (const site of sites) {
|
|
150
|
-
const sitePath = resolve(site);
|
|
151
|
-
if (!existsSync(sitePath)) {
|
|
152
|
-
console.error(`Callsite file not found: ${sitePath}`);
|
|
153
|
-
process.exitCode = 1;
|
|
154
|
-
continue;
|
|
155
|
-
}
|
|
156
|
-
const siteFiles = statSync(sitePath).isDirectory() ? collectNudoFiles(sitePath) : [sitePath];
|
|
157
|
-
for (const sf of siteFiles) {
|
|
158
|
-
records.push(...collectCallRecords(sf, readFileSync(sf, "utf-8")));
|
|
159
|
-
}
|
|
160
|
-
}
|
|
161
|
-
return records.length > 0 ? records : undefined;
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
async function runInfer(
|
|
165
|
-
file: string,
|
|
166
|
-
options: { dts?: boolean; showLoc?: boolean; callsites?: CallRecord[]; emit?: EmitCasesOptions } = {},
|
|
167
|
-
): Promise<void> {
|
|
168
|
-
const filePath = resolve(file);
|
|
169
|
-
const source = readFileSync(filePath, "utf-8");
|
|
170
|
-
let result = await analyzeFileAsync(filePath, source, undefined, options.callsites);
|
|
171
|
-
|
|
172
|
-
// 调用点固化:add 直接把已算好的合成 case 插回源码;update 先剥离旧生成指令,
|
|
173
|
-
// 在剥离后的源码上重算分析再插入,后续打印/摘要都基于重算结果(反映刚固化的形状)
|
|
174
|
-
let emitOut: EmitResult | undefined;
|
|
175
|
-
if (options.emit) {
|
|
176
|
-
if (options.emit.mode === "update") {
|
|
177
|
-
const re = await reemitUpdate(filePath, source, options.callsites);
|
|
178
|
-
result = re.result;
|
|
179
|
-
emitOut = re.emitOut;
|
|
180
|
-
} else {
|
|
181
|
-
emitOut = insertGeneratedCaseDirectives(source, result);
|
|
182
|
-
}
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
if (result.functions.length === 0 && !(result.externalFunctions?.length)) {
|
|
186
|
-
console.log("No functions with @nudo:case directives found.");
|
|
187
|
-
return;
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
const dtsLines: string[] = [];
|
|
191
|
-
|
|
192
|
-
for (const fn of result.functions) {
|
|
193
|
-
const loc = fn.loc;
|
|
194
|
-
const locStr = `${relative(process.cwd(), filePath)}:${loc.start.line}:${loc.start.column}`;
|
|
195
|
-
const header = options.showLoc ? `=== ${fn.name} (${locStr}) ===` : `=== ${fn.name} ===`;
|
|
196
|
-
console.log(`${header}\n`);
|
|
197
|
-
|
|
198
|
-
// .d.ts 与 service 级 generateDts 共用同一实现(单一 widen 主签名 +
|
|
199
|
-
// JSDoc case 说明;真实参数名;noDeclaration 函数排除),两条路径行为一致
|
|
200
|
-
if (options.dts) {
|
|
201
|
-
dtsLines.push(...generateFunctionDtsLines(fn));
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
if (fn.skipped) {
|
|
205
|
-
if (fn.combined) {
|
|
206
|
-
console.log(`Skipped (declared): ${typeValueToString(fn.combined)}`);
|
|
207
|
-
} else {
|
|
208
|
-
console.log("Skipped (no return type declared)");
|
|
209
|
-
}
|
|
210
|
-
console.log();
|
|
211
|
-
continue;
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
for (const c of fn.cases) {
|
|
215
|
-
const argsStr = c.args.map(typeValueToString).join(", ");
|
|
216
|
-
let line = `Case "${c.name}": (${argsStr}) => ${typeValueToString(c.result)}`;
|
|
217
|
-
if (c.throws.kind !== "never") line += ` throws ${typeValueToString(c.throws)}`;
|
|
218
|
-
console.log(line);
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
if (fn.entryOnly) {
|
|
222
|
-
console.log("# no call sites found; parameters default to unknown");
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
if (fn.cases.length > 1 && fn.combined) {
|
|
226
|
-
console.log(`\nCombined: ${typeValueToString(fn.combined)}`);
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
if (fn.assertionErrors && fn.assertionErrors.length > 0) {
|
|
230
|
-
for (const err of fn.assertionErrors) {
|
|
231
|
-
console.log(`\n⚠ ${err}`);
|
|
232
|
-
}
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
console.log();
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
// Imported functions inferred from this file's cross-file call sites.
|
|
239
|
-
// d.ts generation skips them on purpose: they belong to another file's
|
|
240
|
-
// declaration boundary, not this one.
|
|
241
|
-
if (result.externalFunctions && result.externalFunctions.length > 0) {
|
|
242
|
-
const byModule = new Map<string, FunctionAnalysis[]>();
|
|
243
|
-
for (const fn of result.externalFunctions) {
|
|
244
|
-
const mod = fn.fromModule ?? "";
|
|
245
|
-
const list = byModule.get(mod);
|
|
246
|
-
if (list) list.push(fn);
|
|
247
|
-
else byModule.set(mod, [fn]);
|
|
248
|
-
}
|
|
249
|
-
for (const [mod, fns] of byModule) {
|
|
250
|
-
const rel = relative(process.cwd(), mod) || mod;
|
|
251
|
-
console.log(`--- ${rel} (imported) ---\n`);
|
|
252
|
-
for (const fn of fns) {
|
|
253
|
-
console.log(`=== ${fn.name} ===\n`);
|
|
254
|
-
for (const c of fn.cases) {
|
|
255
|
-
const argsStr = c.args.map(typeValueToString).join(", ");
|
|
256
|
-
let line = `Case "${c.name}": (${argsStr}) => ${typeValueToString(c.result)}`;
|
|
257
|
-
if (c.throws.kind !== "never") line += ` throws ${typeValueToString(c.throws)}`;
|
|
258
|
-
console.log(line);
|
|
259
|
-
}
|
|
260
|
-
if (fn.cases.length > 1 && fn.combined) {
|
|
261
|
-
console.log(`\nCombined: ${typeValueToString(fn.combined)}`);
|
|
262
|
-
}
|
|
263
|
-
console.log();
|
|
264
|
-
}
|
|
265
|
-
}
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
if (options.dts && dtsLines.length > 0) {
|
|
269
|
-
const dtsPath = filePath.replace(/\.js$/, ".d.ts");
|
|
270
|
-
const dtsContent = dtsLines.join("\n") + "\n";
|
|
271
|
-
writeFileSync(dtsPath, dtsContent, "utf-8");
|
|
272
|
-
console.log(`Generated: ${relative(process.cwd(), dtsPath)}`);
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
if (result.diagnostics.length > 0) {
|
|
276
|
-
console.log("Diagnostics:\n");
|
|
277
|
-
for (const d of result.diagnostics) {
|
|
278
|
-
const loc = `${relative(process.cwd(), filePath)}:${d.range.start.line}:${d.range.start.column}`;
|
|
279
|
-
console.log(` [${d.severity}] ${loc} ${d.message}${d.code ? ` (${d.code})` : ""}`);
|
|
280
|
-
if (d.origin) {
|
|
281
|
-
console.log(` → value originates at ${d.origin.line}:${d.origin.column}`);
|
|
282
|
-
}
|
|
283
|
-
}
|
|
284
|
-
console.log();
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
// 调用点固化收尾:摘要 / diff / 写盘。是否"有变化"以最终源码与原源码比对为准
|
|
288
|
-
// (update 会先剥离再插回,剥离后重写的相同指令不构成变化)
|
|
289
|
-
if (options.emit && emitOut) {
|
|
290
|
-
const relPath = relative(process.cwd(), filePath) || filePath;
|
|
291
|
-
const skippedLines = emitOut.skipped.map((s) => ` ${s.fn}: ${s.reason}${s.detail ? ` (${s.detail})` : ""}`);
|
|
292
|
-
|
|
293
|
-
if (emitOut.source === source) {
|
|
294
|
-
console.log("No changes.");
|
|
295
|
-
for (const line of skippedLines) console.log(line);
|
|
296
|
-
return;
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
if (options.emit.dryRun) {
|
|
300
|
-
console.log(`Would emit cases → ${relPath} (dry run)`);
|
|
301
|
-
for (const w of emitOut.written) console.log(` ${w.fn}: ${w.cases.join(", ")}`);
|
|
302
|
-
for (const line of skippedLines) console.log(line);
|
|
303
|
-
console.log();
|
|
304
|
-
process.stdout.write(unifiedDiff(source, emitOut.source, relPath));
|
|
305
|
-
if (options.emit.exitOnDiff) process.exitCode = 1;
|
|
306
|
-
return;
|
|
307
|
-
}
|
|
308
|
-
|
|
309
|
-
writeFileSync(filePath, emitOut.source, "utf-8");
|
|
310
|
-
const directiveCount = emitOut.written.reduce((n, w) => n + w.cases.length, 0);
|
|
311
|
-
console.log(
|
|
312
|
-
`Emitted cases → ${relPath} (${directiveCount} directive(s) across ${emitOut.written.length} function(s))`,
|
|
313
|
-
);
|
|
314
|
-
for (const w of emitOut.written) console.log(` ${w.fn}: ${w.cases.join(", ")}`);
|
|
315
|
-
for (const line of skippedLines) console.log(line);
|
|
316
|
-
}
|
|
317
|
-
}
|
|
318
|
-
|
|
319
|
-
async function runInferJson(file: string, externalRecords?: CallRecord[]): Promise<void> {
|
|
320
|
-
const filePath = resolve(file);
|
|
321
|
-
const source = readFileSync(filePath, "utf-8");
|
|
322
|
-
const result = await analyzeFileAsync(filePath, source, undefined, externalRecords);
|
|
323
|
-
|
|
324
|
-
const jsonOutput = {
|
|
325
|
-
functions: result.functions.map((f) => ({
|
|
326
|
-
name: f.name,
|
|
327
|
-
loc: f.loc,
|
|
328
|
-
cases: f.cases.map((c) => ({
|
|
329
|
-
name: c.name,
|
|
330
|
-
args: c.args.map(typeValueToString),
|
|
331
|
-
result: typeValueToString(c.result),
|
|
332
|
-
throws: c.throws.kind !== "never" ? typeValueToString(c.throws) : null,
|
|
333
|
-
source: c.source ?? null,
|
|
334
|
-
})),
|
|
335
|
-
entryOnly: f.entryOnly ?? false,
|
|
336
|
-
assertionErrors: f.assertionErrors,
|
|
337
|
-
})),
|
|
338
|
-
externalFunctions: result.externalFunctions?.map((f) => ({
|
|
339
|
-
name: f.name,
|
|
340
|
-
fromModule: f.fromModule,
|
|
341
|
-
cases: f.cases.map((c) => ({
|
|
342
|
-
name: c.name,
|
|
343
|
-
args: c.args.map(typeValueToString),
|
|
344
|
-
result: typeValueToString(c.result),
|
|
345
|
-
throws: c.throws.kind !== "never" ? typeValueToString(c.throws) : null,
|
|
346
|
-
source: c.source ?? null,
|
|
347
|
-
})),
|
|
348
|
-
})),
|
|
349
|
-
diagnostics: result.diagnostics.map((d) => ({
|
|
350
|
-
range: d.range,
|
|
351
|
-
severity: d.severity,
|
|
352
|
-
message: d.message,
|
|
353
|
-
code: d.code,
|
|
354
|
-
suggestions: d.suggestions,
|
|
355
|
-
tags: d.tags,
|
|
356
|
-
origin: d.origin,
|
|
357
|
-
})),
|
|
358
|
-
};
|
|
359
|
-
|
|
360
|
-
console.log(JSON.stringify(jsonOutput, null, 2));
|
|
361
|
-
}
|
|
362
|
-
|
|
363
|
-
program
|
|
364
|
-
.command("infer")
|
|
365
|
-
.description("Infer types from a JS file — functions with @nudo:case directives use them; all other functions are inferred from call sites (whole-program analysis)")
|
|
366
|
-
.argument("<file>", "Path to the JS file")
|
|
367
|
-
.option("--dts", "Generate .d.ts file")
|
|
368
|
-
.option("--loc", "Show source locations in output")
|
|
369
|
-
.option("--json", "Output as JSON")
|
|
370
|
-
.option("--callsites <paths...>", "Usage-site files (tests/apps) to harvest real call shapes from; their calls to this file's exports become synthesized cases")
|
|
371
|
-
.option("--emit-cases [mode]", "Write synthesized call-site cases back into the source as @nudo:case directives (call@ prefix); mode: update (default: add)")
|
|
372
|
-
.option("--dry-run", "With --emit-cases: print a unified diff instead of writing to disk")
|
|
373
|
-
.option("--exit-on-diff", "With --dry-run: exit with code 1 when the diff is non-empty")
|
|
374
|
-
.action(
|
|
375
|
-
async (
|
|
376
|
-
file: string,
|
|
377
|
-
opts: {
|
|
378
|
-
dts?: boolean;
|
|
379
|
-
loc?: boolean;
|
|
380
|
-
json?: boolean;
|
|
381
|
-
callsites?: string[];
|
|
382
|
-
emitCases?: boolean | string;
|
|
383
|
-
dryRun?: boolean;
|
|
384
|
-
exitOnDiff?: boolean;
|
|
385
|
-
},
|
|
386
|
-
) => {
|
|
387
|
-
// --emit-cases 只允许省略(=add)或 =update 两种形态
|
|
388
|
-
let emit: EmitCasesOptions | undefined;
|
|
389
|
-
if (opts.emitCases !== undefined) {
|
|
390
|
-
let mode: "add" | "update";
|
|
391
|
-
if (opts.emitCases === true) mode = "add";
|
|
392
|
-
else if (opts.emitCases === "update") mode = "update";
|
|
393
|
-
else {
|
|
394
|
-
console.error(`Invalid --emit-cases value: ${opts.emitCases} (expected: =update, or omit the value for add)`);
|
|
395
|
-
process.exitCode = 1;
|
|
396
|
-
return;
|
|
397
|
-
}
|
|
398
|
-
if (opts.json) {
|
|
399
|
-
console.error("--emit-cases cannot be combined with --json");
|
|
400
|
-
process.exitCode = 1;
|
|
401
|
-
return;
|
|
402
|
-
}
|
|
403
|
-
emit = { mode, dryRun: opts.dryRun === true, exitOnDiff: opts.exitOnDiff === true };
|
|
404
|
-
}
|
|
405
|
-
if (opts.exitOnDiff && !opts.dryRun) {
|
|
406
|
-
console.error("--exit-on-diff requires --dry-run");
|
|
407
|
-
process.exitCode = 1;
|
|
408
|
-
return;
|
|
409
|
-
}
|
|
410
|
-
let externalRecords: CallRecord[] | undefined;
|
|
411
|
-
if (opts.callsites?.length) {
|
|
412
|
-
externalRecords = collectExternalRecords(opts.callsites);
|
|
413
|
-
}
|
|
414
|
-
if (opts.json) {
|
|
415
|
-
await runInferJson(file, externalRecords);
|
|
416
|
-
} else {
|
|
417
|
-
await runInfer(file, { dts: opts.dts, showLoc: opts.loc, callsites: externalRecords, emit });
|
|
418
|
-
}
|
|
419
|
-
});
|
|
420
|
-
|
|
421
|
-
async function runCheck(file: string): Promise<void> {
|
|
422
|
-
const filePath = resolve(file);
|
|
423
|
-
const source = readFileSync(filePath, "utf-8");
|
|
424
|
-
const result = await analyzeFileAsync(filePath, source);
|
|
425
|
-
|
|
426
|
-
if (result.diagnostics.length === 0) {
|
|
427
|
-
console.log("No issues found.");
|
|
428
|
-
return;
|
|
429
|
-
}
|
|
430
|
-
|
|
431
|
-
for (const d of result.diagnostics) {
|
|
432
|
-
const loc = `${relative(process.cwd(), filePath)}:${d.range.start.line}:${d.range.start.column}`;
|
|
433
|
-
console.log(`[${d.severity}] ${loc} ${d.message}${d.code ? ` (${d.code})` : ""}`);
|
|
434
|
-
if (d.origin) {
|
|
435
|
-
console.log(` → value originates at ${d.origin.line}:${d.origin.column}`);
|
|
436
|
-
}
|
|
437
|
-
}
|
|
438
|
-
|
|
439
|
-
if (result.diagnostics.some((d) => d.severity === "error")) {
|
|
440
|
-
process.exitCode = 1;
|
|
441
|
-
}
|
|
442
|
-
}
|
|
443
|
-
|
|
444
|
-
program
|
|
445
|
-
.command("check")
|
|
446
|
-
.description("Check a JS file for type errors — exits with code 1 when errors are found")
|
|
447
|
-
.argument("<file>", "Path to the JS file")
|
|
448
|
-
.action(async (file: string) => {
|
|
449
|
-
await runCheck(file);
|
|
450
|
-
});
|
|
451
|
-
|
|
452
|
-
// ---------------------------------------------------------------------------
|
|
453
|
-
// doctor — 项目健康检查:uncovered 函数 / 调用点固化漂移 / 分析报错
|
|
454
|
-
// ---------------------------------------------------------------------------
|
|
455
|
-
|
|
456
|
-
/** doctor 单文件体检结果:uncovered 为信息级,drift/error 决定退出码 */
|
|
457
|
-
type DoctorReport = {
|
|
458
|
-
file: string;
|
|
459
|
-
functions: number;
|
|
460
|
-
entryOnly: number;
|
|
461
|
-
uncovered: string[];
|
|
462
|
-
drift?: { added: number; removed: number };
|
|
463
|
-
error?: string;
|
|
464
|
-
};
|
|
465
|
-
|
|
466
|
-
/** 展示路径:cwd 内取相对路径,cwd 外(如 /tmp fixture)直接用绝对路径,避免 ../ 链 */
|
|
467
|
-
const displayPath = (p: string): string => {
|
|
468
|
-
const rel = relative(process.cwd(), p);
|
|
469
|
-
return rel === "" || rel.startsWith("..") ? p : rel;
|
|
470
|
-
};
|
|
471
|
-
|
|
472
|
-
/**
|
|
473
|
-
* 单文件体检。三项检查:
|
|
474
|
-
* a) uncovered —— 零 case 且非 skipped/entryOnly 的函数(信息级,不影响退出码);
|
|
475
|
-
* b) drift —— 给了调用记录时按 infer --emit-cases=update 的 dry-run 编排
|
|
476
|
-
* (剥离 → 重析 → 重插)重算固化结果,最终源码与原源码不一致即漂移,
|
|
477
|
-
* 指令数按 removed/新增 written 计;
|
|
478
|
-
* c) 报错 —— 读取/分析抛异常即记(含 update dry-run 阶段)。
|
|
479
|
-
*/
|
|
480
|
-
async function doctorFile(filePath: string, records?: CallRecord[]): Promise<DoctorReport> {
|
|
481
|
-
const report: DoctorReport = { file: displayPath(filePath), functions: 0, entryOnly: 0, uncovered: [] };
|
|
482
|
-
let source: string;
|
|
483
|
-
try {
|
|
484
|
-
source = readFileSync(filePath, "utf-8");
|
|
485
|
-
} catch (err) {
|
|
486
|
-
report.error = (err as Error).message;
|
|
487
|
-
return report;
|
|
488
|
-
}
|
|
489
|
-
try {
|
|
490
|
-
const result = await analyzeFileAsync(filePath, source, undefined, records);
|
|
491
|
-
report.functions = result.functions.length;
|
|
492
|
-
report.entryOnly = result.functions.filter((fn) => fn.entryOnly).length;
|
|
493
|
-
report.uncovered = result.functions
|
|
494
|
-
.filter((fn) => fn.cases.length === 0 && !fn.skipped && !fn.entryOnly)
|
|
495
|
-
.map((fn) => fn.name);
|
|
496
|
-
if (records) {
|
|
497
|
-
const { emitOut, removed } = await reemitUpdate(filePath, source, records);
|
|
498
|
-
if (emitOut.source !== source) {
|
|
499
|
-
report.drift = {
|
|
500
|
-
added: emitOut.written.reduce((n, w) => n + w.cases.length, 0),
|
|
501
|
-
removed: removed.length,
|
|
502
|
-
};
|
|
503
|
-
}
|
|
504
|
-
}
|
|
505
|
-
} catch (err) {
|
|
506
|
-
report.error = (err as Error).message;
|
|
507
|
-
}
|
|
508
|
-
return report;
|
|
509
|
-
}
|
|
510
|
-
|
|
511
|
-
async function runDoctor(paths: string[], opts: { callsites?: string[]; json?: boolean }): Promise<void> {
|
|
512
|
-
const targetPaths = paths.length > 0 ? paths : ["."];
|
|
513
|
-
const externalRecords = opts.callsites?.length ? collectExternalRecords(opts.callsites) : undefined;
|
|
514
|
-
|
|
515
|
-
// 目标展开:文件直接体检,目录递归收 .js(与 --callsites 同一规则);
|
|
516
|
-
// 不存在的路径直接记为报错
|
|
517
|
-
const files: string[] = [];
|
|
518
|
-
const reports: DoctorReport[] = [];
|
|
519
|
-
for (const p of targetPaths) {
|
|
520
|
-
const abs = resolve(p);
|
|
521
|
-
if (!existsSync(abs)) {
|
|
522
|
-
reports.push({ file: displayPath(abs), functions: 0, entryOnly: 0, uncovered: [], error: `File not found: ${abs}` });
|
|
523
|
-
continue;
|
|
524
|
-
}
|
|
525
|
-
files.push(...(statSync(abs).isDirectory() ? collectNudoFiles(abs) : [abs]));
|
|
526
|
-
}
|
|
527
|
-
for (const filePath of files) {
|
|
528
|
-
reports.push(await doctorFile(filePath, externalRecords));
|
|
529
|
-
}
|
|
530
|
-
|
|
531
|
-
const driftCount = reports.filter((r) => r.drift).length;
|
|
532
|
-
const errorCount = reports.filter((r) => r.error).length;
|
|
533
|
-
const uncoveredTotal = reports.reduce((n, r) => n + r.uncovered.length, 0);
|
|
534
|
-
const failed = driftCount > 0 || errorCount > 0;
|
|
535
|
-
|
|
536
|
-
if (opts.json) {
|
|
537
|
-
console.log(
|
|
538
|
-
JSON.stringify(
|
|
539
|
-
{
|
|
540
|
-
// ok 与退出码一致:drift/报错 → false;uncovered 仅为信息级不影响
|
|
541
|
-
ok: !failed,
|
|
542
|
-
files: reports.map((r) => ({
|
|
543
|
-
file: r.file,
|
|
544
|
-
functions: r.functions,
|
|
545
|
-
entryOnly: r.entryOnly,
|
|
546
|
-
uncovered: r.uncovered,
|
|
547
|
-
...(r.drift ? { drift: r.drift } : {}),
|
|
548
|
-
...(r.error ? { error: r.error } : {}),
|
|
549
|
-
})),
|
|
550
|
-
summary: { files: reports.length, drift: driftCount, errors: errorCount, uncovered: uncoveredTotal },
|
|
551
|
-
},
|
|
552
|
-
null,
|
|
553
|
-
2,
|
|
554
|
-
),
|
|
555
|
-
);
|
|
556
|
-
} else {
|
|
557
|
-
if (reports.length === 0) {
|
|
558
|
-
console.log("No files to check.");
|
|
559
|
-
return;
|
|
560
|
-
}
|
|
561
|
-
for (const r of reports) {
|
|
562
|
-
console.log(`${r.file}`);
|
|
563
|
-
if (r.error) {
|
|
564
|
-
console.log(` ✗ error: ${r.error}`);
|
|
565
|
-
continue;
|
|
566
|
-
}
|
|
567
|
-
const entryInfo = r.entryOnly > 0 ? `, ${r.entryOnly} entry-only` : "";
|
|
568
|
-
console.log(` · ${r.functions} function(s)${entryInfo}`);
|
|
569
|
-
if (r.uncovered.length > 0) {
|
|
570
|
-
console.log(` ⚠ uncovered (no cases): ${r.uncovered.join(", ")}`);
|
|
571
|
-
}
|
|
572
|
-
if (r.drift) {
|
|
573
|
-
const refresh = `nudo infer ${r.file} --callsites ${(opts.callsites ?? []).join(" ")} --emit-cases=update`;
|
|
574
|
-
console.log(
|
|
575
|
-
` ✗ drift: ${r.drift.added + r.drift.removed} directive(s) changed (+${r.drift.added} new, -${r.drift.removed} removed) — refresh with: ${refresh.trim()}`,
|
|
576
|
-
);
|
|
577
|
-
}
|
|
578
|
-
}
|
|
579
|
-
console.log(
|
|
580
|
-
`\nSummary: ${reports.length} file(s) · ${driftCount} drift · ${errorCount} error(s) · ${uncoveredTotal} uncovered function(s)`,
|
|
581
|
-
);
|
|
582
|
-
console.log(failed ? "Result: FAIL (drift or errors found)" : "Result: OK (uncovered function(s) are informational only)");
|
|
583
|
-
}
|
|
584
|
-
|
|
585
|
-
if (failed) process.exitCode = 1;
|
|
586
|
-
}
|
|
587
|
-
|
|
588
|
-
program
|
|
589
|
-
.command("doctor")
|
|
590
|
-
.description("Health-check JS files: functions without cases, call-site solidification drift (--callsites), analysis errors — exits 1 on drift/errors")
|
|
591
|
-
.argument("[paths...]", "File(s) or directory(s) to check (default: current directory)")
|
|
592
|
-
.option("--callsites <paths...>", "Usage-site files (tests/apps): re-solidify per current call shapes and report drift when directives would change")
|
|
593
|
-
.option("--json", "Output as JSON")
|
|
594
|
-
.action(async (paths: string[], opts: { callsites?: string[]; json?: boolean }) => {
|
|
595
|
-
await runDoctor(paths, opts);
|
|
596
|
-
});
|
|
597
|
-
|
|
598
|
-
program
|
|
599
|
-
.command("watch")
|
|
600
|
-
.description("Watch file(s) for changes and re-run inference")
|
|
601
|
-
.argument("<path>", "File or directory to watch")
|
|
602
|
-
.option("--dts", "Generate .d.ts files on each change")
|
|
603
|
-
.action((watchPath: string, opts: { dts?: boolean }) => {
|
|
604
|
-
const resolved = resolve(watchPath);
|
|
605
|
-
const isDir = existsSync(resolved) && statSync(resolved).isDirectory();
|
|
606
|
-
|
|
607
|
-
const getFiles = (): string[] => {
|
|
608
|
-
if (!isDir) return [resolved];
|
|
609
|
-
return collectNudoFiles(resolved);
|
|
610
|
-
};
|
|
611
|
-
|
|
612
|
-
let graph = buildModuleGraph(getFiles());
|
|
613
|
-
|
|
614
|
-
const runAll = async () => {
|
|
615
|
-
console.clear();
|
|
616
|
-
console.log(`[${new Date().toLocaleTimeString()}] Analyzing...\n`);
|
|
617
|
-
for (const f of getFiles()) {
|
|
618
|
-
try {
|
|
619
|
-
await runInfer(f, { dts: opts.dts, showLoc: true });
|
|
620
|
-
} catch (err) {
|
|
621
|
-
console.error(`Error analyzing ${relative(process.cwd(), f)}:`, (err as Error).message);
|
|
622
|
-
}
|
|
623
|
-
}
|
|
624
|
-
graph = buildModuleGraph(getFiles());
|
|
625
|
-
console.log(`[${new Date().toLocaleTimeString()}] Watching for changes...`);
|
|
626
|
-
};
|
|
627
|
-
|
|
628
|
-
const runIncremental = async (changedFiles: string[]) => {
|
|
629
|
-
const files = getFiles();
|
|
630
|
-
const tracked = new Set(files);
|
|
631
|
-
|
|
632
|
-
// 合并多文件变更的脏集(union):每个变更文件的脏集 = 自身 + 传递依赖方
|
|
633
|
-
const dirtyUnion = new Set<string>();
|
|
634
|
-
for (const cf of changedFiles) {
|
|
635
|
-
for (const d of computeDirtySet(graph.dependents, cf)) dirtyUnion.add(d);
|
|
636
|
-
}
|
|
637
|
-
const dirty = [...dirtyUnion].filter((f) => tracked.has(f));
|
|
638
|
-
if (dirty.length === 0) return;
|
|
639
|
-
|
|
640
|
-
// 依赖先析:moduleCache 中未重析依赖的类型可被复用
|
|
641
|
-
const ordered = topoSortDirty(graph.imports, dirty);
|
|
642
|
-
|
|
643
|
-
console.clear();
|
|
644
|
-
console.log(`[${new Date().toLocaleTimeString()}] Analyzing (incremental)...\n`);
|
|
645
|
-
const t0 = performance.now();
|
|
646
|
-
// 缓存失效:callMemo 按名键陈旧 + moduleCache 嵌入旧类型
|
|
647
|
-
resetMemo();
|
|
648
|
-
for (const f of ordered) {
|
|
649
|
-
try {
|
|
650
|
-
await runInfer(f, { dts: opts.dts, showLoc: true });
|
|
651
|
-
} catch (err) {
|
|
652
|
-
console.error(`Error analyzing ${relative(process.cwd(), f)}:`, (err as Error).message);
|
|
653
|
-
}
|
|
654
|
-
}
|
|
655
|
-
const elapsed = Math.round(performance.now() - t0);
|
|
656
|
-
console.log(`Incremental: re-analyzed ${ordered.length}, skipped ${files.length - ordered.length} (${elapsed}ms)`);
|
|
657
|
-
console.log(`[${new Date().toLocaleTimeString()}] Watching for changes...`);
|
|
658
|
-
|
|
659
|
-
// 重建全图:文件数少时开销可忽略,选简单路线
|
|
660
|
-
graph = buildModuleGraph(getFiles());
|
|
661
|
-
};
|
|
662
|
-
|
|
663
|
-
runAll().catch(() => { /* per-file errors already reported */ });
|
|
664
|
-
|
|
665
|
-
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
|
666
|
-
const watchTarget = isDir ? resolved : dirname(resolved);
|
|
667
|
-
|
|
668
|
-
const pendingChanged = new Set<string>();
|
|
669
|
-
|
|
670
|
-
watch(watchTarget, { recursive: isDir }, (_event, filename) => {
|
|
671
|
-
if (!filename) return;
|
|
672
|
-
const fullPath = isDir ? join(watchTarget, filename) : resolved;
|
|
673
|
-
if (!fullPath.endsWith(".js")) return;
|
|
674
|
-
if (!existsSync(fullPath)) return; // deleted
|
|
675
|
-
|
|
676
|
-
pendingChanged.add(fullPath);
|
|
677
|
-
|
|
678
|
-
if (debounceTimer) clearTimeout(debounceTimer);
|
|
679
|
-
debounceTimer = setTimeout(() => {
|
|
680
|
-
const changedFiles = [...pendingChanged];
|
|
681
|
-
pendingChanged.clear();
|
|
682
|
-
runIncremental(changedFiles).catch(() => { /* per-file errors already reported */ });
|
|
683
|
-
}, 200);
|
|
684
|
-
});
|
|
685
|
-
});
|
|
686
|
-
|
|
687
|
-
function collectNudoFiles(dir: string): string[] {
|
|
688
|
-
const results: string[] = [];
|
|
689
|
-
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
690
|
-
const fullPath = join(dir, entry.name);
|
|
691
|
-
if (entry.isDirectory() && entry.name !== "node_modules") {
|
|
692
|
-
results.push(...collectNudoFiles(fullPath));
|
|
693
|
-
} else if (entry.isFile() && entry.name.endsWith(".js")) {
|
|
694
|
-
// 全程序推断:无指令的纯 JS 文件也能推导类型,无需 @nudo: 过滤
|
|
695
|
-
results.push(fullPath);
|
|
696
|
-
}
|
|
697
|
-
}
|
|
698
|
-
return results;
|
|
699
|
-
}
|
|
700
|
-
|
|
701
|
-
// ---------------------------------------------------------------------------
|
|
702
|
-
// generate 命令的 FunctionAnalysis 组装件:dts 分支复用 service 的
|
|
703
|
-
// generateFunctionDtsLines(与 infer --dts / service generateDts 同一实现),
|
|
704
|
-
// loc 与参数名提取与 analyzer 的 locFromNode/extractParamNames 保持同构,
|
|
705
|
-
// 保证两条路径对同一文件输出一致
|
|
706
|
-
// ---------------------------------------------------------------------------
|
|
707
|
-
|
|
708
|
-
type DirectiveFnNode = ReturnType<typeof extractDirectives>[number]["node"];
|
|
709
|
-
|
|
710
|
-
function nodeLoc(node: DirectiveFnNode): FunctionAnalysis["loc"] {
|
|
711
|
-
return {
|
|
712
|
-
start: { line: node.loc?.start.line ?? 1, column: node.loc?.start.column ?? 0 },
|
|
713
|
-
end: { line: node.loc?.end.line ?? 1, column: node.loc?.end.column ?? 0 },
|
|
714
|
-
};
|
|
715
|
-
}
|
|
716
|
-
|
|
717
|
-
function fnParamNames(node: DirectiveFnNode): string[] {
|
|
718
|
-
const fn = node.type === "ExportDefaultDeclaration" ? node.declaration : node;
|
|
719
|
-
const paramListOf = (params: readonly any[]): string[] =>
|
|
720
|
-
params.map((p: any) => {
|
|
721
|
-
if (p.type === "Identifier") return p.name;
|
|
722
|
-
if (p.type === "AssignmentPattern" && p.left.type === "Identifier") return p.left.name;
|
|
723
|
-
if (p.type === "RestElement" && p.argument.type === "Identifier") return `...${p.argument.name}`;
|
|
724
|
-
return "_";
|
|
725
|
-
});
|
|
726
|
-
if (fn.type === "FunctionDeclaration" || fn.type === "FunctionExpression" || fn.type === "ArrowFunctionExpression") {
|
|
727
|
-
return paramListOf(fn.params);
|
|
728
|
-
}
|
|
729
|
-
if (fn.type === "VariableDeclaration") {
|
|
730
|
-
const init = fn.declarations[0].init;
|
|
731
|
-
if (init?.type === "FunctionExpression" || init?.type === "ArrowFunctionExpression") {
|
|
732
|
-
return paramListOf(init.params);
|
|
733
|
-
}
|
|
734
|
-
}
|
|
735
|
-
return [];
|
|
736
|
-
}
|
|
737
|
-
|
|
738
|
-
program
|
|
739
|
-
.command("generate")
|
|
740
|
-
.description("Generate runtime validators from inferred types")
|
|
741
|
-
.argument("<file>", "JavaScript file to analyze")
|
|
742
|
-
.option("--format <format>", "Output format: zod, guard, dts, all", "all")
|
|
743
|
-
.option("--output <dir>", "Output directory", ".")
|
|
744
|
-
.action((file: string, options: { format: string; output: string }) => {
|
|
745
|
-
const filePath = resolve(file);
|
|
746
|
-
const source = readFileSync(filePath, "utf-8");
|
|
747
|
-
const ast = parse(source);
|
|
748
|
-
const functions = extractDirectives(ast);
|
|
749
|
-
|
|
750
|
-
if (functions.length === 0) {
|
|
751
|
-
console.log("No functions with @nudo:case directives found.");
|
|
752
|
-
return;
|
|
753
|
-
}
|
|
754
|
-
|
|
755
|
-
resetMemo();
|
|
756
|
-
setModuleResolver(resolveModule);
|
|
757
|
-
setCurrentFileDir(dirname(filePath));
|
|
758
|
-
|
|
759
|
-
const globalEnv = createEnvironment();
|
|
760
|
-
evaluateProgram(ast, globalEnv);
|
|
761
|
-
|
|
762
|
-
for (const fn of functions) {
|
|
763
|
-
applyMocks(fn.directives, globalEnv, filePath);
|
|
764
|
-
|
|
765
|
-
const caseDirectives = fn.directives.filter((d) => d.kind === "case");
|
|
766
|
-
if (caseDirectives.length === 0) continue;
|
|
767
|
-
|
|
768
|
-
const caseResults: CaseResult[] = caseDirectives.map((directive) => {
|
|
769
|
-
const fullResult = evaluateFunctionFull(fn.node, directive.args, globalEnv);
|
|
770
|
-
return {
|
|
771
|
-
name: directive.name,
|
|
772
|
-
args: directive.args,
|
|
773
|
-
result: fullResult.value,
|
|
774
|
-
throws: fullResult.throws,
|
|
775
|
-
source: "directive",
|
|
776
|
-
};
|
|
777
|
-
});
|
|
778
|
-
|
|
779
|
-
const baseName = fn.name;
|
|
780
|
-
|
|
781
|
-
if (options.format === "zod" || options.format === "all") {
|
|
782
|
-
console.log(`\n// === ${baseName} Zod Schemas ===`);
|
|
783
|
-
for (const c of caseResults) {
|
|
784
|
-
const inputSchemas = c.args.map((a, i) => `arg${i}: ${typeValueToZodSchema(a)}`).join(", ");
|
|
785
|
-
const outputSchema = typeValueToZodSchema(c.result);
|
|
786
|
-
console.log(`// Case "${c.name}":`);
|
|
787
|
-
console.log(`// Input: { ${inputSchemas} }`);
|
|
788
|
-
console.log(`// Output: ${outputSchema}`);
|
|
789
|
-
}
|
|
790
|
-
}
|
|
791
|
-
|
|
792
|
-
if (options.format === "guard" || options.format === "all") {
|
|
793
|
-
console.log(`\n// === ${baseName} Type Guards ===`);
|
|
794
|
-
for (const c of caseResults) {
|
|
795
|
-
const guard = generateGuardFunction(`is${baseName}${c.name.charAt(0).toUpperCase() + c.name.slice(1)}Output`, c.result);
|
|
796
|
-
console.log(guard);
|
|
797
|
-
}
|
|
798
|
-
}
|
|
799
|
-
|
|
800
|
-
if (options.format === "dts" || options.format === "all") {
|
|
801
|
-
console.log(`\n// === ${baseName} TypeScript Declarations ===`);
|
|
802
|
-
// 与 infer --dts / service 级 generateDts 共用 generateFunctionDtsLines:
|
|
803
|
-
// 单一 widen 主签名 + JSDoc 保留 case 精度 + 真实参数名(取自解析产物)。
|
|
804
|
-
// 旧的逐 case `argN: 字面量` 签名会拦截合法调用(tsc TS2769)
|
|
805
|
-
const analysis: FunctionAnalysis = {
|
|
806
|
-
name: baseName,
|
|
807
|
-
loc: nodeLoc(fn.node),
|
|
808
|
-
paramNames: fnParamNames(fn.node),
|
|
809
|
-
cases: caseResults,
|
|
810
|
-
};
|
|
811
|
-
for (const line of generateFunctionDtsLines(analysis)) {
|
|
812
|
-
console.log(line);
|
|
813
|
-
}
|
|
814
|
-
}
|
|
815
|
-
}
|
|
816
|
-
|
|
817
|
-
setModuleResolver(null);
|
|
818
|
-
});
|
|
819
|
-
|
|
820
|
-
// ---------------------------------------------------------------------------
|
|
821
|
-
// harvest — convert @types/<pkg> .d.ts into a Nudo env module
|
|
822
|
-
// ---------------------------------------------------------------------------
|
|
823
|
-
|
|
824
|
-
const HARVEST_MAX_FILES = 200;
|
|
825
|
-
|
|
826
|
-
const REFERENCE_PATH_REGEX = /<reference\s+path=["']([^"']+)["']\s*\/>/g;
|
|
827
|
-
const RELATIVE_FROM_REGEX = /\bfrom\s+["'](\.[^"']+)["']/g;
|
|
828
|
-
|
|
829
|
-
function collectDtsFiles(entry: string): string[] {
|
|
830
|
-
const files: string[] = [];
|
|
831
|
-
const seen = new Set<string>();
|
|
832
|
-
const queue: string[] = [entry];
|
|
833
|
-
|
|
834
|
-
while (queue.length > 0 && files.length < HARVEST_MAX_FILES) {
|
|
835
|
-
const current = queue.shift()!;
|
|
836
|
-
if (seen.has(current) || !existsSync(current)) continue;
|
|
837
|
-
seen.add(current);
|
|
838
|
-
if (!current.endsWith(".d.ts")) continue;
|
|
839
|
-
files.push(current);
|
|
840
|
-
|
|
841
|
-
let text: string;
|
|
842
|
-
try {
|
|
843
|
-
text = readFileSync(current, "utf-8");
|
|
844
|
-
} catch {
|
|
845
|
-
continue;
|
|
846
|
-
}
|
|
847
|
-
|
|
848
|
-
const dir = dirname(current);
|
|
849
|
-
for (const match of text.matchAll(REFERENCE_PATH_REGEX)) {
|
|
850
|
-
queue.push(resolve(dir, match[1]));
|
|
851
|
-
}
|
|
852
|
-
for (const match of text.matchAll(RELATIVE_FROM_REGEX)) {
|
|
853
|
-
const base = resolve(dir, match[1]);
|
|
854
|
-
for (const candidate of [`${base}.d.ts`, join(base, "index.d.ts")]) {
|
|
855
|
-
if (existsSync(candidate)) {
|
|
856
|
-
queue.push(candidate);
|
|
857
|
-
break;
|
|
858
|
-
}
|
|
859
|
-
}
|
|
860
|
-
}
|
|
861
|
-
}
|
|
862
|
-
|
|
863
|
-
return files;
|
|
864
|
-
}
|
|
865
|
-
|
|
866
|
-
function runHarvest(pkg: string, outOpt?: string): void {
|
|
867
|
-
const typesDir = resolve(process.cwd(), "node_modules", "@types", pkg);
|
|
868
|
-
if (!existsSync(typesDir)) {
|
|
869
|
-
console.error(`Error: ${relative(process.cwd(), typesDir)} not found.`);
|
|
870
|
-
console.error(`Install the package first, e.g. pnpm add -D @types/${pkg}`);
|
|
871
|
-
process.exitCode = 1;
|
|
872
|
-
return;
|
|
873
|
-
}
|
|
874
|
-
|
|
875
|
-
let entry = join(typesDir, "index.d.ts");
|
|
876
|
-
const pkgJsonPath = join(typesDir, "package.json");
|
|
877
|
-
if (existsSync(pkgJsonPath)) {
|
|
878
|
-
try {
|
|
879
|
-
const pkgJson = JSON.parse(readFileSync(pkgJsonPath, "utf-8")) as { types?: string; typings?: string };
|
|
880
|
-
const declared = pkgJson.types ?? pkgJson.typings;
|
|
881
|
-
if (typeof declared === "string") {
|
|
882
|
-
const candidate = resolve(typesDir, declared);
|
|
883
|
-
if (existsSync(candidate)) entry = candidate;
|
|
884
|
-
}
|
|
885
|
-
} catch {
|
|
886
|
-
// malformed package.json — fall back to index.d.ts
|
|
887
|
-
}
|
|
888
|
-
}
|
|
889
|
-
if (!existsSync(entry)) {
|
|
890
|
-
console.error(`Error: no .d.ts entry found in ${relative(process.cwd(), typesDir)} (tried ${basename(entry)}).`);
|
|
891
|
-
process.exitCode = 1;
|
|
892
|
-
return;
|
|
893
|
-
}
|
|
894
|
-
|
|
895
|
-
const files = collectDtsFiles(entry);
|
|
896
|
-
if (files.length === 0) {
|
|
897
|
-
console.error(`Error: no .d.ts files collected from ${relative(process.cwd(), entry)}.`);
|
|
898
|
-
process.exitCode = 1;
|
|
899
|
-
return;
|
|
900
|
-
}
|
|
901
|
-
|
|
902
|
-
const env = harvestDts(files);
|
|
903
|
-
const code = emitEnvModule(env, `@types/${pkg}`);
|
|
904
|
-
|
|
905
|
-
const out = resolve(outOpt ?? `nudo-harvest-${pkg}.ts`);
|
|
906
|
-
writeFileSync(out, code, "utf-8");
|
|
907
|
-
|
|
908
|
-
console.log(`Harvested @types/${pkg} → ${relative(process.cwd(), out) || out}`);
|
|
909
|
-
console.log(` files: ${env.stats.files}`);
|
|
910
|
-
console.log(` symbols: ${env.stats.symbols}`);
|
|
911
|
-
console.log(` skipped: ${env.stats.skipped}`);
|
|
912
|
-
console.log(`\nUsage — add this directive at the top of your JS file:`);
|
|
913
|
-
const outDir = dirname(out);
|
|
914
|
-
const hintPath =
|
|
915
|
-
resolve(process.cwd()) === outDir ? basename(out) : out;
|
|
916
|
-
console.log(` /// @nudo:env ${hintPath}`);
|
|
917
|
-
}
|
|
918
|
-
|
|
919
|
-
program
|
|
920
|
-
.command("harvest")
|
|
921
|
-
.description("Convert @types/<pkg> .d.ts declarations into a Nudo env file (TS source using T.* constructors)")
|
|
922
|
-
.argument("<pkg>", "Package name under @types (e.g. node)")
|
|
923
|
-
.option("--out <file>", "Output .ts env file (default: ./nudo-harvest-<pkg>.ts)")
|
|
924
|
-
.action((pkg: string, opts: { out?: string }) => {
|
|
925
|
-
runHarvest(pkg, opts.out);
|
|
926
|
-
});
|
|
927
|
-
|
|
928
|
-
program.parseAsync(process.argv).catch((err: unknown) => {
|
|
929
|
-
console.error(err instanceof Error ? (process.env.NUDO_DEBUG ? err.stack : err.message) : err);
|
|
930
|
-
process.exitCode = 1;
|
|
931
|
-
});
|