@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/dist/index.js CHANGED
@@ -2,13 +2,13 @@ import {
2
2
  __commonJS,
3
3
  __require,
4
4
  __toESM,
5
- evaluateFunction,
6
5
  evaluateFunctionFull,
7
6
  evaluateProgram,
8
7
  resetMemo,
8
+ resolveNpmNudo,
9
9
  setCurrentFileDir,
10
10
  setModuleResolver
11
- } from "./chunk-JW3RMCNY.js";
11
+ } from "./chunk-3HCPXPEW.js";
12
12
 
13
13
  // ../../node_modules/.pnpm/commander@13.1.0/node_modules/commander/lib/error.js
14
14
  var require_error = __commonJS({
@@ -3312,7 +3312,7 @@ var require_commander = __commonJS({
3312
3312
 
3313
3313
  // src/index.ts
3314
3314
  import { readFileSync, existsSync, watch, readdirSync, statSync, writeFileSync } from "fs";
3315
- import { resolve, dirname, relative, join } from "path";
3315
+ import { resolve, dirname, relative, join, basename } from "path";
3316
3316
 
3317
3317
  // ../../node_modules/.pnpm/commander@13.1.0/node_modules/commander/esm.mjs
3318
3318
  var import_index = __toESM(require_commander(), 1);
@@ -3333,18 +3333,42 @@ var {
3333
3333
 
3334
3334
  // src/index.ts
3335
3335
  import {
3336
+ T,
3336
3337
  typeValueToString,
3337
- simplifyUnion,
3338
3338
  createEnvironment,
3339
- isSubtypeOf
3339
+ mockHelperToTypeValue
3340
3340
  } from "@nudojs/core";
3341
3341
  import { parse, extractDirectives, parseTypeValueExpr } from "@nudojs/parser";
3342
+ import {
3343
+ typeValueToZodSchema,
3344
+ generateGuardFunction,
3345
+ generateFunctionDtsLines,
3346
+ analyzeFileAsync,
3347
+ buildModuleGraph,
3348
+ computeDirtySet,
3349
+ topoSortDirty,
3350
+ collectCallRecords,
3351
+ stripGeneratedCaseDirectives,
3352
+ insertGeneratedCaseDirectives,
3353
+ unifiedDiff
3354
+ } from "@nudojs/service";
3355
+ import { harvestDts, emitEnvModule } from "@nudojs/harvester";
3342
3356
  var program2 = new Command();
3343
3357
  program2.name("nudo").description("Nudo type inference engine").version("0.0.1");
3344
3358
  function applyMocks(directives, env, filePath) {
3345
3359
  for (const d of directives) {
3346
3360
  if (d.kind !== "mock") continue;
3347
- if (d.expression) {
3361
+ if (d.arrowFn) {
3362
+ const fnType = T.fn(d.arrowFn.params, d.arrowFn.body, env);
3363
+ fnType._paramPatterns = d.arrowFn.paramPatterns;
3364
+ env.bind(d.name, fnType);
3365
+ } else if (d.nudoMock) {
3366
+ const typeVal = mockHelperToTypeValue(d.nudoMock, env);
3367
+ env.bind(d.name, typeVal);
3368
+ } else if (d.sinonExpr) {
3369
+ const sinonType = createSinonTypeValue(d.sinonExpr);
3370
+ env.bind(d.name, sinonType);
3371
+ } else if (d.expression) {
3348
3372
  env.bind(d.name, parseTypeValueExpr(d.expression));
3349
3373
  } else if (d.fromPath) {
3350
3374
  const mockPath = resolve(dirname(filePath), d.fromPath);
@@ -3357,148 +3381,152 @@ function applyMocks(directives, env, filePath) {
3357
3381
  }
3358
3382
  }
3359
3383
  }
3384
+ function createSinonTypeValue(sinonExpr) {
3385
+ const body = { type: "BlockStatement", body: [] };
3386
+ const fn = T.fn(["...args"], body, createEnvironment());
3387
+ if (sinonExpr.returnValue) {
3388
+ fn._directReturn = sinonExpr.returnValue;
3389
+ } else if (sinonExpr.resolvedValue) {
3390
+ fn._directReturn = T.promise(sinonExpr.resolvedValue);
3391
+ } else if (sinonExpr.rejectedValue) {
3392
+ fn._directReturn = T.never;
3393
+ } else {
3394
+ fn._directReturn = T.unknown;
3395
+ }
3396
+ return fn;
3397
+ }
3360
3398
  function resolveModule(source, fromDir) {
3361
3399
  const extensions = [".js", ".ts", ".mjs"];
3400
+ const nudoPath = resolveNpmNudo(source, fromDir);
3401
+ if (nudoPath) {
3402
+ const src = readFileSync(nudoPath, "utf-8");
3403
+ return { ast: parse(src), filePath: nudoPath };
3404
+ }
3362
3405
  const basePath = resolve(fromDir, source);
3363
3406
  for (const ext of ["", ...extensions]) {
3364
3407
  const candidate = basePath + ext;
3365
3408
  if (existsSync(candidate)) {
3409
+ if (candidate.endsWith(".json")) {
3410
+ try {
3411
+ return { ast: parse("module.exports = undefined;"), filePath: candidate, json: JSON.parse(readFileSync(candidate, "utf-8")) };
3412
+ } catch {
3413
+ return null;
3414
+ }
3415
+ }
3366
3416
  const src = readFileSync(candidate, "utf-8");
3367
3417
  return { ast: parse(src), filePath: candidate };
3368
3418
  }
3369
3419
  }
3370
3420
  return null;
3371
3421
  }
3372
- function typeValueToTSType(tv) {
3373
- switch (tv.kind) {
3374
- case "literal": {
3375
- const v = tv.value;
3376
- if (v === null) return "null";
3377
- if (v === void 0) return "undefined";
3378
- if (typeof v === "string") return JSON.stringify(v);
3379
- return String(v);
3380
- }
3381
- case "primitive":
3382
- return tv.type;
3383
- case "object": {
3384
- const entries = Object.entries(tv.properties);
3385
- if (entries.length === 0) return "{}";
3386
- const inner = entries.map(([k, v]) => `${k}: ${typeValueToTSType(v)}`).join("; ");
3387
- return `{ ${inner} }`;
3422
+ async function reemitUpdate(filePath, source, callsites) {
3423
+ const stripped = stripGeneratedCaseDirectives(source);
3424
+ const result = await analyzeFileAsync(filePath, stripped.source, void 0, callsites);
3425
+ const emitOut = insertGeneratedCaseDirectives(stripped.source, result);
3426
+ return { result, emitOut, removed: stripped.removed };
3427
+ }
3428
+ function collectExternalRecords(sites) {
3429
+ const records = [];
3430
+ for (const site of sites) {
3431
+ const sitePath = resolve(site);
3432
+ if (!existsSync(sitePath)) {
3433
+ console.error(`Callsite file not found: ${sitePath}`);
3434
+ process.exitCode = 1;
3435
+ continue;
3388
3436
  }
3389
- case "array": {
3390
- const el = typeValueToTSType(tv.element);
3391
- return tv.element.kind === "union" ? `(${el})[]` : `${el}[]`;
3437
+ const siteFiles = statSync(sitePath).isDirectory() ? collectNudoFiles(sitePath) : [sitePath];
3438
+ for (const sf of siteFiles) {
3439
+ records.push(...collectCallRecords(sf, readFileSync(sf, "utf-8")));
3392
3440
  }
3393
- case "tuple":
3394
- return `[${tv.elements.map(typeValueToTSType).join(", ")}]`;
3395
- case "function":
3396
- return `(${tv.params.map((p) => `${p}: unknown`).join(", ")}) => unknown`;
3397
- case "promise":
3398
- return `Promise<${typeValueToTSType(tv.value)}>`;
3399
- case "instance":
3400
- return tv.className;
3401
- case "union":
3402
- return tv.members.map(typeValueToTSType).join(" | ");
3403
- case "never":
3404
- return "never";
3405
- case "unknown":
3406
- return "unknown";
3407
3441
  }
3442
+ return records.length > 0 ? records : void 0;
3408
3443
  }
3409
- function runInfer(file, options = {}) {
3444
+ async function runInfer(file, options = {}) {
3410
3445
  const filePath = resolve(file);
3411
3446
  const source = readFileSync(filePath, "utf-8");
3412
- const ast = parse(source);
3413
- const functions = extractDirectives(ast);
3414
- if (functions.length === 0) {
3447
+ let result = await analyzeFileAsync(filePath, source, void 0, options.callsites);
3448
+ let emitOut;
3449
+ if (options.emit) {
3450
+ if (options.emit.mode === "update") {
3451
+ const re = await reemitUpdate(filePath, source, options.callsites);
3452
+ result = re.result;
3453
+ emitOut = re.emitOut;
3454
+ } else {
3455
+ emitOut = insertGeneratedCaseDirectives(source, result);
3456
+ }
3457
+ }
3458
+ if (result.functions.length === 0 && !result.externalFunctions?.length) {
3415
3459
  console.log("No functions with @nudo:case directives found.");
3416
3460
  return;
3417
3461
  }
3418
- resetMemo();
3419
- setModuleResolver(resolveModule);
3420
- setCurrentFileDir(dirname(filePath));
3421
- const globalEnv = createEnvironment();
3422
- evaluateProgram(ast, globalEnv);
3423
3462
  const dtsLines = [];
3424
- for (const fn of functions) {
3425
- const loc = fn.node.loc;
3426
- const locStr = loc ? `${relative(process.cwd(), filePath)}:${loc.start.line}:${loc.start.column}` : relative(process.cwd(), filePath);
3463
+ for (const fn of result.functions) {
3464
+ const loc = fn.loc;
3465
+ const locStr = `${relative(process.cwd(), filePath)}:${loc.start.line}:${loc.start.column}`;
3427
3466
  const header = options.showLoc ? `=== ${fn.name} (${locStr}) ===` : `=== ${fn.name} ===`;
3428
3467
  console.log(`${header}
3429
3468
  `);
3430
- applyMocks(fn.directives, globalEnv, filePath);
3431
- const isPure = fn.directives.some((d) => d.kind === "pure");
3432
- const skipDirective = fn.directives.find((d) => d.kind === "skip");
3433
- const returnsDirective = fn.directives.find((d) => d.kind === "returns");
3434
- if (skipDirective && skipDirective.kind === "skip") {
3435
- if (skipDirective.returns) {
3436
- console.log(`Skipped (declared): ${typeValueToString(skipDirective.returns)}`);
3437
- if (options.dts) {
3438
- dtsLines.push(`export declare function ${fn.name}(...args: unknown[]): ${typeValueToTSType(skipDirective.returns)};`);
3439
- }
3469
+ if (options.dts) {
3470
+ dtsLines.push(...generateFunctionDtsLines(fn));
3471
+ }
3472
+ if (fn.skipped) {
3473
+ if (fn.combined) {
3474
+ console.log(`Skipped (declared): ${typeValueToString(fn.combined)}`);
3440
3475
  } else {
3441
3476
  console.log("Skipped (no return type declared)");
3442
3477
  }
3443
3478
  console.log();
3444
3479
  continue;
3445
3480
  }
3446
- if (isPure) {
3447
- const fnVal = globalEnv.has(fn.name) ? globalEnv.lookup(fn.name) : null;
3448
- if (fnVal && fnVal.kind === "function") {
3449
- fnVal._memoize = fn.name;
3450
- }
3451
- }
3452
- const caseDirectives = fn.directives.filter((d) => d.kind === "case");
3453
- const caseResults = [];
3454
- for (const directive of caseDirectives) {
3455
- const fullResult = evaluateFunctionFull(fn.node, directive.args, globalEnv);
3456
- const argsStr = directive.args.map(typeValueToString).join(", ");
3457
- const resultStr = typeValueToString(fullResult.value);
3458
- const throwsStr = fullResult.throws.kind !== "never" ? typeValueToString(fullResult.throws) : void 0;
3459
- caseResults.push({
3460
- name: directive.name,
3461
- args: directive.args,
3462
- argsStr,
3463
- result: resultStr,
3464
- resultTV: fullResult.value,
3465
- throws: throwsStr
3466
- });
3467
- let line = `Case "${directive.name}": (${argsStr}) => ${resultStr}`;
3468
- if (throwsStr) line += ` throws ${throwsStr}`;
3481
+ for (const c of fn.cases) {
3482
+ const argsStr = c.args.map(typeValueToString).join(", ");
3483
+ let line = `Case "${c.name}": (${argsStr}) => ${typeValueToString(c.result)}`;
3484
+ if (c.throws.kind !== "never") line += ` throws ${typeValueToString(c.throws)}`;
3469
3485
  console.log(line);
3470
3486
  }
3471
- if (caseResults.length > 1) {
3472
- const allResults = caseDirectives.map(
3473
- (d) => evaluateFunctionFull(fn.node, d.args, globalEnv)
3474
- );
3475
- const combined = simplifyUnion(allResults.map((r) => r.value));
3487
+ if (fn.entryOnly) {
3488
+ console.log("# no call sites found; parameters default to unknown");
3489
+ }
3490
+ if (fn.cases.length > 1 && fn.combined) {
3476
3491
  console.log(`
3477
- Combined: ${typeValueToString(combined)}`);
3492
+ Combined: ${typeValueToString(fn.combined)}`);
3478
3493
  }
3479
- if (returnsDirective && returnsDirective.kind === "returns") {
3480
- for (const directive of caseDirectives) {
3481
- const result = evaluateFunction(fn.node, directive.args, globalEnv);
3482
- const matches = isSubtypeOf(result, returnsDirective.expected);
3483
- if (!matches) {
3484
- console.log(`
3485
- \u26A0 @nudo:returns assertion failed for case "${directive.name}": expected ${typeValueToString(returnsDirective.expected)}, got ${typeValueToString(result)}`);
3486
- }
3494
+ if (fn.assertionErrors && fn.assertionErrors.length > 0) {
3495
+ for (const err of fn.assertionErrors) {
3496
+ console.log(`
3497
+ \u26A0 ${err}`);
3487
3498
  }
3488
3499
  }
3489
- if (options.dts) {
3490
- if (caseResults.length === 1) {
3491
- const c = caseResults[0];
3492
- const params = c.args.map((a, i) => `arg${i}: ${typeValueToTSType(a)}`).join(", ");
3493
- dtsLines.push(`export declare function ${fn.name}(${params}): ${typeValueToTSType(c.resultTV)};`);
3494
- } else {
3495
- for (const c of caseResults) {
3496
- const params = c.args.map((a, i) => `arg${i}: ${typeValueToTSType(a)}`).join(", ");
3497
- dtsLines.push(`export declare function ${fn.name}(${params}): ${typeValueToTSType(c.resultTV)};`);
3500
+ console.log();
3501
+ }
3502
+ if (result.externalFunctions && result.externalFunctions.length > 0) {
3503
+ const byModule = /* @__PURE__ */ new Map();
3504
+ for (const fn of result.externalFunctions) {
3505
+ const mod = fn.fromModule ?? "";
3506
+ const list = byModule.get(mod);
3507
+ if (list) list.push(fn);
3508
+ else byModule.set(mod, [fn]);
3509
+ }
3510
+ for (const [mod, fns] of byModule) {
3511
+ const rel = relative(process.cwd(), mod) || mod;
3512
+ console.log(`--- ${rel} (imported) ---
3513
+ `);
3514
+ for (const fn of fns) {
3515
+ console.log(`=== ${fn.name} ===
3516
+ `);
3517
+ for (const c of fn.cases) {
3518
+ const argsStr = c.args.map(typeValueToString).join(", ");
3519
+ let line = `Case "${c.name}": (${argsStr}) => ${typeValueToString(c.result)}`;
3520
+ if (c.throws.kind !== "never") line += ` throws ${typeValueToString(c.throws)}`;
3521
+ console.log(line);
3498
3522
  }
3523
+ if (fn.cases.length > 1 && fn.combined) {
3524
+ console.log(`
3525
+ Combined: ${typeValueToString(fn.combined)}`);
3526
+ }
3527
+ console.log();
3499
3528
  }
3500
3529
  }
3501
- console.log();
3502
3530
  }
3503
3531
  if (options.dts && dtsLines.length > 0) {
3504
3532
  const dtsPath = filePath.replace(/\.js$/, ".d.ts");
@@ -3506,10 +3534,246 @@ Combined: ${typeValueToString(combined)}`);
3506
3534
  writeFileSync(dtsPath, dtsContent, "utf-8");
3507
3535
  console.log(`Generated: ${relative(process.cwd(), dtsPath)}`);
3508
3536
  }
3509
- setModuleResolver(null);
3537
+ if (result.diagnostics.length > 0) {
3538
+ console.log("Diagnostics:\n");
3539
+ for (const d of result.diagnostics) {
3540
+ const loc = `${relative(process.cwd(), filePath)}:${d.range.start.line}:${d.range.start.column}`;
3541
+ console.log(` [${d.severity}] ${loc} ${d.message}${d.code ? ` (${d.code})` : ""}`);
3542
+ if (d.origin) {
3543
+ console.log(` \u2192 value originates at ${d.origin.line}:${d.origin.column}`);
3544
+ }
3545
+ }
3546
+ console.log();
3547
+ }
3548
+ if (options.emit && emitOut) {
3549
+ const relPath = relative(process.cwd(), filePath) || filePath;
3550
+ const skippedLines = emitOut.skipped.map((s) => ` ${s.fn}: ${s.reason}${s.detail ? ` (${s.detail})` : ""}`);
3551
+ if (emitOut.source === source) {
3552
+ console.log("No changes.");
3553
+ for (const line of skippedLines) console.log(line);
3554
+ return;
3555
+ }
3556
+ if (options.emit.dryRun) {
3557
+ console.log(`Would emit cases \u2192 ${relPath} (dry run)`);
3558
+ for (const w of emitOut.written) console.log(` ${w.fn}: ${w.cases.join(", ")}`);
3559
+ for (const line of skippedLines) console.log(line);
3560
+ console.log();
3561
+ process.stdout.write(unifiedDiff(source, emitOut.source, relPath));
3562
+ if (options.emit.exitOnDiff) process.exitCode = 1;
3563
+ return;
3564
+ }
3565
+ writeFileSync(filePath, emitOut.source, "utf-8");
3566
+ const directiveCount = emitOut.written.reduce((n, w) => n + w.cases.length, 0);
3567
+ console.log(
3568
+ `Emitted cases \u2192 ${relPath} (${directiveCount} directive(s) across ${emitOut.written.length} function(s))`
3569
+ );
3570
+ for (const w of emitOut.written) console.log(` ${w.fn}: ${w.cases.join(", ")}`);
3571
+ for (const line of skippedLines) console.log(line);
3572
+ }
3573
+ }
3574
+ async function runInferJson(file, externalRecords) {
3575
+ const filePath = resolve(file);
3576
+ const source = readFileSync(filePath, "utf-8");
3577
+ const result = await analyzeFileAsync(filePath, source, void 0, externalRecords);
3578
+ const jsonOutput = {
3579
+ functions: result.functions.map((f) => ({
3580
+ name: f.name,
3581
+ loc: f.loc,
3582
+ cases: f.cases.map((c) => ({
3583
+ name: c.name,
3584
+ args: c.args.map(typeValueToString),
3585
+ result: typeValueToString(c.result),
3586
+ throws: c.throws.kind !== "never" ? typeValueToString(c.throws) : null,
3587
+ source: c.source ?? null
3588
+ })),
3589
+ entryOnly: f.entryOnly ?? false,
3590
+ assertionErrors: f.assertionErrors
3591
+ })),
3592
+ externalFunctions: result.externalFunctions?.map((f) => ({
3593
+ name: f.name,
3594
+ fromModule: f.fromModule,
3595
+ cases: f.cases.map((c) => ({
3596
+ name: c.name,
3597
+ args: c.args.map(typeValueToString),
3598
+ result: typeValueToString(c.result),
3599
+ throws: c.throws.kind !== "never" ? typeValueToString(c.throws) : null,
3600
+ source: c.source ?? null
3601
+ }))
3602
+ })),
3603
+ diagnostics: result.diagnostics.map((d) => ({
3604
+ range: d.range,
3605
+ severity: d.severity,
3606
+ message: d.message,
3607
+ code: d.code,
3608
+ suggestions: d.suggestions,
3609
+ tags: d.tags,
3610
+ origin: d.origin
3611
+ }))
3612
+ };
3613
+ console.log(JSON.stringify(jsonOutput, null, 2));
3614
+ }
3615
+ program2.command("infer").description("Infer types from a JS file \u2014 functions with @nudo:case directives use them; all other functions are inferred from call sites (whole-program analysis)").argument("<file>", "Path to the JS file").option("--dts", "Generate .d.ts file").option("--loc", "Show source locations in output").option("--json", "Output as JSON").option("--callsites <paths...>", "Usage-site files (tests/apps) to harvest real call shapes from; their calls to this file's exports become synthesized cases").option("--emit-cases [mode]", "Write synthesized call-site cases back into the source as @nudo:case directives (call@ prefix); mode: update (default: add)").option("--dry-run", "With --emit-cases: print a unified diff instead of writing to disk").option("--exit-on-diff", "With --dry-run: exit with code 1 when the diff is non-empty").action(
3616
+ async (file, opts) => {
3617
+ let emit;
3618
+ if (opts.emitCases !== void 0) {
3619
+ let mode;
3620
+ if (opts.emitCases === true) mode = "add";
3621
+ else if (opts.emitCases === "update") mode = "update";
3622
+ else {
3623
+ console.error(`Invalid --emit-cases value: ${opts.emitCases} (expected: =update, or omit the value for add)`);
3624
+ process.exitCode = 1;
3625
+ return;
3626
+ }
3627
+ if (opts.json) {
3628
+ console.error("--emit-cases cannot be combined with --json");
3629
+ process.exitCode = 1;
3630
+ return;
3631
+ }
3632
+ emit = { mode, dryRun: opts.dryRun === true, exitOnDiff: opts.exitOnDiff === true };
3633
+ }
3634
+ if (opts.exitOnDiff && !opts.dryRun) {
3635
+ console.error("--exit-on-diff requires --dry-run");
3636
+ process.exitCode = 1;
3637
+ return;
3638
+ }
3639
+ let externalRecords;
3640
+ if (opts.callsites?.length) {
3641
+ externalRecords = collectExternalRecords(opts.callsites);
3642
+ }
3643
+ if (opts.json) {
3644
+ await runInferJson(file, externalRecords);
3645
+ } else {
3646
+ await runInfer(file, { dts: opts.dts, showLoc: opts.loc, callsites: externalRecords, emit });
3647
+ }
3648
+ }
3649
+ );
3650
+ async function runCheck(file) {
3651
+ const filePath = resolve(file);
3652
+ const source = readFileSync(filePath, "utf-8");
3653
+ const result = await analyzeFileAsync(filePath, source);
3654
+ if (result.diagnostics.length === 0) {
3655
+ console.log("No issues found.");
3656
+ return;
3657
+ }
3658
+ for (const d of result.diagnostics) {
3659
+ const loc = `${relative(process.cwd(), filePath)}:${d.range.start.line}:${d.range.start.column}`;
3660
+ console.log(`[${d.severity}] ${loc} ${d.message}${d.code ? ` (${d.code})` : ""}`);
3661
+ if (d.origin) {
3662
+ console.log(` \u2192 value originates at ${d.origin.line}:${d.origin.column}`);
3663
+ }
3664
+ }
3665
+ if (result.diagnostics.some((d) => d.severity === "error")) {
3666
+ process.exitCode = 1;
3667
+ }
3668
+ }
3669
+ program2.command("check").description("Check a JS file for type errors \u2014 exits with code 1 when errors are found").argument("<file>", "Path to the JS file").action(async (file) => {
3670
+ await runCheck(file);
3671
+ });
3672
+ var displayPath = (p) => {
3673
+ const rel = relative(process.cwd(), p);
3674
+ return rel === "" || rel.startsWith("..") ? p : rel;
3675
+ };
3676
+ async function doctorFile(filePath, records) {
3677
+ const report = { file: displayPath(filePath), functions: 0, entryOnly: 0, uncovered: [] };
3678
+ let source;
3679
+ try {
3680
+ source = readFileSync(filePath, "utf-8");
3681
+ } catch (err) {
3682
+ report.error = err.message;
3683
+ return report;
3684
+ }
3685
+ try {
3686
+ const result = await analyzeFileAsync(filePath, source, void 0, records);
3687
+ report.functions = result.functions.length;
3688
+ report.entryOnly = result.functions.filter((fn) => fn.entryOnly).length;
3689
+ report.uncovered = result.functions.filter((fn) => fn.cases.length === 0 && !fn.skipped && !fn.entryOnly).map((fn) => fn.name);
3690
+ if (records) {
3691
+ const { emitOut, removed } = await reemitUpdate(filePath, source, records);
3692
+ if (emitOut.source !== source) {
3693
+ report.drift = {
3694
+ added: emitOut.written.reduce((n, w) => n + w.cases.length, 0),
3695
+ removed: removed.length
3696
+ };
3697
+ }
3698
+ }
3699
+ } catch (err) {
3700
+ report.error = err.message;
3701
+ }
3702
+ return report;
3703
+ }
3704
+ async function runDoctor(paths, opts) {
3705
+ const targetPaths = paths.length > 0 ? paths : ["."];
3706
+ const externalRecords = opts.callsites?.length ? collectExternalRecords(opts.callsites) : void 0;
3707
+ const files = [];
3708
+ const reports = [];
3709
+ for (const p of targetPaths) {
3710
+ const abs = resolve(p);
3711
+ if (!existsSync(abs)) {
3712
+ reports.push({ file: displayPath(abs), functions: 0, entryOnly: 0, uncovered: [], error: `File not found: ${abs}` });
3713
+ continue;
3714
+ }
3715
+ files.push(...statSync(abs).isDirectory() ? collectNudoFiles(abs) : [abs]);
3716
+ }
3717
+ for (const filePath of files) {
3718
+ reports.push(await doctorFile(filePath, externalRecords));
3719
+ }
3720
+ const driftCount = reports.filter((r) => r.drift).length;
3721
+ const errorCount = reports.filter((r) => r.error).length;
3722
+ const uncoveredTotal = reports.reduce((n, r) => n + r.uncovered.length, 0);
3723
+ const failed = driftCount > 0 || errorCount > 0;
3724
+ if (opts.json) {
3725
+ console.log(
3726
+ JSON.stringify(
3727
+ {
3728
+ // ok 与退出码一致:drift/报错 → false;uncovered 仅为信息级不影响
3729
+ ok: !failed,
3730
+ files: reports.map((r) => ({
3731
+ file: r.file,
3732
+ functions: r.functions,
3733
+ entryOnly: r.entryOnly,
3734
+ uncovered: r.uncovered,
3735
+ ...r.drift ? { drift: r.drift } : {},
3736
+ ...r.error ? { error: r.error } : {}
3737
+ })),
3738
+ summary: { files: reports.length, drift: driftCount, errors: errorCount, uncovered: uncoveredTotal }
3739
+ },
3740
+ null,
3741
+ 2
3742
+ )
3743
+ );
3744
+ } else {
3745
+ if (reports.length === 0) {
3746
+ console.log("No files to check.");
3747
+ return;
3748
+ }
3749
+ for (const r of reports) {
3750
+ console.log(`${r.file}`);
3751
+ if (r.error) {
3752
+ console.log(` \u2717 error: ${r.error}`);
3753
+ continue;
3754
+ }
3755
+ const entryInfo = r.entryOnly > 0 ? `, ${r.entryOnly} entry-only` : "";
3756
+ console.log(` \xB7 ${r.functions} function(s)${entryInfo}`);
3757
+ if (r.uncovered.length > 0) {
3758
+ console.log(` \u26A0 uncovered (no cases): ${r.uncovered.join(", ")}`);
3759
+ }
3760
+ if (r.drift) {
3761
+ const refresh = `nudo infer ${r.file} --callsites ${(opts.callsites ?? []).join(" ")} --emit-cases=update`;
3762
+ console.log(
3763
+ ` \u2717 drift: ${r.drift.added + r.drift.removed} directive(s) changed (+${r.drift.added} new, -${r.drift.removed} removed) \u2014 refresh with: ${refresh.trim()}`
3764
+ );
3765
+ }
3766
+ }
3767
+ console.log(
3768
+ `
3769
+ Summary: ${reports.length} file(s) \xB7 ${driftCount} drift \xB7 ${errorCount} error(s) \xB7 ${uncoveredTotal} uncovered function(s)`
3770
+ );
3771
+ console.log(failed ? "Result: FAIL (drift or errors found)" : "Result: OK (uncovered function(s) are informational only)");
3772
+ }
3773
+ if (failed) process.exitCode = 1;
3510
3774
  }
3511
- program2.command("infer").description("Infer types from a JS file with @nudo: directives").argument("<file>", "Path to the JS file").option("--dts", "Generate .d.ts file").option("--loc", "Show source locations in output").action((file, opts) => {
3512
- runInfer(file, { dts: opts.dts, showLoc: opts.loc });
3775
+ program2.command("doctor").description("Health-check JS files: functions without cases, call-site solidification drift (--callsites), analysis errors \u2014 exits 1 on drift/errors").argument("[paths...]", "File(s) or directory(s) to check (default: current directory)").option("--callsites <paths...>", "Usage-site files (tests/apps): re-solidify per current call shapes and report drift when directives would change").option("--json", "Output as JSON").action(async (paths, opts) => {
3776
+ await runDoctor(paths, opts);
3513
3777
  });
3514
3778
  program2.command("watch").description("Watch file(s) for changes and re-run inference").argument("<path>", "File or directory to watch").option("--dts", "Generate .d.ts files on each change").action((watchPath, opts) => {
3515
3779
  const resolved = resolve(watchPath);
@@ -3518,28 +3782,66 @@ program2.command("watch").description("Watch file(s) for changes and re-run infe
3518
3782
  if (!isDir) return [resolved];
3519
3783
  return collectNudoFiles(resolved);
3520
3784
  };
3521
- const runAll = () => {
3785
+ let graph = buildModuleGraph(getFiles());
3786
+ const runAll = async () => {
3522
3787
  console.clear();
3523
3788
  console.log(`[${(/* @__PURE__ */ new Date()).toLocaleTimeString()}] Analyzing...
3524
3789
  `);
3525
3790
  for (const f of getFiles()) {
3526
3791
  try {
3527
- runInfer(f, { dts: opts.dts, showLoc: true });
3792
+ await runInfer(f, { dts: opts.dts, showLoc: true });
3793
+ } catch (err) {
3794
+ console.error(`Error analyzing ${relative(process.cwd(), f)}:`, err.message);
3795
+ }
3796
+ }
3797
+ graph = buildModuleGraph(getFiles());
3798
+ console.log(`[${(/* @__PURE__ */ new Date()).toLocaleTimeString()}] Watching for changes...`);
3799
+ };
3800
+ const runIncremental = async (changedFiles) => {
3801
+ const files = getFiles();
3802
+ const tracked = new Set(files);
3803
+ const dirtyUnion = /* @__PURE__ */ new Set();
3804
+ for (const cf of changedFiles) {
3805
+ for (const d of computeDirtySet(graph.dependents, cf)) dirtyUnion.add(d);
3806
+ }
3807
+ const dirty = [...dirtyUnion].filter((f) => tracked.has(f));
3808
+ if (dirty.length === 0) return;
3809
+ const ordered = topoSortDirty(graph.imports, dirty);
3810
+ console.clear();
3811
+ console.log(`[${(/* @__PURE__ */ new Date()).toLocaleTimeString()}] Analyzing (incremental)...
3812
+ `);
3813
+ const t0 = performance.now();
3814
+ resetMemo();
3815
+ for (const f of ordered) {
3816
+ try {
3817
+ await runInfer(f, { dts: opts.dts, showLoc: true });
3528
3818
  } catch (err) {
3529
3819
  console.error(`Error analyzing ${relative(process.cwd(), f)}:`, err.message);
3530
3820
  }
3531
3821
  }
3822
+ const elapsed = Math.round(performance.now() - t0);
3823
+ console.log(`Incremental: re-analyzed ${ordered.length}, skipped ${files.length - ordered.length} (${elapsed}ms)`);
3532
3824
  console.log(`[${(/* @__PURE__ */ new Date()).toLocaleTimeString()}] Watching for changes...`);
3825
+ graph = buildModuleGraph(getFiles());
3533
3826
  };
3534
- runAll();
3827
+ runAll().catch(() => {
3828
+ });
3535
3829
  let debounceTimer = null;
3536
3830
  const watchTarget = isDir ? resolved : dirname(resolved);
3831
+ const pendingChanged = /* @__PURE__ */ new Set();
3537
3832
  watch(watchTarget, { recursive: isDir }, (_event, filename) => {
3538
3833
  if (!filename) return;
3539
3834
  const fullPath = isDir ? join(watchTarget, filename) : resolved;
3540
3835
  if (!fullPath.endsWith(".js")) return;
3836
+ if (!existsSync(fullPath)) return;
3837
+ pendingChanged.add(fullPath);
3541
3838
  if (debounceTimer) clearTimeout(debounceTimer);
3542
- debounceTimer = setTimeout(runAll, 200);
3839
+ debounceTimer = setTimeout(() => {
3840
+ const changedFiles = [...pendingChanged];
3841
+ pendingChanged.clear();
3842
+ runIncremental(changedFiles).catch(() => {
3843
+ });
3844
+ }, 200);
3543
3845
  });
3544
3846
  });
3545
3847
  function collectNudoFiles(dir) {
@@ -3549,12 +3851,185 @@ function collectNudoFiles(dir) {
3549
3851
  if (entry.isDirectory() && entry.name !== "node_modules") {
3550
3852
  results.push(...collectNudoFiles(fullPath));
3551
3853
  } else if (entry.isFile() && entry.name.endsWith(".js")) {
3552
- const content = readFileSync(fullPath, "utf-8");
3553
- if (/@nudo:(case|mock|pure|skip|sample|returns)\b/.test(content)) {
3554
- results.push(fullPath);
3555
- }
3854
+ results.push(fullPath);
3556
3855
  }
3557
3856
  }
3558
3857
  return results;
3559
3858
  }
3560
- program2.parse();
3859
+ function nodeLoc(node) {
3860
+ return {
3861
+ start: { line: node.loc?.start.line ?? 1, column: node.loc?.start.column ?? 0 },
3862
+ end: { line: node.loc?.end.line ?? 1, column: node.loc?.end.column ?? 0 }
3863
+ };
3864
+ }
3865
+ function fnParamNames(node) {
3866
+ const fn = node.type === "ExportDefaultDeclaration" ? node.declaration : node;
3867
+ const paramListOf = (params) => params.map((p) => {
3868
+ if (p.type === "Identifier") return p.name;
3869
+ if (p.type === "AssignmentPattern" && p.left.type === "Identifier") return p.left.name;
3870
+ if (p.type === "RestElement" && p.argument.type === "Identifier") return `...${p.argument.name}`;
3871
+ return "_";
3872
+ });
3873
+ if (fn.type === "FunctionDeclaration" || fn.type === "FunctionExpression" || fn.type === "ArrowFunctionExpression") {
3874
+ return paramListOf(fn.params);
3875
+ }
3876
+ if (fn.type === "VariableDeclaration") {
3877
+ const init = fn.declarations[0].init;
3878
+ if (init?.type === "FunctionExpression" || init?.type === "ArrowFunctionExpression") {
3879
+ return paramListOf(init.params);
3880
+ }
3881
+ }
3882
+ return [];
3883
+ }
3884
+ program2.command("generate").description("Generate runtime validators from inferred types").argument("<file>", "JavaScript file to analyze").option("--format <format>", "Output format: zod, guard, dts, all", "all").option("--output <dir>", "Output directory", ".").action((file, options) => {
3885
+ const filePath = resolve(file);
3886
+ const source = readFileSync(filePath, "utf-8");
3887
+ const ast = parse(source);
3888
+ const functions = extractDirectives(ast);
3889
+ if (functions.length === 0) {
3890
+ console.log("No functions with @nudo:case directives found.");
3891
+ return;
3892
+ }
3893
+ resetMemo();
3894
+ setModuleResolver(resolveModule);
3895
+ setCurrentFileDir(dirname(filePath));
3896
+ const globalEnv = createEnvironment();
3897
+ evaluateProgram(ast, globalEnv);
3898
+ for (const fn of functions) {
3899
+ applyMocks(fn.directives, globalEnv, filePath);
3900
+ const caseDirectives = fn.directives.filter((d) => d.kind === "case");
3901
+ if (caseDirectives.length === 0) continue;
3902
+ const caseResults = caseDirectives.map((directive) => {
3903
+ const fullResult = evaluateFunctionFull(fn.node, directive.args, globalEnv);
3904
+ return {
3905
+ name: directive.name,
3906
+ args: directive.args,
3907
+ result: fullResult.value,
3908
+ throws: fullResult.throws,
3909
+ source: "directive"
3910
+ };
3911
+ });
3912
+ const baseName = fn.name;
3913
+ if (options.format === "zod" || options.format === "all") {
3914
+ console.log(`
3915
+ // === ${baseName} Zod Schemas ===`);
3916
+ for (const c of caseResults) {
3917
+ const inputSchemas = c.args.map((a, i) => `arg${i}: ${typeValueToZodSchema(a)}`).join(", ");
3918
+ const outputSchema = typeValueToZodSchema(c.result);
3919
+ console.log(`// Case "${c.name}":`);
3920
+ console.log(`// Input: { ${inputSchemas} }`);
3921
+ console.log(`// Output: ${outputSchema}`);
3922
+ }
3923
+ }
3924
+ if (options.format === "guard" || options.format === "all") {
3925
+ console.log(`
3926
+ // === ${baseName} Type Guards ===`);
3927
+ for (const c of caseResults) {
3928
+ const guard = generateGuardFunction(`is${baseName}${c.name.charAt(0).toUpperCase() + c.name.slice(1)}Output`, c.result);
3929
+ console.log(guard);
3930
+ }
3931
+ }
3932
+ if (options.format === "dts" || options.format === "all") {
3933
+ console.log(`
3934
+ // === ${baseName} TypeScript Declarations ===`);
3935
+ const analysis = {
3936
+ name: baseName,
3937
+ loc: nodeLoc(fn.node),
3938
+ paramNames: fnParamNames(fn.node),
3939
+ cases: caseResults
3940
+ };
3941
+ for (const line of generateFunctionDtsLines(analysis)) {
3942
+ console.log(line);
3943
+ }
3944
+ }
3945
+ }
3946
+ setModuleResolver(null);
3947
+ });
3948
+ var HARVEST_MAX_FILES = 200;
3949
+ var REFERENCE_PATH_REGEX = /<reference\s+path=["']([^"']+)["']\s*\/>/g;
3950
+ var RELATIVE_FROM_REGEX = /\bfrom\s+["'](\.[^"']+)["']/g;
3951
+ function collectDtsFiles(entry) {
3952
+ const files = [];
3953
+ const seen = /* @__PURE__ */ new Set();
3954
+ const queue = [entry];
3955
+ while (queue.length > 0 && files.length < HARVEST_MAX_FILES) {
3956
+ const current = queue.shift();
3957
+ if (seen.has(current) || !existsSync(current)) continue;
3958
+ seen.add(current);
3959
+ if (!current.endsWith(".d.ts")) continue;
3960
+ files.push(current);
3961
+ let text;
3962
+ try {
3963
+ text = readFileSync(current, "utf-8");
3964
+ } catch {
3965
+ continue;
3966
+ }
3967
+ const dir = dirname(current);
3968
+ for (const match of text.matchAll(REFERENCE_PATH_REGEX)) {
3969
+ queue.push(resolve(dir, match[1]));
3970
+ }
3971
+ for (const match of text.matchAll(RELATIVE_FROM_REGEX)) {
3972
+ const base = resolve(dir, match[1]);
3973
+ for (const candidate of [`${base}.d.ts`, join(base, "index.d.ts")]) {
3974
+ if (existsSync(candidate)) {
3975
+ queue.push(candidate);
3976
+ break;
3977
+ }
3978
+ }
3979
+ }
3980
+ }
3981
+ return files;
3982
+ }
3983
+ function runHarvest(pkg, outOpt) {
3984
+ const typesDir = resolve(process.cwd(), "node_modules", "@types", pkg);
3985
+ if (!existsSync(typesDir)) {
3986
+ console.error(`Error: ${relative(process.cwd(), typesDir)} not found.`);
3987
+ console.error(`Install the package first, e.g. pnpm add -D @types/${pkg}`);
3988
+ process.exitCode = 1;
3989
+ return;
3990
+ }
3991
+ let entry = join(typesDir, "index.d.ts");
3992
+ const pkgJsonPath = join(typesDir, "package.json");
3993
+ if (existsSync(pkgJsonPath)) {
3994
+ try {
3995
+ const pkgJson = JSON.parse(readFileSync(pkgJsonPath, "utf-8"));
3996
+ const declared = pkgJson.types ?? pkgJson.typings;
3997
+ if (typeof declared === "string") {
3998
+ const candidate = resolve(typesDir, declared);
3999
+ if (existsSync(candidate)) entry = candidate;
4000
+ }
4001
+ } catch {
4002
+ }
4003
+ }
4004
+ if (!existsSync(entry)) {
4005
+ console.error(`Error: no .d.ts entry found in ${relative(process.cwd(), typesDir)} (tried ${basename(entry)}).`);
4006
+ process.exitCode = 1;
4007
+ return;
4008
+ }
4009
+ const files = collectDtsFiles(entry);
4010
+ if (files.length === 0) {
4011
+ console.error(`Error: no .d.ts files collected from ${relative(process.cwd(), entry)}.`);
4012
+ process.exitCode = 1;
4013
+ return;
4014
+ }
4015
+ const env = harvestDts(files);
4016
+ const code = emitEnvModule(env, `@types/${pkg}`);
4017
+ const out = resolve(outOpt ?? `nudo-harvest-${pkg}.ts`);
4018
+ writeFileSync(out, code, "utf-8");
4019
+ console.log(`Harvested @types/${pkg} \u2192 ${relative(process.cwd(), out) || out}`);
4020
+ console.log(` files: ${env.stats.files}`);
4021
+ console.log(` symbols: ${env.stats.symbols}`);
4022
+ console.log(` skipped: ${env.stats.skipped}`);
4023
+ console.log(`
4024
+ Usage \u2014 add this directive at the top of your JS file:`);
4025
+ const outDir = dirname(out);
4026
+ const hintPath = resolve(process.cwd()) === outDir ? basename(out) : out;
4027
+ console.log(` /// @nudo:env ${hintPath}`);
4028
+ }
4029
+ program2.command("harvest").description("Convert @types/<pkg> .d.ts declarations into a Nudo env file (TS source using T.* constructors)").argument("<pkg>", "Package name under @types (e.g. node)").option("--out <file>", "Output .ts env file (default: ./nudo-harvest-<pkg>.ts)").action((pkg, opts) => {
4030
+ runHarvest(pkg, opts.out);
4031
+ });
4032
+ program2.parseAsync(process.argv).catch((err) => {
4033
+ console.error(err instanceof Error ? process.env.NUDO_DEBUG ? err.stack : err.message : err);
4034
+ process.exitCode = 1;
4035
+ });