@nudojs/service 0.3.0 → 0.3.2

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/dist/index.d.ts +18 -3
  2. package/dist/index.js +97 -59
  3. package/package.json +16 -6
  4. package/CHANGELOG.md +0 -68
  5. package/src/__tests__/abs-bindings.test.ts +0 -53
  6. package/src/__tests__/abs-call-records.test.ts +0 -33
  7. package/src/__tests__/abs-calls.test.ts +0 -59
  8. package/src/__tests__/abs-module-cache.test.ts +0 -121
  9. package/src/__tests__/abs-modules-graph.test.ts +0 -100
  10. package/src/__tests__/abs-typevalue-diff.test.ts +0 -111
  11. package/src/__tests__/analyzer.test.ts +0 -880
  12. package/src/__tests__/as-replace.test.ts +0 -165
  13. package/src/__tests__/bpath-arg-provenance.test.ts +0 -104
  14. package/src/__tests__/bpath-as.test.ts +0 -54
  15. package/src/__tests__/bpath-cache-key.test.ts +0 -33
  16. package/src/__tests__/bpath-calls.test.ts +0 -55
  17. package/src/__tests__/bpath-diagnostics.test.ts +0 -78
  18. package/src/__tests__/bpath-env-modules.test.ts +0 -30
  19. package/src/__tests__/bpath-env.test.ts +0 -80
  20. package/src/__tests__/bpath-hosted-skip.test.ts +0 -71
  21. package/src/__tests__/bpath-import-default-ns.test.ts +0 -106
  22. package/src/__tests__/bpath-method-dedupe.test.ts +0 -65
  23. package/src/__tests__/bpath-method-diag.test.ts +0 -45
  24. package/src/__tests__/bpath-module-diags.test.ts +0 -101
  25. package/src/__tests__/bpath-path-env.test.ts +0 -46
  26. package/src/__tests__/bpath-reexport.test.ts +0 -131
  27. package/src/__tests__/bpath-replace.test.ts +0 -52
  28. package/src/__tests__/bpath-require.test.ts +0 -66
  29. package/src/__tests__/bpath-run.test.ts +0 -109
  30. package/src/__tests__/case-emitter.test.ts +0 -636
  31. package/src/__tests__/cjs-require.test.ts +0 -54
  32. package/src/__tests__/diagnostics.test.ts +0 -60
  33. package/src/__tests__/dts-generator.test.ts +0 -382
  34. package/src/__tests__/fn-cache-evict.test.ts +0 -56
  35. package/src/__tests__/fn-sig-impl.test.ts +0 -247
  36. package/src/__tests__/guard-generator.test.ts +0 -29
  37. package/src/__tests__/harvest-auto.test.ts +0 -43
  38. package/src/__tests__/harvest-node.test.ts +0 -24
  39. package/src/__tests__/harvest-package.test.ts +0 -38
  40. package/src/__tests__/harvest-to-abs.test.ts +0 -46
  41. package/src/__tests__/hover-intension.test.ts +0 -39
  42. package/src/__tests__/infer-json.test.ts +0 -35
  43. package/src/__tests__/integration.test.ts +0 -421
  44. package/src/__tests__/m3-intension.test.ts +0 -28
  45. package/src/__tests__/mock-seed-fingerprint.test.ts +0 -64
  46. package/src/__tests__/schema-generator.test.ts +0 -38
  47. package/src/__tests__/session-cache-evict.test.ts +0 -90
  48. package/src/__tests__/static-imports.test.ts +0 -44
  49. package/src/abs-modules-graph.ts +0 -387
  50. package/src/analysis-file-cache.ts +0 -51
  51. package/src/analyzer.ts +0 -3133
  52. package/src/bpath-diagnostics.ts +0 -283
  53. package/src/bpath-run.ts +0 -409
  54. package/src/case-emitter.ts +0 -477
  55. package/src/dts-generator.ts +0 -291
  56. package/src/env-to-abs.ts +0 -57
  57. package/src/fn-analysis-cache.ts +0 -119
  58. package/src/guard-generator.ts +0 -57
  59. package/src/harvest-auto.ts +0 -86
  60. package/src/harvest-node.ts +0 -48
  61. package/src/harvest-package.ts +0 -204
  62. package/src/harvest-to-abs.ts +0 -129
  63. package/src/index.ts +0 -150
  64. package/src/infer-json.ts +0 -137
  65. package/src/load-module.ts +0 -34
  66. package/src/mock-abs.ts +0 -275
  67. package/src/schema-generator.ts +0 -43
  68. package/src/semantic-tokens.ts +0 -216
  69. package/src/session-cache.ts +0 -50
  70. package/src/static-imports.ts +0 -241
  71. package/src/target-path.ts +0 -17
  72. package/tsconfig.json +0 -13
@@ -1,880 +0,0 @@
1
- import { describe, it, expect } from "vitest";
2
- import { resolve } from "node:path";
3
- import { join } from "node:path";
4
- import { mkdtempSync, rmSync, writeFileSync, readFileSync, chmodSync, utimesSync } from "node:fs";
5
- import { tmpdir } from "node:os";
6
- import { T, typeValueToString } from "@nudojs/core";
7
- import { analyzeFile, collectCallRecords, getTypeAtPosition, getCompletionsAtPosition, buildModuleGraph, type ModuleGraphCache, computeDirtySet, topoSortDirty } from "../analyzer.ts";
8
- import { generateDts } from "../dts-generator.ts";
9
-
10
- const FIXTURE_PATH = resolve(import.meta.dirname, "fixtures", "sample.js");
11
-
12
- const SAMPLE_SOURCE = `
13
- /**
14
- * @nudo:case "concrete" (1, 2)
15
- * @nudo:case "symbolic" (T.number, T.number)
16
- */
17
- function add(a, b) {
18
- return a + b;
19
- }
20
-
21
- /**
22
- * @nudo:case "test" ({ name: "Alice", age: 30 })
23
- */
24
- function greet({ name, age }) {
25
- return name;
26
- }
27
- `;
28
-
29
- const THROWS_SOURCE = `
30
- /**
31
- * @nudo:case "valid" (10)
32
- * @nudo:case "negative" (-1)
33
- */
34
- function safeSqrt(x) {
35
- if (x < 0) {
36
- throw new RangeError("negative input");
37
- }
38
- return x;
39
- }
40
- `;
41
-
42
- const OBJ_SOURCE = `
43
- const obj = { x: 1, y: "hello", z: true };
44
-
45
- /**
46
- * @nudo:case "test" (T.number)
47
- */
48
- function identity(x) {
49
- return x;
50
- }
51
- `;
52
-
53
- describe("analyzeFile", () => {
54
- it("analyzes functions with @nudo:case directives", () => {
55
- const result = analyzeFile("/test/sample.js", SAMPLE_SOURCE);
56
- expect(result.functions).toHaveLength(2);
57
-
58
- const addFn = result.functions[0];
59
- expect(addFn.name).toBe("add");
60
- expect(addFn.cases).toHaveLength(2);
61
- expect(addFn.cases[0].name).toBe("concrete");
62
- expect(typeValueToString(addFn.cases[0].result)).toBe("3");
63
- expect(addFn.cases[1].name).toBe("symbolic");
64
- expect(typeValueToString(addFn.cases[1].result)).toBe("number");
65
- });
66
-
67
- it("provides combined type for multiple cases", () => {
68
- const result = analyzeFile("/test/sample.js", SAMPLE_SOURCE);
69
- const addFn = result.functions[0];
70
- expect(addFn.combined).toBeDefined();
71
- // 行为已修复:吸收律生效,字面量 3 被共存的 number 吸收(原期望 "3 | number")
72
- expect(typeValueToString(addFn.combined!)).toBe("number");
73
- });
74
-
75
- it("reports throws as diagnostics", () => {
76
- const result = analyzeFile("/test/throws.js", THROWS_SOURCE);
77
- expect(result.functions).toHaveLength(1);
78
- const fn = result.functions[0];
79
- expect(fn.cases).toHaveLength(2);
80
-
81
- const negativeCaseThrows = fn.cases[1].throws;
82
- expect(negativeCaseThrows.kind).not.toBe("never");
83
- });
84
-
85
- it("returns source locations for functions", () => {
86
- const result = analyzeFile("/test/sample.js", SAMPLE_SOURCE);
87
- const addFn = result.functions[0];
88
- expect(addFn.loc.start.line).toBeGreaterThan(0);
89
- expect(addFn.loc.end.line).toBeGreaterThan(addFn.loc.start.line);
90
- });
91
-
92
- it("collects top-level bindings", () => {
93
- const result = analyzeFile("/test/obj.js", OBJ_SOURCE);
94
- expect(result.bindings.has("obj")).toBe(true);
95
- const objBinding = result.bindings.get("obj")!;
96
- expect(objBinding.type.kind).toBe("object");
97
- });
98
-
99
- it("synthesizes cases from call sites for directive-less functions", () => {
100
- const source = `
101
- function uncalled(x) {
102
- return x * 2;
103
- }
104
-
105
- /**
106
- * @nudo:case "t" (5)
107
- */
108
- function caller(y) {
109
- return uncalled(y);
110
- }
111
- `;
112
- const result = analyzeFile("/test/callsite.js", source);
113
- const uncalled = result.functions.find((f) => f.name === "uncalled");
114
- expect(uncalled).toBeDefined();
115
- expect(uncalled!.cases).toHaveLength(1);
116
- expect(uncalled!.cases[0].source).toBe("callsite");
117
- expect(uncalled!.cases[0].name).toMatch(/^call@L\d+$/);
118
- expect(typeValueToString(uncalled!.cases[0].result)).toBe("10");
119
- expect(typeValueToString(uncalled!.combined!)).toBe("10");
120
- expect(uncalled!.entryOnly).toBeFalsy();
121
- });
122
-
123
- it("caps precise call-site cases and folds the rest into a symbolic case", () => {
124
- const calls = Array.from({ length: 20 }, (_, i) => `sq(${i + 1});`).join("\n");
125
- const source = `
126
- function sq(x) {
127
- return x * x;
128
- }
129
- ${calls}
130
- `;
131
- const result = analyzeFile("/test/many-calls.js", source);
132
- const sq = result.functions.find((f) => f.name === "sq");
133
- expect(sq).toBeDefined();
134
- expect(sq!.cases).toHaveLength(4);
135
- const precise = sq!.cases.slice(0, 3);
136
- for (const c of precise) {
137
- expect(c.name).toMatch(/^call@L\d+$/);
138
- expect(c.source).toBe("callsite");
139
- expect(c.aggregatedFrom).toBeUndefined();
140
- }
141
- expect(typeValueToString(precise[0].args[0])).toBe("1");
142
- const symbolic = sq!.cases[3];
143
- expect(symbolic.name).toBe("call@symbolic");
144
- expect(symbolic.source).toBe("callsite");
145
- expect(symbolic.aggregatedFrom).toBe(17);
146
- expect(symbolic.args).toHaveLength(1);
147
- expect(symbolic.args[0]).toEqual(T.number);
148
- expect(typeValueToString(symbolic.result)).toBe("number");
149
- expect(sq!.combined).toEqual(T.number);
150
- expect(sq!.entryOnly).toBeFalsy();
151
- });
152
-
153
- it("keeps per-call-site cases unchanged for few call sites", () => {
154
- const source = `
155
- function twice(x) {
156
- return x * 2;
157
- }
158
- twice(3);
159
- twice(21);
160
- `;
161
- const result = analyzeFile("/test/two-calls.js", source);
162
- const twice = result.functions.find((f) => f.name === "twice")!;
163
- expect(twice.cases).toHaveLength(2);
164
- expect(twice.cases.every((c) => /^call@L\d+$/.test(c.name))).toBe(true);
165
- expect(twice.cases.some((c) => c.name === "call@symbolic")).toBe(false);
166
- expect(twice.cases.some((c) => c.aggregatedFrom !== undefined)).toBe(false);
167
- expect(typeValueToString(twice.cases[0].result)).toBe("6");
168
- expect(typeValueToString(twice.cases[1].result)).toBe("42");
169
- expect(typeValueToString(twice.combined!)).toBe("6 | 42");
170
- });
171
-
172
- it("infers entry-only functions in directive-free files", () => {
173
- const source = `
174
- function lonely(x) {
175
- return x * 2;
176
- }
177
- `;
178
- const result = analyzeFile("/test/lonely.js", source);
179
- const lonely = result.functions.find((f) => f.name === "lonely");
180
- expect(lonely).toBeDefined();
181
- expect(lonely!.entryOnly).toBe(true);
182
- expect(lonely!.cases).toHaveLength(1);
183
- expect(typeValueToString(lonely!.combined!)).toBe("number");
184
- });
185
-
186
- it("keeps directive case output unchanged for functions with @nudo:case", () => {
187
- const result = analyzeFile("/test/sample.js", SAMPLE_SOURCE);
188
- const addFn = result.functions.find((f) => f.name === "add");
189
- expect(addFn!.cases).toHaveLength(2);
190
- expect(addFn!.cases[0].name).toBe("concrete");
191
- expect(addFn!.cases[0].source).toBe("directive");
192
- expect(addFn!.entryOnly).toBeUndefined();
193
- expect(typeValueToString(addFn!.cases[0].result)).toBe("3");
194
- // 行为已修复:吸收律生效,combined 的字面量 3 被共存的 number 吸收(原期望 "3 | number")
195
- expect(typeValueToString(addFn!.combined!)).toBe("number");
196
- expect(addFn!.skipped).toBeUndefined();
197
- });
198
-
199
- it("reports error diagnostic for method missing on concrete receiver", () => {
200
- const source = `
201
- function badNum(n) {
202
- return n.toUpperCase();
203
- }
204
-
205
- const boom = badNum(42);
206
- `;
207
- const result = analyzeFile("/test/badnum.js", source);
208
- const diag = result.diagnostics.find((d) => d.code === "nudo:no-method");
209
- expect(diag).toBeDefined();
210
- expect(diag!.severity).toBe("error");
211
- expect(diag!.message).toContain("toUpperCase");
212
- expect(diag!.message).toContain("number");
213
- expect(diag!.range.start.line).toBeGreaterThan(0);
214
- });
215
-
216
- it("does not report method diagnostics for valid calls on string receivers", () => {
217
- const source = `
218
- function bad(x) {
219
- return x.toUpperCase();
220
- }
221
-
222
- const ok = bad("hello");
223
- `;
224
- const result = analyzeFile("/test/goodstr.js", source);
225
- const methodDiags = result.diagnostics.filter((d) => d.code === "nudo:no-method" || d.code === "nudo:unknown-recv");
226
- expect(methodDiags).toHaveLength(0);
227
- });
228
-
229
- it("downgrades unknown-receiver method failures to warnings", () => {
230
- const source = `
231
- function lonely(u) {
232
- return u.toUpperCase();
233
- }
234
- `;
235
- const result = analyzeFile("/test/lonely-unknown.js", source);
236
- const diag = result.diagnostics.find((d) => d.code === "nudo:unknown-recv");
237
- expect(diag).toBeDefined();
238
- expect(diag!.severity).toBe("warning");
239
- expect(diag!.message).toContain("toUpperCase");
240
- expect(result.diagnostics.some((d) => d.code === "nudo:no-method" && d.message.includes("toUpperCase"))).toBe(false);
241
- });
242
-
243
- it("attaches callsite argument provenance to no-method diagnostics", () => {
244
- const source = `
245
- function badNum(n) {
246
- return n.toUpperCase();
247
- }
248
-
249
- const boom = badNum(42);
250
- `;
251
- const result = analyzeFile("/test/badnum.js", source);
252
- const diag = result.diagnostics.find((d) => d.code === "nudo:no-method");
253
- expect(diag).toBeDefined();
254
- expect(diag!.origin).toBeDefined();
255
- expect(diag!.origin!.line).toBe(6);
256
- expect(diag!.origin!.column).toBeGreaterThanOrEqual(0);
257
- expect(diag!.origin!.column).toBeLessThan(30);
258
- });
259
-
260
- it("omits provenance for unknown receivers without callsite origin", () => {
261
- const source = `
262
- function lonely(u) {
263
- return u.toUpperCase();
264
- }
265
- `;
266
- const result = analyzeFile("/test/lonely-origin.js", source);
267
- const diag = result.diagnostics.find((d) => d.code === "nudo:unknown-recv");
268
- expect(diag).toBeDefined();
269
- expect(diag!.origin).toBeUndefined();
270
- expect(diag!.message).toBe("Cannot resolve 'toUpperCase' on unknown value");
271
- });
272
-
273
- it("aggregates cross-file call sites of imported functions into externalFunctions", () => {
274
- const dir = mkdtempSync(join(tmpdir(), "nudo-ext-"));
275
- try {
276
- const aSrc = `import { triple } from "./b.js";\nfunction caller(n) { return triple(n); }\nconst r = caller(4);\n`;
277
- const aPath = resolve(dir, "a.js");
278
- writeFileSync(aPath, aSrc);
279
- writeFileSync(resolve(dir, "b.js"), `export function triple(x) { return x * 3; }\n`);
280
-
281
- const result = analyzeFile(aPath, aSrc);
282
-
283
- expect(result.externalFunctions).toBeDefined();
284
- expect(result.externalFunctions).toHaveLength(1);
285
- const triple = result.externalFunctions![0];
286
- expect(triple.name).toBe("triple");
287
- expect(triple.fromModule).toContain("b.js");
288
- expect(triple.cases).toHaveLength(1);
289
- expect(triple.cases[0].name).toMatch(/^call@L\d+$/);
290
- expect(triple.cases[0].source).toBe("callsite");
291
- expect(triple.cases[0].args.map(typeValueToString)).toEqual(["4"]);
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");
298
- // imported function must not leak into this file's local functions
299
- expect(result.functions.map((f) => f.name)).toEqual(["caller"]);
300
- } finally {
301
- rmSync(dir, { recursive: true, force: true });
302
- }
303
- });
304
-
305
- it("leaves externalFunctions empty for files without imports", () => {
306
- const source = `function id(x) { return x; }\nconst y = id(1);\n`;
307
- const result = analyzeFile("/test/no-import.js", source);
308
- expect(result.externalFunctions ?? []).toHaveLength(0);
309
- });
310
-
311
- it("collects chained CJS exports as named functions with entry fallback", () => {
312
- const source = `
313
- const internals = {};
314
- module.exports = internals.clone = function (obj, options = {}) {
315
- return obj;
316
- };
317
- `;
318
- const result = analyzeFile("/test/cjs-clone.js", source);
319
- const clone = result.functions.find((f) => f.name === "clone");
320
- expect(clone).toBeDefined();
321
- expect(clone!.paramNames).toEqual(["obj", "options"]);
322
- expect(clone!.noDeclaration).toBe(true);
323
- expect(clone!.entryOnly).toBe(true);
324
- expect(clone!.cases).toHaveLength(1);
325
- // entry fallback: parameters enter as unknown, so returning obj propagates unknown
326
- expect(clone!.cases[0].args).toEqual([T.unknown, T.unknown]);
327
- expect(clone!.cases[0].name).toMatch(/^entry@L\d+$/);
328
- expect(clone!.cases[0].result.kind).toBe("unknown");
329
- });
330
-
331
- it("names chained exports after the first non-module.exports property", () => {
332
- const source = `
333
- module.exports = function (solo) {
334
- return solo;
335
- };
336
- module.exports.pick = function (which) {
337
- return which;
338
- };
339
- `;
340
- const result = analyzeFile("/test/cjs-default.js", source);
341
- const names = result.functions.map((f) => f.name);
342
- expect(names).toContain("default");
343
- expect(names).toContain("pick");
344
- });
345
-
346
- it("prefers a named function expression id over the assigned property name", () => {
347
- const source = `
348
- exports.applyToDefaults = function _apply(src, opts) {
349
- return src;
350
- };
351
- `;
352
- const result = analyzeFile("/test/cjs-named-fn.js", source);
353
- const fn = result.functions.find((f) => f.name === "_apply");
354
- expect(fn).toBeDefined();
355
- expect(fn!.noDeclaration).toBe(true);
356
- expect(fn!.cases[0].result.kind).toBe("unknown");
357
- });
358
-
359
- it("collects exports.X assignment functions", () => {
360
- const source = `
361
- exports.applyToDefaults = function (a, b) {
362
- return a;
363
- };
364
- `;
365
- const result = analyzeFile("/test/cjs-exports.js", source);
366
- const fn = result.functions.find((f) => f.name === "applyToDefaults");
367
- expect(fn).toBeDefined();
368
- expect(fn!.paramNames).toEqual(["a", "b"]);
369
- expect(fn!.entryOnly).toBe(true);
370
- expect(fn!.noDeclaration).toBe(true);
371
- expect(fn!.cases).toHaveLength(1);
372
- expect(fn!.cases[0].result.kind).toBe("unknown");
373
- });
374
-
375
- it("synthesizes callsite cases for const-declared arrow functions", () => {
376
- const source = `
377
- const f = (x) => x * 2;
378
- f(3);
379
- `;
380
- const result = analyzeFile("/test/const-arrow.js", source);
381
- const f = result.functions.find((fn) => fn.name === "f");
382
- expect(f).toBeDefined();
383
- expect(f!.noDeclaration).toBe(true);
384
- expect(f!.entryOnly).toBeFalsy();
385
- expect(f!.cases).toHaveLength(1);
386
- expect(f!.cases[0].source).toBe("callsite");
387
- expect(f!.cases[0].name).toMatch(/^call@L\d+$/);
388
- expect(f!.cases[0].args.map(typeValueToString)).toEqual(["3"]);
389
- expect(typeValueToString(f!.cases[0].result)).toBe("6");
390
- expect(typeValueToString(f!.combined!)).toBe("6");
391
- });
392
-
393
- it("keeps pure FunctionDeclaration collection unchanged alongside CJS forms", () => {
394
- const source = `
395
- function lonely(x) {
396
- return x * 2;
397
- }
398
- exports.helper = function (y) {
399
- return y;
400
- };
401
- `;
402
- const result = analyzeFile("/test/mixed-forms.js", source);
403
- const lonely = result.functions.find((f) => f.name === "lonely")!;
404
- expect(lonely).toBeDefined();
405
- expect(lonely.noDeclaration).toBeUndefined();
406
- expect(lonely.entryOnly).toBe(true);
407
- expect(typeValueToString(lonely.combined!)).toBe("number");
408
- const helper = result.functions.find((f) => f.name === "helper")!;
409
- expect(helper).toBeDefined();
410
- expect(helper.noDeclaration).toBe(true);
411
- });
412
-
413
- it("skips CJS-bound functions in d.ts generation while keeping them in analysis", () => {
414
- const source = `
415
- function declared(x) {
416
- return x * 2;
417
- }
418
- module.exports = internals.clone = function (obj) {
419
- return obj;
420
- };
421
- `;
422
- const result = analyzeFile("/test/dts-skip.js", source);
423
- const clone = result.functions.find((f) => f.name === "clone");
424
- expect(clone).toBeDefined();
425
- const dts = generateDts(result);
426
- expect(dts).toContain("export declare function declared");
427
- expect(dts).not.toContain("clone");
428
- expect(dts).not.toContain("module");
429
- });
430
-
431
- it("synthesizes cases from externally injected usage-site call records", () => {
432
- const source = `
433
- function formatName(first, last) {
434
- return first + " " + last;
435
- }
436
- function shout(msg) {
437
- return msg.toUpperCase();
438
- }
439
- module.exports = { formatName, shout };
440
- `;
441
- // 模拟 CLI --callsites 从使用现场(测试/上层应用)收集的记录:
442
- // targetExport 命中导出名 formatName,fnName 形态也会被匹配
443
- const external = [
444
- {
445
- fnName: "formatName",
446
- argTypes: [T.literal("Ada"), T.literal("Lovelace")],
447
- resultType: T.literal("Ada Lovelace"),
448
- throws: T.never,
449
- targetModule: "/test/lib/util.js",
450
- targetExport: "formatName",
451
- },
452
- ];
453
- const result = analyzeFile("/test/lib/util.js", source, undefined, external);
454
- const formatName = result.functions.find((f) => f.name === "formatName")!;
455
- expect(formatName).toBeDefined();
456
- expect(formatName.entryOnly).toBeFalsy();
457
- expect(formatName.cases).toHaveLength(1);
458
- expect(formatName.cases[0].source).toBe("callsite");
459
- expect(formatName.cases[0].args.map(typeValueToString)).toEqual(['"Ada"', '"Lovelace"']);
460
- expect(typeValueToString(formatName.cases[0].result)).toBe('"Ada Lovelace"');
461
-
462
- // 未被使用现场调用的 shout 保持 entry-only
463
- const shout = result.functions.find((f) => f.name === "shout")!;
464
- expect(shout.entryOnly).toBe(true);
465
- });
466
-
467
- it("collectCallRecords harvests real call shapes from usage-site files", () => {
468
- const dir = mkdtempSync(join(tmpdir(), "nudo-cs-"));
469
- try {
470
- const libPath = join(dir, "util.js");
471
- writeFileSync(libPath, "function double(n) { return n * 2; }\nmodule.exports = { double };\n");
472
- const testPath = join(dir, "test.js");
473
- writeFileSync(testPath, "const { double } = require('./util.js');\nconst r = double(21);\n");
474
- const records = collectCallRecords(testPath, readFileSync(testPath, "utf-8"));
475
- const double = records.find((r) => r.targetExport === "double");
476
- expect(double).toBeDefined();
477
- expect(double!.argTypes.map(typeValueToString)).toEqual(["21"]);
478
- expect(typeValueToString(double!.resultType)).toBe("42");
479
-
480
- // 注入后 double 从 entry-only 升级为真实调用形态
481
- const result = analyzeFile(libPath, readFileSync(libPath, "utf-8"), undefined, records);
482
- const fn = result.functions.find((f) => f.name === "double")!;
483
- expect(fn.entryOnly).toBeFalsy();
484
- expect(typeValueToString(fn.combined!)).toBe("42");
485
- } finally {
486
- rmSync(dir, { recursive: true, force: true });
487
- }
488
- });
489
-
490
- it("matches injected records to anonymous module.exports single-export files via the module path", () => {
491
- const source = `
492
- function helper(n) {
493
- return n - 1;
494
- }
495
- module.exports = function (a, b) {
496
- return a + b;
497
- };
498
- `;
499
- // 使用方经转发 shim 以别的名字调用:fnName/targetExport/targetAliases
500
- // 都对不上本地 candidate 名 "default",仅模块路命中
501
- const external = [
502
- {
503
- fnName: "renamed",
504
- argTypes: [T.literal(1), T.literal(2)],
505
- resultType: T.literal(3),
506
- throws: T.never,
507
- callLoc: { line: 3, column: 0 },
508
- targetModule: "/test/anon-export.js",
509
- targetExport: "renamed",
510
- targetAliases: ["shorthand"],
511
- },
512
- ];
513
- const result = analyzeFile("/test/anon-export.js", source, undefined, external);
514
- const main = result.functions.find((f) => f.name === "default")!;
515
- expect(main).toBeDefined();
516
- expect(main.entryOnly).toBeFalsy();
517
- expect(main.cases).toHaveLength(1);
518
- expect(main.cases[0].source).toBe("callsite");
519
- expect(main.cases[0].args.map(typeValueToString)).toEqual(["1", "2"]);
520
- expect(typeValueToString(main.cases[0].result)).toBe("3");
521
-
522
- // 同文件其他函数不经模块路误染:名字对不上 → 保持 entry-only
523
- const helper = result.functions.find((f) => f.name === "helper")!;
524
- expect(helper).toBeDefined();
525
- expect(helper.entryOnly).toBe(true);
526
- });
527
-
528
- it("does not attribute module-targeted records to functions in multi-export files", () => {
529
- const source = `
530
- function alpha(x) { return x; }
531
- function beta(x) { return x; }
532
- module.exports = { alpha, beta };
533
- `;
534
- const external = [
535
- {
536
- fnName: "gamma",
537
- argTypes: [T.literal(1)],
538
- resultType: T.literal(1),
539
- throws: T.never,
540
- callLoc: { line: 4, column: 0 },
541
- targetModule: "/test/multi-export.js",
542
- targetExport: "gamma",
543
- },
544
- ];
545
- const result = analyzeFile("/test/multi-export.js", source, undefined, external);
546
- for (const name of ["alpha", "beta"]) {
547
- const fn = result.functions.find((f) => f.name === name)!;
548
- expect(fn).toBeDefined();
549
- expect(fn.entryOnly).toBe(true);
550
- expect(fn.cases[0].source).toBeUndefined();
551
- }
552
- });
553
-
554
- it("skips resultType=never records without throws and falls back to entry-only synthesis", () => {
555
- const source = `
556
- function parseChunked(emitter) {
557
- return emitter;
558
- }
559
- `;
560
- // 高阶 async(new Promise(async …))求值中断的信号泄漏:resultType=never
561
- // 且 throws=never,无任何信息 → 跳过
562
- const external = [
563
- {
564
- fnName: "parseChunked",
565
- argTypes: [T.object({})],
566
- resultType: T.never,
567
- throws: T.never,
568
- callLoc: { line: 5, column: 0 },
569
- targetModule: "/test/higher-order.js",
570
- targetExport: "parseChunked",
571
- },
572
- ];
573
- const result = analyzeFile("/test/higher-order.js", source, undefined, external);
574
- const fn = result.functions.find((f) => f.name === "parseChunked")!;
575
- expect(fn).toBeDefined();
576
- expect(fn.entryOnly).toBe(true);
577
- expect(fn.cases).toHaveLength(1);
578
- expect(fn.cases[0].source).toBeUndefined();
579
- expect(fn.cases[0].name).toMatch(/^entry@L\d+$/);
580
- });
581
-
582
- it("keeps throwing-call records whose resultType is never but throws carries the thrown type", () => {
583
- const source = `
584
- function fail(msg) {
585
- throw new Error(msg);
586
- }
587
- `;
588
- // resultType=never 但 throws≠never:真实的抛出调用,argTypes/throws 均有信息
589
- const external = [
590
- {
591
- fnName: "fail",
592
- argTypes: [T.literal("boom")],
593
- resultType: T.never,
594
- throws: T.string,
595
- callLoc: { line: 7, column: 0 },
596
- targetModule: "/test/throwing.js",
597
- targetExport: "fail",
598
- },
599
- ];
600
- const result = analyzeFile("/test/throwing.js", source, undefined, external);
601
- const fn = result.functions.find((f) => f.name === "fail")!;
602
- expect(fn).toBeDefined();
603
- expect(fn.entryOnly).toBeFalsy();
604
- expect(fn.cases).toHaveLength(1);
605
- expect(fn.cases[0].source).toBe("callsite");
606
- expect(fn.cases[0].args.map(typeValueToString)).toEqual(['"boom"']);
607
- expect(fn.cases[0].result).toEqual(T.never);
608
- expect(fn.cases[0].throws).toEqual(T.string);
609
- });
610
- });
611
-
612
- describe("getTypeAtPosition", () => {
613
- it("returns type for identifier at position", () => {
614
- const source = `const x = 42;\nconst y = x;\n`;
615
- const tv = getTypeAtPosition("/test/pos.js", source, 1, 6);
616
- expect(tv).not.toBeNull();
617
- });
618
-
619
- it("returns null for empty position", () => {
620
- const source = `\n\n\n`;
621
- const tv = getTypeAtPosition("/test/empty.js", source, 2, 0);
622
- expect(tv).toBeNull();
623
- });
624
- });
625
-
626
- describe("getCompletionsAtPosition", () => {
627
- it("returns variable completions without dot trigger", () => {
628
- const source = `const x = 42;\nfunction add(a, b) { return a + b; }\n`;
629
- const completions = getCompletionsAtPosition("/test/comp.js", source, 2, 0);
630
- expect(completions.length).toBeGreaterThan(0);
631
- const names = completions.map((c) => c.label);
632
- expect(names).toContain("x");
633
- expect(names).toContain("add");
634
- });
635
-
636
- it("returns property completions for object after dot", () => {
637
- const source = `const obj = { x: 1, y: "hello" };\nobj.x;\n`;
638
- const completions = getCompletionsAtPosition("/test/dot.js", source, 2, 4);
639
- expect(completions.length).toBeGreaterThan(0);
640
- const names = completions.map((c) => c.label);
641
- expect(names).toContain("x");
642
- expect(names).toContain("y");
643
- });
644
-
645
- it("returns array method completions after dot", () => {
646
- const source = `const arr = [1, 2, 3];\narr.map;\n`;
647
- const completions = getCompletionsAtPosition("/test/arr.js", source, 2, 4);
648
- const names = completions.map((c) => c.label);
649
- expect(names).toContain("map");
650
- expect(names).toContain("filter");
651
- expect(names).toContain("reduce");
652
- expect(names).toContain("length");
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
- });
718
- });
719
-
720
- describe("buildModuleGraph / computeDirtySet / topoSortDirty", () => {
721
- const tmpWrite = (dir: string, name: string, content: string) => {
722
- const path = resolve(dir, name);
723
- writeFileSync(path, content);
724
- return path;
725
- };
726
-
727
- it("builds imports/dependents edges and propagates dirt to transitive dependents", () => {
728
- const dir = mkdtempSync(join(tmpdir(), "nudo-graph-"));
729
- try {
730
- const a = tmpWrite(dir, "a.js", `import { b } from "./b.js";\nexport const a = b;\n`);
731
- const b = tmpWrite(dir, "b.js", `export const b = 1;\n`);
732
- const { imports, dependents } = buildModuleGraph([a, b]);
733
- expect(imports.get(a)).toContain(b);
734
- expect(dependents.get(b)).toContain(a);
735
-
736
- expect(new Set(computeDirtySet(dependents, b))).toEqual(new Set([b, a]));
737
- expect(computeDirtySet(dependents, a)).toEqual([a]);
738
- } finally {
739
- rmSync(dir, { recursive: true, force: true });
740
- }
741
- });
742
-
743
- it("propagates dirt along a three-file chain", () => {
744
- const dir = mkdtempSync(join(tmpdir(), "nudo-graph-"));
745
- try {
746
- const a = tmpWrite(dir, "a.js", `import { b } from "./b.js";\n`);
747
- const b = tmpWrite(dir, "b.js", `import { c } from "./c.js";\n`);
748
- const c = tmpWrite(dir, "c.js", `export const c = 1;\n`);
749
- const { dependents } = buildModuleGraph([a, b, c]);
750
- expect(new Set(computeDirtySet(dependents, c))).toEqual(new Set([c, b, a]));
751
- } finally {
752
- rmSync(dir, { recursive: true, force: true });
753
- }
754
- });
755
-
756
- it("handles import cycles without infinite loop", () => {
757
- const dir = mkdtempSync(join(tmpdir(), "nudo-graph-"));
758
- try {
759
- const a = tmpWrite(dir, "a.js", `import { b } from "./b.js";\nexport const a = 1;\n`);
760
- const b = tmpWrite(dir, "b.js", `import { a } from "./a.js";\nexport const b = 2;\n`);
761
- const { imports, dependents } = buildModuleGraph([a, b]);
762
- const dirty = computeDirtySet(dependents, a);
763
- expect(new Set(dirty)).toEqual(new Set([a, b]));
764
-
765
- const ordered = topoSortDirty(imports, dirty);
766
- expect(new Set(ordered)).toEqual(new Set([a, b]));
767
- } finally {
768
- rmSync(dir, { recursive: true, force: true });
769
- }
770
- });
771
-
772
- it("skips bare npm specifiers and orders dirty set dependencies-first", () => {
773
- const dir = mkdtempSync(join(tmpdir(), "nudo-graph-"));
774
- try {
775
- const a = tmpWrite(dir, "a.js", `import _ from "lodash";\nimport { b } from "./b.js";\n`);
776
- const b = tmpWrite(dir, "b.js", `export const b = 1;\n`);
777
- const { imports, dependents } = buildModuleGraph([a, b]);
778
- expect(imports.get(a)).toEqual(new Set([b]));
779
-
780
- const dirty = computeDirtySet(dependents, b);
781
- const ordered = topoSortDirty(imports, dirty);
782
- expect(ordered.indexOf(b)).toBeLessThan(ordered.indexOf(a));
783
- } finally {
784
- rmSync(dir, { recursive: true, force: true });
785
- }
786
- });
787
- });
788
-
789
- // 来源:LSP 隔离控制工程化——buildModuleGraph mtime 边缓存
790
- describe("buildModuleGraph mtime 边缓存", () => {
791
- const tmpWrite = (dir: string, name: string, content: string) => {
792
- const path = resolve(dir, name);
793
- writeFileSync(path, content);
794
- return path;
795
- };
796
-
797
- it("同输入带/不带 cache 结果一致(含二次全命中)", () => {
798
- const dir = mkdtempSync(join(tmpdir(), "nudo-graph-cache-"));
799
- try {
800
- const a = tmpWrite(dir, "a.js", `import { b } from "./b.js";\n`);
801
- const b = tmpWrite(dir, "b.js", `import { c } from "./c.js";\n`);
802
- const c = tmpWrite(dir, "c.js", `export const c = 1;\n`);
803
- const plain = buildModuleGraph([a, b, c]);
804
- const cache: ModuleGraphCache = new Map();
805
- const once = buildModuleGraph([a, b, c], cache);
806
- const twice = buildModuleGraph([a, b, c], cache); // 第二次全命中
807
- const normalize = (g: { imports: Map<string, Set<string>>; dependents: Map<string, Set<string>> }) =>
808
- JSON.stringify({
809
- imports: [...g.imports].map(([k, v]) => [k, [...v].sort()]),
810
- dependents: [...g.dependents].map(([k, v]) => [k, [...v].sort()]),
811
- });
812
- expect(normalize(once)).toBe(normalize(plain));
813
- expect(normalize(twice)).toBe(normalize(plain));
814
- expect(cache.size).toBe(3); // 三个文件均回填缓存
815
- } finally {
816
- rmSync(dir, { recursive: true, force: true });
817
- }
818
- });
819
-
820
- it("内容变化(size 改变)后失效重读并回填", () => {
821
- const dir = mkdtempSync(join(tmpdir(), "nudo-graph-cache-"));
822
- try {
823
- const a = tmpWrite(dir, "a.js", `import { b } from "./b.js";\n`);
824
- const b = tmpWrite(dir, "b.js", `export const b = 1;\n`);
825
- const d = tmpWrite(dir, "d.js", `export const d = 1;\n`);
826
- const cache: ModuleGraphCache = new Map();
827
- const before = buildModuleGraph([a, b, d], cache);
828
- expect(before.imports.get(a)).toEqual(new Set([b]));
829
- // 改为同时导入 b、d:size 变化必然导致缓存失效
830
- writeFileSync(a, `import { b } from "./b.js";\nimport { d } from "./d.js";\n`);
831
- const after = buildModuleGraph([a, b, d], cache);
832
- expect(after.imports.get(a)).toEqual(new Set([b, d]));
833
- expect(new Set(cache.get(a)!.edges)).toEqual(new Set([b, d]));
834
- } finally {
835
- rmSync(dir, { recursive: true, force: true });
836
- }
837
- });
838
-
839
- it("仅 mtime 变化(size 不变)后失效重读", () => {
840
- const dir = mkdtempSync(join(tmpdir(), "nudo-graph-cache-"));
841
- try {
842
- const a = tmpWrite(dir, "a.js", `import { v } from "./b.js";\n`);
843
- const b = tmpWrite(dir, "b.js", `export const v = 1;\n`);
844
- const c = tmpWrite(dir, "c.js", `export const v = 2;\n`);
845
- const cache: ModuleGraphCache = new Map();
846
- expect(buildModuleGraph([a, b, c], cache).imports.get(a)).toEqual(new Set([b]));
847
- // 同尺寸内容换成导入 c,再用 utimesSync 显式把 mtime 拉开 10s(size 维度保持一致)
848
- writeFileSync(a, `import { v } from "./c.js";\n`);
849
- const forced = new Date(cache.get(a)!.mtimeMs + 10_000);
850
- utimesSync(a, forced, forced);
851
- const after = buildModuleGraph([a, b, c], cache);
852
- expect(after.imports.get(a)).toEqual(new Set([c]));
853
- } finally {
854
- rmSync(dir, { recursive: true, force: true });
855
- }
856
- });
857
-
858
- it("命中时不读盘:文件不可读仍能从 cache 得出正确边", () => {
859
- const dir = mkdtempSync(join(tmpdir(), "nudo-graph-cache-"));
860
- try {
861
- const a = tmpWrite(dir, "a.js", `import { b } from "./b.js";\n`);
862
- const b = tmpWrite(dir, "b.js", `export const b = 1;\n`);
863
- const cache: ModuleGraphCache = new Map();
864
- buildModuleGraph([a, b], cache);
865
- chmodSync(a, 0o000); // chmod 不改 mtime → 必命中;statSync 仍可用,readFileSync 将 EACCES
866
- try {
867
- const hit = buildModuleGraph([a, b], cache);
868
- expect(hit.imports.get(a)).toEqual(new Set([b]));
869
- expect(hit.dependents.get(b)).toEqual(new Set([a]));
870
- // 对照:不带 cache 必须读盘,EACCES 下抽不出任何边——证明上面的边确实来自缓存
871
- const miss = buildModuleGraph([a, b]);
872
- expect(miss.imports.get(a)).toEqual(new Set());
873
- } finally {
874
- chmodSync(a, 0o644);
875
- }
876
- } finally {
877
- rmSync(dir, { recursive: true, force: true });
878
- }
879
- });
880
- });