pi-lens 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/README.md +152 -0
  2. package/clients/ast-grep-client.ts +254 -0
  3. package/clients/biome-client.ts +362 -0
  4. package/clients/dependency-checker.ts +368 -0
  5. package/clients/jscpd-client.ts +144 -0
  6. package/clients/knip-client.ts +244 -0
  7. package/clients/ruff-client.ts +295 -0
  8. package/clients/todo-scanner.ts +161 -0
  9. package/clients/type-coverage-client.ts +141 -0
  10. package/clients/types.ts +59 -0
  11. package/clients/typescript-client.ts +542 -0
  12. package/index.ts +471 -0
  13. package/package.json +50 -0
  14. package/rules/.sgconfig.yml +4 -0
  15. package/rules/ast-grep-rules/.sgconfig.yml +4 -0
  16. package/rules/ast-grep-rules/rules/array-callback-return.yml +33 -0
  17. package/rules/ast-grep-rules/rules/constructor-super.yml +22 -0
  18. package/rules/ast-grep-rules/rules/empty-catch.yml +16 -0
  19. package/rules/ast-grep-rules/rules/getter-return.yml +59 -0
  20. package/rules/ast-grep-rules/rules/hardcoded-url.yml +12 -0
  21. package/rules/ast-grep-rules/rules/in-correct-optional-input-type.yml +63 -0
  22. package/rules/ast-grep-rules/rules/missing-component-decorator.yml +30 -0
  23. package/rules/ast-grep-rules/rules/nested-ternary.yml +10 -0
  24. package/rules/ast-grep-rules/rules/no-alert.yml +6 -0
  25. package/rules/ast-grep-rules/rules/no-any-type.yml +11 -0
  26. package/rules/ast-grep-rules/rules/no-array-constructor.yml +9 -0
  27. package/rules/ast-grep-rules/rules/no-as-any.yml +8 -0
  28. package/rules/ast-grep-rules/rules/no-async-promise-executor.yml +15 -0
  29. package/rules/ast-grep-rules/rules/no-await-in-loop.yml +29 -0
  30. package/rules/ast-grep-rules/rules/no-await-in-promise-all.yml +19 -0
  31. package/rules/ast-grep-rules/rules/no-bare-except.yml +13 -0
  32. package/rules/ast-grep-rules/rules/no-case-declarations.yml +16 -0
  33. package/rules/ast-grep-rules/rules/no-compare-neg-zero.yml +13 -0
  34. package/rules/ast-grep-rules/rules/no-comparison-to-none.yml +12 -0
  35. package/rules/ast-grep-rules/rules/no-cond-assign.yml +36 -0
  36. package/rules/ast-grep-rules/rules/no-console-log.yml +9 -0
  37. package/rules/ast-grep-rules/rules/no-constant-condition.yml +25 -0
  38. package/rules/ast-grep-rules/rules/no-constructor-return.yml +28 -0
  39. package/rules/ast-grep-rules/rules/no-dangerously-set-inner-html.yml +13 -0
  40. package/rules/ast-grep-rules/rules/no-debugger.yml +10 -0
  41. package/rules/ast-grep-rules/rules/no-delete-operator.yml +9 -0
  42. package/rules/ast-grep-rules/rules/no-dupe-args.yml +15 -0
  43. package/rules/ast-grep-rules/rules/no-dupe-class-members.yml +76 -0
  44. package/rules/ast-grep-rules/rules/no-dupe-keys.yml +73 -0
  45. package/rules/ast-grep-rules/rules/no-eval.yml +12 -0
  46. package/rules/ast-grep-rules/rules/no-extra-boolean-cast.yml +25 -0
  47. package/rules/ast-grep-rules/rules/no-hardcoded-secrets.yml +13 -0
  48. package/rules/ast-grep-rules/rules/no-implied-eval.yml +15 -0
  49. package/rules/ast-grep-rules/rules/no-inline-styles.yml +18 -0
  50. package/rules/ast-grep-rules/rules/no-inner-html.yml +13 -0
  51. package/rules/ast-grep-rules/rules/no-insecure-randomness.yml +20 -0
  52. package/rules/ast-grep-rules/rules/no-javascript-url.yml +11 -0
  53. package/rules/ast-grep-rules/rules/no-lonely-if.yml +13 -0
  54. package/rules/ast-grep-rules/rules/no-mutable-default.yml +11 -0
  55. package/rules/ast-grep-rules/rules/no-nested-links.yml +28 -0
  56. package/rules/ast-grep-rules/rules/no-new-symbol.yml +8 -0
  57. package/rules/ast-grep-rules/rules/no-new-wrappers.yml +13 -0
  58. package/rules/ast-grep-rules/rules/no-non-null-assertion.yml +14 -0
  59. package/rules/ast-grep-rules/rules/no-open-redirect.yml +15 -0
  60. package/rules/ast-grep-rules/rules/no-prototype-builtins-native.yml +17 -0
  61. package/rules/ast-grep-rules/rules/no-prototype-builtins.yml +15 -0
  62. package/rules/ast-grep-rules/rules/no-return-await.yml +15 -0
  63. package/rules/ast-grep-rules/rules/no-shadow.yml +20 -0
  64. package/rules/ast-grep-rules/rules/no-sql-in-code.yml +13 -0
  65. package/rules/ast-grep-rules/rules/no-star-imports.yml +11 -0
  66. package/rules/ast-grep-rules/rules/no-string-ref.yml +15 -0
  67. package/rules/ast-grep-rules/rules/no-throw-string.yml +12 -0
  68. package/rules/ast-grep-rules/rules/no-unnecessary-state-initializer.yml +17 -0
  69. package/rules/ast-grep-rules/rules/no-useless-concat.yml +17 -0
  70. package/rules/ast-grep-rules/rules/no-var.yml +10 -0
  71. package/rules/ast-grep-rules/rules/prefer-const.yml +16 -0
  72. package/rules/ast-grep-rules/rules/prefer-nullish-coalescing.yml +23 -0
  73. package/rules/ast-grep-rules/rules/prefer-optional-chain.yml +14 -0
  74. package/rules/ast-grep-rules/rules/prefer-template.yml +20 -0
  75. package/rules/ast-grep-rules/rules/require-await.yml +14 -0
  76. package/rules/ast-grep-rules/rules/strict-equality.yml +10 -0
  77. package/rules/ast-grep-rules/rules/strict-inequality.yml +10 -0
  78. package/rules/ast-grep-rules/rules/switch-needs-default.yml +12 -0
  79. package/tsconfig.json +14 -0
@@ -0,0 +1,368 @@
1
+ /**
2
+ * Dependency Checker for pi-local
3
+ *
4
+ * Real-time circular dependency detection.
5
+ * Caches the dependency graph and only re-scans when imports change.
6
+ * Runs in the tool_result hook like ast-grep and Biome.
7
+ *
8
+ * Requires: npm install -D madge
9
+ * Docs: https://github.com/pahen/madge
10
+ */
11
+
12
+ import { spawnSync } from "node:child_process";
13
+ import * as path from "node:path";
14
+ import * as fs from "node:fs";
15
+
16
+ // --- Types ---
17
+
18
+ export interface CircularDep {
19
+ file: string;
20
+ path: string[]; // The cycle path
21
+ }
22
+
23
+ export interface DepCheckResult {
24
+ hasCircular: boolean;
25
+ circular: CircularDep[];
26
+ checked: boolean;
27
+ cacheHit: boolean;
28
+ }
29
+
30
+ // --- Graph Cache ---
31
+
32
+ interface FileImports {
33
+ imports: Set<string>;
34
+ timestamp: number;
35
+ }
36
+
37
+ // --- Client ---
38
+
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 data = JSON.parse(output);
239
+
240
+ // Madge returns { "file.ts": ["other.ts", ...] } for circular deps
241
+ const circular: CircularDep[] = [];
242
+ const circularFiles = new Set<string>();
243
+
244
+ for (const [file, deps] of Object.entries(data)) {
245
+ if (Array.isArray(deps) && deps.length > 0) {
246
+ const resolvedFile = path.resolve(file);
247
+ circularFiles.add(resolvedFile);
248
+
249
+ circular.push({
250
+ file: resolvedFile,
251
+ path: [resolvedFile, ...deps.map((d: string) => path.resolve(d))],
252
+ });
253
+
254
+ for (const dep of deps) {
255
+ circularFiles.add(path.resolve(dep));
256
+ }
257
+ }
258
+ }
259
+
260
+ this.lastCircular = circular;
261
+ this.circularFiles = circularFiles;
262
+
263
+ return {
264
+ hasCircular: circular.length > 0,
265
+ circular: circular.filter(d =>
266
+ d.file === normalized || d.path.includes(normalized)
267
+ ),
268
+ checked: true,
269
+ cacheHit: false,
270
+ };
271
+ } catch (err: any) {
272
+ this.log(`Check error: ${err.message}`);
273
+ return { hasCircular: false, circular: [], checked: false, cacheHit: false };
274
+ }
275
+ }
276
+
277
+ /**
278
+ * Format circular dependency warning for LLM
279
+ */
280
+ formatWarning(filePath: string, deps: string[]): string {
281
+ if (deps.length === 0) return "";
282
+
283
+ const filename = path.basename(filePath);
284
+ const depNames = deps.map(d => path.basename(d));
285
+
286
+ let output = `[Circular Deps] ${filename} is in a cycle:\n`;
287
+ output += ` ${filename} ↔ ${depNames.join(", ")}\n`;
288
+ output += `\n Consider extracting shared code to a separate module.\n`;
289
+
290
+ return output;
291
+ }
292
+
293
+ /**
294
+ * Full project scan (for /check-deps command)
295
+ */
296
+ scanProject(cwd?: string): { circular: CircularDep[]; count: number } {
297
+ if (!this.isAvailable()) {
298
+ return { circular: [], count: 0 };
299
+ }
300
+
301
+ const projectRoot = cwd || process.cwd();
302
+
303
+ try {
304
+ const result = spawnSync("npx", [
305
+ "madge",
306
+ "--circular",
307
+ "--extensions", "ts,tsx,js,jsx",
308
+ "--json",
309
+ projectRoot,
310
+ ], {
311
+ encoding: "utf-8",
312
+ timeout: 30000,
313
+ cwd: projectRoot,
314
+ shell: true,
315
+ });
316
+
317
+ const output = result.stdout || "{}";
318
+ const data = JSON.parse(output);
319
+
320
+ const circular: CircularDep[] = [];
321
+ const circularFiles = new Set<string>();
322
+
323
+ for (const [file, deps] of Object.entries(data)) {
324
+ if (Array.isArray(deps) && deps.length > 0) {
325
+ const resolvedFile = path.resolve(file);
326
+ circularFiles.add(resolvedFile);
327
+
328
+ circular.push({
329
+ file: resolvedFile,
330
+ path: [resolvedFile, ...deps.map((d: string) => path.resolve(d))],
331
+ });
332
+ }
333
+ }
334
+
335
+ this.lastCircular = circular;
336
+ this.circularFiles = circularFiles;
337
+
338
+ return { circular, count: circular.length };
339
+ } catch (err: any) {
340
+ this.log(`Scan error: ${err.message}`);
341
+ return { circular: [], count: 0 };
342
+ }
343
+ }
344
+
345
+ /**
346
+ * Format full scan results
347
+ */
348
+ formatScanResult(circular: CircularDep[]): string {
349
+ if (circular.length === 0) return "";
350
+
351
+ // Group by cycle to avoid duplicate entries
352
+ const seen = new Set<string>();
353
+ let output = `[Circular Deps] ${circular.length} cycle(s) found:\n`;
354
+
355
+ for (const dep of circular) {
356
+ const cycleKey = dep.path.sort().join("→");
357
+ if (seen.has(cycleKey)) continue;
358
+ seen.add(cycleKey);
359
+
360
+ const names = dep.path.map(p => path.relative(process.cwd(), p));
361
+ output += ` • ${names.join(" → ")}\n`;
362
+ }
363
+
364
+ output += "\n Consider extracting shared code to break cycles.\n";
365
+
366
+ return output;
367
+ }
368
+ }
@@ -0,0 +1,144 @@
1
+ /**
2
+ * jscpd Client for pi-lens
3
+ *
4
+ * Detects copy-paste / duplicate code blocks across the project.
5
+ * Helps the agent avoid unknowingly duplicating logic that already exists.
6
+ *
7
+ * Requires: npm install -D jscpd
8
+ * Docs: https://github.com/kucherenko/jscpd
9
+ */
10
+
11
+ import { spawnSync } from "node:child_process";
12
+ import * as path from "node:path";
13
+ import * as fs from "node:fs";
14
+ import * as os from "node:os";
15
+
16
+ // --- Types ---
17
+
18
+ export interface DuplicateClone {
19
+ fileA: string;
20
+ startA: number;
21
+ fileB: string;
22
+ startB: number;
23
+ lines: number;
24
+ tokens: number;
25
+ }
26
+
27
+ export interface JscpdResult {
28
+ success: boolean;
29
+ clones: DuplicateClone[];
30
+ duplicatedLines: number;
31
+ totalLines: number;
32
+ percentage: number;
33
+ }
34
+
35
+ // --- Client ---
36
+
37
+ export class JscpdClient {
38
+ private available: boolean | null = null;
39
+ private log: (msg: string) => void;
40
+
41
+ constructor(verbose = false) {
42
+ this.log = verbose ? (msg) => console.log(`[jscpd] ${msg}`) : () => {};
43
+ }
44
+
45
+ isAvailable(): boolean {
46
+ if (this.available !== null) return this.available;
47
+ const result = spawnSync("npx", ["jscpd", "--version"], {
48
+ encoding: "utf-8",
49
+ timeout: 10000,
50
+ shell: true,
51
+ });
52
+ this.available = !result.error && result.status === 0;
53
+ return this.available;
54
+ }
55
+
56
+ /**
57
+ * Scan a directory for duplicate code blocks.
58
+ * Uses a temp output dir to capture JSON report.
59
+ */
60
+ scan(cwd: string, minLines = 5, minTokens = 50): JscpdResult {
61
+ if (!this.isAvailable()) {
62
+ return { success: false, clones: [], duplicatedLines: 0, totalLines: 0, percentage: 0 };
63
+ }
64
+
65
+ const outDir = path.join(os.tmpdir(), `pi-lens-jscpd-${Date.now()}`);
66
+ fs.mkdirSync(outDir, { recursive: true });
67
+
68
+ try {
69
+ spawnSync("npx", [
70
+ "jscpd",
71
+ ".",
72
+ "--min-lines", String(minLines),
73
+ "--min-tokens", String(minTokens),
74
+ "--reporters", "json",
75
+ "--output", outDir,
76
+ "--ignore", "**/node_modules/**,**/dist/**,**/build/**,**/.git/**",
77
+ ], {
78
+ encoding: "utf-8",
79
+ timeout: 30000,
80
+ cwd,
81
+ shell: true,
82
+ });
83
+
84
+ const reportPath = path.join(outDir, "jscpd-report.json");
85
+ if (!fs.existsSync(reportPath)) {
86
+ return { success: true, clones: [], duplicatedLines: 0, totalLines: 0, percentage: 0 };
87
+ }
88
+
89
+ return this.parseReport(reportPath);
90
+ } catch (err: any) {
91
+ this.log(`Scan error: ${err.message}`);
92
+ return { success: false, clones: [], duplicatedLines: 0, totalLines: 0, percentage: 0 };
93
+ } finally {
94
+ try { fs.rmSync(outDir, { recursive: true, force: true }); } catch {}
95
+ }
96
+ }
97
+
98
+ formatResult(result: JscpdResult, maxClones = 8): string {
99
+ if (!result.success || result.clones.length === 0) return "";
100
+
101
+ const pct = result.percentage.toFixed(1);
102
+ let output = `[jscpd] ${result.clones.length} duplicate block(s) — ${pct}% of codebase (${result.duplicatedLines}/${result.totalLines} lines):\n`;
103
+
104
+ for (const clone of result.clones.slice(0, maxClones)) {
105
+ const a = `${path.basename(clone.fileA)}:${clone.startA}`;
106
+ const b = `${path.basename(clone.fileB)}:${clone.startB}`;
107
+ output += ` ${clone.lines} lines — ${a} ↔ ${b}\n`;
108
+ }
109
+
110
+ if (result.clones.length > maxClones) {
111
+ output += ` ... and ${result.clones.length - maxClones} more\n`;
112
+ }
113
+
114
+ return output;
115
+ }
116
+
117
+ // --- Internal ---
118
+
119
+ private parseReport(reportPath: string): JscpdResult {
120
+ try {
121
+ const data = JSON.parse(fs.readFileSync(reportPath, "utf-8"));
122
+ // Stats live in statistics.total, not statistics.clones
123
+ const total = data.statistics?.total ?? {};
124
+
125
+ const duplicatedLines: number = total.duplicatedLines ?? 0;
126
+ const totalLines: number = total.lines ?? 0;
127
+ const percentage: number = total.percentage ?? (totalLines > 0 ? (duplicatedLines / totalLines) * 100 : 0);
128
+
129
+ const rawClones: any[] = data.duplicates ?? [];
130
+ const clones: DuplicateClone[] = rawClones.map((c: any) => ({
131
+ fileA: c.firstFile?.name ?? "",
132
+ startA: c.firstFile?.start ?? 0,
133
+ fileB: c.secondFile?.name ?? "",
134
+ startB: c.secondFile?.start ?? 0,
135
+ lines: c.lines ?? 0,
136
+ tokens: c.tokens ?? 0,
137
+ }));
138
+
139
+ return { success: true, clones, duplicatedLines, totalLines, percentage };
140
+ } catch {
141
+ return { success: false, clones: [], duplicatedLines: 0, totalLines: 0, percentage: 0 };
142
+ }
143
+ }
144
+ }