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.
- package/CHANGELOG.md +8 -0
- package/README.md +2 -0
- package/clients/dispatch/runners/lsp.ts +58 -3
- package/clients/dispatch/runners/tree-sitter.ts +467 -0
- package/clients/lsp/client.ts +229 -3
- package/clients/lsp/index.ts +111 -1
- package/clients/pipeline.ts +2 -2
- package/clients/runtime-session.ts +43 -5
- package/clients/tree-sitter-client.ts +162 -0
- package/clients/tree-sitter-logger.ts +47 -0
- package/clients/tree-sitter-query-loader.ts +13 -2
- package/package.json +3 -1
- package/rules/rule-catalog.json +64 -0
- package/rules/tree-sitter-queries/go/go-bare-error.yml +19 -7
- package/rules/tree-sitter-queries/go/go-command-injection.yml +55 -0
- package/rules/tree-sitter-queries/go/go-direct-panic.yml +45 -0
- package/rules/tree-sitter-queries/go/go-empty-if-err.yml +47 -0
- package/rules/tree-sitter-queries/go/go-goroutine-loop-capture.yml +49 -0
- package/rules/tree-sitter-queries/go/go-ignored-call-result.yml +51 -0
- package/rules/tree-sitter-queries/go/go-insecure-random.yml +51 -0
- package/rules/tree-sitter-queries/go/go-log-fatal.yml +49 -0
- package/rules/tree-sitter-queries/go/go-path-traversal.yml +51 -0
- package/rules/tree-sitter-queries/go/go-shared-map-write-goroutine.yml +54 -0
- package/rules/tree-sitter-queries/go/go-sql-injection.yml +55 -0
- package/rules/tree-sitter-queries/go/go-weak-hash.yml +51 -0
- package/rules/tree-sitter-queries/python/python-command-injection.yml +63 -0
- package/rules/tree-sitter-queries/python/python-insecure-deserialization.yml +48 -0
- package/rules/tree-sitter-queries/python/python-insecure-random.yml +51 -0
- package/rules/tree-sitter-queries/python/python-path-traversal.yml +55 -0
- package/rules/tree-sitter-queries/python/python-sql-injection.yml +47 -0
- package/rules/tree-sitter-queries/python/python-ssrf.yml +50 -0
- package/rules/tree-sitter-queries/python/python-thread-global-write.yml +58 -0
- package/rules/tree-sitter-queries/python/python-weak-hash.yml +51 -0
- package/rules/tree-sitter-queries/ruby/ruby-command-injection.yml +56 -0
- package/rules/tree-sitter-queries/ruby/ruby-insecure-deserialization.yml +47 -0
- package/rules/tree-sitter-queries/ruby/ruby-insecure-random.yml +54 -0
- package/rules/tree-sitter-queries/ruby/ruby-weak-hash.yml +50 -0
- package/rules/tree-sitter-queries/rust/rust-lock-held-across-await.yml +59 -0
- package/rules/tree-sitter-queries/typescript/ts-command-injection.yml +60 -0
- package/rules/tree-sitter-queries/typescript/ts-detached-async-call.yml +56 -0
- package/rules/tree-sitter-queries/typescript/ts-insecure-random.yml +54 -0
- package/rules/tree-sitter-queries/typescript/ts-ssrf.yml +53 -0
- package/rules/tree-sitter-queries/typescript/ts-weak-hash.yml +54 -0
- package/scripts/validate-rule-catalog.mjs +227 -0
- package/skills/lsp-navigation/SKILL.md +15 -3
- package/tools/lsp-navigation.js +259 -28
- 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
|
|
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
|
```
|
package/tools/lsp-navigation.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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: `
|
|
195
|
+
text: `filePath is required for ${operation}`,
|
|
104
196
|
},
|
|
105
197
|
],
|
|
106
198
|
isError: true,
|
|
107
199
|
details: {},
|
|
108
200
|
};
|
|
109
201
|
}
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
-
|
|
116
|
-
|
|
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 (
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
};
|