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
@@ -10,355 +10,380 @@
10
10
  */
11
11
 
12
12
  import { spawnSync } from "node:child_process";
13
- import * as path from "node:path";
14
13
  import * as fs from "node:fs";
14
+ import * as path from "node:path";
15
15
 
16
16
  // --- Types ---
17
17
 
18
18
  export interface CircularDep {
19
- file: string;
20
- path: string[]; // The cycle path
19
+ file: string;
20
+ path: string[]; // The cycle path
21
21
  }
22
22
 
23
23
  export interface DepCheckResult {
24
- hasCircular: boolean;
25
- circular: CircularDep[];
26
- checked: boolean;
27
- cacheHit: boolean;
24
+ hasCircular: boolean;
25
+ circular: CircularDep[];
26
+ checked: boolean;
27
+ cacheHit: boolean;
28
28
  }
29
29
 
30
30
  // --- Graph Cache ---
31
31
 
32
32
  interface FileImports {
33
- imports: Set<string>;
34
- timestamp: number;
33
+ imports: Set<string>;
34
+ timestamp: number;
35
35
  }
36
36
 
37
37
  // --- Client ---
38
38
 
39
39
  export class DependencyChecker {
40
- private available: boolean | null = null;
41
- private log: (msg: string) => void;
42
-
43
- // Cache: file path -> its imports
44
- private importCache = new Map<string, FileImports>();
45
-
46
- // Circular deps: last known circular deps
47
- private lastCircular: CircularDep[] = [];
48
-
49
- // Files that are part of a circular dependency
50
- private circularFiles = new Set<string>();
51
-
52
- constructor(verbose = false) {
53
- this.log = verbose
54
- ? (msg: string) => console.log(`[deps] ${msg}`)
55
- : () => {};
56
- }
57
-
58
- /**
59
- * Check if madge is available
60
- */
61
- isAvailable(): boolean {
62
- if (this.available !== null) return this.available;
63
-
64
- const result = spawnSync("npx", ["madge", "--version"], {
65
- encoding: "utf-8",
66
- timeout: 10000,
67
- shell: true,
68
- });
69
-
70
- this.available = !result.error && result.status === 0;
71
- if (this.available) {
72
- this.log("Madge available for dependency checking");
73
- }
74
-
75
- return this.available;
76
- }
77
-
78
- /**
79
- * Check if a file is part of a circular dependency (from cache)
80
- */
81
- isInCircular(filePath: string): boolean {
82
- const normalized = path.resolve(filePath);
83
- return this.circularFiles.has(normalized);
84
- }
85
-
86
- /**
87
- * Get circular deps for a specific file
88
- */
89
- getCircularForFile(filePath: string): string[] {
90
- const normalized = path.resolve(filePath);
91
- const deps: string[] = [];
92
-
93
- for (const dep of this.lastCircular) {
94
- if (dep.file === normalized || dep.path.includes(normalized)) {
95
- // Add the other files in the cycle
96
- for (const f of dep.path) {
97
- if (f !== normalized) {
98
- deps.push(path.relative(process.cwd(), f));
99
- }
100
- }
101
- }
102
- }
103
-
104
- return [...new Set(deps)];
105
- }
106
-
107
- /**
108
- * Extract imports from a TypeScript/JavaScript file
109
- */
110
- extractImports(filePath: string): Set<string> {
111
- const content = fs.readFileSync(filePath, "utf-8");
112
- const imports = new Set<string>();
113
-
114
- // Match import statements: import ... from '...'
115
- const importPattern = /(?:import|export)\s+(?:.*?\s+from\s+)?['"]([^'"]+)['"]/g;
116
- const requirePattern = /require\s*\(\s*['"]([^'"]+)['"]\s*\)/g;
117
-
118
- let match;
119
- while ((match = importPattern.exec(content)) !== null) {
120
- if (match[1].startsWith(".")) {
121
- imports.add(match[1]);
122
- }
123
- }
124
-
125
- while ((match = requirePattern.exec(content)) !== null) {
126
- if (match[1].startsWith(".")) {
127
- imports.add(match[1]);
128
- }
129
- }
130
-
131
- return imports;
132
- }
133
-
134
- /**
135
- * Check if imports have changed for a file
136
- */
137
- importsChanged(filePath: string): boolean {
138
- const normalized = path.resolve(filePath);
139
-
140
- if (!fs.existsSync(normalized)) {
141
- // File deleted, remove from cache
142
- this.importCache.delete(normalized);
143
- return true;
144
- }
145
-
146
- const stat = fs.statSync(normalized);
147
- const mtime = stat.mtimeMs;
148
-
149
- const cached = this.importCache.get(normalized);
150
-
151
- // If timestamp hasn't changed, imports haven't changed
152
- if (cached && cached.timestamp >= mtime) {
153
- return false;
154
- }
155
-
156
- // Parse new imports
157
- const newImports = this.extractImports(normalized);
158
- const newEntry: FileImports = {
159
- imports: newImports,
160
- timestamp: mtime,
161
- };
162
-
163
- // Check if imports actually changed
164
- if (cached) {
165
- if (cached.imports.size !== newImports.size) {
166
- this.importCache.set(normalized, newEntry);
167
- return true;
168
- }
169
-
170
- for (const imp of newImports) {
171
- if (!cached.imports.has(imp)) {
172
- this.importCache.set(normalized, newEntry);
173
- return true;
174
- }
175
- }
176
-
177
- for (const imp of cached.imports) {
178
- if (!newImports.has(imp)) {
179
- this.importCache.set(normalized, newEntry);
180
- return true;
181
- }
182
- }
183
-
184
- // Imports are the same, just update timestamp
185
- this.importCache.set(normalized, newEntry);
186
- return false;
187
- }
188
-
189
- this.importCache.set(normalized, newEntry);
190
- return true;
191
- }
192
-
193
- /**
194
- * Quick circular dependency check using DFS on cached graph.
195
- * Only re-runs full madge check when imports change.
196
- */
197
- checkFile(filePath: string, cwd?: string): DepCheckResult {
198
- if (!this.isAvailable()) {
199
- return { hasCircular: false, circular: [], checked: false, cacheHit: false };
200
- }
201
-
202
- const normalized = path.resolve(filePath);
203
- const projectRoot = cwd || process.cwd();
204
-
205
- // Check if imports changed
206
- const importsChanged = this.importsChanged(normalized);
207
-
208
- if (!importsChanged) {
209
- // Return cached result
210
- return {
211
- hasCircular: this.circularFiles.has(normalized),
212
- circular: this.lastCircular.filter(d =>
213
- d.file === normalized || d.path.includes(normalized)
214
- ),
215
- checked: true,
216
- cacheHit: true,
217
- };
218
- }
219
-
220
- this.log(`Imports changed for ${path.basename(filePath)}, checking dependencies...`);
221
-
222
- // Run madge on the specific file (fast)
223
- try {
224
- const result = spawnSync("npx", [
225
- "madge",
226
- "--circular",
227
- "--extensions", "ts,tsx,js,jsx",
228
- "--json",
229
- normalized,
230
- ], {
231
- encoding: "utf-8",
232
- timeout: 15000,
233
- cwd: projectRoot,
234
- shell: true,
235
- });
236
-
237
- const output = result.stdout || "[]";
238
- const parsed = JSON.parse(output);
239
-
240
- // Madge --circular --json returns array of cycle arrays: [["a.ts", "b.ts"], ...]
241
- const cycles: string[][] = Array.isArray(parsed) ? parsed : [];
242
- const circular: CircularDep[] = [];
243
- const circularFiles = new Set<string>();
244
-
245
- for (const cycle of cycles) {
246
- const resolvedPaths = cycle.map((f: string) => path.resolve(projectRoot, f));
247
- for (const f of resolvedPaths) {
248
- circularFiles.add(f);
249
- }
250
- circular.push({
251
- file: resolvedPaths[0],
252
- path: resolvedPaths,
253
- });
254
- }
255
-
256
- this.lastCircular = circular;
257
- this.circularFiles = circularFiles;
258
-
259
- return {
260
- hasCircular: circular.length > 0,
261
- circular: circular.filter(d =>
262
- d.file === normalized || d.path.includes(normalized)
263
- ),
264
- checked: true,
265
- cacheHit: false,
266
- };
267
- } catch (err: any) {
268
- this.log(`Check error: ${err.message}`);
269
- return { hasCircular: false, circular: [], checked: false, cacheHit: false };
270
- }
271
- }
272
-
273
- /**
274
- * Format circular dependency warning for LLM
275
- */
276
- formatWarning(filePath: string, deps: string[]): string {
277
- if (deps.length === 0) return "";
278
-
279
- const filename = path.basename(filePath);
280
- const depNames = deps.map(d => path.basename(d));
281
-
282
- let output = `[Circular Deps] ${filename} is in a cycle:\n`;
283
- output += ` ${filename} ${depNames.join(", ")}\n`;
284
- output += `\n Consider extracting shared code to a separate module.\n`;
285
-
286
- return output;
287
- }
288
-
289
- /**
290
- * Full project scan (for /check-deps command)
291
- */
292
- scanProject(cwd?: string): { circular: CircularDep[]; count: number } {
293
- if (!this.isAvailable()) {
294
- return { circular: [], count: 0 };
295
- }
296
-
297
- const projectRoot = cwd || process.cwd();
298
-
299
- try {
300
- const result = spawnSync("npx", [
301
- "madge",
302
- "--circular",
303
- "--extensions", "ts,tsx,js,jsx",
304
- "--json",
305
- projectRoot,
306
- ], {
307
- encoding: "utf-8",
308
- timeout: 30000,
309
- cwd: projectRoot,
310
- shell: true,
311
- });
312
-
313
- const output = result.stdout || "{}";
314
- const data = JSON.parse(output);
315
-
316
- const circular: CircularDep[] = [];
317
- const circularFiles = new Set<string>();
318
-
319
- for (const [file, deps] of Object.entries(data)) {
320
- if (Array.isArray(deps) && deps.length > 0) {
321
- const resolvedFile = path.resolve(file);
322
- circularFiles.add(resolvedFile);
323
-
324
- circular.push({
325
- file: resolvedFile,
326
- path: [resolvedFile, ...deps.map((d: string) => path.resolve(d))],
327
- });
328
- }
329
- }
330
-
331
- this.lastCircular = circular;
332
- this.circularFiles = circularFiles;
333
-
334
- return { circular, count: circular.length };
335
- } catch (err: any) {
336
- this.log(`Scan error: ${err.message}`);
337
- return { circular: [], count: 0 };
338
- }
339
- }
340
-
341
- /**
342
- * Format full scan results
343
- */
344
- formatScanResult(circular: CircularDep[]): string {
345
- if (circular.length === 0) return "";
346
-
347
- // Group by cycle to avoid duplicate entries
348
- const seen = new Set<string>();
349
- let output = `[Circular Deps] ${circular.length} cycle(s) found:\n`;
350
-
351
- for (const dep of circular) {
352
- const cycleKey = dep.path.sort().join("→");
353
- if (seen.has(cycleKey)) continue;
354
- seen.add(cycleKey);
355
-
356
- const names = dep.path.map(p => path.relative(process.cwd(), p));
357
- output += ` • ${names.join(" ")}\n`;
358
- }
359
-
360
- output += "\n Consider extracting shared code to break cycles.\n";
361
-
362
- return output;
363
- }
40
+ private available: boolean | null = null;
41
+ private log: (msg: string) => void;
42
+
43
+ // Cache: file path -> its imports
44
+ private importCache = new Map<string, FileImports>();
45
+
46
+ // Circular deps: last known circular deps
47
+ private lastCircular: CircularDep[] = [];
48
+
49
+ // Files that are part of a circular dependency
50
+ private circularFiles = new Set<string>();
51
+
52
+ constructor(verbose = false) {
53
+ this.log = verbose
54
+ ? (msg: string) => console.log(`[deps] ${msg}`)
55
+ : () => {};
56
+ }
57
+
58
+ /**
59
+ * Check if madge is available
60
+ */
61
+ isAvailable(): boolean {
62
+ if (this.available !== null) return this.available;
63
+
64
+ const result = spawnSync("npx", ["madge", "--version"], {
65
+ encoding: "utf-8",
66
+ timeout: 10000,
67
+ shell: true,
68
+ });
69
+
70
+ this.available = !result.error && result.status === 0;
71
+ if (this.available) {
72
+ this.log("Madge available for dependency checking");
73
+ }
74
+
75
+ return this.available;
76
+ }
77
+
78
+ /**
79
+ * Check if a file is part of a circular dependency (from cache)
80
+ */
81
+ isInCircular(filePath: string): boolean {
82
+ const normalized = path.resolve(filePath);
83
+ return this.circularFiles.has(normalized);
84
+ }
85
+
86
+ /**
87
+ * Get circular deps for a specific file
88
+ */
89
+ getCircularForFile(filePath: string): string[] {
90
+ const normalized = path.resolve(filePath);
91
+ const deps: string[] = [];
92
+
93
+ for (const dep of this.lastCircular) {
94
+ if (dep.file === normalized || dep.path.includes(normalized)) {
95
+ // Add the other files in the cycle
96
+ for (const f of dep.path) {
97
+ if (f !== normalized) {
98
+ deps.push(path.relative(process.cwd(), f));
99
+ }
100
+ }
101
+ }
102
+ }
103
+
104
+ return [...new Set(deps)];
105
+ }
106
+
107
+ /**
108
+ * Extract imports from a TypeScript/JavaScript file
109
+ */
110
+ extractImports(filePath: string): Set<string> {
111
+ const content = fs.readFileSync(filePath, "utf-8");
112
+ const imports = new Set<string>();
113
+
114
+ // Match import statements: import ... from '...'
115
+ const importPattern =
116
+ /(?:import|export)\s+(?:.*?\s+from\s+)?['"]([^'"]+)['"]/g;
117
+ const requirePattern = /require\s*\(\s*['"]([^'"]+)['"]\s*\)/g;
118
+
119
+ let match;
120
+ while ((match = importPattern.exec(content)) !== null) {
121
+ if (match[1].startsWith(".")) {
122
+ imports.add(match[1]);
123
+ }
124
+ }
125
+
126
+ while ((match = requirePattern.exec(content)) !== null) {
127
+ if (match[1].startsWith(".")) {
128
+ imports.add(match[1]);
129
+ }
130
+ }
131
+
132
+ return imports;
133
+ }
134
+
135
+ /**
136
+ * Check if imports have changed for a file
137
+ */
138
+ importsChanged(filePath: string): boolean {
139
+ const normalized = path.resolve(filePath);
140
+
141
+ if (!fs.existsSync(normalized)) {
142
+ // File deleted, remove from cache
143
+ this.importCache.delete(normalized);
144
+ return true;
145
+ }
146
+
147
+ const stat = fs.statSync(normalized);
148
+ const mtime = stat.mtimeMs;
149
+
150
+ const cached = this.importCache.get(normalized);
151
+
152
+ // If timestamp hasn't changed, imports haven't changed
153
+ if (cached && cached.timestamp >= mtime) {
154
+ return false;
155
+ }
156
+
157
+ // Parse new imports
158
+ const newImports = this.extractImports(normalized);
159
+ const newEntry: FileImports = {
160
+ imports: newImports,
161
+ timestamp: mtime,
162
+ };
163
+
164
+ // Check if imports actually changed
165
+ if (cached) {
166
+ if (cached.imports.size !== newImports.size) {
167
+ this.importCache.set(normalized, newEntry);
168
+ return true;
169
+ }
170
+
171
+ for (const imp of newImports) {
172
+ if (!cached.imports.has(imp)) {
173
+ this.importCache.set(normalized, newEntry);
174
+ return true;
175
+ }
176
+ }
177
+
178
+ for (const imp of cached.imports) {
179
+ if (!newImports.has(imp)) {
180
+ this.importCache.set(normalized, newEntry);
181
+ return true;
182
+ }
183
+ }
184
+
185
+ // Imports are the same, just update timestamp
186
+ this.importCache.set(normalized, newEntry);
187
+ return false;
188
+ }
189
+
190
+ this.importCache.set(normalized, newEntry);
191
+ return true;
192
+ }
193
+
194
+ /**
195
+ * Quick circular dependency check using DFS on cached graph.
196
+ * Only re-runs full madge check when imports change.
197
+ */
198
+ checkFile(filePath: string, cwd?: string): DepCheckResult {
199
+ if (!this.isAvailable()) {
200
+ return {
201
+ hasCircular: false,
202
+ circular: [],
203
+ checked: false,
204
+ cacheHit: false,
205
+ };
206
+ }
207
+
208
+ const normalized = path.resolve(filePath);
209
+ const projectRoot = cwd || process.cwd();
210
+
211
+ // Check if imports changed
212
+ const importsChanged = this.importsChanged(normalized);
213
+
214
+ if (!importsChanged) {
215
+ // Return cached result
216
+ return {
217
+ hasCircular: this.circularFiles.has(normalized),
218
+ circular: this.lastCircular.filter(
219
+ (d) => d.file === normalized || d.path.includes(normalized),
220
+ ),
221
+ checked: true,
222
+ cacheHit: true,
223
+ };
224
+ }
225
+
226
+ this.log(
227
+ `Imports changed for ${path.basename(filePath)}, checking dependencies...`,
228
+ );
229
+
230
+ // Run madge on the specific file (fast)
231
+ try {
232
+ const result = spawnSync(
233
+ "npx",
234
+ [
235
+ "madge",
236
+ "--circular",
237
+ "--extensions",
238
+ "ts,tsx,js,jsx",
239
+ "--json",
240
+ normalized,
241
+ ],
242
+ {
243
+ encoding: "utf-8",
244
+ timeout: 15000,
245
+ cwd: projectRoot,
246
+ shell: true,
247
+ },
248
+ );
249
+
250
+ const output = result.stdout || "[]";
251
+ const parsed = JSON.parse(output);
252
+
253
+ // Madge --circular --json returns array of cycle arrays: [["a.ts", "b.ts"], ...]
254
+ const cycles: string[][] = Array.isArray(parsed) ? parsed : [];
255
+ const circular: CircularDep[] = [];
256
+ const circularFiles = new Set<string>();
257
+
258
+ for (const cycle of cycles) {
259
+ const resolvedPaths = cycle.map((f: string) =>
260
+ path.resolve(projectRoot, f),
261
+ );
262
+ for (const f of resolvedPaths) {
263
+ circularFiles.add(f);
264
+ }
265
+ circular.push({
266
+ file: resolvedPaths[0],
267
+ path: resolvedPaths,
268
+ });
269
+ }
270
+
271
+ this.lastCircular = circular;
272
+ this.circularFiles = circularFiles;
273
+
274
+ return {
275
+ hasCircular: circular.length > 0,
276
+ circular: circular.filter(
277
+ (d) => d.file === normalized || d.path.includes(normalized),
278
+ ),
279
+ checked: true,
280
+ cacheHit: false,
281
+ };
282
+ } catch (err: any) {
283
+ this.log(`Check error: ${err.message}`);
284
+ return {
285
+ hasCircular: false,
286
+ circular: [],
287
+ checked: false,
288
+ cacheHit: false,
289
+ };
290
+ }
291
+ }
292
+
293
+ /**
294
+ * Format circular dependency warning for LLM
295
+ */
296
+ formatWarning(filePath: string, deps: string[]): string {
297
+ if (deps.length === 0) return "";
298
+
299
+ const filename = path.basename(filePath);
300
+ const depNames = deps.map((d) => path.basename(d));
301
+
302
+ let output = `[Circular Deps] ${filename} is in a cycle:\n`;
303
+ output += ` ${filename} ↔ ${depNames.join(", ")}\n`;
304
+ output += `\n Consider extracting shared code to a separate module.\n`;
305
+
306
+ return output;
307
+ }
308
+
309
+ /**
310
+ * Full project scan (for /check-deps command)
311
+ */
312
+ scanProject(cwd?: string): { circular: CircularDep[]; count: number } {
313
+ if (!this.isAvailable()) {
314
+ return { circular: [], count: 0 };
315
+ }
316
+
317
+ const projectRoot = cwd || process.cwd();
318
+
319
+ try {
320
+ const result = spawnSync(
321
+ "npx",
322
+ [
323
+ "madge",
324
+ "--circular",
325
+ "--extensions",
326
+ "ts,tsx,js,jsx",
327
+ "--json",
328
+ projectRoot,
329
+ ],
330
+ {
331
+ encoding: "utf-8",
332
+ timeout: 30000,
333
+ cwd: projectRoot,
334
+ shell: true,
335
+ },
336
+ );
337
+
338
+ const output = result.stdout || "{}";
339
+ const data = JSON.parse(output);
340
+
341
+ const circular: CircularDep[] = [];
342
+ const circularFiles = new Set<string>();
343
+
344
+ for (const [file, deps] of Object.entries(data)) {
345
+ if (Array.isArray(deps) && deps.length > 0) {
346
+ const resolvedFile = path.resolve(file);
347
+ circularFiles.add(resolvedFile);
348
+
349
+ circular.push({
350
+ file: resolvedFile,
351
+ path: [resolvedFile, ...deps.map((d: string) => path.resolve(d))],
352
+ });
353
+ }
354
+ }
355
+
356
+ this.lastCircular = circular;
357
+ this.circularFiles = circularFiles;
358
+
359
+ return { circular, count: circular.length };
360
+ } catch (err: any) {
361
+ this.log(`Scan error: ${err.message}`);
362
+ return { circular: [], count: 0 };
363
+ }
364
+ }
365
+
366
+ /**
367
+ * Format full scan results
368
+ */
369
+ formatScanResult(circular: CircularDep[]): string {
370
+ if (circular.length === 0) return "";
371
+
372
+ // Group by cycle to avoid duplicate entries
373
+ const seen = new Set<string>();
374
+ let output = `[Circular Deps] ${circular.length} cycle(s) found:\n`;
375
+
376
+ for (const dep of circular) {
377
+ const cycleKey = dep.path.sort().join("→");
378
+ if (seen.has(cycleKey)) continue;
379
+ seen.add(cycleKey);
380
+
381
+ const names = dep.path.map((p) => path.relative(process.cwd(), p));
382
+ output += ` • ${names.join(" → ")}\n`;
383
+ }
384
+
385
+ output += "\n Consider extracting shared code to break cycles.\n";
386
+
387
+ return output;
388
+ }
364
389
  }