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
@@ -5,8 +5,137 @@
5
5
  */
6
6
  import * as nodeFs from "node:fs";
7
7
  import * as path from "node:path";
8
+ import { pathToFileURL } from "node:url";
8
9
  import { Type } from "@sinclair/typebox";
9
10
  import { getLSPService } from "../clients/lsp/index.js";
11
+ import { logLatency } from "../clients/latency-logger.js";
12
+ function operationSupportStatus(operation, support) {
13
+ if (!support)
14
+ return null;
15
+ if (operation === "definition")
16
+ return support.definition;
17
+ if (operation === "references")
18
+ return support.references;
19
+ if (operation === "hover")
20
+ return support.hover;
21
+ if (operation === "signatureHelp")
22
+ return support.signatureHelp;
23
+ if (operation === "documentSymbol")
24
+ return support.documentSymbol;
25
+ if (operation === "workspaceSymbol")
26
+ return support.workspaceSymbol;
27
+ if (operation === "codeAction")
28
+ return support.codeAction;
29
+ if (operation === "rename")
30
+ return support.rename;
31
+ if (operation === "implementation")
32
+ return support.implementation;
33
+ if (operation === "prepareCallHierarchy" ||
34
+ operation === "incomingCalls" ||
35
+ operation === "outgoingCalls")
36
+ return support.callHierarchy;
37
+ return null;
38
+ }
39
+ function emptyReasonForOperation(operation) {
40
+ if (operation === "signatureHelp")
41
+ return "position-sensitive-or-no-signature";
42
+ if (operation === "codeAction")
43
+ return "no-applicable-actions";
44
+ if (operation === "rename")
45
+ return "no-rename-edits-or-symbol-not-renamable";
46
+ if (operation === "workspaceSymbol")
47
+ return "no-matching-symbols-or-server-index-unavailable";
48
+ if (operation === "incomingCalls" || operation === "outgoingCalls")
49
+ return "no-call-hierarchy-results";
50
+ return "no-results";
51
+ }
52
+ function tokenAtPosition(content, line1, char1) {
53
+ const lines = content.split(/\r?\n/);
54
+ const line = lines[line1 - 1];
55
+ if (!line)
56
+ return undefined;
57
+ const chars = [...line];
58
+ const idx = Math.max(0, Math.min(chars.length - 1, char1 - 1));
59
+ const isWord = (ch) => !!ch && /[A-Za-z0-9_?!]/.test(ch);
60
+ let left = idx;
61
+ let right = idx;
62
+ if (!isWord(chars[idx]) && isWord(chars[idx + 1])) {
63
+ left = idx + 1;
64
+ right = idx + 1;
65
+ }
66
+ while (left > 0 && isWord(chars[left - 1]))
67
+ left -= 1;
68
+ while (right < chars.length - 1 && isWord(chars[right + 1]))
69
+ right += 1;
70
+ const token = chars.slice(left, right + 1).join("").trim();
71
+ return token.length > 0 ? token : undefined;
72
+ }
73
+ function flattenSymbols(symbols) {
74
+ const all = [];
75
+ for (const symbol of symbols) {
76
+ all.push(symbol);
77
+ if (symbol.children && symbol.children.length > 0) {
78
+ all.push(...flattenSymbols(symbol.children));
79
+ }
80
+ }
81
+ return all;
82
+ }
83
+ function pickLocalSymbolLocation(symbols, token, filePath) {
84
+ const flat = flattenSymbols(symbols).filter((symbol) => symbol.name === token);
85
+ if (flat.length === 0)
86
+ return [];
87
+ const uri = pathToFileURL(filePath).href;
88
+ return flat
89
+ .map((symbol) => {
90
+ if (symbol.location?.uri && symbol.location.range) {
91
+ return { uri: symbol.location.uri, range: symbol.location.range };
92
+ }
93
+ if (symbol.range) {
94
+ return { uri, range: symbol.range };
95
+ }
96
+ return undefined;
97
+ })
98
+ .filter((entry) => Boolean(entry));
99
+ }
100
+ function classifyCodeActions(actions) {
101
+ if (!actions || actions.length === 0)
102
+ return { quickfix: 0, refactor: 0, other: 0 };
103
+ let quickfix = 0;
104
+ let refactor = 0;
105
+ let other = 0;
106
+ for (const action of actions) {
107
+ const kind = action.kind ?? "";
108
+ if (kind.startsWith("quickfix"))
109
+ quickfix += 1;
110
+ else if (kind.startsWith("refactor"))
111
+ refactor += 1;
112
+ else
113
+ other += 1;
114
+ }
115
+ return { quickfix, refactor, other };
116
+ }
117
+ async function openFileBestEffort(lspService, filePath, waitForDiagnostics = false) {
118
+ let fileContent;
119
+ try {
120
+ fileContent = nodeFs.readFileSync(filePath, "utf-8");
121
+ }
122
+ catch {
123
+ return;
124
+ }
125
+ if (!fileContent)
126
+ return;
127
+ try {
128
+ if (typeof lspService.touchFile === "function") {
129
+ await lspService.touchFile(filePath, fileContent, waitForDiagnostics);
130
+ }
131
+ else {
132
+ await lspService.openFile(filePath, fileContent);
133
+ }
134
+ }
135
+ catch {
136
+ /* LSP server may not be ready yet — proceed anyway */
137
+ }
138
+ }
10
139
  export function createLspNavigationTool(getFlag) {
11
140
  return {
12
141
  name: "lsp_navigation",
@@ -16,12 +145,16 @@ export function createLspNavigationTool(getFlag) {
16
145
  "- definition: Jump to where a symbol is defined\n" +
17
146
  "- references: Find all usages of a symbol\n" +
18
147
  "- hover: Get type/doc info at a position\n" +
148
+ "- signatureHelp: Show callable signatures at cursor\n" +
19
149
  "- documentSymbol: List all symbols (functions/classes/vars) in a file\n" +
20
- "- workspaceSymbol: Search symbols across the whole project\n" +
150
+ "- workspaceSymbol: Search symbols across the whole project (best with filePath context)\n" +
151
+ "- codeAction: Find available quick fixes/refactors at a range\n" +
152
+ "- rename: Compute workspace edits for renaming a symbol\n" +
21
153
  "- implementation: Jump to interface implementations\n" +
22
154
  "- prepareCallHierarchy: Get callable item at position (for incoming/outgoing)\n" +
23
155
  "- incomingCalls: Find all functions/methods that CALL this function\n" +
24
- "- outgoingCalls: Find all functions/methods CALLED by this function\n\n" +
156
+ "- outgoingCalls: Find all functions/methods CALLED by this function\n" +
157
+ "- workspaceDiagnostics: List all diagnostics tracked by active LSP clients\n\n" +
25
158
  "Line and character are 1-based (as shown in editors).",
26
159
  promptSnippet: "Use lsp_navigation to find definitions, references, and hover info via LSP",
27
160
  parameters: Type.Object({
@@ -29,24 +162,37 @@ export function createLspNavigationTool(getFlag) {
29
162
  Type.Literal("definition"),
30
163
  Type.Literal("references"),
31
164
  Type.Literal("hover"),
165
+ Type.Literal("signatureHelp"),
32
166
  Type.Literal("documentSymbol"),
33
167
  Type.Literal("workspaceSymbol"),
168
+ Type.Literal("codeAction"),
169
+ Type.Literal("rename"),
34
170
  Type.Literal("implementation"),
35
171
  Type.Literal("prepareCallHierarchy"),
36
172
  Type.Literal("incomingCalls"),
37
173
  Type.Literal("outgoingCalls"),
174
+ Type.Literal("workspaceDiagnostics"),
38
175
  ], { description: "LSP operation to perform" }),
39
- filePath: Type.String({
40
- description: "Absolute or relative path to the file",
41
- }),
176
+ filePath: Type.Optional(Type.String({
177
+ description: "Absolute or relative file path. Required for file-scoped operations; optional for workspaceSymbol/workspaceDiagnostics.",
178
+ })),
42
179
  line: Type.Optional(Type.Number({
43
180
  description: "Line number (1-based). Required for definition/references/hover/implementation",
44
181
  })),
45
182
  character: Type.Optional(Type.Number({
46
183
  description: "Character offset (1-based). Required for definition/references/hover/implementation",
47
184
  })),
185
+ endLine: Type.Optional(Type.Number({
186
+ description: "End line (1-based). Optional; used by codeAction range.",
187
+ })),
188
+ endCharacter: Type.Optional(Type.Number({
189
+ description: "End character (1-based). Optional; used by codeAction range.",
190
+ })),
191
+ newName: Type.Optional(Type.String({
192
+ description: "Required for rename operation.",
193
+ })),
48
194
  query: Type.Optional(Type.String({
49
- description: "Symbol name to search. Used by workspaceSymbol",
195
+ description: "Symbol name to search. Used by workspaceSymbol (best with filePath for active project context).",
50
196
  })),
51
197
  callHierarchyItem: Type.Optional(Type.Object({
52
198
  name: Type.String(),
@@ -77,8 +223,34 @@ export function createLspNavigationTool(getFlag) {
77
223
  })),
78
224
  }),
79
225
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
80
- if (!getFlag("lens-lsp") || getFlag("no-lsp")) {
226
+ const startedAt = Date.now();
227
+ let supported = null;
228
+ let diagnosticsMode = "unknown";
229
+ const finalize = (payload, meta) => {
230
+ const normalizedFilePath = meta.filePath.replace(/\\/g, "/");
231
+ logLatency({
232
+ type: "phase",
233
+ phase: "lsp_navigation_result",
234
+ filePath: normalizedFilePath,
235
+ durationMs: Date.now() - startedAt,
236
+ metadata: {
237
+ operation: meta.operation,
238
+ failureKind: meta.failureKind,
239
+ resultCount: meta.resultCount,
240
+ supported,
241
+ diagnosticsMode,
242
+ },
243
+ });
81
244
  return {
245
+ ...payload,
246
+ details: {
247
+ ...(payload.details ?? {}),
248
+ failureKind: meta.failureKind,
249
+ },
250
+ };
251
+ };
252
+ if (!getFlag("lens-lsp") || getFlag("no-lsp")) {
253
+ return finalize({
82
254
  content: [
83
255
  {
84
256
  type: "text",
@@ -86,110 +258,290 @@ export function createLspNavigationTool(getFlag) {
86
258
  },
87
259
  ],
88
260
  isError: true,
89
- details: {},
90
- };
261
+ }, {
262
+ operation: "precheck",
263
+ filePath: "(workspace)",
264
+ failureKind: "lsp_disabled",
265
+ resultCount: 0,
266
+ });
91
267
  }
92
- const { operation, filePath: rawPath, line, character, query, } = params;
93
- const filePath = path.isAbsolute(rawPath)
94
- ? rawPath
95
- : path.resolve(ctx.cwd || ".", rawPath);
96
- const lspService = getLSPService();
97
- const hasLSP = await lspService.hasLSP(filePath);
98
- if (!hasLSP) {
99
- return {
268
+ const { operation, filePath: rawPath, line, character, endLine, endCharacter, newName, query, } = params;
269
+ const isCallHierarchyTraversal = operation === "incomingCalls" || operation === "outgoingCalls";
270
+ const needsFilePath = operation !== "workspaceDiagnostics" &&
271
+ operation !== "workspaceSymbol" &&
272
+ !isCallHierarchyTraversal;
273
+ if (needsFilePath && (!rawPath || rawPath.trim().length === 0)) {
274
+ return finalize({
100
275
  content: [
101
276
  {
102
277
  type: "text",
103
- text: `No LSP server available for ${path.basename(filePath)}. Check that the language server is installed.`,
278
+ text: `filePath is required for ${operation}`,
104
279
  },
105
280
  ],
106
281
  isError: true,
107
- details: {},
108
- };
282
+ }, {
283
+ operation,
284
+ filePath: "(workspace)",
285
+ failureKind: "missing_file_path",
286
+ resultCount: 0,
287
+ });
109
288
  }
110
- // Ensure file is open in LSP before querying
111
- let fileContent;
112
- try {
113
- fileContent = nodeFs.readFileSync(filePath, "utf-8");
289
+ const filePath = rawPath
290
+ ? path.isAbsolute(rawPath)
291
+ ? rawPath
292
+ : path.resolve(ctx.cwd || ".", rawPath)
293
+ : "";
294
+ const lspService = getLSPService();
295
+ if (operation === "workspaceDiagnostics") {
296
+ const wsDiagSupport = await lspService.getWorkspaceDiagnosticsSupport(rawPath ? filePath : undefined);
297
+ diagnosticsMode = wsDiagSupport?.mode ?? "unknown";
298
+ if (rawPath) {
299
+ const hasLSP = await lspService.hasLSP(filePath);
300
+ if (!hasLSP) {
301
+ return finalize({
302
+ content: [
303
+ {
304
+ type: "text",
305
+ text: `No LSP server available for ${path.basename(filePath)}. Check that the language server is installed.`,
306
+ },
307
+ ],
308
+ isError: true,
309
+ }, {
310
+ operation,
311
+ filePath,
312
+ failureKind: "no_server",
313
+ resultCount: 0,
314
+ });
315
+ }
316
+ await openFileBestEffort(lspService, filePath, true);
317
+ const diagnostics = await lspService.getDiagnostics(filePath);
318
+ const result = [
319
+ {
320
+ filePath,
321
+ diagnostics,
322
+ count: diagnostics.length,
323
+ },
324
+ ];
325
+ const note = diagnosticsMode === "pull"
326
+ ? "Note: filePath mode requests pull diagnostics for this file and returns the aggregated result."
327
+ : diagnosticsMode === "push-only"
328
+ ? "Note: server is push-only; result depends on published diagnostics for this file."
329
+ : "Note: workspace diagnostics mode unknown (no active capability snapshot).";
330
+ const resultCount = diagnostics.length;
331
+ return finalize({
332
+ content: [
333
+ {
334
+ type: "text",
335
+ text: `${note}\n${JSON.stringify(result, null, 2)}`,
336
+ },
337
+ ],
338
+ details: {
339
+ operation,
340
+ resultCount,
341
+ diagnosticsMode,
342
+ coverage: "requested-file",
343
+ },
344
+ }, {
345
+ operation,
346
+ filePath,
347
+ failureKind: resultCount === 0 ? "empty_result" : "success",
348
+ resultCount,
349
+ });
350
+ }
351
+ const allDiagnostics = await lspService.getAllDiagnostics();
352
+ const result = Array.from(allDiagnostics.entries()).map(([trackedFile, diags]) => ({
353
+ filePath: trackedFile,
354
+ diagnostics: diags,
355
+ count: diags.length,
356
+ }));
357
+ const note = diagnosticsMode === "push-only"
358
+ ? "Note: push-only tracked diagnostics snapshot (not full workspace pull diagnostics)."
359
+ : diagnosticsMode === "pull"
360
+ ? "Note: tracked diagnostics snapshot from active clients. Provide filePath to force file-level diagnostics collection."
361
+ : "Note: workspace diagnostics mode unknown (no active capability snapshot).";
362
+ return finalize({
363
+ content: [
364
+ {
365
+ type: "text",
366
+ text: `${note}\n${JSON.stringify(result, null, 2)}`,
367
+ },
368
+ ],
369
+ details: {
370
+ operation,
371
+ resultCount: result.length,
372
+ diagnosticsMode,
373
+ coverage: "tracked-open-files",
374
+ },
375
+ }, {
376
+ operation,
377
+ filePath: rawPath ? filePath : "(workspace)",
378
+ failureKind: diagnosticsMode === "push-only"
379
+ ? "tracked_snapshot"
380
+ : "success",
381
+ resultCount: result.length,
382
+ });
114
383
  }
115
- catch {
116
- /* ignore */
384
+ const hasLSP = filePath ? await lspService.hasLSP(filePath) : false;
385
+ if (needsFilePath && !hasLSP) {
386
+ return finalize({
387
+ content: [
388
+ {
389
+ type: "text",
390
+ text: `No LSP server available for ${path.basename(filePath)}. Check that the language server is installed.`,
391
+ },
392
+ ],
393
+ isError: true,
394
+ }, {
395
+ operation,
396
+ filePath,
397
+ failureKind: "no_server",
398
+ resultCount: 0,
399
+ });
117
400
  }
118
- if (fileContent) {
119
- try {
120
- await lspService.openFile(filePath, fileContent);
121
- }
122
- catch {
123
- /* LSP server may not be ready yet — proceed anyway */
401
+ if (needsFilePath) {
402
+ const support = await lspService.getOperationSupport(filePath);
403
+ supported = operationSupportStatus(operation, support);
404
+ if (supported === false) {
405
+ return finalize({
406
+ content: [
407
+ {
408
+ type: "text",
409
+ text: `LSP server for ${path.basename(filePath)} does not advertise support for ${operation}`,
410
+ },
411
+ ],
412
+ isError: true,
413
+ details: { operation, supported: false, emptyReason: "unsupported" },
414
+ }, { operation, filePath, failureKind: "unsupported", resultCount: 0 });
124
415
  }
416
+ await openFileBestEffort(lspService, filePath);
125
417
  }
126
418
  // Convert 1-based editor coords to 0-based LSP coords
127
419
  const lspLine = (line ?? 1) - 1;
128
420
  const lspChar = (character ?? 1) - 1;
129
- let result;
130
- try {
421
+ const lspEndLine = (endLine ?? line ?? 1) - 1;
422
+ const lspEndChar = (endCharacter ?? character ?? 1) - 1;
423
+ const runOperation = async () => {
131
424
  switch (operation) {
132
425
  case "definition":
133
- result = await lspService.definition(filePath, lspLine, lspChar);
134
- break;
426
+ return lspService.definition(filePath, lspLine, lspChar);
135
427
  case "references":
136
- result = await lspService.references(filePath, lspLine, lspChar);
137
- break;
428
+ return lspService.references(filePath, lspLine, lspChar);
138
429
  case "hover":
139
- result = await lspService.hover(filePath, lspLine, lspChar);
140
- break;
430
+ return lspService.hover(filePath, lspLine, lspChar);
431
+ case "signatureHelp":
432
+ return lspService.signatureHelp(filePath, lspLine, lspChar);
141
433
  case "documentSymbol":
142
- result = await lspService.documentSymbol(filePath);
143
- break;
434
+ return lspService.documentSymbol(filePath);
144
435
  case "workspaceSymbol":
145
- result = await lspService.workspaceSymbol(query ?? "");
146
- break;
436
+ supported = operationSupportStatus(operation, await lspService.getOperationSupport(rawPath ? filePath : undefined));
437
+ if (supported === false) {
438
+ throw new Error("__UNSUPPORTED__ Active LSP server does not advertise support for workspaceSymbol");
439
+ }
440
+ if (!query || query.trim().length === 0) {
441
+ throw new Error("__BADINPUT__ query parameter required for workspaceSymbol");
442
+ }
443
+ if (rawPath) {
444
+ await openFileBestEffort(lspService, filePath);
445
+ }
446
+ try {
447
+ return await lspService.workspaceSymbol(query ?? "", rawPath ? filePath : undefined);
448
+ }
449
+ catch (err) {
450
+ const msg = err instanceof Error ? err.message : String(err);
451
+ if (rawPath && /No Project/i.test(msg)) {
452
+ await openFileBestEffort(lspService, filePath);
453
+ await new Promise((resolve) => setTimeout(resolve, 120));
454
+ return lspService.workspaceSymbol(query ?? "", filePath);
455
+ }
456
+ throw err;
457
+ }
458
+ case "codeAction":
459
+ return lspService.codeAction(filePath, lspLine, lspChar, lspEndLine, lspEndChar);
460
+ case "rename":
461
+ if (!newName || newName.trim().length === 0) {
462
+ throw new Error("__BADINPUT__ newName parameter required for rename");
463
+ }
464
+ return lspService.rename(filePath, lspLine, lspChar, newName);
147
465
  case "implementation":
148
- result = await lspService.implementation(filePath, lspLine, lspChar);
149
- break;
466
+ return lspService.implementation(filePath, lspLine, lspChar);
150
467
  case "prepareCallHierarchy":
151
- result = await lspService.prepareCallHierarchy(filePath, lspLine, lspChar);
152
- break;
468
+ return lspService.prepareCallHierarchy(filePath, lspLine, lspChar);
153
469
  case "incomingCalls": {
154
470
  const callItem = params.callHierarchyItem;
155
471
  if (!callItem) {
156
- return {
157
- content: [
158
- {
159
- type: "text",
160
- text: "callHierarchyItem parameter required for incomingCalls",
161
- },
162
- ],
163
- isError: true,
164
- details: {},
165
- };
472
+ throw new Error("__BADINPUT__ callHierarchyItem parameter required for incomingCalls");
166
473
  }
167
- result = await lspService.incomingCalls(callItem);
168
- break;
474
+ return lspService.incomingCalls(callItem);
169
475
  }
170
476
  case "outgoingCalls": {
171
477
  const callItem = params.callHierarchyItem;
172
478
  if (!callItem) {
173
- return {
174
- content: [
175
- {
176
- type: "text",
177
- text: "callHierarchyItem parameter required for outgoingCalls",
178
- },
179
- ],
180
- isError: true,
181
- details: {},
182
- };
479
+ throw new Error("__BADINPUT__ callHierarchyItem parameter required for outgoingCalls");
183
480
  }
184
- result = await lspService.outgoingCalls(callItem);
185
- break;
481
+ return lspService.outgoingCalls(callItem);
186
482
  }
187
483
  default:
188
- result = [];
484
+ return [];
485
+ }
486
+ };
487
+ let result;
488
+ let usedDocumentSymbolFallback = false;
489
+ try {
490
+ result = await runOperation();
491
+ const isEmptyInitial = !result || (Array.isArray(result) && result.length === 0);
492
+ const shouldRetryOnEmpty = isEmptyInitial &&
493
+ needsFilePath &&
494
+ [
495
+ "definition",
496
+ "references",
497
+ "hover",
498
+ "signatureHelp",
499
+ "workspaceSymbol",
500
+ "codeAction",
501
+ "rename",
502
+ "implementation",
503
+ ].includes(operation);
504
+ if (shouldRetryOnEmpty) {
505
+ await openFileBestEffort(lspService, filePath, true);
506
+ result = await runOperation();
507
+ }
508
+ const stillEmpty = !result || (Array.isArray(result) && result.length === 0);
509
+ if (stillEmpty &&
510
+ needsFilePath &&
511
+ (operation === "definition" || operation === "workspaceSymbol")) {
512
+ const content = nodeFs.readFileSync(filePath, "utf-8");
513
+ const token = operation === "workspaceSymbol"
514
+ ? (query?.trim() || undefined)
515
+ : line && character
516
+ ? tokenAtPosition(content, line, character)
517
+ : undefined;
518
+ if (token) {
519
+ const docSymbols = (await lspService.documentSymbol(filePath));
520
+ const locations = pickLocalSymbolLocation(docSymbols, token, filePath);
521
+ if (locations.length > 0) {
522
+ result = locations;
523
+ usedDocumentSymbolFallback = true;
524
+ }
525
+ }
189
526
  }
190
527
  }
191
528
  catch (err) {
192
- return {
529
+ const msg = err instanceof Error ? err.message : String(err);
530
+ if (msg.startsWith("__UNSUPPORTED__ ")) {
531
+ return finalize({
532
+ content: [{ type: "text", text: msg.replace("__UNSUPPORTED__ ", "") }],
533
+ isError: true,
534
+ details: { operation, supported: false, emptyReason: "unsupported" },
535
+ }, { operation, filePath, failureKind: "unsupported", resultCount: 0 });
536
+ }
537
+ if (msg.startsWith("__BADINPUT__ ")) {
538
+ return finalize({
539
+ content: [{ type: "text", text: msg.replace("__BADINPUT__ ", "") }],
540
+ isError: true,
541
+ details: {},
542
+ }, { operation, filePath, failureKind: "bad_input", resultCount: 0 });
543
+ }
544
+ return finalize({
193
545
  content: [
194
546
  {
195
547
  type: "text",
@@ -198,19 +550,54 @@ export function createLspNavigationTool(getFlag) {
198
550
  ],
199
551
  isError: true,
200
552
  details: {},
201
- };
553
+ }, { operation, filePath, failureKind: "lsp_error", resultCount: 0 });
202
554
  }
203
555
  const isEmpty = !result || (Array.isArray(result) && result.length === 0);
204
- const output = isEmpty
556
+ let output = isEmpty
205
557
  ? `No results for ${operation} at ${path.basename(filePath)}${line ? `:${line}:${character}` : ""}`
206
558
  : JSON.stringify(result, null, 2);
207
- return {
559
+ if (isEmpty && operation === "workspaceSymbol" && !rawPath) {
560
+ output +=
561
+ "\nHint: provide filePath to scope workspaceSymbol to the active language server/root.";
562
+ }
563
+ if (usedDocumentSymbolFallback) {
564
+ output += "\nNote: served from documentSymbol fallback due to empty primary result.";
565
+ }
566
+ if (operation === "references" &&
567
+ Array.isArray(result) &&
568
+ result.length <= 2) {
569
+ output +=
570
+ "\nHint: references from usage sites can be partial; retry from the symbol definition for broader cross-file results.";
571
+ }
572
+ const actionStats = operation === "codeAction" && Array.isArray(result)
573
+ ? classifyCodeActions(result)
574
+ : null;
575
+ if (operation === "codeAction" && actionStats) {
576
+ if (actionStats.quickfix === 0 && actionStats.refactor > 0) {
577
+ output +=
578
+ "\nNote: no diagnostic quick fixes returned; refactor-only actions available.";
579
+ }
580
+ }
581
+ const resultCount = Array.isArray(result) ? result.length : result ? 1 : 0;
582
+ return finalize({
208
583
  content: [{ type: "text", text: output }],
209
584
  details: {
210
585
  operation,
211
- resultCount: Array.isArray(result) ? result.length : result ? 1 : 0,
586
+ supported,
587
+ emptyReason: isEmpty ? emptyReasonForOperation(operation) : undefined,
588
+ codeActionKinds: actionStats ?? undefined,
589
+ resultCount,
212
590
  },
213
- };
591
+ }, {
592
+ operation,
593
+ filePath: rawPath ? filePath : "(workspace)",
594
+ failureKind: isEmpty
595
+ ? "empty_result"
596
+ : usedDocumentSymbolFallback
597
+ ? "fallback_success"
598
+ : "success",
599
+ resultCount,
600
+ });
214
601
  },
215
602
  };
216
603
  }