@nudojs/lsp 0.4.0 → 0.4.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.
- package/dist/server.js +525 -87
- package/package.json +15 -5
- 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,103 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, beforeEach } from "vitest";
|
|
2
|
-
import { mkdtempSync, writeFileSync } from "node:fs";
|
|
3
|
-
import { tmpdir } from "node:os";
|
|
4
|
-
import { join, resolve } from "node:path";
|
|
5
|
-
import {
|
|
6
|
-
clearValidationState,
|
|
7
|
-
handleNudoDepFileChanged,
|
|
8
|
-
nudoDepParents,
|
|
9
|
-
registerNudoImportDeps,
|
|
10
|
-
validateText,
|
|
11
|
-
type ValidateTextDeps,
|
|
12
|
-
} from "../validation.ts";
|
|
13
|
-
import { generalizeFromAst, getGeneralizeMemoSize, resetGeneralizeMemo } from "@nudojs/core";
|
|
14
|
-
|
|
15
|
-
describe("*.nudo.js watched-file → targeted eviction + parent revalidate", () => {
|
|
16
|
-
beforeEach(() => {
|
|
17
|
-
clearValidationState();
|
|
18
|
-
});
|
|
19
|
-
|
|
20
|
-
it("registerNudoImportDeps records parent → dep edges", () => {
|
|
21
|
-
const dir = mkdtempSync(join(tmpdir(), "nudo-dep-idx-"));
|
|
22
|
-
const parent = join(dir, "a.js");
|
|
23
|
-
const dep = join(dir, "shapes.nudo.js");
|
|
24
|
-
registerNudoImportDeps(
|
|
25
|
-
parent,
|
|
26
|
-
`/// @nudo:import { positive } from "./shapes.nudo.js"\nfunction f(x){return x;}\n`,
|
|
27
|
-
);
|
|
28
|
-
expect(nudoDepParents.get(resolve(dep).replace(/\\/g, "/"))).toContain(
|
|
29
|
-
resolve(parent).replace(/\\/g, "/"),
|
|
30
|
-
);
|
|
31
|
-
});
|
|
32
|
-
|
|
33
|
-
it("handleNudoDepFileChanged revalidates open parent and evicts memo deps", async () => {
|
|
34
|
-
const dir = mkdtempSync(join(tmpdir(), "nudo-dep-chg-"));
|
|
35
|
-
const parent = join(dir, "a.js");
|
|
36
|
-
const dep = join(dir, "shapes.nudo.js");
|
|
37
|
-
writeFileSync(dep, "export const positive = number().gt(0);\n");
|
|
38
|
-
const parentSrc = `/// @nudo:import { positive } from "./shapes.nudo.js"
|
|
39
|
-
/**
|
|
40
|
-
* @nudo:refine x positive
|
|
41
|
-
*/
|
|
42
|
-
function needsPositive(x) {
|
|
43
|
-
return x;
|
|
44
|
-
}
|
|
45
|
-
needsPositive(-1);
|
|
46
|
-
`;
|
|
47
|
-
writeFileSync(parent, parentSrc);
|
|
48
|
-
// 模拟已验证会话:登记 @nudo:import 边
|
|
49
|
-
registerNudoImportDeps(parent, parentSrc);
|
|
50
|
-
|
|
51
|
-
// 建立 generalize 缓存(含 dep 路径索引)
|
|
52
|
-
const loadModule = (spec: string, fromFile: string) => {
|
|
53
|
-
if (spec.includes("shapes")) return "export const positive = number().gt(0);\n";
|
|
54
|
-
return undefined;
|
|
55
|
-
};
|
|
56
|
-
const g1 = generalizeFromAst("needsPositive", parentSrc, {
|
|
57
|
-
refine: { loadModule, fromFile: parent },
|
|
58
|
-
});
|
|
59
|
-
expect(g1).toBeDefined();
|
|
60
|
-
expect(getGeneralizeMemoSize()).toBeGreaterThan(0);
|
|
61
|
-
|
|
62
|
-
let revalidated = 0;
|
|
63
|
-
const sent = new Map<string, unknown[]>();
|
|
64
|
-
const openDocs = new Map([
|
|
65
|
-
[
|
|
66
|
-
resolve(parent).replace(/\\/g, "/"),
|
|
67
|
-
{
|
|
68
|
-
uri: `file://${parent}`,
|
|
69
|
-
version: 2,
|
|
70
|
-
getText: () => parentSrc,
|
|
71
|
-
},
|
|
72
|
-
],
|
|
73
|
-
]);
|
|
74
|
-
const deps: ValidateTextDeps = {
|
|
75
|
-
sendDiagnostics: (p) => {
|
|
76
|
-
sent.set(p.uri, p.diagnostics);
|
|
77
|
-
revalidated++;
|
|
78
|
-
},
|
|
79
|
-
getOpenDocumentByPath: (p) => openDocs.get(p.replace(/\\/g, "/")),
|
|
80
|
-
};
|
|
81
|
-
|
|
82
|
-
await handleNudoDepFileChanged(dep, deps);
|
|
83
|
-
|
|
84
|
-
// 定向逐出后,同参 generalize 得新实例
|
|
85
|
-
const g2 = generalizeFromAst("needsPositive", parentSrc, {
|
|
86
|
-
refine: { loadModule, fromFile: parent },
|
|
87
|
-
});
|
|
88
|
-
expect(g2).toBeDefined();
|
|
89
|
-
expect(g2).not.toBe(g1);
|
|
90
|
-
// 打开中的父文件被重检
|
|
91
|
-
expect(revalidated).toBeGreaterThan(0);
|
|
92
|
-
expect(sent.has(`file://${parent}`)).toBe(true);
|
|
93
|
-
});
|
|
94
|
-
|
|
95
|
-
it("does nothing when no parent depends on the path", async () => {
|
|
96
|
-
resetGeneralizeMemo();
|
|
97
|
-
const before = getGeneralizeMemoSize();
|
|
98
|
-
await handleNudoDepFileChanged("/nope/x.nudo.js", {
|
|
99
|
-
sendDiagnostics: () => {},
|
|
100
|
-
});
|
|
101
|
-
expect(getGeneralizeMemoSize()).toBe(before);
|
|
102
|
-
});
|
|
103
|
-
});
|
package/src/agent-tools.ts
DELETED
|
@@ -1,486 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Agent-facing tool implementations (`nudo.whatIf` / `nudo.suggestCase` /
|
|
3
|
-
* `nudo.trace`), ported from the MCP server (packages/mcp/src/tools.ts) and
|
|
4
|
-
* wired into both LSP channels in server.ts: executeCommand commands
|
|
5
|
-
* (`nudo.*`) and custom requests (`nudo/…`). Like validation.ts, this module
|
|
6
|
-
* holds pure logic with injected readers so tests exercise it without a live
|
|
7
|
-
* connection; server.ts supplies the document/disk readers.
|
|
8
|
-
*
|
|
9
|
-
* Unlike the MCP original, whatIf really applies its bindings: each binding
|
|
10
|
-
* becomes a `// @nudo:as <type>` comment inserted above the declaring
|
|
11
|
-
* statement (source-level injection). The evaluator already honors `as` for
|
|
12
|
-
* variable declarations, expression statements and returns, so the assumed
|
|
13
|
-
* type flows through the whole program like any other directive.
|
|
14
|
-
*/
|
|
15
|
-
import { readFileSync } from "node:fs";
|
|
16
|
-
import { resolve } from "node:path";
|
|
17
|
-
import {
|
|
18
|
-
analyzeFile,
|
|
19
|
-
buildCaseDirective,
|
|
20
|
-
getHoverAtPosition,
|
|
21
|
-
collectAbsInlays,
|
|
22
|
-
serializeInferJson,
|
|
23
|
-
} from "@nudojs/service";
|
|
24
|
-
import { parse } from "@nudojs/parser";
|
|
25
|
-
import { T, typeValueToString, checkSource, serializeCheckJson, pTrue } from "@nudojs/core";
|
|
26
|
-
import type { TypeValue, CheckJson } from "@nudojs/core";
|
|
27
|
-
import { lspLoadModule } from "./validation.ts";
|
|
28
|
-
|
|
29
|
-
export type TypeBinding = { name: string; type: string };
|
|
30
|
-
|
|
31
|
-
/** MCP-compatible tool result shape — keeps bridge layers zero-rewrite. */
|
|
32
|
-
export type AgentToolResult = { content: [{ type: "text"; text: string }] };
|
|
33
|
-
|
|
34
|
-
export type AgentToolDeps = {
|
|
35
|
-
/** Disk reader for files not open in the editor; defaults to readFileSync. */
|
|
36
|
-
readFile?: (filePath: string) => string;
|
|
37
|
-
/** Open-document lookup by absolute file path; the editor buffer wins over disk. */
|
|
38
|
-
getOpenText?: (filePath: string) => { text: string } | undefined;
|
|
39
|
-
};
|
|
40
|
-
|
|
41
|
-
export function textResult(text: string): AgentToolResult {
|
|
42
|
-
return { content: [{ type: "text", text }] };
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
/**
|
|
46
|
-
* Parse an agent-facing type expression into a TypeValue.
|
|
47
|
-
* Ported from packages/mcp/src/tools.ts: primitive names, `|` unions,
|
|
48
|
-
* everything else unknown.
|
|
49
|
-
*/
|
|
50
|
-
export function parseTypeExpr(expr: string): TypeValue {
|
|
51
|
-
const trimmed = expr.trim();
|
|
52
|
-
if (trimmed === "number") return T.number;
|
|
53
|
-
if (trimmed === "string") return T.string;
|
|
54
|
-
if (trimmed === "boolean") return T.boolean;
|
|
55
|
-
if (trimmed === "null") return T.null;
|
|
56
|
-
if (trimmed === "undefined") return T.undefined;
|
|
57
|
-
if (trimmed === "bigint") return T.bigint;
|
|
58
|
-
if (trimmed === "symbol") return T.symbol;
|
|
59
|
-
if (trimmed.includes("|")) {
|
|
60
|
-
const members = trimmed.split("|").map(parseTypeExpr);
|
|
61
|
-
return T.union(...members);
|
|
62
|
-
}
|
|
63
|
-
return T.unknown;
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
/**
|
|
67
|
-
* Normalize a `file` parameter: strips and percent-decodes a `file://` prefix
|
|
68
|
-
* (mirrors validation.ts uriToFilePath), then resolves to an absolute path.
|
|
69
|
-
*/
|
|
70
|
-
export function normalizeFilePath(file: string): string {
|
|
71
|
-
const path = file.startsWith("file://") ? decodeURIComponent(file.slice(7)) : file;
|
|
72
|
-
return resolve(path);
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
const BARE_PRIMITIVES = new Set(["number", "string", "boolean", "bigint", "symbol"]);
|
|
76
|
-
|
|
77
|
-
/** Split a type expression on top-level `|`, respecting nesting and strings. */
|
|
78
|
-
function splitTopLevelUnion(expr: string): string[] {
|
|
79
|
-
const members: string[] = [];
|
|
80
|
-
let depth = 0;
|
|
81
|
-
let inString: string | null = null;
|
|
82
|
-
let start = 0;
|
|
83
|
-
for (let i = 0; i < expr.length; i++) {
|
|
84
|
-
const ch = expr[i];
|
|
85
|
-
if (inString) {
|
|
86
|
-
if (ch === inString && expr[i - 1] !== "\\") inString = null;
|
|
87
|
-
continue;
|
|
88
|
-
}
|
|
89
|
-
if (ch === '"' || ch === "'") {
|
|
90
|
-
inString = ch;
|
|
91
|
-
continue;
|
|
92
|
-
}
|
|
93
|
-
if (ch === "(" || ch === "[" || ch === "{") {
|
|
94
|
-
depth++;
|
|
95
|
-
continue;
|
|
96
|
-
}
|
|
97
|
-
if (ch === ")" || ch === "]" || ch === "}") {
|
|
98
|
-
depth--;
|
|
99
|
-
continue;
|
|
100
|
-
}
|
|
101
|
-
if (ch === "|" && depth === 0) {
|
|
102
|
-
members.push(expr.slice(start, i));
|
|
103
|
-
start = i + 1;
|
|
104
|
-
}
|
|
105
|
-
}
|
|
106
|
-
members.push(expr.slice(start));
|
|
107
|
-
return members.map((m) => m.trim()).filter(Boolean);
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
/**
|
|
111
|
-
* Translate an agent-facing type expression into `@nudo:as` directive syntax
|
|
112
|
-
* (parser's parseTypeValueExpr language). Bare primitives gain a `T.` prefix;
|
|
113
|
-
* unions become `T.union(...)`; structural forms pass through untouched.
|
|
114
|
-
*/
|
|
115
|
-
export function typeExprToDirective(expr: string): string {
|
|
116
|
-
const members = splitTopLevelUnion(expr);
|
|
117
|
-
if (members.length === 0) return "T.unknown";
|
|
118
|
-
const mapped = members.map((m) => {
|
|
119
|
-
if (m.startsWith("T.")) return m;
|
|
120
|
-
if (BARE_PRIMITIVES.has(m)) return `T.${m}`;
|
|
121
|
-
if (m === "null" || m === "undefined" || m === "true" || m === "false") return m;
|
|
122
|
-
if (/^-?\d+(\.\d+)?$/.test(m)) return m;
|
|
123
|
-
if (/^["']/.test(m)) return m;
|
|
124
|
-
if (/[([{]|=>/.test(m)) return m;
|
|
125
|
-
return "T.unknown";
|
|
126
|
-
});
|
|
127
|
-
return mapped.length === 1 ? mapped[0] : `T.union(${mapped.join(", ")})`;
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
/** Collect the names a top-level statement declares (descends into exports). */
|
|
131
|
-
function declaredNames(stmt: any, out: Set<string>): void {
|
|
132
|
-
if (stmt.type === "FunctionDeclaration" && stmt.id) {
|
|
133
|
-
out.add(stmt.id.name);
|
|
134
|
-
} else if (stmt.type === "ClassDeclaration" && stmt.id) {
|
|
135
|
-
out.add(stmt.id.name);
|
|
136
|
-
} else if (stmt.type === "VariableDeclaration") {
|
|
137
|
-
for (const decl of stmt.declarations) {
|
|
138
|
-
if (decl.id?.type === "Identifier") out.add(decl.id.name);
|
|
139
|
-
}
|
|
140
|
-
} else if (stmt.type === "ExportNamedDeclaration" && stmt.declaration) {
|
|
141
|
-
declaredNames(stmt.declaration, out);
|
|
142
|
-
}
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
/**
|
|
146
|
-
* Inject each binding as a `// @nudo:as <type>` line above the statement that
|
|
147
|
-
* declares its name. The comment is placed above any existing leading
|
|
148
|
-
* comments, so the assumption takes priority over source-level directives
|
|
149
|
-
* (only the first `as` on a statement wins — hence one binding per statement;
|
|
150
|
-
* siblings of multi-declarator statements share the override, a known
|
|
151
|
-
* limitation of statement-granular `as`).
|
|
152
|
-
*/
|
|
153
|
-
export function injectBindings(
|
|
154
|
-
source: string,
|
|
155
|
-
bindings: TypeBinding[],
|
|
156
|
-
): { source: string; applied: string[]; unapplied: string[] } {
|
|
157
|
-
const applied: string[] = [];
|
|
158
|
-
const unapplied: string[] = [];
|
|
159
|
-
if (bindings.length === 0) return { source, applied, unapplied };
|
|
160
|
-
|
|
161
|
-
const ast = parse(source);
|
|
162
|
-
const declLines = new Map<string, number>();
|
|
163
|
-
for (const stmt of ast.program.body as any[]) {
|
|
164
|
-
const names = new Set<string>();
|
|
165
|
-
declaredNames(stmt, names);
|
|
166
|
-
if (names.size === 0 || !stmt.loc) continue;
|
|
167
|
-
// anchor above existing leading comments so the injected `as` wins
|
|
168
|
-
const anchorLine = stmt.leadingComments?.[0]?.loc?.start.line ?? stmt.loc.start.line;
|
|
169
|
-
for (const name of names) {
|
|
170
|
-
if (!declLines.has(name)) declLines.set(name, anchorLine);
|
|
171
|
-
}
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
const byLine = new Map<number, TypeBinding[]>();
|
|
175
|
-
for (const binding of bindings) {
|
|
176
|
-
const line = declLines.get(binding.name);
|
|
177
|
-
if (line === undefined) {
|
|
178
|
-
unapplied.push(binding.name);
|
|
179
|
-
continue;
|
|
180
|
-
}
|
|
181
|
-
const group = byLine.get(line) ?? [];
|
|
182
|
-
group.push(binding);
|
|
183
|
-
byLine.set(line, group);
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
const insertions: Array<{ index: number; text: string }> = [];
|
|
187
|
-
for (const [line, group] of byLine) {
|
|
188
|
-
insertions.push({
|
|
189
|
-
index: line - 1,
|
|
190
|
-
text: `// @nudo:as ${typeExprToDirective(group[0].type)}`,
|
|
191
|
-
});
|
|
192
|
-
applied.push(`${group[0].name}: ${group[0].type}`);
|
|
193
|
-
for (const shadowed of group.slice(1)) unapplied.push(shadowed.name);
|
|
194
|
-
}
|
|
195
|
-
// bottom-up so earlier indices stay valid
|
|
196
|
-
insertions.sort((a, b) => b.index - a.index);
|
|
197
|
-
const lines = source.split("\n");
|
|
198
|
-
for (const ins of insertions) lines.splice(ins.index, 0, ins.text);
|
|
199
|
-
|
|
200
|
-
return { source: lines.join("\n"), applied, unapplied };
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
/**
|
|
204
|
-
* Source resolution for agent tools: open editor buffer first, disk fallback
|
|
205
|
-
* (readFileSync) for files the agent changed without didOpen.
|
|
206
|
-
*/
|
|
207
|
-
export function readSource(filePath: string, deps: AgentToolDeps = {}): string {
|
|
208
|
-
const open = deps.getOpenText?.(filePath);
|
|
209
|
-
if (open) return open.text;
|
|
210
|
-
return (deps.readFile ?? ((p: string) => readFileSync(p, "utf-8")))(filePath);
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
function analysisError(err: unknown): AgentToolResult {
|
|
214
|
-
return textResult(`Error: ${(err as Error).message}`);
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
export type CheckToolParams = {
|
|
218
|
-
file: string;
|
|
219
|
-
source?: string;
|
|
220
|
-
/** "json" → CheckJson only;缺省人类可读摘要 + JSON */
|
|
221
|
-
format?: "text" | "json";
|
|
222
|
-
/** 解析 @nudo:import 的相对 .nudo.js(测试可注入) */
|
|
223
|
-
loadModule?: (spec: string, fromFile: string) => string | undefined;
|
|
224
|
-
};
|
|
225
|
-
|
|
226
|
-
/**
|
|
227
|
-
* Agent 门禁工具:返回 CheckJson v1 契约(与 CLI --json 同构)。
|
|
228
|
-
*/
|
|
229
|
-
export function checkTool(
|
|
230
|
-
params: CheckToolParams,
|
|
231
|
-
deps: AgentToolDeps = {},
|
|
232
|
-
): AgentToolResult {
|
|
233
|
-
try {
|
|
234
|
-
const filePath = normalizeFilePath(params.file);
|
|
235
|
-
const source = params.source ?? readSource(filePath, deps);
|
|
236
|
-
const report = checkSource(filePath, source, pTrue, {
|
|
237
|
-
loadModule: params.loadModule ?? lspLoadModule,
|
|
238
|
-
fromFile: filePath,
|
|
239
|
-
});
|
|
240
|
-
const json = serializeCheckJson(report);
|
|
241
|
-
if (params.format === "json") {
|
|
242
|
-
return textResult(JSON.stringify(json, null, 2));
|
|
243
|
-
}
|
|
244
|
-
const lines: string[] = [
|
|
245
|
-
json.ok ? "nudo check OK" : "nudo check FAILED",
|
|
246
|
-
`${json.summary.errors} error · ${json.summary.warnings} warning · ${json.summary.functions} fn`,
|
|
247
|
-
];
|
|
248
|
-
for (const i of json.issues) {
|
|
249
|
-
if (i.severity === "info") continue;
|
|
250
|
-
const loc = i.line != null ? ` L${i.line}` : "";
|
|
251
|
-
lines.push(`[${i.severity}]${loc} ${i.message}`);
|
|
252
|
-
if (i.actual) lines.push(` actual: ${i.actual}`);
|
|
253
|
-
if (i.expected) lines.push(` expected: ${i.expected}`);
|
|
254
|
-
}
|
|
255
|
-
lines.push("");
|
|
256
|
-
lines.push(JSON.stringify(json, null, 2));
|
|
257
|
-
return textResult(lines.join("\n"));
|
|
258
|
-
} catch (err) {
|
|
259
|
-
return analysisError(err);
|
|
260
|
-
}
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
export type HoverToolParams = {
|
|
264
|
-
file: string;
|
|
265
|
-
/** 1-based 行 */
|
|
266
|
-
line: number;
|
|
267
|
-
/** 0-based 列 */
|
|
268
|
-
column: number;
|
|
269
|
-
source?: string;
|
|
270
|
-
/** true 时同时返回该文件全部 Abs inlay */
|
|
271
|
-
includeInlays?: boolean;
|
|
272
|
-
/** 可选:*.nudo.js 加载(契约进 inlay) */
|
|
273
|
-
loadModule?: (spec: string, fromFile: string) => string | undefined;
|
|
274
|
-
};
|
|
275
|
-
|
|
276
|
-
/**
|
|
277
|
-
* Agent hover:无损 Abs(不经 TypeValue bridge)。
|
|
278
|
-
* 与 LSP hover 同一信息源。
|
|
279
|
-
*/
|
|
280
|
-
export function hoverTool(
|
|
281
|
-
params: HoverToolParams,
|
|
282
|
-
deps: AgentToolDeps = {},
|
|
283
|
-
): AgentToolResult {
|
|
284
|
-
try {
|
|
285
|
-
const filePath = normalizeFilePath(params.file);
|
|
286
|
-
const source = params.source ?? readSource(filePath, deps);
|
|
287
|
-
const hover = getHoverAtPosition(filePath, source, params.line, params.column);
|
|
288
|
-
const payload: Record<string, unknown> = {
|
|
289
|
-
file: filePath,
|
|
290
|
-
line: params.line,
|
|
291
|
-
column: params.column,
|
|
292
|
-
abs: hover?.abs ?? null,
|
|
293
|
-
absMultiline: hover?.absMultiline ?? null,
|
|
294
|
-
intension: hover?.intension ?? null,
|
|
295
|
-
/** 有损外延,仅对照 */
|
|
296
|
-
ext: hover?.typeText ?? null,
|
|
297
|
-
};
|
|
298
|
-
if (params.includeInlays) {
|
|
299
|
-
payload.inlays = collectAbsInlays(source, {
|
|
300
|
-
loadModule: params.loadModule ?? lspLoadModule,
|
|
301
|
-
fromFile: filePath,
|
|
302
|
-
});
|
|
303
|
-
}
|
|
304
|
-
return textResult(JSON.stringify(payload, null, 2));
|
|
305
|
-
} catch (err) {
|
|
306
|
-
return analysisError(err);
|
|
307
|
-
}
|
|
308
|
-
}
|
|
309
|
-
|
|
310
|
-
export type InferToolParams = {
|
|
311
|
-
file: string;
|
|
312
|
-
source?: string;
|
|
313
|
-
/** "json" → InferJson only;缺省摘要 + JSON */
|
|
314
|
-
format?: "text" | "json";
|
|
315
|
-
/** 只返回这些函数名(可选过滤) */
|
|
316
|
-
functions?: string[];
|
|
317
|
-
};
|
|
318
|
-
|
|
319
|
-
/**
|
|
320
|
-
* Agent infer:InferJson v1 契约(与 CLI infer --json 同构)。
|
|
321
|
-
* intension 携带无损 Abs;args/result 为 TypeValue 投影。
|
|
322
|
-
*/
|
|
323
|
-
export function inferTool(
|
|
324
|
-
params: InferToolParams,
|
|
325
|
-
deps: AgentToolDeps = {},
|
|
326
|
-
): AgentToolResult {
|
|
327
|
-
try {
|
|
328
|
-
const filePath = normalizeFilePath(params.file);
|
|
329
|
-
const source = params.source ?? readSource(filePath, deps);
|
|
330
|
-
const result = analyzeFile(filePath, source);
|
|
331
|
-
let json = serializeInferJson(result, filePath);
|
|
332
|
-
if (params.functions && params.functions.length > 0) {
|
|
333
|
-
const keep = new Set(params.functions);
|
|
334
|
-
const filtered = json.functions.filter((f) => keep.has(f.name));
|
|
335
|
-
let cases = 0;
|
|
336
|
-
for (const f of filtered) {
|
|
337
|
-
cases += (f as { cases?: unknown[] }).cases?.length ?? 0;
|
|
338
|
-
}
|
|
339
|
-
const summary = {
|
|
340
|
-
...json.summary,
|
|
341
|
-
functions: filtered.length,
|
|
342
|
-
cases,
|
|
343
|
-
};
|
|
344
|
-
json = { ...json, functions: filtered, summary } as typeof json;
|
|
345
|
-
}
|
|
346
|
-
if (params.format === "json") {
|
|
347
|
-
return textResult(JSON.stringify(json, null, 2));
|
|
348
|
-
}
|
|
349
|
-
const lines: string[] = [
|
|
350
|
-
`nudo infer ${json.file}`,
|
|
351
|
-
`${json.summary.functions} fn · ${json.summary.cases} case · ${json.summary.diagnostics} diag`,
|
|
352
|
-
];
|
|
353
|
-
for (const f of json.functions) {
|
|
354
|
-
lines.push("");
|
|
355
|
-
lines.push(`${f.name}${f.entryOnly ? " [entry-only]" : ""}`);
|
|
356
|
-
for (const c of f.cases) {
|
|
357
|
-
const args = c.args.join(", ");
|
|
358
|
-
lines.push(` ${c.name}: (${args}) => ${c.result}`);
|
|
359
|
-
if (c.intension?.abs) lines.push(` abs: ${c.intension.abs}`);
|
|
360
|
-
else if (c.intension?.display) lines.push(` intension: ${c.intension.display}`);
|
|
361
|
-
}
|
|
362
|
-
}
|
|
363
|
-
lines.push("");
|
|
364
|
-
lines.push(JSON.stringify(json, null, 2));
|
|
365
|
-
return textResult(lines.join("\n"));
|
|
366
|
-
} catch (err) {
|
|
367
|
-
return analysisError(err);
|
|
368
|
-
}
|
|
369
|
-
}
|
|
370
|
-
|
|
371
|
-
export type WhatIfParams = {
|
|
372
|
-
file: string;
|
|
373
|
-
bindings?: TypeBinding[];
|
|
374
|
-
target: string;
|
|
375
|
-
/** Pre-read source (tests / callers that already hold the text). */
|
|
376
|
-
source?: string;
|
|
377
|
-
};
|
|
378
|
-
|
|
379
|
-
/**
|
|
380
|
-
* Set type assumptions and observe the inferred type of `target`. Bindings
|
|
381
|
-
* are genuinely injected via `@nudo:as` before analysis — the inference gap
|
|
382
|
-
* of the original MCP tool.
|
|
383
|
-
*/
|
|
384
|
-
export function whatIf(params: WhatIfParams, deps: AgentToolDeps = {}): AgentToolResult {
|
|
385
|
-
try {
|
|
386
|
-
const filePath = normalizeFilePath(params.file);
|
|
387
|
-
const original = params.source ?? readSource(filePath, deps);
|
|
388
|
-
const { source, applied, unapplied } = injectBindings(original, params.bindings ?? []);
|
|
389
|
-
// Direct analysis: the injected source never matches the version-keyed
|
|
390
|
-
// editor cache, so bypass it entirely.
|
|
391
|
-
const result = analyzeFile(filePath, source);
|
|
392
|
-
const typeStr = result.bindings.has(params.target)
|
|
393
|
-
? typeValueToString(result.bindings.get(params.target)!.type)
|
|
394
|
-
: "unknown";
|
|
395
|
-
|
|
396
|
-
const notes: string[] = [];
|
|
397
|
-
if (applied.length > 0) notes.push(`Bindings applied: ${applied.join(", ")}`);
|
|
398
|
-
if (unapplied.length > 0) {
|
|
399
|
-
notes.push(`Bindings not applied (no top-level declaration found): ${unapplied.join(", ")}`);
|
|
400
|
-
}
|
|
401
|
-
return textResult(
|
|
402
|
-
`Type of "${params.target}": ${typeStr}${notes.length > 0 ? `\n${notes.join("\n")}` : ""}`,
|
|
403
|
-
);
|
|
404
|
-
} catch (err) {
|
|
405
|
-
return analysisError(err);
|
|
406
|
-
}
|
|
407
|
-
}
|
|
408
|
-
|
|
409
|
-
export type FunctionToolParams = {
|
|
410
|
-
file: string;
|
|
411
|
-
functionName: string;
|
|
412
|
-
/** Pre-read source (tests / callers that already hold the text). */
|
|
413
|
-
source?: string;
|
|
414
|
-
};
|
|
415
|
-
|
|
416
|
-
/** Suggest @nudo:case directives for a function (ported from MCP). */
|
|
417
|
-
export function suggestCase(params: FunctionToolParams, deps: AgentToolDeps = {}): AgentToolResult {
|
|
418
|
-
try {
|
|
419
|
-
const filePath = normalizeFilePath(params.file);
|
|
420
|
-
const source = params.source ?? readSource(filePath, deps);
|
|
421
|
-
const result = analyzeFile(filePath, source);
|
|
422
|
-
const fn = result.functions.find((f) => f.name === params.functionName);
|
|
423
|
-
|
|
424
|
-
if (!fn) {
|
|
425
|
-
return textResult(`Function "${params.functionName}" not found`);
|
|
426
|
-
}
|
|
427
|
-
|
|
428
|
-
if (fn.cases.length > 0) {
|
|
429
|
-
// 手写指令 case 的 source 未标记,合成 case 才带 "callsite";
|
|
430
|
-
// 全部为合成 case 时可产出直接粘贴回源码的指令文本
|
|
431
|
-
if (fn.cases.every((c) => c.source === "callsite")) {
|
|
432
|
-
const directives = fn.cases
|
|
433
|
-
.map((c) => buildCaseDirective(c.name, c.args))
|
|
434
|
-
.filter((d): d is string => d !== null);
|
|
435
|
-
if (directives.length > 0) {
|
|
436
|
-
const skipped = fn.cases.length - directives.length;
|
|
437
|
-
const lines = [
|
|
438
|
-
`Function "${params.functionName}" has ${fn.cases.length} synthesized case(s); suggested directives:`,
|
|
439
|
-
"/**",
|
|
440
|
-
...directives,
|
|
441
|
-
"*/",
|
|
442
|
-
];
|
|
443
|
-
if (skipped > 0) {
|
|
444
|
-
lines.push(`(${skipped} case(s) skipped: not serializable as directives)`);
|
|
445
|
-
}
|
|
446
|
-
return textResult(lines.join("\n"));
|
|
447
|
-
}
|
|
448
|
-
return textResult(
|
|
449
|
-
`Function "${params.functionName}" already has ${fn.cases.length} case(s) (none serializable as directives)`,
|
|
450
|
-
);
|
|
451
|
-
}
|
|
452
|
-
return textResult(`Function "${params.functionName}" already has ${fn.cases.length} case(s)`);
|
|
453
|
-
}
|
|
454
|
-
|
|
455
|
-
return textResult(`Suggested: /** @nudo:case */\nfunction ${params.functionName}(...) { ... }`);
|
|
456
|
-
} catch (err) {
|
|
457
|
-
return analysisError(err);
|
|
458
|
-
}
|
|
459
|
-
}
|
|
460
|
-
|
|
461
|
-
/** Trace how a type transforms from input to output across a function's cases (ported from MCP). */
|
|
462
|
-
export function trace(params: FunctionToolParams, deps: AgentToolDeps = {}): AgentToolResult {
|
|
463
|
-
try {
|
|
464
|
-
const filePath = normalizeFilePath(params.file);
|
|
465
|
-
const source = params.source ?? readSource(filePath, deps);
|
|
466
|
-
const result = analyzeFile(filePath, source);
|
|
467
|
-
const fn = result.functions.find((f) => f.name === params.functionName);
|
|
468
|
-
|
|
469
|
-
if (!fn) {
|
|
470
|
-
return textResult(`Function "${params.functionName}" not found`);
|
|
471
|
-
}
|
|
472
|
-
|
|
473
|
-
if (fn.cases.length === 0) {
|
|
474
|
-
return textResult(`No cases found for "${params.functionName}"`);
|
|
475
|
-
}
|
|
476
|
-
|
|
477
|
-
const traces = fn.cases.map((c) => {
|
|
478
|
-
const args = c.args.map(typeValueToString).join(", ");
|
|
479
|
-
return `Input: (${args}) => Output: ${typeValueToString(c.result)}`;
|
|
480
|
-
}).join("\n");
|
|
481
|
-
|
|
482
|
-
return textResult(traces);
|
|
483
|
-
} catch (err) {
|
|
484
|
-
return analysisError(err);
|
|
485
|
-
}
|
|
486
|
-
}
|
package/src/semantic-tokens.ts
DELETED
|
@@ -1,9 +0,0 @@
|
|
|
1
|
-
// Semantic tokens 的图例与编码单一来源在 @nudojs/service(提取器
|
|
2
|
-
// buildSemanticTokens 与图例同处一模块,tokenType 索引不可能与图例漂移)。
|
|
3
|
-
// 此处仅按 server.ts 原引用名(TOKEN_TYPES/TOKEN_MODIFIERS)再导出。
|
|
4
|
-
export {
|
|
5
|
-
SEMANTIC_TOKEN_TYPES as TOKEN_TYPES,
|
|
6
|
-
SEMANTIC_TOKEN_MODIFIERS as TOKEN_MODIFIERS,
|
|
7
|
-
encodeSemanticTokens,
|
|
8
|
-
type SemanticToken,
|
|
9
|
-
} from "@nudojs/service";
|