pi-lens 2.1.1 → 2.2.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.
@@ -0,0 +1,348 @@
1
+ /**
2
+ * /lens-rate command
3
+ *
4
+ * Provides a visual scoring breakdown of code quality across multiple dimensions.
5
+ * Uses existing scan data to calculate scores.
6
+ */
7
+
8
+ import * as childProcess from "node:child_process";
9
+ import * as nodeFs from "node:fs";
10
+ import * as path from "node:path";
11
+ import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
12
+ import type { ArchitectClient } from "../clients/architect-client.js";
13
+ import type { ComplexityClient } from "../clients/complexity-client.js";
14
+ import type { KnipClient } from "../clients/knip-client.js";
15
+ import { getSourceFiles } from "../clients/scan-utils.js";
16
+ import type { TypeCoverageClient } from "../clients/type-coverage-client.js";
17
+
18
+ interface CategoryScore {
19
+ name: string;
20
+ score: number; // 0-100
21
+ icon: string;
22
+ issues: string[];
23
+ }
24
+
25
+ interface RateResult {
26
+ overall: number;
27
+ categories: CategoryScore[];
28
+ }
29
+
30
+ interface ScanClients {
31
+ complexity: ComplexityClient;
32
+ knip: KnipClient;
33
+ typeCoverage: TypeCoverageClient;
34
+ architect: ArchitectClient;
35
+ }
36
+
37
+ /**
38
+ * Run all scans and calculate scores
39
+ */
40
+ export async function gatherScores(
41
+ targetPath: string,
42
+ clients: ScanClients,
43
+ ): Promise<RateResult> {
44
+ const isTsProject = nodeFs.existsSync(path.join(targetPath, "tsconfig.json"));
45
+ const files = getSourceFiles(targetPath, isTsProject);
46
+ const categories: CategoryScore[] = [];
47
+
48
+ // ─── Type Safety ───
49
+ let typeCoverageScore = 100;
50
+ const typeIssues: string[] = [];
51
+
52
+ if (clients.typeCoverage.isAvailable()) {
53
+ const result = clients.typeCoverage.scan(targetPath);
54
+ if (result.success) {
55
+ typeCoverageScore = result.percentage;
56
+ if (result.percentage < 90) {
57
+ typeIssues.push(`${result.total - result.typed} untyped identifiers`);
58
+ }
59
+ }
60
+ }
61
+ categories.push({
62
+ name: "Type Safety",
63
+ score: Math.round(typeCoverageScore),
64
+ icon: "🔷",
65
+ issues: typeIssues,
66
+ });
67
+
68
+ // ─── Complexity ───
69
+ let complexityScore = 100;
70
+ const complexityIssues: string[] = [];
71
+
72
+ let totalScore = 0;
73
+ let fileCount = 0;
74
+ let worstFile = "";
75
+ let worstScore = 100;
76
+
77
+ for (const file of files.slice(0, 50)) {
78
+ if (clients.complexity.isSupportedFile(file)) {
79
+ const metrics = clients.complexity.analyzeFile(file);
80
+ if (metrics) {
81
+ totalScore += metrics.maintainabilityIndex;
82
+ fileCount++;
83
+ if (metrics.maintainabilityIndex < worstScore) {
84
+ worstScore = metrics.maintainabilityIndex;
85
+ worstFile = path.basename(file);
86
+ }
87
+ }
88
+ }
89
+ }
90
+ if (fileCount > 0) {
91
+ complexityScore = totalScore / fileCount;
92
+ if (complexityScore < 70) {
93
+ complexityIssues.push(`High complexity: ${worstFile}`);
94
+ }
95
+ }
96
+ categories.push({
97
+ name: "Complexity",
98
+ score: Math.round(complexityScore),
99
+ icon: "🧩",
100
+ issues: complexityIssues,
101
+ });
102
+
103
+ // ─── Security ───
104
+ let securityScore = 100;
105
+ const securityIssues: string[] = [];
106
+ let secretsFound = 0;
107
+
108
+ // Check for secrets in source files
109
+ const secretPatterns = [
110
+ { name: "API Key (sk-)", pattern: /sk-[a-zA-Z0-9]{20,}/ },
111
+ { name: "GitHub Token", pattern: /ghp_[a-zA-Z0-9]{36}/ },
112
+ { name: "AWS Key", pattern: /AKIA[A-Z0-9]{16}/ },
113
+ { name: "Anthropic Key", pattern: /sk-ant-[a-zA-Z0-9]{20,}/ },
114
+ { name: "OpenAI Key", pattern: /sk-proj-[a-zA-Z0-9]{20,}/ },
115
+ {
116
+ name: "Private Key",
117
+ pattern: /-----BEGIN (?:RSA |EC |DSA )?PRIVATE KEY-----/,
118
+ },
119
+ ];
120
+
121
+ for (const file of files.slice(0, 100)) {
122
+ try {
123
+ const content = nodeFs.readFileSync(file, "utf-8");
124
+ for (const line of content.split("\n")) {
125
+ if (line.trim().startsWith("//") || line.trim().startsWith("#"))
126
+ continue;
127
+ for (const { name, pattern } of secretPatterns) {
128
+ if (pattern.test(line)) {
129
+ secretsFound++;
130
+ if (securityIssues.length < 3) {
131
+ securityIssues.push(`${name} in ${path.basename(file)}`);
132
+ }
133
+ }
134
+ }
135
+ }
136
+ } catch {
137
+ // Skip unreadable files
138
+ }
139
+ }
140
+ securityScore = Math.max(0, 100 - secretsFound * 15);
141
+ categories.push({
142
+ name: "Security",
143
+ score: securityScore,
144
+ icon: "🔒",
145
+ issues: securityIssues,
146
+ });
147
+
148
+ // ─── Architecture ───
149
+ let archScore = 100;
150
+ const archIssues: string[] = [];
151
+
152
+ clients.architect.loadConfig(targetPath);
153
+ if (clients.architect.hasConfig()) {
154
+ let archViolations = 0;
155
+ const scanDir = (dir: string) => {
156
+ for (const entry of nodeFs.readdirSync(dir, { withFileTypes: true })) {
157
+ const full = path.join(dir, entry.name);
158
+ if (entry.isDirectory()) {
159
+ if (
160
+ [
161
+ "node_modules",
162
+ ".git",
163
+ "dist",
164
+ "build",
165
+ ".next",
166
+ ".pi-lens",
167
+ ].includes(entry.name)
168
+ )
169
+ continue;
170
+ scanDir(full);
171
+ } else if (/\.(ts|tsx|js|jsx|py|go|rs)$/.test(entry.name)) {
172
+ const relPath = path.relative(targetPath, full).replace(/\\/g, "/");
173
+ const content = nodeFs.readFileSync(full, "utf-8");
174
+ const violations = clients.architect.checkFile(relPath, content);
175
+ archViolations += violations.length;
176
+ if (violations.length > 0 && archIssues.length < 3) {
177
+ archIssues.push(`${violations.length} in ${path.basename(full)}`);
178
+ }
179
+ const sizeV = clients.architect.checkFileSize(
180
+ relPath,
181
+ content.split("\n").length,
182
+ );
183
+ if (sizeV) archViolations++;
184
+ }
185
+ }
186
+ };
187
+ scanDir(targetPath);
188
+ archScore = Math.max(0, 100 - archViolations * 10);
189
+ }
190
+ categories.push({
191
+ name: "Architecture",
192
+ score: archScore,
193
+ icon: "🏗️",
194
+ issues: archIssues,
195
+ });
196
+
197
+ // ─── Dead Code ───
198
+ let deadCodeScore = 100;
199
+ const deadCodeIssues: string[] = [];
200
+
201
+ if (clients.knip.isAvailable()) {
202
+ const result = clients.knip.analyze(targetPath);
203
+ if (result.success) {
204
+ const unusedExports = result.unusedExports.length;
205
+ const unusedFiles = result.unusedFiles.length;
206
+ const total = unusedExports + unusedFiles;
207
+ deadCodeScore = Math.max(0, 100 - total * 3);
208
+ if (unusedExports > 0) {
209
+ deadCodeIssues.push(`${unusedExports} unused export(s)`);
210
+ }
211
+ if (unusedFiles > 0) {
212
+ deadCodeIssues.push(`${unusedFiles} unused file(s)`);
213
+ }
214
+ }
215
+ }
216
+ categories.push({
217
+ name: "Dead Code",
218
+ score: deadCodeScore,
219
+ icon: "🗑️",
220
+ issues: deadCodeIssues,
221
+ });
222
+
223
+ // ─── Tests ───
224
+ let testScore = 100;
225
+ const testIssues: string[] = [];
226
+
227
+ // Quick test run
228
+ try {
229
+ const testResult = childProcess.spawnSync(
230
+ "npx",
231
+ ["vitest", "run", "--reporter=basic"],
232
+ {
233
+ encoding: "utf-8",
234
+ timeout: 60000,
235
+ shell: true,
236
+ cwd: targetPath,
237
+ },
238
+ );
239
+ if (testResult.status !== 0) {
240
+ const output = (testResult.stdout || "") + (testResult.stderr || "");
241
+ if (output.includes("failed")) {
242
+ // Count failing tests
243
+ const failMatch = output.match(/(\d+) failed/);
244
+ testScore = 50;
245
+ testIssues.push(
246
+ failMatch ? `${failMatch[1]} test(s) failing` : "Some tests failing",
247
+ );
248
+ } else {
249
+ testScore = 70;
250
+ testIssues.push("Tests timed out or errored");
251
+ }
252
+ }
253
+ } catch {
254
+ testScore = 70;
255
+ testIssues.push("Could not run tests");
256
+ }
257
+ categories.push({
258
+ name: "Tests",
259
+ score: testScore,
260
+ icon: "✅",
261
+ issues: testIssues,
262
+ });
263
+
264
+ // ─── Calculate Overall ───
265
+ const overall = Math.round(
266
+ categories.reduce((sum, c) => sum + c.score, 0) / categories.length,
267
+ );
268
+
269
+ return { overall, categories };
270
+ }
271
+
272
+ /**
273
+ * Format score as a bar
274
+ */
275
+ function scoreBar(score: number, width = 10): string {
276
+ const filled = Math.round((score / 100) * width);
277
+ const empty = width - filled;
278
+ const color = score >= 80 ? "🟩" : score >= 60 ? "🟨" : "🟥";
279
+ return color.repeat(filled) + "⬜".repeat(empty);
280
+ }
281
+
282
+ /**
283
+ * Get grade from score
284
+ */
285
+ function getGrade(score: number): string {
286
+ if (score >= 90) return "A";
287
+ if (score >= 80) return "B";
288
+ if (score >= 70) return "C";
289
+ if (score >= 60) return "D";
290
+ return "F";
291
+ }
292
+
293
+ /**
294
+ * Format rate result for terminal
295
+ */
296
+ export function formatRateResult(result: RateResult): string {
297
+ const lines: string[] = [];
298
+
299
+ lines.push("┌─────────────────────────────────────────────────────────┐");
300
+ const gradeText = ` (${getGrade(result.overall)})`;
301
+ const scoreText = `📊 CODE QUALITY SCORE: ${result.overall}/100${gradeText}`;
302
+ const padding = Math.max(0, 55 - scoreText.length);
303
+ lines.push(`│ ${scoreText}${" ".repeat(padding)}│`);
304
+ lines.push("├─────────────────────────────────────────────────────────┤");
305
+
306
+ for (const cat of result.categories) {
307
+ const name = cat.name.padEnd(14);
308
+ const bar = scoreBar(cat.score);
309
+ const score = String(cat.score).padStart(3);
310
+ lines.push(`│ ${cat.icon} ${name} ${bar} ${score} │`);
311
+ }
312
+
313
+ lines.push("└─────────────────────────────────────────────────────────┘");
314
+
315
+ // Show issues if any
316
+ const allIssues = result.categories
317
+ .filter((c) => c.issues.length > 0)
318
+ .flatMap((c) => c.issues.map((i) => `${c.icon} ${c.name}: ${i}`));
319
+
320
+ if (allIssues.length > 0) {
321
+ lines.push("");
322
+ lines.push("Issues to address:");
323
+ for (const issue of allIssues.slice(0, 5)) {
324
+ lines.push(` • ${issue}`);
325
+ }
326
+ if (allIssues.length > 5) {
327
+ lines.push(` ... and ${allIssues.length - 5} more`);
328
+ }
329
+ lines.push("");
330
+ lines.push("💡 Run /lens-booboo for full details");
331
+ }
332
+
333
+ return lines.join("\n");
334
+ }
335
+
336
+ /**
337
+ * Handle /lens-rate command
338
+ */
339
+ export async function handleRate(
340
+ args: string,
341
+ ctx: ExtensionContext,
342
+ clients: ScanClients,
343
+ ): Promise<string> {
344
+ const targetPath = args.trim() || ctx.cwd || process.cwd();
345
+ ctx.ui.notify("📊 Calculating code quality scores...", "info");
346
+ const result = await gatherScores(targetPath, clients);
347
+ return formatRateResult(result);
348
+ }
package/index.ts CHANGED
@@ -38,6 +38,7 @@ import { TypeCoverageClient } from "./clients/type-coverage-client.js";
38
38
  import { TypeScriptClient } from "./clients/typescript-client.js";
39
39
  import { handleBooboo } from "./commands/booboo.js";
40
40
  import { handleFix } from "./commands/fix.js";
41
+ import { handleRate } from "./commands/rate.js";
41
42
  import { handleRefactor, initRefactorLoop } from "./commands/refactor.js";
42
43
 
43
44
  /** Parse a diff to extract modified line ranges in the new file.
@@ -619,6 +620,20 @@ export default function (pi: ExtensionAPI) {
619
620
  },
620
621
  });
621
622
 
623
+ pi.registerCommand("lens-rate", {
624
+ description:
625
+ "Show code quality score with visual breakdown. Usage: /lens-rate [path]",
626
+ handler: async (args, ctx) => {
627
+ const result = await handleRate(args, ctx, {
628
+ complexity: complexityClient,
629
+ knip: knipClient,
630
+ typeCoverage: typeCoverageClient,
631
+ architect: architectClient,
632
+ });
633
+ ctx.ui.notify(result, "info");
634
+ },
635
+ });
636
+
622
637
  pi.registerCommand("lens-format", {
623
638
  description:
624
639
  "Apply Biome formatting to files. Usage: /lens-format [file-path] or /lens-format --all",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-lens",
3
- "version": "2.1.1",
3
+ "version": "2.2.0",
4
4
  "type": "module",
5
5
  "description": "Real-time code quality feedback for pi — TypeScript LSP, Biome, ast-grep, Ruff, complexity metrics, duplicate detection. Includes automated fix loop (/lens-booboo-fix) and interactive architectural refactoring (/lens-booboo-refactor) with browser-based interviews.",
6
6
  "repository": {