@nudojs/service 0.2.1 → 0.3.0

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 (64) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/dist/index.d.ts +502 -4
  3. package/dist/index.js +3017 -212
  4. package/package.json +5 -4
  5. package/src/__tests__/abs-bindings.test.ts +53 -0
  6. package/src/__tests__/abs-call-records.test.ts +33 -0
  7. package/src/__tests__/abs-calls.test.ts +59 -0
  8. package/src/__tests__/abs-module-cache.test.ts +121 -0
  9. package/src/__tests__/abs-modules-graph.test.ts +100 -0
  10. package/src/__tests__/abs-typevalue-diff.test.ts +111 -0
  11. package/src/__tests__/analyzer.test.ts +71 -3
  12. package/src/__tests__/bpath-arg-provenance.test.ts +104 -0
  13. package/src/__tests__/bpath-as.test.ts +54 -0
  14. package/src/__tests__/bpath-cache-key.test.ts +33 -0
  15. package/src/__tests__/bpath-calls.test.ts +55 -0
  16. package/src/__tests__/bpath-diagnostics.test.ts +78 -0
  17. package/src/__tests__/bpath-env-modules.test.ts +30 -0
  18. package/src/__tests__/bpath-env.test.ts +80 -0
  19. package/src/__tests__/bpath-hosted-skip.test.ts +71 -0
  20. package/src/__tests__/bpath-import-default-ns.test.ts +106 -0
  21. package/src/__tests__/bpath-method-dedupe.test.ts +65 -0
  22. package/src/__tests__/bpath-method-diag.test.ts +45 -0
  23. package/src/__tests__/bpath-module-diags.test.ts +101 -0
  24. package/src/__tests__/bpath-path-env.test.ts +46 -0
  25. package/src/__tests__/bpath-reexport.test.ts +131 -0
  26. package/src/__tests__/bpath-replace.test.ts +52 -0
  27. package/src/__tests__/bpath-require.test.ts +66 -0
  28. package/src/__tests__/bpath-run.test.ts +109 -0
  29. package/src/__tests__/cjs-require.test.ts +54 -0
  30. package/src/__tests__/diagnostics.test.ts +0 -30
  31. package/src/__tests__/fn-cache-evict.test.ts +56 -0
  32. package/src/__tests__/harvest-auto.test.ts +43 -0
  33. package/src/__tests__/harvest-node.test.ts +24 -0
  34. package/src/__tests__/harvest-package.test.ts +38 -0
  35. package/src/__tests__/harvest-to-abs.test.ts +46 -0
  36. package/src/__tests__/hover-intension.test.ts +39 -0
  37. package/src/__tests__/infer-json.test.ts +35 -0
  38. package/src/__tests__/integration.test.ts +89 -0
  39. package/src/__tests__/m3-intension.test.ts +28 -0
  40. package/src/__tests__/mock-seed-fingerprint.test.ts +64 -0
  41. package/src/__tests__/session-cache-evict.test.ts +90 -0
  42. package/src/__tests__/static-imports.test.ts +44 -0
  43. package/src/abs-modules-graph.ts +387 -0
  44. package/src/analysis-file-cache.ts +51 -0
  45. package/src/analyzer.ts +1416 -124
  46. package/src/bpath-diagnostics.ts +283 -0
  47. package/src/bpath-run.ts +409 -0
  48. package/src/case-emitter.ts +2 -0
  49. package/src/dts-generator.ts +3 -3
  50. package/src/env-to-abs.ts +57 -0
  51. package/src/fn-analysis-cache.ts +119 -0
  52. package/src/guard-generator.ts +8 -1
  53. package/src/harvest-auto.ts +86 -0
  54. package/src/harvest-node.ts +48 -0
  55. package/src/harvest-package.ts +204 -0
  56. package/src/harvest-to-abs.ts +129 -0
  57. package/src/index.ts +104 -1
  58. package/src/infer-json.ts +137 -0
  59. package/src/load-module.ts +34 -0
  60. package/src/mock-abs.ts +275 -0
  61. package/src/semantic-tokens.ts +216 -0
  62. package/src/session-cache.ts +50 -0
  63. package/src/static-imports.ts +241 -0
  64. package/src/target-path.ts +17 -0
@@ -0,0 +1,104 @@
1
+ import { it, expect, describe, afterAll } from "vitest";
2
+ import { analyzeFile, clearBPathCache } from "@nudojs/service";
3
+ import { mkdtempSync, writeFileSync, rmSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+
7
+ const dirs: string[] = [];
8
+ afterAll(() => {
9
+ for (const d of dirs) rmSync(d, { recursive: true, force: true });
10
+ });
11
+
12
+ function analyze(src: string) {
13
+ const dir = mkdtempSync(join(tmpdir(), "nudo-prov-"));
14
+ dirs.push(dir);
15
+ writeFileSync(join(dir, "package.json"), JSON.stringify({ name: "prov", version: "1.0.0" }));
16
+ writeFileSync(join(dir, "index.js"), src);
17
+ clearBPathCache();
18
+ return analyzeFile(join(dir, "index.js"), src);
19
+ }
20
+
21
+ describe("B argument provenance on method-missing", () => {
22
+ it("origin points at the argument literal, not just the callee", () => {
23
+ const src = `function badNum(n) {
24
+ return n.toUpperCase();
25
+ }
26
+ const boom = badNum(42);
27
+ `;
28
+ const r = analyze(src);
29
+ const diags = r.diagnostics.filter(
30
+ (d) => d.code === "nudo:no-method" && d.message.includes("toUpperCase"),
31
+ );
32
+ expect(diags.length).toBe(1);
33
+ const origin = diags[0]!.origin;
34
+ expect(origin).toBeDefined();
35
+ // `const boom = badNum(42);` — 42 starts at column 20
36
+ expect(origin!.line).toBe(4);
37
+ expect(origin!.column).toBeGreaterThanOrEqual(18);
38
+ expect(origin!.column).toBeLessThanOrEqual(22);
39
+ });
40
+
41
+ it("mocked top-level call does not hit real fetch", () => {
42
+ const src = `// @nudo:mock fetch = sinon.stub().onFirstCall().returns({ data: "first" })
43
+ function run() { return fetch(); }
44
+ const r = run();
45
+ function badNum(n) {
46
+ return n.toUpperCase();
47
+ }
48
+ const boom = badNum(7);
49
+ `;
50
+ const r = analyze(src);
51
+ const fetchErrs = r.diagnostics.filter(
52
+ (d) => d.message.includes("fetch") && d.severity === "error",
53
+ );
54
+ expect(fetchErrs).toHaveLength(0);
55
+ const diags = r.diagnostics.filter(
56
+ (d) => d.code === "nudo:no-method" && d.message.includes("toUpperCase"),
57
+ );
58
+ expect(diags.length).toBe(1);
59
+ expect(diags[0]!.origin?.column).toBeGreaterThanOrEqual(16);
60
+ });
61
+
62
+ it("unknown-global is not double-reported with builtin-unknown", () => {
63
+ const src = `function f() {
64
+ return WeakRef;
65
+ }
66
+ const boom = f();
67
+ `;
68
+ const r = analyze(src);
69
+ const globals = r.diagnostics.filter((d) => d.code === "nudo:unknown-global");
70
+ const builtins = r.diagnostics.filter((d) => d.code === "nudo:builtin-unknown");
71
+ expect(builtins.some((d) => d.message.includes("WeakRef"))).toBe(true);
72
+ expect(globals.filter((d) => d.message.includes("WeakRef"))).toHaveLength(0);
73
+ });
74
+
75
+ it("B reports unknown-recv for method on unknown param (no TypeValue double)", () => {
76
+ const src = `function lonely(u) {
77
+ return u.toUpperCase();
78
+ }
79
+ `;
80
+ const r = analyze(src);
81
+ const unknownRecv = r.diagnostics.filter(
82
+ (d) => d.code === "nudo:unknown-recv" && d.message.includes("toUpperCase"),
83
+ );
84
+ expect(unknownRecv.length).toBe(1);
85
+ expect(unknownRecv[0]!.severity).toBe("warning");
86
+ // B 已报则不应再有 no-method 同名
87
+ expect(
88
+ r.diagnostics.filter((d) => d.code === "nudo:no-method" && d.message.includes("toUpperCase")),
89
+ ).toHaveLength(0);
90
+ });
91
+
92
+ it("B reports unknown-recv for property on unknown", () => {
93
+ const src = `function lonely(u) {
94
+ return u.foo;
95
+ }
96
+ `;
97
+ const r = analyze(src);
98
+ const diags = r.diagnostics.filter(
99
+ (d) => (d.code === "nudo:unknown-recv" || d.code === "nudo:no-method") && d.message.includes("foo"),
100
+ );
101
+ expect(diags.length).toBeGreaterThanOrEqual(1);
102
+ expect(diags.some((d) => d.code === "nudo:unknown-recv")).toBe(true);
103
+ });
104
+ });
@@ -0,0 +1,54 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { analyzeFile } from "@nudojs/service";
3
+ import { typeValueToString } from "@nudojs/core";
4
+
5
+ describe("@nudo:as on B path", () => {
6
+ it("overrides VariableDeclaration init value", () => {
7
+ const source = `
8
+ /**
9
+ * @nudo:case "test" ("{}")
10
+ */
11
+ function loadConfig(path) {
12
+ // @nudo:as T.object({ port: T.number, host: T.string })
13
+ const config = JSON.parse(path);
14
+ return config;
15
+ }
16
+ `;
17
+ const result = analyzeFile("/test/as.js", source);
18
+ const res = typeValueToString(result.functions[0].cases[0].result);
19
+ expect(res).toContain("port: number");
20
+ expect(res).toContain("host: string");
21
+ });
22
+
23
+ it("overrides ReturnStatement value", () => {
24
+ const source = `
25
+ /**
26
+ * @nudo:case "test" ("{}")
27
+ */
28
+ function loadConfig(path) {
29
+ // @nudo:as T.object({ name: T.string, age: T.number })
30
+ return JSON.parse(path);
31
+ }
32
+ `;
33
+ const result = analyzeFile("/test/as2.js", source);
34
+ const res = typeValueToString(result.functions[0].cases[0].result);
35
+ expect(res).toContain("name: string");
36
+ expect(res).toContain("age: number");
37
+ });
38
+
39
+ it("does not affect statements without @nudo:as", () => {
40
+ const source = `
41
+ /**
42
+ * @nudo:case "test" (5)
43
+ */
44
+ function add(x) {
45
+ // @nudo:as T.literal(99)
46
+ const y = x + 1;
47
+ const z = x + 2;
48
+ return z;
49
+ }
50
+ `;
51
+ const result = analyzeFile("/test/as3.js", source);
52
+ expect(typeValueToString(result.functions[0].cases[0].result)).toBe("7");
53
+ });
54
+ });
@@ -0,0 +1,33 @@
1
+ import { it, expect, describe, afterEach } from "vitest";
2
+ import { tryRunBPath, tryBPathCall, clearBPathCache } from "@nudojs/service";
3
+ import { litValue } from "@nudojs/core";
4
+
5
+ afterEach(() => clearBPathCache());
6
+
7
+ describe("bRunCache key", () => {
8
+ // 同长度、前 200+ 字符完全相同、尾部不同的两份源码:
9
+ // 旧前缀截断键会命中同一条缓存,静默返回陈旧结果。
10
+ const head = `// ${"x".repeat(210)}\nexport function f() { return `;
11
+ const srcA = `${head}1; }`;
12
+ const srcB = `${head}2; }`;
13
+
14
+ it("same source twice returns the identical cached run", () => {
15
+ const a = tryRunBPath(srcA, "/test/cache-key.js");
16
+ const b = tryRunBPath(srcA, "/test/cache-key.js");
17
+ expect(a).toBeDefined();
18
+ expect(b).toBe(a);
19
+ });
20
+
21
+ it("same length + same prefix but different tail must not collide", () => {
22
+ const r1 = tryRunBPath(srcA, "/test/cache-key.js");
23
+ const r2 = tryRunBPath(srcB, "/test/cache-key.js");
24
+ expect(r1).toBeDefined();
25
+ expect(r2).toBeDefined();
26
+ expect(r2).not.toBe(r1);
27
+
28
+ // 语义正确性:srcB 的求值必须看到 f() = 2,而非 srcA 的陈旧 1
29
+ const out = tryBPathCall(srcB, "/test/cache-key.js", "f", []);
30
+ if (!out) throw new Error("expected f to resolve via B path");
31
+ expect(litValue(out)).toBe(2);
32
+ });
33
+ });
@@ -0,0 +1,55 @@
1
+ import { describe, it, expect, afterAll } from "vitest";
2
+ import { mkdtempSync, writeFileSync, rmSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { tryBPathCallFull, analyzeFile } from "@nudojs/service";
6
+ import { $lit, litValue, typeValueToString } from "@nudojs/core";
7
+
8
+ const dirs: string[] = [];
9
+ afterAll(() => {
10
+ for (const d of dirs) rmSync(d, { recursive: true, force: true });
11
+ });
12
+
13
+ describe("B-path call site collection", () => {
14
+ it("records calls during B evaluation", () => {
15
+ const dir = mkdtempSync(join(tmpdir(), "nudo-bcall-"));
16
+ dirs.push(dir);
17
+ const src = `
18
+ function helper(x) { return x + 1; }
19
+ export function caller(n) { return helper(n); }
20
+ `;
21
+ const p = join(dir, "main.js");
22
+ writeFileSync(p, src, "utf-8");
23
+ const full = tryBPathCallFull(src, p, "caller", [$lit(4)], { collectCalls: true });
24
+ expect(full).toBeDefined();
25
+ expect(litValue(full!.result)).toBe(5);
26
+ expect(full!.calls?.length).toBeGreaterThanOrEqual(1);
27
+ expect(full!.calls!.map((c) => c.fnName)).toContain("helper");
28
+ });
29
+
30
+ it("analyzeFile still synthesizes call@ for uncalled helper", () => {
31
+ const source = `
32
+ function uncalled(x) {
33
+ return x * 2;
34
+ }
35
+
36
+ /**
37
+ * @nudo:case "t" (5)
38
+ */
39
+ function caller(y) {
40
+ return uncalled(y);
41
+ }
42
+ `;
43
+ const result = analyzeFile("/test/bcall.js", source);
44
+ const uncalled = result.functions.find((f) => f.name === "uncalled");
45
+ expect(uncalled).toBeDefined();
46
+ expect(uncalled!.cases.length).toBeGreaterThanOrEqual(1);
47
+ const names = uncalled!.cases.map((c) => c.name);
48
+ expect(names.some((n) => n.startsWith("call@") || n.startsWith("entry@"))).toBe(true);
49
+ // 结果应为 10
50
+ const hit = uncalled!.cases.find((c) => c.name.startsWith("call@"));
51
+ if (hit) {
52
+ expect(typeValueToString(hit.result)).toBe("10");
53
+ }
54
+ });
55
+ });
@@ -0,0 +1,78 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { collectBPathDiagnostics } from "@nudojs/service";
3
+
4
+ describe("B-path static diagnostics", () => {
5
+ it("marks statements after return as unreachable", () => {
6
+ const src = `
7
+ function f() {
8
+ return 1;
9
+ const dead = 2;
10
+ console.log("no");
11
+ }
12
+ `;
13
+ const d = collectBPathDiagnostics(src);
14
+ expect(d.unreachable.length).toBeGreaterThanOrEqual(1);
15
+ // dead 声明与后续 console
16
+ const lines = d.unreachable.map((u) => u.range.start.line);
17
+ expect(lines.some((l) => l >= 4)).toBe(true);
18
+ });
19
+
20
+ it("marks statements after throw as unreachable", () => {
21
+ const src = `
22
+ function f(n) {
23
+ if (n) {
24
+ throw new Error("x");
25
+ return 1;
26
+ }
27
+ return 0;
28
+ }
29
+ `;
30
+ const d = collectBPathDiagnostics(src);
31
+ expect(d.unreachable.length).toBeGreaterThanOrEqual(1);
32
+ });
33
+
34
+ it("does not flag code after if/else both return", () => {
35
+ const src = `
36
+ function f(n) {
37
+ if (n) { return 1; } else { return 2; }
38
+ }
39
+ `;
40
+ const d = collectBPathDiagnostics(src);
41
+ // if 内无后续语句
42
+ expect(d.unreachable).toHaveLength(0);
43
+ });
44
+
45
+ it("flags unknown global calls as builtin-unknown", () => {
46
+ const src = `
47
+ function f() {
48
+ return someNativeApi(1);
49
+ }
50
+ `;
51
+ const d = collectBPathDiagnostics(src);
52
+ expect(d.builtinUnknown.map((b) => b.name)).toContain("someNativeApi");
53
+ });
54
+
55
+ it("extraKnown (@nudo:mock / @nudo:env covered) globals are not flagged", () => {
56
+ const src = `
57
+ function f() {
58
+ return someNativeApi(1);
59
+ }
60
+ `;
61
+ expect(collectBPathDiagnostics(src, ["someNativeApi"]).builtinUnknown).toHaveLength(0);
62
+ // 无覆盖时仍报
63
+ expect(collectBPathDiagnostics(src).builtinUnknown.map((b) => b.name)).toContain("someNativeApi");
64
+ });
65
+
66
+ it("does not flag locals, imports, or known globals", () => {
67
+ const src = `
68
+ import { helper } from "./h.js";
69
+ function local(x) { return x; }
70
+ function f() {
71
+ const y = local(1);
72
+ return helper(y) + Math.max(1, 2) + JSON.stringify(y);
73
+ }
74
+ `;
75
+ const d = collectBPathDiagnostics(src);
76
+ expect(d.builtinUnknown).toHaveLength(0);
77
+ });
78
+ });
@@ -0,0 +1,30 @@
1
+ import { describe, it, expect, afterAll } from "vitest";
2
+ import { mkdtempSync, writeFileSync, rmSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { analyzeFile, clearBPathCache } from "@nudojs/service";
6
+ import { typeValueToString } from "@nudojs/core";
7
+
8
+ const dirs: string[] = [];
9
+ afterAll(() => {
10
+ for (const d of dirs) rmSync(d, { recursive: true, force: true });
11
+ });
12
+
13
+ describe("B-path env modules (node:path)", () => {
14
+ it("path.join via import from node:path", () => {
15
+ clearBPathCache();
16
+ const source = `/// @nudo:env node
17
+
18
+ import { join } from "node:path";
19
+
20
+ /**
21
+ * @nudo:case "test" ("src", "utils", "index.js")
22
+ */
23
+ function buildPath(a, b, c) {
24
+ return join(a, b, c);
25
+ }
26
+ `;
27
+ const result = analyzeFile("/test/pathmod.js", source);
28
+ expect(typeValueToString(result.functions[0].cases[0].result)).toBe('"src/utils/index.js"');
29
+ });
30
+ });
@@ -0,0 +1,80 @@
1
+ import { describe, it, expect, afterAll } from "vitest";
2
+ import { mkdtempSync, writeFileSync, rmSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { analyzeFile, tryBPathCall, isBPathCapable, clearBPathCache } from "@nudojs/service";
6
+ import { typeValueToString, $lit, absToString } from "@nudojs/core";
7
+
8
+ const dirs: string[] = [];
9
+ afterAll(() => {
10
+ for (const d of dirs) rmSync(d, { recursive: true, force: true });
11
+ });
12
+
13
+ describe("B-path @nudo:env", () => {
14
+ it("isBPathCapable allows builtin and path env", () => {
15
+ expect(isBPathCapable("function f() { return 1; }", ["es"])).toBe(true);
16
+ expect(isBPathCapable("function f() { return 1; }", ["./custom.ts"])).toBe(true);
17
+ });
18
+
19
+ it("JSON.parse via @nudo:env es", () => {
20
+ clearBPathCache();
21
+ const dir = mkdtempSync(join(tmpdir(), "nudo-env-"));
22
+ dirs.push(dir);
23
+ const main = `/// @nudo:env es
24
+
25
+ /**
26
+ * @nudo:case "t" ("{}")
27
+ */
28
+ export function parseId(s) {
29
+ return typeof JSON.parse(s);
30
+ }
31
+ `;
32
+ const p = join(dir, "main.js");
33
+ writeFileSync(p, main, "utf-8");
34
+ const r = tryBPathCall(main, p, "parseId", [$lit("{}")], { envNames: ["es"] });
35
+ expect(r).toBeDefined();
36
+ expect(absToString(r!)).toContain("string");
37
+ });
38
+
39
+ it("analyzeFile works with @nudo:env es", () => {
40
+ clearBPathCache();
41
+ const source = `/// @nudo:env es
42
+
43
+ /**
44
+ * @nudo:case "t" (5)
45
+ */
46
+ function addOne(n) {
47
+ return Math.floor(n) + 1;
48
+ }
49
+ `;
50
+ const result = analyzeFile("/test/env.js", source);
51
+ const fn = result.functions.find((f) => f.name === "addOne");
52
+ expect(fn).toBeDefined();
53
+ expect(typeValueToString(fn!.cases[0].result)).toBe("6");
54
+ });
55
+
56
+ it("arrowFn @nudo:mock injects into B-path (no real fetch call)", () => {
57
+ clearBPathCache();
58
+ const dir = mkdtempSync(join(tmpdir(), "nudo-mock-b-"));
59
+ dirs.push(dir);
60
+ // 回归:arrowFn mock 落在 seedFns,此前 B 路径注入只吃 seedVars——
61
+ // 函数体内的 fetch 调用落到真实原生 fetch,拿 Abs 当 URL 直接崩
62
+ const source = `// @nudo:mock fetch = (url) => ({ ok: true, json: () => ({ id: 1, name: "ada" }) })
63
+ async function loadUser(id) {
64
+ const res = await fetch("/users/" + id);
65
+ return res.json();
66
+ }
67
+ loadUser(42);
68
+ `;
69
+ const p = join(dir, "main.js");
70
+ writeFileSync(p, source, "utf-8");
71
+ const result = analyzeFile(p, source);
72
+ const fn = result.functions.find((f) => f.name === "loadUser");
73
+ expect(fn).toBeDefined();
74
+ const call = fn!.cases.find((c) => c.name.startsWith("call@"));
75
+ expect(call).toBeDefined();
76
+ expect(call!.intension?.abs).toContain("ada");
77
+ // mock 覆盖后 fetch 不再是 builtin-unknown
78
+ expect(result.diagnostics.some((d) => d.code === "nudo:builtin-unknown")).toBe(false);
79
+ });
80
+ });
@@ -0,0 +1,71 @@
1
+ import { it, expect, describe, afterAll } from "vitest";
2
+ import { analyzeFile, clearBPathCache, isBPathCapable } from "@nudojs/service";
3
+ import { mkdtempSync, writeFileSync, rmSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+
7
+ const dirs: string[] = [];
8
+ afterAll(() => {
9
+ for (const d of dirs) rmSync(d, { recursive: true, force: true });
10
+ });
11
+
12
+ function analyze(src: string, file = "index.js") {
13
+ const dir = mkdtempSync(join(tmpdir(), "nudo-hosted-"));
14
+ dirs.push(dir);
15
+ writeFileSync(join(dir, "package.json"), JSON.stringify({ name: "hosted", version: "1.0.0" }));
16
+ const p = join(dir, file);
17
+ writeFileSync(p, src);
18
+ clearBPathCache();
19
+ return { result: analyzeFile(p, src), path: p, src };
20
+ }
21
+
22
+ describe("B-hosted skips TypeValue evaluateProgram", () => {
23
+ it("capable file is B-hosted and still infers cases", () => {
24
+ const src = `/**
25
+ * @nudo:case "n" (3)
26
+ */
27
+ export function scale(x) {
28
+ return x * 2 + 1;
29
+ }
30
+ `;
31
+ const { result, path } = analyze(src);
32
+ expect(isBPathCapable(src, [])).toBe(true);
33
+ const fn = result.functions.find((f) => f.name === "scale");
34
+ expect(fn?.cases).toHaveLength(1);
35
+ expect(fn?.cases[0]?.result.kind).toBe("literal");
36
+ // 无 TypeValue 专有叠报
37
+ expect(result.diagnostics.filter((d) => d.code === "nudo:unknown-global")).toHaveLength(0);
38
+ expect(result.bindings.has("scale")).toBe(true);
39
+ });
40
+
41
+ it("import + top-level call still yields call@ cases", () => {
42
+ const dir = mkdtempSync(join(tmpdir(), "nudo-hosted2-"));
43
+ dirs.push(dir);
44
+ writeFileSync(join(dir, "package.json"), JSON.stringify({ name: "h2", version: "1.0.0" }));
45
+ writeFileSync(
46
+ join(dir, "util.js"),
47
+ `export function twice(n) { return n * 2; }
48
+ `,
49
+ );
50
+ const src = `import { twice } from "./util.js";
51
+ const boom = twice(21);
52
+ export function go(n) { return twice(n); }
53
+ `;
54
+ writeFileSync(join(dir, "index.js"), src);
55
+ clearBPathCache();
56
+ const r = analyzeFile(join(dir, "index.js"), src);
57
+ const go = r.functions.find((f) => f.name === "go");
58
+ const twice = r.functions.find((f) => f.name === "twice");
59
+ expect(go).toBeDefined();
60
+ // 跨文件导出可能合成 externalFunctions 或本文件 twice
61
+ expect(go!.cases.length + (twice?.cases.length ?? 0)).toBeGreaterThan(0);
62
+ });
63
+
64
+ it("nodeTypeMap is populated without TypeValue env walk", () => {
65
+ const src = `const n = 1;
66
+ export function id(x) { return x; }
67
+ `;
68
+ const { result } = analyze(src);
69
+ expect(result.nodeTypeMap.size).toBeGreaterThan(0);
70
+ });
71
+ });
@@ -0,0 +1,106 @@
1
+ import { it, expect, describe, afterAll } from "vitest";
2
+ import { analyzeFile, clearBPathCache } from "@nudojs/service";
3
+ import { mkdtempSync, writeFileSync, rmSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+
7
+ const dirs: string[] = [];
8
+ afterAll(() => {
9
+ for (const d of dirs) rmSync(d, { recursive: true, force: true });
10
+ });
11
+
12
+ function setup() {
13
+ const dir = mkdtempSync(join(tmpdir(), "nudo-imp-"));
14
+ dirs.push(dir);
15
+ writeFileSync(join(dir, "package.json"), JSON.stringify({ name: "imp", version: "1.0.0" }));
16
+ writeFileSync(
17
+ join(dir, "util.js"),
18
+ `export function add(a, b) {
19
+ return a + b;
20
+ }
21
+ export default function mul(a, b) {
22
+ return a * b;
23
+ }
24
+ `,
25
+ );
26
+ return dir;
27
+ }
28
+
29
+ function goResult(dir: string, src: string) {
30
+ writeFileSync(join(dir, "index.js"), src);
31
+ const r = analyzeFile(join(dir, "index.js"), src);
32
+ const go = r.functions.find((f) => f.name === "go");
33
+ return { result: go?.cases[0]?.result, diagnostics: r.diagnostics };
34
+ }
35
+
36
+ describe("B-path import default / namespace", () => {
37
+ it("named import", () => {
38
+ clearBPathCache();
39
+ const dir = setup();
40
+ const { result } = goResult(
41
+ dir,
42
+ `import { add } from "./util.js";
43
+ /**
44
+ * @nudo:case "nums" (1, 2)
45
+ */
46
+ export function go(a, b) {
47
+ return add(a, b);
48
+ }
49
+ `,
50
+ );
51
+ expect(result).toMatchObject({ kind: "literal", value: 3 });
52
+ });
53
+
54
+ it("default import of export default function", () => {
55
+ clearBPathCache();
56
+ const dir = setup();
57
+ const { result, diagnostics } = goResult(
58
+ dir,
59
+ `import mul from "./util.js";
60
+ /**
61
+ * @nudo:case "nums" (3, 4)
62
+ */
63
+ export function go(a, b) {
64
+ return mul(a, b);
65
+ }
66
+ `,
67
+ );
68
+ expect(result).toMatchObject({ kind: "literal", value: 12 });
69
+ expect(diagnostics.filter((d) => d.severity === "error")).toHaveLength(0);
70
+ });
71
+
72
+ it("namespace import member call", () => {
73
+ clearBPathCache();
74
+ const dir = setup();
75
+ const { result, diagnostics } = goResult(
76
+ dir,
77
+ `import * as util from "./util.js";
78
+ /**
79
+ * @nudo:case "nums" (5, 6)
80
+ */
81
+ export function go(a, b) {
82
+ return util.add(a, b);
83
+ }
84
+ `,
85
+ );
86
+ expect(result).toMatchObject({ kind: "literal", value: 11 });
87
+ expect(diagnostics.filter((d) => d.severity === "error")).toHaveLength(0);
88
+ });
89
+
90
+ it("namespace import default slot", () => {
91
+ clearBPathCache();
92
+ const dir = setup();
93
+ const { result } = goResult(
94
+ dir,
95
+ `import * as util from "./util.js";
96
+ /**
97
+ * @nudo:case "nums" (2, 5)
98
+ */
99
+ export function go(a, b) {
100
+ return util.default(a, b);
101
+ }
102
+ `,
103
+ );
104
+ expect(result).toMatchObject({ kind: "literal", value: 10 });
105
+ });
106
+ });
@@ -0,0 +1,65 @@
1
+ import { it, expect, describe } from "vitest";
2
+ import { analyzeFile } from "@nudojs/service";
3
+ import { mkdtempSync, writeFileSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+
7
+ function analyze(src: string) {
8
+ const dir = mkdtempSync(join(tmpdir(), "nudo-bdedup-"));
9
+ writeFileSync(join(dir, "package.json"), JSON.stringify({ name: "bdedup", version: "1.0.0" }));
10
+ writeFileSync(join(dir, "index.js"), src);
11
+ return analyzeFile(join(dir, "index.js"), src);
12
+ }
13
+
14
+ describe("B method-missing provenance + dedupe", () => {
15
+ it("no-method is not double-reported when B and TypeValue both see the call", () => {
16
+ // B case 执行 + evaluateProgram 顶层 const 调用都会碰到 toUpperCase 缺失;
17
+ // 按名去重后只应剩一条。
18
+ const src = `
19
+ /**
20
+ * @nudo:case "n" (42)
21
+ */
22
+ function demo(n) {
23
+ return n.toUpperCase();
24
+ }
25
+
26
+ const boom = demo(42);
27
+ `;
28
+ const r = analyze(src);
29
+ const methodDiags = r.diagnostics.filter(
30
+ (d) => d.code === "nudo:no-method" && d.message.includes("toUpperCase"),
31
+ );
32
+ expect(methodDiags.length).toBe(1);
33
+ });
34
+
35
+ it("TypeValue-only method diags are kept when B does not report the name", () => {
36
+ const src = `
37
+ function badNum(n) {
38
+ return n.toUpperCase();
39
+ }
40
+ const boom = badNum(42);
41
+ `;
42
+ const r = analyze(src);
43
+ const diags = r.diagnostics.filter((d) => d.code === "nudo:no-method");
44
+ expect(diags.length).toBeGreaterThanOrEqual(1);
45
+ expect(diags.some((d) => d.message.includes("toUpperCase"))).toBe(true);
46
+ });
47
+
48
+ it("same name:line is reported at most once", () => {
49
+ const src = `
50
+ /**
51
+ * @nudo:case "null" (null)
52
+ * @nudo:case "num" (1)
53
+ */
54
+ function demo(o) {
55
+ return o.foo;
56
+ }
57
+ `;
58
+ const r = analyze(src);
59
+ const fooDiags = r.diagnostics.filter(
60
+ (d) => d.code === "nudo:no-method" && d.message.includes("'foo'"),
61
+ );
62
+ const keys = fooDiags.map((d) => `${d.range.start.line}:${d.message}`);
63
+ expect(new Set(keys).size).toBe(keys.length);
64
+ });
65
+ });