@nudojs/cli 0.3.0 → 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/src/index.ts DELETED
@@ -1,1034 +0,0 @@
1
- import { readFileSync, existsSync, watch, readdirSync, statSync, writeFileSync, mkdirSync } from "node:fs";
2
- import { resolve, dirname, relative, join, basename } from "node:path";
3
- import { Command } from "commander";
4
- import { typeValueToString } from "@nudojs/core";
5
- import { extractDirectives } from "@nudojs/parser";
6
- import { resetMemo } from "./evaluator.ts";
7
- import {
8
- typeValueToZodSchema,
9
- generateGuardFunction,
10
- generateGuardFunctionFromAbs,
11
- generateFunctionDtsLines,
12
- analyzeFileAsync,
13
- buildModuleGraph,
14
- computeDirtySet,
15
- topoSortDirty,
16
- collectCallRecords,
17
- stripGeneratedCaseDirectives,
18
- insertGeneratedCaseDirectives,
19
- unifiedDiff,
20
- isNudoTargetPath,
21
- collectDtsFromEntry,
22
- evictAnalysisCachesForFiles,
23
- type CallRecord,
24
- type CaseResult,
25
- type FunctionAnalysis,
26
- type AnalysisResult,
27
- type EmitResult,
28
- } from "@nudojs/service";
29
- import { harvestDts, emitEnvModule } from "@nudojs/harvester";
30
- import { buildTestReport, formatTestReport } from "./run-test.ts";
31
-
32
- const program = new Command();
33
-
34
- program
35
- .name("nudo")
36
- .description("Nudo type inference engine")
37
- .version("0.0.1");
38
-
39
- /** `--emit-cases` 的编排选项:mode 决定 add/update 两条固化路径 */
40
- type EmitCasesOptions = { mode: "add" | "update"; dryRun: boolean; exitOnDiff: boolean };
41
-
42
- /**
43
- * update 固化路径的编排:剥离旧生成指令(call@ 前缀)→ 以剥离后的源码
44
- * 重新分析(合成 case 反映当前真实调用形状)→ 重新插入生成指令。
45
- * infer --emit-cases=update 与 doctor 的 drift 判定共用此链路。
46
- * 注意是否有变化必须比较 emitOut.source 与原始 source(EmitResult.changed
47
- * 以 stripped 基准恒真);removed 供指令数统计。
48
- */
49
- async function reemitUpdate(
50
- filePath: string,
51
- source: string,
52
- callsites?: CallRecord[],
53
- ): Promise<{ result: AnalysisResult; emitOut: EmitResult; removed: string[] }> {
54
- const stripped = stripGeneratedCaseDirectives(source);
55
- const result = await analyzeFileAsync(filePath, stripped.source, undefined, callsites);
56
- const emitOut = insertGeneratedCaseDirectives(stripped.source, result);
57
- return { result, emitOut, removed: stripped.removed };
58
- }
59
-
60
- /**
61
- * --callsites 公共采集:路径可为文件或目录(目录递归收 .js),逐文件
62
- * collectCallRecords 汇总为外部调用记录。找不到的路径报错并置退出码 1
63
- * (不中断其余路径);一条都收不到时返回 undefined(与未传等价)。
64
- */
65
- function collectExternalRecords(sites: string[]): CallRecord[] | undefined {
66
- const records: CallRecord[] = [];
67
- for (const site of sites) {
68
- const sitePath = resolve(site);
69
- if (!existsSync(sitePath)) {
70
- console.error(`Callsite file not found: ${sitePath}`);
71
- process.exitCode = 1;
72
- continue;
73
- }
74
- const siteFiles = statSync(sitePath).isDirectory() ? collectNudoFiles(sitePath) : [sitePath];
75
- for (const sf of siteFiles) {
76
- records.push(...collectCallRecords(sf, readFileSync(sf, "utf-8")));
77
- }
78
- }
79
- return records.length > 0 ? records : undefined;
80
- }
81
-
82
- async function runInfer(
83
- file: string,
84
- options: { dts?: boolean; showLoc?: boolean; callsites?: CallRecord[]; emit?: EmitCasesOptions } = {},
85
- ): Promise<void> {
86
- const filePath = resolve(file);
87
- const source = readFileSync(filePath, "utf-8");
88
- let result = await analyzeFileAsync(filePath, source, undefined, options.callsites);
89
-
90
- // 调用点固化:add 直接把已算好的合成 case 插回源码;update 先剥离旧生成指令,
91
- // 在剥离后的源码上重算分析再插入,后续打印/摘要都基于重算结果(反映刚固化的形状)
92
- let emitOut: EmitResult | undefined;
93
- if (options.emit) {
94
- if (options.emit.mode === "update") {
95
- const re = await reemitUpdate(filePath, source, options.callsites);
96
- result = re.result;
97
- emitOut = re.emitOut;
98
- } else {
99
- emitOut = insertGeneratedCaseDirectives(source, result);
100
- }
101
- }
102
-
103
- if (result.functions.length === 0 && !(result.externalFunctions?.length)) {
104
- console.log("No functions with @nudo:case directives found.");
105
- return;
106
- }
107
-
108
- const dtsLines: string[] = [];
109
-
110
- for (const fn of result.functions) {
111
- const loc = fn.loc;
112
- const locStr = `${relative(process.cwd(), filePath)}:${loc.start.line}:${loc.start.column}`;
113
- const header = options.showLoc ? `=== ${fn.name} (${locStr}) ===` : `=== ${fn.name} ===`;
114
- console.log(`${header}\n`);
115
-
116
- // .d.ts 与 service 级 generateDts 共用同一实现(单一 widen 主签名 +
117
- // JSDoc case 说明;真实参数名;noDeclaration 函数排除),两条路径行为一致
118
- if (options.dts) {
119
- dtsLines.push(...generateFunctionDtsLines(fn));
120
- }
121
-
122
- if (fn.skipped) {
123
- if (fn.combined) {
124
- console.log(`Skipped (declared): ${typeValueToString(fn.combined)}`);
125
- } else {
126
- console.log("Skipped (no return type declared)");
127
- }
128
- console.log();
129
- continue;
130
- }
131
-
132
- for (const c of fn.cases) {
133
- const argsStr = c.args.map(typeValueToString).join(", ");
134
- let line = `Case "${c.name}": (${argsStr}) => ${typeValueToString(c.result)}`;
135
- if (c.throws.kind !== "never") line += ` throws ${typeValueToString(c.throws)}`;
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
- }
150
- }
151
-
152
- if (fn.entryOnly) {
153
- console.log("# no call sites found; parameters default to unknown");
154
- }
155
-
156
- if (fn.cases.length > 1 && fn.combined) {
157
- console.log(`\nCombined: ${typeValueToString(fn.combined)}`);
158
- }
159
-
160
- console.log();
161
- }
162
-
163
- // Imported functions inferred from this file's cross-file call sites.
164
- // d.ts generation skips them on purpose: they belong to another file's
165
- // declaration boundary, not this one.
166
- if (result.externalFunctions && result.externalFunctions.length > 0) {
167
- const byModule = new Map<string, FunctionAnalysis[]>();
168
- for (const fn of result.externalFunctions) {
169
- const mod = fn.fromModule ?? "";
170
- const list = byModule.get(mod);
171
- if (list) list.push(fn);
172
- else byModule.set(mod, [fn]);
173
- }
174
- for (const [mod, fns] of byModule) {
175
- const rel = relative(process.cwd(), mod) || mod;
176
- console.log(`--- ${rel} (imported) ---\n`);
177
- for (const fn of fns) {
178
- console.log(`=== ${fn.name} ===\n`);
179
- for (const c of fn.cases) {
180
- const argsStr = c.args.map(typeValueToString).join(", ");
181
- let line = `Case "${c.name}": (${argsStr}) => ${typeValueToString(c.result)}`;
182
- if (c.throws.kind !== "never") line += ` throws ${typeValueToString(c.throws)}`;
183
- console.log(line);
184
- }
185
- if (fn.cases.length > 1 && fn.combined) {
186
- console.log(`\nCombined: ${typeValueToString(fn.combined)}`);
187
- }
188
- console.log();
189
- }
190
- }
191
- }
192
-
193
- if (options.dts && dtsLines.length > 0) {
194
- const dtsPath = filePath.replace(/\.[cm]?js$|\.ts$/, ".d.ts");
195
- const dtsContent = dtsLines.join("\n") + "\n";
196
- writeFileSync(dtsPath, dtsContent, "utf-8");
197
- console.log(`Generated: ${relative(process.cwd(), dtsPath)}`);
198
- }
199
-
200
- if (result.diagnostics.length > 0) {
201
- console.log("Diagnostics:\n");
202
- for (const d of result.diagnostics) {
203
- const loc = `${relative(process.cwd(), filePath)}:${d.range.start.line}:${d.range.start.column}`;
204
- console.log(` [${d.severity}] ${loc} ${d.message}${d.code ? ` (${d.code})` : ""}`);
205
- if (d.origin) {
206
- console.log(` → value originates at ${d.origin.line}:${d.origin.column}`);
207
- }
208
- }
209
- console.log();
210
- }
211
-
212
- // 调用点固化收尾:摘要 / diff / 写盘。是否"有变化"以最终源码与原源码比对为准
213
- // (update 会先剥离再插回,剥离后重写的相同指令不构成变化)
214
- if (options.emit && emitOut) {
215
- const relPath = relative(process.cwd(), filePath) || filePath;
216
- const skippedLines = emitOut.skipped.map((s) => ` ${s.fn}: ${s.reason}${s.detail ? ` (${s.detail})` : ""}`);
217
-
218
- if (emitOut.source === source) {
219
- console.log("No changes.");
220
- for (const line of skippedLines) console.log(line);
221
- return;
222
- }
223
-
224
- if (options.emit.dryRun) {
225
- console.log(`Would emit cases → ${relPath} (dry run)`);
226
- for (const w of emitOut.written) console.log(` ${w.fn}: ${w.cases.join(", ")}`);
227
- for (const line of skippedLines) console.log(line);
228
- console.log();
229
- process.stdout.write(unifiedDiff(source, emitOut.source, relPath));
230
- if (options.emit.exitOnDiff) process.exitCode = 1;
231
- return;
232
- }
233
-
234
- writeFileSync(filePath, emitOut.source, "utf-8");
235
- const directiveCount = emitOut.written.reduce((n, w) => n + w.cases.length, 0);
236
- console.log(
237
- `Emitted cases → ${relPath} (${directiveCount} directive(s) across ${emitOut.written.length} function(s))`,
238
- );
239
- for (const w of emitOut.written) console.log(` ${w.fn}: ${w.cases.join(", ")}`);
240
- for (const line of skippedLines) console.log(line);
241
- }
242
- }
243
-
244
- async function runInferJson(file: string, externalRecords?: CallRecord[]): Promise<void> {
245
- const filePath = resolve(file);
246
- const source = readFileSync(filePath, "utf-8");
247
- const result = await analyzeFileAsync(filePath, source, undefined, externalRecords);
248
- const { serializeInferJson } = await import("@nudojs/service");
249
- console.log(JSON.stringify(serializeInferJson(result, filePath), null, 2));
250
- }
251
-
252
- program
253
- .command("infer")
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)")
256
- .option("--dts", "Generate .d.ts file")
257
- .option("--loc", "Show source locations in output")
258
- .option("--json", "Output as JSON")
259
- .option("--callsites <paths...>", "Usage-site files (tests/apps) to harvest real call shapes from; their calls to this file's exports become synthesized cases")
260
- .option("--emit-cases [mode]", "Write synthesized call-site cases back into the source as @nudo:case directives (call@ prefix); mode: update (default: add)")
261
- .option("--dry-run", "With --emit-cases: print a unified diff instead of writing to disk")
262
- .option("--exit-on-diff", "With --dry-run: exit with code 1 when the diff is non-empty")
263
- .action(
264
- async (
265
- file: string,
266
- opts: {
267
- dts?: boolean;
268
- loc?: boolean;
269
- json?: boolean;
270
- callsites?: string[];
271
- emitCases?: boolean | string;
272
- dryRun?: boolean;
273
- exitOnDiff?: boolean;
274
- },
275
- ) => {
276
- // --emit-cases 只允许省略(=add)或 =update 两种形态
277
- let emit: EmitCasesOptions | undefined;
278
- if (opts.emitCases !== undefined) {
279
- let mode: "add" | "update";
280
- if (opts.emitCases === true) mode = "add";
281
- else if (opts.emitCases === "update") mode = "update";
282
- else {
283
- console.error(`Invalid --emit-cases value: ${opts.emitCases} (expected: =update, or omit the value for add)`);
284
- process.exitCode = 1;
285
- return;
286
- }
287
- if (opts.json) {
288
- console.error("--emit-cases cannot be combined with --json");
289
- process.exitCode = 1;
290
- return;
291
- }
292
- emit = { mode, dryRun: opts.dryRun === true, exitOnDiff: opts.exitOnDiff === true };
293
- }
294
- if (opts.exitOnDiff && !opts.dryRun) {
295
- console.error("--exit-on-diff requires --dry-run");
296
- process.exitCode = 1;
297
- return;
298
- }
299
- let externalRecords: CallRecord[] | undefined;
300
- if (opts.callsites?.length) {
301
- externalRecords = collectExternalRecords(opts.callsites);
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
- }
326
- if (opts.json) {
327
- await runInferJson(file, externalRecords);
328
- } else {
329
- await runInfer(file, { dts: opts.dts, showLoc: opts.loc, callsites: externalRecords, emit });
330
- }
331
- });
332
-
333
- /**
334
- * 严格 Abs-only:只跑 checkSource 代数门禁。
335
- * 不再叠加 analyzeFileAsync 外延诊断——语言表面问题由 Abs/B-path 诊断吸收,
336
- * 避免双路径语义分叉。
337
- */
338
- async function runCheck(file: string, opts: { json?: boolean } = {}): Promise<void> {
339
- const filePath = resolve(file);
340
- const source = readFileSync(filePath, "utf-8");
341
-
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;
358
- }
359
- }
360
-
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;
374
- }
375
- return files;
376
- }
377
- return [resolved];
378
- }
379
-
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)}`);
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("");
460
- }
461
- }
462
-
463
- program
464
- .command("check")
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")
485
- .action(async (file: string) => {
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
- }
500
- });
501
-
502
- // ---------------------------------------------------------------------------
503
- // doctor — 项目健康检查:uncovered 函数 / 调用点固化漂移 / 分析报错
504
- // ---------------------------------------------------------------------------
505
-
506
- /** doctor 单文件体检结果:uncovered 为信息级,drift/error 决定退出码 */
507
- type DoctorReport = {
508
- file: string;
509
- functions: number;
510
- entryOnly: number;
511
- uncovered: string[];
512
- drift?: { added: number; removed: number };
513
- error?: string;
514
- };
515
-
516
- /** 展示路径:cwd 内取相对路径,cwd 外(如 /tmp fixture)直接用绝对路径,避免 ../ 链 */
517
- const displayPath = (p: string): string => {
518
- const rel = relative(process.cwd(), p);
519
- return rel === "" || rel.startsWith("..") ? p : rel;
520
- };
521
-
522
- /**
523
- * 单文件体检。三项检查:
524
- * a) uncovered —— 零 case 且非 skipped/entryOnly 的函数(信息级,不影响退出码);
525
- * b) drift —— 给了调用记录时按 infer --emit-cases=update 的 dry-run 编排
526
- * (剥离 → 重析 → 重插)重算固化结果,最终源码与原源码不一致即漂移,
527
- * 指令数按 removed/新增 written 计;
528
- * c) 报错 —— 读取/分析抛异常即记(含 update dry-run 阶段)。
529
- */
530
- async function doctorFile(filePath: string, records?: CallRecord[]): Promise<DoctorReport> {
531
- const report: DoctorReport = { file: displayPath(filePath), functions: 0, entryOnly: 0, uncovered: [] };
532
- let source: string;
533
- try {
534
- source = readFileSync(filePath, "utf-8");
535
- } catch (err) {
536
- report.error = (err as Error).message;
537
- return report;
538
- }
539
- try {
540
- const result = await analyzeFileAsync(filePath, source, undefined, records);
541
- report.functions = result.functions.length;
542
- report.entryOnly = result.functions.filter((fn) => fn.entryOnly).length;
543
- report.uncovered = result.functions
544
- .filter((fn) => fn.cases.length === 0 && !fn.skipped && !fn.entryOnly)
545
- .map((fn) => fn.name);
546
- if (records) {
547
- const { emitOut, removed } = await reemitUpdate(filePath, source, records);
548
- if (emitOut.source !== source) {
549
- report.drift = {
550
- added: emitOut.written.reduce((n, w) => n + w.cases.length, 0),
551
- removed: removed.length,
552
- };
553
- }
554
- }
555
- } catch (err) {
556
- report.error = (err as Error).message;
557
- }
558
- return report;
559
- }
560
-
561
- async function runDoctor(paths: string[], opts: { callsites?: string[]; json?: boolean }): Promise<void> {
562
- const targetPaths = paths.length > 0 ? paths : ["."];
563
- const externalRecords = opts.callsites?.length ? collectExternalRecords(opts.callsites) : undefined;
564
-
565
- // 目标展开:文件直接体检,目录递归收 .js(与 --callsites 同一规则);
566
- // 不存在的路径直接记为报错
567
- const files: string[] = [];
568
- const reports: DoctorReport[] = [];
569
- for (const p of targetPaths) {
570
- const abs = resolve(p);
571
- if (!existsSync(abs)) {
572
- reports.push({ file: displayPath(abs), functions: 0, entryOnly: 0, uncovered: [], error: `File not found: ${abs}` });
573
- continue;
574
- }
575
- files.push(...(statSync(abs).isDirectory() ? collectNudoFiles(abs) : [abs]));
576
- }
577
- for (const filePath of files) {
578
- reports.push(await doctorFile(filePath, externalRecords));
579
- }
580
-
581
- const driftCount = reports.filter((r) => r.drift).length;
582
- const errorCount = reports.filter((r) => r.error).length;
583
- const uncoveredTotal = reports.reduce((n, r) => n + r.uncovered.length, 0);
584
- const failed = driftCount > 0 || errorCount > 0;
585
-
586
- if (opts.json) {
587
- console.log(
588
- JSON.stringify(
589
- {
590
- // ok 与退出码一致:drift/报错 → false;uncovered 仅为信息级不影响
591
- ok: !failed,
592
- files: reports.map((r) => ({
593
- file: r.file,
594
- functions: r.functions,
595
- entryOnly: r.entryOnly,
596
- uncovered: r.uncovered,
597
- ...(r.drift ? { drift: r.drift } : {}),
598
- ...(r.error ? { error: r.error } : {}),
599
- })),
600
- summary: { files: reports.length, drift: driftCount, errors: errorCount, uncovered: uncoveredTotal },
601
- },
602
- null,
603
- 2,
604
- ),
605
- );
606
- } else {
607
- if (reports.length === 0) {
608
- console.log("No files to check.");
609
- return;
610
- }
611
- for (const r of reports) {
612
- console.log(`${r.file}`);
613
- if (r.error) {
614
- console.log(` ✗ error: ${r.error}`);
615
- continue;
616
- }
617
- const entryInfo = r.entryOnly > 0 ? `, ${r.entryOnly} entry-only` : "";
618
- console.log(` · ${r.functions} function(s)${entryInfo}`);
619
- if (r.uncovered.length > 0) {
620
- console.log(` ⚠ uncovered (no cases): ${r.uncovered.join(", ")}`);
621
- }
622
- if (r.drift) {
623
- const refresh = `nudo infer ${r.file} --callsites ${(opts.callsites ?? []).join(" ")} --emit-cases=update`;
624
- console.log(
625
- ` ✗ drift: ${r.drift.added + r.drift.removed} directive(s) changed (+${r.drift.added} new, -${r.drift.removed} removed) — refresh with: ${refresh.trim()}`,
626
- );
627
- }
628
- }
629
- console.log(
630
- `\nSummary: ${reports.length} file(s) · ${driftCount} drift · ${errorCount} error(s) · ${uncoveredTotal} uncovered function(s)`,
631
- );
632
- console.log(failed ? "Result: FAIL (drift or errors found)" : "Result: OK (uncovered function(s) are informational only)");
633
- }
634
-
635
- if (failed) process.exitCode = 1;
636
- }
637
-
638
- program
639
- .command("doctor")
640
- .description("Health-check JS files: functions without cases, call-site solidification drift (--callsites), analysis errors — exits 1 on drift/errors")
641
- .argument("[paths...]", "File(s) or directory(s) to check (default: current directory)")
642
- .option("--callsites <paths...>", "Usage-site files (tests/apps): re-solidify per current call shapes and report drift when directives would change")
643
- .option("--json", "Output as JSON")
644
- .action(async (paths: string[], opts: { callsites?: string[]; json?: boolean }) => {
645
- await runDoctor(paths, opts);
646
- });
647
-
648
- program
649
- .command("watch")
650
- .description("Watch file(s) for changes and re-run inference")
651
- .argument("<path>", "File or directory to watch")
652
- .option("--dts", "Generate .d.ts files on each change")
653
- .action((watchPath: string, opts: { dts?: boolean }) => {
654
- const resolved = resolve(watchPath);
655
- const isDir = existsSync(resolved) && statSync(resolved).isDirectory();
656
-
657
- const getFiles = (): string[] => {
658
- if (!isDir) return [resolved];
659
- return collectNudoFiles(resolved);
660
- };
661
-
662
- let graph = buildModuleGraph(getFiles());
663
-
664
- const runAll = async () => {
665
- console.clear();
666
- console.log(`[${new Date().toLocaleTimeString()}] Analyzing...\n`);
667
- for (const f of getFiles()) {
668
- try {
669
- await runInfer(f, { dts: opts.dts, showLoc: true });
670
- } catch (err) {
671
- console.error(`Error analyzing ${relative(process.cwd(), f)}:`, (err as Error).message);
672
- }
673
- }
674
- graph = buildModuleGraph(getFiles());
675
- console.log(`[${new Date().toLocaleTimeString()}] Watching for changes...`);
676
- };
677
-
678
- const runIncremental = async (changedFiles: string[]) => {
679
- const files = getFiles();
680
- const tracked = new Set(files);
681
-
682
- // 合并多文件变更的脏集(union):每个变更文件的脏集 = 自身 + 传递依赖方
683
- const dirtyUnion = new Set<string>();
684
- for (const cf of changedFiles) {
685
- for (const d of computeDirtySet(graph.dependents, cf)) dirtyUnion.add(d);
686
- }
687
- const dirty = [...dirtyUnion].filter((f) => tracked.has(f));
688
- if (dirty.length === 0) return;
689
-
690
- // 依赖先析:moduleCache 中未重析依赖的类型可被复用
691
- const ordered = topoSortDirty(graph.imports, dirty);
692
-
693
- console.clear();
694
- console.log(`[${new Date().toLocaleTimeString()}] Analyzing (incremental)...\n`);
695
- const t0 = performance.now();
696
- // 缓存失效:callMemo 按名键陈旧 + moduleCache 嵌入旧类型;
697
- // 入口 source 未变但 dep 变了时,B-path / AnalysisResult / fn-cache
698
- // 键不含 dep 指纹——必须按脏集入口文件定向逐出,否则命中陈旧结果。
699
- resetMemo();
700
- evictAnalysisCachesForFiles(ordered);
701
- for (const f of ordered) {
702
- try {
703
- await runInfer(f, { dts: opts.dts, showLoc: true });
704
- } catch (err) {
705
- console.error(`Error analyzing ${relative(process.cwd(), f)}:`, (err as Error).message);
706
- }
707
- }
708
- const elapsed = Math.round(performance.now() - t0);
709
- console.log(`Incremental: re-analyzed ${ordered.length}, skipped ${files.length - ordered.length} (${elapsed}ms)`);
710
- console.log(`[${new Date().toLocaleTimeString()}] Watching for changes...`);
711
-
712
- // 重建全图:文件数少时开销可忽略,选简单路线
713
- graph = buildModuleGraph(getFiles());
714
- };
715
-
716
- runAll().catch(() => { /* per-file errors already reported */ });
717
-
718
- let debounceTimer: ReturnType<typeof setTimeout> | null = null;
719
- const watchTarget = isDir ? resolved : dirname(resolved);
720
-
721
- const pendingChanged = new Set<string>();
722
-
723
- watch(watchTarget, { recursive: isDir }, (_event, filename) => {
724
- if (!filename) return;
725
- const fullPath = isDir ? join(watchTarget, filename) : resolved;
726
- if (!isNudoTargetPath(fullPath)) return; // .js/.mjs/.ts(排除 .d.ts/.tsx)
727
- if (!existsSync(fullPath)) return; // deleted
728
-
729
- pendingChanged.add(fullPath);
730
-
731
- if (debounceTimer) clearTimeout(debounceTimer);
732
- debounceTimer = setTimeout(() => {
733
- const changedFiles = [...pendingChanged];
734
- pendingChanged.clear();
735
- runIncremental(changedFiles).catch(() => { /* per-file errors already reported */ });
736
- }, 200);
737
- });
738
- });
739
-
740
- function collectNudoFiles(dir: string): string[] {
741
- const results: string[] = [];
742
- for (const entry of readdirSync(dir, { withFileTypes: true })) {
743
- const fullPath = join(dir, entry.name);
744
- if (entry.isDirectory() && entry.name !== "node_modules") {
745
- results.push(...collectNudoFiles(fullPath));
746
- } else if (entry.isFile() && isNudoTargetPath(fullPath)) {
747
- // 全程序推断:无指令的纯 JS/TS 文件也能推导类型;.d.ts(类型声明)与
748
- // .tsx(JSX)不是推断目标,由 isNudoTargetPath 排除
749
- results.push(fullPath);
750
- }
751
- }
752
- return results;
753
- }
754
-
755
- // ---------------------------------------------------------------------------
756
- // generate 命令的 FunctionAnalysis 组装件:dts 分支复用 service 的
757
- // generateFunctionDtsLines(与 infer --dts / service generateDts 同一实现),
758
- // loc 与参数名提取与 analyzer 的 locFromNode/extractParamNames 保持同构,
759
- // 保证两条路径对同一文件输出一致
760
- // ---------------------------------------------------------------------------
761
-
762
- type DirectiveFnNode = ReturnType<typeof extractDirectives>[number]["node"];
763
-
764
- function nodeLoc(node: DirectiveFnNode): FunctionAnalysis["loc"] {
765
- return {
766
- start: { line: node.loc?.start.line ?? 1, column: node.loc?.start.column ?? 0 },
767
- end: { line: node.loc?.end.line ?? 1, column: node.loc?.end.column ?? 0 },
768
- };
769
- }
770
-
771
- function fnParamNames(node: DirectiveFnNode): string[] {
772
- const fn = node.type === "ExportDefaultDeclaration" ? node.declaration : node;
773
- const paramListOf = (params: readonly any[]): string[] =>
774
- params.map((p: any) => {
775
- if (p.type === "Identifier") return p.name;
776
- if (p.type === "AssignmentPattern" && p.left.type === "Identifier") return p.left.name;
777
- if (p.type === "RestElement" && p.argument.type === "Identifier") return `...${p.argument.name}`;
778
- return "_";
779
- });
780
- if (fn.type === "FunctionDeclaration" || fn.type === "FunctionExpression" || fn.type === "ArrowFunctionExpression") {
781
- return paramListOf(fn.params);
782
- }
783
- if (fn.type === "VariableDeclaration") {
784
- const init = fn.declarations[0].init;
785
- if (init?.type === "FunctionExpression" || init?.type === "ArrowFunctionExpression") {
786
- return paramListOf(init.params);
787
- }
788
- }
789
- return [];
790
- }
791
-
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
- );
805
-
806
- if (functions.length === 0) {
807
- console.log("No functions with @nudo:case directives found.");
808
- return;
809
- }
810
-
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}`);
827
- }
828
- zodChunks.push(lines.join("\n"));
829
- }
830
-
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));
840
- }
841
- }
842
- guardChunks.push(lines.join("\n"));
843
- }
844
-
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;
893
- }
894
- await runGenerate(file, format as "zod" | "guard" | "dts" | "all", options.output);
895
- });
896
-
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);
915
- });
916
-
917
- // ---------------------------------------------------------------------------
918
- // harvest — convert @types/<pkg> .d.ts into a Nudo env module
919
- // ---------------------------------------------------------------------------
920
-
921
- const HARVEST_MAX_FILES = 200;
922
-
923
- function runHarvest(pkg: string, outOpt?: string): void {
924
- const typesDir = resolve(process.cwd(), "node_modules", "@types", pkg);
925
- if (!existsSync(typesDir)) {
926
- console.error(`Error: ${relative(process.cwd(), typesDir)} not found.`);
927
- console.error(`Install the package first, e.g. pnpm add -D @types/${pkg}`);
928
- process.exitCode = 1;
929
- return;
930
- }
931
-
932
- let entry = join(typesDir, "index.d.ts");
933
- const pkgJsonPath = join(typesDir, "package.json");
934
- if (existsSync(pkgJsonPath)) {
935
- try {
936
- const pkgJson = JSON.parse(readFileSync(pkgJsonPath, "utf-8")) as { types?: string; typings?: string };
937
- const declared = pkgJson.types ?? pkgJson.typings;
938
- if (typeof declared === "string") {
939
- const candidate = resolve(typesDir, declared);
940
- if (existsSync(candidate)) entry = candidate;
941
- }
942
- } catch {
943
- // malformed package.json — fall back to index.d.ts
944
- }
945
- }
946
- if (!existsSync(entry)) {
947
- console.error(`Error: no .d.ts entry found in ${relative(process.cwd(), typesDir)} (tried ${basename(entry)}).`);
948
- process.exitCode = 1;
949
- return;
950
- }
951
-
952
- // 单一收集实现(service):入口 BFS + reference/相对 import 图
953
- const files = collectDtsFromEntry(entry, HARVEST_MAX_FILES);
954
- if (files.length === 0) {
955
- console.error(`Error: no .d.ts files collected from ${relative(process.cwd(), entry)}.`);
956
- process.exitCode = 1;
957
- return;
958
- }
959
-
960
- const env = harvestDts(files);
961
- const code = emitEnvModule(env, `@types/${pkg}`);
962
-
963
- const out = resolve(outOpt ?? `nudo-harvest-${pkg}.ts`);
964
- writeFileSync(out, code, "utf-8");
965
-
966
- console.log(`Harvested @types/${pkg} → ${relative(process.cwd(), out) || out}`);
967
- console.log(` files: ${env.stats.files}`);
968
- console.log(` symbols: ${env.stats.symbols}`);
969
- console.log(` skipped: ${env.stats.skipped}`);
970
- console.log(`\nUsage — add this directive at the top of your JS file:`);
971
- const outDir = dirname(out);
972
- const hintPath =
973
- resolve(process.cwd()) === outDir ? basename(out) : out;
974
- console.log(` /// @nudo:env ${hintPath}`);
975
- }
976
-
977
- program
978
- .command("harvest")
979
- .description("Convert @types/<pkg> .d.ts declarations into a Nudo env file (TS source using T.* constructors)")
980
- .argument("[pkg]", "Package name under @types (e.g. node)")
981
- .option("--out <file>", "Output .ts env file (default: ./nudo-harvest-<pkg>.ts)")
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
- }
1028
- runHarvest(pkg, opts.out);
1029
- });
1030
-
1031
- program.parseAsync(process.argv).catch((err: unknown) => {
1032
- console.error(err instanceof Error ? (process.env.NUDO_DEBUG ? err.stack : err.message) : err);
1033
- process.exitCode = 1;
1034
- });