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