@nudojs/cli 0.2.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 (60) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +34 -0
  3. package/dist/chunk-3HCPXPEW.js +5286 -0
  4. package/dist/evaluator-api.d.ts +94 -2
  5. package/dist/evaluator-api.js +187 -3
  6. package/dist/index.js +595 -130
  7. package/examples/real-world.js +66 -0
  8. package/examples/validate-real-world.ts +160 -0
  9. package/package.json +17 -2
  10. package/src/__tests__/builtin-advanced.test.ts +292 -0
  11. package/src/__tests__/builtin-api.test.ts +308 -0
  12. package/src/__tests__/builtin-functions.test.ts +313 -0
  13. package/src/__tests__/builtin-map.test.ts +129 -0
  14. package/src/__tests__/builtin-promise.test.ts +150 -0
  15. package/src/__tests__/builtin-regexp.test.ts +133 -0
  16. package/src/__tests__/builtin-set.test.ts +163 -0
  17. package/src/__tests__/builtin-url.test.ts +256 -0
  18. package/src/__tests__/builtin-web.test.ts +340 -0
  19. package/src/__tests__/combination-scenarios.test.ts +384 -0
  20. package/src/__tests__/edge-cases.test.ts +749 -0
  21. package/src/__tests__/evaluator-loops.test.ts +120 -0
  22. package/src/__tests__/evaluator-nullish-coalescing.test.ts +86 -0
  23. package/src/__tests__/evaluator-optional-chaining.test.ts +37 -0
  24. package/src/__tests__/evaluator-phase3.test.ts +353 -2
  25. package/src/__tests__/evaluator.test.ts +702 -2
  26. package/src/__tests__/mock-arrow-function.test.ts +169 -0
  27. package/src/__tests__/mock-comparison.test.ts +221 -0
  28. package/src/__tests__/mock-comprehensive.test.ts +250 -0
  29. package/src/__tests__/mock-final-integration.test.ts +263 -0
  30. package/src/__tests__/mock-helpers.test.ts +174 -0
  31. package/src/__tests__/mock-realworld.test.ts +191 -0
  32. package/src/__tests__/mock-rest-debug.test.ts +110 -0
  33. package/src/__tests__/mock-rest-edge.test.ts +176 -0
  34. package/src/__tests__/mock-sinon-runtime.test.ts +168 -0
  35. package/src/__tests__/narrowing-discriminated.test.ts +35 -0
  36. package/src/__tests__/narrowing-in-operator.test.ts +56 -0
  37. package/src/__tests__/narrowing-switch.test.ts +56 -0
  38. package/src/__tests__/narrowing-truthiness.test.ts +52 -0
  39. package/src/__tests__/nudo-mock-syntax.test.ts +245 -0
  40. package/src/__tests__/realworld-patterns.test.ts +430 -0
  41. package/src/__tests__/syntax-sugar.test.ts +345 -0
  42. package/src/builtins/builtin-intl.ts +17 -0
  43. package/src/builtins/builtin-map.ts +174 -0
  44. package/src/builtins/builtin-promise.ts +116 -0
  45. package/src/builtins/builtin-reflect.ts +30 -0
  46. package/src/builtins/builtin-regexp.ts +34 -0
  47. package/src/builtins/builtin-set.ts +131 -0
  48. package/src/builtins/builtin-symbol.ts +16 -0
  49. package/src/builtins/builtin-url.ts +58 -0
  50. package/src/builtins/builtin-weak.ts +22 -0
  51. package/src/builtins/builtin-web.ts +111 -0
  52. package/src/builtins/index.ts +61 -0
  53. package/src/config.ts +32 -0
  54. package/src/env-loader.ts +174 -0
  55. package/src/evaluator-api.ts +20 -0
  56. package/src/evaluator.ts +4018 -342
  57. package/src/index.ts +776 -137
  58. package/src/narrowing.ts +230 -15
  59. package/src/resolve-npm.ts +63 -0
  60. package/dist/chunk-LGWVGEMB.js +0 -1817
package/src/index.ts CHANGED
@@ -1,18 +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,
10
- isTemplate,
11
- getTemplateParts,
8
+ mockHelperToTypeValue,
12
9
  } from "@nudojs/core";
13
10
  import type { TypeValue } from "@nudojs/core";
14
11
  import { parse, extractDirectives, parseTypeValueExpr } from "@nudojs/parser";
15
- 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";
16
33
 
17
34
  const program = new Command();
18
35
 
@@ -28,7 +45,20 @@ function applyMocks(
28
45
  ): void {
29
46
  for (const d of directives) {
30
47
  if (d.kind !== "mock") continue;
31
- 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) {
32
62
  env.bind(d.name, parseTypeValueExpr(d.expression));
33
63
  } else if (d.fromPath) {
34
64
  const mockPath = resolve(dirname(filePath), d.fromPath);
@@ -42,13 +72,45 @@ function applyMocks(
42
72
  }
43
73
  }
44
74
 
45
- 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 {
46
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
+
47
101
  const basePath = resolve(fromDir, source);
48
102
 
49
103
  for (const ext of ["", ...extensions]) {
50
104
  const candidate = basePath + ext;
51
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
+ }
52
114
  const src = readFileSync(candidate, "utf-8");
53
115
  return { ast: parse(src), filePath: candidate };
54
116
  }
@@ -56,92 +118,92 @@ function resolveModule(source: string, fromDir: string): { ast: ReturnType<typeo
56
118
  return null;
57
119
  }
58
120
 
59
- function typeValueToTSType(tv: TypeValue): string {
60
- switch (tv.kind) {
61
- case "literal": {
62
- const v = tv.value;
63
- if (v === null) return "null";
64
- if (v === undefined) return "undefined";
65
- if (typeof v === "string") return JSON.stringify(v);
66
- return String(v);
67
- }
68
- case "primitive":
69
- return tv.type;
70
- case "refined": {
71
- if (isTemplate(tv)) {
72
- const parts = getTemplateParts(tv)!;
73
- const inner = parts
74
- .map((p) => (p.kind === "literal" && typeof p.value === "string" ? p.value : `\${${typeValueToTSType(p)}}`))
75
- .join("");
76
- return `\`${inner}\``;
77
- }
78
- return typeValueToTSType(tv.base);
79
- }
80
- case "object": {
81
- const entries = Object.entries(tv.properties);
82
- if (entries.length === 0) return "{}";
83
- const inner = entries.map(([k, v]) => `${k}: ${typeValueToTSType(v)}`).join("; ");
84
- 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;
85
155
  }
86
- case "array": {
87
- const el = typeValueToTSType(tv.element);
88
- 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")));
89
159
  }
90
- case "tuple":
91
- return `[${tv.elements.map(typeValueToTSType).join(", ")}]`;
92
- case "function":
93
- return `(${tv.params.map((p) => `${p}: unknown`).join(", ")}) => unknown`;
94
- case "promise":
95
- return `Promise<${typeValueToTSType(tv.value)}>`;
96
- case "instance":
97
- return tv.className;
98
- case "union":
99
- return tv.members.map(typeValueToTSType).join(" | ");
100
- case "never":
101
- return "never";
102
- case "unknown":
103
- return "unknown";
104
160
  }
161
+ return records.length > 0 ? records : undefined;
105
162
  }
106
163
 
107
- 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> {
108
168
  const filePath = resolve(file);
109
169
  const source = readFileSync(filePath, "utf-8");
110
- const ast = parse(source);
111
- 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
+ }
112
184
 
113
- if (functions.length === 0) {
185
+ if (result.functions.length === 0 && !(result.externalFunctions?.length)) {
114
186
  console.log("No functions with @nudo:case directives found.");
115
187
  return;
116
188
  }
117
189
 
118
- resetMemo();
119
- setModuleResolver(resolveModule);
120
- setCurrentFileDir(dirname(filePath));
121
-
122
- const globalEnv = createEnvironment();
123
- evaluateProgram(ast, globalEnv);
124
-
125
190
  const dtsLines: string[] = [];
126
191
 
127
- for (const fn of functions) {
128
- const loc = fn.node.loc;
129
- 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}`;
130
195
  const header = options.showLoc ? `=== ${fn.name} (${locStr}) ===` : `=== ${fn.name} ===`;
131
196
  console.log(`${header}\n`);
132
197
 
133
- applyMocks(fn.directives, globalEnv, filePath);
134
-
135
- const isPure = fn.directives.some((d) => d.kind === "pure");
136
- const skipDirective = fn.directives.find((d) => d.kind === "skip");
137
- 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
+ }
138
203
 
139
- if (skipDirective && skipDirective.kind === "skip") {
140
- if (skipDirective.returns) {
141
- console.log(`Skipped (declared): ${typeValueToString(skipDirective.returns)}`);
142
- if (options.dts) {
143
- dtsLines.push(`export declare function ${fn.name}(...args: unknown[]): ${typeValueToTSType(skipDirective.returns)};`);
144
- }
204
+ if (fn.skipped) {
205
+ if (fn.combined) {
206
+ console.log(`Skipped (declared): ${typeValueToString(fn.combined)}`);
145
207
  } else {
146
208
  console.log("Skipped (no return type declared)");
147
209
  }
@@ -149,66 +211,58 @@ function runInfer(file: string, options: { dts?: boolean; showLoc?: boolean } =
149
211
  continue;
150
212
  }
151
213
 
152
- if (isPure) {
153
- const fnVal = globalEnv.has(fn.name) ? globalEnv.lookup(fn.name) : null;
154
- if (fnVal && fnVal.kind === "function") {
155
- (fnVal as any)._memoize = fn.name;
156
- }
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);
157
219
  }
158
220
 
159
- const caseDirectives = fn.directives.filter((d) => d.kind === "case");
160
- const caseResults: { name: string; args: TypeValue[]; argsStr: string; result: string; resultTV: TypeValue; throws?: string }[] = [];
161
-
162
- for (const directive of caseDirectives) {
163
- const fullResult = evaluateFunctionFull(fn.node, directive.args, globalEnv);
164
- const argsStr = directive.args.map(typeValueToString).join(", ");
165
- const resultStr = typeValueToString(fullResult.value);
166
- const throwsStr = fullResult.throws.kind !== "never" ? typeValueToString(fullResult.throws) : undefined;
167
- caseResults.push({
168
- name: directive.name,
169
- args: directive.args,
170
- argsStr,
171
- result: resultStr,
172
- resultTV: fullResult.value,
173
- throws: throwsStr,
174
- });
175
- let line = `Case "${directive.name}": (${argsStr}) => ${resultStr}`;
176
- if (throwsStr) line += ` throws ${throwsStr}`;
177
- console.log(line);
221
+ if (fn.entryOnly) {
222
+ console.log("# no call sites found; parameters default to unknown");
178
223
  }
179
224
 
180
- if (caseResults.length > 1) {
181
- const allResults = caseDirectives.map((d) =>
182
- evaluateFunctionFull(fn.node, d.args, globalEnv),
183
- );
184
- const combined = simplifyUnion(allResults.map((r) => r.value));
185
- console.log(`\nCombined: ${typeValueToString(combined)}`);
225
+ if (fn.cases.length > 1 && fn.combined) {
226
+ console.log(`\nCombined: ${typeValueToString(fn.combined)}`);
186
227
  }
187
228
 
188
- if (returnsDirective && returnsDirective.kind === "returns") {
189
- for (const directive of caseDirectives) {
190
- const result = evaluateFunction(fn.node, directive.args, globalEnv);
191
- const matches = isSubtypeOf(result, returnsDirective.expected);
192
- if (!matches) {
193
- console.log(`\n⚠ @nudo:returns assertion failed for case "${directive.name}": expected ${typeValueToString(returnsDirective.expected)}, got ${typeValueToString(result)}`);
194
- }
229
+ if (fn.assertionErrors && fn.assertionErrors.length > 0) {
230
+ for (const err of fn.assertionErrors) {
231
+ console.log(`\n⚠ ${err}`);
195
232
  }
196
233
  }
197
234
 
198
- if (options.dts) {
199
- if (caseResults.length === 1) {
200
- const c = caseResults[0];
201
- const params = c.args.map((a, i) => `arg${i}: ${typeValueToTSType(a)}`).join(", ");
202
- dtsLines.push(`export declare function ${fn.name}(${params}): ${typeValueToTSType(c.resultTV)};`);
203
- } else {
204
- for (const c of caseResults) {
205
- const params = c.args.map((a, i) => `arg${i}: ${typeValueToTSType(a)}`).join(", ");
206
- 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);
259
+ }
260
+ if (fn.cases.length > 1 && fn.combined) {
261
+ console.log(`\nCombined: ${typeValueToString(fn.combined)}`);
207
262
  }
263
+ console.log();
208
264
  }
209
265
  }
210
-
211
- console.log();
212
266
  }
213
267
 
214
268
  if (options.dts && dtsLines.length > 0) {
@@ -218,17 +272,327 @@ function runInfer(file: string, options: { dts?: boolean; showLoc?: boolean } =
218
272
  console.log(`Generated: ${relative(process.cwd(), dtsPath)}`);
219
273
  }
220
274
 
221
- 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));
222
361
  }
223
362
 
224
363
  program
225
364
  .command("infer")
226
- .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)")
227
366
  .argument("<file>", "Path to the JS file")
228
367
  .option("--dts", "Generate .d.ts file")
229
368
  .option("--loc", "Show source locations in output")
230
- .action((file: string, opts: { dts?: boolean; loc?: boolean }) => {
231
- 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);
232
596
  });
233
597
 
234
598
  program
@@ -245,31 +609,78 @@ program
245
609
  return collectNudoFiles(resolved);
246
610
  };
247
611
 
248
- const runAll = () => {
612
+ let graph = buildModuleGraph(getFiles());
613
+
614
+ const runAll = async () => {
249
615
  console.clear();
250
616
  console.log(`[${new Date().toLocaleTimeString()}] Analyzing...\n`);
251
617
  for (const f of getFiles()) {
252
618
  try {
253
- runInfer(f, { dts: opts.dts, showLoc: true });
619
+ await runInfer(f, { dts: opts.dts, showLoc: true });
620
+ } catch (err) {
621
+ console.error(`Error analyzing ${relative(process.cwd(), f)}:`, (err as Error).message);
622
+ }
623
+ }
624
+ graph = buildModuleGraph(getFiles());
625
+ console.log(`[${new Date().toLocaleTimeString()}] Watching for changes...`);
626
+ };
627
+
628
+ const runIncremental = async (changedFiles: string[]) => {
629
+ const files = getFiles();
630
+ const tracked = new Set(files);
631
+
632
+ // 合并多文件变更的脏集(union):每个变更文件的脏集 = 自身 + 传递依赖方
633
+ const dirtyUnion = new Set<string>();
634
+ for (const cf of changedFiles) {
635
+ for (const d of computeDirtySet(graph.dependents, cf)) dirtyUnion.add(d);
636
+ }
637
+ const dirty = [...dirtyUnion].filter((f) => tracked.has(f));
638
+ if (dirty.length === 0) return;
639
+
640
+ // 依赖先析:moduleCache 中未重析依赖的类型可被复用
641
+ const ordered = topoSortDirty(graph.imports, dirty);
642
+
643
+ console.clear();
644
+ console.log(`[${new Date().toLocaleTimeString()}] Analyzing (incremental)...\n`);
645
+ const t0 = performance.now();
646
+ // 缓存失效:callMemo 按名键陈旧 + moduleCache 嵌入旧类型
647
+ resetMemo();
648
+ for (const f of ordered) {
649
+ try {
650
+ await runInfer(f, { dts: opts.dts, showLoc: true });
254
651
  } catch (err) {
255
652
  console.error(`Error analyzing ${relative(process.cwd(), f)}:`, (err as Error).message);
256
653
  }
257
654
  }
655
+ const elapsed = Math.round(performance.now() - t0);
656
+ console.log(`Incremental: re-analyzed ${ordered.length}, skipped ${files.length - ordered.length} (${elapsed}ms)`);
258
657
  console.log(`[${new Date().toLocaleTimeString()}] Watching for changes...`);
658
+
659
+ // 重建全图:文件数少时开销可忽略,选简单路线
660
+ graph = buildModuleGraph(getFiles());
259
661
  };
260
662
 
261
- runAll();
663
+ runAll().catch(() => { /* per-file errors already reported */ });
262
664
 
263
665
  let debounceTimer: ReturnType<typeof setTimeout> | null = null;
264
666
  const watchTarget = isDir ? resolved : dirname(resolved);
265
667
 
668
+ const pendingChanged = new Set<string>();
669
+
266
670
  watch(watchTarget, { recursive: isDir }, (_event, filename) => {
267
671
  if (!filename) return;
268
672
  const fullPath = isDir ? join(watchTarget, filename) : resolved;
269
673
  if (!fullPath.endsWith(".js")) return;
674
+ if (!existsSync(fullPath)) return; // deleted
675
+
676
+ pendingChanged.add(fullPath);
270
677
 
271
678
  if (debounceTimer) clearTimeout(debounceTimer);
272
- 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);
273
684
  });
274
685
  });
275
686
 
@@ -280,13 +691,241 @@ function collectNudoFiles(dir: string): string[] {
280
691
  if (entry.isDirectory() && entry.name !== "node_modules") {
281
692
  results.push(...collectNudoFiles(fullPath));
282
693
  } else if (entry.isFile() && entry.name.endsWith(".js")) {
283
- const content = readFileSync(fullPath, "utf-8");
284
- if (/@nudo:(case|mock|pure|skip|sample|returns)\b/.test(content)) {
285
- results.push(fullPath);
286
- }
694
+ // 全程序推断:无指令的纯 JS 文件也能推导类型,无需 @nudo: 过滤
695
+ results.push(fullPath);
287
696
  }
288
697
  }
289
698
  return results;
290
699
  }
291
700
 
292
- 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
+ });