@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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nudojs/service",
3
- "version": "0.2.1",
3
+ "version": "0.3.0",
4
4
  "description": "Shared inference service for Nudo IDE integrations — file analysis, completions, and .d.ts generation",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -28,9 +28,10 @@
28
28
  "dependencies": {
29
29
  "@babel/traverse": "^7.29.0",
30
30
  "@babel/types": "^7.29.0",
31
- "@nudojs/core": "0.2.0",
32
- "@nudojs/parser": "0.2.0",
33
- "@nudojs/cli": "0.2.1"
31
+ "@nudojs/core": "0.3.0",
32
+ "@nudojs/parser": "0.3.0",
33
+ "@nudojs/cli": "0.3.0",
34
+ "@nudojs/harvester": "0.2.0"
34
35
  },
35
36
  "scripts": {
36
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
+ });
@@ -188,7 +188,7 @@ function lonely(x) {
188
188
  const addFn = result.functions.find((f) => f.name === "add");
189
189
  expect(addFn!.cases).toHaveLength(2);
190
190
  expect(addFn!.cases[0].name).toBe("concrete");
191
- expect(addFn!.cases[0].source).toBeUndefined();
191
+ expect(addFn!.cases[0].source).toBe("directive");
192
192
  expect(addFn!.entryOnly).toBeUndefined();
193
193
  expect(typeValueToString(addFn!.cases[0].result)).toBe("3");
194
194
  // 行为已修复:吸收律生效,combined 的字面量 3 被共存的 number 吸收(原期望 "3 | number")
@@ -289,8 +289,12 @@ function lonely(u) {
289
289
  expect(triple.cases[0].name).toMatch(/^call@L\d+$/);
290
290
  expect(triple.cases[0].source).toBe("callsite");
291
291
  expect(triple.cases[0].args.map(typeValueToString)).toEqual(["4"]);
292
- expect(typeValueToString(triple.cases[0].result)).toBe("12");
293
- expect(typeValueToString(triple.combined)).toBe("12");
292
+ const caseResult = triple.cases[0].result;
293
+ if (caseResult === undefined) throw new Error("expected case result");
294
+ expect(typeValueToString(caseResult)).toBe("12");
295
+ const combined = triple.combined;
296
+ if (combined === undefined) throw new Error("expected combined result");
297
+ expect(typeValueToString(combined)).toBe("12");
294
298
  // imported function must not leak into this file's local functions
295
299
  expect(result.functions.map((f) => f.name)).toEqual(["caller"]);
296
300
  } finally {
@@ -647,6 +651,70 @@ describe("getCompletionsAtPosition", () => {
647
651
  expect(names).toContain("reduce");
648
652
  expect(names).toContain("length");
649
653
  });
654
+
655
+ // 来源:IDE 深度批次——union 接收者的 dot 补全不再返回空
656
+ it("completes only members common to every union member, with per-member type detail", () => {
657
+ const source = [
658
+ `const a = { x: 1, m() { return 1; } };`,
659
+ `const b = { x: 2 };`,
660
+ `const u = Math.random() > 0.5 ? a : b;`,
661
+ `u.x;`,
662
+ ].join("\n");
663
+ const completions = getCompletionsAtPosition("/test/union.js", source, 4, 2);
664
+ const names = completions.map((c) => c.label);
665
+ expect(names).toContain("x");
666
+ // m 只存在于一个成员:非公共成员不得出现在补全里
667
+ expect(names).not.toContain("m");
668
+ // detail 是各成员上该成员类型的并集渲染
669
+ const x = completions.find((c) => c.label === "x");
670
+ expect(x?.kind).toBe("property");
671
+ expect(x?.detail).toBe("1 | 2");
672
+ });
673
+
674
+ it("returns empty completions for a union with no common members", () => {
675
+ const source = [
676
+ `const c1 = { p: 1 };`,
677
+ `const c2 = { q: 2 };`,
678
+ `const u = Math.random() > 0.5 ? c1 : c2;`,
679
+ `u.a;`,
680
+ ].join("\n");
681
+ const completions = getCompletionsAtPosition("/test/union-empty.js", source, 4, 2);
682
+ expect(completions).toEqual([]);
683
+ });
684
+
685
+ // 来源:IDE 深度批次——内置方法 detail 不再硬编码,取 evaluator 真实 fnSig
686
+ it("derives array method detail from the evaluator instead of hardcoding", () => {
687
+ const source = `const arr = [1, 2, 3];\narr.map;\n`;
688
+ const completions = getCompletionsAtPosition("/test/arr-sig.js", source, 2, 4);
689
+ // Array.prototype.map 的近似签名:(_arg0: unknown) => unknown[]
690
+ expect(completions.find((c) => c.label === "map")?.detail).toBe("(_arg0: unknown) => unknown[]");
691
+ expect(completions.find((c) => c.label === "join")?.detail).toBe("(_arg0: string) => string");
692
+ // 字面量 [1,2,3] 求值为 tuple:length 是精确字面量
693
+ expect(completions.find((c) => c.label === "length")?.detail).toBe("3");
694
+ // 抽象 array(filter 结果)的 length 回到 number
695
+ const widened = getCompletionsAtPosition(
696
+ "/test/arr-wide.js",
697
+ `const arr = [1, "a"].filter(() => Math.random() > 0.5);\narr.m;\n`,
698
+ 2,
699
+ 4,
700
+ );
701
+ expect(widened.find((c) => c.label === "length")?.detail).toBe("number");
702
+ });
703
+
704
+ it("derives tuple length and string/promise method detail from the evaluator", () => {
705
+ const tuple = getCompletionsAtPosition("/test/tuple.js", `const pair = [1, "a"];\npair.x;\n`, 2, 5);
706
+ expect(tuple.find((c) => c.label === "length")?.detail).toBe("2");
707
+
708
+ // [1,2].join("-") 推断出 primitive string(字面量接收者不走 string 分支)
709
+ const str = getCompletionsAtPosition("/test/str.js", `const s = [1, 2].join("-");\ns.to;\n`, 2, 2);
710
+ expect(str.find((c) => c.label === "toUpperCase")?.detail).toBe("() => string");
711
+ expect(str.find((c) => c.label === "slice")?.detail).toBe("(_arg0: number, _arg1: number) => string");
712
+
713
+ const promise = getCompletionsAtPosition("/test/promise.js", `const p = Promise.resolve(1);\np.then;\n`, 2, 2);
714
+ expect(promise.find((c) => c.label === "then")?.detail).toBe("(_arg0: unknown) => Promise<unknown>");
715
+ // 派生自求值器原型近似表:含建模过的 Object.prototype 继承方法 toString
716
+ expect(promise.map((c) => c.label).sort()).toEqual(["catch", "finally", "then", "toString"]);
717
+ });
650
718
  });
651
719
 
652
720
  describe("buildModuleGraph / computeDirtySet / topoSortDirty", () => {