@nudojs/service 0.2.1 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,812 +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).toBeUndefined();
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
- expect(typeValueToString(triple.cases[0].result)).toBe("12");
293
- expect(typeValueToString(triple.combined)).toBe("12");
294
- // imported function must not leak into this file's local functions
295
- expect(result.functions.map((f) => f.name)).toEqual(["caller"]);
296
- } finally {
297
- rmSync(dir, { recursive: true, force: true });
298
- }
299
- });
300
-
301
- it("leaves externalFunctions empty for files without imports", () => {
302
- const source = `function id(x) { return x; }\nconst y = id(1);\n`;
303
- const result = analyzeFile("/test/no-import.js", source);
304
- expect(result.externalFunctions ?? []).toHaveLength(0);
305
- });
306
-
307
- it("collects chained CJS exports as named functions with entry fallback", () => {
308
- const source = `
309
- const internals = {};
310
- module.exports = internals.clone = function (obj, options = {}) {
311
- return obj;
312
- };
313
- `;
314
- const result = analyzeFile("/test/cjs-clone.js", source);
315
- const clone = result.functions.find((f) => f.name === "clone");
316
- expect(clone).toBeDefined();
317
- expect(clone!.paramNames).toEqual(["obj", "options"]);
318
- expect(clone!.noDeclaration).toBe(true);
319
- expect(clone!.entryOnly).toBe(true);
320
- expect(clone!.cases).toHaveLength(1);
321
- // entry fallback: parameters enter as unknown, so returning obj propagates unknown
322
- expect(clone!.cases[0].args).toEqual([T.unknown, T.unknown]);
323
- expect(clone!.cases[0].name).toMatch(/^entry@L\d+$/);
324
- expect(clone!.cases[0].result.kind).toBe("unknown");
325
- });
326
-
327
- it("names chained exports after the first non-module.exports property", () => {
328
- const source = `
329
- module.exports = function (solo) {
330
- return solo;
331
- };
332
- module.exports.pick = function (which) {
333
- return which;
334
- };
335
- `;
336
- const result = analyzeFile("/test/cjs-default.js", source);
337
- const names = result.functions.map((f) => f.name);
338
- expect(names).toContain("default");
339
- expect(names).toContain("pick");
340
- });
341
-
342
- it("prefers a named function expression id over the assigned property name", () => {
343
- const source = `
344
- exports.applyToDefaults = function _apply(src, opts) {
345
- return src;
346
- };
347
- `;
348
- const result = analyzeFile("/test/cjs-named-fn.js", source);
349
- const fn = result.functions.find((f) => f.name === "_apply");
350
- expect(fn).toBeDefined();
351
- expect(fn!.noDeclaration).toBe(true);
352
- expect(fn!.cases[0].result.kind).toBe("unknown");
353
- });
354
-
355
- it("collects exports.X assignment functions", () => {
356
- const source = `
357
- exports.applyToDefaults = function (a, b) {
358
- return a;
359
- };
360
- `;
361
- const result = analyzeFile("/test/cjs-exports.js", source);
362
- const fn = result.functions.find((f) => f.name === "applyToDefaults");
363
- expect(fn).toBeDefined();
364
- expect(fn!.paramNames).toEqual(["a", "b"]);
365
- expect(fn!.entryOnly).toBe(true);
366
- expect(fn!.noDeclaration).toBe(true);
367
- expect(fn!.cases).toHaveLength(1);
368
- expect(fn!.cases[0].result.kind).toBe("unknown");
369
- });
370
-
371
- it("synthesizes callsite cases for const-declared arrow functions", () => {
372
- const source = `
373
- const f = (x) => x * 2;
374
- f(3);
375
- `;
376
- const result = analyzeFile("/test/const-arrow.js", source);
377
- const f = result.functions.find((fn) => fn.name === "f");
378
- expect(f).toBeDefined();
379
- expect(f!.noDeclaration).toBe(true);
380
- expect(f!.entryOnly).toBeFalsy();
381
- expect(f!.cases).toHaveLength(1);
382
- expect(f!.cases[0].source).toBe("callsite");
383
- expect(f!.cases[0].name).toMatch(/^call@L\d+$/);
384
- expect(f!.cases[0].args.map(typeValueToString)).toEqual(["3"]);
385
- expect(typeValueToString(f!.cases[0].result)).toBe("6");
386
- expect(typeValueToString(f!.combined!)).toBe("6");
387
- });
388
-
389
- it("keeps pure FunctionDeclaration collection unchanged alongside CJS forms", () => {
390
- const source = `
391
- function lonely(x) {
392
- return x * 2;
393
- }
394
- exports.helper = function (y) {
395
- return y;
396
- };
397
- `;
398
- const result = analyzeFile("/test/mixed-forms.js", source);
399
- const lonely = result.functions.find((f) => f.name === "lonely")!;
400
- expect(lonely).toBeDefined();
401
- expect(lonely.noDeclaration).toBeUndefined();
402
- expect(lonely.entryOnly).toBe(true);
403
- expect(typeValueToString(lonely.combined!)).toBe("number");
404
- const helper = result.functions.find((f) => f.name === "helper")!;
405
- expect(helper).toBeDefined();
406
- expect(helper.noDeclaration).toBe(true);
407
- });
408
-
409
- it("skips CJS-bound functions in d.ts generation while keeping them in analysis", () => {
410
- const source = `
411
- function declared(x) {
412
- return x * 2;
413
- }
414
- module.exports = internals.clone = function (obj) {
415
- return obj;
416
- };
417
- `;
418
- const result = analyzeFile("/test/dts-skip.js", source);
419
- const clone = result.functions.find((f) => f.name === "clone");
420
- expect(clone).toBeDefined();
421
- const dts = generateDts(result);
422
- expect(dts).toContain("export declare function declared");
423
- expect(dts).not.toContain("clone");
424
- expect(dts).not.toContain("module");
425
- });
426
-
427
- it("synthesizes cases from externally injected usage-site call records", () => {
428
- const source = `
429
- function formatName(first, last) {
430
- return first + " " + last;
431
- }
432
- function shout(msg) {
433
- return msg.toUpperCase();
434
- }
435
- module.exports = { formatName, shout };
436
- `;
437
- // 模拟 CLI --callsites 从使用现场(测试/上层应用)收集的记录:
438
- // targetExport 命中导出名 formatName,fnName 形态也会被匹配
439
- const external = [
440
- {
441
- fnName: "formatName",
442
- argTypes: [T.literal("Ada"), T.literal("Lovelace")],
443
- resultType: T.literal("Ada Lovelace"),
444
- throws: T.never,
445
- targetModule: "/test/lib/util.js",
446
- targetExport: "formatName",
447
- },
448
- ];
449
- const result = analyzeFile("/test/lib/util.js", source, undefined, external);
450
- const formatName = result.functions.find((f) => f.name === "formatName")!;
451
- expect(formatName).toBeDefined();
452
- expect(formatName.entryOnly).toBeFalsy();
453
- expect(formatName.cases).toHaveLength(1);
454
- expect(formatName.cases[0].source).toBe("callsite");
455
- expect(formatName.cases[0].args.map(typeValueToString)).toEqual(['"Ada"', '"Lovelace"']);
456
- expect(typeValueToString(formatName.cases[0].result)).toBe('"Ada Lovelace"');
457
-
458
- // 未被使用现场调用的 shout 保持 entry-only
459
- const shout = result.functions.find((f) => f.name === "shout")!;
460
- expect(shout.entryOnly).toBe(true);
461
- });
462
-
463
- it("collectCallRecords harvests real call shapes from usage-site files", () => {
464
- const dir = mkdtempSync(join(tmpdir(), "nudo-cs-"));
465
- try {
466
- const libPath = join(dir, "util.js");
467
- writeFileSync(libPath, "function double(n) { return n * 2; }\nmodule.exports = { double };\n");
468
- const testPath = join(dir, "test.js");
469
- writeFileSync(testPath, "const { double } = require('./util.js');\nconst r = double(21);\n");
470
- const records = collectCallRecords(testPath, readFileSync(testPath, "utf-8"));
471
- const double = records.find((r) => r.targetExport === "double");
472
- expect(double).toBeDefined();
473
- expect(double!.argTypes.map(typeValueToString)).toEqual(["21"]);
474
- expect(typeValueToString(double!.resultType)).toBe("42");
475
-
476
- // 注入后 double 从 entry-only 升级为真实调用形态
477
- const result = analyzeFile(libPath, readFileSync(libPath, "utf-8"), undefined, records);
478
- const fn = result.functions.find((f) => f.name === "double")!;
479
- expect(fn.entryOnly).toBeFalsy();
480
- expect(typeValueToString(fn.combined!)).toBe("42");
481
- } finally {
482
- rmSync(dir, { recursive: true, force: true });
483
- }
484
- });
485
-
486
- it("matches injected records to anonymous module.exports single-export files via the module path", () => {
487
- const source = `
488
- function helper(n) {
489
- return n - 1;
490
- }
491
- module.exports = function (a, b) {
492
- return a + b;
493
- };
494
- `;
495
- // 使用方经转发 shim 以别的名字调用:fnName/targetExport/targetAliases
496
- // 都对不上本地 candidate 名 "default",仅模块路命中
497
- const external = [
498
- {
499
- fnName: "renamed",
500
- argTypes: [T.literal(1), T.literal(2)],
501
- resultType: T.literal(3),
502
- throws: T.never,
503
- callLoc: { line: 3, column: 0 },
504
- targetModule: "/test/anon-export.js",
505
- targetExport: "renamed",
506
- targetAliases: ["shorthand"],
507
- },
508
- ];
509
- const result = analyzeFile("/test/anon-export.js", source, undefined, external);
510
- const main = result.functions.find((f) => f.name === "default")!;
511
- expect(main).toBeDefined();
512
- expect(main.entryOnly).toBeFalsy();
513
- expect(main.cases).toHaveLength(1);
514
- expect(main.cases[0].source).toBe("callsite");
515
- expect(main.cases[0].args.map(typeValueToString)).toEqual(["1", "2"]);
516
- expect(typeValueToString(main.cases[0].result)).toBe("3");
517
-
518
- // 同文件其他函数不经模块路误染:名字对不上 → 保持 entry-only
519
- const helper = result.functions.find((f) => f.name === "helper")!;
520
- expect(helper).toBeDefined();
521
- expect(helper.entryOnly).toBe(true);
522
- });
523
-
524
- it("does not attribute module-targeted records to functions in multi-export files", () => {
525
- const source = `
526
- function alpha(x) { return x; }
527
- function beta(x) { return x; }
528
- module.exports = { alpha, beta };
529
- `;
530
- const external = [
531
- {
532
- fnName: "gamma",
533
- argTypes: [T.literal(1)],
534
- resultType: T.literal(1),
535
- throws: T.never,
536
- callLoc: { line: 4, column: 0 },
537
- targetModule: "/test/multi-export.js",
538
- targetExport: "gamma",
539
- },
540
- ];
541
- const result = analyzeFile("/test/multi-export.js", source, undefined, external);
542
- for (const name of ["alpha", "beta"]) {
543
- const fn = result.functions.find((f) => f.name === name)!;
544
- expect(fn).toBeDefined();
545
- expect(fn.entryOnly).toBe(true);
546
- expect(fn.cases[0].source).toBeUndefined();
547
- }
548
- });
549
-
550
- it("skips resultType=never records without throws and falls back to entry-only synthesis", () => {
551
- const source = `
552
- function parseChunked(emitter) {
553
- return emitter;
554
- }
555
- `;
556
- // 高阶 async(new Promise(async …))求值中断的信号泄漏:resultType=never
557
- // 且 throws=never,无任何信息 → 跳过
558
- const external = [
559
- {
560
- fnName: "parseChunked",
561
- argTypes: [T.object({})],
562
- resultType: T.never,
563
- throws: T.never,
564
- callLoc: { line: 5, column: 0 },
565
- targetModule: "/test/higher-order.js",
566
- targetExport: "parseChunked",
567
- },
568
- ];
569
- const result = analyzeFile("/test/higher-order.js", source, undefined, external);
570
- const fn = result.functions.find((f) => f.name === "parseChunked")!;
571
- expect(fn).toBeDefined();
572
- expect(fn.entryOnly).toBe(true);
573
- expect(fn.cases).toHaveLength(1);
574
- expect(fn.cases[0].source).toBeUndefined();
575
- expect(fn.cases[0].name).toMatch(/^entry@L\d+$/);
576
- });
577
-
578
- it("keeps throwing-call records whose resultType is never but throws carries the thrown type", () => {
579
- const source = `
580
- function fail(msg) {
581
- throw new Error(msg);
582
- }
583
- `;
584
- // resultType=never 但 throws≠never:真实的抛出调用,argTypes/throws 均有信息
585
- const external = [
586
- {
587
- fnName: "fail",
588
- argTypes: [T.literal("boom")],
589
- resultType: T.never,
590
- throws: T.string,
591
- callLoc: { line: 7, column: 0 },
592
- targetModule: "/test/throwing.js",
593
- targetExport: "fail",
594
- },
595
- ];
596
- const result = analyzeFile("/test/throwing.js", source, undefined, external);
597
- const fn = result.functions.find((f) => f.name === "fail")!;
598
- expect(fn).toBeDefined();
599
- expect(fn.entryOnly).toBeFalsy();
600
- expect(fn.cases).toHaveLength(1);
601
- expect(fn.cases[0].source).toBe("callsite");
602
- expect(fn.cases[0].args.map(typeValueToString)).toEqual(['"boom"']);
603
- expect(fn.cases[0].result).toEqual(T.never);
604
- expect(fn.cases[0].throws).toEqual(T.string);
605
- });
606
- });
607
-
608
- describe("getTypeAtPosition", () => {
609
- it("returns type for identifier at position", () => {
610
- const source = `const x = 42;\nconst y = x;\n`;
611
- const tv = getTypeAtPosition("/test/pos.js", source, 1, 6);
612
- expect(tv).not.toBeNull();
613
- });
614
-
615
- it("returns null for empty position", () => {
616
- const source = `\n\n\n`;
617
- const tv = getTypeAtPosition("/test/empty.js", source, 2, 0);
618
- expect(tv).toBeNull();
619
- });
620
- });
621
-
622
- describe("getCompletionsAtPosition", () => {
623
- it("returns variable completions without dot trigger", () => {
624
- const source = `const x = 42;\nfunction add(a, b) { return a + b; }\n`;
625
- const completions = getCompletionsAtPosition("/test/comp.js", source, 2, 0);
626
- expect(completions.length).toBeGreaterThan(0);
627
- const names = completions.map((c) => c.label);
628
- expect(names).toContain("x");
629
- expect(names).toContain("add");
630
- });
631
-
632
- it("returns property completions for object after dot", () => {
633
- const source = `const obj = { x: 1, y: "hello" };\nobj.x;\n`;
634
- const completions = getCompletionsAtPosition("/test/dot.js", source, 2, 4);
635
- expect(completions.length).toBeGreaterThan(0);
636
- const names = completions.map((c) => c.label);
637
- expect(names).toContain("x");
638
- expect(names).toContain("y");
639
- });
640
-
641
- it("returns array method completions after dot", () => {
642
- const source = `const arr = [1, 2, 3];\narr.map;\n`;
643
- const completions = getCompletionsAtPosition("/test/arr.js", source, 2, 4);
644
- const names = completions.map((c) => c.label);
645
- expect(names).toContain("map");
646
- expect(names).toContain("filter");
647
- expect(names).toContain("reduce");
648
- expect(names).toContain("length");
649
- });
650
- });
651
-
652
- describe("buildModuleGraph / computeDirtySet / topoSortDirty", () => {
653
- const tmpWrite = (dir: string, name: string, content: string) => {
654
- const path = resolve(dir, name);
655
- writeFileSync(path, content);
656
- return path;
657
- };
658
-
659
- it("builds imports/dependents edges and propagates dirt to transitive dependents", () => {
660
- const dir = mkdtempSync(join(tmpdir(), "nudo-graph-"));
661
- try {
662
- const a = tmpWrite(dir, "a.js", `import { b } from "./b.js";\nexport const a = b;\n`);
663
- const b = tmpWrite(dir, "b.js", `export const b = 1;\n`);
664
- const { imports, dependents } = buildModuleGraph([a, b]);
665
- expect(imports.get(a)).toContain(b);
666
- expect(dependents.get(b)).toContain(a);
667
-
668
- expect(new Set(computeDirtySet(dependents, b))).toEqual(new Set([b, a]));
669
- expect(computeDirtySet(dependents, a)).toEqual([a]);
670
- } finally {
671
- rmSync(dir, { recursive: true, force: true });
672
- }
673
- });
674
-
675
- it("propagates dirt along a three-file chain", () => {
676
- const dir = mkdtempSync(join(tmpdir(), "nudo-graph-"));
677
- try {
678
- const a = tmpWrite(dir, "a.js", `import { b } from "./b.js";\n`);
679
- const b = tmpWrite(dir, "b.js", `import { c } from "./c.js";\n`);
680
- const c = tmpWrite(dir, "c.js", `export const c = 1;\n`);
681
- const { dependents } = buildModuleGraph([a, b, c]);
682
- expect(new Set(computeDirtySet(dependents, c))).toEqual(new Set([c, b, a]));
683
- } finally {
684
- rmSync(dir, { recursive: true, force: true });
685
- }
686
- });
687
-
688
- it("handles import cycles without infinite loop", () => {
689
- const dir = mkdtempSync(join(tmpdir(), "nudo-graph-"));
690
- try {
691
- const a = tmpWrite(dir, "a.js", `import { b } from "./b.js";\nexport const a = 1;\n`);
692
- const b = tmpWrite(dir, "b.js", `import { a } from "./a.js";\nexport const b = 2;\n`);
693
- const { imports, dependents } = buildModuleGraph([a, b]);
694
- const dirty = computeDirtySet(dependents, a);
695
- expect(new Set(dirty)).toEqual(new Set([a, b]));
696
-
697
- const ordered = topoSortDirty(imports, dirty);
698
- expect(new Set(ordered)).toEqual(new Set([a, b]));
699
- } finally {
700
- rmSync(dir, { recursive: true, force: true });
701
- }
702
- });
703
-
704
- it("skips bare npm specifiers and orders dirty set dependencies-first", () => {
705
- const dir = mkdtempSync(join(tmpdir(), "nudo-graph-"));
706
- try {
707
- const a = tmpWrite(dir, "a.js", `import _ from "lodash";\nimport { b } from "./b.js";\n`);
708
- const b = tmpWrite(dir, "b.js", `export const b = 1;\n`);
709
- const { imports, dependents } = buildModuleGraph([a, b]);
710
- expect(imports.get(a)).toEqual(new Set([b]));
711
-
712
- const dirty = computeDirtySet(dependents, b);
713
- const ordered = topoSortDirty(imports, dirty);
714
- expect(ordered.indexOf(b)).toBeLessThan(ordered.indexOf(a));
715
- } finally {
716
- rmSync(dir, { recursive: true, force: true });
717
- }
718
- });
719
- });
720
-
721
- // 来源:LSP 隔离控制工程化——buildModuleGraph mtime 边缓存
722
- describe("buildModuleGraph mtime 边缓存", () => {
723
- const tmpWrite = (dir: string, name: string, content: string) => {
724
- const path = resolve(dir, name);
725
- writeFileSync(path, content);
726
- return path;
727
- };
728
-
729
- it("同输入带/不带 cache 结果一致(含二次全命中)", () => {
730
- const dir = mkdtempSync(join(tmpdir(), "nudo-graph-cache-"));
731
- try {
732
- const a = tmpWrite(dir, "a.js", `import { b } from "./b.js";\n`);
733
- const b = tmpWrite(dir, "b.js", `import { c } from "./c.js";\n`);
734
- const c = tmpWrite(dir, "c.js", `export const c = 1;\n`);
735
- const plain = buildModuleGraph([a, b, c]);
736
- const cache: ModuleGraphCache = new Map();
737
- const once = buildModuleGraph([a, b, c], cache);
738
- const twice = buildModuleGraph([a, b, c], cache); // 第二次全命中
739
- const normalize = (g: { imports: Map<string, Set<string>>; dependents: Map<string, Set<string>> }) =>
740
- JSON.stringify({
741
- imports: [...g.imports].map(([k, v]) => [k, [...v].sort()]),
742
- dependents: [...g.dependents].map(([k, v]) => [k, [...v].sort()]),
743
- });
744
- expect(normalize(once)).toBe(normalize(plain));
745
- expect(normalize(twice)).toBe(normalize(plain));
746
- expect(cache.size).toBe(3); // 三个文件均回填缓存
747
- } finally {
748
- rmSync(dir, { recursive: true, force: true });
749
- }
750
- });
751
-
752
- it("内容变化(size 改变)后失效重读并回填", () => {
753
- const dir = mkdtempSync(join(tmpdir(), "nudo-graph-cache-"));
754
- try {
755
- const a = tmpWrite(dir, "a.js", `import { b } from "./b.js";\n`);
756
- const b = tmpWrite(dir, "b.js", `export const b = 1;\n`);
757
- const d = tmpWrite(dir, "d.js", `export const d = 1;\n`);
758
- const cache: ModuleGraphCache = new Map();
759
- const before = buildModuleGraph([a, b, d], cache);
760
- expect(before.imports.get(a)).toEqual(new Set([b]));
761
- // 改为同时导入 b、d:size 变化必然导致缓存失效
762
- writeFileSync(a, `import { b } from "./b.js";\nimport { d } from "./d.js";\n`);
763
- const after = buildModuleGraph([a, b, d], cache);
764
- expect(after.imports.get(a)).toEqual(new Set([b, d]));
765
- expect(new Set(cache.get(a)!.edges)).toEqual(new Set([b, d]));
766
- } finally {
767
- rmSync(dir, { recursive: true, force: true });
768
- }
769
- });
770
-
771
- it("仅 mtime 变化(size 不变)后失效重读", () => {
772
- const dir = mkdtempSync(join(tmpdir(), "nudo-graph-cache-"));
773
- try {
774
- const a = tmpWrite(dir, "a.js", `import { v } from "./b.js";\n`);
775
- const b = tmpWrite(dir, "b.js", `export const v = 1;\n`);
776
- const c = tmpWrite(dir, "c.js", `export const v = 2;\n`);
777
- const cache: ModuleGraphCache = new Map();
778
- expect(buildModuleGraph([a, b, c], cache).imports.get(a)).toEqual(new Set([b]));
779
- // 同尺寸内容换成导入 c,再用 utimesSync 显式把 mtime 拉开 10s(size 维度保持一致)
780
- writeFileSync(a, `import { v } from "./c.js";\n`);
781
- const forced = new Date(cache.get(a)!.mtimeMs + 10_000);
782
- utimesSync(a, forced, forced);
783
- const after = buildModuleGraph([a, b, c], cache);
784
- expect(after.imports.get(a)).toEqual(new Set([c]));
785
- } finally {
786
- rmSync(dir, { recursive: true, force: true });
787
- }
788
- });
789
-
790
- it("命中时不读盘:文件不可读仍能从 cache 得出正确边", () => {
791
- const dir = mkdtempSync(join(tmpdir(), "nudo-graph-cache-"));
792
- try {
793
- const a = tmpWrite(dir, "a.js", `import { b } from "./b.js";\n`);
794
- const b = tmpWrite(dir, "b.js", `export const b = 1;\n`);
795
- const cache: ModuleGraphCache = new Map();
796
- buildModuleGraph([a, b], cache);
797
- chmodSync(a, 0o000); // chmod 不改 mtime → 必命中;statSync 仍可用,readFileSync 将 EACCES
798
- try {
799
- const hit = buildModuleGraph([a, b], cache);
800
- expect(hit.imports.get(a)).toEqual(new Set([b]));
801
- expect(hit.dependents.get(b)).toEqual(new Set([a]));
802
- // 对照:不带 cache 必须读盘,EACCES 下抽不出任何边——证明上面的边确实来自缓存
803
- const miss = buildModuleGraph([a, b]);
804
- expect(miss.imports.get(a)).toEqual(new Set());
805
- } finally {
806
- chmodSync(a, 0o644);
807
- }
808
- } finally {
809
- rmSync(dir, { recursive: true, force: true });
810
- }
811
- });
812
- });