@nudojs/lsp 0.2.0 → 0.4.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/src/server.ts CHANGED
@@ -5,26 +5,65 @@ import {
5
5
  TextDocumentSyncKind,
6
6
  type InitializeParams,
7
7
  type InitializeResult,
8
- type Diagnostic as LspDiagnostic,
9
- DiagnosticSeverity,
10
- DiagnosticTag,
11
8
  type CompletionItem as LspCompletionItem,
12
9
  CompletionItemKind,
13
10
  MarkupKind,
14
11
  type CodeLens,
15
12
  CodeLensRefreshRequest,
13
+ FileChangeType,
14
+ type FileEvent,
16
15
  type InlayHint,
17
16
  InlayHintKind,
18
17
  } from "vscode-languageserver/node";
19
18
  import { TextDocument } from "vscode-languageserver-textdocument";
20
19
  import { typeValueToString } from "@nudojs/core";
21
20
  import {
22
- analyzeFile,
23
21
  getTypeAtPosition,
22
+ getHoverAtPosition,
24
23
  getCompletionsAtPosition,
25
24
  getCasesForFile,
26
- type DiagnosticSeverity as JsDiagSeverity,
25
+ buildSemanticTokens,
26
+ isNudoTargetPath,
27
+ collectAbsInlays,
27
28
  } from "@nudojs/service";
29
+ import { parse } from "@nudojs/parser";
30
+ import { buildSymbolTable, findDefinition, findReferences, findIdentifierAtPosition } from "./symbols.ts";
31
+ import { TOKEN_TYPES, TOKEN_MODIFIERS } from "./semantic-tokens.ts";
32
+ import {
33
+ analysisCache,
34
+ evictModuleGraphCacheEntries,
35
+ forgetValidatedFile,
36
+ getCachedOrAnalyze,
37
+ handleNudoDepFileChanged,
38
+ hasNudoDirectives,
39
+ lspLoadModule,
40
+ toLspDiagnostic,
41
+ uriToFilePath,
42
+ validateText,
43
+ type ValidateTextDeps,
44
+ } from "./validation.ts";
45
+ import {
46
+ normalizeFilePath,
47
+ suggestCase,
48
+ trace,
49
+ whatIf,
50
+ checkTool,
51
+ hoverTool,
52
+ inferTool,
53
+ type AgentToolDeps,
54
+ type AgentToolResult,
55
+ } from "./agent-tools.ts";
56
+
57
+ const NUDO_COMMANDS = [
58
+ "nudo.whatIf",
59
+ "nudo.suggestCase",
60
+ "nudo.trace",
61
+ "nudo.check",
62
+ "nudo.hover",
63
+ "nudo.infer",
64
+ "nudo.selectCase",
65
+ "nudo.getActiveCases",
66
+ ] as const;
28
67
 
29
68
  const connection = createConnection(ProposedFeatures.all);
30
69
  const documents = new TextDocuments(TextDocument);
@@ -39,12 +78,6 @@ function getActiveCasesForUri(uri: string): Map<string, number> {
39
78
  return map;
40
79
  }
41
80
 
42
- const severityMap: Record<JsDiagSeverity, DiagnosticSeverity> = {
43
- error: DiagnosticSeverity.Error,
44
- warning: DiagnosticSeverity.Warning,
45
- info: DiagnosticSeverity.Information,
46
- };
47
-
48
81
  connection.onInitialize((_params: InitializeParams): InitializeResult => ({
49
82
  capabilities: {
50
83
  textDocumentSync: TextDocumentSyncKind.Full,
@@ -57,11 +90,42 @@ connection.onInitialize((_params: InitializeParams): InitializeResult => ({
57
90
  resolveProvider: false,
58
91
  },
59
92
  inlayHintProvider: true,
93
+ definitionProvider: true,
94
+ referencesProvider: true,
95
+ renameProvider: true,
96
+ codeActionProvider: {
97
+ codeActionKinds: ["quickfix"],
98
+ },
99
+ signatureHelpProvider: {
100
+ triggerCharacters: ["(", ","],
101
+ },
102
+ semanticTokensProvider: {
103
+ full: true,
104
+ legend: {
105
+ tokenTypes: [...TOKEN_TYPES],
106
+ tokenModifiers: [...TOKEN_MODIFIERS],
107
+ },
108
+ },
109
+ executeCommandProvider: {
110
+ commands: [...NUDO_COMMANDS],
111
+ },
112
+ diagnosticProvider: {
113
+ interFileDependencies: false,
114
+ workspaceDiagnostics: false,
115
+ },
60
116
  },
61
117
  }));
62
118
 
63
119
  let debounceTimers = new Map<string, ReturnType<typeof setTimeout>>();
64
120
 
121
+ // 打开即验证:didOpen 不会触发 onDidChangeContent,若不在此主动验证,
122
+ // 新打开的文件要等到首次编辑(300ms 防抖后)或客户端 pull 诊断才有结果。
123
+ // propagate=true 与编辑防抖路径同语义(含对打开依赖项的一次脏传播)。
124
+ documents.onDidOpen((event) => {
125
+ nudoFileCache.delete(event.document.uri);
126
+ validateDocument(event.document, true).catch(() => {});
127
+ });
128
+
65
129
  documents.onDidChangeContent((change) => {
66
130
  const uri = change.document.uri;
67
131
  nudoFileCache.delete(uri);
@@ -72,7 +136,7 @@ documents.onDidChangeContent((change) => {
72
136
  uri,
73
137
  setTimeout(() => {
74
138
  debounceTimers.delete(uri);
75
- validateDocument(change.document);
139
+ validateDocument(change.document, true).catch(() => {});
76
140
  }, 300),
77
141
  );
78
142
  });
@@ -82,51 +146,101 @@ documents.onDidClose((event) => {
82
146
  if (timer) clearTimeout(timer);
83
147
  debounceTimers.delete(event.document.uri);
84
148
  nudoFileCache.delete(event.document.uri);
149
+ analysisCache.delete(uriToFilePath(event.document.uri));
85
150
  connection.sendDiagnostics({ uri: event.document.uri, diagnostics: [] });
86
151
  });
87
152
 
88
- function validateDocument(document: TextDocument): void {
89
- const uri = document.uri;
90
- if (!isNudoFile(uri)) {
91
- connection.sendDiagnostics({ uri, diagnostics: [] });
92
- return;
93
- }
153
+ /**
154
+ * watched-files 删除事件监听器:接收「被删除且不在打开集」的 uri 列表。
155
+ * 缓存逐出等后续逻辑通过 registerWatchedFilesListener 挂到这里。
156
+ */
157
+ export const watchedFilesListeners: Array<(uris: string[]) => void> = [];
158
+
159
+ /** 注册 watched-files 监听器,返回注销函数。 */
160
+ export function registerWatchedFilesListener(listener: (uris: string[]) => void): () => void {
161
+ watchedFilesListeners.push(listener);
162
+ return () => {
163
+ const idx = watchedFilesListeners.indexOf(listener);
164
+ if (idx >= 0) watchedFilesListeners.splice(idx, 1);
165
+ };
166
+ }
94
167
 
95
- const filePath = uriToFilePath(uri);
96
- const source = document.getText();
97
- const cases = getActiveCasesForUri(uri);
168
+ /**
169
+ * watched-files 事件核心:对 Deleted 且不在打开集的文件清理会话登记项
170
+ * (knownFiles/analysisCache 走 forgetValidatedFile,activeCases/nudoFileCache 按 uri),
171
+ * 清空其已发布诊断,并把被清理的 uri 列表广播给监听器。
172
+ * 打开中的文件跳过——其内容由编辑流负责,外部删除会被编辑器以 didOpen/didChange 覆盖。
173
+ */
174
+ function isNudoDepPath(filePath: string): boolean {
175
+ return /\.nudo\.(js|mjs|ts)$/.test(filePath);
176
+ }
98
177
 
99
- try {
100
- const result = analyzeFile(filePath, source, cases);
101
- const diagnostics: LspDiagnostic[] = result.diagnostics.map((d) => {
102
- const diag: LspDiagnostic = {
103
- severity: severityMap[d.severity],
104
- range: {
105
- start: { line: d.range.start.line - 1, character: d.range.start.column },
106
- end: { line: d.range.end.line - 1, character: d.range.end.column },
107
- },
108
- message: d.message,
109
- source: "nudo",
110
- };
111
- if (d.tags?.includes("unnecessary")) {
112
- diag.tags = [DiagnosticTag.Unnecessary];
178
+ function handleWatchedFilesChanges(changes: readonly FileEvent[], isOpen: (uri: string) => boolean): string[] {
179
+ const gone: string[] = [];
180
+ const nudoTouched: string[] = [];
181
+ for (const change of changes) {
182
+ const filePath = uriToFilePath(change.uri);
183
+ if (change.type === FileChangeType.Deleted) {
184
+ if (isOpen(change.uri)) continue;
185
+ gone.push(change.uri);
186
+ forgetValidatedFile(filePath);
187
+ activeCases.delete(change.uri);
188
+ nudoFileCache.delete(change.uri);
189
+ connection.sendDiagnostics({ uri: change.uri, diagnostics: [] });
190
+ if (isNudoDepPath(filePath)) nudoTouched.push(filePath);
191
+ continue;
192
+ }
193
+ // Create/Change:契约模板变更 → 定向逐出 L0 + 重检打开中的父文件
194
+ if (isNudoDepPath(filePath)) {
195
+ nudoTouched.push(filePath);
196
+ }
197
+ }
198
+ if (gone.length > 0) {
199
+ // 拷贝后再遍历:监听器内注销自身不应影响本轮广播
200
+ for (const listener of [...watchedFilesListeners]) {
201
+ try {
202
+ listener(gone);
203
+ } catch {
204
+ // 单个监听器异常不阻断其余监听器的缓存逐出
113
205
  }
114
- return diag;
115
- });
116
- connection.sendDiagnostics({ uri, diagnostics });
117
- } catch (err) {
118
- connection.sendDiagnostics({
119
- uri,
120
- diagnostics: [
121
- {
122
- severity: DiagnosticSeverity.Error,
123
- range: { start: { line: 0, character: 0 }, end: { line: 0, character: 1 } },
124
- message: `Analysis error: ${(err as Error).message}`,
125
- source: "nudo",
126
- },
127
- ],
128
- });
206
+ }
129
207
  }
208
+ if (nudoTouched.length > 0) {
209
+ const deps = validationDeps();
210
+ for (const p of nudoTouched) {
211
+ void handleNudoDepFileChanged(p, deps).catch(() => {});
212
+ }
213
+ }
214
+ return gone;
215
+ }
216
+
217
+ connection.onDidChangeWatchedFiles((event) =>
218
+ handleWatchedFilesChanges(event.changes, (uri) => documents.get(uri) !== undefined),
219
+ );
220
+
221
+ // 模块图边缓存逐出:收到被清理 uri 时逐出 moduleGraphCache 对应 filePath 的条目。
222
+ // 会话级常驻注册,无需持有注销函数。
223
+ registerWatchedFilesListener(evictModuleGraphCacheEntries);
224
+
225
+ function validationDeps(): ValidateTextDeps {
226
+ return {
227
+ sendDiagnostics: (params) => connection.sendDiagnostics(params),
228
+ isNudoUri: (uri) => isNudoFile(uri),
229
+ getActiveCases: (uri) => getActiveCasesForUri(uri),
230
+ getOpenDocumentByPath: (filePath) =>
231
+ documents.all().find((doc) => uriToFilePath(doc.uri) === filePath),
232
+ };
233
+ }
234
+
235
+ function validateDocument(document: TextDocument, propagate = false): Promise<void> {
236
+ return validateText(
237
+ uriToFilePath(document.uri),
238
+ document.uri,
239
+ document.getText(),
240
+ document.version,
241
+ validationDeps(),
242
+ propagate,
243
+ );
130
244
  }
131
245
 
132
246
  connection.onHover((params) => {
@@ -141,13 +255,30 @@ connection.onHover((params) => {
141
255
  const cases = getActiveCasesForUri(params.textDocument.uri);
142
256
 
143
257
  try {
144
- const tv = getTypeAtPosition(filePath, source, line, column, cases);
145
- if (!tv) return null;
146
-
258
+ const hover = getHoverAtPosition(filePath, source, line, column, cases);
259
+ if (!hover) return null;
260
+
261
+ const lines: string[] = [];
262
+ // 无损 Abs 优先(类型即计算本体)
263
+ if (hover.absMultiline) {
264
+ lines.push("```nudo", hover.absMultiline, "```");
265
+ } else if (hover.abs) {
266
+ lines.push("```nudo", hover.abs, "```");
267
+ }
268
+ if (hover.intension && hover.intension !== hover.abs) {
269
+ lines.push("```nudo", hover.intension, "```");
270
+ }
271
+ // 外延 TypeValue 仅作对照,且与内涵不同时才显示
272
+ if (hover.typeText && hover.typeText !== hover.intension && hover.typeText !== hover.abs) {
273
+ lines.push("```nudo", `ext: ${hover.typeText}`, "```");
274
+ }
275
+ if (lines.length === 0) {
276
+ lines.push("```nudo", hover.typeText, "```");
277
+ }
147
278
  return {
148
279
  contents: {
149
280
  kind: MarkupKind.Markdown,
150
- value: `\`\`\`nudo\n${typeValueToString(tv)}\n\`\`\``,
281
+ value: lines.join("\n"),
151
282
  },
152
283
  };
153
284
  } catch {
@@ -232,7 +363,7 @@ connection.languages.inlayHint.on((params) => {
232
363
  const lines = source.split("\n");
233
364
 
234
365
  try {
235
- const result = analyzeFile(filePath, source, cases);
366
+ const result = getCachedOrAnalyze(filePath, source, document.version, cases);
236
367
  const hints: InlayHint[] = [];
237
368
 
238
369
  for (const hint of result.caseHints) {
@@ -248,39 +379,362 @@ connection.languages.inlayHint.on((params) => {
248
379
  });
249
380
  }
250
381
 
382
+ // Abs inlay:参数约束 + 返回 term/pred(类型即计算,无损)
383
+ try {
384
+ for (const abs of collectAbsInlays(source, {
385
+ loadModule: lspLoadModule,
386
+ fromFile: filePath,
387
+ })) {
388
+ const lineIdx = abs.line - 1;
389
+ if (lineIdx < 0 || lineIdx >= lines.length) continue;
390
+ hints.push({
391
+ position: { line: lineIdx, character: abs.character },
392
+ label: abs.label,
393
+ kind:
394
+ abs.kind === "parameter"
395
+ ? InlayHintKind.Parameter
396
+ : InlayHintKind.Type,
397
+ paddingLeft: true,
398
+ });
399
+ }
400
+ } catch {
401
+ // Abs inlay 失败不影响 caseHints
402
+ }
403
+
251
404
  return hints;
252
405
  } catch {
253
406
  return [];
254
407
  }
255
408
  });
256
409
 
257
- connection.onRequest("nudo/selectCase", (params: { uri: string; functionName: string; caseIndex: number }) => {
258
- const cases = getActiveCasesForUri(params.uri);
410
+ connection.onDefinition((params) => {
411
+ const document = documents.get(params.textDocument.uri);
412
+ if (!document) return null;
413
+ if (!isNudoFile(params.textDocument.uri)) return null;
414
+
415
+ const source = document.getText();
416
+ const ast = parse(source);
417
+ const table = buildSymbolTable(ast, params.textDocument.uri);
418
+
419
+ const line = params.position.line + 1;
420
+ const column = params.position.character;
421
+ const identAtPos = findIdentifierAtPosition(ast, line, column);
422
+ if (!identAtPos) return null;
423
+
424
+ const def = findDefinition(table, identAtPos);
425
+ if (!def) return null;
426
+
427
+ return {
428
+ uri: params.textDocument.uri,
429
+ range: {
430
+ start: { line: def.loc.start.line - 1, character: def.loc.start.column },
431
+ end: { line: def.loc.end.line - 1, character: def.loc.end.column },
432
+ },
433
+ };
434
+ });
435
+
436
+ connection.onReferences((params) => {
437
+ const document = documents.get(params.textDocument.uri);
438
+ if (!document) return [];
439
+ if (!isNudoFile(params.textDocument.uri)) return [];
440
+
441
+ const source = document.getText();
442
+ const ast = parse(source);
443
+ const table = buildSymbolTable(ast, params.textDocument.uri);
444
+
445
+ const line = params.position.line + 1;
446
+ const column = params.position.character;
447
+ const identAtPos = findIdentifierAtPosition(ast, line, column);
448
+ if (!identAtPos) return [];
449
+
450
+ const refs = findReferences(table, identAtPos);
451
+ return refs.map((ref) => ({
452
+ uri: params.textDocument.uri,
453
+ range: {
454
+ start: { line: ref.loc.start.line - 1, character: ref.loc.start.column },
455
+ end: { line: ref.loc.end.line - 1, character: ref.loc.end.column },
456
+ },
457
+ }));
458
+ });
459
+
460
+ connection.onRenameRequest((params) => {
461
+ const document = documents.get(params.textDocument.uri);
462
+ if (!document) return null;
463
+ if (!isNudoFile(params.textDocument.uri)) return null;
464
+
465
+ const source = document.getText();
466
+ const ast = parse(source);
467
+ const table = buildSymbolTable(ast, params.textDocument.uri);
468
+
469
+ const line = params.position.line + 1;
470
+ const column = params.position.character;
471
+ const identAtPos = findIdentifierAtPosition(ast, line, column);
472
+ if (!identAtPos) return null;
473
+
474
+ const def = findDefinition(table, identAtPos);
475
+ const refs = findReferences(table, identAtPos);
476
+
477
+ const edits = [];
478
+
479
+ if (def) {
480
+ edits.push({
481
+ range: {
482
+ start: { line: def.loc.start.line - 1, character: def.loc.start.column },
483
+ end: { line: def.loc.end.line - 1, character: def.loc.end.column },
484
+ },
485
+ newText: params.newName,
486
+ });
487
+ }
488
+
489
+ for (const ref of refs) {
490
+ edits.push({
491
+ range: {
492
+ start: { line: ref.loc.start.line - 1, character: ref.loc.start.column },
493
+ end: { line: ref.loc.end.line - 1, character: ref.loc.end.column },
494
+ },
495
+ newText: params.newName,
496
+ });
497
+ }
498
+
499
+ return {
500
+ changes: {
501
+ [params.textDocument.uri]: edits,
502
+ },
503
+ };
504
+ });
505
+
506
+ connection.onCodeAction((params) => {
507
+ const document = documents.get(params.textDocument.uri);
508
+ if (!document) return [];
509
+ if (!isNudoFile(params.textDocument.uri)) return [];
510
+
511
+ const actions = [];
512
+
513
+ for (const diag of params.context.diagnostics) {
514
+ if (diag.code === "nudo-unreachable") {
515
+ actions.push({
516
+ title: "Remove unreachable code",
517
+ kind: "quickfix",
518
+ diagnostics: [diag],
519
+ edit: {
520
+ changes: {
521
+ [params.textDocument.uri]: [{
522
+ range: diag.range,
523
+ newText: "",
524
+ }],
525
+ },
526
+ },
527
+ });
528
+ }
529
+ }
530
+
531
+ return actions;
532
+ });
533
+
534
+ connection.onSignatureHelp((params) => {
535
+ const document = documents.get(params.textDocument.uri);
536
+ if (!document) return null;
537
+ if (!isNudoFile(params.textDocument.uri)) return null;
538
+
539
+ const filePath = uriToFilePath(params.textDocument.uri);
540
+ const source = document.getText();
541
+ const line = params.position.line + 1;
542
+ const column = params.position.character;
543
+ const cases = getActiveCasesForUri(params.textDocument.uri);
544
+
545
+ try {
546
+ const ast = parse(source);
547
+ const callInfo = findEnclosingCall(ast, line, column);
548
+ if (!callInfo) return null;
549
+
550
+ const fnType = getTypeAtPosition(filePath, source, callInfo.calleeLine, callInfo.calleeCol, cases);
551
+ if (!fnType || fnType.kind !== "function") return null;
552
+
553
+ const paramLabels = fnType.params.map((p) => `${p}: unknown`);
554
+ const activeParam = callInfo.currentParamIndex;
555
+
556
+ return {
557
+ signatures: [{
558
+ label: `(${paramLabels.join(", ")}) => unknown`,
559
+ parameters: paramLabels.map((label) => ({ label })),
560
+ activeParameter: activeParam,
561
+ }],
562
+ activeSignature: 0,
563
+ activeParameter: activeParam,
564
+ };
565
+ } catch {
566
+ return null;
567
+ }
568
+ });
569
+
570
+ function findEnclosingCall(ast: any, line: number, column: number): { calleeLine: number; calleeCol: number; currentParamIndex: number } | null {
571
+ let result: any = null;
572
+
573
+ function visit(node: any): void {
574
+ if (!node || result) return;
575
+
576
+ if (node.type === "CallExpression") {
577
+ const loc = node.loc;
578
+ if (loc && loc.start.line <= line && loc.end.line >= line) {
579
+ const calleeLoc = node.callee.loc;
580
+ if (calleeLoc) {
581
+ let paramIndex = 0;
582
+ for (let i = 0; i < node.arguments.length; i++) {
583
+ const argLoc = node.arguments[i].loc;
584
+ if (argLoc) {
585
+ if (argLoc.start.line < line || (argLoc.start.line === line && argLoc.start.column <= column)) {
586
+ paramIndex = i + 1;
587
+ }
588
+ }
589
+ }
590
+ result = {
591
+ calleeLine: calleeLoc.start.line,
592
+ calleeCol: calleeLoc.start.column,
593
+ currentParamIndex: Math.min(paramIndex, node.arguments.length),
594
+ };
595
+ }
596
+ }
597
+ }
598
+
599
+ for (const key of Object.keys(node)) {
600
+ if (key === "type" || key === "loc" || key === "start" || key === "end") continue;
601
+ const child = node[key];
602
+ if (Array.isArray(child)) {
603
+ for (const item of child) {
604
+ if (item && typeof item === "object" && item.type) visit(item);
605
+ }
606
+ } else if (child && typeof child === "object" && child.type) {
607
+ visit(child);
608
+ }
609
+ }
610
+ }
611
+
612
+ visit(ast);
613
+ return result;
614
+ }
615
+
616
+ connection.languages.semanticTokens.on((params) => {
617
+ const document = documents.get(params.textDocument.uri);
618
+ if (!document) return { data: [] };
619
+ if (!isNudoFile(params.textDocument.uri)) return { data: [] };
620
+
621
+ try {
622
+ const filePath = uriToFilePath(document.uri);
623
+ return { data: buildSemanticTokens(filePath, document.getText()) };
624
+ } catch {
625
+ return { data: [] };
626
+ }
627
+ });
628
+
629
+ /** Resolve a `uri`- or `file`-identified target to the uri key used by activeCases/documents. */
630
+ function uriForFileOrUri(params: { uri?: string; file?: string }): string {
631
+ if (params.uri) return params.uri;
632
+ const filePath = normalizeFilePath(params.file ?? "");
633
+ const doc = documents.all().find((d) => uriToFilePath(d.uri) === filePath);
634
+ return doc ? doc.uri : `file://${filePath}`;
635
+ }
636
+
637
+ async function handleSelectCase(params: { uri?: string; file?: string; functionName: string; caseIndex: number }) {
638
+ const uri = uriForFileOrUri(params);
639
+ const cases = getActiveCasesForUri(uri);
259
640
  cases.set(params.functionName, params.caseIndex);
260
641
 
261
- const document = documents.get(params.uri);
642
+ const document = documents.get(uri);
262
643
  if (document) {
263
- validateDocument(document);
644
+ await validateDocument(document);
264
645
  }
265
646
 
266
647
  connection.sendRequest(CodeLensRefreshRequest.type).catch(() => {});
267
648
 
268
649
  return { success: true };
269
- });
650
+ }
270
651
 
271
- connection.onRequest("nudo/getActiveCases", (params: { uri: string }) => {
272
- const cases = getActiveCasesForUri(params.uri);
652
+ function handleGetActiveCases(params: { uri?: string; file?: string }) {
653
+ const cases = getActiveCasesForUri(uriForFileOrUri(params));
273
654
  const result: Record<string, number> = {};
274
655
  for (const [fn, idx] of cases) {
275
656
  result[fn] = idx;
276
657
  }
277
658
  return result;
659
+ }
660
+
661
+ const agentToolDeps: AgentToolDeps = {
662
+ getOpenText: (filePath) => {
663
+ const doc = documents.all().find((d) => uriToFilePath(d.uri) === filePath);
664
+ return doc ? { text: doc.getText() } : undefined;
665
+ },
666
+ };
667
+
668
+ /**
669
+ * One dispatch table shared by the executeCommand commands (`nudo.*`) and the
670
+ * custom request aliases (`nudo/…`) — both channels run the same handlers.
671
+ */
672
+ function dispatchNudoCommand(command: string, arg: Record<string, unknown>) {
673
+ switch (command) {
674
+ case "nudo.whatIf":
675
+ return whatIf(arg as Parameters<typeof whatIf>[0], agentToolDeps);
676
+ case "nudo.suggestCase":
677
+ return suggestCase(arg as Parameters<typeof suggestCase>[0], agentToolDeps);
678
+ case "nudo.trace":
679
+ return trace(arg as Parameters<typeof trace>[0], agentToolDeps);
680
+ case "nudo.check":
681
+ return checkTool(arg as Parameters<typeof checkTool>[0], agentToolDeps);
682
+ case "nudo.hover":
683
+ return hoverTool(arg as Parameters<typeof hoverTool>[0], agentToolDeps);
684
+ case "nudo.infer":
685
+ return inferTool(arg as Parameters<typeof inferTool>[0], agentToolDeps);
686
+ case "nudo.selectCase":
687
+ return handleSelectCase(arg as Parameters<typeof handleSelectCase>[0]);
688
+ case "nudo.getActiveCases":
689
+ return handleGetActiveCases(arg as Parameters<typeof handleGetActiveCases>[0]);
690
+ default:
691
+ return null;
692
+ }
693
+ }
694
+
695
+ connection.onExecuteCommand((params) =>
696
+ dispatchNudoCommand(params.command, (params.arguments?.[0] as Record<string, unknown>) ?? {}),
697
+ );
698
+
699
+ connection.onRequest("nudo/selectCase", handleSelectCase);
700
+
701
+ connection.onRequest("nudo/getActiveCases", handleGetActiveCases);
702
+
703
+ /** Request aliases share the command handlers; the pinned return type keeps onRequest overload inference happy. */
704
+ function dispatchAgentRequest(command: string, params: Record<string, unknown>): AgentToolResult {
705
+ return dispatchNudoCommand(command, params) as AgentToolResult;
706
+ }
707
+
708
+ connection.onRequest("nudo/whatIf", (params: Record<string, unknown>) => dispatchAgentRequest("nudo.whatIf", params));
709
+ connection.onRequest("nudo/suggestCase", (params: Record<string, unknown>) => dispatchAgentRequest("nudo.suggestCase", params));
710
+ connection.onRequest("nudo/trace", (params: Record<string, unknown>) => dispatchAgentRequest("nudo.trace", params));
711
+ connection.onRequest("nudo/check", (params: Record<string, unknown>) => dispatchAgentRequest("nudo.check", params));
712
+ connection.onRequest("nudo/hover", (params: Record<string, unknown>) => dispatchAgentRequest("nudo.hover", params));
713
+ connection.onRequest("nudo/infer", (params: Record<string, unknown>) => dispatchAgentRequest("nudo.infer", params));
714
+
715
+ connection.languages.diagnostics.on((params) => {
716
+ const document = documents.get(params.textDocument.uri);
717
+ if (!document) return { kind: "full", items: [] };
718
+ if (!isNudoFile(params.textDocument.uri)) return { kind: "full", items: [] };
719
+
720
+ try {
721
+ const filePath = uriToFilePath(document.uri);
722
+ const result = getCachedOrAnalyze(filePath, document.getText(), document.version, getActiveCasesForUri(document.uri));
723
+ return {
724
+ kind: "full",
725
+ items: result.diagnostics.map((d) => toLspDiagnostic(d, document.uri)),
726
+ };
727
+ } catch {
728
+ return { kind: "full", items: [] };
729
+ }
278
730
  });
279
731
 
280
732
  const nudoFileCache = new Map<string, boolean>();
281
733
 
282
734
  function isNudoFile(uri: string): boolean {
283
- if (!uri.endsWith(".js") && !uri.endsWith(".ts") && !uri.endsWith(".mjs")) return false;
735
+ // 推断目标判定收敛到 service 层(TaskA 提供):.js/.mjs/.ts 为目标,
736
+ // .d.ts(类型声明,harvester 输入)与 .tsx/.jsx 等一律排除
737
+ if (!isNudoTargetPath(uriToFilePath(uri))) return false;
284
738
  const cached = nudoFileCache.get(uri);
285
739
  if (cached !== undefined) return cached;
286
740
  const doc = documents.get(uri);
@@ -290,13 +744,5 @@ function isNudoFile(uri: string): boolean {
290
744
  return result;
291
745
  }
292
746
 
293
- function hasNudoDirectives(source: string): boolean {
294
- return /@nudo:(case|mock|pure|skip|sample|returns)\b/.test(source);
295
- }
296
-
297
- function uriToFilePath(uri: string): string {
298
- return uri.startsWith("file://") ? decodeURIComponent(uri.slice(7)) : uri;
299
- }
300
-
301
747
  documents.listen(connection);
302
748
  connection.listen();