@nudojs/service 0.2.0 → 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 (72) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/README.md +27 -0
  3. package/dist/index.d.ts +638 -5
  4. package/dist/index.js +4439 -208
  5. package/package.json +17 -4
  6. package/src/__tests__/abs-bindings.test.ts +53 -0
  7. package/src/__tests__/abs-call-records.test.ts +33 -0
  8. package/src/__tests__/abs-calls.test.ts +59 -0
  9. package/src/__tests__/abs-module-cache.test.ts +121 -0
  10. package/src/__tests__/abs-modules-graph.test.ts +100 -0
  11. package/src/__tests__/abs-typevalue-diff.test.ts +111 -0
  12. package/src/__tests__/analyzer.test.ts +745 -2
  13. package/src/__tests__/as-replace.test.ts +165 -0
  14. package/src/__tests__/bpath-arg-provenance.test.ts +104 -0
  15. package/src/__tests__/bpath-as.test.ts +54 -0
  16. package/src/__tests__/bpath-cache-key.test.ts +33 -0
  17. package/src/__tests__/bpath-calls.test.ts +55 -0
  18. package/src/__tests__/bpath-diagnostics.test.ts +78 -0
  19. package/src/__tests__/bpath-env-modules.test.ts +30 -0
  20. package/src/__tests__/bpath-env.test.ts +80 -0
  21. package/src/__tests__/bpath-hosted-skip.test.ts +71 -0
  22. package/src/__tests__/bpath-import-default-ns.test.ts +106 -0
  23. package/src/__tests__/bpath-method-dedupe.test.ts +65 -0
  24. package/src/__tests__/bpath-method-diag.test.ts +45 -0
  25. package/src/__tests__/bpath-module-diags.test.ts +101 -0
  26. package/src/__tests__/bpath-path-env.test.ts +46 -0
  27. package/src/__tests__/bpath-reexport.test.ts +131 -0
  28. package/src/__tests__/bpath-replace.test.ts +52 -0
  29. package/src/__tests__/bpath-require.test.ts +66 -0
  30. package/src/__tests__/bpath-run.test.ts +109 -0
  31. package/src/__tests__/case-emitter.test.ts +636 -0
  32. package/src/__tests__/cjs-require.test.ts +54 -0
  33. package/src/__tests__/diagnostics.test.ts +60 -0
  34. package/src/__tests__/dts-generator.test.ts +290 -6
  35. package/src/__tests__/fn-cache-evict.test.ts +56 -0
  36. package/src/__tests__/fn-sig-impl.test.ts +247 -0
  37. package/src/__tests__/guard-generator.test.ts +29 -0
  38. package/src/__tests__/harvest-auto.test.ts +43 -0
  39. package/src/__tests__/harvest-node.test.ts +24 -0
  40. package/src/__tests__/harvest-package.test.ts +38 -0
  41. package/src/__tests__/harvest-to-abs.test.ts +46 -0
  42. package/src/__tests__/hover-intension.test.ts +39 -0
  43. package/src/__tests__/infer-json.test.ts +35 -0
  44. package/src/__tests__/integration.test.ts +421 -0
  45. package/src/__tests__/m3-intension.test.ts +28 -0
  46. package/src/__tests__/mock-seed-fingerprint.test.ts +64 -0
  47. package/src/__tests__/schema-generator.test.ts +38 -0
  48. package/src/__tests__/session-cache-evict.test.ts +90 -0
  49. package/src/__tests__/static-imports.test.ts +44 -0
  50. package/src/abs-modules-graph.ts +387 -0
  51. package/src/analysis-file-cache.ts +51 -0
  52. package/src/analyzer.ts +2569 -94
  53. package/src/bpath-diagnostics.ts +283 -0
  54. package/src/bpath-run.ts +409 -0
  55. package/src/case-emitter.ts +477 -0
  56. package/src/dts-generator.ts +220 -24
  57. package/src/env-to-abs.ts +57 -0
  58. package/src/fn-analysis-cache.ts +119 -0
  59. package/src/guard-generator.ts +57 -0
  60. package/src/harvest-auto.ts +86 -0
  61. package/src/harvest-node.ts +48 -0
  62. package/src/harvest-package.ts +204 -0
  63. package/src/harvest-to-abs.ts +129 -0
  64. package/src/index.ts +128 -0
  65. package/src/infer-json.ts +137 -0
  66. package/src/load-module.ts +34 -0
  67. package/src/mock-abs.ts +275 -0
  68. package/src/schema-generator.ts +43 -0
  69. package/src/semantic-tokens.ts +216 -0
  70. package/src/session-cache.ts +50 -0
  71. package/src/static-imports.ts +241 -0
  72. package/src/target-path.ts +17 -0
package/package.json CHANGED
@@ -1,13 +1,25 @@
1
1
  {
2
2
  "name": "@nudojs/service",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
+ "description": "Shared inference service for Nudo IDE integrations — file analysis, completions, and .d.ts generation",
4
5
  "type": "module",
6
+ "license": "MIT",
7
+ "keywords": [
8
+ "nudo",
9
+ "type-inference",
10
+ "javascript",
11
+ "language-service",
12
+ "dts-generation",
13
+ "analysis"
14
+ ],
5
15
  "main": "src/index.ts",
6
16
  "repository": {
7
17
  "type": "git",
8
18
  "url": "git+https://github.com/nudojs/nudo.git",
9
19
  "directory": "packages/service"
10
20
  },
21
+ "homepage": "https://github.com/nudojs/nudo/tree/main/packages/service",
22
+ "bugs": "https://github.com/nudojs/nudo/issues",
11
23
  "publishConfig": {
12
24
  "access": "public",
13
25
  "provenance": true,
@@ -16,9 +28,10 @@
16
28
  "dependencies": {
17
29
  "@babel/traverse": "^7.29.0",
18
30
  "@babel/types": "^7.29.0",
19
- "@nudojs/core": "0.2.0",
20
- "@nudojs/parser": "0.2.0",
21
- "@nudojs/cli": "0.2.0"
31
+ "@nudojs/core": "0.3.0",
32
+ "@nudojs/parser": "0.3.0",
33
+ "@nudojs/cli": "0.3.0",
34
+ "@nudojs/harvester": "0.2.0"
22
35
  },
23
36
  "scripts": {
24
37
  "build": "tsup src/index.ts --format esm --dts"
@@ -0,0 +1,53 @@
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 { collectAbsBindingsFromGraph, analyzeFile, getHoverAtPosition } from "@nudojs/service";
6
+ import { formatAbs, 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("Abs bindings from module graph", () => {
14
+ it("includes local functions and imported names", () => {
15
+ const dir = mkdtempSync(join(tmpdir(), "nudo-abs-bind-"));
16
+ dirs.push(dir);
17
+ writeFileSync(join(dir, "math.js"), `export function double(x) { return x * 2; }\n`);
18
+ const main = `import { double } from "./math.js";
19
+ export function run(n) { return double(n); }
20
+ const k = 1;
21
+ `;
22
+ const mainPath = join(dir, "main.js");
23
+ writeFileSync(mainPath, main, "utf-8");
24
+ const binds = collectAbsBindingsFromGraph(main, mainPath);
25
+ expect(binds.has("run")).toBe(true);
26
+ expect(binds.has("double")).toBe(true);
27
+ expect(binds.has("k")).toBe(true);
28
+ expect(formatAbs(binds.get("k")!)).toContain("1");
29
+ });
30
+
31
+ it("analyzeFile bindings prefer Abs projection for capable files", () => {
32
+ const dir = mkdtempSync(join(tmpdir(), "nudo-abs-bind2-"));
33
+ dirs.push(dir);
34
+ writeFileSync(join(dir, "math.js"), `export function double(x) { return x * 2; }\n`);
35
+ const main = `import { double } from "./math.js";
36
+ export function run(n) { return double(n); }
37
+ `;
38
+ const mainPath = join(dir, "main.js");
39
+ writeFileSync(mainPath, main, "utf-8");
40
+ const result = analyzeFile(mainPath, main);
41
+ expect(result.bindings.has("run")).toBe(true);
42
+ // imported binding should exist
43
+ expect(result.bindings.has("double")).toBe(true);
44
+ });
45
+
46
+ it("hover on local function returns abs", () => {
47
+ const src = `export function add(a, b) { return a + b; }
48
+ `;
49
+ const h = getHoverAtPosition("/tmp/hover-abs.js", src, 1, 16);
50
+ // 不要求精确命中,但不应抛错
51
+ expect(h === null || typeof h.typeText === "string").toBe(true);
52
+ });
53
+ });
@@ -0,0 +1,33 @@
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, resolve } from "node:path";
5
+ import { analyzeFile } 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("Abs call records with relative import", () => {
14
+ it("tags imported calls with targetModule and feeds externalFunctions", () => {
15
+ const dir = mkdtempSync(join(tmpdir(), "nudo-abs-call-"));
16
+ dirs.push(dir);
17
+ const bSrc = `export function triple(x) { return x * 3; }\n`;
18
+ const aSrc = `import { triple } from "./b.js";\nfunction caller(n) { return triple(n); }\nconst r = caller(4);\n`;
19
+ writeFileSync(join(dir, "b.js"), bSrc);
20
+ const aPath = resolve(dir, "a.js");
21
+ writeFileSync(aPath, aSrc);
22
+
23
+ const result = analyzeFile(aPath, aSrc);
24
+
25
+ expect(result.externalFunctions).toBeDefined();
26
+ expect(result.externalFunctions).toHaveLength(1);
27
+ const triple = result.externalFunctions![0];
28
+ expect(triple.name).toBe("triple");
29
+ expect(triple.fromModule).toContain("b.js");
30
+ expect(triple.cases).toHaveLength(1);
31
+ expect(typeValueToString(triple.cases[0].result)).toBe("12");
32
+ });
33
+ });
@@ -0,0 +1,59 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { analyzeFile } from "../analyzer.ts";
3
+ import { typeValueToString } from "@nudojs/core";
4
+
5
+ describe("self-contained Abs call records", () => {
6
+ it("collects call sites from Abs program eval", () => {
7
+ const source = `
8
+ function add(a, b) { return a + b; }
9
+ const r = add(2, 3);
10
+ `;
11
+ const result = analyzeFile("/t/abs-call.js", source);
12
+ const add = result.functions.find((f) => f.name === "add");
13
+ expect(add).toBeDefined();
14
+ expect(add!.cases.length).toBeGreaterThan(0);
15
+ expect(add!.cases[0]!.source).toBe("callsite");
16
+ expect(typeValueToString(add!.cases[0]!.result)).toBe("5");
17
+ });
18
+
19
+ it("self-contained entry still works", () => {
20
+ const source = `function scale(x) { return x + 1; }`;
21
+ const result = analyzeFile("/t/abs-entry.js", source);
22
+ const scale = result.functions.find((f) => f.name === "scale");
23
+ expect(scale!.entryOnly).toBe(true);
24
+ expect(scale!.cases[0]!.intension).toBeDefined();
25
+ });
26
+
27
+ it("mock seeds feed Abs path (call@ stays exact)", () => {
28
+ const source = `
29
+ // @nudo:mock parse = stub().returns(42)
30
+ function run() {
31
+ return parse();
32
+ }
33
+ const r = run();
34
+ `;
35
+ const result = analyzeFile("/t/abs-mock.js", source);
36
+ const run = result.functions.find((f) => f.name === "run");
37
+ expect(run).toBeDefined();
38
+ const call = run!.cases.find((c) => c.source === "callsite");
39
+ expect(call).toBeDefined();
40
+ expect(typeValueToString(call!.result)).toBe("42");
41
+ });
42
+
43
+ it("directive expected is carried on CaseResult", () => {
44
+ const source = `
45
+ /**
46
+ * @nudo:case "ok" (1) => T.number
47
+ * @nudo:case "bad" (1) => T.string
48
+ */
49
+ function id(x) { return x; }
50
+ `;
51
+ const result = analyzeFile("/t/abs-expected.js", source);
52
+ const id = result.functions.find((f) => f.name === "id");
53
+ const ok = id!.cases.find((c) => c.name === "ok");
54
+ const bad = id!.cases.find((c) => c.name === "bad");
55
+ expect(ok!.expected).toBeDefined();
56
+ expect(bad!.expected).toBeDefined();
57
+ expect(result.diagnostics.some((d) => d.code === "nudo:case-expected")).toBe(true);
58
+ });
59
+ });
@@ -0,0 +1,121 @@
1
+ import { it, expect, describe, afterAll, beforeEach } from "vitest";
2
+ import { mkdtempSync, writeFileSync, rmSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import {
6
+ evalAbsModuleGraph,
7
+ defaultAbsLoadModule,
8
+ clearAbsModuleCache,
9
+ evictAbsModuleCacheFiles,
10
+ } from "@nudojs/service";
11
+
12
+ const dirs: string[] = [];
13
+
14
+ function setup(files: Record<string, string>): string {
15
+ const dir = mkdtempSync(join(tmpdir(), "nudo-modcache-"));
16
+ dirs.push(dir);
17
+ for (const [f, src] of Object.entries(files)) {
18
+ writeFileSync(join(dir, f), src, "utf-8");
19
+ }
20
+ return dir;
21
+ }
22
+
23
+ beforeEach(() => clearAbsModuleCache());
24
+ afterAll(() => {
25
+ clearAbsModuleCache();
26
+ for (const d of dirs) rmSync(d, { recursive: true, force: true });
27
+ });
28
+
29
+ describe("abs module session cache", () => {
30
+ it("reuses evaluated dependency exports across entries (skips re-read/re-eval)", () => {
31
+ const dir = setup({
32
+ "util.js": `export function inc(x) { return x + 1; }\n`,
33
+ });
34
+ let loads = 0;
35
+ const loadModule = (spec: string, fromFile: string): string | undefined => {
36
+ loads++;
37
+ return defaultAbsLoadModule(spec, fromFile);
38
+ };
39
+ const entry = (f: string) => `import { inc } from "./util.js";\nexport function go(x) { return inc(x); }`;
40
+
41
+ const g1 = evalAbsModuleGraph(entry("a.js"), join(dir, "a.js"), { loadModule });
42
+ expect(g1.modules["./util.js"]).toBeDefined();
43
+ const loadsAfterFirst = loads;
44
+
45
+ const g2 = evalAbsModuleGraph(entry("b.js"), join(dir, "b.js"), { loadModule });
46
+ expect(g2.modules["./util.js"]).toBeDefined();
47
+ // 缓存命中:第二个入口不再读盘重载 util.js
48
+ expect(loads).toBe(loadsAfterFirst);
49
+ // 同一条导出(引用同一对象)
50
+ expect(g2.modules["./util.js"]).toBe(g1.modules["./util.js"]);
51
+ });
52
+
53
+ it("invalidates by stat fingerprint when dependency content changes", () => {
54
+ const dir = setup({
55
+ "util.js": `export function inc(x) { return x + 1; }\n`,
56
+ });
57
+ const entry = `import { inc } from "./util.js";\nexport function go(x) { return inc(x); }`;
58
+ const entryFile = join(dir, "main.js");
59
+
60
+ const g1 = evalAbsModuleGraph(entry, entryFile);
61
+ expect(g1.modules["./util.js"]!.named.inc).toBeDefined();
62
+ expect(g1.modules["./util.js"]!.named.ver).toBeUndefined();
63
+
64
+ // 变更内容(size 必变 → 指纹必失效,与 mtime 精度无关)
65
+ writeFileSync(join(dir, "util.js"), `export function inc(x) { return x + 1; }\nexport const ver = 2;\n`, "utf-8");
66
+ const g2 = evalAbsModuleGraph(entry, entryFile);
67
+ expect(g2.modules["./util.js"]!.named.ver).toBeDefined();
68
+ });
69
+
70
+ it("clear and evict drop cached entries", () => {
71
+ const dir = setup({
72
+ "util.js": `export const n = 1;\n`,
73
+ });
74
+ let loads = 0;
75
+ const loadModule = (spec: string, fromFile: string): string | undefined => {
76
+ loads++;
77
+ return defaultAbsLoadModule(spec, fromFile);
78
+ };
79
+ const entry = `import { n } from "./util.js";\nexport function go() { return n; }`;
80
+ const entryFile = join(dir, "main.js");
81
+
82
+ evalAbsModuleGraph(entry, entryFile, { loadModule });
83
+ const loadsAfterFirst = loads;
84
+
85
+ clearAbsModuleCache();
86
+ evalAbsModuleGraph(entry, entryFile, { loadModule });
87
+ expect(loads).toBeGreaterThan(loadsAfterFirst);
88
+
89
+ const loadsAfterSecond = loads;
90
+ evictAbsModuleCacheFiles([join(dir, "util.js")]);
91
+ evalAbsModuleGraph(entry, entryFile, { loadModule });
92
+ expect(loads).toBeGreaterThan(loadsAfterSecond);
93
+ });
94
+
95
+ it("replays subtree load issues (missing) on cache hit", () => {
96
+ const dir = setup({
97
+ "util.js": `import { gone } from "./gone.js";\nexport const y = gone;\n`,
98
+ });
99
+ const entry = (f: string) => `import { y } from "./util.js";\nexport function go() { return y; }`;
100
+
101
+ const g1 = evalAbsModuleGraph(entry("a.js"), join(dir, "a.js"));
102
+ expect(g1.issues.some((i) => i.kind === "missing" && i.label === "./gone.js")).toBe(true);
103
+
104
+ const g2 = evalAbsModuleGraph(entry("b.js"), join(dir, "b.js"));
105
+ expect(g2.issues.some((i) => i.kind === "missing" && i.label === "./gone.js")).toBe(true);
106
+ });
107
+
108
+ it("replays cycle issues on cache hit", () => {
109
+ const dir = setup({
110
+ "a.js": `import { b } from "./b.js";\nexport const a = 1;\n`,
111
+ "b.js": `import { a } from "./a.js";\nexport const b = 2;\n`,
112
+ });
113
+ const entry = (f: string) => `import { a } from "./a.js";\nexport function go() { return a; }`;
114
+
115
+ const g1 = evalAbsModuleGraph(entry("e1.js"), join(dir, "e1.js"));
116
+ expect(g1.issues.some((i) => i.kind === "cycle")).toBe(true);
117
+
118
+ const g2 = evalAbsModuleGraph(entry("e2.js"), join(dir, "e2.js"));
119
+ expect(g2.issues.some((i) => i.kind === "cycle")).toBe(true);
120
+ });
121
+ });
@@ -0,0 +1,100 @@
1
+ import { describe, it, expect, afterAll } from "vitest";
2
+ import { mkdtempSync, writeFileSync, rmSync, mkdirSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { evalAbsModuleGraph } from "@nudojs/service";
6
+ import { analyzeFn, litValue, numLit } from "@nudojs/core";
7
+
8
+ const dirs: string[] = [];
9
+
10
+ afterAll(() => {
11
+ for (const d of dirs) rmSync(d, { recursive: true, force: true });
12
+ });
13
+
14
+ function tmpProject(files: Record<string, string>): string {
15
+ const dir = mkdtempSync(join(tmpdir(), "nudo-abs-mod-"));
16
+ dirs.push(dir);
17
+ for (const [rel, src] of Object.entries(files)) {
18
+ const p = join(dir, rel);
19
+ mkdirSync(join(p, ".."), { recursive: true });
20
+ writeFileSync(p, src, "utf-8");
21
+ }
22
+ return dir;
23
+ }
24
+
25
+ describe("Abs module graph", () => {
26
+ it("evaluates a relative import and binds named exports", () => {
27
+ const dir = tmpProject({
28
+ "math.js": `
29
+ export function double(x) { return x * 2; }
30
+ export const answer = 42;
31
+ `,
32
+ "main.js": `
33
+ import { double, answer } from "./math.js";
34
+ export function run(n) { return double(n); }
35
+ `,
36
+ });
37
+ const mainSrc = `import { double, answer } from "./math.js";
38
+ export function run(n) { return double(n); }
39
+ `;
40
+ const { modules } = evalAbsModuleGraph(mainSrc, join(dir, "main.js"));
41
+ expect(modules["./math.js"]).toBeDefined();
42
+ expect(modules["./math.js"]!.named.double).toBeDefined();
43
+ expect(litValue(modules["./math.js"]!.named.answer!)).toBe(42);
44
+
45
+ const r = analyzeFn(
46
+ mainSrc,
47
+ "run",
48
+ [numLit(21)],
49
+ undefined,
50
+ undefined,
51
+ undefined,
52
+ modules,
53
+ );
54
+ expect(litValue(r)).toBe(42);
55
+ });
56
+
57
+ it("chained relative imports resolve", () => {
58
+ const dir = tmpProject({
59
+ "a.js": `export function inc(x) { return x + 1; }`,
60
+ "b.js": `
61
+ import { inc } from "./a.js";
62
+ export function twice(x) { return inc(inc(x)); }
63
+ `,
64
+ "main.js": `
65
+ import { twice } from "./b.js";
66
+ export function go(x) { return twice(x); }
67
+ `,
68
+ });
69
+ const mainSrc = `import { twice } from "./b.js";
70
+ export function go(x) { return twice(x); }
71
+ `;
72
+ const { modules } = evalAbsModuleGraph(mainSrc, join(dir, "main.js"));
73
+ const result = analyzeFn(
74
+ mainSrc,
75
+ "go",
76
+ [numLit(0)],
77
+ undefined,
78
+ undefined,
79
+ undefined,
80
+ modules,
81
+ );
82
+ expect(litValue(result)).toBe(2);
83
+ });
84
+
85
+ it("cycle does not hang", () => {
86
+ const dir = tmpProject({
87
+ "a.js": `
88
+ import { b } from "./b.js";
89
+ export function a(x) { return b(x); }
90
+ `,
91
+ "b.js": `
92
+ import { a } from "./a.js";
93
+ export function b(x) { return x; }
94
+ `,
95
+ });
96
+ const mainSrc = `import { a } from "./a.js"; export function go(x) { return a(x); }`;
97
+ const { modules } = evalAbsModuleGraph(mainSrc, join(dir, "main.js"));
98
+ expect(modules["./a.js"]).toBeDefined();
99
+ });
100
+ });
@@ -0,0 +1,111 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { analyzeFile } from "../analyzer.ts";
3
+ import { typeValueToString } from "@nudojs/core";
4
+
5
+ /**
6
+ * Service 层 Abs ↔ TypeValue 差分门禁。
7
+ * 自包含源码:call@ 结果必须与 TypeValue 路径投影一致
8
+ * (Abs 成功时 callRecords 被 Abs 覆盖;此处验证覆盖后的结果合理)。
9
+ */
10
+ describe("Abs vs TypeValue service differential", () => {
11
+ const samples: Array<{ name: string; source: string; fn: string; expect: string }> = [
12
+ {
13
+ name: "literal add",
14
+ source: `function add(a, b) { return a + b; }\nconst r = add(2, 3);`,
15
+ fn: "add",
16
+ expect: "5",
17
+ },
18
+ {
19
+ name: "string concat",
20
+ source: `function cat(a, b) { return a + b; }\nconst r = cat("x", "y");`,
21
+ fn: "cat",
22
+ expect: '"xy"',
23
+ },
24
+ {
25
+ name: "branch join",
26
+ source: `function f(n) { if (n > 0) return 1; return 0; }\nconst r = f(3);`,
27
+ fn: "f",
28
+ expect: "1",
29
+ },
30
+ {
31
+ name: "object field",
32
+ source: `function get(o) { return o.id; }\nconst r = get({ id: 7 });`,
33
+ fn: "get",
34
+ expect: "7",
35
+ },
36
+ ];
37
+
38
+ for (const s of samples) {
39
+ it(s.name, () => {
40
+ const result = analyzeFile(`/t/diff-${s.fn}.js`, s.source);
41
+ const fn = result.functions.find((f) => f.name === s.fn);
42
+ const call = fn!.cases.find((c) => c.source === "callsite");
43
+ expect(call, `no call@ for ${s.fn}`).toBeDefined();
44
+ expect(typeValueToString(call!.result)).toBe(s.expect);
45
+ // Abs 路径应填充 intension
46
+ expect(call!.intension).toBeDefined();
47
+ });
48
+ }
49
+
50
+ it("mock + call does not degrade to unknown", () => {
51
+ const source = `
52
+ // @nudo:mock handler = stub().returns("ok")
53
+ function run() { return handler(); }
54
+ const r = run();
55
+ `;
56
+ const result = analyzeFile("/t/diff-mock.js", source);
57
+ const run = result.functions.find((f) => f.name === "run");
58
+ const call = run!.cases.find((c) => c.source === "callsite");
59
+ expect(typeValueToString(call!.result)).not.toBe("unknown");
60
+ expect(typeValueToString(call!.result)).toBe('"ok"');
61
+ });
62
+
63
+ it("mock withArgs dispatches on Abs path (literal hit)", () => {
64
+ const source = `
65
+ // @nudo:mock id = stub().withArgs(21).returns("hit")
66
+ function run() { return id(21); }
67
+ const r = run();
68
+ `;
69
+ const result = analyzeFile("/t/diff-withargs.js", source);
70
+ const run = result.functions.find((f) => f.name === "run");
71
+ const call = run!.cases.find((c) => c.source === "callsite");
72
+ expect(typeValueToString(call!.result)).toBe('"hit"');
73
+ });
74
+
75
+ it("mock withArgs miss falls back to unknown", () => {
76
+ const source = `
77
+ // @nudo:mock id = stub().withArgs(21).returns("hit")
78
+ function run() { return id(99); }
79
+ const r = run();
80
+ `;
81
+ const result = analyzeFile("/t/diff-withargs-miss.js", source);
82
+ const run = result.functions.find((f) => f.name === "run");
83
+ const call = run!.cases.find((c) => c.source === "callsite");
84
+ expect(typeValueToString(call!.result)).toBe("unknown");
85
+ });
86
+
87
+ it("mock onFirstCall without returns is default value", () => {
88
+ const source = `
89
+ // @nudo:mock getPort = stub().onFirstCall(8080)
90
+ function run() { return getPort(); }
91
+ const r = run();
92
+ `;
93
+ const result = analyzeFile("/t/diff-onfirst.js", source);
94
+ const run = result.functions.find((f) => f.name === "run");
95
+ const call = run!.cases.find((c) => c.source === "callsite");
96
+ expect(typeValueToString(call!.result)).toBe("8080");
97
+ });
98
+
99
+ it("mock sinon.stub().onFirstCall().returns() matches Abs path", () => {
100
+ const source = `
101
+ // @nudo:mock fetch = sinon.stub().onFirstCall().returns({ data: "first" })
102
+ function run() { return fetch(); }
103
+ const r = run();
104
+ `;
105
+ const result = analyzeFile("/t/diff-onfirst-sinon.js", source);
106
+ const run = result.functions.find((f) => f.name === "run");
107
+ const call = run!.cases.find((c) => c.source === "callsite");
108
+ expect(typeValueToString(call!.result)).not.toBe("unknown");
109
+ expect(typeValueToString(call!.result)).toContain("first");
110
+ });
111
+ });