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.
- package/clients/ast-grep-client.test.ts +146 -116
- package/clients/ast-grep-client.ts +645 -551
- package/clients/biome-client.test.ts +154 -137
- package/clients/biome-client.ts +397 -337
- package/clients/complexity-client.test.ts +188 -200
- package/clients/complexity-client.ts +815 -667
- package/clients/dependency-checker.test.ts +55 -55
- package/clients/dependency-checker.ts +358 -333
- package/clients/go-client.test.ts +121 -111
- package/clients/go-client.ts +218 -216
- package/clients/jscpd-client.test.ts +132 -132
- package/clients/jscpd-client.ts +155 -118
- package/clients/knip-client.test.ts +123 -133
- package/clients/knip-client.ts +231 -218
- package/clients/metrics-client.test.ts +171 -167
- package/clients/metrics-client.ts +283 -252
- package/clients/ruff-client.test.ts +128 -117
- package/clients/ruff-client.ts +300 -269
- package/clients/rust-client.test.ts +104 -85
- package/clients/rust-client.ts +241 -234
- package/clients/subprocess-client.ts +1 -1
- package/clients/test-runner-client.test.ts +248 -215
- package/clients/test-runner-client.ts +728 -608
- package/clients/test-utils.ts +10 -3
- package/clients/todo-scanner.test.ts +288 -202
- package/clients/todo-scanner.ts +225 -187
- package/clients/type-coverage-client.test.ts +119 -119
- package/clients/type-coverage-client.ts +142 -115
- package/clients/types.ts +28 -28
- package/clients/typescript-client.test.ts +99 -93
- package/clients/typescript-client.ts +527 -502
- package/index.ts +662 -212
- package/package.json +1 -1
- 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
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
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
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
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
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
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
|
}
|