pi-lens 3.8.21 → 3.8.22

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 (47) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/README.md +2 -0
  3. package/clients/dispatch/runners/lsp.ts +58 -3
  4. package/clients/dispatch/runners/tree-sitter.ts +467 -0
  5. package/clients/lsp/client.ts +229 -3
  6. package/clients/lsp/index.ts +111 -1
  7. package/clients/pipeline.ts +2 -2
  8. package/clients/runtime-session.ts +43 -5
  9. package/clients/tree-sitter-client.ts +162 -0
  10. package/clients/tree-sitter-logger.ts +47 -0
  11. package/clients/tree-sitter-query-loader.ts +13 -2
  12. package/package.json +3 -1
  13. package/rules/rule-catalog.json +64 -0
  14. package/rules/tree-sitter-queries/go/go-bare-error.yml +19 -7
  15. package/rules/tree-sitter-queries/go/go-command-injection.yml +55 -0
  16. package/rules/tree-sitter-queries/go/go-direct-panic.yml +45 -0
  17. package/rules/tree-sitter-queries/go/go-empty-if-err.yml +47 -0
  18. package/rules/tree-sitter-queries/go/go-goroutine-loop-capture.yml +49 -0
  19. package/rules/tree-sitter-queries/go/go-ignored-call-result.yml +51 -0
  20. package/rules/tree-sitter-queries/go/go-insecure-random.yml +51 -0
  21. package/rules/tree-sitter-queries/go/go-log-fatal.yml +49 -0
  22. package/rules/tree-sitter-queries/go/go-path-traversal.yml +51 -0
  23. package/rules/tree-sitter-queries/go/go-shared-map-write-goroutine.yml +54 -0
  24. package/rules/tree-sitter-queries/go/go-sql-injection.yml +55 -0
  25. package/rules/tree-sitter-queries/go/go-weak-hash.yml +51 -0
  26. package/rules/tree-sitter-queries/python/python-command-injection.yml +63 -0
  27. package/rules/tree-sitter-queries/python/python-insecure-deserialization.yml +48 -0
  28. package/rules/tree-sitter-queries/python/python-insecure-random.yml +51 -0
  29. package/rules/tree-sitter-queries/python/python-path-traversal.yml +55 -0
  30. package/rules/tree-sitter-queries/python/python-sql-injection.yml +47 -0
  31. package/rules/tree-sitter-queries/python/python-ssrf.yml +50 -0
  32. package/rules/tree-sitter-queries/python/python-thread-global-write.yml +58 -0
  33. package/rules/tree-sitter-queries/python/python-weak-hash.yml +51 -0
  34. package/rules/tree-sitter-queries/ruby/ruby-command-injection.yml +56 -0
  35. package/rules/tree-sitter-queries/ruby/ruby-insecure-deserialization.yml +47 -0
  36. package/rules/tree-sitter-queries/ruby/ruby-insecure-random.yml +54 -0
  37. package/rules/tree-sitter-queries/ruby/ruby-weak-hash.yml +50 -0
  38. package/rules/tree-sitter-queries/rust/rust-lock-held-across-await.yml +59 -0
  39. package/rules/tree-sitter-queries/typescript/ts-command-injection.yml +60 -0
  40. package/rules/tree-sitter-queries/typescript/ts-detached-async-call.yml +56 -0
  41. package/rules/tree-sitter-queries/typescript/ts-insecure-random.yml +54 -0
  42. package/rules/tree-sitter-queries/typescript/ts-ssrf.yml +53 -0
  43. package/rules/tree-sitter-queries/typescript/ts-weak-hash.yml +54 -0
  44. package/scripts/validate-rule-catalog.mjs +227 -0
  45. package/skills/lsp-navigation/SKILL.md +15 -3
  46. package/tools/lsp-navigation.js +259 -28
  47. package/tools/lsp-navigation.ts +294 -29
@@ -0,0 +1,227 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+
4
+ const STRICT = process.argv.includes("--strict");
5
+
6
+ const root = process.cwd();
7
+ const catalogPath = path.join(root, "rules", "rule-catalog.json");
8
+ const treeSitterRoot = path.join(root, "rules", "tree-sitter-queries");
9
+ const astGrepRoot = path.join(root, "rules", "ast-grep-rules", "rules");
10
+
11
+ const TRACKED_AST_GREP_IDS = new Set([
12
+ "no-sql-in-code",
13
+ "no-sql-in-code-js",
14
+ "no-open-redirect",
15
+ "no-open-redirect-js",
16
+ "no-javascript-url",
17
+ "no-javascript-url-js",
18
+ "no-insecure-randomness",
19
+ "no-insecure-randomness-js",
20
+ "no-implied-eval",
21
+ "no-implied-eval-js",
22
+ "no-hardcoded-secrets",
23
+ "no-hardcoded-secrets-js",
24
+ "no-global-eval-js",
25
+ "jwt-no-verify",
26
+ "jwt-no-verify-js",
27
+ "toctou",
28
+ "toctou-js",
29
+ "missed-concurrency",
30
+ "missed-concurrency-js",
31
+ "no-await-in-loop",
32
+ "no-await-in-loop-js",
33
+ ]);
34
+
35
+ function walkYaml(dir, acc = []) {
36
+ if (!fs.existsSync(dir)) return acc;
37
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
38
+ const full = path.join(dir, entry.name);
39
+ if (entry.isDirectory()) {
40
+ walkYaml(full, acc);
41
+ } else if (entry.name.endsWith(".yml")) {
42
+ acc.push(full);
43
+ }
44
+ }
45
+ return acc;
46
+ }
47
+
48
+ function readYamlScalar(text, key) {
49
+ const match = text.match(new RegExp(`^${key}:\\s*(.+)$`, "m"));
50
+ if (!match) return undefined;
51
+ return match[1].trim().replace(/^['\"]|['\"]$/g, "");
52
+ }
53
+
54
+ function collectTreeSitterSecurityConcurrency() {
55
+ const files = walkYaml(treeSitterRoot);
56
+ const rules = [];
57
+ for (const file of files) {
58
+ const text = fs.readFileSync(file, "utf8");
59
+ const id = readYamlScalar(text, "id");
60
+ const category = readYamlScalar(text, "category");
61
+ if (!id || !category) continue;
62
+ if (category !== "security" && category !== "concurrency") continue;
63
+ const language = readYamlScalar(text, "language") ?? path.basename(path.dirname(file));
64
+ rules.push({
65
+ id,
66
+ category,
67
+ language,
68
+ file: path.relative(root, file).replaceAll("\\", "/"),
69
+ });
70
+ }
71
+ return rules;
72
+ }
73
+
74
+ function collectAstGrepTrackedRules() {
75
+ const files = walkYaml(astGrepRoot);
76
+ const rules = [];
77
+ for (const file of files) {
78
+ const text = fs.readFileSync(file, "utf8");
79
+ const id = readYamlScalar(text, "id");
80
+ if (!id || !TRACKED_AST_GREP_IDS.has(id)) continue;
81
+ const language = readYamlScalar(text, "language") ?? "unknown";
82
+ rules.push({
83
+ id,
84
+ language,
85
+ file: path.relative(root, file).replaceAll("\\", "/"),
86
+ });
87
+ }
88
+ return rules;
89
+ }
90
+
91
+ function validateCatalog(catalog) {
92
+ const required = [
93
+ "rule_id",
94
+ "engine",
95
+ "language",
96
+ "family",
97
+ "scope",
98
+ "canonical_concept",
99
+ "severity_default",
100
+ "confidence",
101
+ "status",
102
+ ];
103
+ const validEngine = new Set(["tree-sitter", "ast-grep", "architect"]);
104
+ const validSeverity = new Set(["error", "warning", "info", "review"]);
105
+ const validConfidence = new Set(["low", "medium", "high"]);
106
+ const validStatus = new Set(["experimental", "active", "deprecated"]);
107
+
108
+ const errors = [];
109
+ const warnings = [];
110
+ const byRuleId = new Map();
111
+ const activeByConceptScopeLang = new Map();
112
+
113
+ for (const [index, entry] of catalog.entries.entries()) {
114
+ for (const field of required) {
115
+ if (!entry[field]) {
116
+ errors.push(`entries[${index}] missing required field '${field}'`);
117
+ }
118
+ }
119
+
120
+ if (entry.engine && !validEngine.has(entry.engine)) {
121
+ errors.push(`entries[${index}] has invalid engine '${entry.engine}'`);
122
+ }
123
+ if (entry.severity_default && !validSeverity.has(entry.severity_default)) {
124
+ errors.push(
125
+ `entries[${index}] has invalid severity_default '${entry.severity_default}'`,
126
+ );
127
+ }
128
+ if (entry.confidence && !validConfidence.has(entry.confidence)) {
129
+ errors.push(`entries[${index}] has invalid confidence '${entry.confidence}'`);
130
+ }
131
+ if (entry.status && !validStatus.has(entry.status)) {
132
+ errors.push(`entries[${index}] has invalid status '${entry.status}'`);
133
+ }
134
+
135
+ if (entry.rule_id) {
136
+ if (byRuleId.has(entry.rule_id)) {
137
+ errors.push(`duplicate rule_id '${entry.rule_id}' in rule catalog`);
138
+ }
139
+ byRuleId.set(entry.rule_id, entry);
140
+ }
141
+
142
+ if (entry.status === "active" && !entry.allow_overlap) {
143
+ const key = `${entry.language}::${entry.scope}::${entry.canonical_concept}`;
144
+ const prev = activeByConceptScopeLang.get(key);
145
+ if (prev) {
146
+ warnings.push(
147
+ `possible overlap for ${key}: '${prev.rule_id}' and '${entry.rule_id}' (consider allow_overlap or concept split)`,
148
+ );
149
+ } else {
150
+ activeByConceptScopeLang.set(key, entry);
151
+ }
152
+ }
153
+ }
154
+
155
+ return { errors, warnings, byRuleId };
156
+ }
157
+
158
+ if (!fs.existsSync(catalogPath)) {
159
+ console.error(`[rule-catalog] missing ${catalogPath}`);
160
+ process.exit(1);
161
+ }
162
+
163
+ const catalogRaw = fs.readFileSync(catalogPath, "utf8");
164
+ let parsed;
165
+ try {
166
+ parsed = JSON.parse(catalogRaw);
167
+ } catch (error) {
168
+ console.error(`[rule-catalog] invalid JSON: ${error}`);
169
+ process.exit(1);
170
+ }
171
+
172
+ const entries = Array.isArray(parsed.entries) ? parsed.entries : [];
173
+ const treeRules = collectTreeSitterSecurityConcurrency();
174
+ const astRules = collectAstGrepTrackedRules();
175
+ const { errors, warnings, byRuleId } = validateCatalog({ entries });
176
+
177
+ for (const rule of treeRules) {
178
+ if (!byRuleId.has(rule.id)) {
179
+ warnings.push(
180
+ `missing catalog entry for ${rule.id} (${rule.category}, ${rule.language}) at ${rule.file}`,
181
+ );
182
+ }
183
+ }
184
+
185
+ for (const entry of entries) {
186
+ if (entry.engine !== "tree-sitter") continue;
187
+ const exists = treeRules.some((rule) => rule.id === entry.rule_id);
188
+ if (!exists) {
189
+ warnings.push(
190
+ `catalog entry '${entry.rule_id}' has no matching tree-sitter rule file (maybe removed or renamed)`,
191
+ );
192
+ }
193
+ }
194
+
195
+ for (const rule of astRules) {
196
+ if (!byRuleId.has(rule.id)) {
197
+ warnings.push(
198
+ `missing catalog entry for ${rule.id} (ast-grep, ${rule.language}) at ${rule.file}`,
199
+ );
200
+ }
201
+ }
202
+
203
+ for (const entry of entries) {
204
+ if (entry.engine !== "ast-grep") continue;
205
+ const exists = astRules.some((rule) => rule.id === entry.rule_id);
206
+ if (!exists) {
207
+ warnings.push(
208
+ `catalog entry '${entry.rule_id}' has no matching tracked ast-grep rule file`,
209
+ );
210
+ }
211
+ }
212
+
213
+ const report = {
214
+ catalogEntries: entries.length,
215
+ trackedTreeSitterSecurityConcurrencyRules: treeRules.length,
216
+ trackedAstGrepRules: astRules.length,
217
+ errors: errors.length,
218
+ warnings: warnings.length,
219
+ strict: STRICT,
220
+ };
221
+
222
+ console.log(JSON.stringify(report, null, 2));
223
+ for (const err of errors) console.error(`[rule-catalog][error] ${err}`);
224
+ for (const warn of warnings) console.error(`[rule-catalog][warn] ${warn}`);
225
+
226
+ if (errors.length > 0) process.exit(1);
227
+ if (STRICT && warnings.length > 0) process.exit(1);
@@ -16,11 +16,25 @@ Use `lsp_navigation` as **PRIMARY** for code intelligence. Do NOT use grep/glob/
16
16
  | "Where is this defined?" | `definition` | filePath, line, character |
17
17
  | "Find all usages" | `references` | filePath, line, character |
18
18
  | "What type is this?" | `hover` | filePath, line, character |
19
+ | "Show call signature here" | `signatureHelp` | filePath, line, character (at call-site args) |
19
20
  | "What symbols in this file?" | `documentSymbol` | filePath |
20
- | "Find symbol across project" | `workspaceSymbol` | filePath, query |
21
+ | "Find symbol across project" | `workspaceSymbol` | query + **filePath strongly recommended** |
22
+ | "What quick fixes are available?" | `codeAction` | filePath, line, character, endLine, endCharacter |
23
+ | "Rename symbol safely" | `rename` | filePath, line, character, newName |
21
24
  | "Who implements this interface?" | `implementation` | filePath, line, character |
22
25
  | "Who calls this function?" | `prepareCallHierarchy` → `incomingCalls` | filePath, line, character |
23
26
  | "What does this function call?" | `prepareCallHierarchy` → `outgoingCalls` | filePath, line, character |
27
+ | "Show tracked LSP diagnostics" | `workspaceDiagnostics` | optional filePath (snapshot, not full pull workspace) |
28
+
29
+ ## Operational Guidance (From Field Tests)
30
+
31
+ - Always pass `filePath` for `workspaceSymbol` when possible. Unscoped queries are best-effort and often empty.
32
+ - For `references`, prefer querying from the definition site for broader cross-file coverage; usage-site queries can be partial.
33
+ - Use `signatureHelp` only at call-site argument positions; declaration positions often return empty.
34
+ - Treat `workspaceDiagnostics` as tracked push snapshot (`publishDiagnostics`), not protocol pull `workspace/diagnostic` coverage.
35
+ - For `codeAction`, separate `quickfix` from generic refactors (for example "Move to new file"). Do not treat generic refactors as error fixes.
36
+ - `prepareCallHierarchy` is server-capability dependent; if unsupported, skip incoming/outgoing calls.
37
+ - If TypeScript returns `No Project` on `workspaceSymbol`, retry after opening the scoped file context.
24
38
 
25
39
  ## Call Hierarchy Pattern
26
40
 
@@ -36,14 +50,12 @@ const items = await lsp_navigation({
36
50
  // Step 2: Get callers (who calls this function)
37
51
  const callers = await lsp_navigation({
38
52
  operation: "incomingCalls",
39
- filePath: "src/api.ts",
40
53
  callHierarchyItem: items[0]
41
54
  });
42
55
 
43
56
  // Step 2: Get callees (what this function calls)
44
57
  const callees = await lsp_navigation({
45
58
  operation: "outgoingCalls",
46
- filePath: "src/api.ts",
47
59
  callHierarchyItem: items[0]
48
60
  });
49
61
  ```
@@ -7,6 +7,80 @@ import * as nodeFs from "node:fs";
7
7
  import * as path from "node:path";
8
8
  import { Type } from "@sinclair/typebox";
9
9
  import { getLSPService } from "../clients/lsp/index.js";
10
+ function operationSupportStatus(operation, support) {
11
+ if (!support)
12
+ return null;
13
+ if (operation === "definition")
14
+ return support.definition;
15
+ if (operation === "references")
16
+ return support.references;
17
+ if (operation === "hover")
18
+ return support.hover;
19
+ if (operation === "signatureHelp")
20
+ return support.signatureHelp;
21
+ if (operation === "documentSymbol")
22
+ return support.documentSymbol;
23
+ if (operation === "workspaceSymbol")
24
+ return support.workspaceSymbol;
25
+ if (operation === "codeAction")
26
+ return support.codeAction;
27
+ if (operation === "rename")
28
+ return support.rename;
29
+ if (operation === "implementation")
30
+ return support.implementation;
31
+ if (operation === "prepareCallHierarchy" ||
32
+ operation === "incomingCalls" ||
33
+ operation === "outgoingCalls")
34
+ return support.callHierarchy;
35
+ return null;
36
+ }
37
+ function emptyReasonForOperation(operation) {
38
+ if (operation === "signatureHelp")
39
+ return "position-sensitive-or-no-signature";
40
+ if (operation === "codeAction")
41
+ return "no-applicable-actions";
42
+ if (operation === "rename")
43
+ return "no-rename-edits-or-symbol-not-renamable";
44
+ if (operation === "workspaceSymbol")
45
+ return "no-matching-symbols-or-server-index-unavailable";
46
+ if (operation === "incomingCalls" || operation === "outgoingCalls")
47
+ return "no-call-hierarchy-results";
48
+ return "no-results";
49
+ }
50
+ function classifyCodeActions(actions) {
51
+ if (!actions || actions.length === 0)
52
+ return { quickfix: 0, refactor: 0, other: 0 };
53
+ let quickfix = 0;
54
+ let refactor = 0;
55
+ let other = 0;
56
+ for (const action of actions) {
57
+ const kind = action.kind ?? "";
58
+ if (kind.startsWith("quickfix"))
59
+ quickfix += 1;
60
+ else if (kind.startsWith("refactor"))
61
+ refactor += 1;
62
+ else
63
+ other += 1;
64
+ }
65
+ return { quickfix, refactor, other };
66
+ }
67
+ async function openFileBestEffort(lspService, filePath) {
68
+ let fileContent;
69
+ try {
70
+ fileContent = nodeFs.readFileSync(filePath, "utf-8");
71
+ }
72
+ catch {
73
+ return;
74
+ }
75
+ if (!fileContent)
76
+ return;
77
+ try {
78
+ await lspService.openFile(filePath, fileContent);
79
+ }
80
+ catch {
81
+ /* LSP server may not be ready yet — proceed anyway */
82
+ }
83
+ }
10
84
  export function createLspNavigationTool(getFlag) {
11
85
  return {
12
86
  name: "lsp_navigation",
@@ -16,12 +90,16 @@ export function createLspNavigationTool(getFlag) {
16
90
  "- definition: Jump to where a symbol is defined\n" +
17
91
  "- references: Find all usages of a symbol\n" +
18
92
  "- hover: Get type/doc info at a position\n" +
93
+ "- signatureHelp: Show callable signatures at cursor\n" +
19
94
  "- documentSymbol: List all symbols (functions/classes/vars) in a file\n" +
20
- "- workspaceSymbol: Search symbols across the whole project\n" +
95
+ "- workspaceSymbol: Search symbols across the whole project (best with filePath context)\n" +
96
+ "- codeAction: Find available quick fixes/refactors at a range\n" +
97
+ "- rename: Compute workspace edits for renaming a symbol\n" +
21
98
  "- implementation: Jump to interface implementations\n" +
22
99
  "- prepareCallHierarchy: Get callable item at position (for incoming/outgoing)\n" +
23
100
  "- incomingCalls: Find all functions/methods that CALL this function\n" +
24
- "- outgoingCalls: Find all functions/methods CALLED by this function\n\n" +
101
+ "- outgoingCalls: Find all functions/methods CALLED by this function\n" +
102
+ "- workspaceDiagnostics: List all diagnostics tracked by active LSP clients\n\n" +
25
103
  "Line and character are 1-based (as shown in editors).",
26
104
  promptSnippet: "Use lsp_navigation to find definitions, references, and hover info via LSP",
27
105
  parameters: Type.Object({
@@ -29,24 +107,37 @@ export function createLspNavigationTool(getFlag) {
29
107
  Type.Literal("definition"),
30
108
  Type.Literal("references"),
31
109
  Type.Literal("hover"),
110
+ Type.Literal("signatureHelp"),
32
111
  Type.Literal("documentSymbol"),
33
112
  Type.Literal("workspaceSymbol"),
113
+ Type.Literal("codeAction"),
114
+ Type.Literal("rename"),
34
115
  Type.Literal("implementation"),
35
116
  Type.Literal("prepareCallHierarchy"),
36
117
  Type.Literal("incomingCalls"),
37
118
  Type.Literal("outgoingCalls"),
119
+ Type.Literal("workspaceDiagnostics"),
38
120
  ], { description: "LSP operation to perform" }),
39
- filePath: Type.String({
40
- description: "Absolute or relative path to the file",
41
- }),
121
+ filePath: Type.Optional(Type.String({
122
+ description: "Absolute or relative file path. Required for file-scoped operations; optional for workspaceSymbol/workspaceDiagnostics.",
123
+ })),
42
124
  line: Type.Optional(Type.Number({
43
125
  description: "Line number (1-based). Required for definition/references/hover/implementation",
44
126
  })),
45
127
  character: Type.Optional(Type.Number({
46
128
  description: "Character offset (1-based). Required for definition/references/hover/implementation",
47
129
  })),
130
+ endLine: Type.Optional(Type.Number({
131
+ description: "End line (1-based). Optional; used by codeAction range.",
132
+ })),
133
+ endCharacter: Type.Optional(Type.Number({
134
+ description: "End character (1-based). Optional; used by codeAction range.",
135
+ })),
136
+ newName: Type.Optional(Type.String({
137
+ description: "Required for rename operation.",
138
+ })),
48
139
  query: Type.Optional(Type.String({
49
- description: "Symbol name to search. Used by workspaceSymbol",
140
+ description: "Symbol name to search. Used by workspaceSymbol (best with filePath for active project context).",
50
141
  })),
51
142
  callHierarchyItem: Type.Optional(Type.Object({
52
143
  name: Type.String(),
@@ -77,6 +168,8 @@ export function createLspNavigationTool(getFlag) {
77
168
  })),
78
169
  }),
79
170
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
171
+ let supported = null;
172
+ let diagnosticsMode = "unknown";
80
173
  if (!getFlag("lens-lsp") || getFlag("no-lsp")) {
81
174
  return {
82
175
  content: [
@@ -89,43 +182,93 @@ export function createLspNavigationTool(getFlag) {
89
182
  details: {},
90
183
  };
91
184
  }
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) {
185
+ const { operation, filePath: rawPath, line, character, endLine, endCharacter, newName, query, } = params;
186
+ const isCallHierarchyTraversal = operation === "incomingCalls" || operation === "outgoingCalls";
187
+ const needsFilePath = operation !== "workspaceDiagnostics" &&
188
+ operation !== "workspaceSymbol" &&
189
+ !isCallHierarchyTraversal;
190
+ if (needsFilePath && (!rawPath || rawPath.trim().length === 0)) {
99
191
  return {
100
192
  content: [
101
193
  {
102
194
  type: "text",
103
- text: `No LSP server available for ${path.basename(filePath)}. Check that the language server is installed.`,
195
+ text: `filePath is required for ${operation}`,
104
196
  },
105
197
  ],
106
198
  isError: true,
107
199
  details: {},
108
200
  };
109
201
  }
110
- // Ensure file is open in LSP before querying
111
- let fileContent;
112
- try {
113
- fileContent = nodeFs.readFileSync(filePath, "utf-8");
202
+ const filePath = rawPath
203
+ ? path.isAbsolute(rawPath)
204
+ ? rawPath
205
+ : path.resolve(ctx.cwd || ".", rawPath)
206
+ : "";
207
+ const lspService = getLSPService();
208
+ if (operation === "workspaceDiagnostics") {
209
+ const allDiagnostics = await lspService.getAllDiagnostics();
210
+ const wsDiagSupport = await lspService.getWorkspaceDiagnosticsSupport(rawPath ? filePath : undefined);
211
+ diagnosticsMode = wsDiagSupport?.mode ?? "unknown";
212
+ const result = Array.from(allDiagnostics.entries()).map(([trackedFile, diags]) => ({
213
+ filePath: trackedFile,
214
+ diagnostics: diags,
215
+ count: diags.length,
216
+ }));
217
+ const note = diagnosticsMode === "push-only"
218
+ ? "Note: push-only tracked diagnostics snapshot (not full workspace pull diagnostics)."
219
+ : diagnosticsMode === "pull"
220
+ ? "Note: server advertises workspace pull diagnostics support."
221
+ : "Note: workspace diagnostics mode unknown (no active capability snapshot).";
222
+ return {
223
+ content: [
224
+ {
225
+ type: "text",
226
+ text: `${note}\n${JSON.stringify(result, null, 2)}`,
227
+ },
228
+ ],
229
+ details: {
230
+ operation,
231
+ resultCount: result.length,
232
+ diagnosticsMode,
233
+ coverage: "tracked-open-files",
234
+ },
235
+ };
114
236
  }
115
- catch {
116
- /* ignore */
237
+ const hasLSP = filePath ? await lspService.hasLSP(filePath) : false;
238
+ if (needsFilePath && !hasLSP) {
239
+ return {
240
+ content: [
241
+ {
242
+ type: "text",
243
+ text: `No LSP server available for ${path.basename(filePath)}. Check that the language server is installed.`,
244
+ },
245
+ ],
246
+ isError: true,
247
+ details: {},
248
+ };
117
249
  }
118
- if (fileContent) {
119
- try {
120
- await lspService.openFile(filePath, fileContent);
121
- }
122
- catch {
123
- /* LSP server may not be ready yet — proceed anyway */
250
+ if (needsFilePath) {
251
+ const support = await lspService.getOperationSupport(filePath);
252
+ supported = operationSupportStatus(operation, support);
253
+ if (supported === false) {
254
+ return {
255
+ content: [
256
+ {
257
+ type: "text",
258
+ text: `LSP server for ${path.basename(filePath)} does not advertise support for ${operation}`,
259
+ },
260
+ ],
261
+ isError: true,
262
+ details: { operation, supported: false, emptyReason: "unsupported" },
263
+ };
124
264
  }
265
+ await openFileBestEffort(lspService, filePath);
125
266
  }
126
267
  // Convert 1-based editor coords to 0-based LSP coords
127
268
  const lspLine = (line ?? 1) - 1;
128
269
  const lspChar = (character ?? 1) - 1;
270
+ const lspEndLine = (endLine ?? line ?? 1) - 1;
271
+ const lspEndChar = (endCharacter ?? character ?? 1) - 1;
129
272
  let result;
130
273
  try {
131
274
  switch (operation) {
@@ -138,11 +281,77 @@ export function createLspNavigationTool(getFlag) {
138
281
  case "hover":
139
282
  result = await lspService.hover(filePath, lspLine, lspChar);
140
283
  break;
284
+ case "signatureHelp":
285
+ result = await lspService.signatureHelp(filePath, lspLine, lspChar);
286
+ break;
141
287
  case "documentSymbol":
142
288
  result = await lspService.documentSymbol(filePath);
143
289
  break;
144
290
  case "workspaceSymbol":
145
- result = await lspService.workspaceSymbol(query ?? "");
291
+ supported = operationSupportStatus(operation, await lspService.getOperationSupport(rawPath ? filePath : undefined));
292
+ if (supported === false) {
293
+ return {
294
+ content: [
295
+ {
296
+ type: "text",
297
+ text: "Active LSP server does not advertise support for workspaceSymbol",
298
+ },
299
+ ],
300
+ isError: true,
301
+ details: {
302
+ operation,
303
+ supported: false,
304
+ emptyReason: "unsupported",
305
+ },
306
+ };
307
+ }
308
+ if (!query || query.trim().length === 0) {
309
+ return {
310
+ content: [
311
+ {
312
+ type: "text",
313
+ text: "query parameter required for workspaceSymbol",
314
+ },
315
+ ],
316
+ isError: true,
317
+ details: {},
318
+ };
319
+ }
320
+ if (rawPath) {
321
+ await openFileBestEffort(lspService, filePath);
322
+ }
323
+ try {
324
+ result = await lspService.workspaceSymbol(query ?? "", rawPath ? filePath : undefined);
325
+ }
326
+ catch (err) {
327
+ const msg = err instanceof Error ? err.message : String(err);
328
+ if (rawPath && /No Project/i.test(msg)) {
329
+ await openFileBestEffort(lspService, filePath);
330
+ await new Promise((resolve) => setTimeout(resolve, 120));
331
+ result = await lspService.workspaceSymbol(query ?? "", filePath);
332
+ }
333
+ else {
334
+ throw err;
335
+ }
336
+ }
337
+ break;
338
+ case "codeAction":
339
+ result = await lspService.codeAction(filePath, lspLine, lspChar, lspEndLine, lspEndChar);
340
+ break;
341
+ case "rename":
342
+ if (!newName || newName.trim().length === 0) {
343
+ return {
344
+ content: [
345
+ {
346
+ type: "text",
347
+ text: "newName parameter required for rename",
348
+ },
349
+ ],
350
+ isError: true,
351
+ details: {},
352
+ };
353
+ }
354
+ result = await lspService.rename(filePath, lspLine, lspChar, newName);
146
355
  break;
147
356
  case "implementation":
148
357
  result = await lspService.implementation(filePath, lspLine, lspChar);
@@ -201,13 +410,35 @@ export function createLspNavigationTool(getFlag) {
201
410
  };
202
411
  }
203
412
  const isEmpty = !result || (Array.isArray(result) && result.length === 0);
204
- const output = isEmpty
413
+ let output = isEmpty
205
414
  ? `No results for ${operation} at ${path.basename(filePath)}${line ? `:${line}:${character}` : ""}`
206
415
  : JSON.stringify(result, null, 2);
416
+ if (isEmpty && operation === "workspaceSymbol" && !rawPath) {
417
+ output +=
418
+ "\nHint: provide filePath to scope workspaceSymbol to the active language server/root.";
419
+ }
420
+ if (operation === "references" &&
421
+ Array.isArray(result) &&
422
+ result.length <= 2) {
423
+ output +=
424
+ "\nHint: references from usage sites can be partial; retry from the symbol definition for broader cross-file results.";
425
+ }
426
+ const actionStats = operation === "codeAction" && Array.isArray(result)
427
+ ? classifyCodeActions(result)
428
+ : null;
429
+ if (operation === "codeAction" && actionStats) {
430
+ if (actionStats.quickfix === 0 && actionStats.refactor > 0) {
431
+ output +=
432
+ "\nNote: no diagnostic quick fixes returned; refactor-only actions available.";
433
+ }
434
+ }
207
435
  return {
208
436
  content: [{ type: "text", text: output }],
209
437
  details: {
210
438
  operation,
439
+ supported,
440
+ emptyReason: isEmpty ? emptyReasonForOperation(operation) : undefined,
441
+ codeActionKinds: actionStats ?? undefined,
211
442
  resultCount: Array.isArray(result) ? result.length : result ? 1 : 0,
212
443
  },
213
444
  };