@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
@@ -66,6 +66,126 @@ describe("ForStatement", () => {
66
66
  `);
67
67
  expect(typeValueEquals(result, T.literal(2))).toBe(true);
68
68
  });
69
+
70
+ it("let loop variable gets a fresh binding per iteration for closures", () => {
71
+ // Real JS semantics: let creates a per-iteration scope, so each closure
72
+ // captures the value at its own iteration.
73
+ const result = evalCode(`
74
+ const fns = [];
75
+ for (let i = 0; i < 3; i++) {
76
+ fns.push(() => i);
77
+ }
78
+ [fns[0](), fns[1](), fns[2]()]
79
+ `);
80
+ expect(typeValueToString(result)).toBe("[0, 1, 2]");
81
+ });
82
+
83
+ it("var loop variable shares one binding across iterations for closures", () => {
84
+ // var keeps a single shared cell: every closure observes the final value.
85
+ const result = evalCode(`
86
+ const fns = [];
87
+ for (var i = 0; i < 3; i++) {
88
+ fns.push(() => i);
89
+ }
90
+ [fns[0](), fns[1](), fns[2]()]
91
+ `);
92
+ expect(typeValueToString(result)).toBe("[3, 3, 3]");
93
+ });
94
+
95
+ it("let loop closure sees body-side increments; loop still progresses", () => {
96
+ // Real JS: an assignment in the body targets the per-iteration binding
97
+ // the closure captured (node: fns[0]() === 1), unlike the update clause
98
+ // which runs outside it. No update clause here — the copy-back from the
99
+ // per-iteration binding must still drive the loop condition.
100
+ const result = evalCode(`
101
+ const fns = [];
102
+ let total = 0;
103
+ for (let i = 0; i < 3;) {
104
+ fns.push(() => i);
105
+ total = total + i;
106
+ i = i + 1;
107
+ }
108
+ [fns[0](), total]
109
+ `);
110
+ expect(typeValueToString(result)).toBe("[1, 3]");
111
+ });
112
+
113
+ it("for-of over a union of tuples iterates every member", () => {
114
+ const result = evalWith(`
115
+ function pick(c) {
116
+ if (c) { return [9]; }
117
+ return [10, [11, 12]];
118
+ }
119
+ function f(a, t) {
120
+ const r = t || [];
121
+ for (const e of a) {
122
+ if (Array.isArray(e)) { f(e, r) } else { r.push(e) }
123
+ }
124
+ return r;
125
+ }
126
+ f(pick(c))
127
+ `, { c: T.boolean });
128
+ // Both union members' elements flow through the body (concatenated),
129
+ // including the recursive flattening of the nested [11, 12] member.
130
+ // (typeValueEquals has no tuple branch — compare rendered form.)
131
+ expect(typeValueToString(result)).toBe("[9, 10, 11, 12]");
132
+ });
133
+
134
+ it("for-of over a union with non-iterable members skips them", () => {
135
+ const result = evalWith(`
136
+ function pick(c) {
137
+ if (c) { return [1, 2]; }
138
+ if (c === 2) { return 42; }
139
+ return "str";
140
+ }
141
+ function f(a, t) {
142
+ const r = t || [];
143
+ for (const e of a) {
144
+ if (Array.isArray(e)) { f(e, r) } else { r.push(e) }
145
+ }
146
+ return r;
147
+ }
148
+ f(pick(c))
149
+ `, { c: T.boolean });
150
+ // number/string members of the union contribute nothing; the tuple
151
+ // member's pushes still land on the shared accumulator.
152
+ expect(typeValueToString(result)).toBe("[1, 2]");
153
+ });
154
+
155
+ it("break terminates loop iteration and skips the rest of the body", () => {
156
+ // Guard-style break (reach.js pattern): the member access after break
157
+ // must not execute on the falsy receiver. Before break was implemented
158
+ // the loop kept evaluating the body and dereferenced the undefined ref.
159
+ const result = evalWith(`
160
+ function step(ref, key) {
161
+ let out = null;
162
+ for (const k of [key]) {
163
+ if (!ref) {
164
+ out = 'default';
165
+ break;
166
+ }
167
+ out = ref[k];
168
+ }
169
+ return out;
170
+ }
171
+ step(ref, 'a')
172
+ `, { ref: T.undefined });
173
+ expect(typeValueToString(result)).toBe('"default"');
174
+ });
175
+
176
+ it("break stops the loop and continue skips to the update clause", () => {
177
+ const result = evalCode(`
178
+ const collected = [];
179
+ for (let i = 0; i < 5; i++) {
180
+ if (i === 1) { continue; }
181
+ if (i === 3) { break; }
182
+ collected.push(i);
183
+ }
184
+ collected
185
+ `);
186
+ // continue skips push(1); break stops before 3 and 4
187
+ expect(typeValueToString(result)).toBe("[0, 2]");
188
+ });
69
189
  });
70
190
 
71
191
  describe("WhileStatement", () => {
@@ -0,0 +1,86 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { T, typeValueToString, createEnvironment } from "@nudojs/core";
3
+ import type { TypeValue } from "@nudojs/core";
4
+ import { evaluateProgram, evaluateFunction } from "../evaluator.ts";
5
+ import { parse } from "@nudojs/parser";
6
+
7
+ function evalSource(source: string) {
8
+ const ast = parse(source);
9
+ const env = createEnvironment();
10
+ evaluateProgram(ast, env);
11
+ return env;
12
+ }
13
+
14
+ function evalFn(code: string, args: TypeValue[]): TypeValue {
15
+ const ast = parse(code);
16
+ const env = createEnvironment();
17
+ evaluateProgram(ast, env);
18
+ const fns = ast.type === "File" ? ast.program.body : [];
19
+ const fnNode = fns.find(
20
+ (n: any) => n.type === "FunctionDeclaration",
21
+ );
22
+ if (!fnNode) throw new Error("No function found");
23
+ return evaluateFunction(fnNode, args, env);
24
+ }
25
+
26
+ describe("nullish coalescing narrowing", () => {
27
+ it("narrows ?? to exclude null/undefined from left side", () => {
28
+ const env = evalSource(`
29
+ const x = "hello" ?? "default";
30
+ `);
31
+ expect(typeValueToString(env.lookup("x"))).toBe('"hello"');
32
+ });
33
+
34
+ it("returns right side when left is always null", () => {
35
+ const env = evalSource(`
36
+ const x = null;
37
+ const result = x ?? 42;
38
+ `);
39
+ expect(typeValueToString(env.lookup("result"))).toBe("42");
40
+ });
41
+
42
+ it("returns left side when left is never null", () => {
43
+ const env = evalSource(`
44
+ const x = "hello";
45
+ const result = x ?? "default";
46
+ `);
47
+ expect(typeValueToString(env.lookup("result"))).toBe('"hello"');
48
+ });
49
+
50
+ it("narrows union type by removing null from left side", () => {
51
+ const result = evalFn(
52
+ `function test(x) { return x ?? "default"; }`,
53
+ [T.union(T.string, T.null)],
54
+ );
55
+ // null is excluded from string | null, leaving string.
56
+ // "default" is a subtype of string, so result is string.
57
+ expect(typeValueToString(result)).toBe("string");
58
+ });
59
+
60
+ it("narrows union type by removing undefined from left side", () => {
61
+ const result = evalFn(
62
+ `function test(x) { return x ?? "fallback"; }`,
63
+ [T.union(T.string, T.undefined)],
64
+ );
65
+ // undefined is excluded from string | undefined, leaving string.
66
+ expect(typeValueToString(result)).toBe("string");
67
+ });
68
+
69
+ it("narrows union type by removing null and undefined from left side", () => {
70
+ const result = evalFn(
71
+ `function test(x) { return x ?? "fallback"; }`,
72
+ [T.union(T.string, T.null, T.undefined)],
73
+ );
74
+ // null and undefined are excluded, leaving string.
75
+ expect(typeValueToString(result)).toBe("string");
76
+ });
77
+
78
+ it("returns right side when left is always null/undefined union", () => {
79
+ const result = evalFn(
80
+ `function test(x) { return x ?? 42; }`,
81
+ [T.union(T.null, T.undefined)],
82
+ );
83
+ // All left members are null/undefined, so use right side.
84
+ expect(typeValueToString(result)).toBe("42");
85
+ });
86
+ });
@@ -0,0 +1,37 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { T, typeValueToString, createEnvironment } from "@nudojs/core";
3
+ import { evaluateProgram } from "../evaluator.ts";
4
+ import { parse } from "@nudojs/parser";
5
+
6
+ function evalSource(source: string) {
7
+ const ast = parse(source);
8
+ const env = createEnvironment();
9
+ evaluateProgram(ast, env);
10
+ return env;
11
+ }
12
+
13
+ describe("optional chaining", () => {
14
+ it("evaluates obj?.foo when obj is object", () => {
15
+ const env = evalSource(`
16
+ const obj = { foo: "hello" };
17
+ const result = obj?.foo;
18
+ `);
19
+ expect(typeValueToString(env.lookup("result"))).toBe('"hello"');
20
+ });
21
+
22
+ it("evaluates arr?.[0] for optional computed access", () => {
23
+ const env = evalSource(`
24
+ const arr = [1, 2, 3];
25
+ const result = arr?.[0];
26
+ `);
27
+ expect(typeValueToString(env.lookup("result"))).toBe("1");
28
+ });
29
+
30
+ it("evaluates fn?.() for optional call", () => {
31
+ const env = evalSource(`
32
+ const fn = () => 42;
33
+ const result = fn?.();
34
+ `);
35
+ expect(typeValueToString(env.lookup("result"))).toBe("42");
36
+ });
37
+ });
@@ -8,7 +8,7 @@ import {
8
8
  } from "@nudojs/core";
9
9
  import type { TypeValue } from "@nudojs/core";
10
10
  import { parse } from "@nudojs/parser";
11
- import { evaluate, evaluateFunction, evaluateFunctionFull, evaluateProgram, resetMemo, setModuleResolver, setCurrentFileDir } from "../evaluator.ts";
11
+ import { evaluate, evaluateFunction, evaluateFunctionFull, evaluateProgram, resetMemo, setModuleResolver, setCurrentFileDir, setUnknownCollector, setMockModules, resetMockModules, type UnknownRecord } from "../evaluator.ts";
12
12
 
13
13
  function evalCode(code: string): TypeValue {
14
14
  const ast = parse(code);
@@ -207,7 +207,15 @@ describe("instanceof", () => {
207
207
  const x = 42;
208
208
  x instanceof Error
209
209
  `);
210
- expect(result).toBe(T.boolean);
210
+ // primitives can never be instanceof anything — decided statically
211
+ expect(typeValueEquals(result, T.literal(false))).toBe(true);
212
+ // unknown receivers keep the conservative boolean fallback
213
+ const fn = evaluateFunction(
214
+ parse("function f(x) { return x instanceof Error; }").program.body[0],
215
+ [T.unknown],
216
+ createEnvironment(),
217
+ );
218
+ expect(fn).toBe(T.boolean);
211
219
  });
212
220
  });
213
221
 
@@ -301,6 +309,51 @@ describe("recursion with memoization", () => {
301
309
  const result = env.lookup("result");
302
310
  expect(result.kind).toBe("unknown");
303
311
  });
312
+
313
+ it("budget-truncated recursion falls back to the function's observed results instead of unknown", () => {
314
+ // eq over chains deeper than MAX_CALL_DEPTH truncates mid-recursion.
315
+ // The first truncation has no observations and stays unknown; precise
316
+ // calls then accumulate true/false; a later truncation (fresh argument
317
+ // shapes, so no memo hit) degrades to the observed union.
318
+ const nest = (n: number, leaf: string): string => (n === 0 ? leaf : `{ x: ${nest(n - 1, leaf)} }`);
319
+ const ast = parse(`
320
+ function eq(a, b) {
321
+ if (a === b) return true;
322
+ if (typeof a !== "object") return false;
323
+ return eq(a.x, b.x);
324
+ }
325
+ const r1 = eq(${nest(70, "1")}, ${nest(70, "2")});
326
+ const t = eq(1, 1);
327
+ const f = eq(1, 2);
328
+ const r2 = eq(${nest(80, "3")}, ${nest(80, "4")});
329
+ `);
330
+ const env = createEnvironment();
331
+ evaluateProgram(ast, env);
332
+ expect(env.lookup("r1").kind).toBe("unknown");
333
+ expect(typeValueToString(env.lookup("t"))).toBe("true");
334
+ expect(typeValueToString(env.lookup("f"))).toBe("false");
335
+ const r2 = env.lookup("r2");
336
+ expect(r2.kind).toBe("union");
337
+ if (r2.kind === "union") {
338
+ expect(r2.members).toHaveLength(2);
339
+ expect(typeValueToString(r2)).toBe("true | false");
340
+ }
341
+ });
342
+
343
+ it("budget-truncated recursion without observed results stays unknown", () => {
344
+ const nest = (n: number, leaf: string): string => (n === 0 ? leaf : `{ x: ${nest(n - 1, leaf)} }`);
345
+ const ast = parse(`
346
+ function eq(a, b) {
347
+ if (a === b) return true;
348
+ if (typeof a !== "object") return false;
349
+ return eq(a.x, b.x);
350
+ }
351
+ const r = eq(${nest(70, "5")}, ${nest(70, "6")});
352
+ `);
353
+ const env = createEnvironment();
354
+ evaluateProgram(ast, env);
355
+ expect(env.lookup("r").kind).toBe("unknown");
356
+ });
304
357
  });
305
358
 
306
359
  describe("modules import/export", () => {
@@ -436,6 +489,304 @@ describe("modules import/export", () => {
436
489
 
437
490
  setModuleResolver(null);
438
491
  });
492
+
493
+ it("resolves require() of a CJS module exporting an object", () => {
494
+ setModuleResolver((source) => {
495
+ if (source === "./b") {
496
+ return {
497
+ ast: parse(`function triple(x) { return x * 3; }\nmodule.exports = { triple };\n`),
498
+ filePath: "/fake/b.js",
499
+ };
500
+ }
501
+ return null;
502
+ });
503
+ setCurrentFileDir("/fake");
504
+
505
+ const ast = parse(`
506
+ const b = require("./b");
507
+ const r = b.triple(4);
508
+ `);
509
+ const env = createEnvironment();
510
+ evaluateProgram(ast, env);
511
+
512
+ const b = env.lookup("b");
513
+ expect(b.kind).toBe("object");
514
+ if (b.kind === "object") {
515
+ expect(b.properties.triple.kind).toBe("function");
516
+ }
517
+ expect(typeValueEquals(env.lookup("r"), T.literal(12))).toBe(true);
518
+
519
+ setModuleResolver(null);
520
+ });
521
+
522
+ it("require() picks up a chained module.exports = internals.x = function assignment", () => {
523
+ setModuleResolver((source) => {
524
+ if (source === "./clone") {
525
+ return {
526
+ ast: parse(`
527
+ const internals = {};
528
+ module.exports = internals.clone = function (obj, options = {}) {
529
+ if (options.shallow) {
530
+ return obj;
531
+ }
532
+ return obj;
533
+ };
534
+ `),
535
+ filePath: "/fake/clone.js",
536
+ };
537
+ }
538
+ return null;
539
+ });
540
+ setCurrentFileDir("/fake");
541
+
542
+ const ast = parse(`
543
+ const Clone = require("./clone");
544
+ const r = Clone(5);
545
+ `);
546
+ const env = createEnvironment();
547
+ evaluateProgram(ast, env);
548
+
549
+ expect(env.lookup("Clone").kind).toBe("function");
550
+ expect(typeValueEquals(env.lookup("r"), T.literal(5))).toBe(true);
551
+
552
+ setModuleResolver(null);
553
+ });
554
+
555
+ it("supports exports.name assignments", () => {
556
+ setModuleResolver((source) => {
557
+ if (source === "./b") {
558
+ return {
559
+ ast: parse(`exports.triple = function (x) { return x * 3; };\n`),
560
+ filePath: "/fake/b.js",
561
+ };
562
+ }
563
+ return null;
564
+ });
565
+ setCurrentFileDir("/fake");
566
+
567
+ const ast = parse(`
568
+ const b = require("./b");
569
+ const r = b.triple(2);
570
+ `);
571
+ const env = createEnvironment();
572
+ evaluateProgram(ast, env);
573
+
574
+ expect(typeValueEquals(env.lookup("r"), T.literal(6))).toBe(true);
575
+
576
+ setModuleResolver(null);
577
+ });
578
+
579
+ it("require() of an ES module returns its exports as properties", () => {
580
+ setModuleResolver((source) => {
581
+ if (source === "./esm") {
582
+ return {
583
+ ast: parse(`export const n = 1;\nexport function f(x) { return x + 1; }\n`),
584
+ filePath: "/fake/esm.js",
585
+ };
586
+ }
587
+ return null;
588
+ });
589
+ setCurrentFileDir("/fake");
590
+
591
+ const ast = parse(`
592
+ const b = require("./esm");
593
+ const r = b.f(b.n);
594
+ `);
595
+ const env = createEnvironment();
596
+ evaluateProgram(ast, env);
597
+
598
+ expect(typeValueEquals(env.lookup("r"), T.literal(2))).toBe(true);
599
+
600
+ setModuleResolver(null);
601
+ });
602
+
603
+ it("require() with a bare specifier returns unknown and records a warning", () => {
604
+ setCurrentFileDir("/fake");
605
+
606
+ const records: UnknownRecord[] = [];
607
+ setUnknownCollector((r) => records.push(r));
608
+ const env = createEnvironment();
609
+ try {
610
+ const ast = parse(`const u = require("@hapi/hoek");\n`);
611
+ evaluateProgram(ast, env);
612
+ } finally {
613
+ setUnknownCollector(null);
614
+ setCurrentFileDir("");
615
+ }
616
+
617
+ expect(env.lookup("u").kind).toBe("unknown");
618
+ expect(records.some((r) => r.kind === "global" && r.name === `require('@hapi/hoek')`)).toBe(true);
619
+ });
620
+
621
+ // --- 模块加载边界守卫(环 / 深度 / 缺失),来源:evaluator.ts loadModuleEnv 加固 ---
622
+
623
+ it("reports a circular import chain a -> b -> a and keeps partial-binding semantics", () => {
624
+ const aAst = parse(`import { bVal } from "./b.js";\nexport const aVal = 1;\nexport const mixed = bVal;\n`);
625
+ const bAst = parse(`import { aVal } from "./a.js";\nexport const bVal = aVal;\n`);
626
+ const files: Record<string, { ast: ReturnType<typeof parse>; filePath: string }> = {
627
+ "./a.js": { ast: aAst, filePath: "/fake/a.js" },
628
+ "./b.js": { ast: bAst, filePath: "/fake/b.js" },
629
+ };
630
+ setModuleResolver((source) => files[source] ?? null);
631
+ setCurrentFileDir("/fake");
632
+
633
+ const records: UnknownRecord[] = [];
634
+ setUnknownCollector((r) => records.push(r));
635
+ const env = createEnvironment();
636
+ evaluateProgram(parse(`import { aVal, mixed } from "./a.js";\n`), env);
637
+
638
+ const cycle = records.find((r) => r.kind === "global" && r.name.startsWith("module-cycle:"));
639
+ expect(cycle).toBeDefined();
640
+ expect(cycle?.name).toBe("module-cycle:a.js");
641
+ expect(cycle?.reason).toBe("Circular module load: /fake/a.js -> /fake/b.js -> /fake/a.js (bindings inside the cycle resolve to their partially evaluated types)");
642
+ // 环内 aVal 在 b 求值时尚未执行 → unknown;环后补齐的导出照常可得
643
+ expect(typeValueEquals(env.lookup("aVal"), T.literal(1))).toBe(true);
644
+ expect(env.lookup("mixed").kind).toBe("unknown");
645
+
646
+ setUnknownCollector(null);
647
+ setModuleResolver(null);
648
+ setCurrentFileDir("");
649
+ });
650
+
651
+ it("reports a mock-module cycle when the mock file re-imports the mocked module", () => {
652
+ const mockAst = parse(`import { helper } from "calc";\nexport const value = helper;\nexport const own = 2;\n`);
653
+ setMockModules(new Map([["calc", { fromPath: "./mockCalc.js" }]]));
654
+ setModuleResolver((source) => {
655
+ if (source === "./mockCalc.js") return { ast: mockAst, filePath: "/fake/mockCalc.js" };
656
+ return null;
657
+ });
658
+ setCurrentFileDir("/fake");
659
+
660
+ const records: UnknownRecord[] = [];
661
+ setUnknownCollector((r) => records.push(r));
662
+ const env = createEnvironment();
663
+ evaluateProgram(parse(`import { value, own } from "calc";\nconst sum = own + 40;\n`), env);
664
+
665
+ const cycle = records.find((r) => r.kind === "global" && r.name.startsWith("module-cycle:"));
666
+ expect(cycle).toBeDefined();
667
+ expect(cycle?.reason).toBe("Circular module load: /fake/mockCalc.js -> /fake/mockCalc.js (bindings inside the cycle resolve to their partially evaluated types)");
668
+ // 环截断后 mock 文件其余导出照常求值,分析不中断
669
+ expect(env.lookup("value").kind).toBe("unknown");
670
+ expect(typeValueEquals(env.lookup("sum"), T.literal(42))).toBe(true);
671
+
672
+ setUnknownCollector(null);
673
+ setModuleResolver(null);
674
+ setCurrentFileDir("");
675
+ resetMockModules();
676
+ });
677
+
678
+ it("truncates module chains deeper than the depth limit and reports the chain", () => {
679
+ // f1 -> f2 -> ... -> f20:第 17 层(f17)触发深度上限(MAX_MODULE_LOAD_DEPTH = 16)
680
+ const files: Record<string, { ast: ReturnType<typeof parse>; filePath: string }> = {};
681
+ for (let i = 1; i <= 20; i++) {
682
+ const body = i < 20
683
+ ? `import { value as inner } from "./f${i + 1}.js";\nexport const value = inner;\n`
684
+ : `export const value = 20;\n`;
685
+ files[`./f${i}.js`] = { ast: parse(body), filePath: `/fake/f${i}.js` };
686
+ }
687
+ setModuleResolver((source) => files[source] ?? null);
688
+ setCurrentFileDir("/fake");
689
+
690
+ const records: UnknownRecord[] = [];
691
+ setUnknownCollector((r) => records.push(r));
692
+ const env = createEnvironment();
693
+ evaluateProgram(parse(`import { value } from "./f1.js";\n`), env);
694
+
695
+ const depth = records.find((r) => r.kind === "global" && r.name.startsWith("module-depth:"));
696
+ expect(depth).toBeDefined();
697
+ expect(depth?.name).toBe("module-depth:f17.js");
698
+ expect(depth?.reason).toContain("depth 17 > 16 max");
699
+ const expectedChain = Array.from({ length: 17 }, (_, k) => `/fake/f${k + 1}.js`).join(" -> ");
700
+ expect(depth?.reason).toContain(expectedChain);
701
+ // 截断为 unknown,而非栈溢出崩溃
702
+ expect(env.lookup("value").kind).toBe("unknown");
703
+
704
+ setUnknownCollector(null);
705
+ setModuleResolver(null);
706
+ setCurrentFileDir("");
707
+ });
708
+
709
+ it("reports a missing @nudo:mock-module target with its full path and falls back to the original", () => {
710
+ setMockModules(new Map([["b", { fromPath: "./nope.js" }]]));
711
+ setModuleResolver((source) => {
712
+ if (source === "b") return { ast: parse(`export const x = 41;\n`), filePath: "/fake/b.js" };
713
+ return null; // "./nope.js" 不存在
714
+ });
715
+ setCurrentFileDir("/fake");
716
+
717
+ const records: UnknownRecord[] = [];
718
+ setUnknownCollector((r) => records.push(r));
719
+ const env = createEnvironment();
720
+ evaluateProgram(parse(`import { x } from "b";\nconst y = x + 1;\n`), env);
721
+
722
+ const missing = records.find((r) => r.kind === "global" && r.name.startsWith("module-missing:"));
723
+ expect(missing).toBeDefined();
724
+ expect(missing?.name).toBe("module-missing:nope.js");
725
+ expect(missing?.reason).toContain('@nudo:mock-module target for "b" not found');
726
+ expect(missing?.reason).toContain("/fake/nope.js");
727
+ // 回落到原模块,mock 缺失不中断分析
728
+ expect(typeValueEquals(env.lookup("y"), T.literal(42))).toBe(true);
729
+
730
+ setUnknownCollector(null);
731
+ setModuleResolver(null);
732
+ setCurrentFileDir("");
733
+ resetMockModules();
734
+ });
735
+
736
+ it("reports an unresolvable relative import with the attempted full path", () => {
737
+ setModuleResolver(() => null);
738
+ setCurrentFileDir("/fake");
739
+
740
+ const records: UnknownRecord[] = [];
741
+ setUnknownCollector((r) => records.push(r));
742
+ const env = createEnvironment();
743
+ evaluateProgram(parse(`import { x } from "./gone.js";\nconst y = 1;\n`), env);
744
+
745
+ const missing = records.find((r) => r.kind === "global" && r.name.startsWith("module-missing:"));
746
+ expect(missing).toBeDefined();
747
+ expect(missing?.reason).toContain("Cannot resolve import './gone.js'");
748
+ expect(missing?.reason).toContain("/fake/gone.js");
749
+ expect(typeValueEquals(env.lookup("y"), T.literal(1))).toBe(true);
750
+
751
+ setUnknownCollector(null);
752
+ setModuleResolver(null);
753
+ setCurrentFileDir("");
754
+ });
755
+
756
+ it("loads nested mock-module dependencies without triggering guard diagnostics", () => {
757
+ const helperAst = parse(`export const h = 2;\n`);
758
+ const mockAst = parse(`import { h } from "./helper.js";\nexport const answer = h * 21;\n`);
759
+ const files: Record<string, { ast: ReturnType<typeof parse>; filePath: string }> = {
760
+ "./helper.js": { ast: helperAst, filePath: "/fake/helper.js" },
761
+ "./mockCalc.js": { ast: mockAst, filePath: "/fake/mockCalc.js" },
762
+ };
763
+ setMockModules(new Map([["calc", { fromPath: "./mockCalc.js" }]]));
764
+ setModuleResolver((source) => files[source] ?? null);
765
+ setCurrentFileDir("/fake");
766
+
767
+ const records: UnknownRecord[] = [];
768
+ setUnknownCollector((r) => records.push(r));
769
+ const env = createEnvironment();
770
+ evaluateProgram(parse(`import { answer } from "calc";\nconst life = answer;\n`), env);
771
+
772
+ // 正常嵌套 mock:不产生任何守卫诊断
773
+ expect(records.filter((r) => r.name.startsWith("module-"))).toHaveLength(0);
774
+ expect(typeValueEquals(env.lookup("life"), T.literal(42))).toBe(true);
775
+
776
+ setUnknownCollector(null);
777
+ setModuleResolver(null);
778
+ setCurrentFileDir("");
779
+ resetMockModules();
780
+ });
781
+
782
+ it("pre-binds __dirname and __filename as strings", () => {
783
+ const ast = parse(`const d = __dirname;\nconst f = __filename;\n`);
784
+ const env = createEnvironment();
785
+ evaluateProgram(ast, env);
786
+
787
+ expect(typeValueEquals(env.lookup("d"), T.string)).toBe(true);
788
+ expect(typeValueEquals(env.lookup("f"), T.string)).toBe(true);
789
+ });
439
790
  });
440
791
 
441
792
  describe("SwitchStatement", () => {