pi-lens 2.0.7 → 2.0.8

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 (34) hide show
  1. package/clients/ast-grep-client.test.ts +146 -116
  2. package/clients/ast-grep-client.ts +645 -551
  3. package/clients/biome-client.test.ts +154 -137
  4. package/clients/biome-client.ts +397 -337
  5. package/clients/complexity-client.test.ts +188 -200
  6. package/clients/complexity-client.ts +815 -667
  7. package/clients/dependency-checker.test.ts +55 -55
  8. package/clients/dependency-checker.ts +358 -333
  9. package/clients/go-client.test.ts +121 -111
  10. package/clients/go-client.ts +218 -216
  11. package/clients/jscpd-client.test.ts +132 -132
  12. package/clients/jscpd-client.ts +155 -118
  13. package/clients/knip-client.test.ts +123 -133
  14. package/clients/knip-client.ts +231 -218
  15. package/clients/metrics-client.test.ts +171 -167
  16. package/clients/metrics-client.ts +283 -252
  17. package/clients/ruff-client.test.ts +128 -117
  18. package/clients/ruff-client.ts +300 -269
  19. package/clients/rust-client.test.ts +104 -85
  20. package/clients/rust-client.ts +241 -234
  21. package/clients/subprocess-client.ts +1 -1
  22. package/clients/test-runner-client.test.ts +248 -215
  23. package/clients/test-runner-client.ts +728 -608
  24. package/clients/test-utils.ts +10 -3
  25. package/clients/todo-scanner.test.ts +288 -202
  26. package/clients/todo-scanner.ts +225 -187
  27. package/clients/type-coverage-client.test.ts +119 -119
  28. package/clients/type-coverage-client.ts +142 -115
  29. package/clients/types.ts +28 -28
  30. package/clients/typescript-client.test.ts +99 -93
  31. package/clients/typescript-client.ts +527 -502
  32. package/index.ts +662 -212
  33. package/package.json +1 -1
  34. package/tsconfig.json +12 -12
@@ -9,585 +9,679 @@
9
9
  */
10
10
 
11
11
  import { spawn, spawnSync } from "node:child_process";
12
- import * as path from "node:path";
13
12
  import * as fs from "node:fs";
13
+ import * as path from "node:path";
14
14
 
15
15
  // --- Types ---
16
16
 
17
17
  export interface RuleDescription {
18
- id: string;
19
- message: string;
20
- note?: string;
21
- severity: "error" | "warning" | "info" | "hint";
18
+ id: string;
19
+ message: string;
20
+ note?: string;
21
+ severity: "error" | "warning" | "info" | "hint";
22
22
  }
23
23
 
24
24
  export interface AstGrepMatch {
25
- file: string;
26
- range: { start: { line: number; column: number }; end: { line: number; column: number } };
27
- text: string;
28
- replacement?: string;
25
+ file: string;
26
+ range: {
27
+ start: { line: number; column: number };
28
+ end: { line: number; column: number };
29
+ };
30
+ text: string;
31
+ replacement?: string;
29
32
  }
30
33
 
31
34
  export interface AstGrepDiagnostic {
32
- line: number;
33
- column: number;
34
- endLine: number;
35
- endColumn: number;
36
- severity: "error" | "warning" | "info" | "hint";
37
- message: string;
38
- rule: string;
39
- ruleDescription?: RuleDescription;
40
- file: string;
41
- fix?: string;
35
+ line: number;
36
+ column: number;
37
+ endLine: number;
38
+ endColumn: number;
39
+ severity: "error" | "warning" | "info" | "hint";
40
+ message: string;
41
+ rule: string;
42
+ ruleDescription?: RuleDescription;
43
+ file: string;
44
+ fix?: string;
42
45
  }
43
46
 
44
47
  // New ast-grep JSON format
45
48
  interface AstGrepJsonDiagnostic {
46
- ruleId: string;
47
- severity: string;
48
- message: string;
49
- note?: string;
50
- labels: Array<{
51
- text: string;
52
- range: {
53
- start: { line: number; column: number };
54
- end: { line: number; column: number };
55
- };
56
- file?: string;
57
- style: string;
58
- }>;
59
- // Legacy format support
60
- Message?: { text: string };
61
- Severity?: string;
62
- spans?: Array<{
63
- context: string;
64
- range: {
65
- start: { line: number; column: number };
66
- end: { line: number; column: number };
67
- };
68
- file: string;
69
- }>;
70
- name?: string;
49
+ ruleId: string;
50
+ severity: string;
51
+ message: string;
52
+ note?: string;
53
+ labels: Array<{
54
+ text: string;
55
+ range: {
56
+ start: { line: number; column: number };
57
+ end: { line: number; column: number };
58
+ };
59
+ file?: string;
60
+ style: string;
61
+ }>;
62
+ // Legacy format support
63
+ Message?: { text: string };
64
+ Severity?: string;
65
+ spans?: Array<{
66
+ context: string;
67
+ range: {
68
+ start: { line: number; column: number };
69
+ end: { line: number; column: number };
70
+ };
71
+ file: string;
72
+ }>;
73
+ name?: string;
71
74
  }
72
75
 
73
76
  // --- Client ---
74
77
 
75
78
  export class AstGrepClient {
76
- private available: boolean | null = null;
77
- private ruleDir: string;
78
- private log: (msg: string) => void;
79
- private ruleDescriptions: Map<string, RuleDescription> | null = null;
80
-
81
- constructor(ruleDir?: string, verbose = false) {
82
- this.ruleDir = ruleDir || path.join(typeof __dirname !== "undefined" ? __dirname : ".", "..", "rules");
83
- this.log = verbose
84
- ? (msg: string) => console.log(`[ast-grep] ${msg}`)
85
- : () => {};
86
- }
87
-
88
- /**
89
- * Load rule descriptions from YAML files
90
- */
91
- private loadRuleDescriptions(): Map<string, RuleDescription> {
92
- if (this.ruleDescriptions !== null) return this.ruleDescriptions;
93
-
94
- const descriptions = new Map<string, RuleDescription>();
95
-
96
- // Find the rules directory - check more specific paths first
97
- const possiblePaths = [
98
- path.join(this.ruleDir, "ast-grep-rules", "rules"),
99
- path.join(this.ruleDir, "rules"),
100
- this.ruleDir,
101
- ];
102
-
103
- let rulesPath = possiblePaths.find(p => fs.existsSync(p));
104
-
105
- if (!rulesPath) {
106
- this.log(`Rule descriptions: no rules directory found in ${possiblePaths.join(", ")}`);
107
- this.ruleDescriptions = descriptions;
108
- return descriptions;
109
- }
110
-
111
- try {
112
- const files = fs.readdirSync(rulesPath).filter(f => f.endsWith(".yml"));
113
- this.log(`Loaded ${files.length} rule descriptions from ${rulesPath}`);
114
- for (const file of files) {
115
- const filePath = path.join(rulesPath, file);
116
- const content = fs.readFileSync(filePath, "utf-8");
117
- const rule = this.parseRuleYaml(content);
118
- if (rule) {
119
- descriptions.set(rule.id, rule);
120
- }
121
- }
122
- } catch (err: any) {
123
- this.log(`Failed to load rule descriptions: ${err.message}`);
124
- }
125
-
126
- this.ruleDescriptions = descriptions;
127
- return descriptions;
128
- }
129
-
130
- /**
131
- * Simple YAML parser for rule descriptions
132
- */
133
- private parseRuleYaml(content: string): RuleDescription | null {
134
- const result: Partial<RuleDescription> = {};
135
-
136
- // Extract id
137
- const idMatch = content.match(/^id:\s*(.+)$/m);
138
- if (idMatch) result.id = idMatch[1].trim();
139
-
140
- // Extract message (handle quoted strings)
141
- const msgMatch = content.match(/^message:\s*"([^"]+)"/m) || content.match(/^message:\s*'([^']+)'/m) || content.match(/^message:\s*(.+)$/m);
142
- if (msgMatch) result.message = (msgMatch[3] || msgMatch[2] || msgMatch[1]).trim();
143
-
144
- // Extract note (multiline, indented lines)
145
- const noteMatch = content.match(/^note:\s*\|([\s\S]*?)(?=^\w|\n\n|\nrule:)/m);
146
- if (noteMatch) {
147
- result.note = noteMatch[1]
148
- .split("\n")
149
- .map(line => line.trim())
150
- .filter(line => line.length > 0)
151
- .join(" ");
152
- }
153
-
154
- // Extract severity
155
- const sevMatch = content.match(/^severity:\s*(.+)$/m);
156
- if (sevMatch) result.severity = this.mapSeverity(sevMatch[1].trim());
157
-
158
- if (result.id && result.message) {
159
- return result as RuleDescription;
160
- }
161
- return null;
162
- }
163
-
164
- /**
165
- * Check if ast-grep CLI is available
166
- */
167
- isAvailable(): boolean {
168
- if (this.available !== null) return this.available;
169
-
170
- const result = spawnSync("npx", ["sg", "--version"], {
171
- encoding: "utf-8",
172
- timeout: 10000,
173
- shell: true,
174
- });
175
-
176
- this.available = !result.error && result.status === 0;
177
- if (this.available) {
178
- this.log("ast-grep available");
179
- }
180
-
181
- return this.available;
182
- }
183
-
184
- /**
185
- * Search for AST patterns in files
186
- */
187
- async search(pattern: string, lang: string, paths: string[]): Promise<{ matches: AstGrepMatch[]; error?: string }> {
188
- return this.runSg(["run", "-p", pattern, "--lang", lang, "--json=compact", ...paths]);
189
- }
190
-
191
- /**
192
- * Search and replace AST patterns
193
- */
194
- async replace(pattern: string, rewrite: string, lang: string, paths: string[], apply = false): Promise<{ matches: AstGrepMatch[]; applied: boolean; error?: string }> {
195
- const args = ["run", "-p", pattern, "-r", rewrite, "--lang", lang, "--json=compact"];
196
- if (apply) args.push("--update-all");
197
- args.push(...paths);
198
-
199
- const result = await this.runSg(args);
200
- return { matches: result.matches, applied: apply, error: result.error };
201
- }
202
-
203
- /**
204
- * Find similar functions by comparing normalized AST structure
205
- */
206
- async findSimilarFunctions(dir: string, lang: string = "typescript"): Promise<Array<{ pattern: string; functions: Array<{ name: string; file: string; line: number }> }>> {
207
- if (!this.isAvailable()) return [];
208
-
209
- const tmpDir = require("node:os").tmpdir();
210
- const ts = Date.now();
211
- const ruleDir = require("node:path").join(tmpDir, `pi-lens-similar-${ts}`);
212
- const rulesSubdir = require("node:path").join(ruleDir, "rules");
213
- const ruleFile = require("node:path").join(rulesSubdir, "find-functions.yml");
214
- const configFile = require("node:path").join(ruleDir, ".sgconfig.yml");
215
-
216
- require("node:fs").mkdirSync(rulesSubdir, { recursive: true });
217
- require("node:fs").writeFileSync(configFile, `ruleDirs:\n - ./rules\n`);
218
- require("node:fs").writeFileSync(ruleFile, `id: find-functions
79
+ private available: boolean | null = null;
80
+ private ruleDir: string;
81
+ private log: (msg: string) => void;
82
+ private ruleDescriptions: Map<string, RuleDescription> | null = null;
83
+
84
+ constructor(ruleDir?: string, verbose = false) {
85
+ this.ruleDir =
86
+ ruleDir ||
87
+ path.join(
88
+ typeof __dirname !== "undefined" ? __dirname : ".",
89
+ "..",
90
+ "rules",
91
+ );
92
+ this.log = verbose
93
+ ? (msg: string) => console.log(`[ast-grep] ${msg}`)
94
+ : () => {};
95
+ }
96
+
97
+ /**
98
+ * Load rule descriptions from YAML files
99
+ */
100
+ private loadRuleDescriptions(): Map<string, RuleDescription> {
101
+ if (this.ruleDescriptions !== null) return this.ruleDescriptions;
102
+
103
+ const descriptions = new Map<string, RuleDescription>();
104
+
105
+ // Find the rules directory - check more specific paths first
106
+ const possiblePaths = [
107
+ path.join(this.ruleDir, "ast-grep-rules", "rules"),
108
+ path.join(this.ruleDir, "rules"),
109
+ this.ruleDir,
110
+ ];
111
+
112
+ const rulesPath = possiblePaths.find((p) => fs.existsSync(p));
113
+
114
+ if (!rulesPath) {
115
+ this.log(
116
+ `Rule descriptions: no rules directory found in ${possiblePaths.join(", ")}`,
117
+ );
118
+ this.ruleDescriptions = descriptions;
119
+ return descriptions;
120
+ }
121
+
122
+ try {
123
+ const files = fs.readdirSync(rulesPath).filter((f) => f.endsWith(".yml"));
124
+ this.log(`Loaded ${files.length} rule descriptions from ${rulesPath}`);
125
+ for (const file of files) {
126
+ const filePath = path.join(rulesPath, file);
127
+ const content = fs.readFileSync(filePath, "utf-8");
128
+ const rule = this.parseRuleYaml(content);
129
+ if (rule) {
130
+ descriptions.set(rule.id, rule);
131
+ }
132
+ }
133
+ } catch (err: any) {
134
+ this.log(`Failed to load rule descriptions: ${err.message}`);
135
+ }
136
+
137
+ this.ruleDescriptions = descriptions;
138
+ return descriptions;
139
+ }
140
+
141
+ /**
142
+ * Simple YAML parser for rule descriptions
143
+ */
144
+ private parseRuleYaml(content: string): RuleDescription | null {
145
+ const result: Partial<RuleDescription> = {};
146
+
147
+ // Extract id
148
+ const idMatch = content.match(/^id:\s*(.+)$/m);
149
+ if (idMatch) result.id = idMatch[1].trim();
150
+
151
+ // Extract message (handle quoted strings)
152
+ const msgMatch =
153
+ content.match(/^message:\s*"([^"]+)"/m) ||
154
+ content.match(/^message:\s*'([^']+)'/m) ||
155
+ content.match(/^message:\s*(.+)$/m);
156
+ if (msgMatch)
157
+ result.message = (msgMatch[3] || msgMatch[2] || msgMatch[1]).trim();
158
+
159
+ // Extract note (multiline, indented lines)
160
+ const noteMatch = content.match(
161
+ /^note:\s*\|([\s\S]*?)(?=^\w|\n\n|\nrule:)/m,
162
+ );
163
+ if (noteMatch) {
164
+ result.note = noteMatch[1]
165
+ .split("\n")
166
+ .map((line) => line.trim())
167
+ .filter((line) => line.length > 0)
168
+ .join(" ");
169
+ }
170
+
171
+ // Extract severity
172
+ const sevMatch = content.match(/^severity:\s*(.+)$/m);
173
+ if (sevMatch) result.severity = this.mapSeverity(sevMatch[1].trim());
174
+
175
+ if (result.id && result.message) {
176
+ return result as RuleDescription;
177
+ }
178
+ return null;
179
+ }
180
+
181
+ /**
182
+ * Check if ast-grep CLI is available
183
+ */
184
+ isAvailable(): boolean {
185
+ if (this.available !== null) return this.available;
186
+
187
+ const result = spawnSync("npx", ["sg", "--version"], {
188
+ encoding: "utf-8",
189
+ timeout: 10000,
190
+ shell: true,
191
+ });
192
+
193
+ this.available = !result.error && result.status === 0;
194
+ if (this.available) {
195
+ this.log("ast-grep available");
196
+ }
197
+
198
+ return this.available;
199
+ }
200
+
201
+ /**
202
+ * Search for AST patterns in files
203
+ */
204
+ async search(
205
+ pattern: string,
206
+ lang: string,
207
+ paths: string[],
208
+ ): Promise<{ matches: AstGrepMatch[]; error?: string }> {
209
+ return this.runSg([
210
+ "run",
211
+ "-p",
212
+ pattern,
213
+ "--lang",
214
+ lang,
215
+ "--json=compact",
216
+ ...paths,
217
+ ]);
218
+ }
219
+
220
+ /**
221
+ * Search and replace AST patterns
222
+ */
223
+ async replace(
224
+ pattern: string,
225
+ rewrite: string,
226
+ lang: string,
227
+ paths: string[],
228
+ apply = false,
229
+ ): Promise<{ matches: AstGrepMatch[]; applied: boolean; error?: string }> {
230
+ const args = [
231
+ "run",
232
+ "-p",
233
+ pattern,
234
+ "-r",
235
+ rewrite,
236
+ "--lang",
237
+ lang,
238
+ "--json=compact",
239
+ ];
240
+ if (apply) args.push("--update-all");
241
+ args.push(...paths);
242
+
243
+ const result = await this.runSg(args);
244
+ return { matches: result.matches, applied: apply, error: result.error };
245
+ }
246
+
247
+ /**
248
+ * Find similar functions by comparing normalized AST structure
249
+ */
250
+ async findSimilarFunctions(
251
+ dir: string,
252
+ lang: string = "typescript",
253
+ ): Promise<
254
+ Array<{
255
+ pattern: string;
256
+ functions: Array<{ name: string; file: string; line: number }>;
257
+ }>
258
+ > {
259
+ if (!this.isAvailable()) return [];
260
+
261
+ const tmpDir = require("node:os").tmpdir();
262
+ const ts = Date.now();
263
+ const ruleDir = require("node:path").join(tmpDir, `pi-lens-similar-${ts}`);
264
+ const rulesSubdir = require("node:path").join(ruleDir, "rules");
265
+ const ruleFile = require("node:path").join(
266
+ rulesSubdir,
267
+ "find-functions.yml",
268
+ );
269
+ const configFile = require("node:path").join(ruleDir, ".sgconfig.yml");
270
+
271
+ require("node:fs").mkdirSync(rulesSubdir, { recursive: true });
272
+ require("node:fs").writeFileSync(configFile, `ruleDirs:\n - ./rules\n`);
273
+ require("node:fs").writeFileSync(
274
+ ruleFile,
275
+ `id: find-functions
219
276
  language: ${lang}
220
277
  rule:
221
278
  kind: function_declaration
222
279
  severity: info
223
280
  message: found
224
- `);
225
-
226
- try {
227
- const result = spawnSync("npx", [
228
- "sg", "scan",
229
- "--config", configFile,
230
- "--json",
231
- dir,
232
- ], {
233
- encoding: "utf-8",
234
- timeout: 30000,
235
- shell: true,
236
- });
237
-
238
- const output = result.stdout || result.stderr || "";
239
- if (!output.trim()) return [];
240
-
241
- const items = JSON.parse(output);
242
- const matches = Array.isArray(items) ? items : [items];
243
-
244
- // Normalize each function by removing identifiers
245
- const normalized = new Map<string, Array<{ name: string; file: string; line: number }>>();
246
-
247
- for (const item of matches) {
248
- const text = item.text || "";
249
- const nameMatch = text.match(/function\s+(\w+)/);
250
- if (!nameMatch || !nameMatch[1]) continue;
251
-
252
- // Normalize by replacing function name with FN, parameters with P1..Pn, and removing specific values
253
- let normalizedText = text
254
- .replace(/function\s+\w+/, "function FN")
255
- .replace(/\bconst\b|\blet\b|\bvar\b/g, "VAR")
256
- .replace(/["'].*?["']/g, "STR")
257
- .replace(/`[^`]*`/g, "TMPL")
258
- .replace(/\b\d+\b/g, "NUM")
259
- .replace(/\btrue\b|\bfalse\b/g, "BOOL")
260
- .replace(/\/\/.*/g, "")
261
- .replace(/\/\*[\s\S]*?\*\//g, "")
262
- .replace(/\s+/g, " ")
263
- .trim();
264
-
265
- // Extract just the body structure
266
- const bodyMatch = normalizedText.match(/\{(.*)\}/);
267
- const body = bodyMatch ? bodyMatch[1].trim() : normalizedText;
268
-
269
- // Use first 200 chars as signature
270
- const signature = body.slice(0, 200);
271
-
272
- if (!normalized.has(signature)) {
273
- normalized.set(signature, []);
274
- }
275
-
276
- const line = item.range?.start?.line || item.labels?.[0]?.range?.start?.line || 0;
277
- normalized.get(signature)!.push({
278
- name: nameMatch[1],
279
- file: item.file,
280
- line: line + 1,
281
- });
282
- }
283
-
284
- // Return groups with more than one function
285
- const result_groups: Array<{ pattern: string; functions: Array<{ name: string; file: string; line: number }> }> = [];
286
- for (const [pattern, functions] of normalized) {
287
- if (functions.length > 1) {
288
- result_groups.push({ pattern, functions });
289
- }
290
- }
291
-
292
- return result_groups;
293
- } catch {
294
- return [];
295
- } finally {
296
- try { require("node:fs").rmSync(ruleDir, { recursive: true, force: true }); } catch {}
297
- }
298
- }
299
-
300
- /**
301
- * Scan for exported function names in a directory
302
- */
303
- async scanExports(dir: string, lang: string = "typescript"): Promise<Map<string, string>> {
304
- const exports = new Map<string, string>();
305
-
306
- if (!this.isAvailable()) {
307
- return exports;
308
- }
309
-
310
- const tmpDir = require("node:os").tmpdir();
311
- const ts = Date.now();
312
- const ruleDir = require("node:path").join(tmpDir, `pi-lens-exports-${ts}`);
313
- const rulesSubdir = require("node:path").join(ruleDir, "rules");
314
- const ruleFile = require("node:path").join(rulesSubdir, "find-functions.yml");
315
- const configFile = require("node:path").join(ruleDir, ".sgconfig.yml");
316
-
317
- require("node:fs").mkdirSync(rulesSubdir, { recursive: true });
318
-
319
- require("node:fs").writeFileSync(configFile, `ruleDirs:\n - ./rules\n`);
320
- require("node:fs").writeFileSync(ruleFile, `id: find-functions
281
+ `,
282
+ );
283
+
284
+ try {
285
+ const result = spawnSync(
286
+ "npx",
287
+ ["sg", "scan", "--config", configFile, "--json", dir],
288
+ {
289
+ encoding: "utf-8",
290
+ timeout: 30000,
291
+ shell: true,
292
+ },
293
+ );
294
+
295
+ const output = result.stdout || result.stderr || "";
296
+ if (!output.trim()) return [];
297
+
298
+ const items = JSON.parse(output);
299
+ const matches = Array.isArray(items) ? items : [items];
300
+
301
+ // Normalize each function by removing identifiers
302
+ const normalized = new Map<
303
+ string,
304
+ Array<{ name: string; file: string; line: number }>
305
+ >();
306
+
307
+ for (const item of matches) {
308
+ const text = item.text || "";
309
+ const nameMatch = text.match(/function\s+(\w+)/);
310
+ if (!nameMatch?.[1]) continue;
311
+
312
+ // Normalize by replacing function name with FN, parameters with P1..Pn, and removing specific values
313
+ const normalizedText = text
314
+ .replace(/function\s+\w+/, "function FN")
315
+ .replace(/\bconst\b|\blet\b|\bvar\b/g, "VAR")
316
+ .replace(/["'].*?["']/g, "STR")
317
+ .replace(/`[^`]*`/g, "TMPL")
318
+ .replace(/\b\d+\b/g, "NUM")
319
+ .replace(/\btrue\b|\bfalse\b/g, "BOOL")
320
+ .replace(/\/\/.*/g, "")
321
+ .replace(/\/\*[\s\S]*?\*\//g, "")
322
+ .replace(/\s+/g, " ")
323
+ .trim();
324
+
325
+ // Extract just the body structure
326
+ const bodyMatch = normalizedText.match(/\{(.*)\}/);
327
+ const body = bodyMatch ? bodyMatch[1].trim() : normalizedText;
328
+
329
+ // Use first 200 chars as signature
330
+ const signature = body.slice(0, 200);
331
+
332
+ if (!normalized.has(signature)) {
333
+ normalized.set(signature, []);
334
+ }
335
+
336
+ const line =
337
+ item.range?.start?.line || item.labels?.[0]?.range?.start?.line || 0;
338
+ normalized.get(signature)?.push({
339
+ name: nameMatch[1],
340
+ file: item.file,
341
+ line: line + 1,
342
+ });
343
+ }
344
+
345
+ // Return groups with more than one function
346
+ const result_groups: Array<{
347
+ pattern: string;
348
+ functions: Array<{ name: string; file: string; line: number }>;
349
+ }> = [];
350
+ for (const [pattern, functions] of normalized) {
351
+ if (functions.length > 1) {
352
+ result_groups.push({ pattern, functions });
353
+ }
354
+ }
355
+
356
+ return result_groups;
357
+ } catch {
358
+ return [];
359
+ } finally {
360
+ try {
361
+ require("node:fs").rmSync(ruleDir, { recursive: true, force: true });
362
+ } catch (err) { void err; }
363
+ }
364
+ }
365
+
366
+ /**
367
+ * Scan for exported function names in a directory
368
+ */
369
+ async scanExports(
370
+ dir: string,
371
+ lang: string = "typescript",
372
+ ): Promise<Map<string, string>> {
373
+ const exports = new Map<string, string>();
374
+
375
+ if (!this.isAvailable()) {
376
+ return exports;
377
+ }
378
+
379
+ const tmpDir = require("node:os").tmpdir();
380
+ const ts = Date.now();
381
+ const ruleDir = require("node:path").join(tmpDir, `pi-lens-exports-${ts}`);
382
+ const rulesSubdir = require("node:path").join(ruleDir, "rules");
383
+ const ruleFile = require("node:path").join(
384
+ rulesSubdir,
385
+ "find-functions.yml",
386
+ );
387
+ const configFile = require("node:path").join(ruleDir, ".sgconfig.yml");
388
+
389
+ require("node:fs").mkdirSync(rulesSubdir, { recursive: true });
390
+
391
+ require("node:fs").writeFileSync(configFile, `ruleDirs:\n - ./rules\n`);
392
+ require("node:fs").writeFileSync(
393
+ ruleFile,
394
+ `id: find-functions
321
395
  language: ${lang}
322
396
  rule:
323
397
  kind: function_declaration
324
398
  severity: info
325
399
  message: found
326
- `);
327
-
328
- try {
329
- const result = spawnSync("npx", [
330
- "sg", "scan",
331
- "--config", configFile,
332
- "--json",
333
- dir,
334
- ], {
335
- encoding: "utf-8",
336
- timeout: 15000,
337
- shell: true,
338
- });
339
-
340
-
341
-
342
- const output = result.stdout || result.stderr || "";
343
- this.log(`scanExports output length: ${output.length}`);
344
- if (output.trim()) {
345
- try {
346
- const items = JSON.parse(output);
347
- const matches = Array.isArray(items) ? items : [items];
348
-
349
- for (const item of matches) {
350
- const text = item.text || "";
351
- const nameMatch = text.match(/function\s+(\w+)/);
352
- if (nameMatch && nameMatch[1]) {
353
- this.log(`scanExports found: ${nameMatch[1]} in ${item.file}`);
354
- exports.set(nameMatch[1], item.file);
355
- }
356
- }
357
- } catch (e) {
358
- this.log(`scanExports parse error: ${e}`);
359
- }
360
- }
361
- } catch (err: any) {
362
- this.log(`scanExports error: ${err.message}`);
363
- } finally {
364
- try { require("node:fs").rmSync(ruleDir, { recursive: true, force: true }); } catch {}
365
- }
366
-
367
- return exports;
368
- }
369
-
370
- private runSg(args: string[]): Promise<{ matches: AstGrepMatch[]; error?: string }> {
371
- return new Promise((resolve) => {
372
- const proc = spawn("npx", ["sg", ...args], { stdio: ["ignore", "pipe", "pipe"], shell: true });
373
- let stdout = "";
374
- let stderr = "";
375
-
376
- proc.stdout.on("data", (data: Buffer) => (stdout += data.toString()));
377
- proc.stderr.on("data", (data: Buffer) => (stderr += data.toString()));
378
-
379
- proc.on("error", (err: Error) => {
380
- if (err.message.includes("ENOENT")) {
381
- resolve({ matches: [], error: "ast-grep CLI not found. Install: npm i -D @ast-grep/cli" });
382
- } else {
383
- resolve({ matches: [], error: err.message });
384
- }
385
- });
386
-
387
- proc.on("close", (code: number | null) => {
388
- if (code !== 0 && !stdout.trim()) {
389
- resolve({ matches: [], error: stderr.includes("No files found") ? undefined : stderr.trim() || `Exit code ${code}` });
390
- return;
391
- }
392
- if (!stdout.trim()) { resolve({ matches: [] }); return; }
393
- try {
394
- const parsed = JSON.parse(stdout);
395
- const matches = Array.isArray(parsed) ? parsed : [parsed];
396
- resolve({ matches });
397
- } catch {
398
- resolve({ matches: [], error: "Failed to parse output" });
399
- }
400
- });
401
- });
402
- }
403
-
404
- formatMatches(matches: AstGrepMatch[], isDryRun = false): string {
405
- if (matches.length === 0) return "No matches found";
406
- const MAX = 50;
407
- const shown = matches.slice(0, MAX);
408
- const lines = shown.map((m) => {
409
- const loc = `${m.file}:${m.range.start.line + 1}:${m.range.start.column + 1}`;
410
- const text = m.text.length > 100 ? m.text.slice(0, 100) + "..." : m.text;
411
- return isDryRun && m.replacement ? `${loc}\n - ${text}\n + ${m.replacement}` : `${loc}: ${text}`;
412
- });
413
- if (matches.length > MAX) lines.unshift(`Found ${matches.length} matches (showing first ${MAX}):`);
414
- return lines.join("\n");
415
- }
416
-
417
- /**
418
- * Scan a file against all rules
419
- */
420
- scanFile(filePath: string): AstGrepDiagnostic[] {
421
- if (!this.isAvailable()) return [];
422
-
423
- const absolutePath = path.resolve(filePath);
424
- if (!fs.existsSync(absolutePath)) return [];
425
-
426
- const configPath = path.join(this.ruleDir, ".sgconfig.yml");
427
-
428
- try {
429
- const result = spawnSync("npx", [
430
- "sg",
431
- "scan",
432
- "--config", configPath,
433
- "--json",
434
- absolutePath,
435
- ], {
436
- encoding: "utf-8",
437
- timeout: 15000,
438
- shell: true,
439
- });
440
-
441
- // ast-grep exits 1 when it finds issues
442
- const output = result.stdout || result.stderr || "";
443
- if (!output.trim()) return [];
444
-
445
- return this.parseOutput(output, absolutePath);
446
- } catch (err: any) {
447
- this.log(`Scan error: ${err.message}`);
448
- return [];
449
- }
450
- }
451
-
452
- /**
453
- * Format diagnostics for LLM consumption
454
- */
455
- formatDiagnostics(diags: AstGrepDiagnostic[]): string {
456
- if (diags.length === 0) return "";
457
-
458
- const errors = diags.filter(d => d.severity === "error");
459
- const warnings = diags.filter(d => d.severity === "warning");
460
- const hints = diags.filter(d => d.severity === "hint");
461
-
462
- let output = `[ast-grep] ${diags.length} structural issue(s)`;
463
- if (errors.length) output += ` — ${errors.length} error(s)`;
464
- if (warnings.length) output += ` ${warnings.length} warning(s)`;
465
- if (hints.length) output += ` — ${hints.length} hint(s)`;
466
- output += ":\n";
467
-
468
- for (const d of diags.slice(0, 10)) {
469
- const loc = d.line === d.endLine
470
- ? `L${d.line}`
471
- : `L${d.line}-${d.endLine}`;
472
- const ruleInfo = d.ruleDescription
473
- ? `${d.rule}: ${d.ruleDescription.message}`
474
- : d.rule;
475
- const fix = d.fix || d.ruleDescription?.note ? " [fixable]" : "";
476
- output += ` ${ruleInfo} (${loc})${fix}\n`;
477
-
478
- // Include note for actionable guidance
479
- if (d.ruleDescription?.note) {
480
- const shortNote = d.ruleDescription.note.split("\n")[0];
481
- output += ` ${shortNote}\n`;
482
- }
483
- }
484
-
485
- if (diags.length > 10) {
486
- output += ` ... and ${diags.length - 10} more\n`;
487
- }
488
-
489
- return output;
490
- }
491
-
492
- // --- Internal ---
493
-
494
- private parseOutput(output: string, filterFile: string): AstGrepDiagnostic[] {
495
- const diagnostics: AstGrepDiagnostic[] = [];
496
- const resolvedFilterFile = path.resolve(filterFile);
497
-
498
- // Try parsing as JSON array first (new format)
499
- try {
500
- const items: AstGrepJsonDiagnostic[] = JSON.parse(output);
501
- if (Array.isArray(items)) {
502
- for (const item of items) {
503
- const diag = this.parseDiagnostic(item, resolvedFilterFile);
504
- if (diag) diagnostics.push(diag);
505
- }
506
- return diagnostics;
507
- }
508
- } catch {
509
- // Not a JSON array, try ndjson format (legacy)
510
- }
511
-
512
- // Parse ndjson (one JSON object per line) - legacy format
513
- const lines = output.split("\n").filter(l => l.trim());
514
-
515
- for (const line of lines) {
516
- try {
517
- const item: AstGrepJsonDiagnostic = JSON.parse(line);
518
- const diag = this.parseDiagnostic(item, resolvedFilterFile);
519
- if (diag) diagnostics.push(diag);
520
- } catch {
521
- // Skip unparseable lines
522
- }
523
- }
524
-
525
- return diagnostics;
526
- }
527
-
528
- private parseDiagnostic(item: AstGrepJsonDiagnostic, filterFile: string): AstGrepDiagnostic | null {
529
- // New format uses labels array
530
- if (item.labels && item.labels.length > 0) {
531
- const label = item.labels.find(l => l.style === "primary") || item.labels[0];
532
- const filePath = path.resolve(label.file || filterFile);
533
-
534
- // Filter to our file
535
- if (filePath !== filterFile) return null;
536
-
537
- const start = label.range?.start || { line: 0, column: 0 };
538
- const end = label.range?.end || start;
539
-
540
- return {
541
- line: start.line + 1, // ast-grep is 0-indexed, we want 1-indexed
542
- column: start.column,
543
- endLine: end.line + 1,
544
- endColumn: end.column,
545
- severity: this.mapSeverity(item.severity),
546
- message: item.message || "Unknown issue",
547
- rule: item.ruleId || "unknown",
548
- ruleDescription: this.getRuleDescription(item.ruleId || "unknown"),
549
- file: filePath,
550
- };
551
- }
552
-
553
- // Legacy format uses spans array
554
- if (item.spans && item.spans.length > 0) {
555
- const span = item.spans[0];
556
- const filePath = path.resolve(span.file || filterFile);
557
-
558
- // Filter to our file
559
- if (filePath !== filterFile) return null;
560
-
561
- const start = span.range?.start || { line: 0, column: 0 };
562
- const end = span.range?.end || start;
563
-
564
- const ruleId = item.name || item.ruleId || "unknown";
565
- return {
566
- line: start.line + 1,
567
- column: start.column,
568
- endLine: end.line + 1,
569
- endColumn: end.column,
570
- severity: this.mapSeverity(item.severity || item.Severity || "warning"),
571
- message: item.Message?.text || item.message || "Unknown issue",
572
- rule: ruleId,
573
- ruleDescription: this.getRuleDescription(ruleId),
574
- file: filePath,
575
- };
576
- }
577
-
578
- return null;
579
- }
580
-
581
- getRuleDescription(ruleId: string): RuleDescription | undefined {
582
- const descriptions = this.loadRuleDescriptions();
583
- return descriptions.get(ruleId);
584
- }
585
-
586
- private mapSeverity(severity: string): AstGrepDiagnostic["severity"] {
587
- const lower = severity.toLowerCase();
588
- if (lower === "error") return "error";
589
- if (lower === "warning") return "warning";
590
- if (lower === "info") return "info";
591
- return "hint";
592
- }
400
+ `,
401
+ );
402
+
403
+ try {
404
+ const result = spawnSync(
405
+ "npx",
406
+ ["sg", "scan", "--config", configFile, "--json", dir],
407
+ {
408
+ encoding: "utf-8",
409
+ timeout: 15000,
410
+ shell: true,
411
+ },
412
+ );
413
+
414
+ const output = result.stdout || result.stderr || "";
415
+ this.log(`scanExports output length: ${output.length}`);
416
+ if (output.trim()) {
417
+ try {
418
+ const items = JSON.parse(output);
419
+ const matches = Array.isArray(items) ? items : [items];
420
+
421
+ for (const item of matches) {
422
+ const text = item.text || "";
423
+ const nameMatch = text.match(/function\s+(\w+)/);
424
+ if (nameMatch?.[1]) {
425
+ this.log(`scanExports found: ${nameMatch[1]} in ${item.file}`);
426
+ exports.set(nameMatch[1], item.file);
427
+ }
428
+ }
429
+ } catch (e) {
430
+ this.log(`scanExports parse error: ${e}`);
431
+ }
432
+ }
433
+ } catch (err: any) {
434
+ this.log(`scanExports error: ${err.message}`);
435
+ } finally {
436
+ try {
437
+ require("node:fs").rmSync(ruleDir, { recursive: true, force: true });
438
+ } catch (err) { void err; }
439
+ }
440
+
441
+ return exports;
442
+ }
443
+
444
+ private runSg(
445
+ args: string[],
446
+ ): Promise<{ matches: AstGrepMatch[]; error?: string }> {
447
+ return new Promise((resolve) => {
448
+ const proc = spawn("npx", ["sg", ...args], {
449
+ stdio: ["ignore", "pipe", "pipe"],
450
+ shell: true,
451
+ });
452
+ let stdout = "";
453
+ let stderr = "";
454
+
455
+ proc.stdout.on("data", (data: Buffer) => (stdout += data.toString()));
456
+ proc.stderr.on("data", (data: Buffer) => (stderr += data.toString()));
457
+
458
+ proc.on("error", (err: Error) => {
459
+ if (err.message.includes("ENOENT")) {
460
+ resolve({
461
+ matches: [],
462
+ error: "ast-grep CLI not found. Install: npm i -D @ast-grep/cli",
463
+ });
464
+ } else {
465
+ resolve({ matches: [], error: err.message });
466
+ }
467
+ });
468
+
469
+ proc.on("close", (code: number | null) => {
470
+ if (code !== 0 && !stdout.trim()) {
471
+ resolve({
472
+ matches: [],
473
+ error: stderr.includes("No files found")
474
+ ? undefined
475
+ : stderr.trim() || `Exit code ${code}`,
476
+ });
477
+ return;
478
+ }
479
+ if (!stdout.trim()) {
480
+ resolve({ matches: [] });
481
+ return;
482
+ }
483
+ try {
484
+ const parsed = JSON.parse(stdout);
485
+ const matches = Array.isArray(parsed) ? parsed : [parsed];
486
+ resolve({ matches });
487
+ } catch {
488
+ resolve({ matches: [], error: "Failed to parse output" });
489
+ }
490
+ });
491
+ });
492
+ }
493
+
494
+ formatMatches(matches: AstGrepMatch[], isDryRun = false): string {
495
+ if (matches.length === 0) return "No matches found";
496
+ const MAX = 50;
497
+ const shown = matches.slice(0, MAX);
498
+ const lines = shown.map((m) => {
499
+ const loc = `${m.file}:${m.range.start.line + 1}:${m.range.start.column + 1}`;
500
+ const text = m.text.length > 100 ? `${m.text.slice(0, 100)}...` : m.text;
501
+ return isDryRun && m.replacement
502
+ ? `${loc}\n - ${text}\n + ${m.replacement}`
503
+ : `${loc}: ${text}`;
504
+ });
505
+ if (matches.length > MAX)
506
+ lines.unshift(`Found ${matches.length} matches (showing first ${MAX}):`);
507
+ return lines.join("\n");
508
+ }
509
+
510
+ /**
511
+ * Scan a file against all rules
512
+ */
513
+ scanFile(filePath: string): AstGrepDiagnostic[] {
514
+ if (!this.isAvailable()) return [];
515
+
516
+ const absolutePath = path.resolve(filePath);
517
+ if (!fs.existsSync(absolutePath)) return [];
518
+
519
+ const configPath = path.join(this.ruleDir, ".sgconfig.yml");
520
+
521
+ try {
522
+ const result = spawnSync(
523
+ "npx",
524
+ ["sg", "scan", "--config", configPath, "--json", absolutePath],
525
+ {
526
+ encoding: "utf-8",
527
+ timeout: 15000,
528
+ shell: true,
529
+ },
530
+ );
531
+
532
+ // ast-grep exits 1 when it finds issues
533
+ const output = result.stdout || result.stderr || "";
534
+ if (!output.trim()) return [];
535
+
536
+ return this.parseOutput(output, absolutePath);
537
+ } catch (err: any) {
538
+ this.log(`Scan error: ${err.message}`);
539
+ return [];
540
+ }
541
+ }
542
+
543
+ /**
544
+ * Format diagnostics for LLM consumption
545
+ */
546
+ formatDiagnostics(diags: AstGrepDiagnostic[]): string {
547
+ if (diags.length === 0) return "";
548
+
549
+ const errors = diags.filter((d) => d.severity === "error");
550
+ const warnings = diags.filter((d) => d.severity === "warning");
551
+ const hints = diags.filter((d) => d.severity === "hint");
552
+
553
+ let output = `[ast-grep] ${diags.length} structural issue(s)`;
554
+ if (errors.length) output += ` — ${errors.length} error(s)`;
555
+ if (warnings.length) output += ` ${warnings.length} warning(s)`;
556
+ if (hints.length) output += ` — ${hints.length} hint(s)`;
557
+ output += ":\n";
558
+
559
+ for (const d of diags.slice(0, 10)) {
560
+ const loc =
561
+ d.line === d.endLine ? `L${d.line}` : `L${d.line}-${d.endLine}`;
562
+ const ruleInfo = d.ruleDescription
563
+ ? `${d.rule}: ${d.ruleDescription.message}`
564
+ : d.rule;
565
+ const fix = d.fix || d.ruleDescription?.note ? " [fixable]" : "";
566
+ output += ` ${ruleInfo} (${loc})${fix}\n`;
567
+
568
+ // Include note for actionable guidance
569
+ if (d.ruleDescription?.note) {
570
+ const shortNote = d.ruleDescription.note.split("\n")[0];
571
+ output += ` → ${shortNote}\n`;
572
+ }
573
+ }
574
+
575
+ if (diags.length > 10) {
576
+ output += ` ... and ${diags.length - 10} more\n`;
577
+ }
578
+
579
+ return output;
580
+ }
581
+
582
+ // --- Internal ---
583
+
584
+ private parseOutput(output: string, filterFile: string): AstGrepDiagnostic[] {
585
+ const diagnostics: AstGrepDiagnostic[] = [];
586
+ const resolvedFilterFile = path.resolve(filterFile);
587
+
588
+ // Try parsing as JSON array first (new format)
589
+ try {
590
+ const items: AstGrepJsonDiagnostic[] = JSON.parse(output);
591
+ if (Array.isArray(items)) {
592
+ for (const item of items) {
593
+ const diag = this.parseDiagnostic(item, resolvedFilterFile);
594
+ if (diag) diagnostics.push(diag);
595
+ }
596
+ return diagnostics;
597
+ }
598
+ } catch {
599
+ // Not a JSON array, try ndjson format (legacy)
600
+ }
601
+
602
+ // Parse ndjson (one JSON object per line) - legacy format
603
+ const lines = output.split("\n").filter((l) => l.trim());
604
+
605
+ for (const line of lines) {
606
+ try {
607
+ const item: AstGrepJsonDiagnostic = JSON.parse(line);
608
+ const diag = this.parseDiagnostic(item, resolvedFilterFile);
609
+ if (diag) diagnostics.push(diag);
610
+ } catch {
611
+ // Skip unparseable lines
612
+ }
613
+ }
614
+
615
+ return diagnostics;
616
+ }
617
+
618
+ private parseDiagnostic(
619
+ item: AstGrepJsonDiagnostic,
620
+ filterFile: string,
621
+ ): AstGrepDiagnostic | null {
622
+ // New format uses labels array
623
+ if (item.labels && item.labels.length > 0) {
624
+ const label =
625
+ item.labels.find((l) => l.style === "primary") || item.labels[0];
626
+ const filePath = path.resolve(label.file || filterFile);
627
+
628
+ // Filter to our file
629
+ if (filePath !== filterFile) return null;
630
+
631
+ const start = label.range?.start || { line: 0, column: 0 };
632
+ const end = label.range?.end || start;
633
+
634
+ return {
635
+ line: start.line + 1, // ast-grep is 0-indexed, we want 1-indexed
636
+ column: start.column,
637
+ endLine: end.line + 1,
638
+ endColumn: end.column,
639
+ severity: this.mapSeverity(item.severity),
640
+ message: item.message || "Unknown issue",
641
+ rule: item.ruleId || "unknown",
642
+ ruleDescription: this.getRuleDescription(item.ruleId || "unknown"),
643
+ file: filePath,
644
+ };
645
+ }
646
+
647
+ // Legacy format uses spans array
648
+ if (item.spans && item.spans.length > 0) {
649
+ const span = item.spans[0];
650
+ const filePath = path.resolve(span.file || filterFile);
651
+
652
+ // Filter to our file
653
+ if (filePath !== filterFile) return null;
654
+
655
+ const start = span.range?.start || { line: 0, column: 0 };
656
+ const end = span.range?.end || start;
657
+
658
+ const ruleId = item.name || item.ruleId || "unknown";
659
+ return {
660
+ line: start.line + 1,
661
+ column: start.column,
662
+ endLine: end.line + 1,
663
+ endColumn: end.column,
664
+ severity: this.mapSeverity(item.severity || item.Severity || "warning"),
665
+ message: item.Message?.text || item.message || "Unknown issue",
666
+ rule: ruleId,
667
+ ruleDescription: this.getRuleDescription(ruleId),
668
+ file: filePath,
669
+ };
670
+ }
671
+
672
+ return null;
673
+ }
674
+
675
+ getRuleDescription(ruleId: string): RuleDescription | undefined {
676
+ const descriptions = this.loadRuleDescriptions();
677
+ return descriptions.get(ruleId);
678
+ }
679
+
680
+ private mapSeverity(severity: string): AstGrepDiagnostic["severity"] {
681
+ const lower = severity.toLowerCase();
682
+ if (lower === "error") return "error";
683
+ if (lower === "warning") return "warning";
684
+ if (lower === "info") return "info";
685
+ return "hint";
686
+ }
593
687
  }