pi-lens 3.8.21 → 3.8.23

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.
Files changed (92) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/README.md +2 -0
  3. package/clients/dispatch/dispatcher.ts +75 -91
  4. package/clients/dispatch/fact-provider-types.ts +22 -0
  5. package/clients/dispatch/fact-rule-runner.ts +22 -0
  6. package/clients/dispatch/fact-runner.ts +28 -0
  7. package/clients/dispatch/fact-scheduler.ts +78 -0
  8. package/clients/dispatch/fact-store.ts +67 -0
  9. package/clients/dispatch/facts/comment-facts.ts +59 -0
  10. package/clients/dispatch/facts/file-content.ts +20 -0
  11. package/clients/dispatch/facts/function-facts.ts +177 -0
  12. package/clients/dispatch/facts/try-catch-facts.ts +80 -0
  13. package/clients/dispatch/integration.ts +130 -24
  14. package/clients/dispatch/priorities.ts +22 -0
  15. package/clients/dispatch/rules/async-noise.ts +43 -0
  16. package/clients/dispatch/rules/error-obscuring.ts +40 -0
  17. package/clients/dispatch/rules/error-swallowing.ts +35 -0
  18. package/clients/dispatch/rules/pass-through-wrappers.ts +52 -0
  19. package/clients/dispatch/rules/placeholder-comments.ts +47 -0
  20. package/clients/dispatch/runners/architect.ts +2 -1
  21. package/clients/dispatch/runners/ast-grep-napi.ts +2 -1
  22. package/clients/dispatch/runners/biome-check.ts +40 -8
  23. package/clients/dispatch/runners/biome.ts +2 -1
  24. package/clients/dispatch/runners/eslint.ts +34 -6
  25. package/clients/dispatch/runners/go-vet.ts +2 -1
  26. package/clients/dispatch/runners/golangci-lint.ts +2 -1
  27. package/clients/dispatch/runners/index.ts +29 -27
  28. package/clients/dispatch/runners/lsp.ts +60 -4
  29. package/clients/dispatch/runners/oxlint.ts +2 -1
  30. package/clients/dispatch/runners/pyright.ts +2 -1
  31. package/clients/dispatch/runners/python-slop.ts +2 -1
  32. package/clients/dispatch/runners/rubocop.ts +2 -1
  33. package/clients/dispatch/runners/ruff.ts +2 -1
  34. package/clients/dispatch/runners/rust-clippy.ts +2 -1
  35. package/clients/dispatch/runners/shellcheck.ts +2 -1
  36. package/clients/dispatch/runners/similarity.ts +2 -1
  37. package/clients/dispatch/runners/spellcheck.ts +2 -1
  38. package/clients/dispatch/runners/sqlfluff.ts +2 -1
  39. package/clients/dispatch/runners/tree-sitter.ts +469 -1
  40. package/clients/dispatch/runners/ts-lsp.ts +2 -1
  41. package/clients/dispatch/runners/type-safety.ts +2 -1
  42. package/clients/dispatch/runners/yamllint.ts +2 -1
  43. package/clients/dispatch/tool-profile.ts +40 -0
  44. package/clients/dispatch/types.ts +3 -13
  45. package/clients/lsp/client.ts +366 -12
  46. package/clients/lsp/index.ts +374 -76
  47. package/clients/lsp/launch.ts +42 -2
  48. package/clients/lsp/server.ts +186 -12
  49. package/clients/pipeline.ts +2 -2
  50. package/clients/runtime-context.ts +2 -2
  51. package/clients/runtime-session.ts +43 -5
  52. package/clients/session-summary.ts +21 -0
  53. package/clients/tree-sitter-client.ts +162 -0
  54. package/clients/tree-sitter-logger.ts +47 -0
  55. package/clients/tree-sitter-query-loader.ts +13 -2
  56. package/index.ts +67 -17
  57. package/package.json +3 -1
  58. package/rules/rule-catalog.json +64 -0
  59. package/rules/tree-sitter-queries/go/go-bare-error.yml +19 -7
  60. package/rules/tree-sitter-queries/go/go-command-injection.yml +55 -0
  61. package/rules/tree-sitter-queries/go/go-direct-panic.yml +45 -0
  62. package/rules/tree-sitter-queries/go/go-empty-if-err.yml +47 -0
  63. package/rules/tree-sitter-queries/go/go-goroutine-loop-capture.yml +49 -0
  64. package/rules/tree-sitter-queries/go/go-ignored-call-result.yml +51 -0
  65. package/rules/tree-sitter-queries/go/go-insecure-random.yml +51 -0
  66. package/rules/tree-sitter-queries/go/go-log-fatal.yml +49 -0
  67. package/rules/tree-sitter-queries/go/go-path-traversal.yml +51 -0
  68. package/rules/tree-sitter-queries/go/go-shared-map-write-goroutine.yml +54 -0
  69. package/rules/tree-sitter-queries/go/go-sql-injection.yml +55 -0
  70. package/rules/tree-sitter-queries/go/go-weak-hash.yml +51 -0
  71. package/rules/tree-sitter-queries/python/python-command-injection.yml +63 -0
  72. package/rules/tree-sitter-queries/python/python-insecure-deserialization.yml +48 -0
  73. package/rules/tree-sitter-queries/python/python-insecure-random.yml +51 -0
  74. package/rules/tree-sitter-queries/python/python-path-traversal.yml +55 -0
  75. package/rules/tree-sitter-queries/python/python-sql-injection.yml +47 -0
  76. package/rules/tree-sitter-queries/python/python-ssrf.yml +50 -0
  77. package/rules/tree-sitter-queries/python/python-thread-global-write.yml +58 -0
  78. package/rules/tree-sitter-queries/python/python-weak-hash.yml +51 -0
  79. package/rules/tree-sitter-queries/ruby/ruby-command-injection.yml +56 -0
  80. package/rules/tree-sitter-queries/ruby/ruby-insecure-deserialization.yml +47 -0
  81. package/rules/tree-sitter-queries/ruby/ruby-insecure-random.yml +54 -0
  82. package/rules/tree-sitter-queries/ruby/ruby-weak-hash.yml +50 -0
  83. package/rules/tree-sitter-queries/rust/rust-lock-held-across-await.yml +59 -0
  84. package/rules/tree-sitter-queries/typescript/ts-command-injection.yml +60 -0
  85. package/rules/tree-sitter-queries/typescript/ts-detached-async-call.yml +56 -0
  86. package/rules/tree-sitter-queries/typescript/ts-insecure-random.yml +54 -0
  87. package/rules/tree-sitter-queries/typescript/ts-ssrf.yml +53 -0
  88. package/rules/tree-sitter-queries/typescript/ts-weak-hash.yml +54 -0
  89. package/scripts/validate-rule-catalog.mjs +227 -0
  90. package/skills/lsp-navigation/SKILL.md +15 -3
  91. package/tools/lsp-navigation.js +466 -79
  92. package/tools/lsp-navigation.ts +587 -85
@@ -6,9 +6,152 @@
6
6
 
7
7
  import * as nodeFs from "node:fs";
8
8
  import * as path from "node:path";
9
+ import { pathToFileURL } from "node:url";
9
10
  import { Type } from "@sinclair/typebox";
10
11
  import type { LSPCallHierarchyItem } from "../clients/lsp/client.js";
11
12
  import { getLSPService } from "../clients/lsp/index.js";
13
+ import { logLatency } from "../clients/latency-logger.js";
14
+
15
+ function operationSupportStatus(
16
+ operation: string,
17
+ support: import("../clients/lsp/client.js").LSPOperationSupport | null,
18
+ ): boolean | null {
19
+ if (!support) return null;
20
+ if (operation === "definition") return support.definition;
21
+ if (operation === "references") return support.references;
22
+ if (operation === "hover") return support.hover;
23
+ if (operation === "signatureHelp") return support.signatureHelp;
24
+ if (operation === "documentSymbol") return support.documentSymbol;
25
+ if (operation === "workspaceSymbol") return support.workspaceSymbol;
26
+ if (operation === "codeAction") return support.codeAction;
27
+ if (operation === "rename") return support.rename;
28
+ if (operation === "implementation") return support.implementation;
29
+ if (
30
+ operation === "prepareCallHierarchy" ||
31
+ operation === "incomingCalls" ||
32
+ operation === "outgoingCalls"
33
+ )
34
+ return support.callHierarchy;
35
+ return null;
36
+ }
37
+
38
+ function emptyReasonForOperation(operation: string): string {
39
+ if (operation === "signatureHelp") return "position-sensitive-or-no-signature";
40
+ if (operation === "codeAction") return "no-applicable-actions";
41
+ if (operation === "rename") return "no-rename-edits-or-symbol-not-renamable";
42
+ if (operation === "workspaceSymbol")
43
+ return "no-matching-symbols-or-server-index-unavailable";
44
+ if (operation === "incomingCalls" || operation === "outgoingCalls")
45
+ return "no-call-hierarchy-results";
46
+ return "no-results";
47
+ }
48
+
49
+ function tokenAtPosition(
50
+ content: string,
51
+ line1: number,
52
+ char1: number,
53
+ ): string | undefined {
54
+ const lines = content.split(/\r?\n/);
55
+ const line = lines[line1 - 1];
56
+ if (!line) return undefined;
57
+ const chars = [...line];
58
+ const idx = Math.max(0, Math.min(chars.length - 1, char1 - 1));
59
+ const isWord = (ch: string | undefined) =>
60
+ !!ch && /[A-Za-z0-9_?!]/.test(ch);
61
+
62
+ let left = idx;
63
+ let right = idx;
64
+ if (!isWord(chars[idx]) && isWord(chars[idx + 1])) {
65
+ left = idx + 1;
66
+ right = idx + 1;
67
+ }
68
+ while (left > 0 && isWord(chars[left - 1])) left -= 1;
69
+ while (right < chars.length - 1 && isWord(chars[right + 1])) right += 1;
70
+ const token = chars.slice(left, right + 1).join("").trim();
71
+ return token.length > 0 ? token : undefined;
72
+ }
73
+
74
+ type SymbolNode = {
75
+ name?: string;
76
+ location?: { uri: string; range: Record<string, unknown> };
77
+ range?: Record<string, unknown>;
78
+ children?: SymbolNode[];
79
+ };
80
+
81
+ function flattenSymbols(symbols: SymbolNode[]): SymbolNode[] {
82
+ const all: SymbolNode[] = [];
83
+ for (const symbol of symbols) {
84
+ all.push(symbol);
85
+ if (symbol.children && symbol.children.length > 0) {
86
+ all.push(...flattenSymbols(symbol.children));
87
+ }
88
+ }
89
+ return all;
90
+ }
91
+
92
+ function pickLocalSymbolLocation(
93
+ symbols: SymbolNode[],
94
+ token: string,
95
+ filePath: string,
96
+ ): Array<{ uri: string; range: Record<string, unknown> }> {
97
+ const flat = flattenSymbols(symbols).filter(
98
+ (symbol) => symbol.name === token,
99
+ );
100
+ if (flat.length === 0) return [];
101
+ const uri = pathToFileURL(filePath).href;
102
+ return flat
103
+ .map((symbol) => {
104
+ if (symbol.location?.uri && symbol.location.range) {
105
+ return { uri: symbol.location.uri, range: symbol.location.range };
106
+ }
107
+ if (symbol.range) {
108
+ return { uri, range: symbol.range };
109
+ }
110
+ return undefined;
111
+ })
112
+ .filter((entry): entry is { uri: string; range: Record<string, unknown> } =>
113
+ Boolean(entry),
114
+ );
115
+ }
116
+
117
+ function classifyCodeActions(
118
+ actions: Array<{ kind?: string }> | undefined,
119
+ ): { quickfix: number; refactor: number; other: number } {
120
+ if (!actions || actions.length === 0) return { quickfix: 0, refactor: 0, other: 0 };
121
+ let quickfix = 0;
122
+ let refactor = 0;
123
+ let other = 0;
124
+ for (const action of actions) {
125
+ const kind = action.kind ?? "";
126
+ if (kind.startsWith("quickfix")) quickfix += 1;
127
+ else if (kind.startsWith("refactor")) refactor += 1;
128
+ else other += 1;
129
+ }
130
+ return { quickfix, refactor, other };
131
+ }
132
+
133
+ async function openFileBestEffort(
134
+ lspService: ReturnType<typeof getLSPService>,
135
+ filePath: string,
136
+ waitForDiagnostics = false,
137
+ ): Promise<void> {
138
+ let fileContent: string | undefined;
139
+ try {
140
+ fileContent = nodeFs.readFileSync(filePath, "utf-8");
141
+ } catch {
142
+ return;
143
+ }
144
+ if (!fileContent) return;
145
+ try {
146
+ if (typeof lspService.touchFile === "function") {
147
+ await lspService.touchFile(filePath, fileContent, waitForDiagnostics);
148
+ } else {
149
+ await lspService.openFile(filePath, fileContent);
150
+ }
151
+ } catch {
152
+ /* LSP server may not be ready yet — proceed anyway */
153
+ }
154
+ }
12
155
 
13
156
  export function createLspNavigationTool(
14
157
  getFlag: (name: string) => boolean | string | undefined,
@@ -16,18 +159,22 @@ export function createLspNavigationTool(
16
159
  return {
17
160
  name: "lsp_navigation" as const,
18
161
  label: "LSP Navigate",
19
- description:
162
+ description:
20
163
  "Navigate code using LSP (Language Server Protocol). Requires --lens-lsp flag.\n" +
21
164
  "Operations:\n" +
22
165
  "- definition: Jump to where a symbol is defined\n" +
23
166
  "- references: Find all usages of a symbol\n" +
24
167
  "- hover: Get type/doc info at a position\n" +
168
+ "- signatureHelp: Show callable signatures at cursor\n" +
25
169
  "- documentSymbol: List all symbols (functions/classes/vars) in a file\n" +
26
- "- workspaceSymbol: Search symbols across the whole project\n" +
170
+ "- workspaceSymbol: Search symbols across the whole project (best with filePath context)\n" +
171
+ "- codeAction: Find available quick fixes/refactors at a range\n" +
172
+ "- rename: Compute workspace edits for renaming a symbol\n" +
27
173
  "- implementation: Jump to interface implementations\n" +
28
174
  "- prepareCallHierarchy: Get callable item at position (for incoming/outgoing)\n" +
29
175
  "- incomingCalls: Find all functions/methods that CALL this function\n" +
30
- "- outgoingCalls: Find all functions/methods CALLED by this function\n\n" +
176
+ "- outgoingCalls: Find all functions/methods CALLED by this function\n" +
177
+ "- workspaceDiagnostics: List all diagnostics tracked by active LSP clients\n\n" +
31
178
  "Line and character are 1-based (as shown in editors).",
32
179
  promptSnippet:
33
180
  "Use lsp_navigation to find definitions, references, and hover info via LSP",
@@ -37,18 +184,25 @@ export function createLspNavigationTool(
37
184
  Type.Literal("definition"),
38
185
  Type.Literal("references"),
39
186
  Type.Literal("hover"),
187
+ Type.Literal("signatureHelp"),
40
188
  Type.Literal("documentSymbol"),
41
189
  Type.Literal("workspaceSymbol"),
190
+ Type.Literal("codeAction"),
191
+ Type.Literal("rename"),
42
192
  Type.Literal("implementation"),
43
193
  Type.Literal("prepareCallHierarchy"),
44
194
  Type.Literal("incomingCalls"),
45
195
  Type.Literal("outgoingCalls"),
196
+ Type.Literal("workspaceDiagnostics"),
46
197
  ],
47
198
  { description: "LSP operation to perform" },
48
199
  ),
49
- filePath: Type.String({
50
- description: "Absolute or relative path to the file",
51
- }),
200
+ filePath: Type.Optional(
201
+ Type.String({
202
+ description:
203
+ "Absolute or relative file path. Required for file-scoped operations; optional for workspaceSymbol/workspaceDiagnostics.",
204
+ }),
205
+ ),
52
206
  line: Type.Optional(
53
207
  Type.Number({
54
208
  description:
@@ -61,9 +215,27 @@ export function createLspNavigationTool(
61
215
  "Character offset (1-based). Required for definition/references/hover/implementation",
62
216
  }),
63
217
  ),
218
+ endLine: Type.Optional(
219
+ Type.Number({
220
+ description:
221
+ "End line (1-based). Optional; used by codeAction range.",
222
+ }),
223
+ ),
224
+ endCharacter: Type.Optional(
225
+ Type.Number({
226
+ description:
227
+ "End character (1-based). Optional; used by codeAction range.",
228
+ }),
229
+ ),
230
+ newName: Type.Optional(
231
+ Type.String({
232
+ description: "Required for rename operation.",
233
+ }),
234
+ ),
64
235
  query: Type.Optional(
65
236
  Type.String({
66
- description: "Symbol name to search. Used by workspaceSymbol",
237
+ description:
238
+ "Symbol name to search. Used by workspaceSymbol (best with filePath for active project context).",
67
239
  }),
68
240
  ),
69
241
  callHierarchyItem: Type.Optional(
@@ -107,8 +279,50 @@ export function createLspNavigationTool(
107
279
  _onUpdate: unknown,
108
280
  ctx: { cwd?: string },
109
281
  ) {
110
- if (!getFlag("lens-lsp") || getFlag("no-lsp")) {
282
+ const startedAt = Date.now();
283
+ let supported: boolean | null = null;
284
+ let diagnosticsMode: "pull" | "push-only" | "unknown" = "unknown";
285
+
286
+ const finalize = (
287
+ payload: {
288
+ content: Array<{ type: "text"; text: string }>;
289
+ isError?: boolean;
290
+ details?: Record<string, unknown>;
291
+ },
292
+ meta: {
293
+ operation: string;
294
+ filePath: string;
295
+ failureKind: string;
296
+ resultCount: number;
297
+ },
298
+ ) => {
299
+ const normalizedFilePath = meta.filePath.replace(/\\/g, "/");
300
+ logLatency({
301
+ type: "phase",
302
+ phase: "lsp_navigation_result",
303
+ filePath: normalizedFilePath,
304
+ durationMs: Date.now() - startedAt,
305
+ metadata: {
306
+ operation: meta.operation,
307
+ failureKind: meta.failureKind,
308
+ resultCount: meta.resultCount,
309
+ supported,
310
+ diagnosticsMode,
311
+ },
312
+ });
313
+
111
314
  return {
315
+ ...payload,
316
+ details: {
317
+ ...(payload.details ?? {}),
318
+ failureKind: meta.failureKind,
319
+ },
320
+ };
321
+ };
322
+
323
+ if (!getFlag("lens-lsp") || getFlag("no-lsp")) {
324
+ return finalize(
325
+ {
112
326
  content: [
113
327
  {
114
328
  type: "text" as const,
@@ -116,8 +330,14 @@ export function createLspNavigationTool(
116
330
  },
117
331
  ],
118
332
  isError: true,
119
- details: {},
120
- };
333
+ },
334
+ {
335
+ operation: "precheck",
336
+ filePath: "(workspace)",
337
+ failureKind: "lsp_disabled",
338
+ resultCount: 0,
339
+ },
340
+ );
121
341
  }
122
342
 
123
343
  const {
@@ -125,23 +345,165 @@ export function createLspNavigationTool(
125
345
  filePath: rawPath,
126
346
  line,
127
347
  character,
348
+ endLine,
349
+ endCharacter,
350
+ newName,
128
351
  query,
129
352
  } = params as {
130
353
  operation: string;
131
- filePath: string;
354
+ filePath?: string;
132
355
  line?: number;
133
356
  character?: number;
357
+ endLine?: number;
358
+ endCharacter?: number;
359
+ newName?: string;
134
360
  query?: string;
135
361
  };
136
362
 
137
- const filePath = path.isAbsolute(rawPath)
138
- ? rawPath
139
- : path.resolve(ctx.cwd || ".", rawPath);
363
+ const isCallHierarchyTraversal =
364
+ operation === "incomingCalls" || operation === "outgoingCalls";
365
+ const needsFilePath =
366
+ operation !== "workspaceDiagnostics" &&
367
+ operation !== "workspaceSymbol" &&
368
+ !isCallHierarchyTraversal;
369
+ if (needsFilePath && (!rawPath || rawPath.trim().length === 0)) {
370
+ return finalize(
371
+ {
372
+ content: [
373
+ {
374
+ type: "text" as const,
375
+ text: `filePath is required for ${operation}`,
376
+ },
377
+ ],
378
+ isError: true,
379
+ },
380
+ {
381
+ operation,
382
+ filePath: "(workspace)",
383
+ failureKind: "missing_file_path",
384
+ resultCount: 0,
385
+ },
386
+ );
387
+ }
388
+
389
+ const filePath = rawPath
390
+ ? path.isAbsolute(rawPath)
391
+ ? rawPath
392
+ : path.resolve(ctx.cwd || ".", rawPath)
393
+ : "";
140
394
 
141
395
  const lspService = getLSPService();
142
- const hasLSP = await lspService.hasLSP(filePath);
143
- if (!hasLSP) {
144
- return {
396
+ if (operation === "workspaceDiagnostics") {
397
+ const wsDiagSupport = await lspService.getWorkspaceDiagnosticsSupport(
398
+ rawPath ? filePath : undefined,
399
+ );
400
+ diagnosticsMode = wsDiagSupport?.mode ?? "unknown";
401
+
402
+ if (rawPath) {
403
+ const hasLSP = await lspService.hasLSP(filePath);
404
+ if (!hasLSP) {
405
+ return finalize(
406
+ {
407
+ content: [
408
+ {
409
+ type: "text" as const,
410
+ text: `No LSP server available for ${path.basename(filePath)}. Check that the language server is installed.`,
411
+ },
412
+ ],
413
+ isError: true,
414
+ },
415
+ {
416
+ operation,
417
+ filePath,
418
+ failureKind: "no_server",
419
+ resultCount: 0,
420
+ },
421
+ );
422
+ }
423
+
424
+ await openFileBestEffort(lspService, filePath, true);
425
+ const diagnostics = await lspService.getDiagnostics(filePath);
426
+ const result = [
427
+ {
428
+ filePath,
429
+ diagnostics,
430
+ count: diagnostics.length,
431
+ },
432
+ ];
433
+ const note =
434
+ diagnosticsMode === "pull"
435
+ ? "Note: filePath mode requests pull diagnostics for this file and returns the aggregated result."
436
+ : diagnosticsMode === "push-only"
437
+ ? "Note: server is push-only; result depends on published diagnostics for this file."
438
+ : "Note: workspace diagnostics mode unknown (no active capability snapshot).";
439
+ const resultCount = diagnostics.length;
440
+ return finalize(
441
+ {
442
+ content: [
443
+ {
444
+ type: "text" as const,
445
+ text: `${note}\n${JSON.stringify(result, null, 2)}`,
446
+ },
447
+ ],
448
+ details: {
449
+ operation,
450
+ resultCount,
451
+ diagnosticsMode,
452
+ coverage: "requested-file",
453
+ },
454
+ },
455
+ {
456
+ operation,
457
+ filePath,
458
+ failureKind: resultCount === 0 ? "empty_result" : "success",
459
+ resultCount,
460
+ },
461
+ );
462
+ }
463
+
464
+ const allDiagnostics = await lspService.getAllDiagnostics();
465
+ const result = Array.from(allDiagnostics.entries()).map(([trackedFile, diags]) => ({
466
+ filePath: trackedFile,
467
+ diagnostics: diags,
468
+ count: diags.length,
469
+ }));
470
+ const note =
471
+ diagnosticsMode === "push-only"
472
+ ? "Note: push-only tracked diagnostics snapshot (not full workspace pull diagnostics)."
473
+ : diagnosticsMode === "pull"
474
+ ? "Note: tracked diagnostics snapshot from active clients. Provide filePath to force file-level diagnostics collection."
475
+ : "Note: workspace diagnostics mode unknown (no active capability snapshot).";
476
+ return finalize(
477
+ {
478
+ content: [
479
+ {
480
+ type: "text" as const,
481
+ text: `${note}\n${JSON.stringify(result, null, 2)}`,
482
+ },
483
+ ],
484
+ details: {
485
+ operation,
486
+ resultCount: result.length,
487
+ diagnosticsMode,
488
+ coverage: "tracked-open-files",
489
+ },
490
+ },
491
+ {
492
+ operation,
493
+ filePath: rawPath ? filePath : "(workspace)",
494
+ failureKind:
495
+ diagnosticsMode === "push-only"
496
+ ? "tracked_snapshot"
497
+ : "success",
498
+ resultCount: result.length,
499
+ },
500
+ );
501
+ }
502
+
503
+ const hasLSP = filePath ? await lspService.hasLSP(filePath) : false;
504
+ if (needsFilePath && !hasLSP) {
505
+ return finalize(
506
+ {
145
507
  content: [
146
508
  {
147
509
  type: "text" as const,
@@ -149,104 +511,201 @@ export function createLspNavigationTool(
149
511
  },
150
512
  ],
151
513
  isError: true,
152
- details: {},
153
- };
514
+ },
515
+ {
516
+ operation,
517
+ filePath,
518
+ failureKind: "no_server",
519
+ resultCount: 0,
520
+ },
521
+ );
154
522
  }
155
523
 
156
- // Ensure file is open in LSP before querying
157
- let fileContent: string | undefined;
158
- try {
159
- fileContent = nodeFs.readFileSync(filePath, "utf-8");
160
- } catch {
161
- /* ignore */
162
- }
163
- if (fileContent) {
164
- try {
165
- await lspService.openFile(filePath, fileContent);
166
- } catch {
167
- /* LSP server may not be ready yet — proceed anyway */
524
+ if (needsFilePath) {
525
+ const support = await lspService.getOperationSupport(filePath);
526
+ supported = operationSupportStatus(operation, support);
527
+ if (supported === false) {
528
+ return finalize(
529
+ {
530
+ content: [
531
+ {
532
+ type: "text" as const,
533
+ text: `LSP server for ${path.basename(filePath)} does not advertise support for ${operation}`,
534
+ },
535
+ ],
536
+ isError: true,
537
+ details: { operation, supported: false, emptyReason: "unsupported" },
538
+ },
539
+ { operation, filePath, failureKind: "unsupported", resultCount: 0 },
540
+ );
168
541
  }
542
+
543
+ await openFileBestEffort(lspService, filePath);
169
544
  }
170
545
 
171
546
  // Convert 1-based editor coords to 0-based LSP coords
172
547
  const lspLine = (line ?? 1) - 1;
173
548
  const lspChar = (character ?? 1) - 1;
549
+ const lspEndLine = (endLine ?? line ?? 1) - 1;
550
+ const lspEndChar = (endCharacter ?? character ?? 1) - 1;
174
551
 
175
- let result: unknown;
176
- try {
552
+ const runOperation = async (): Promise<unknown> => {
177
553
  switch (operation) {
178
554
  case "definition":
179
- result = await lspService.definition(filePath, lspLine, lspChar);
180
- break;
555
+ return lspService.definition(filePath, lspLine, lspChar);
181
556
  case "references":
182
- result = await lspService.references(filePath, lspLine, lspChar);
183
- break;
557
+ return lspService.references(filePath, lspLine, lspChar);
184
558
  case "hover":
185
- result = await lspService.hover(filePath, lspLine, lspChar);
186
- break;
559
+ return lspService.hover(filePath, lspLine, lspChar);
560
+ case "signatureHelp":
561
+ return lspService.signatureHelp(filePath, lspLine, lspChar);
187
562
  case "documentSymbol":
188
- result = await lspService.documentSymbol(filePath);
189
- break;
563
+ return lspService.documentSymbol(filePath);
190
564
  case "workspaceSymbol":
191
- result = await lspService.workspaceSymbol(query ?? "");
192
- break;
193
- case "implementation":
194
- result = await lspService.implementation(
195
- filePath,
196
- lspLine,
197
- lspChar,
565
+ supported = operationSupportStatus(
566
+ operation,
567
+ await lspService.getOperationSupport(rawPath ? filePath : undefined),
198
568
  );
199
- break;
200
- case "prepareCallHierarchy":
201
- result = await lspService.prepareCallHierarchy(
569
+ if (supported === false) {
570
+ throw new Error(
571
+ "__UNSUPPORTED__ Active LSP server does not advertise support for workspaceSymbol",
572
+ );
573
+ }
574
+ if (!query || query.trim().length === 0) {
575
+ throw new Error("__BADINPUT__ query parameter required for workspaceSymbol");
576
+ }
577
+ if (rawPath) {
578
+ await openFileBestEffort(lspService, filePath);
579
+ }
580
+ try {
581
+ return await lspService.workspaceSymbol(
582
+ query ?? "",
583
+ rawPath ? filePath : undefined,
584
+ );
585
+ } catch (err) {
586
+ const msg = err instanceof Error ? err.message : String(err);
587
+ if (rawPath && /No Project/i.test(msg)) {
588
+ await openFileBestEffort(lspService, filePath);
589
+ await new Promise((resolve) => setTimeout(resolve, 120));
590
+ return lspService.workspaceSymbol(query ?? "", filePath);
591
+ }
592
+ throw err;
593
+ }
594
+ case "codeAction":
595
+ return lspService.codeAction(
202
596
  filePath,
203
597
  lspLine,
204
598
  lspChar,
599
+ lspEndLine,
600
+ lspEndChar,
205
601
  );
206
- break;
602
+ case "rename":
603
+ if (!newName || newName.trim().length === 0) {
604
+ throw new Error("__BADINPUT__ newName parameter required for rename");
605
+ }
606
+ return lspService.rename(filePath, lspLine, lspChar, newName);
607
+ case "implementation":
608
+ return lspService.implementation(filePath, lspLine, lspChar);
609
+ case "prepareCallHierarchy":
610
+ return lspService.prepareCallHierarchy(filePath, lspLine, lspChar);
207
611
  case "incomingCalls": {
208
612
  const callItem = (
209
613
  params as { callHierarchyItem?: LSPCallHierarchyItem }
210
614
  ).callHierarchyItem;
211
615
  if (!callItem) {
212
- return {
213
- content: [
214
- {
215
- type: "text" as const,
216
- text: "callHierarchyItem parameter required for incomingCalls",
217
- },
218
- ],
219
- isError: true,
220
- details: {},
221
- };
616
+ throw new Error(
617
+ "__BADINPUT__ callHierarchyItem parameter required for incomingCalls",
618
+ );
222
619
  }
223
- result = await lspService.incomingCalls(callItem);
224
- break;
620
+ return lspService.incomingCalls(callItem);
225
621
  }
226
622
  case "outgoingCalls": {
227
623
  const callItem = (
228
624
  params as { callHierarchyItem?: LSPCallHierarchyItem }
229
625
  ).callHierarchyItem;
230
626
  if (!callItem) {
231
- return {
232
- content: [
233
- {
234
- type: "text" as const,
235
- text: "callHierarchyItem parameter required for outgoingCalls",
236
- },
237
- ],
238
- isError: true,
239
- details: {},
240
- };
627
+ throw new Error(
628
+ "__BADINPUT__ callHierarchyItem parameter required for outgoingCalls",
629
+ );
241
630
  }
242
- result = await lspService.outgoingCalls(callItem);
243
- break;
631
+ return lspService.outgoingCalls(callItem);
244
632
  }
245
633
  default:
246
- result = [];
634
+ return [];
635
+ }
636
+ };
637
+
638
+ let result: unknown;
639
+ let usedDocumentSymbolFallback = false;
640
+ try {
641
+ result = await runOperation();
642
+ const isEmptyInitial =
643
+ !result || (Array.isArray(result) && result.length === 0);
644
+ const shouldRetryOnEmpty =
645
+ isEmptyInitial &&
646
+ needsFilePath &&
647
+ [
648
+ "definition",
649
+ "references",
650
+ "hover",
651
+ "signatureHelp",
652
+ "workspaceSymbol",
653
+ "codeAction",
654
+ "rename",
655
+ "implementation",
656
+ ].includes(operation);
657
+ if (shouldRetryOnEmpty) {
658
+ await openFileBestEffort(lspService, filePath, true);
659
+ result = await runOperation();
660
+ }
661
+
662
+ const stillEmpty =
663
+ !result || (Array.isArray(result) && result.length === 0);
664
+ if (
665
+ stillEmpty &&
666
+ needsFilePath &&
667
+ (operation === "definition" || operation === "workspaceSymbol")
668
+ ) {
669
+ const content = nodeFs.readFileSync(filePath, "utf-8");
670
+ const token =
671
+ operation === "workspaceSymbol"
672
+ ? (query?.trim() || undefined)
673
+ : line && character
674
+ ? tokenAtPosition(content, line, character)
675
+ : undefined;
676
+ if (token) {
677
+ const docSymbols = (await lspService.documentSymbol(filePath)) as SymbolNode[];
678
+ const locations = pickLocalSymbolLocation(docSymbols, token, filePath);
679
+ if (locations.length > 0) {
680
+ result = locations;
681
+ usedDocumentSymbolFallback = true;
682
+ }
683
+ }
247
684
  }
248
685
  } catch (err) {
249
- return {
686
+ const msg = err instanceof Error ? err.message : String(err);
687
+ if (msg.startsWith("__UNSUPPORTED__ ")) {
688
+ return finalize(
689
+ {
690
+ content: [{ type: "text" as const, text: msg.replace("__UNSUPPORTED__ ", "") }],
691
+ isError: true,
692
+ details: { operation, supported: false, emptyReason: "unsupported" },
693
+ },
694
+ { operation, filePath, failureKind: "unsupported", resultCount: 0 },
695
+ );
696
+ }
697
+ if (msg.startsWith("__BADINPUT__ ")) {
698
+ return finalize(
699
+ {
700
+ content: [{ type: "text" as const, text: msg.replace("__BADINPUT__ ", "") }],
701
+ isError: true,
702
+ details: {},
703
+ },
704
+ { operation, filePath, failureKind: "bad_input", resultCount: 0 },
705
+ );
706
+ }
707
+ return finalize(
708
+ {
250
709
  content: [
251
710
  {
252
711
  type: "text" as const,
@@ -255,21 +714,64 @@ export function createLspNavigationTool(
255
714
  ],
256
715
  isError: true,
257
716
  details: {},
258
- };
717
+ },
718
+ { operation, filePath, failureKind: "lsp_error", resultCount: 0 },
719
+ );
259
720
  }
260
721
 
261
722
  const isEmpty = !result || (Array.isArray(result) && result.length === 0);
262
- const output = isEmpty
723
+ let output = isEmpty
263
724
  ? `No results for ${operation} at ${path.basename(filePath)}${line ? `:${line}:${character}` : ""}`
264
725
  : JSON.stringify(result, null, 2);
726
+ if (isEmpty && operation === "workspaceSymbol" && !rawPath) {
727
+ output +=
728
+ "\nHint: provide filePath to scope workspaceSymbol to the active language server/root.";
729
+ }
730
+ if (usedDocumentSymbolFallback) {
731
+ output += "\nNote: served from documentSymbol fallback due to empty primary result.";
732
+ }
733
+ if (
734
+ operation === "references" &&
735
+ Array.isArray(result) &&
736
+ result.length <= 2
737
+ ) {
738
+ output +=
739
+ "\nHint: references from usage sites can be partial; retry from the symbol definition for broader cross-file results.";
740
+ }
741
+ const actionStats =
742
+ operation === "codeAction" && Array.isArray(result)
743
+ ? classifyCodeActions(result as Array<{ kind?: string }>)
744
+ : null;
745
+ if (operation === "codeAction" && actionStats) {
746
+ if (actionStats.quickfix === 0 && actionStats.refactor > 0) {
747
+ output +=
748
+ "\nNote: no diagnostic quick fixes returned; refactor-only actions available.";
749
+ }
750
+ }
265
751
 
266
- return {
267
- content: [{ type: "text" as const, text: output }],
268
- details: {
752
+ const resultCount = Array.isArray(result) ? result.length : result ? 1 : 0;
753
+ return finalize(
754
+ {
755
+ content: [{ type: "text" as const, text: output }],
756
+ details: {
757
+ operation,
758
+ supported,
759
+ emptyReason: isEmpty ? emptyReasonForOperation(operation) : undefined,
760
+ codeActionKinds: actionStats ?? undefined,
761
+ resultCount,
762
+ },
763
+ },
764
+ {
269
765
  operation,
270
- resultCount: Array.isArray(result) ? result.length : result ? 1 : 0,
766
+ filePath: rawPath ? filePath : "(workspace)",
767
+ failureKind: isEmpty
768
+ ? "empty_result"
769
+ : usedDocumentSymbolFallback
770
+ ? "fallback_success"
771
+ : "success",
772
+ resultCount,
271
773
  },
272
- };
774
+ );
273
775
  },
274
776
  };
275
777
  }