@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.
Files changed (58) hide show
  1. package/package.json +12 -7
  2. package/src/abs-route.ts +273 -0
  3. package/src/builtins/builtin-prototype.ts +279 -0
  4. package/src/builtins/builtin-static.ts +260 -0
  5. package/src/eval-binary.ts +113 -0
  6. package/src/evaluator-api.ts +3 -0
  7. package/src/evaluator.ts +174 -633
  8. package/src/index.ts +373 -270
  9. package/src/narrowing.ts +7 -0
  10. package/src/phi-from-test.ts +42 -0
  11. package/src/run-test.ts +92 -0
  12. package/src/term-registry.ts +96 -0
  13. package/CHANGELOG.md +0 -41
  14. package/dist/chunk-3HCPXPEW.js +0 -5286
  15. package/dist/evaluator-api.d.ts +0 -151
  16. package/dist/evaluator-api.js +0 -214
  17. package/dist/index.d.ts +0 -2
  18. package/dist/index.js +0 -4035
  19. package/examples/real-world.js +0 -66
  20. package/examples/validate-real-world.ts +0 -160
  21. package/src/__tests__/builtin-advanced.test.ts +0 -292
  22. package/src/__tests__/builtin-api.test.ts +0 -308
  23. package/src/__tests__/builtin-functions.test.ts +0 -313
  24. package/src/__tests__/builtin-map.test.ts +0 -129
  25. package/src/__tests__/builtin-promise.test.ts +0 -150
  26. package/src/__tests__/builtin-regexp.test.ts +0 -133
  27. package/src/__tests__/builtin-set.test.ts +0 -163
  28. package/src/__tests__/builtin-url.test.ts +0 -256
  29. package/src/__tests__/builtin-web.test.ts +0 -340
  30. package/src/__tests__/combination-scenarios.test.ts +0 -384
  31. package/src/__tests__/edge-cases.test.ts +0 -749
  32. package/src/__tests__/evaluator-loops.test.ts +0 -283
  33. package/src/__tests__/evaluator-nullish-coalescing.test.ts +0 -86
  34. package/src/__tests__/evaluator-optional-chaining.test.ts +0 -37
  35. package/src/__tests__/evaluator-phase2.test.ts +0 -564
  36. package/src/__tests__/evaluator-phase3.test.ts +0 -864
  37. package/src/__tests__/evaluator-refined.test.ts +0 -121
  38. package/src/__tests__/evaluator-string-methods.test.ts +0 -151
  39. package/src/__tests__/evaluator.test.ts +0 -943
  40. package/src/__tests__/mock-arrow-function.test.ts +0 -169
  41. package/src/__tests__/mock-comparison.test.ts +0 -221
  42. package/src/__tests__/mock-comprehensive.test.ts +0 -250
  43. package/src/__tests__/mock-final-integration.test.ts +0 -263
  44. package/src/__tests__/mock-helpers.test.ts +0 -174
  45. package/src/__tests__/mock-realworld.test.ts +0 -191
  46. package/src/__tests__/mock-rest-debug.test.ts +0 -110
  47. package/src/__tests__/mock-rest-edge.test.ts +0 -176
  48. package/src/__tests__/mock-sinon-runtime.test.ts +0 -168
  49. package/src/__tests__/narrowing-discriminated.test.ts +0 -35
  50. package/src/__tests__/narrowing-in-operator.test.ts +0 -56
  51. package/src/__tests__/narrowing-switch.test.ts +0 -56
  52. package/src/__tests__/narrowing-truthiness.test.ts +0 -52
  53. package/src/__tests__/narrowing.test.ts +0 -82
  54. package/src/__tests__/nudo-mock-syntax.test.ts +0 -245
  55. package/src/__tests__/realworld-patterns.test.ts +0 -430
  56. package/src/__tests__/syntax-sugar.test.ts +0 -345
  57. package/src/builtins/index.ts +0 -61
  58. 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
- 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";
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 { resolveNpmNudo } from "./resolve-npm.ts";
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
- 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));
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
- async function runCheck(file: string): Promise<void> {
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
- if (result.diagnostics.length === 0) {
427
- console.log("No issues found.");
428
- return;
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
- 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}`);
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
- if (result.diagnostics.some((d) => d.severity === "error")) {
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
- await runCheck(file);
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.endsWith(".js")) return;
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() && entry.name.endsWith(".js")) {
694
- // 全程序推断:无指令的纯 JS 文件也能推导类型,无需 @nudo: 过滤
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
- 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
- });
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
- const baseName = fn.name;
806
+ if (functions.length === 0) {
807
+ console.log("No functions with @nudo:case directives found.");
808
+ return;
809
+ }
780
810
 
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
- }
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
- 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);
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
- 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
- }
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
- setModuleResolver(null);
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
- const files = collectDtsFiles(entry);
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("<pkg>", "Package name under @types (e.g. node)")
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
- .action((pkg: string, opts: { out?: string }) => {
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