pi-lens 3.3.0 → 3.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/CHANGELOG.md +91 -0
  2. package/README.md +175 -13
  3. package/clients/cache/rule-cache.js +72 -0
  4. package/clients/cache/rule-cache.ts +104 -0
  5. package/clients/dispatch/integration.js +48 -1
  6. package/clients/dispatch/integration.ts +60 -2
  7. package/clients/dispatch/plan.js +5 -2
  8. package/clients/dispatch/plan.ts +5 -2
  9. package/clients/dispatch/runners/ast-grep-napi.js +175 -56
  10. package/clients/dispatch/runners/ast-grep-napi.test.js +2 -1
  11. package/clients/dispatch/runners/ast-grep-napi.test.ts +2 -1
  12. package/clients/dispatch/runners/ast-grep-napi.ts +191 -79
  13. package/clients/dispatch/runners/similarity.js +1 -1
  14. package/clients/dispatch/runners/similarity.ts +2 -2
  15. package/clients/dispatch/runners/tree-sitter.js +137 -10
  16. package/clients/dispatch/runners/tree-sitter.ts +168 -13
  17. package/clients/dispatch/runners/ts-lsp.js +3 -2
  18. package/clients/dispatch/runners/ts-lsp.ts +3 -2
  19. package/clients/dispatch/runners/yaml-rule-parser.js +70 -2
  20. package/clients/dispatch/runners/yaml-rule-parser.ts +71 -2
  21. package/clients/dispatch/types.js +1 -1
  22. package/clients/dispatch/types.ts +1 -1
  23. package/clients/lsp/__tests__/service.test.js +3 -0
  24. package/clients/lsp/__tests__/service.test.ts +3 -0
  25. package/clients/lsp/client.js +42 -0
  26. package/clients/lsp/client.ts +79 -0
  27. package/clients/lsp/index.js +27 -0
  28. package/clients/lsp/index.ts +35 -0
  29. package/clients/lsp/launch.js +11 -6
  30. package/clients/lsp/launch.ts +11 -6
  31. package/clients/metrics-client.js +3 -160
  32. package/clients/metrics-client.tdr.test.js +78 -0
  33. package/clients/metrics-client.test.js +30 -43
  34. package/clients/metrics-client.test.ts +30 -54
  35. package/clients/metrics-client.ts +5 -219
  36. package/clients/metrics-history.js +33 -7
  37. package/clients/metrics-history.ts +47 -10
  38. package/clients/pipeline.js +272 -0
  39. package/clients/pipeline.ts +371 -0
  40. package/clients/sg-runner.js +21 -3
  41. package/clients/sg-runner.ts +22 -3
  42. package/clients/tree-sitter-client.js +23 -2
  43. package/clients/tree-sitter-client.ts +27 -2
  44. package/index.ts +604 -771
  45. package/package.json +1 -1
  46. package/rules/ast-grep-rules/rules/no-architecture-violation.yml +7 -4
  47. package/rules/ast-grep-rules/rules/no-single-char-var.yml +3 -3
  48. package/rules/ast-grep-rules/slop-patterns.yml +85 -62
  49. package/skills/ast-grep/SKILL.md +42 -1
  50. package/skills/lsp-navigation/SKILL.md +62 -0
  51. package/tsconfig.json +1 -1
  52. package/rules/ast-grep-rules/rules/no-console-log.yml +0 -10
  53. package/rules/ast-grep-rules/rules/no-default-export.yml +0 -19
@@ -6,9 +6,15 @@
6
6
  * Updated: ast-grep-napi test
7
7
  */
8
8
 
9
- import path from "node:path";
9
+ import * as fs from "node:fs";
10
+ import * as path from "node:path";
11
+ import { RuleCache } from "../../cache/rule-cache.js";
12
+ import { normalizeMapKey } from "../../path-utils.js";
10
13
  import { TreeSitterClient } from "../../tree-sitter-client.js";
11
- import { queryLoader } from "../../tree-sitter-query-loader.js";
14
+ import {
15
+ queryLoader,
16
+ type TreeSitterQuery,
17
+ } from "../../tree-sitter-query-loader.js";
12
18
  import type {
13
19
  Diagnostic,
14
20
  DispatchContext,
@@ -27,6 +33,88 @@ function getSharedClient(): TreeSitterClient {
27
33
  return _sharedClient;
28
34
  }
29
35
 
36
+ /**
37
+ * Check if a code block is effectively empty (ignoring comments and whitespace)
38
+ */
39
+ function isEmptyBlock(blockContent: string): boolean {
40
+ // Remove comments, whitespace, and check if anything remains
41
+ const cleaned = blockContent
42
+ .replace(/\/\/.*$/gm, "") // Remove single-line comments
43
+ .replace(/\/\*[\s\S]*?\*\//g, "") // Remove multi-line comments
44
+ .replace(/\s+/g, "") // Remove all whitespace
45
+ .trim();
46
+ return cleaned.length === 0 || cleaned === "{}";
47
+ }
48
+
49
+ /**
50
+ * Extract parameter count from match text
51
+ */
52
+ function countParameters(matchText: string): number {
53
+ // Count commas in parameter list, or check for non-empty params
54
+ // Simple heuristic: count commas + 1, or 0 if empty
55
+ const paramsMatch = matchText.match(/\((.*)\)/);
56
+ if (!paramsMatch) return 0;
57
+ const params = paramsMatch[1].trim();
58
+ if (!params) return 0;
59
+ return params.split(",").length;
60
+ }
61
+
62
+ /**
63
+ * Apply post-filter to determine if a match should be reported
64
+ */
65
+ function applyPostFilter(
66
+ query: TreeSitterQuery,
67
+ captures: Record<string, string>,
68
+ ): boolean {
69
+ if (!query.post_filter) return true; // No filter = always include
70
+
71
+ switch (query.post_filter) {
72
+ case "empty_body": {
73
+ // Check if the BODY capture is effectively empty
74
+ const body = captures.BODY || captures.body || "";
75
+ return isEmptyBlock(body);
76
+ }
77
+
78
+ case "count_params": {
79
+ // Check if parameter count meets minimum
80
+ const minParams = query.post_filter_params?.min_params || 6;
81
+ // Get PARAMS capture which contains the parameter list like "(a, b, c)"
82
+ const params = captures.PARAMS || captures.params || captures.PARAM || "";
83
+ const paramCount = countParameters(params);
84
+ return paramCount >= minParams;
85
+ }
86
+
87
+ case "not_dbg_method":
88
+ // Exclude debug methods (for console-statement)
89
+ return !/\b(dbg|debug|logDebug)\b/i.test(captures.METHOD || "");
90
+
91
+ default:
92
+ // Unknown filter - include by default (safer than excluding)
93
+ return true;
94
+ }
95
+ }
96
+
97
+ /**
98
+ * Check if variable name matches secret patterns
99
+ * This handles the #match? predicate from tree-sitter queries
100
+ */
101
+ function matchesSecretPattern(varName: string): boolean {
102
+ const secretPatterns = [
103
+ /api[_-]?key/i,
104
+ /api[_-]?secret/i,
105
+ /password/i,
106
+ /secret/i,
107
+ /token/i,
108
+ /auth/i,
109
+ /private[_-]?key/i,
110
+ /access[_-]?token/i,
111
+ /credentials/i,
112
+ /aws[_-]?secret/i,
113
+ /github[_-]?token/i,
114
+ ];
115
+ return secretPatterns.some((pattern) => pattern.test(varName));
116
+ }
117
+
30
118
  const treeSitterRunner: RunnerDefinition = {
31
119
  id: "tree-sitter",
32
120
  appliesTo: ["jsts", "python"],
@@ -66,18 +154,68 @@ const treeSitterRunner: RunnerDefinition = {
66
154
  return { status: "skipped", diagnostics: [], semantic: "none" };
67
155
  }
68
156
 
69
- // Load queries if not already loaded
70
- if (!queryLoader.getAllQueries().length) {
71
- await queryLoader.loadQueries();
72
- }
157
+ // Try cache first, fall back to loading from disk
158
+ let languageQueries: TreeSitterQuery[] = [];
159
+ const cache = new RuleCache(languageId);
73
160
 
74
- // Get all loaded queries for this language
75
- const allQueries = queryLoader.getAllQueries();
76
- const languageQueries = allQueries.filter(
77
- (q) =>
78
- q.language === languageId ||
79
- (isJavaScript && q.language === "typescript"),
161
+ // Get all rule files for this language (use ctx.cwd for project root)
162
+ const rulesDir = path.join(
163
+ ctx.cwd,
164
+ "rules",
165
+ "tree-sitter-queries",
166
+ languageId,
80
167
  );
168
+ const ruleFiles: string[] = [];
169
+ if (fs.existsSync(rulesDir)) {
170
+ ruleFiles.push(
171
+ ...fs
172
+ .readdirSync(rulesDir)
173
+ .filter((f) => f.endsWith(".yml"))
174
+ .map((f) => path.join(rulesDir, f)),
175
+ );
176
+ }
177
+
178
+ // Try cache
179
+ const cached = cache.get(ruleFiles);
180
+ if (cached) {
181
+ // Use cached queries
182
+ languageQueries = cached.queries.map(
183
+ (q) =>
184
+ ({
185
+ ...q,
186
+ has_fix: false,
187
+ filePath: "",
188
+ }) as TreeSitterQuery,
189
+ );
190
+ } else {
191
+ // Load from disk
192
+ if (!queryLoader.getAllQueries().length) {
193
+ await queryLoader.loadQueries();
194
+ }
195
+
196
+ const allQueries = queryLoader.getAllQueries();
197
+ languageQueries = allQueries.filter(
198
+ (q) =>
199
+ q.language === languageId ||
200
+ (isJavaScript && q.language === "typescript"),
201
+ );
202
+
203
+ // Save to cache
204
+ cache.set(
205
+ ruleFiles,
206
+ languageQueries.map((q) => ({
207
+ id: q.id,
208
+ name: q.name,
209
+ severity: q.severity,
210
+ language: q.language,
211
+ message: q.message,
212
+ query: q.query,
213
+ metavars: q.metavars,
214
+ post_filter: q.post_filter,
215
+ post_filter_params: q.post_filter_params,
216
+ })),
217
+ );
218
+ }
81
219
 
82
220
  if (languageQueries.length === 0) {
83
221
  return { status: "succeeded", diagnostics: [], semantic: "none" };
@@ -95,10 +233,27 @@ const treeSitterRunner: RunnerDefinition = {
95
233
  query.id, // Use query ID as pattern (findMatchingQuery will resolve it)
96
234
  languageId,
97
235
  rootDir,
98
- { maxResults: 10, fileFilter: (f) => f === filePath },
236
+ {
237
+ maxResults: 10,
238
+ fileFilter: (f) => normalizeMapKey(f) === normalizeMapKey(filePath),
239
+ },
99
240
  );
100
241
 
101
242
  for (const match of matches) {
243
+ // Apply post-filter if defined (pass captures for proper filtering)
244
+ if (!applyPostFilter(query, match.captures)) {
245
+ continue; // Skip this match - filter didn't pass
246
+ }
247
+
248
+ // For hardcoded-secrets, also check variable name pattern
249
+ if (query.id === "hardcoded-secrets") {
250
+ // Extract variable name from captures
251
+ const varName = match.captures?.VARNAME || "";
252
+ if (!varName || !matchesSecretPattern(varName)) {
253
+ continue; // Skip - no variable name or doesn't match secret patterns
254
+ }
255
+ }
256
+
102
257
  // Get line/column from match (already 0-indexed from tree-sitter)
103
258
  const line = match.line;
104
259
  const column = match.column;
@@ -19,9 +19,10 @@ const tsLspRunner = {
19
19
  if (!ctx.filePath.match(/\.tsx?$/)) {
20
20
  return { status: "skipped", diagnostics: [], semantic: "none" };
21
21
  }
22
- // Phase 3: Use LSP client if --lens-lsp flag is enabled
22
+ // When --lens-lsp is active, the `lsp` runner (priority 4) already
23
+ // handles TypeScript via the LSP service. Skip to avoid duplicate work.
23
24
  if (ctx.pi.getFlag("lens-lsp")) {
24
- return runWithLSPClient(ctx);
25
+ return { status: "skipped", diagnostics: [], semantic: "none" };
25
26
  }
26
27
  // DEPRECATED: Fall back to built-in TypeScriptClient
27
28
  // This path is deprecated and will be removed in a future release
@@ -29,9 +29,10 @@ const tsLspRunner: RunnerDefinition = {
29
29
  return { status: "skipped", diagnostics: [], semantic: "none" };
30
30
  }
31
31
 
32
- // Phase 3: Use LSP client if --lens-lsp flag is enabled
32
+ // When --lens-lsp is active, the `lsp` runner (priority 4) already
33
+ // handles TypeScript via the LSP service. Skip to avoid duplicate work.
33
34
  if (ctx.pi.getFlag("lens-lsp")) {
34
- return runWithLSPClient(ctx);
35
+ return { status: "skipped", diagnostics: [], semantic: "none" };
35
36
  }
36
37
 
37
38
  // DEPRECATED: Fall back to built-in TypeScriptClient
@@ -108,6 +108,46 @@ export function isStructuredRule(rule) {
108
108
  rule.rule.not ||
109
109
  rule.rule.regex);
110
110
  }
111
+ /**
112
+ * Check if a rule or any of its nested conditions use features
113
+ * not supported by the NAPI runner (inside, follows, precedes,
114
+ * stopBy, field, nthChild). Rules using these must be skipped
115
+ * to prevent false positives from incomplete condition evaluation.
116
+ */
117
+ export function hasUnsupportedConditions(rule) {
118
+ if (rule.constraints)
119
+ return true;
120
+ if (!rule.rule)
121
+ return false;
122
+ return conditionHasUnsupported(rule.rule);
123
+ }
124
+ function conditionHasUnsupported(c) {
125
+ if (c.inside ||
126
+ c.follows ||
127
+ c.precedes ||
128
+ c.stopBy ||
129
+ c.field ||
130
+ c.nthChild) {
131
+ return true;
132
+ }
133
+ if (c.has && conditionHasUnsupported(c.has))
134
+ return true;
135
+ if (c.not && conditionHasUnsupported(c.not))
136
+ return true;
137
+ if (c.any) {
138
+ for (const sub of c.any) {
139
+ if (conditionHasUnsupported(sub))
140
+ return true;
141
+ }
142
+ }
143
+ if (c.all) {
144
+ for (const sub of c.all) {
145
+ if (conditionHasUnsupported(sub))
146
+ return true;
147
+ }
148
+ }
149
+ return false;
150
+ }
111
151
  export function calculateRuleComplexity(condition) {
112
152
  if (!condition)
113
153
  return 0;
@@ -209,6 +249,14 @@ export function parseSimpleYaml(content) {
209
249
  ? (multilineKey = "message")
210
250
  : (rule.message = stripQuotes(value));
211
251
  }
252
+ else if (key === "constraints") {
253
+ rule.constraints = {};
254
+ stack.push({
255
+ name: "constraints",
256
+ indent,
257
+ obj: rule.constraints,
258
+ });
259
+ }
212
260
  else if (key === "metadata") {
213
261
  rule.metadata = {};
214
262
  stack.push({ name: "metadata", indent, obj: rule.metadata });
@@ -241,6 +289,21 @@ export function parseSimpleYaml(content) {
241
289
  else if (key === "regex") {
242
290
  obj.regex = stripQuotes(value);
243
291
  }
292
+ else if (key === "inside" || key === "follows" || key === "precedes") {
293
+ // Mark as present for unsupported-condition detection
294
+ obj[key] = {};
295
+ stack.push({ name: key, indent, obj: obj[key] });
296
+ }
297
+ else if (key === "stopBy") {
298
+ obj.stopBy = stripQuotes(value) || "end";
299
+ }
300
+ else if (key === "field") {
301
+ obj.field = stripQuotes(value);
302
+ }
303
+ else if (key === "nthChild") {
304
+ obj.nthChild = value || true;
305
+ stack.push({ name: "nthChild", indent, obj: {} });
306
+ }
244
307
  else if (key === "has" || key === "not") {
245
308
  obj[key] = {};
246
309
  stack.push({ name: key, indent, obj: obj[key] });
@@ -269,14 +332,19 @@ export function parseSimpleYaml(content) {
269
332
  obj: item,
270
333
  });
271
334
  const itemContent = nextTrimmed.substring(2);
272
- if (itemContent.includes(":")) {
273
- const [itemKey, itemVal] = itemContent.split(":", 2);
335
+ const colonPos = itemContent.indexOf(":");
336
+ if (colonPos !== -1) {
337
+ const itemKey = itemContent.substring(0, colonPos);
338
+ const itemVal = itemContent.substring(colonPos + 1);
274
339
  if (itemKey.trim() === "pattern") {
275
340
  item.pattern = stripQuotes(itemVal.trim());
276
341
  }
277
342
  else if (itemKey.trim() === "kind") {
278
343
  item.kind = itemVal.trim();
279
344
  }
345
+ else if (itemKey.trim() === "regex") {
346
+ item.regex = stripQuotes(itemVal.trim());
347
+ }
280
348
  }
281
349
  else if (itemContent) {
282
350
  item.pattern = stripQuotes(itemContent);
@@ -25,6 +25,14 @@ export interface YamlRuleCondition {
25
25
  any?: YamlRuleCondition[];
26
26
  all?: YamlRuleCondition[];
27
27
  not?: YamlRuleCondition;
28
+ // Conditions parsed but NOT supported by the NAPI runner.
29
+ // Rules using these are skipped to prevent false positives.
30
+ inside?: YamlRuleCondition;
31
+ follows?: YamlRuleCondition;
32
+ precedes?: YamlRuleCondition;
33
+ stopBy?: string;
34
+ field?: string;
35
+ nthChild?: unknown;
28
36
  }
29
37
 
30
38
  export interface YamlRule {
@@ -34,6 +42,7 @@ export interface YamlRule {
34
42
  message?: string;
35
43
  metadata?: { weight?: number; category?: string };
36
44
  rule?: YamlRuleCondition;
45
+ constraints?: Record<string, { regex?: string }>;
37
46
  }
38
47
 
39
48
  interface CachedRules {
@@ -164,6 +173,44 @@ export function isStructuredRule(rule: YamlRule): boolean {
164
173
  );
165
174
  }
166
175
 
176
+ /**
177
+ * Check if a rule or any of its nested conditions use features
178
+ * not supported by the NAPI runner (inside, follows, precedes,
179
+ * stopBy, field, nthChild). Rules using these must be skipped
180
+ * to prevent false positives from incomplete condition evaluation.
181
+ */
182
+ export function hasUnsupportedConditions(rule: YamlRule): boolean {
183
+ if (rule.constraints) return true;
184
+ if (!rule.rule) return false;
185
+ return conditionHasUnsupported(rule.rule);
186
+ }
187
+
188
+ function conditionHasUnsupported(c: YamlRuleCondition): boolean {
189
+ if (
190
+ c.inside ||
191
+ c.follows ||
192
+ c.precedes ||
193
+ c.stopBy ||
194
+ c.field ||
195
+ c.nthChild
196
+ ) {
197
+ return true;
198
+ }
199
+ if (c.has && conditionHasUnsupported(c.has)) return true;
200
+ if (c.not && conditionHasUnsupported(c.not)) return true;
201
+ if (c.any) {
202
+ for (const sub of c.any) {
203
+ if (conditionHasUnsupported(sub)) return true;
204
+ }
205
+ }
206
+ if (c.all) {
207
+ for (const sub of c.all) {
208
+ if (conditionHasUnsupported(sub)) return true;
209
+ }
210
+ }
211
+ return false;
212
+ }
213
+
167
214
  export function calculateRuleComplexity(
168
215
  condition: YamlRuleCondition | undefined,
169
216
  ): number {
@@ -264,6 +311,13 @@ export function parseSimpleYaml(content: string): YamlRule | null {
264
311
  value === "|"
265
312
  ? (multilineKey = "message")
266
313
  : (rule.message = stripQuotes(value));
314
+ } else if (key === "constraints") {
315
+ rule.constraints = {};
316
+ stack.push({
317
+ name: "constraints",
318
+ indent,
319
+ obj: rule.constraints as unknown as YamlNode,
320
+ });
267
321
  } else if (key === "metadata") {
268
322
  rule.metadata = {};
269
323
  stack.push({ name: "metadata", indent, obj: rule.metadata as YamlNode });
@@ -288,6 +342,17 @@ export function parseSimpleYaml(content: string): YamlRule | null {
288
342
  obj.kind = value;
289
343
  } else if (key === "regex") {
290
344
  obj.regex = stripQuotes(value);
345
+ } else if (key === "inside" || key === "follows" || key === "precedes") {
346
+ // Mark as present for unsupported-condition detection
347
+ obj[key] = {} as YamlRuleCondition;
348
+ stack.push({ name: key, indent, obj: obj[key] as YamlNode });
349
+ } else if (key === "stopBy") {
350
+ obj.stopBy = stripQuotes(value) || "end";
351
+ } else if (key === "field") {
352
+ obj.field = stripQuotes(value);
353
+ } else if (key === "nthChild") {
354
+ obj.nthChild = value || true;
355
+ stack.push({ name: "nthChild", indent, obj: {} as YamlNode });
291
356
  } else if (key === "has" || key === "not") {
292
357
  obj[key] = {} as YamlRuleCondition;
293
358
  stack.push({ name: key, indent, obj: obj[key] as YamlNode });
@@ -316,12 +381,16 @@ export function parseSimpleYaml(content: string): YamlRule | null {
316
381
  });
317
382
 
318
383
  const itemContent = nextTrimmed.substring(2);
319
- if (itemContent.includes(":")) {
320
- const [itemKey, itemVal] = itemContent.split(":", 2);
384
+ const colonPos = itemContent.indexOf(":");
385
+ if (colonPos !== -1) {
386
+ const itemKey = itemContent.substring(0, colonPos);
387
+ const itemVal = itemContent.substring(colonPos + 1);
321
388
  if (itemKey.trim() === "pattern") {
322
389
  item.pattern = stripQuotes(itemVal.trim());
323
390
  } else if (itemKey.trim() === "kind") {
324
391
  item.kind = itemVal.trim();
392
+ } else if (itemKey.trim() === "regex") {
393
+ item.regex = stripQuotes(itemVal.trim());
325
394
  }
326
395
  } else if (itemContent) {
327
396
  item.pattern = stripQuotes(itemContent);
@@ -5,7 +5,7 @@
5
5
  * - BLOCKING: Errors that stop the agent (architect, ts-lsp errors)
6
6
  * - WARNING: Non-blocking issues (biome warnings, type-safety)
7
7
  * - FIXABLE: Issues with auto-fix available
8
- * - SILENT: Metrics tracked but not shown (complexity, TDR)
8
+ * - SILENT: Metrics tracked but not shown (complexity)
9
9
  * - INFORMATIONAL: Shown in session summary only
10
10
  *
11
11
  * The dispatcher must handle these semantics consistently.
@@ -5,7 +5,7 @@
5
5
  * - BLOCKING: Errors that stop the agent (architect, ts-lsp errors)
6
6
  * - WARNING: Non-blocking issues (biome warnings, type-safety)
7
7
  * - FIXABLE: Issues with auto-fix available
8
- * - SILENT: Metrics tracked but not shown (complexity, TDR)
8
+ * - SILENT: Metrics tracked but not shown (complexity)
9
9
  * - INFORMATIONAL: Shown in session summary only
10
10
  *
11
11
  * The dispatcher must handle these semantics consistently.
@@ -430,6 +430,9 @@ function createMockClient(diagnostics = []) {
430
430
  documentSymbol: vi.fn().mockResolvedValue([]),
431
431
  workspaceSymbol: vi.fn().mockResolvedValue([]),
432
432
  implementation: vi.fn().mockResolvedValue([]),
433
+ prepareCallHierarchy: vi.fn().mockResolvedValue([]),
434
+ incomingCalls: vi.fn().mockResolvedValue([]),
435
+ outgoingCalls: vi.fn().mockResolvedValue([]),
433
436
  shutdown: vi.fn().mockResolvedValue(undefined),
434
437
  };
435
438
  }
@@ -522,6 +522,9 @@ function createMockClient(diagnostics: any[] = []): LSPClientInfo {
522
522
  documentSymbol: vi.fn().mockResolvedValue([]),
523
523
  workspaceSymbol: vi.fn().mockResolvedValue([]),
524
524
  implementation: vi.fn().mockResolvedValue([]),
525
+ prepareCallHierarchy: vi.fn().mockResolvedValue([]),
526
+ incomingCalls: vi.fn().mockResolvedValue([]),
527
+ outgoingCalls: vi.fn().mockResolvedValue([]),
525
528
  shutdown: vi.fn().mockResolvedValue(undefined),
526
529
  };
527
530
  }
@@ -263,6 +263,48 @@ export async function createLSPClient(options) {
263
263
  return [];
264
264
  }
265
265
  },
266
+ // --- Call Hierarchy Methods ---
267
+ async prepareCallHierarchy(filePath, line, character) {
268
+ const uri = pathToFileURL(filePath).href;
269
+ try {
270
+ const result = await connection.sendRequest("textDocument/prepareCallHierarchy", {
271
+ textDocument: { uri },
272
+ position: { line, character },
273
+ });
274
+ if (!result)
275
+ return [];
276
+ return Array.isArray(result) ? result : [result];
277
+ }
278
+ catch {
279
+ return [];
280
+ }
281
+ },
282
+ async incomingCalls(item) {
283
+ try {
284
+ const result = await connection.sendRequest("callHierarchy/incomingCalls", {
285
+ item,
286
+ });
287
+ if (!result)
288
+ return [];
289
+ return Array.isArray(result) ? result : [];
290
+ }
291
+ catch {
292
+ return [];
293
+ }
294
+ },
295
+ async outgoingCalls(item) {
296
+ try {
297
+ const result = await connection.sendRequest("callHierarchy/outgoingCalls", {
298
+ item,
299
+ });
300
+ if (!result)
301
+ return [];
302
+ return Array.isArray(result) ? result : [];
303
+ }
304
+ catch {
305
+ return [];
306
+ }
307
+ },
266
308
  async shutdown() {
267
309
  // Clear pending timers
268
310
  for (const timer of pendingDiagnostics.values()) {
@@ -59,6 +59,26 @@ export interface LSPSymbol {
59
59
  children?: LSPSymbol[];
60
60
  }
61
61
 
62
+ // --- Call Hierarchy Types ---
63
+
64
+ export interface LSPCallHierarchyItem {
65
+ name: string;
66
+ kind: number;
67
+ uri: string;
68
+ range: LSPLocation["range"];
69
+ selectionRange: LSPLocation["range"];
70
+ }
71
+
72
+ export interface LSPCallHierarchyIncomingCall {
73
+ from: LSPCallHierarchyItem;
74
+ fromRanges: LSPLocation["range"][];
75
+ }
76
+
77
+ export interface LSPCallHierarchyOutgoingCall {
78
+ to: LSPCallHierarchyItem;
79
+ fromRanges: LSPLocation["range"][];
80
+ }
81
+
62
82
  export interface LSPClientInfo {
63
83
  serverId: string;
64
84
  root: string;
@@ -100,6 +120,16 @@ export interface LSPClientInfo {
100
120
  line: number,
101
121
  character: number,
102
122
  ): Promise<LSPLocation[]>;
123
+ /** Prepare call hierarchy at position */
124
+ prepareCallHierarchy(
125
+ filePath: string,
126
+ line: number,
127
+ character: number,
128
+ ): Promise<LSPCallHierarchyItem[]>;
129
+ /** Find incoming calls (callers) */
130
+ incomingCalls(item: LSPCallHierarchyItem): Promise<LSPCallHierarchyIncomingCall[]>;
131
+ /** Find outgoing calls (callees) */
132
+ outgoingCalls(item: LSPCallHierarchyItem): Promise<LSPCallHierarchyOutgoingCall[]>;
103
133
  shutdown(): Promise<void>;
104
134
  }
105
135
 
@@ -402,6 +432,55 @@ export async function createLSPClient(options: {
402
432
  }
403
433
  },
404
434
 
435
+ // --- Call Hierarchy Methods ---
436
+
437
+ async prepareCallHierarchy(filePath, line, character) {
438
+ const uri = pathToFileURL(filePath).href;
439
+ try {
440
+ const result = await connection.sendRequest(
441
+ "textDocument/prepareCallHierarchy",
442
+ {
443
+ textDocument: { uri },
444
+ position: { line, character },
445
+ },
446
+ );
447
+ if (!result) return [];
448
+ return Array.isArray(result) ? result : [result];
449
+ } catch {
450
+ return [];
451
+ }
452
+ },
453
+
454
+ async incomingCalls(item) {
455
+ try {
456
+ const result = await connection.sendRequest(
457
+ "callHierarchy/incomingCalls",
458
+ {
459
+ item,
460
+ },
461
+ );
462
+ if (!result) return [];
463
+ return Array.isArray(result) ? result : [];
464
+ } catch {
465
+ return [];
466
+ }
467
+ },
468
+
469
+ async outgoingCalls(item) {
470
+ try {
471
+ const result = await connection.sendRequest(
472
+ "callHierarchy/outgoingCalls",
473
+ {
474
+ item,
475
+ },
476
+ );
477
+ if (!result) return [];
478
+ return Array.isArray(result) ? result : [];
479
+ } catch {
480
+ return [];
481
+ }
482
+ },
483
+
405
484
  async shutdown() {
406
485
  // Clear pending timers
407
486
  for (const timer of pendingDiagnostics.values()) {
@@ -191,6 +191,33 @@ export class LSPService {
191
191
  return [];
192
192
  return spawned.client.implementation(filePath, line, character);
193
193
  }
194
+ /**
195
+ * Navigation: prepare call hierarchy at position
196
+ */
197
+ async prepareCallHierarchy(filePath, line, character) {
198
+ const spawned = await this.getClientForFile(filePath);
199
+ if (!spawned)
200
+ return [];
201
+ return spawned.client.prepareCallHierarchy(filePath, line, character);
202
+ }
203
+ /**
204
+ * Navigation: find incoming calls (callers)
205
+ */
206
+ async incomingCalls(item) {
207
+ const spawned = await this.getClientForFile(item.uri.replace("file://", ""));
208
+ if (!spawned)
209
+ return [];
210
+ return spawned.client.incomingCalls(item);
211
+ }
212
+ /**
213
+ * Navigation: find outgoing calls (callees)
214
+ */
215
+ async outgoingCalls(item) {
216
+ const spawned = await this.getClientForFile(item.uri.replace("file://", ""));
217
+ if (!spawned)
218
+ return [];
219
+ return spawned.client.outgoingCalls(item);
220
+ }
194
221
  /**
195
222
  * Get all diagnostics across all tracked files (for cascade checking)
196
223
  */