@nudojs/cli 0.1.0 → 0.2.1

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