@nudojs/cli 0.2.1 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +12 -7
- package/src/abs-route.ts +273 -0
- package/src/builtins/builtin-prototype.ts +279 -0
- package/src/builtins/builtin-static.ts +260 -0
- package/src/eval-binary.ts +113 -0
- package/src/evaluator-api.ts +3 -0
- package/src/evaluator.ts +174 -633
- package/src/index.ts +373 -270
- package/src/narrowing.ts +7 -0
- package/src/phi-from-test.ts +42 -0
- package/src/run-test.ts +92 -0
- package/src/term-registry.ts +96 -0
- package/CHANGELOG.md +0 -41
- package/dist/chunk-3HCPXPEW.js +0 -5286
- package/dist/evaluator-api.d.ts +0 -151
- package/dist/evaluator-api.js +0 -214
- package/dist/index.d.ts +0 -2
- package/dist/index.js +0 -4035
- 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/index.ts +0 -61
- package/tsconfig.json +0 -9
package/src/index.ts
CHANGED
|
@@ -1,18 +1,13 @@
|
|
|
1
|
-
import { readFileSync, existsSync, watch, readdirSync, statSync, writeFileSync } from "node:fs";
|
|
1
|
+
import { readFileSync, existsSync, watch, readdirSync, statSync, writeFileSync, mkdirSync } from "node:fs";
|
|
2
2
|
import { resolve, dirname, relative, join, basename } from "node:path";
|
|
3
3
|
import { Command } from "commander";
|
|
4
|
-
import {
|
|
5
|
-
|
|
6
|
-
|
|
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";
|
|
4
|
+
import { typeValueToString } from "@nudojs/core";
|
|
5
|
+
import { extractDirectives } from "@nudojs/parser";
|
|
6
|
+
import { resetMemo } from "./evaluator.ts";
|
|
13
7
|
import {
|
|
14
8
|
typeValueToZodSchema,
|
|
15
9
|
generateGuardFunction,
|
|
10
|
+
generateGuardFunctionFromAbs,
|
|
16
11
|
generateFunctionDtsLines,
|
|
17
12
|
analyzeFileAsync,
|
|
18
13
|
buildModuleGraph,
|
|
@@ -22,6 +17,9 @@ import {
|
|
|
22
17
|
stripGeneratedCaseDirectives,
|
|
23
18
|
insertGeneratedCaseDirectives,
|
|
24
19
|
unifiedDiff,
|
|
20
|
+
isNudoTargetPath,
|
|
21
|
+
collectDtsFromEntry,
|
|
22
|
+
evictAnalysisCachesForFiles,
|
|
25
23
|
type CallRecord,
|
|
26
24
|
type CaseResult,
|
|
27
25
|
type FunctionAnalysis,
|
|
@@ -29,7 +27,7 @@ import {
|
|
|
29
27
|
type EmitResult,
|
|
30
28
|
} from "@nudojs/service";
|
|
31
29
|
import { harvestDts, emitEnvModule } from "@nudojs/harvester";
|
|
32
|
-
import {
|
|
30
|
+
import { buildTestReport, formatTestReport } from "./run-test.ts";
|
|
33
31
|
|
|
34
32
|
const program = new Command();
|
|
35
33
|
|
|
@@ -38,86 +36,6 @@ program
|
|
|
38
36
|
.description("Nudo type inference engine")
|
|
39
37
|
.version("0.0.1");
|
|
40
38
|
|
|
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
39
|
/** `--emit-cases` 的编排选项:mode 决定 add/update 两条固化路径 */
|
|
122
40
|
type EmitCasesOptions = { mode: "add" | "update"; dryRun: boolean; exitOnDiff: boolean };
|
|
123
41
|
|
|
@@ -216,6 +134,19 @@ async function runInfer(
|
|
|
216
134
|
let line = `Case "${c.name}": (${argsStr}) => ${typeValueToString(c.result)}`;
|
|
217
135
|
if (c.throws.kind !== "never") line += ` throws ${typeValueToString(c.throws)}`;
|
|
218
136
|
console.log(line);
|
|
137
|
+
// M3:内涵摘要(term/pred/conf)+ 无损 Abs
|
|
138
|
+
if (c.intension?.display) {
|
|
139
|
+
console.log(` intension: ${c.intension.display}`);
|
|
140
|
+
} else if (c.intension?.term || c.intension?.pred) {
|
|
141
|
+
const parts: string[] = [];
|
|
142
|
+
if (c.intension.term) parts.push(`term=${c.intension.term}`);
|
|
143
|
+
if (c.intension.pred) parts.push(`pred: ${c.intension.pred}`);
|
|
144
|
+
if (c.intension.conf) parts.push(`#${c.intension.conf}`);
|
|
145
|
+
console.log(` ${parts.join(" ")}`);
|
|
146
|
+
}
|
|
147
|
+
if (c.intension?.abs) {
|
|
148
|
+
console.log(` abs: ${c.intension.abs}`);
|
|
149
|
+
}
|
|
219
150
|
}
|
|
220
151
|
|
|
221
152
|
if (fn.entryOnly) {
|
|
@@ -226,12 +157,6 @@ async function runInfer(
|
|
|
226
157
|
console.log(`\nCombined: ${typeValueToString(fn.combined)}`);
|
|
227
158
|
}
|
|
228
159
|
|
|
229
|
-
if (fn.assertionErrors && fn.assertionErrors.length > 0) {
|
|
230
|
-
for (const err of fn.assertionErrors) {
|
|
231
|
-
console.log(`\n⚠ ${err}`);
|
|
232
|
-
}
|
|
233
|
-
}
|
|
234
|
-
|
|
235
160
|
console.log();
|
|
236
161
|
}
|
|
237
162
|
|
|
@@ -266,7 +191,7 @@ async function runInfer(
|
|
|
266
191
|
}
|
|
267
192
|
|
|
268
193
|
if (options.dts && dtsLines.length > 0) {
|
|
269
|
-
const dtsPath = filePath.replace(/\.js$/, ".d.ts");
|
|
194
|
+
const dtsPath = filePath.replace(/\.[cm]?js$|\.ts$/, ".d.ts");
|
|
270
195
|
const dtsContent = dtsLines.join("\n") + "\n";
|
|
271
196
|
writeFileSync(dtsPath, dtsContent, "utf-8");
|
|
272
197
|
console.log(`Generated: ${relative(process.cwd(), dtsPath)}`);
|
|
@@ -320,50 +245,14 @@ async function runInferJson(file: string, externalRecords?: CallRecord[]): Promi
|
|
|
320
245
|
const filePath = resolve(file);
|
|
321
246
|
const source = readFileSync(filePath, "utf-8");
|
|
322
247
|
const result = await analyzeFileAsync(filePath, source, undefined, externalRecords);
|
|
323
|
-
|
|
324
|
-
|
|
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));
|
|
248
|
+
const { serializeInferJson } = await import("@nudojs/service");
|
|
249
|
+
console.log(JSON.stringify(serializeInferJson(result, filePath), null, 2));
|
|
361
250
|
}
|
|
362
251
|
|
|
363
252
|
program
|
|
364
253
|
.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")
|
|
254
|
+
.description("Infer types from a JS/TS file (or a directory of them) — functions with @nudo:case directives use them; all other functions are inferred from call sites (whole-program analysis)")
|
|
255
|
+
.argument("<file>", "Path to the JS/TS file (or directory)")
|
|
367
256
|
.option("--dts", "Generate .d.ts file")
|
|
368
257
|
.option("--loc", "Show source locations in output")
|
|
369
258
|
.option("--json", "Output as JSON")
|
|
@@ -411,6 +300,29 @@ program
|
|
|
411
300
|
if (opts.callsites?.length) {
|
|
412
301
|
externalRecords = collectExternalRecords(opts.callsites);
|
|
413
302
|
}
|
|
303
|
+
// 目录模式:与 watch/doctor 的 collectNudoFiles 同一收集规则
|
|
304
|
+
// (.js/.mjs/.ts,排除 .d.ts/.tsx)
|
|
305
|
+
const target = resolve(file);
|
|
306
|
+
if (existsSync(target) && statSync(target).isDirectory()) {
|
|
307
|
+
const files = collectNudoFiles(target);
|
|
308
|
+
if (files.length === 0) {
|
|
309
|
+
console.log("No nudo files found in directory.");
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
if (opts.json) {
|
|
313
|
+
console.error("--json requires a single file, not a directory");
|
|
314
|
+
process.exitCode = 1;
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
for (const f of files) {
|
|
318
|
+
try {
|
|
319
|
+
await runInfer(f, { dts: opts.dts, showLoc: opts.loc, callsites: externalRecords, emit });
|
|
320
|
+
} catch (err) {
|
|
321
|
+
console.error(`Error analyzing ${relative(process.cwd(), f)}:`, (err as Error).message);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
414
326
|
if (opts.json) {
|
|
415
327
|
await runInferJson(file, externalRecords);
|
|
416
328
|
} else {
|
|
@@ -418,35 +330,173 @@ program
|
|
|
418
330
|
}
|
|
419
331
|
});
|
|
420
332
|
|
|
421
|
-
|
|
333
|
+
/**
|
|
334
|
+
* 严格 Abs-only:只跑 checkSource 代数门禁。
|
|
335
|
+
* 不再叠加 analyzeFileAsync 外延诊断——语言表面问题由 Abs/B-path 诊断吸收,
|
|
336
|
+
* 避免双路径语义分叉。
|
|
337
|
+
*/
|
|
338
|
+
async function runCheck(file: string, opts: { json?: boolean } = {}): Promise<void> {
|
|
422
339
|
const filePath = resolve(file);
|
|
423
340
|
const source = readFileSync(filePath, "utf-8");
|
|
424
|
-
const result = await analyzeFileAsync(filePath, source);
|
|
425
341
|
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
342
|
+
const { checkSource, formatCheckReport, serializeCheckJson, pTrue } = await import("@nudojs/core");
|
|
343
|
+
const { defaultLoadModule: loadModule } = await import("@nudojs/service");
|
|
344
|
+
const algebraReport = checkSource(filePath, source, pTrue, {
|
|
345
|
+
loadModule,
|
|
346
|
+
fromFile: filePath,
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
if (opts.json) {
|
|
350
|
+
// 稳定契约:只输出 check JSON
|
|
351
|
+
console.log(JSON.stringify(serializeCheckJson(algebraReport), null, 2));
|
|
352
|
+
} else {
|
|
353
|
+
console.log(formatCheckReport(algebraReport, { verbose: true }));
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
if (!algebraReport.ok) {
|
|
357
|
+
process.exitCode = 1;
|
|
429
358
|
}
|
|
359
|
+
}
|
|
430
360
|
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
361
|
+
/** 单文件或目录 → 推断目标列表(目录递归,跳过 node_modules) */
|
|
362
|
+
function resolveTargets(path: string): string[] {
|
|
363
|
+
const resolved = resolve(path);
|
|
364
|
+
if (!existsSync(resolved)) {
|
|
365
|
+
console.error(`Not found: ${resolved}`);
|
|
366
|
+
process.exitCode = 1;
|
|
367
|
+
return [];
|
|
368
|
+
}
|
|
369
|
+
if (statSync(resolved).isDirectory()) {
|
|
370
|
+
const files = collectNudoFiles(resolved);
|
|
371
|
+
if (files.length === 0) {
|
|
372
|
+
console.error(`No nudo files found in directory: ${resolved}`);
|
|
373
|
+
process.exitCode = 1;
|
|
436
374
|
}
|
|
375
|
+
return files;
|
|
437
376
|
}
|
|
377
|
+
return [resolved];
|
|
378
|
+
}
|
|
438
379
|
|
|
439
|
-
|
|
380
|
+
program
|
|
381
|
+
.command("types")
|
|
382
|
+
.description("Type-as-computation view: show term + constraints from the algebra (not just extensional shape)")
|
|
383
|
+
.argument("<file>", "Path to the JS/TS file or a directory of them")
|
|
384
|
+
.option("--fn <name>", "Only analyze this function")
|
|
385
|
+
.option("--assume <pred...>", "Assume constraints, e.g. x>0 y>=1")
|
|
386
|
+
.option("--generalize", "Show polymorphic signatures via symbolic execution")
|
|
387
|
+
.action(
|
|
388
|
+
async (
|
|
389
|
+
file: string,
|
|
390
|
+
opts: { fn?: string; assume?: string[]; generalize?: boolean },
|
|
391
|
+
) => {
|
|
392
|
+
const targets = resolveTargets(file);
|
|
393
|
+
if (targets.length === 0) return;
|
|
394
|
+
for (const t of targets) {
|
|
395
|
+
await runTypes(t, opts);
|
|
396
|
+
if (targets.length > 1) console.log("");
|
|
397
|
+
}
|
|
398
|
+
},
|
|
399
|
+
);
|
|
400
|
+
|
|
401
|
+
async function runTypes(
|
|
402
|
+
filePath: string,
|
|
403
|
+
opts: { fn?: string; assume?: string[]; generalize?: boolean },
|
|
404
|
+
): Promise<void> {
|
|
405
|
+
const { readFileSync } = await import("node:fs");
|
|
406
|
+
const { basename } = await import("node:path");
|
|
407
|
+
const algebra = await import("@nudojs/core");
|
|
408
|
+
|
|
409
|
+
const source = readFileSync(filePath, "utf8");
|
|
410
|
+
let phi = algebra.pTrue;
|
|
411
|
+
const assumeIds = new Set<string>();
|
|
412
|
+
for (const a of opts.assume ?? []) {
|
|
413
|
+
const m = /^([A-Za-z_$][\w$]*)\s*(>=|>)\s*(-?\d+(?:\.\d+)?)$/.exec(a.trim());
|
|
414
|
+
if (!m) {
|
|
415
|
+
console.error(`无法解析 --assume: ${a}(支持 x>0 / x>=1)`);
|
|
416
|
+
continue;
|
|
417
|
+
}
|
|
418
|
+
const id = m[1]!;
|
|
419
|
+
const n = Number(m[3]);
|
|
420
|
+
phi = algebra.gtNum(algebra.v(id), n);
|
|
421
|
+
assumeIds.add(id);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// 列出函数
|
|
425
|
+
const list = opts.fn ? [opts.fn] : algebra.listFunctionNames(source);
|
|
426
|
+
if (list.length === 0) {
|
|
427
|
+
console.error(`未找到函数: ${basename(filePath)}`);
|
|
440
428
|
process.exitCode = 1;
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
const { defaultLoadModule: loadModule } = await import("@nudojs/service");
|
|
433
|
+
|
|
434
|
+
console.log(`nudo types ${basename(filePath)}`);
|
|
435
|
+
if (assumeIds.size > 0) {
|
|
436
|
+
console.log(`assume: ${[...assumeIds].map((id) => `${id} > 0`).join(", ")}`);
|
|
437
|
+
}
|
|
438
|
+
if (opts.generalize) {
|
|
439
|
+
console.log("mode: generalize (symbolic α)\n");
|
|
440
|
+
} else {
|
|
441
|
+
console.log("");
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
for (const name of list) {
|
|
445
|
+
if (opts.generalize) {
|
|
446
|
+
const g = algebra.generalizeFromAst(name, source, {
|
|
447
|
+
refine: { loadModule, fromFile: filePath },
|
|
448
|
+
});
|
|
449
|
+
if (!g) continue;
|
|
450
|
+
console.log(g.display);
|
|
451
|
+
console.log("");
|
|
452
|
+
continue;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
const args = algebra.buildArgsFromAssume(source, name, assumeIds);
|
|
456
|
+
const result = algebra.analyzeFn(source, name, args, phi);
|
|
457
|
+
const label = `${name}(${args.map((a) => algebra.formatShape(a)).join(", ")})`;
|
|
458
|
+
console.log(algebra.formatAbsMultiline(result, label));
|
|
459
|
+
console.log("");
|
|
441
460
|
}
|
|
442
461
|
}
|
|
443
462
|
|
|
444
463
|
program
|
|
445
464
|
.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")
|
|
465
|
+
.description("Check a JS/TS file (or directory) for type errors — exits with code 1 when errors are found")
|
|
466
|
+
.argument("<file>", "Path to the JS/TS file or a directory of them")
|
|
467
|
+
.option("--json", "Emit stable CheckJson (CI / Agent contract; single file only)")
|
|
468
|
+
.action(async (file: string, opts: { json?: boolean }) => {
|
|
469
|
+
const targets = resolveTargets(file);
|
|
470
|
+
if (targets.length === 0) return;
|
|
471
|
+
if (opts.json && targets.length > 1) {
|
|
472
|
+
console.error("--json requires a single file, not a directory");
|
|
473
|
+
process.exitCode = 1;
|
|
474
|
+
return;
|
|
475
|
+
}
|
|
476
|
+
for (const t of targets) {
|
|
477
|
+
await runCheck(t, opts);
|
|
478
|
+
}
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
program
|
|
482
|
+
.command("test")
|
|
483
|
+
.description("Run @nudo:case directives as assertions (case-as-test); exit 1 on failure")
|
|
484
|
+
.argument("<file>", "Path to the JS/TS file or a directory of them")
|
|
448
485
|
.action(async (file: string) => {
|
|
449
|
-
|
|
486
|
+
const targets = resolveTargets(file);
|
|
487
|
+
if (targets.length === 0) return;
|
|
488
|
+
for (const filePath of targets) {
|
|
489
|
+
const source = readFileSync(filePath, "utf-8");
|
|
490
|
+
try {
|
|
491
|
+
const result = await analyzeFileAsync(filePath, source);
|
|
492
|
+
const report = buildTestReport(filePath, result);
|
|
493
|
+
console.log(formatTestReport(report));
|
|
494
|
+
if (report.failed > 0) process.exitCode = 1;
|
|
495
|
+
} catch (err) {
|
|
496
|
+
console.error(`nudo test failed to analyze ${filePath}: ${(err as Error).message}`);
|
|
497
|
+
process.exitCode = 1;
|
|
498
|
+
}
|
|
499
|
+
}
|
|
450
500
|
});
|
|
451
501
|
|
|
452
502
|
// ---------------------------------------------------------------------------
|
|
@@ -643,8 +693,11 @@ program
|
|
|
643
693
|
console.clear();
|
|
644
694
|
console.log(`[${new Date().toLocaleTimeString()}] Analyzing (incremental)...\n`);
|
|
645
695
|
const t0 = performance.now();
|
|
646
|
-
// 缓存失效:callMemo 按名键陈旧 + moduleCache
|
|
696
|
+
// 缓存失效:callMemo 按名键陈旧 + moduleCache 嵌入旧类型;
|
|
697
|
+
// 入口 source 未变但 dep 变了时,B-path / AnalysisResult / fn-cache
|
|
698
|
+
// 键不含 dep 指纹——必须按脏集入口文件定向逐出,否则命中陈旧结果。
|
|
647
699
|
resetMemo();
|
|
700
|
+
evictAnalysisCachesForFiles(ordered);
|
|
648
701
|
for (const f of ordered) {
|
|
649
702
|
try {
|
|
650
703
|
await runInfer(f, { dts: opts.dts, showLoc: true });
|
|
@@ -670,7 +723,7 @@ program
|
|
|
670
723
|
watch(watchTarget, { recursive: isDir }, (_event, filename) => {
|
|
671
724
|
if (!filename) return;
|
|
672
725
|
const fullPath = isDir ? join(watchTarget, filename) : resolved;
|
|
673
|
-
if (!fullPath
|
|
726
|
+
if (!isNudoTargetPath(fullPath)) return; // .js/.mjs/.ts(排除 .d.ts/.tsx)
|
|
674
727
|
if (!existsSync(fullPath)) return; // deleted
|
|
675
728
|
|
|
676
729
|
pendingChanged.add(fullPath);
|
|
@@ -690,8 +743,9 @@ function collectNudoFiles(dir: string): string[] {
|
|
|
690
743
|
const fullPath = join(dir, entry.name);
|
|
691
744
|
if (entry.isDirectory() && entry.name !== "node_modules") {
|
|
692
745
|
results.push(...collectNudoFiles(fullPath));
|
|
693
|
-
} else if (entry.isFile() &&
|
|
694
|
-
// 全程序推断:无指令的纯 JS
|
|
746
|
+
} else if (entry.isFile() && isNudoTargetPath(fullPath)) {
|
|
747
|
+
// 全程序推断:无指令的纯 JS/TS 文件也能推导类型;.d.ts(类型声明)与
|
|
748
|
+
// .tsx(JSX)不是推断目标,由 isNudoTargetPath 排除
|
|
695
749
|
results.push(fullPath);
|
|
696
750
|
}
|
|
697
751
|
}
|
|
@@ -735,86 +789,129 @@ function fnParamNames(node: DirectiveFnNode): string[] {
|
|
|
735
789
|
return [];
|
|
736
790
|
}
|
|
737
791
|
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
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
|
-
});
|
|
792
|
+
/** generate/emit/guard 共用管道:analyzeFileAsync → zod/guard/dts 片段 */
|
|
793
|
+
async function runGenerate(
|
|
794
|
+
file: string,
|
|
795
|
+
format: "zod" | "guard" | "dts" | "all",
|
|
796
|
+
output?: string,
|
|
797
|
+
): Promise<void> {
|
|
798
|
+
const filePath = resolve(file);
|
|
799
|
+
const source = readFileSync(filePath, "utf-8");
|
|
800
|
+
// 与 infer/check 同一分析管道(Abs 优先 + refine + env preload)
|
|
801
|
+
const result = await analyzeFileAsync(filePath, source);
|
|
802
|
+
const functions = result.functions.filter(
|
|
803
|
+
(f) => f.cases.some((c) => c.source === "directive"),
|
|
804
|
+
);
|
|
778
805
|
|
|
779
|
-
|
|
806
|
+
if (functions.length === 0) {
|
|
807
|
+
console.log("No functions with @nudo:case directives found.");
|
|
808
|
+
return;
|
|
809
|
+
}
|
|
780
810
|
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
811
|
+
const zodChunks: string[] = [];
|
|
812
|
+
const guardChunks: string[] = [];
|
|
813
|
+
const dtsChunks: string[] = [];
|
|
814
|
+
|
|
815
|
+
for (const fn of functions) {
|
|
816
|
+
const caseResults: CaseResult[] = fn.cases.filter((c) => c.source === "directive");
|
|
817
|
+
const baseName = fn.name;
|
|
818
|
+
|
|
819
|
+
if (format === "zod" || format === "all") {
|
|
820
|
+
const lines: string[] = [`\n// === ${baseName} Zod Schemas ===`];
|
|
821
|
+
for (const c of caseResults) {
|
|
822
|
+
const inputSchemas = c.args.map((a, i) => `arg${i}: ${typeValueToZodSchema(a)}`).join(", ");
|
|
823
|
+
const outputSchema = typeValueToZodSchema(c.result);
|
|
824
|
+
lines.push(`// Case "${c.name}":`);
|
|
825
|
+
lines.push(`// Input: { ${inputSchemas} }`);
|
|
826
|
+
lines.push(`// Output: ${outputSchema}`);
|
|
790
827
|
}
|
|
828
|
+
zodChunks.push(lines.join("\n"));
|
|
829
|
+
}
|
|
791
830
|
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
831
|
+
if (format === "guard" || format === "all") {
|
|
832
|
+
const lines: string[] = [`\n// === ${baseName} Type Guards ===`];
|
|
833
|
+
for (const c of caseResults) {
|
|
834
|
+
const guardName = `is${baseName}${c.name.charAt(0).toUpperCase() + c.name.slice(1)}Output`;
|
|
835
|
+
// Abs 路径优先:denote 保留 pred;否则回退 TypeValue 投影
|
|
836
|
+
if (c.abs) {
|
|
837
|
+
lines.push(generateGuardFunctionFromAbs(guardName, c.abs));
|
|
838
|
+
} else {
|
|
839
|
+
lines.push(generateGuardFunction(guardName, c.result));
|
|
797
840
|
}
|
|
798
841
|
}
|
|
842
|
+
guardChunks.push(lines.join("\n"));
|
|
843
|
+
}
|
|
799
844
|
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
845
|
+
if (format === "dts" || format === "all") {
|
|
846
|
+
dtsChunks.push(generateFunctionDtsLines(fn).join("\n"));
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
const stem = basename(filePath).replace(/\.[cm]?[jt]s$/, "");
|
|
851
|
+
if (output) {
|
|
852
|
+
const outDir = resolve(output);
|
|
853
|
+
mkdirSync(outDir, { recursive: true });
|
|
854
|
+
const written: string[] = [];
|
|
855
|
+
if (zodChunks.length > 0) {
|
|
856
|
+
const p = join(outDir, `${stem}.nudo.zod.ts`);
|
|
857
|
+
writeFileSync(p, zodChunks.join("\n") + "\n", "utf-8");
|
|
858
|
+
written.push(p);
|
|
859
|
+
}
|
|
860
|
+
if (guardChunks.length > 0) {
|
|
861
|
+
const p = join(outDir, `${stem}.nudo.guard.ts`);
|
|
862
|
+
writeFileSync(p, guardChunks.join("\n") + "\n", "utf-8");
|
|
863
|
+
written.push(p);
|
|
864
|
+
}
|
|
865
|
+
if (dtsChunks.length > 0) {
|
|
866
|
+
const p = join(outDir, `${stem}.d.ts`);
|
|
867
|
+
writeFileSync(p, dtsChunks.join("\n") + "\n", "utf-8");
|
|
868
|
+
written.push(p);
|
|
869
|
+
}
|
|
870
|
+
for (const p of written) {
|
|
871
|
+
console.log(`wrote ${relative(process.cwd(), p)}`);
|
|
872
|
+
}
|
|
873
|
+
return;
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
for (const chunk of [...zodChunks, ...guardChunks, ...dtsChunks]) {
|
|
877
|
+
console.log(chunk);
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
program
|
|
882
|
+
.command("generate")
|
|
883
|
+
.description("Generate runtime validators from inferred types")
|
|
884
|
+
.argument("<file>", "JavaScript file to analyze")
|
|
885
|
+
.option("--format <format>", "Output format: zod, guard, dts, all", "all")
|
|
886
|
+
.option("--output <dir>", "Write validator files to this directory (omit for stdout)")
|
|
887
|
+
.action(async (file: string, options: { format: string; output?: string }) => {
|
|
888
|
+
const format = options.format;
|
|
889
|
+
if (!["zod", "guard", "dts", "all"].includes(format)) {
|
|
890
|
+
console.error(`Unknown --format ${format}; expected zod | guard | dts | all`);
|
|
891
|
+
process.exitCode = 1;
|
|
892
|
+
return;
|
|
815
893
|
}
|
|
894
|
+
await runGenerate(file, format as "zod" | "guard" | "dts" | "all", options.output);
|
|
895
|
+
});
|
|
816
896
|
|
|
817
|
-
|
|
897
|
+
/** 设计命令面 `nudo emit`:为 npm 生态导出 .d.ts(= generate --format dts) */
|
|
898
|
+
program
|
|
899
|
+
.command("emit")
|
|
900
|
+
.description("Emit TypeScript .d.ts declarations from inferred types (npm compatibility exit)")
|
|
901
|
+
.argument("<file>", "JavaScript file to analyze")
|
|
902
|
+
.option("--output <dir>", "Write <stem>.d.ts to this directory (omit for stdout)")
|
|
903
|
+
.action(async (file: string, options: { output?: string }) => {
|
|
904
|
+
await runGenerate(file, "dts", options.output);
|
|
905
|
+
});
|
|
906
|
+
|
|
907
|
+
/** 设计命令面 `nudo guard`:边界运行时校验(= generate --format guard) */
|
|
908
|
+
program
|
|
909
|
+
.command("guard")
|
|
910
|
+
.description("Generate runtime type-guard functions from inferred result types")
|
|
911
|
+
.argument("<file>", "JavaScript file to analyze")
|
|
912
|
+
.option("--output <dir>", "Write <stem>.nudo.guard.ts to this directory (omit for stdout)")
|
|
913
|
+
.action(async (file: string, options: { output?: string }) => {
|
|
914
|
+
await runGenerate(file, "guard", options.output);
|
|
818
915
|
});
|
|
819
916
|
|
|
820
917
|
// ---------------------------------------------------------------------------
|
|
@@ -823,46 +920,6 @@ program
|
|
|
823
920
|
|
|
824
921
|
const HARVEST_MAX_FILES = 200;
|
|
825
922
|
|
|
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
923
|
function runHarvest(pkg: string, outOpt?: string): void {
|
|
867
924
|
const typesDir = resolve(process.cwd(), "node_modules", "@types", pkg);
|
|
868
925
|
if (!existsSync(typesDir)) {
|
|
@@ -892,7 +949,8 @@ function runHarvest(pkg: string, outOpt?: string): void {
|
|
|
892
949
|
return;
|
|
893
950
|
}
|
|
894
951
|
|
|
895
|
-
|
|
952
|
+
// 单一收集实现(service):入口 BFS + reference/相对 import 图
|
|
953
|
+
const files = collectDtsFromEntry(entry, HARVEST_MAX_FILES);
|
|
896
954
|
if (files.length === 0) {
|
|
897
955
|
console.error(`Error: no .d.ts files collected from ${relative(process.cwd(), entry)}.`);
|
|
898
956
|
process.exitCode = 1;
|
|
@@ -919,9 +977,54 @@ function runHarvest(pkg: string, outOpt?: string): void {
|
|
|
919
977
|
program
|
|
920
978
|
.command("harvest")
|
|
921
979
|
.description("Convert @types/<pkg> .d.ts declarations into a Nudo env file (TS source using T.* constructors)")
|
|
922
|
-
.argument("
|
|
980
|
+
.argument("[pkg]", "Package name under @types (e.g. node)")
|
|
923
981
|
.option("--out <file>", "Output .ts env file (default: ./nudo-harvest-<pkg>.ts)")
|
|
924
|
-
.
|
|
982
|
+
.option("--auto [dir]", "Scan directory (default .) for bare imports and report auto-harvestable @types packages")
|
|
983
|
+
.action(async (pkg: string | undefined, opts: { out?: string; auto?: boolean | string }) => {
|
|
984
|
+
if (opts.auto !== undefined) {
|
|
985
|
+
const dir = resolve(typeof opts.auto === "string" ? opts.auto : ".");
|
|
986
|
+
const files = existsSync(dir) && statSync(dir).isDirectory()
|
|
987
|
+
? collectNudoFiles(dir)
|
|
988
|
+
: existsSync(dir)
|
|
989
|
+
? [dir]
|
|
990
|
+
: [];
|
|
991
|
+
if (files.length === 0) {
|
|
992
|
+
console.error(`No inference targets under ${dir}`);
|
|
993
|
+
process.exitCode = 1;
|
|
994
|
+
return;
|
|
995
|
+
}
|
|
996
|
+
const { collectBarePackages, harvestPackageCached, formatHarvestSummary } = await import("@nudojs/service");
|
|
997
|
+
const seen = new Set<string>();
|
|
998
|
+
const report: string[] = [];
|
|
999
|
+
for (const f of files) {
|
|
1000
|
+
let src: string;
|
|
1001
|
+
try {
|
|
1002
|
+
src = readFileSync(f, "utf-8");
|
|
1003
|
+
} catch {
|
|
1004
|
+
continue;
|
|
1005
|
+
}
|
|
1006
|
+
for (const p of collectBarePackages(src)) {
|
|
1007
|
+
if (seen.has(p)) continue;
|
|
1008
|
+
seen.add(p);
|
|
1009
|
+
const h = harvestPackageCached(p, dirname(f));
|
|
1010
|
+
if (h) report.push(formatHarvestSummary(h));
|
|
1011
|
+
else report.push(`${p}: no .d.ts / @types (skipped)`);
|
|
1012
|
+
}
|
|
1013
|
+
}
|
|
1014
|
+
if (report.length === 0) {
|
|
1015
|
+
console.log("No bare imports found (or nothing to harvest).");
|
|
1016
|
+
return;
|
|
1017
|
+
}
|
|
1018
|
+
console.log(`auto harvest candidates under ${relative(process.cwd(), dir) || "."}:\n`);
|
|
1019
|
+
for (const line of report) console.log(line);
|
|
1020
|
+
console.log(`\nAnalysis injects these automatically; use \`nudo harvest <pkg>\` to write a persistent env file.`);
|
|
1021
|
+
return;
|
|
1022
|
+
}
|
|
1023
|
+
if (!pkg) {
|
|
1024
|
+
console.error("Error: <pkg> is required (or use --auto).");
|
|
1025
|
+
process.exitCode = 1;
|
|
1026
|
+
return;
|
|
1027
|
+
}
|
|
925
1028
|
runHarvest(pkg, opts.out);
|
|
926
1029
|
});
|
|
927
1030
|
|