@nudojs/lsp 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,822 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { chmodSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join, resolve } from "node:path";
5
+ import {
6
+ analyzeFile,
7
+ getTypeAtPosition,
8
+ getCompletionsAtPosition,
9
+ getCasesForFile,
10
+ typeValueToTSType,
11
+ generateDts,
12
+ typeValueToZodSchema,
13
+ generateGuardFunction,
14
+ } from "@nudojs/service";
15
+ import { T } from "@nudojs/core";
16
+ import { parse } from "@nudojs/parser";
17
+ import { buildSymbolTable, findDefinition, findReferences, findIdentifierAtPosition } from "../symbols.ts";
18
+ import {
19
+ analysisCache,
20
+ clearValidationState,
21
+ evictModuleGraphCacheEntries,
22
+ forgetValidatedFile,
23
+ getCachedOrAnalyze,
24
+ hasNudoDirectives,
25
+ knownFiles,
26
+ moduleGraphCache,
27
+ validateText,
28
+ type ValidateTextDeps,
29
+ } from "../validation.ts";
30
+ import {
31
+ injectBindings,
32
+ normalizeFilePath,
33
+ parseTypeExpr,
34
+ readSource,
35
+ suggestCase,
36
+ trace,
37
+ typeExprToDirective,
38
+ whatIf,
39
+ type AgentToolDeps,
40
+ } from "../agent-tools.ts";
41
+
42
+ const filePath = "/test/lsp-integration.js";
43
+ const testCode = `
44
+ // @nudo:case "admin" ({ role: "admin", name: "Alice" })
45
+ // @nudo:case "user" ({ role: "user", name: "Bob" })
46
+ function getGreeting(user) {
47
+ switch (user.role) {
48
+ case "admin":
49
+ return "Hello Admin " + user.name;
50
+ case "user":
51
+ return "Hi " + user.name;
52
+ }
53
+ }
54
+
55
+ // @nudo:case "success" ({ status: 200, data: { items: [1, 2, 3] } })
56
+ // @nudo:case "error" ({ status: 500, error: "fail" })
57
+ function handleResponse(res) {
58
+ if (res.status === 200) {
59
+ return res.data.items;
60
+ }
61
+ return res.error;
62
+ }
63
+
64
+ // @nudo:case "with-val" ("hello")
65
+ // @nudo:case "null" (null)
66
+ function process(val) {
67
+ if (!val) return "empty";
68
+ return val.toUpperCase();
69
+ }
70
+
71
+ function helper(x) {
72
+ return x * 2;
73
+ }
74
+
75
+ // @nudo:case "test" ([1, 2, 3])
76
+ function first(arr) {
77
+ if (!Array.isArray(arr)) return null;
78
+ return arr[0];
79
+ }
80
+
81
+ /**
82
+ * @nudo:returns (T.string)
83
+ * @nudo:case "num" (42)
84
+ */
85
+ function alwaysString(x) {
86
+ if (typeof x === "number") return x;
87
+ return "default";
88
+ }
89
+ `;
90
+
91
+ describe("LSP Integration - Full Pipeline", () => {
92
+
93
+ describe("analyzeFile", () => {
94
+ it("analyzes all functions with directives", () => {
95
+ const result = analyzeFile(filePath, testCode);
96
+ expect(result.functions.length).toBeGreaterThanOrEqual(4);
97
+ const names = result.functions.map(f => f.name);
98
+ expect(names).toContain("getGreeting");
99
+ expect(names).toContain("handleResponse");
100
+ expect(names).toContain("process");
101
+ expect(names).toContain("first");
102
+ });
103
+
104
+ it("returns case hints for each function", () => {
105
+ const result = analyzeFile(filePath, testCode);
106
+ expect(result.caseHints.length).toBeGreaterThan(0);
107
+ for (const hint of result.caseHints) {
108
+ expect(hint.line).toBeGreaterThan(0);
109
+ expect(hint.label).toBeTruthy();
110
+ }
111
+ });
112
+
113
+ it("generates diagnostics for assertion failures", () => {
114
+ const result = analyzeFile(filePath, testCode);
115
+ const assertionDiags = result.diagnostics.filter(d => d.code === "nudo-assertion-failed");
116
+ expect(assertionDiags.length).toBeGreaterThan(0);
117
+ expect(assertionDiags[0].message).toContain("inferred");
118
+ });
119
+ });
120
+
121
+ describe("getTypeAtPosition", () => {
122
+ it("returns type for function with directives", () => {
123
+ // Test with a simple single-function source
124
+ const simpleCode = `
125
+ // @nudo:case "test" (42)
126
+ function foo(x) {
127
+ return x + 1;
128
+ }
129
+ `;
130
+ const tv = getTypeAtPosition("/test/simple.js", simpleCode, 3, 10);
131
+ // May return null if position is not in the right place, but should not throw
132
+ expect(true).toBe(true);
133
+ });
134
+
135
+ it("returns null for non-nudo files", () => {
136
+ const plainCode = `function foo(x) { return x; }`;
137
+ const tv = getTypeAtPosition("/test/plain.js", plainCode, 1, 22);
138
+ expect(tv).toBeNull();
139
+ });
140
+ });
141
+
142
+ describe("getCompletionsAtPosition", () => {
143
+ it("returns completions for object properties", () => {
144
+ const completions = getCompletionsAtPosition(filePath, testCode, 7, 20); // after 'user.'
145
+ expect(Array.isArray(completions)).toBe(true);
146
+ });
147
+ });
148
+
149
+ describe("getCasesForFile", () => {
150
+ it("returns all cases for all functions", () => {
151
+ const cases = getCasesForFile(filePath, testCode);
152
+ expect(cases.length).toBeGreaterThanOrEqual(4);
153
+
154
+ const greetingCases = cases.find(c => c.functionName === "getGreeting");
155
+ expect(greetingCases).toBeDefined();
156
+ expect(greetingCases!.cases.length).toBe(2);
157
+ expect(greetingCases!.cases[0].name).toBe("admin");
158
+ expect(greetingCases!.cases[1].name).toBe("user");
159
+ });
160
+ });
161
+
162
+ describe("validation core (validateText / analysis cache / dirty propagation)", () => {
163
+ it("getCachedOrAnalyze reuses the cached result for the same document version", () => {
164
+ clearValidationState();
165
+ const src = `function badNum(n) { return n.toUpperCase(); }\nconst boom = badNum(42);\n`;
166
+
167
+ const first = getCachedOrAnalyze("/test/cache.js", src, 1);
168
+ const second = getCachedOrAnalyze("/test/cache.js", src, 1);
169
+ expect(second).toBe(first); // identical reference → no re-analysis on cache hit
170
+
171
+ const bumped = getCachedOrAnalyze("/test/cache.js", src, 2);
172
+ expect(bumped).not.toBe(first); // version change → re-evaluated
173
+ expect(bumped.diagnostics.some((d) => d.code === "nudo:no-method")).toBe(true);
174
+ });
175
+
176
+ it("validateText maps origin provenance to relatedInformation at the argument literal", async () => {
177
+ clearValidationState();
178
+ const src = [
179
+ "function badNum(n) {",
180
+ " return n.toUpperCase();",
181
+ "}",
182
+ "const boom = badNum(42);",
183
+ "",
184
+ ].join("\n");
185
+
186
+ const sent = new Map<string, any[]>();
187
+ await validateText("/test/origin.js", "file:///test/origin.js", src, 1, {
188
+ sendDiagnostics: (p) => sent.set(p.uri, p.diagnostics),
189
+ });
190
+
191
+ expect([...sent.keys()]).toEqual(["file:///test/origin.js"]);
192
+ const noMethod = sent.get("file:///test/origin.js")!.find((d) => d.code === "nudo:no-method");
193
+ expect(noMethod).toBeDefined();
194
+ expect(noMethod.relatedInformation).toHaveLength(1);
195
+ const related = noMethod.relatedInformation[0];
196
+ expect(related.message).toBe("value originates here");
197
+ expect(related.location.uri).toBe("file:///test/origin.js");
198
+ expect(related.location.range.start.line).toBe(3); // 1-based line 4 → 0-based
199
+ const col = related.location.range.start.character;
200
+ expect(col).toBeGreaterThanOrEqual(18); // points at the 42 literal
201
+ expect(col).toBeLessThanOrEqual(22);
202
+
203
+ // async validation refreshes the analysis cache for subsequent handlers
204
+ expect(analysisCache.get("/test/origin.js")?.version).toBe(1);
205
+ expect(knownFiles.has("/test/origin.js")).toBe(true);
206
+ });
207
+
208
+ it("revalidates open dependents when an imported module changes", async () => {
209
+ clearValidationState();
210
+ const dir = mkdtempSync(join(tmpdir(), "nudo-lsp-prop-"));
211
+ try {
212
+ const bPath = resolve(dir, "b.js");
213
+ const aPath = resolve(dir, "a.js");
214
+ const uriOf = (p: string) => `file://${p}`;
215
+ // a.js: calls the imported g(42) from inside a function (top-level call makes it evaluate)
216
+ const aSrc = [
217
+ 'import { g } from "./b.js";',
218
+ '// @nudo:case "smoke" (1)',
219
+ "function tiny(v) { return v; }",
220
+ "function useG() { return g(42); }",
221
+ "useG();",
222
+ "",
223
+ ].join("\n");
224
+ const bV1 = '// @nudo:case "s" ("hi")\nexport function g(x) { return x; }\n';
225
+ const bV2 = '// @nudo:case "s" ("hi")\nexport function g(x) { return x.toUpperCase(); }\n';
226
+ writeFileSync(bPath, bV1);
227
+ writeFileSync(aPath, aSrc);
228
+
229
+ let bText = bV1;
230
+ let bVersion = 1;
231
+ const openDocs = new Map<string, { uri: string; version: number; getText(): string }>([
232
+ [aPath, { uri: uriOf(aPath), version: 1, getText: () => aSrc }],
233
+ [bPath, { uri: uriOf(bPath), version: bVersion, getText: () => bText }],
234
+ ]);
235
+ const sent = new Map<string, any[]>();
236
+ const deps: ValidateTextDeps = {
237
+ sendDiagnostics: (p) => sent.set(p.uri, p.diagnostics),
238
+ isNudoUri: (uri) => {
239
+ const doc = [...openDocs.values()].find((d) => d.uri === uri);
240
+ return doc ? hasNudoDirectives(doc.getText()) : false;
241
+ },
242
+ getActiveCases: () => new Map(),
243
+ getOpenDocumentByPath: (p) => openDocs.get(p),
244
+ };
245
+
246
+ // initial validation: b v1 is safe, a must have no no-method diagnostic
247
+ await validateText(aPath, uriOf(aPath), aSrc, 1, deps, true);
248
+ const initial = sent.get(uriOf(aPath)) ?? [];
249
+ expect(initial.some((d) => d.code === "nudo:no-method")).toBe(false);
250
+
251
+ // b changes (buffer + disk, so a's import resolution sees the new body)
252
+ bText = bV2;
253
+ bVersion = 2;
254
+ writeFileSync(bPath, bV2);
255
+ await validateText(bPath, uriOf(bPath), bText, bVersion, deps, true);
256
+
257
+ // a was revalidated via dirty propagation and now flags g(42)
258
+ const refreshed = sent.get(uriOf(aPath)) ?? [];
259
+ const noMethod = refreshed.find((d) => d.code === "nudo:no-method");
260
+ expect(noMethod).toBeDefined();
261
+ expect(noMethod.message).toContain("toUpperCase");
262
+ expect(noMethod.relatedInformation).toHaveLength(1);
263
+ expect(noMethod.relatedInformation[0].location.uri).toBe(uriOf(aPath));
264
+ expect(noMethod.relatedInformation[0].location.range.start.line).toBe(3); // `function useG() { return g(42); }`
265
+
266
+ // propagation registered both files and refreshed a's cached result
267
+ expect(knownFiles.has(aPath)).toBe(true);
268
+ expect(knownFiles.has(bPath)).toBe(true);
269
+ expect(analysisCache.get(aPath)?.result.diagnostics.some((d) => d.code === "nudo:no-method")).toBe(true);
270
+ } finally {
271
+ rmSync(dir, { recursive: true, force: true });
272
+ }
273
+ });
274
+
275
+ it("opening a never-validated file pushes diagnostics immediately (onDidOpen path)", async () => {
276
+ // server.ts 的 onDidOpen → validateDocument(doc, /* propagate */ true) → validateText;
277
+ // didOpen 不经过 onDidChangeContent,打开路径必须主动调一次验证才有即时诊断。
278
+ clearValidationState();
279
+ const src = "function badNum(n) { return n.toUpperCase(); }\nconst boom = badNum(42);\n";
280
+ const sent = new Map<string, any[]>();
281
+ expect(analysisCache.has("/test/open-fresh.js")).toBe(false); // 之前从未验证过
282
+
283
+ await validateText("/test/open-fresh.js", "file:///test/open-fresh.js", src, 1, {
284
+ sendDiagnostics: (p) => sent.set(p.uri, p.diagnostics),
285
+ }, true);
286
+
287
+ const diags = sent.get("file:///test/open-fresh.js") ?? [];
288
+ expect(diags.some((d) => d.code === "nudo:no-method")).toBe(true);
289
+ expect(analysisCache.get("/test/open-fresh.js")?.version).toBe(1);
290
+ expect(knownFiles.has("/test/open-fresh.js")).toBe(true);
291
+ });
292
+
293
+ it("opening a non-nudo file publishes empty diagnostics and stays out of session state", async () => {
294
+ // 打开入口对非 nudo 文件的门控:清空诊断且不进 knownFiles/analysisCache
295
+ clearValidationState();
296
+ const sent = new Map<string, any[]>();
297
+ await validateText("/test/open-plain.js", "file:///test/open-plain.js", "function id(x) { return x; }\n", 1, {
298
+ sendDiagnostics: (p) => sent.set(p.uri, p.diagnostics),
299
+ isNudoUri: () => false,
300
+ }, true);
301
+
302
+ expect(sent.get("file:///test/open-plain.js")).toEqual([]);
303
+ expect(knownFiles.has("/test/open-plain.js")).toBe(false);
304
+ expect(analysisCache.has("/test/open-plain.js")).toBe(false);
305
+ });
306
+
307
+ it("an externally deleted closed file is dropped from knownFiles (watched-files cleanup)", async () => {
308
+ // server.ts 的 DidChangeWatchedFiles(Deleted 且不在打开集) handler 对每个被删
309
+ // uri 调 forgetValidatedFile;这里验证该清理单元确实移除会话登记项。
310
+ clearValidationState();
311
+ const src = '// @nudo:case "n" (1)\nfunction f(x) { return x; }\n';
312
+ await validateText("/test/gone.js", "file:///test/gone.js", src, 1, {
313
+ sendDiagnostics: () => {},
314
+ });
315
+ expect(knownFiles.has("/test/gone.js")).toBe(true);
316
+ expect(analysisCache.has("/test/gone.js")).toBe(true);
317
+
318
+ forgetValidatedFile("/test/gone.js");
319
+
320
+ expect(knownFiles.has("/test/gone.js")).toBe(false);
321
+ expect(analysisCache.has("/test/gone.js")).toBe(false);
322
+ });
323
+
324
+ it("second propagation hits the module-graph edge cache — an unreadable dependency still yields the correct dirty set", async () => {
325
+ // 缓存命中的可观测证明:首验后把「携带依赖边的文件」a chmod 成不可读。
326
+ // 若脏传播重读 a,extractImportEdges 会因 EACCES 静默返回空边,dependents(b)
327
+ // 丢失 a→b,打开中的 a 就不会被重验;只有命中缓存(仅 stat 比对、跳过重读)
328
+ // 时 dirty 集才仍包含 a。
329
+ clearValidationState();
330
+ const dir = mkdtempSync(join(tmpdir(), "nudo-lsp-mgc-hit-"));
331
+ try {
332
+ const aPath = resolve(dir, "a.js");
333
+ const bPath = resolve(dir, "b.js");
334
+ const uriOf = (p: string) => `file://${p}`;
335
+ const aSrc = [
336
+ 'import { g } from "./b.js";',
337
+ "function useG() { return g(42); }",
338
+ "useG();",
339
+ "",
340
+ ].join("\n");
341
+ const bV1 = "export function g(x) { return x; }\n";
342
+ const bV2 = "export function g(x) { return x.toUpperCase(); }\n";
343
+ writeFileSync(aPath, aSrc);
344
+ writeFileSync(bPath, bV1);
345
+
346
+ let bText = bV1;
347
+ const openDocs = new Map<string, { uri: string; version: number; getText(): string }>([
348
+ [aPath, { uri: uriOf(aPath), version: 1, getText: () => aSrc }],
349
+ [bPath, { uri: uriOf(bPath), version: 1, getText: () => bText }],
350
+ ]);
351
+ const sent = new Map<string, any[]>();
352
+ const deps: ValidateTextDeps = {
353
+ sendDiagnostics: (p) => sent.set(p.uri, p.diagnostics),
354
+ getActiveCases: () => new Map(),
355
+ getOpenDocumentByPath: (p) => openDocs.get(p),
356
+ };
357
+
358
+ // 首验:b、a 依次入会话;a 的传播首次构建模块图并回填边缓存
359
+ await validateText(bPath, uriOf(bPath), bV1, 1, deps);
360
+ await validateText(aPath, uriOf(aPath), aSrc, 1, deps, true);
361
+ expect(moduleGraphCache.has(aPath)).toBe(true);
362
+ expect(moduleGraphCache.has(bPath)).toBe(true);
363
+ expect(new Set(moduleGraphCache.get(aPath)!.edges)).toEqual(new Set([bPath]));
364
+ expect((sent.get(uriOf(aPath)) ?? []).some((d) => d.code === "nudo:no-method")).toBe(false);
365
+
366
+ // 依赖边携带者变磁盘不可读(chmod 不改 mtime/size → 缓存条目仍命中),b 内容换版
367
+ chmodSync(aPath, 0o000);
368
+ bText = bV2;
369
+ writeFileSync(bPath, bV2);
370
+ await validateText(bPath, uriOf(bPath), bText, 2, deps, true);
371
+
372
+ // dirty 集仍含 a:传播用缓存边算出 dependents(b)={a},a 用打开缓冲重验并抓到 g(42)
373
+ const refreshed = sent.get(uriOf(aPath)) ?? [];
374
+ const noMethod = refreshed.find((d) => d.code === "nudo:no-method");
375
+ expect(noMethod).toBeDefined();
376
+ expect(noMethod.message).toContain("toUpperCase");
377
+ } finally {
378
+ rmSync(dir, { recursive: true, force: true });
379
+ }
380
+ });
381
+
382
+ it("mtime/size change invalidates the cached edges — propagation follows the rewired import", async () => {
383
+ clearValidationState();
384
+ const dir = mkdtempSync(join(tmpdir(), "nudo-lsp-mgc-inval-"));
385
+ try {
386
+ const aPath = resolve(dir, "a.js");
387
+ const bPath = resolve(dir, "b.js");
388
+ const cPath = resolve(dir, "c.js");
389
+ const uriOf = (p: string) => `file://${p}`;
390
+ const aV1 = [
391
+ 'import { g } from "./b.js";',
392
+ "function useG() { return g(42); }",
393
+ "useG();",
394
+ "",
395
+ ].join("\n");
396
+ // 改依赖目标 b→c:注释行保证 size 变化,mtime/size 任一不同即缓存失效重读
397
+ const aV2 = [
398
+ "// rewired: b → c",
399
+ 'import { h } from "./c.js";',
400
+ "function useH() { return h(42); }",
401
+ "useH();",
402
+ "",
403
+ ].join("\n");
404
+ const bV1 = "export function g(x) { return x; }\n";
405
+ const bV2 = "export function g(x) { return x.toUpperCase(); }\n";
406
+ const cV1 = "export function h(x) { return x; }\n";
407
+ const cV2 = "export function h(x) { return x.toUpperCase(); }\n";
408
+ writeFileSync(aPath, aV1);
409
+ writeFileSync(bPath, bV1);
410
+ writeFileSync(cPath, cV1);
411
+
412
+ let aText = aV1;
413
+ let cText = cV1;
414
+ const openDocs = new Map<string, { uri: string; version: number; getText(): string }>([
415
+ [aPath, { uri: uriOf(aPath), version: 1, getText: () => aText }],
416
+ [cPath, { uri: uriOf(cPath), version: 1, getText: () => cText }],
417
+ ]);
418
+ const publishes = new Map<string, number>();
419
+ const sent = new Map<string, any[]>();
420
+ const deps: ValidateTextDeps = {
421
+ sendDiagnostics: (p) => {
422
+ publishes.set(p.uri, (publishes.get(p.uri) ?? 0) + 1);
423
+ sent.set(p.uri, p.diagnostics);
424
+ },
425
+ getActiveCases: () => new Map(),
426
+ getOpenDocumentByPath: (p) => openDocs.get(p),
427
+ };
428
+
429
+ // 首轮:c、b、a 入会话,a 的传播回填边缓存 a→[b]
430
+ await validateText(cPath, uriOf(cPath), cV1, 1, deps);
431
+ await validateText(bPath, uriOf(bPath), bV1, 1, deps);
432
+ await validateText(aPath, uriOf(aPath), aV1, 1, deps, true);
433
+ expect(new Set(moduleGraphCache.get(aPath)!.edges)).toEqual(new Set([bPath]));
434
+
435
+ // 磁盘 + 缓冲同步改写 a(size 变化)→ 失效重读,缓存边集换成 a→[c]
436
+ aText = aV2;
437
+ writeFileSync(aPath, aV2);
438
+ await validateText(aPath, uriOf(aPath), aV2, 2, deps, true);
439
+ expect(new Set(moduleGraphCache.get(aPath)!.edges)).toEqual(new Set([cPath]));
440
+
441
+ const aPublishesBefore = publishes.get(uriOf(aPath)) ?? 0;
442
+
443
+ // 旧依赖 b 变化:a 已不在 b 的 dependents 里,不应再触发 a 的重验发布
444
+ writeFileSync(bPath, bV2);
445
+ await validateText(bPath, uriOf(bPath), bV2, 2, deps, true);
446
+ expect(publishes.get(uriOf(aPath)) ?? 0).toBe(aPublishesBefore);
447
+
448
+ // 新依赖 c 变化:传播沿新边 a→c 重验 a,抓到 h(42) 的 no-method
449
+ cText = cV2;
450
+ writeFileSync(cPath, cV2);
451
+ await validateText(cPath, uriOf(cPath), cText, 2, deps, true);
452
+ expect(publishes.get(uriOf(aPath)) ?? 0).toBe(aPublishesBefore + 1);
453
+ const noMethod = (sent.get(uriOf(aPath)) ?? []).find((d) => d.code === "nudo:no-method");
454
+ expect(noMethod).toBeDefined();
455
+ expect(noMethod.message).toContain("toUpperCase");
456
+ } finally {
457
+ rmSync(dir, { recursive: true, force: true });
458
+ }
459
+ });
460
+
461
+ it("watched-files cleanup evicts the module-graph cache entry (registerWatchedFilesListener chain)", async () => {
462
+ // server.ts 在 vitest 下不可 import,链路两段各自落在可测面:handler 对每个
463
+ // gone uri 调 forgetValidatedFile,并把 uri 列表广播给 server.ts 模块级注册的
464
+ // evictModuleGraphCacheEntries。这里用同一导出面模拟整条清理链。
465
+ clearValidationState();
466
+ const dir = mkdtempSync(join(tmpdir(), "nudo-lsp-mgc-evict-"));
467
+ try {
468
+ const aPath = resolve(dir, "a.js");
469
+ const src = "function id(x) { return x; }\n";
470
+ writeFileSync(aPath, src);
471
+ // propagate=true 才会构建模块图回填缓存,故必须提供 getOpenDocumentByPath
472
+ await validateText(aPath, `file://${aPath}`, src, 1, {
473
+ sendDiagnostics: () => {},
474
+ getOpenDocumentByPath: () => undefined,
475
+ }, true);
476
+ expect(knownFiles.has(aPath)).toBe(true);
477
+ expect(analysisCache.has(aPath)).toBe(true);
478
+ expect(moduleGraphCache.has(aPath)).toBe(true);
479
+
480
+ // watched-files Deleted 且不在打开集:登记项 + 边缓存条目一并逐出
481
+ forgetValidatedFile(aPath);
482
+ evictModuleGraphCacheEntries([`file://${aPath}`]);
483
+
484
+ expect(knownFiles.has(aPath)).toBe(false);
485
+ expect(analysisCache.has(aPath)).toBe(false);
486
+ expect(moduleGraphCache.has(aPath)).toBe(false);
487
+
488
+ // 未知条目/纯路径形式:no-op 不抛错
489
+ expect(() => evictModuleGraphCacheEntries(["file:///nope/missing.js", "/nope/raw-path.js"])).not.toThrow();
490
+ } finally {
491
+ rmSync(dir, { recursive: true, force: true });
492
+ }
493
+ });
494
+ });
495
+ });
496
+
497
+ describe("LSP Integration - Symbol Table", () => {
498
+ it("finds function definition", () => {
499
+ const ast = parse(testCode);
500
+ const table = buildSymbolTable(ast, "file:///test.js");
501
+
502
+ const def = findDefinition(table, "getGreeting");
503
+ expect(def).not.toBeNull();
504
+ expect(def!.kind).toBe("function");
505
+ expect(def!.loc.start.line).toBe(4);
506
+ });
507
+
508
+ it("finds variable definition", () => {
509
+ const source = `const myVar = 42; console.log(myVar);`;
510
+ const ast = parse(source);
511
+ const table = buildSymbolTable(ast, "file:///test.js");
512
+
513
+ const def = findDefinition(table, "myVar");
514
+ expect(def).not.toBeNull();
515
+ expect(def!.kind).toBe("variable");
516
+ });
517
+
518
+ it("finds all references to a symbol", () => {
519
+ const source = `
520
+ function add(a, b) { return a + b; }
521
+ const result = add(1, 2);
522
+ const doubled = add(result, result);
523
+ `;
524
+ const ast = parse(source);
525
+ const table = buildSymbolTable(ast, "file:///test.js");
526
+ const refs = findReferences(table, "add");
527
+ expect(refs.length).toBeGreaterThanOrEqual(2);
528
+ });
529
+
530
+ it("finds identifier at specific position", () => {
531
+ const ast = parse(testCode);
532
+ const ident = findIdentifierAtPosition(ast, 5, 10); // inside getGreeting function
533
+ expect(ident).not.toBeNull();
534
+ });
535
+ });
536
+
537
+ describe("LSP Integration - Type Generation", () => {
538
+ it("generates DTS for analyzed functions", () => {
539
+ const result = analyzeFile(filePath, testCode);
540
+ const dts = generateDts(result);
541
+ expect(dts).toContain("getGreeting");
542
+ expect(dts).toContain("handleResponse");
543
+ expect(dts).toContain("export declare function");
544
+ });
545
+
546
+ it("generates Zod schemas for inferred types", () => {
547
+ const result = analyzeFile(filePath, testCode);
548
+ const greetingFn = result.functions.find(f => f.name === "getGreeting");
549
+ expect(greetingFn).toBeDefined();
550
+ if (greetingFn?.combined) {
551
+ const schema = typeValueToZodSchema(greetingFn.combined);
552
+ expect(schema).toBeTruthy();
553
+ expect(schema.length).toBeGreaterThan(0);
554
+ }
555
+ });
556
+
557
+ it("generates guard functions for inferred types", () => {
558
+ // Verify the function exists and can be called
559
+ expect(typeof generateGuardFunction).toBe("function");
560
+ // Test with a mock literal type value
561
+ const mockLiteral = { kind: "literal", value: 42 };
562
+ const guard = generateGuardFunction("is42", mockLiteral as any);
563
+ expect(guard).toContain("function is42");
564
+ expect(guard).toContain("return");
565
+ expect(guard).toContain("42");
566
+ });
567
+
568
+ it("converts type values to TS types", () => {
569
+ const result = analyzeFile(filePath, testCode);
570
+ for (const fn of result.functions) {
571
+ for (const c of fn.cases) {
572
+ const tsType = typeValueToTSType(c.result);
573
+ expect(tsType).toBeTruthy();
574
+ expect(typeof tsType).toBe("string");
575
+ }
576
+ }
577
+ });
578
+ });
579
+
580
+ describe("LSP Integration - Narrowing Features", () => {
581
+ it("narrows through switch statement", () => {
582
+ // Test that analyzeFile produces different results for different cases
583
+ const result = analyzeFile(filePath, testCode);
584
+ const greetingFn = result.functions.find(f => f.name === "getGreeting");
585
+ expect(greetingFn).toBeDefined();
586
+ expect(greetingFn!.cases.length).toBe(2);
587
+ // Each case should produce different types
588
+ const case0 = greetingFn!.cases[0];
589
+ const case1 = greetingFn!.cases[1];
590
+ expect(case0.result).toBeDefined();
591
+ expect(case1.result).toBeDefined();
592
+ });
593
+
594
+ it("narrows through truthiness check", () => {
595
+ // Test that the process function narrows correctly
596
+ const result = analyzeFile(filePath, testCode);
597
+ const processFn = result.functions.find(f => f.name === "process");
598
+ expect(processFn).toBeDefined();
599
+ expect(processFn!.cases.length).toBe(2);
600
+ // The "with-val" case should return a string
601
+ const withValCase = processFn!.cases.find(c => c.name === "with-val");
602
+ expect(withValCase).toBeDefined();
603
+ });
604
+
605
+ it("narrows through Array.isArray", () => {
606
+ // Test that the first function handles arrays
607
+ const result = analyzeFile(filePath, testCode);
608
+ const firstFn = result.functions.find(f => f.name === "first");
609
+ expect(firstFn).toBeDefined();
610
+ expect(firstFn!.cases.length).toBe(1);
611
+ expect(firstFn!.cases[0].name).toBe("test");
612
+ });
613
+
614
+ it("narrows through status comparison", () => {
615
+ // Test that handleResponse narrows based on status
616
+ const result = analyzeFile(filePath, testCode);
617
+ const handleResponseFn = result.functions.find(f => f.name === "handleResponse");
618
+ expect(handleResponseFn).toBeDefined();
619
+ expect(handleResponseFn!.cases.length).toBe(2);
620
+ const successCase = handleResponseFn!.cases.find(c => c.name === "success");
621
+ const errorCase = handleResponseFn!.cases.find(c => c.name === "error");
622
+ expect(successCase).toBeDefined();
623
+ expect(errorCase).toBeDefined();
624
+ });
625
+ });
626
+
627
+ describe("LSP Integration - Agent Tools (whatIf / suggestCase / trace)", () => {
628
+ const whatIfSrc = "const x = 1;\nconst y = x + 1;\n";
629
+ const srcDeps = (src: string): AgentToolDeps => ({ readFile: () => src });
630
+
631
+ describe("parseTypeExpr (ported from MCP)", () => {
632
+ it("maps primitive names to T singletons", () => {
633
+ expect(parseTypeExpr("number")).toEqual(T.number);
634
+ expect(parseTypeExpr("string")).toEqual(T.string);
635
+ expect(parseTypeExpr("boolean")).toEqual(T.boolean);
636
+ expect(parseTypeExpr("bigint")).toEqual(T.bigint);
637
+ expect(parseTypeExpr("symbol")).toEqual(T.symbol);
638
+ expect(parseTypeExpr("null")).toEqual(T.null);
639
+ expect(parseTypeExpr("undefined")).toEqual(T.undefined);
640
+ });
641
+
642
+ it("builds unions from `|` expressions", () => {
643
+ const u = parseTypeExpr("string | null");
644
+ expect(u.kind).toBe("union");
645
+ if (u.kind === "union") {
646
+ expect(u.members).toHaveLength(2);
647
+ expect(u.members[0]).toEqual(T.string);
648
+ expect(u.members[1]).toEqual(T.null);
649
+ }
650
+ });
651
+
652
+ it("falls back to unknown for unrecognized names", () => {
653
+ expect(parseTypeExpr("Date")).toEqual(T.unknown);
654
+ });
655
+ });
656
+
657
+ describe("typeExprToDirective / injectBindings", () => {
658
+ it("translates agent type expressions to @nudo:as directive syntax", () => {
659
+ expect(typeExprToDirective("string")).toBe("T.string");
660
+ expect(typeExprToDirective("string | null")).toBe("T.union(T.string, null)");
661
+ expect(typeExprToDirective("T.object({ port: T.number })")).toBe("T.object({ port: T.number })");
662
+ expect(typeExprToDirective("Date")).toBe("T.unknown");
663
+ });
664
+
665
+ it("inserts an @nudo:as line above the declaring statement", () => {
666
+ const { source, applied, unapplied } = injectBindings(whatIfSrc, [
667
+ { name: "x", type: "string | null" },
668
+ ]);
669
+ expect(applied).toEqual(["x: string | null"]);
670
+ expect(unapplied).toEqual([]);
671
+ expect(source).toBe("// @nudo:as T.union(T.string, null)\nconst x = 1;\nconst y = x + 1;\n");
672
+ });
673
+
674
+ it("reports bindings with no matching top-level declaration as unapplied", () => {
675
+ const { source, applied, unapplied } = injectBindings(whatIfSrc, [
676
+ { name: "nope", type: "string" },
677
+ ]);
678
+ expect(applied).toEqual([]);
679
+ expect(unapplied).toEqual(["nope"]);
680
+ expect(source).toBe(whatIfSrc);
681
+ });
682
+ });
683
+
684
+ describe("whatIf binding injection", () => {
685
+ it("bindings change the inferred target type (default vs assumed)", () => {
686
+ const base = whatIf({ file: "/test/what-if.js", bindings: [], target: "y" }, srcDeps(whatIfSrc));
687
+ const assumed = whatIf(
688
+ { file: "/test/what-if.js", bindings: [{ name: "x", type: "string" }], target: "y" },
689
+ srcDeps(whatIfSrc),
690
+ );
691
+
692
+ // without assumptions x=1 folds to a literal and y = 2
693
+ expect(base.content[0].text).toContain('Type of "y": 2');
694
+ // with x:string the + operator widens y to string — the assumption really flowed
695
+ expect(assumed.content[0].text).toContain('Type of "y": string');
696
+ expect(assumed.content[0].text).not.toBe(base.content[0].text);
697
+ expect(assumed.content[0].text).toContain("Bindings applied: x: string");
698
+ });
699
+
700
+ it("union bindings surface as union types", () => {
701
+ const res = whatIf(
702
+ { file: "/test/what-if.js", bindings: [{ name: "x", type: "string | null" }], target: "x" },
703
+ srcDeps(whatIfSrc),
704
+ );
705
+ expect(res.content[0].text).toContain("string | null");
706
+ });
707
+
708
+ it("reports unknown targets", () => {
709
+ const res = whatIf(
710
+ { file: "/test/what-if.js", bindings: [{ name: "x", type: "string" }], target: "missing" },
711
+ srcDeps(whatIfSrc),
712
+ );
713
+ expect(res.content[0].text).toContain('Type of "missing": unknown');
714
+ // x was still applied — only the target lookup missed
715
+ expect(res.content[0].text).toContain("Bindings applied: x: string");
716
+ expect(res.content[0].text).not.toContain("Bindings not applied");
717
+ });
718
+ });
719
+
720
+ describe("open-document priority and disk fallback", () => {
721
+ it("open buffer text wins over disk; disk is read when not open", () => {
722
+ const openSrc = "const x = 7;\nconst y = x * 2;\n";
723
+ const deps: AgentToolDeps = {
724
+ getOpenText: (p) => (p === "/test/open-doc.js" ? { text: openSrc } : undefined),
725
+ readFile: (p) => {
726
+ if (p !== "/test/open-doc.js") throw new Error("unexpected read of " + p);
727
+ return whatIfSrc;
728
+ },
729
+ };
730
+ const fromBuffer = whatIf({ file: "/test/open-doc.js", bindings: [], target: "y" }, deps);
731
+ expect(fromBuffer.content[0].text).toContain('Type of "y": 14');
732
+ });
733
+
734
+ it("whatIf / suggestCase / trace read unopened files from disk (os.tmpdir)", () => {
735
+ const dir = mkdtempSync(join(tmpdir(), "nudo-agent-disk-"));
736
+ try {
737
+ const plainPath = join(dir, "plain.js");
738
+ const casedPath = join(dir, "cased.js");
739
+ writeFileSync(plainPath, whatIfSrc);
740
+ writeFileSync(casedPath, '// @nudo:case "one" (1)\nfunction inc(n) { return n + 1; }\n// @nudo:skip\nfunction bare(a) { return a; }\n');
741
+
742
+ // no didOpen anywhere: default deps fall back to readFileSync
743
+ const base = whatIf({ file: plainPath, bindings: [], target: "y" });
744
+ expect(base.content[0].text).toContain('Type of "y": 2');
745
+
746
+ // file:// URI form normalizes to the same path
747
+ const viaUri = whatIf(
748
+ { file: `file://${plainPath}`, bindings: [{ name: "x", type: "string" }], target: "y" },
749
+ );
750
+ expect(viaUri.content[0].text).toContain('Type of "y": string');
751
+
752
+ // normalizeFilePath decodes and resolves both forms identically
753
+ expect(normalizeFilePath(`file://${plainPath}`)).toBe(normalizeFilePath(plainPath));
754
+
755
+ const tr = trace({ file: casedPath, functionName: "inc" });
756
+ expect(tr.content[0].text).toContain("Input: (1)");
757
+ expect(tr.content[0].text).toContain("Output: 2");
758
+
759
+ const withCases = suggestCase({ file: casedPath, functionName: "inc" });
760
+ expect(withCases.content[0].text).toContain("already has 1 case(s)");
761
+
762
+ // full-program inference synthesizes cases for plain functions;
763
+ // only skipped ones stay at zero cases
764
+ const withoutCases = suggestCase({ file: casedPath, functionName: "bare" });
765
+ expect(withoutCases.content[0].text).toContain("Suggested: /** @nudo:case */");
766
+
767
+ const missing = trace({ file: casedPath, functionName: "nope" });
768
+ expect(missing.content[0].text).toContain('Function "nope" not found');
769
+ } finally {
770
+ rmSync(dir, { recursive: true, force: true });
771
+ }
772
+ });
773
+
774
+ it("readSource prefers open documents and defaults to disk for the rest", () => {
775
+ const dir = mkdtempSync(join(tmpdir(), "nudo-agent-read-"));
776
+ try {
777
+ const p = join(dir, "on-disk.js");
778
+ writeFileSync(p, "// disk\n");
779
+ expect(readSource(p)).toBe("// disk\n");
780
+ expect(readSource("/test/only-open.js", { getOpenText: () => ({ text: "// buffer\n" }) })).toBe("// buffer\n");
781
+ expect(readSource(p, { getOpenText: () => undefined })).toBe("// disk\n");
782
+ } finally {
783
+ rmSync(dir, { recursive: true, force: true });
784
+ }
785
+ });
786
+
787
+ it("returns an Error text result for unreadable files instead of throwing", () => {
788
+ const res = whatIf({ file: join(tmpdir(), "nudo-agent-missing-file.js"), bindings: [], target: "y" });
789
+ expect(res.content[0].text).toMatch(/^Error: /);
790
+ });
791
+ });
792
+
793
+ it("suggestCase emits pasteable directives for synthesized cases; hand-written and zero-case stay as-is", () => {
794
+ // 同文件内调用产生合成 case:add 两条 call@L* 记录 → 直接可粘贴的指令文本
795
+ const synthSrc = [
796
+ "function add(a, b) { return a + b; }",
797
+ "const r1 = add(1, 2);",
798
+ "const r2 = add(\"x\", \"y\");",
799
+ "// @nudo:case \"manual\" (5)",
800
+ "function inc(n) { return n + 1; }",
801
+ "// @nudo:skip",
802
+ "function bare(a) { return a; }",
803
+ ].join("\n");
804
+ const deps = srcDeps(synthSrc);
805
+
806
+ const synthesized = suggestCase({ file: "/test/synth.js", functionName: "add" }, deps);
807
+ const text = synthesized.content[0].text;
808
+ expect(text).toContain('Function "add" has 2 synthesized case(s); suggested directives:');
809
+ expect(text).toContain("/**");
810
+ expect(text).toContain(' * @nudo:case "call@L2" (1, 2)');
811
+ expect(text).toContain(' * @nudo:case "call@L3" ("x", "y")');
812
+ expect(text).toContain("*/");
813
+
814
+ // 手写 case:source 未标记(非 callsite),报 already has N case(s) 原样
815
+ const handWritten = suggestCase({ file: "/test/synth.js", functionName: "inc" }, deps);
816
+ expect(handWritten.content[0].text).toBe('Function "inc" already has 1 case(s)');
817
+
818
+ // 零 case(skip):占位符不变
819
+ const zero = suggestCase({ file: "/test/synth.js", functionName: "bare" }, deps);
820
+ expect(zero.content[0].text).toContain("Suggested: /** @nudo:case */");
821
+ });
822
+ });