pi-lens 2.1.0 → 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.
Files changed (34) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/README.md +70 -1
  3. package/clients/ast-grep-client.js +12 -12
  4. package/clients/ast-grep-client.ts +21 -11
  5. package/clients/dispatch/dispatcher.js +2 -2
  6. package/clients/dispatch/dispatcher.ts +2 -2
  7. package/clients/dispatch/runners/index.js +3 -1
  8. package/clients/dispatch/runners/index.ts +3 -1
  9. package/clients/dispatch/runners/pyright.js +68 -0
  10. package/clients/dispatch/runners/pyright.test.js +84 -0
  11. package/clients/dispatch/runners/pyright.test.ts +109 -0
  12. package/clients/dispatch/runners/pyright.ts +102 -0
  13. package/clients/dispatch/runners/secrets.js +109 -0
  14. package/clients/secrets-scanner.js +113 -0
  15. package/clients/secrets-scanner.test.js +100 -0
  16. package/clients/secrets-scanner.test.ts +113 -0
  17. package/clients/secrets-scanner.ts +134 -0
  18. package/clients/sg-runner.js +15 -2
  19. package/clients/sg-runner.ts +25 -2
  20. package/commands/fix.js +48 -50
  21. package/commands/fix.ts +71 -61
  22. package/commands/rate.js +285 -0
  23. package/commands/rate.test.js +119 -0
  24. package/commands/rate.test.ts +131 -0
  25. package/commands/rate.ts +348 -0
  26. package/commands/refactor.js +33 -9
  27. package/commands/refactor.ts +44 -11
  28. package/default-architect.yaml +7 -0
  29. package/index.ts +58 -10
  30. package/package.json +1 -1
  31. package/rules/ast-grep-rules/rules/no-default-export.yml +19 -0
  32. package/rules/ast-grep-rules/rules/no-hardcoded-secrets.yml +9 -6
  33. package/rules/ast-grep-rules/rules/no-process-env.yml +12 -12
  34. package/rules/ast-grep-rules/rules/no-relative-imports.yml +21 -0
package/commands/fix.js CHANGED
@@ -84,71 +84,69 @@ function generatePlan(results, session, _isTsProject, prevCounts) {
84
84
  if (noProgress) {
85
85
  return `⚠️ BOOBOO FIX LOOP STOPPED — No progress after ${session.iteration} iteration(s).\n\nRemaining items may be false positives. Mark with: /lens-booboo-fix --false-positive "<type>:<file>:<line>"`;
86
86
  }
87
- // Build plan
88
- const lines = [];
89
- lines.push(`📋 BOOBOO FIX PLAN — Iteration ${session.iteration}/${MAX_ITERATIONS} (${totalFixable} fixable items remaining)`);
90
- lines.push("");
87
+ // --- Write TSV plan file for agent to read ---
88
+ const reportDir = path.join(process.cwd(), ".pi-lens", "reports");
89
+ nodeFs.mkdirSync(reportDir, { recursive: true });
90
+ const reportPath = path.join(reportDir, "fix-plan.tsv");
91
+ const tsvRows = ["type\tfile\trule\tmessage"];
91
92
  // Duplicates
92
- if (filteredDups.length > 0) {
93
- lines.push(`## 🔁 Duplicate code [${filteredDups.length} block(s)] fix first`);
94
- lines.push("→ Extract duplicated blocks into shared utilities.");
95
- for (const clone of filteredDups.slice(0, 5)) {
96
- lines.push(` - ${clone.lines} lines: \`${clone.fileA}:${clone.startA}\` ↔ \`${clone.fileB}:${clone.startB}\``);
97
- }
98
- if (filteredDups.length > 5)
99
- lines.push(` ... and ${filteredDups.length - 5} more`);
100
- lines.push("");
93
+ for (const clone of filteredDups) {
94
+ tsvRows.push(`dup\t${clone.fileA}:${clone.startA}\tduplicate-code\t${clone.lines} lines duplicated with ${clone.fileB}:${clone.startB}`);
101
95
  }
102
96
  // Dead code
103
- if (filteredDeadCode.length > 0) {
104
- lines.push(`## 🗑️ Dead code [${filteredDeadCode.length} item(s)]`);
105
- for (const issue of filteredDeadCode.slice(0, 10)) {
106
- lines.push(` - [${issue.type}] \`${issue.name}\`${issue.file ? ` in ${issue.file}` : ""}`);
97
+ for (const issue of filteredDeadCode) {
98
+ tsvRows.push(`dead\t${issue.file || issue.name}\t${issue.type}\t${issue.name} is unused`);
99
+ }
100
+ // AST issues
101
+ for (const issue of agentTasks) {
102
+ tsvRows.push(`ast\t${issue.file}:${issue.line}\t${issue.rule}\t${issue.message}`);
103
+ }
104
+ // Biome
105
+ for (const issue of filteredBiome) {
106
+ tsvRows.push(`biome\t${issue.file}:${issue.line}\t${issue.rule}\t${issue.message}`);
107
+ }
108
+ // Slop
109
+ for (const { file, warnings } of filteredSlop) {
110
+ for (const w of warnings) {
111
+ tsvRows.push(`slop\t${file}\tcomplexity\t${w}`);
107
112
  }
108
- if (filteredDeadCode.length > 10)
109
- lines.push(` ... and ${filteredDeadCode.length - 10} more`);
110
- lines.push("");
111
113
  }
112
- // AST issues to fix
114
+ nodeFs.writeFileSync(reportPath, tsvRows.join("\n"), "utf-8");
115
+ // --- Build compact summary for terminal ---
116
+ const lines = [];
117
+ lines.push(`📋 FIX PLAN — Iteration ${session.iteration}/${MAX_ITERATIONS} — ${totalFixable} issues`);
118
+ lines.push(`📄 Full plan: .pi-lens/reports/fix-plan.tsv\n`);
119
+ // Summary by category with counts
120
+ if (filteredDups.length > 0) {
121
+ lines.push(`🔁 Duplicates: ${filteredDups.length} block(s) — extract to shared utils`);
122
+ }
123
+ if (filteredDeadCode.length > 0) {
124
+ lines.push(`🗑️ Dead code: ${filteredDeadCode.length} item(s) — remove unused exports`);
125
+ }
113
126
  if (agentTasks.length > 0) {
114
- lines.push(`## 🔨 Fix these [${agentTasks.length} items]`);
127
+ // Group by rule, show top 5 rules
115
128
  const grouped = new Map();
116
129
  for (const t of agentTasks) {
117
- const list = grouped.get(t.rule) ?? [];
118
- list.push(t);
119
- grouped.set(t.rule, list);
130
+ grouped.set(t.rule, (grouped.get(t.rule) ?? 0) + 1);
120
131
  }
121
- for (const [rule, issues] of grouped) {
122
- lines.push(`### ${rule} (${issues.length})`);
123
- for (const issue of issues.slice(0, 10)) {
124
- lines.push(` - \`${issue.file}:${issue.line}\``);
125
- }
126
- if (issues.length > 10)
127
- lines.push(` ... and ${issues.length - 10} more`);
128
- lines.push("");
132
+ const sorted = [...grouped.entries()].sort((a, b) => b[1] - a[1]);
133
+ lines.push(`🔨 Lint: ${agentTasks.length} item(s)`);
134
+ for (const [rule, count] of sorted.slice(0, 5)) {
135
+ lines.push(` ${rule}: ${count}`);
129
136
  }
137
+ if (sorted.length > 5)
138
+ lines.push(` ... and ${sorted.length - 5} more rules`);
130
139
  }
131
- // Biome lint
132
140
  if (filteredBiome.length > 0) {
133
- lines.push(`## 🟠 Biome lint [${filteredBiome.length} items]`);
134
- for (const d of filteredBiome.slice(0, 5)) {
135
- lines.push(` - \`${d.file}:${d.line}\` [${d.rule}] ${d.message}`);
136
- }
137
- if (filteredBiome.length > 5)
138
- lines.push(` ... and ${filteredBiome.length - 5} more`);
139
- lines.push("");
141
+ lines.push(`🟠 Biome: ${filteredBiome.length} item(s) — auto-fixable`);
140
142
  }
141
- // AI slop
142
143
  if (filteredSlop.length > 0) {
143
- lines.push(`## 🤖 AI Slop indicators [${filteredSlop.length} files]`);
144
- for (const { file, warnings } of filteredSlop.slice(0, 5)) {
145
- lines.push(` - \`${file}\`: ${warnings.map((w) => w.split(" — ")[0]).join(", ")}`);
146
- }
147
- lines.push("");
144
+ lines.push(`🤖 AI Slop: ${filteredSlop.length} file(s) — review complexity`);
148
145
  }
149
- lines.push("---");
150
- lines.push("**ACTION REQUIRED**: Fix items above, then run `/lens-booboo-fix --loop` again.");
151
- lines.push('Mark false positives with: `/lens-booboo-fix --false-positive "type:file:line"`');
146
+ lines.push("\n---");
147
+ lines.push("📖 **Read plan**: `read .pi-lens/reports/fix-plan.tsv` for full details");
148
+ lines.push("🚀 **Fix & loop**: Fix items, then run `/lens-booboo-fix --loop`");
149
+ lines.push('🚫 **False positive**: `/lens-booboo-fix --false-positive "type:file:line"`');
152
150
  return lines.join("\n");
153
151
  }
154
152
  // --- Main handler ---
package/commands/fix.ts CHANGED
@@ -139,90 +139,100 @@ function generatePlan(
139
139
  return `⚠️ BOOBOO FIX LOOP STOPPED — No progress after ${session.iteration} iteration(s).\n\nRemaining items may be false positives. Mark with: /lens-booboo-fix --false-positive "<type>:<file>:<line>"`;
140
140
  }
141
141
 
142
- // Build plan
142
+ // --- Write TSV plan file for agent to read ---
143
+ const reportDir = path.join(process.cwd(), ".pi-lens", "reports");
144
+ nodeFs.mkdirSync(reportDir, { recursive: true });
145
+ const reportPath = path.join(reportDir, "fix-plan.tsv");
146
+
147
+ const tsvRows: string[] = ["type\tfile\trule\tmessage"];
148
+
149
+ // Duplicates
150
+ for (const clone of filteredDups) {
151
+ tsvRows.push(
152
+ `dup\t${clone.fileA}:${clone.startA}\tduplicate-code\t${clone.lines} lines duplicated with ${clone.fileB}:${clone.startB}`,
153
+ );
154
+ }
155
+
156
+ // Dead code
157
+ for (const issue of filteredDeadCode) {
158
+ tsvRows.push(
159
+ `dead\t${issue.file || issue.name}\t${issue.type}\t${issue.name} is unused`,
160
+ );
161
+ }
162
+
163
+ // AST issues
164
+ for (const issue of agentTasks) {
165
+ tsvRows.push(
166
+ `ast\t${issue.file}:${issue.line}\t${issue.rule}\t${issue.message}`,
167
+ );
168
+ }
169
+
170
+ // Biome
171
+ for (const issue of filteredBiome) {
172
+ tsvRows.push(
173
+ `biome\t${issue.file}:${issue.line}\t${issue.rule}\t${issue.message}`,
174
+ );
175
+ }
176
+
177
+ // Slop
178
+ for (const { file, warnings } of filteredSlop) {
179
+ for (const w of warnings) {
180
+ tsvRows.push(`slop\t${file}\tcomplexity\t${w}`);
181
+ }
182
+ }
183
+
184
+ nodeFs.writeFileSync(reportPath, tsvRows.join("\n"), "utf-8");
185
+
186
+ // --- Build compact summary for terminal ---
143
187
  const lines: string[] = [];
144
188
  lines.push(
145
- `📋 BOOBOO FIX PLAN — Iteration ${session.iteration}/${MAX_ITERATIONS} (${totalFixable} fixable items remaining)`,
189
+ `📋 FIX PLAN — Iteration ${session.iteration}/${MAX_ITERATIONS} ${totalFixable} issues`,
146
190
  );
147
- lines.push("");
191
+ lines.push(`📄 Full plan: .pi-lens/reports/fix-plan.tsv\n`);
148
192
 
149
- // Duplicates
193
+ // Summary by category with counts
150
194
  if (filteredDups.length > 0) {
151
195
  lines.push(
152
- `## 🔁 Duplicate code [${filteredDups.length} block(s)]fix first`,
196
+ `🔁 Duplicates: ${filteredDups.length} block(s) — extract to shared utils`,
153
197
  );
154
- lines.push("→ Extract duplicated blocks into shared utilities.");
155
- for (const clone of filteredDups.slice(0, 5)) {
156
- lines.push(
157
- ` - ${clone.lines} lines: \`${clone.fileA}:${clone.startA}\` ↔ \`${clone.fileB}:${clone.startB}\``,
158
- );
159
- }
160
- if (filteredDups.length > 5)
161
- lines.push(` ... and ${filteredDups.length - 5} more`);
162
- lines.push("");
163
198
  }
164
-
165
- // Dead code
166
199
  if (filteredDeadCode.length > 0) {
167
- lines.push(`## 🗑️ Dead code [${filteredDeadCode.length} item(s)]`);
168
- for (const issue of filteredDeadCode.slice(0, 10)) {
169
- lines.push(
170
- ` - [${issue.type}] \`${issue.name}\`${issue.file ? ` in ${issue.file}` : ""}`,
171
- );
172
- }
173
- if (filteredDeadCode.length > 10)
174
- lines.push(` ... and ${filteredDeadCode.length - 10} more`);
175
- lines.push("");
200
+ lines.push(
201
+ `🗑️ Dead code: ${filteredDeadCode.length} item(s) — remove unused exports`,
202
+ );
176
203
  }
177
-
178
- // AST issues to fix
179
204
  if (agentTasks.length > 0) {
180
- lines.push(`## 🔨 Fix these [${agentTasks.length} items]`);
181
- const grouped = new Map<string, AstIssue[]>();
205
+ // Group by rule, show top 5 rules
206
+ const grouped = new Map<string, number>();
182
207
  for (const t of agentTasks) {
183
- const list = grouped.get(t.rule) ?? [];
184
- list.push(t);
185
- grouped.set(t.rule, list);
208
+ grouped.set(t.rule, (grouped.get(t.rule) ?? 0) + 1);
186
209
  }
187
- for (const [rule, issues] of grouped) {
188
- lines.push(`### ${rule} (${issues.length})`);
189
- for (const issue of issues.slice(0, 10)) {
190
- lines.push(` - \`${issue.file}:${issue.line}\``);
191
- }
192
- if (issues.length > 10)
193
- lines.push(` ... and ${issues.length - 10} more`);
194
- lines.push("");
210
+ const sorted = [...grouped.entries()].sort((a, b) => b[1] - a[1]);
211
+ lines.push(`🔨 Lint: ${agentTasks.length} item(s)`);
212
+ for (const [rule, count] of sorted.slice(0, 5)) {
213
+ lines.push(` ${rule}: ${count}`);
195
214
  }
215
+ if (sorted.length > 5)
216
+ lines.push(` ... and ${sorted.length - 5} more rules`);
196
217
  }
197
-
198
- // Biome lint
199
218
  if (filteredBiome.length > 0) {
200
- lines.push(`## 🟠 Biome lint [${filteredBiome.length} items]`);
201
- for (const d of filteredBiome.slice(0, 5)) {
202
- lines.push(` - \`${d.file}:${d.line}\` [${d.rule}] ${d.message}`);
203
- }
204
- if (filteredBiome.length > 5)
205
- lines.push(` ... and ${filteredBiome.length - 5} more`);
206
- lines.push("");
219
+ lines.push(`🟠 Biome: ${filteredBiome.length} item(s) — auto-fixable`);
207
220
  }
208
-
209
- // AI slop
210
221
  if (filteredSlop.length > 0) {
211
- lines.push(`## 🤖 AI Slop indicators [${filteredSlop.length} files]`);
212
- for (const { file, warnings } of filteredSlop.slice(0, 5)) {
213
- lines.push(
214
- ` - \`${file}\`: ${warnings.map((w) => w.split(" — ")[0]).join(", ")}`,
215
- );
216
- }
217
- lines.push("");
222
+ lines.push(
223
+ `🤖 AI Slop: ${filteredSlop.length} file(s) review complexity`,
224
+ );
218
225
  }
219
226
 
220
- lines.push("---");
227
+ lines.push("\n---");
228
+ lines.push(
229
+ "📖 **Read plan**: `read .pi-lens/reports/fix-plan.tsv` for full details",
230
+ );
221
231
  lines.push(
222
- "**ACTION REQUIRED**: Fix items above, then run `/lens-booboo-fix --loop` again.",
232
+ "🚀 **Fix & loop**: Fix items, then run `/lens-booboo-fix --loop`",
223
233
  );
224
234
  lines.push(
225
- 'Mark false positives with: `/lens-booboo-fix --false-positive "type:file:line"`',
235
+ '🚫 **False positive**: `/lens-booboo-fix --false-positive "type:file:line"`',
226
236
  );
227
237
 
228
238
  return lines.join("\n");
@@ -0,0 +1,285 @@
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
+ import * as childProcess from "node:child_process";
8
+ import * as nodeFs from "node:fs";
9
+ import * as path from "node:path";
10
+ import { getSourceFiles } from "../clients/scan-utils.js";
11
+ /**
12
+ * Run all scans and calculate scores
13
+ */
14
+ export async function gatherScores(targetPath, clients) {
15
+ const isTsProject = nodeFs.existsSync(path.join(targetPath, "tsconfig.json"));
16
+ const files = getSourceFiles(targetPath, isTsProject);
17
+ const categories = [];
18
+ // ─── Type Safety ───
19
+ let typeCoverageScore = 100;
20
+ const typeIssues = [];
21
+ if (clients.typeCoverage.isAvailable()) {
22
+ const result = clients.typeCoverage.scan(targetPath);
23
+ if (result.success) {
24
+ typeCoverageScore = result.percentage;
25
+ if (result.percentage < 90) {
26
+ typeIssues.push(`${result.total - result.typed} untyped identifiers`);
27
+ }
28
+ }
29
+ }
30
+ categories.push({
31
+ name: "Type Safety",
32
+ score: Math.round(typeCoverageScore),
33
+ icon: "🔷",
34
+ issues: typeIssues,
35
+ });
36
+ // ─── Complexity ───
37
+ let complexityScore = 100;
38
+ const complexityIssues = [];
39
+ let totalScore = 0;
40
+ let fileCount = 0;
41
+ let worstFile = "";
42
+ let worstScore = 100;
43
+ for (const file of files.slice(0, 50)) {
44
+ if (clients.complexity.isSupportedFile(file)) {
45
+ const metrics = clients.complexity.analyzeFile(file);
46
+ if (metrics) {
47
+ totalScore += metrics.maintainabilityIndex;
48
+ fileCount++;
49
+ if (metrics.maintainabilityIndex < worstScore) {
50
+ worstScore = metrics.maintainabilityIndex;
51
+ worstFile = path.basename(file);
52
+ }
53
+ }
54
+ }
55
+ }
56
+ if (fileCount > 0) {
57
+ complexityScore = totalScore / fileCount;
58
+ if (complexityScore < 70) {
59
+ complexityIssues.push(`High complexity: ${worstFile}`);
60
+ }
61
+ }
62
+ categories.push({
63
+ name: "Complexity",
64
+ score: Math.round(complexityScore),
65
+ icon: "🧩",
66
+ issues: complexityIssues,
67
+ });
68
+ // ─── Security ───
69
+ let securityScore = 100;
70
+ const securityIssues = [];
71
+ let secretsFound = 0;
72
+ // Check for secrets in source files
73
+ const secretPatterns = [
74
+ { name: "API Key (sk-)", pattern: /sk-[a-zA-Z0-9]{20,}/ },
75
+ { name: "GitHub Token", pattern: /ghp_[a-zA-Z0-9]{36}/ },
76
+ { name: "AWS Key", pattern: /AKIA[A-Z0-9]{16}/ },
77
+ { name: "Anthropic Key", pattern: /sk-ant-[a-zA-Z0-9]{20,}/ },
78
+ { name: "OpenAI Key", pattern: /sk-proj-[a-zA-Z0-9]{20,}/ },
79
+ {
80
+ name: "Private Key",
81
+ pattern: /-----BEGIN (?:RSA |EC |DSA )?PRIVATE KEY-----/,
82
+ },
83
+ ];
84
+ for (const file of files.slice(0, 100)) {
85
+ try {
86
+ const content = nodeFs.readFileSync(file, "utf-8");
87
+ for (const line of content.split("\n")) {
88
+ if (line.trim().startsWith("//") || line.trim().startsWith("#"))
89
+ continue;
90
+ for (const { name, pattern } of secretPatterns) {
91
+ if (pattern.test(line)) {
92
+ secretsFound++;
93
+ if (securityIssues.length < 3) {
94
+ securityIssues.push(`${name} in ${path.basename(file)}`);
95
+ }
96
+ }
97
+ }
98
+ }
99
+ }
100
+ catch {
101
+ // Skip unreadable files
102
+ }
103
+ }
104
+ securityScore = Math.max(0, 100 - secretsFound * 15);
105
+ categories.push({
106
+ name: "Security",
107
+ score: securityScore,
108
+ icon: "🔒",
109
+ issues: securityIssues,
110
+ });
111
+ // ─── Architecture ───
112
+ let archScore = 100;
113
+ const archIssues = [];
114
+ clients.architect.loadConfig(targetPath);
115
+ if (clients.architect.hasConfig()) {
116
+ let archViolations = 0;
117
+ const scanDir = (dir) => {
118
+ for (const entry of nodeFs.readdirSync(dir, { withFileTypes: true })) {
119
+ const full = path.join(dir, entry.name);
120
+ if (entry.isDirectory()) {
121
+ if ([
122
+ "node_modules",
123
+ ".git",
124
+ "dist",
125
+ "build",
126
+ ".next",
127
+ ".pi-lens",
128
+ ].includes(entry.name))
129
+ continue;
130
+ scanDir(full);
131
+ }
132
+ else if (/\.(ts|tsx|js|jsx|py|go|rs)$/.test(entry.name)) {
133
+ const relPath = path.relative(targetPath, full).replace(/\\/g, "/");
134
+ const content = nodeFs.readFileSync(full, "utf-8");
135
+ const violations = clients.architect.checkFile(relPath, content);
136
+ archViolations += violations.length;
137
+ if (violations.length > 0 && archIssues.length < 3) {
138
+ archIssues.push(`${violations.length} in ${path.basename(full)}`);
139
+ }
140
+ const sizeV = clients.architect.checkFileSize(relPath, content.split("\n").length);
141
+ if (sizeV)
142
+ archViolations++;
143
+ }
144
+ }
145
+ };
146
+ scanDir(targetPath);
147
+ archScore = Math.max(0, 100 - archViolations * 10);
148
+ }
149
+ categories.push({
150
+ name: "Architecture",
151
+ score: archScore,
152
+ icon: "🏗️",
153
+ issues: archIssues,
154
+ });
155
+ // ─── Dead Code ───
156
+ let deadCodeScore = 100;
157
+ const deadCodeIssues = [];
158
+ if (clients.knip.isAvailable()) {
159
+ const result = clients.knip.analyze(targetPath);
160
+ if (result.success) {
161
+ const unusedExports = result.unusedExports.length;
162
+ const unusedFiles = result.unusedFiles.length;
163
+ const total = unusedExports + unusedFiles;
164
+ deadCodeScore = Math.max(0, 100 - total * 3);
165
+ if (unusedExports > 0) {
166
+ deadCodeIssues.push(`${unusedExports} unused export(s)`);
167
+ }
168
+ if (unusedFiles > 0) {
169
+ deadCodeIssues.push(`${unusedFiles} unused file(s)`);
170
+ }
171
+ }
172
+ }
173
+ categories.push({
174
+ name: "Dead Code",
175
+ score: deadCodeScore,
176
+ icon: "🗑️",
177
+ issues: deadCodeIssues,
178
+ });
179
+ // ─── Tests ───
180
+ let testScore = 100;
181
+ const testIssues = [];
182
+ // Quick test run
183
+ try {
184
+ const testResult = childProcess.spawnSync("npx", ["vitest", "run", "--reporter=basic"], {
185
+ encoding: "utf-8",
186
+ timeout: 60000,
187
+ shell: true,
188
+ cwd: targetPath,
189
+ });
190
+ if (testResult.status !== 0) {
191
+ const output = (testResult.stdout || "") + (testResult.stderr || "");
192
+ if (output.includes("failed")) {
193
+ // Count failing tests
194
+ const failMatch = output.match(/(\d+) failed/);
195
+ testScore = 50;
196
+ testIssues.push(failMatch ? `${failMatch[1]} test(s) failing` : "Some tests failing");
197
+ }
198
+ else {
199
+ testScore = 70;
200
+ testIssues.push("Tests timed out or errored");
201
+ }
202
+ }
203
+ }
204
+ catch {
205
+ testScore = 70;
206
+ testIssues.push("Could not run tests");
207
+ }
208
+ categories.push({
209
+ name: "Tests",
210
+ score: testScore,
211
+ icon: "✅",
212
+ issues: testIssues,
213
+ });
214
+ // ─── Calculate Overall ───
215
+ const overall = Math.round(categories.reduce((sum, c) => sum + c.score, 0) / categories.length);
216
+ return { overall, categories };
217
+ }
218
+ /**
219
+ * Format score as a bar
220
+ */
221
+ function scoreBar(score, width = 10) {
222
+ const filled = Math.round((score / 100) * width);
223
+ const empty = width - filled;
224
+ const color = score >= 80 ? "🟩" : score >= 60 ? "🟨" : "🟥";
225
+ return color.repeat(filled) + "⬜".repeat(empty);
226
+ }
227
+ /**
228
+ * Get grade from score
229
+ */
230
+ function getGrade(score) {
231
+ if (score >= 90)
232
+ return "A";
233
+ if (score >= 80)
234
+ return "B";
235
+ if (score >= 70)
236
+ return "C";
237
+ if (score >= 60)
238
+ return "D";
239
+ return "F";
240
+ }
241
+ /**
242
+ * Format rate result for terminal
243
+ */
244
+ export function formatRateResult(result) {
245
+ const lines = [];
246
+ lines.push("┌─────────────────────────────────────────────────────────┐");
247
+ const gradeText = ` (${getGrade(result.overall)})`;
248
+ const scoreText = `📊 CODE QUALITY SCORE: ${result.overall}/100${gradeText}`;
249
+ const padding = Math.max(0, 55 - scoreText.length);
250
+ lines.push(`│ ${scoreText}${" ".repeat(padding)}│`);
251
+ lines.push("├─────────────────────────────────────────────────────────┤");
252
+ for (const cat of result.categories) {
253
+ const name = cat.name.padEnd(14);
254
+ const bar = scoreBar(cat.score);
255
+ const score = String(cat.score).padStart(3);
256
+ lines.push(`│ ${cat.icon} ${name} ${bar} ${score} │`);
257
+ }
258
+ lines.push("└─────────────────────────────────────────────────────────┘");
259
+ // Show issues if any
260
+ const allIssues = result.categories
261
+ .filter((c) => c.issues.length > 0)
262
+ .flatMap((c) => c.issues.map((i) => `${c.icon} ${c.name}: ${i}`));
263
+ if (allIssues.length > 0) {
264
+ lines.push("");
265
+ lines.push("Issues to address:");
266
+ for (const issue of allIssues.slice(0, 5)) {
267
+ lines.push(` • ${issue}`);
268
+ }
269
+ if (allIssues.length > 5) {
270
+ lines.push(` ... and ${allIssues.length - 5} more`);
271
+ }
272
+ lines.push("");
273
+ lines.push("💡 Run /lens-booboo for full details");
274
+ }
275
+ return lines.join("\n");
276
+ }
277
+ /**
278
+ * Handle /lens-rate command
279
+ */
280
+ export async function handleRate(args, ctx, clients) {
281
+ const targetPath = args.trim() || ctx.cwd || process.cwd();
282
+ ctx.ui.notify("📊 Calculating code quality scores...", "info");
283
+ const result = await gatherScores(targetPath, clients);
284
+ return formatRateResult(result);
285
+ }