@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/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-LGWVGEMB.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,20 +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,
3340
- isTemplate,
3341
- getTemplateParts
3339
+ mockHelperToTypeValue
3342
3340
  } from "@nudojs/core";
3343
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";
3344
3356
  var program2 = new Command();
3345
3357
  program2.name("nudo").description("Nudo type inference engine").version("0.0.1");
3346
3358
  function applyMocks(directives, env, filePath) {
3347
3359
  for (const d of directives) {
3348
3360
  if (d.kind !== "mock") continue;
3349
- 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) {
3350
3372
  env.bind(d.name, parseTypeValueExpr(d.expression));
3351
3373
  } else if (d.fromPath) {
3352
3374
  const mockPath = resolve(dirname(filePath), d.fromPath);
@@ -3359,156 +3381,152 @@ function applyMocks(directives, env, filePath) {
3359
3381
  }
3360
3382
  }
3361
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
+ }
3362
3398
  function resolveModule(source, fromDir) {
3363
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
+ }
3364
3405
  const basePath = resolve(fromDir, source);
3365
3406
  for (const ext of ["", ...extensions]) {
3366
3407
  const candidate = basePath + ext;
3367
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
+ }
3368
3416
  const src = readFileSync(candidate, "utf-8");
3369
3417
  return { ast: parse(src), filePath: candidate };
3370
3418
  }
3371
3419
  }
3372
3420
  return null;
3373
3421
  }
3374
- function typeValueToTSType(tv) {
3375
- switch (tv.kind) {
3376
- case "literal": {
3377
- const v = tv.value;
3378
- if (v === null) return "null";
3379
- if (v === void 0) return "undefined";
3380
- if (typeof v === "string") return JSON.stringify(v);
3381
- return String(v);
3382
- }
3383
- case "primitive":
3384
- return tv.type;
3385
- case "refined": {
3386
- if (isTemplate(tv)) {
3387
- const parts = getTemplateParts(tv);
3388
- const inner = parts.map((p) => p.kind === "literal" && typeof p.value === "string" ? p.value : `\${${typeValueToTSType(p)}}`).join("");
3389
- return `\`${inner}\``;
3390
- }
3391
- return typeValueToTSType(tv.base);
3392
- }
3393
- case "object": {
3394
- const entries = Object.entries(tv.properties);
3395
- if (entries.length === 0) return "{}";
3396
- const inner = entries.map(([k, v]) => `${k}: ${typeValueToTSType(v)}`).join("; ");
3397
- 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;
3398
3436
  }
3399
- case "array": {
3400
- const el = typeValueToTSType(tv.element);
3401
- 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")));
3402
3440
  }
3403
- case "tuple":
3404
- return `[${tv.elements.map(typeValueToTSType).join(", ")}]`;
3405
- case "function":
3406
- return `(${tv.params.map((p) => `${p}: unknown`).join(", ")}) => unknown`;
3407
- case "promise":
3408
- return `Promise<${typeValueToTSType(tv.value)}>`;
3409
- case "instance":
3410
- return tv.className;
3411
- case "union":
3412
- return tv.members.map(typeValueToTSType).join(" | ");
3413
- case "never":
3414
- return "never";
3415
- case "unknown":
3416
- return "unknown";
3417
3441
  }
3442
+ return records.length > 0 ? records : void 0;
3418
3443
  }
3419
- function runInfer(file, options = {}) {
3444
+ async function runInfer(file, options = {}) {
3420
3445
  const filePath = resolve(file);
3421
3446
  const source = readFileSync(filePath, "utf-8");
3422
- const ast = parse(source);
3423
- const functions = extractDirectives(ast);
3424
- 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) {
3425
3459
  console.log("No functions with @nudo:case directives found.");
3426
3460
  return;
3427
3461
  }
3428
- resetMemo();
3429
- setModuleResolver(resolveModule);
3430
- setCurrentFileDir(dirname(filePath));
3431
- const globalEnv = createEnvironment();
3432
- evaluateProgram(ast, globalEnv);
3433
3462
  const dtsLines = [];
3434
- for (const fn of functions) {
3435
- const loc = fn.node.loc;
3436
- 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}`;
3437
3466
  const header = options.showLoc ? `=== ${fn.name} (${locStr}) ===` : `=== ${fn.name} ===`;
3438
3467
  console.log(`${header}
3439
3468
  `);
3440
- applyMocks(fn.directives, globalEnv, filePath);
3441
- const isPure = fn.directives.some((d) => d.kind === "pure");
3442
- const skipDirective = fn.directives.find((d) => d.kind === "skip");
3443
- const returnsDirective = fn.directives.find((d) => d.kind === "returns");
3444
- if (skipDirective && skipDirective.kind === "skip") {
3445
- if (skipDirective.returns) {
3446
- console.log(`Skipped (declared): ${typeValueToString(skipDirective.returns)}`);
3447
- if (options.dts) {
3448
- dtsLines.push(`export declare function ${fn.name}(...args: unknown[]): ${typeValueToTSType(skipDirective.returns)};`);
3449
- }
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)}`);
3450
3475
  } else {
3451
3476
  console.log("Skipped (no return type declared)");
3452
3477
  }
3453
3478
  console.log();
3454
3479
  continue;
3455
3480
  }
3456
- if (isPure) {
3457
- const fnVal = globalEnv.has(fn.name) ? globalEnv.lookup(fn.name) : null;
3458
- if (fnVal && fnVal.kind === "function") {
3459
- fnVal._memoize = fn.name;
3460
- }
3461
- }
3462
- const caseDirectives = fn.directives.filter((d) => d.kind === "case");
3463
- const caseResults = [];
3464
- for (const directive of caseDirectives) {
3465
- const fullResult = evaluateFunctionFull(fn.node, directive.args, globalEnv);
3466
- const argsStr = directive.args.map(typeValueToString).join(", ");
3467
- const resultStr = typeValueToString(fullResult.value);
3468
- const throwsStr = fullResult.throws.kind !== "never" ? typeValueToString(fullResult.throws) : void 0;
3469
- caseResults.push({
3470
- name: directive.name,
3471
- args: directive.args,
3472
- argsStr,
3473
- result: resultStr,
3474
- resultTV: fullResult.value,
3475
- throws: throwsStr
3476
- });
3477
- let line = `Case "${directive.name}": (${argsStr}) => ${resultStr}`;
3478
- 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)}`;
3479
3485
  console.log(line);
3480
3486
  }
3481
- if (caseResults.length > 1) {
3482
- const allResults = caseDirectives.map(
3483
- (d) => evaluateFunctionFull(fn.node, d.args, globalEnv)
3484
- );
3485
- 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) {
3486
3491
  console.log(`
3487
- Combined: ${typeValueToString(combined)}`);
3492
+ Combined: ${typeValueToString(fn.combined)}`);
3488
3493
  }
3489
- if (returnsDirective && returnsDirective.kind === "returns") {
3490
- for (const directive of caseDirectives) {
3491
- const result = evaluateFunction(fn.node, directive.args, globalEnv);
3492
- const matches = isSubtypeOf(result, returnsDirective.expected);
3493
- if (!matches) {
3494
- console.log(`
3495
- \u26A0 @nudo:returns assertion failed for case "${directive.name}": expected ${typeValueToString(returnsDirective.expected)}, got ${typeValueToString(result)}`);
3496
- }
3494
+ if (fn.assertionErrors && fn.assertionErrors.length > 0) {
3495
+ for (const err of fn.assertionErrors) {
3496
+ console.log(`
3497
+ \u26A0 ${err}`);
3497
3498
  }
3498
3499
  }
3499
- if (options.dts) {
3500
- if (caseResults.length === 1) {
3501
- const c = caseResults[0];
3502
- const params = c.args.map((a, i) => `arg${i}: ${typeValueToTSType(a)}`).join(", ");
3503
- dtsLines.push(`export declare function ${fn.name}(${params}): ${typeValueToTSType(c.resultTV)};`);
3504
- } else {
3505
- for (const c of caseResults) {
3506
- const params = c.args.map((a, i) => `arg${i}: ${typeValueToTSType(a)}`).join(", ");
3507
- 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);
3522
+ }
3523
+ if (fn.cases.length > 1 && fn.combined) {
3524
+ console.log(`
3525
+ Combined: ${typeValueToString(fn.combined)}`);
3508
3526
  }
3527
+ console.log();
3509
3528
  }
3510
3529
  }
3511
- console.log();
3512
3530
  }
3513
3531
  if (options.dts && dtsLines.length > 0) {
3514
3532
  const dtsPath = filePath.replace(/\.js$/, ".d.ts");
@@ -3516,10 +3534,246 @@ Combined: ${typeValueToString(combined)}`);
3516
3534
  writeFileSync(dtsPath, dtsContent, "utf-8");
3517
3535
  console.log(`Generated: ${relative(process.cwd(), dtsPath)}`);
3518
3536
  }
3519
- 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
+ }
3520
3668
  }
3521
- 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) => {
3522
- runInfer(file, { dts: opts.dts, showLoc: opts.loc });
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;
3774
+ }
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);
3523
3777
  });
3524
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) => {
3525
3779
  const resolved = resolve(watchPath);
@@ -3528,28 +3782,66 @@ program2.command("watch").description("Watch file(s) for changes and re-run infe
3528
3782
  if (!isDir) return [resolved];
3529
3783
  return collectNudoFiles(resolved);
3530
3784
  };
3531
- const runAll = () => {
3785
+ let graph = buildModuleGraph(getFiles());
3786
+ const runAll = async () => {
3532
3787
  console.clear();
3533
3788
  console.log(`[${(/* @__PURE__ */ new Date()).toLocaleTimeString()}] Analyzing...
3534
3789
  `);
3535
3790
  for (const f of getFiles()) {
3536
3791
  try {
3537
- 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 });
3538
3818
  } catch (err) {
3539
3819
  console.error(`Error analyzing ${relative(process.cwd(), f)}:`, err.message);
3540
3820
  }
3541
3821
  }
3822
+ const elapsed = Math.round(performance.now() - t0);
3823
+ console.log(`Incremental: re-analyzed ${ordered.length}, skipped ${files.length - ordered.length} (${elapsed}ms)`);
3542
3824
  console.log(`[${(/* @__PURE__ */ new Date()).toLocaleTimeString()}] Watching for changes...`);
3825
+ graph = buildModuleGraph(getFiles());
3543
3826
  };
3544
- runAll();
3827
+ runAll().catch(() => {
3828
+ });
3545
3829
  let debounceTimer = null;
3546
3830
  const watchTarget = isDir ? resolved : dirname(resolved);
3831
+ const pendingChanged = /* @__PURE__ */ new Set();
3547
3832
  watch(watchTarget, { recursive: isDir }, (_event, filename) => {
3548
3833
  if (!filename) return;
3549
3834
  const fullPath = isDir ? join(watchTarget, filename) : resolved;
3550
3835
  if (!fullPath.endsWith(".js")) return;
3836
+ if (!existsSync(fullPath)) return;
3837
+ pendingChanged.add(fullPath);
3551
3838
  if (debounceTimer) clearTimeout(debounceTimer);
3552
- debounceTimer = setTimeout(runAll, 200);
3839
+ debounceTimer = setTimeout(() => {
3840
+ const changedFiles = [...pendingChanged];
3841
+ pendingChanged.clear();
3842
+ runIncremental(changedFiles).catch(() => {
3843
+ });
3844
+ }, 200);
3553
3845
  });
3554
3846
  });
3555
3847
  function collectNudoFiles(dir) {
@@ -3559,12 +3851,185 @@ function collectNudoFiles(dir) {
3559
3851
  if (entry.isDirectory() && entry.name !== "node_modules") {
3560
3852
  results.push(...collectNudoFiles(fullPath));
3561
3853
  } else if (entry.isFile() && entry.name.endsWith(".js")) {
3562
- const content = readFileSync(fullPath, "utf-8");
3563
- if (/@nudo:(case|mock|pure|skip|sample|returns)\b/.test(content)) {
3564
- results.push(fullPath);
3565
- }
3854
+ results.push(fullPath);
3566
3855
  }
3567
3856
  }
3568
3857
  return results;
3569
3858
  }
3570
- 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
+ });