pi-lens 2.0.42 → 2.0.43

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,363 @@
1
+ import * as childProcess from "node:child_process";
2
+ import * as nodeFs from "node:fs";
3
+ import * as path from "node:path";
4
+ import { getSourceFiles } from "../clients/scan-utils.js";
5
+ const getExtensionDir = () => {
6
+ if (typeof __dirname !== "undefined") {
7
+ return __dirname;
8
+ }
9
+ return ".";
10
+ };
11
+ export async function handleBooboo(args, ctx, clients, pi) {
12
+ const targetPath = args.trim() || ctx.cwd || process.cwd();
13
+ ctx.ui.notify("šŸ” Running full codebase review...", "info");
14
+ const parts = [];
15
+ const fullReport = [];
16
+ const timestamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
17
+ const reviewDir = path.join(process.cwd(), ".pi-lens", "reviews");
18
+ // Part 1: Design smells via ast-grep
19
+ if (clients.astGrep.isAvailable()) {
20
+ const configPath = path.join(getExtensionDir(), "..", "rules", "ast-grep-rules", ".sgconfig.yml");
21
+ try {
22
+ const result = childProcess.spawnSync("npx", [
23
+ "sg",
24
+ "scan",
25
+ "--config",
26
+ configPath,
27
+ "--json",
28
+ "--globs",
29
+ "!**/*.test.ts",
30
+ "--globs",
31
+ "!**/*.spec.ts",
32
+ "--globs",
33
+ "!**/test-utils.ts",
34
+ "--globs",
35
+ "!**/.pi-lens/**",
36
+ targetPath,
37
+ ], {
38
+ encoding: "utf-8",
39
+ timeout: 30000,
40
+ shell: true,
41
+ maxBuffer: 32 * 1024 * 1024, // 32MB
42
+ });
43
+ const output = result.stdout || result.stderr || "";
44
+ if (output.trim() && result.status !== undefined) {
45
+ const issues = [];
46
+ const parseItems = (raw) => {
47
+ const trimmed = raw.trim();
48
+ if (trimmed.startsWith("[")) {
49
+ try {
50
+ return JSON.parse(trimmed);
51
+ }
52
+ catch (err) {
53
+ void err;
54
+ return [];
55
+ }
56
+ }
57
+ return raw.split("\n").flatMap((l) => {
58
+ try {
59
+ return [JSON.parse(l)];
60
+ }
61
+ catch (err) {
62
+ void err;
63
+ return [];
64
+ }
65
+ });
66
+ };
67
+ for (const item of parseItems(output)) {
68
+ const ruleId = item.ruleId || item.rule?.title || item.name || "unknown";
69
+ const ruleDesc = clients.astGrep.getRuleDescription?.(ruleId);
70
+ const message = ruleDesc?.message || item.message || ruleId;
71
+ const lineNum = item.labels?.[0]?.range?.start?.line ||
72
+ item.spans?.[0]?.range?.start?.line ||
73
+ item.range?.start?.line ||
74
+ 0;
75
+ issues.push({
76
+ line: lineNum + 1,
77
+ rule: ruleId,
78
+ message: message,
79
+ });
80
+ }
81
+ if (issues.length > 0) {
82
+ let report = `[ast-grep] ${issues.length} issue(s) found:\n`;
83
+ for (const issue of issues.slice(0, 20)) {
84
+ report += ` L${issue.line}: ${issue.rule} — ${issue.message}\n`;
85
+ }
86
+ if (issues.length > 20) {
87
+ report += ` ... and ${issues.length - 20} more\n`;
88
+ }
89
+ parts.push(report);
90
+ let fullSection = `## ast-grep (Structural Issues)\n\n**${issues.length} issue(s) found**\n\n`;
91
+ fullSection +=
92
+ "| Line | Rule | Message |\n|------|------|--------|\n";
93
+ for (const issue of issues) {
94
+ fullSection += `| ${issue.line} | ${issue.rule} | ${issue.message} |\n`;
95
+ }
96
+ fullReport.push(fullSection);
97
+ }
98
+ }
99
+ }
100
+ catch (err) {
101
+ const _err = err;
102
+ // Ignored
103
+ }
104
+ }
105
+ // Part 2: Similar functions
106
+ if (clients.astGrep.isAvailable()) {
107
+ const similarGroups = await clients.astGrep.findSimilarFunctions(targetPath, "typescript");
108
+ if (similarGroups.length > 0) {
109
+ let report = `[Similar Functions] ${similarGroups.length} group(s) of structurally similar functions:\n`;
110
+ for (const group of similarGroups.slice(0, 5)) {
111
+ report += ` Pattern: ${group.functions.map((f) => f.name).join(", ")}\n`;
112
+ for (const fn of group.functions) {
113
+ report += ` ${fn.name} (${path.basename(fn.file)}:${fn.line})\n`;
114
+ }
115
+ }
116
+ if (similarGroups.length > 5) {
117
+ report += ` ... and ${similarGroups.length - 5} more groups\n`;
118
+ }
119
+ parts.push(report);
120
+ let fullSection = `## Similar Functions\n\n**${similarGroups.length} group(s) of structurally similar functions**\n\n`;
121
+ for (const group of similarGroups) {
122
+ fullSection += `### Pattern: ${group.functions.map((f) => f.name).join(", ")}\n\n`;
123
+ fullSection +=
124
+ "| Function | File | Line |\n|----------|------|------|\n";
125
+ for (const fn of group.functions) {
126
+ fullSection += `| ${fn.name} | ${fn.file} | ${fn.line} |\n`;
127
+ }
128
+ fullSection += "\n";
129
+ }
130
+ fullReport.push(fullSection);
131
+ }
132
+ }
133
+ // Part 3: Complexity metrics
134
+ const results = [];
135
+ const aiSlopIssues = [];
136
+ const isTsProject = nodeFs.existsSync(path.join(targetPath, "tsconfig.json"));
137
+ const files = getSourceFiles(targetPath, isTsProject);
138
+ for (const fullPath of files) {
139
+ if (clients.complexity.isSupportedFile(fullPath)) {
140
+ const metrics = clients.complexity.analyzeFile(fullPath);
141
+ if (metrics) {
142
+ results.push(metrics);
143
+ if (!/\.(test|spec)\.[jt]sx?$/.test(path.basename(fullPath))) {
144
+ const warnings = clients.complexity.checkThresholds(metrics);
145
+ if (warnings.length > 0) {
146
+ aiSlopIssues.push(` ${metrics.filePath}:`);
147
+ for (const w of warnings) {
148
+ aiSlopIssues.push(` ⚠ ${w}`);
149
+ }
150
+ }
151
+ }
152
+ }
153
+ }
154
+ }
155
+ if (results.length > 0) {
156
+ const avgMI = results.reduce((a, b) => a + b.maintainabilityIndex, 0) / results.length;
157
+ const avgCognitive = results.reduce((a, b) => a + b.cognitiveComplexity, 0) / results.length;
158
+ const avgCyclomatic = results.reduce((a, b) => a + b.cyclomaticComplexity, 0) / results.length;
159
+ const maxNesting = Math.max(...results.map((r) => r.maxNestingDepth));
160
+ const maxCognitive = Math.max(...results.map((r) => r.cognitiveComplexity));
161
+ const minMI = Math.min(...results.map((r) => r.maintainabilityIndex));
162
+ const lowMI = results
163
+ .filter((r) => r.maintainabilityIndex < 60)
164
+ .sort((a, b) => a.maintainabilityIndex - b.maintainabilityIndex);
165
+ const highCognitive = results
166
+ .filter((r) => r.cognitiveComplexity > 20)
167
+ .sort((a, b) => b.cognitiveComplexity - a.cognitiveComplexity);
168
+ let summary = `[Complexity] ${results.length} file(s) scanned\n`;
169
+ summary += ` Maintainability: ${avgMI.toFixed(1)} avg | Cognitive: ${avgCognitive.toFixed(1)} avg | Max Nesting: ${maxNesting} levels\n`;
170
+ if (lowMI.length > 0) {
171
+ summary += `\n Low Maintainability (MI < 60):\n`;
172
+ for (const f of lowMI.slice(0, 5)) {
173
+ summary += ` āœ— ${f.filePath}: MI ${f.maintainabilityIndex.toFixed(1)}\n`;
174
+ }
175
+ if (lowMI.length > 5)
176
+ summary += ` ... and ${lowMI.length - 5} more\n`;
177
+ }
178
+ if (highCognitive.length > 0) {
179
+ summary += `\n High Cognitive Complexity (> 20):\n`;
180
+ for (const f of highCognitive.slice(0, 5)) {
181
+ summary += ` ⚠ ${f.filePath}: ${f.cognitiveComplexity}\n`;
182
+ }
183
+ if (highCognitive.length > 5)
184
+ summary += ` ... and ${highCognitive.length - 5} more\n`;
185
+ }
186
+ if (aiSlopIssues.length > 0) {
187
+ summary += `\n[AI Slop Indicators]\n${aiSlopIssues.join("\n")}`;
188
+ }
189
+ parts.push(summary);
190
+ let fullSection = `## Complexity Metrics\n\n**${results.length} file(s) scanned**\n\n`;
191
+ fullSection += `### Summary\n\n| Metric | Value |\n|--------|-------|\n| Avg Maintainability Index | ${avgMI.toFixed(1)} |\n| Min Maintainability Index | ${minMI.toFixed(1)} |\n| Avg Cognitive Complexity | ${avgCognitive.toFixed(1)} |\n| Max Cognitive Complexity | ${maxCognitive} |\n| Avg Cyclomatic Complexity | ${avgCyclomatic.toFixed(1)} |\n| Max Nesting Depth | ${maxNesting} |\n| Total Files | ${results.length} |\n\n`;
192
+ if (lowMI.length > 0) {
193
+ fullSection += `### Low Maintainability (MI < 60)\n\n| File | MI | Cognitive | Cyclomatic | Nesting |\n|------|-----|-----------|------------|--------|\n`;
194
+ for (const f of lowMI) {
195
+ fullSection += `| ${f.filePath} | ${f.maintainabilityIndex.toFixed(1)} | ${f.cognitiveComplexity} | ${f.cyclomaticComplexity} | ${f.maxNestingDepth} |\n`;
196
+ }
197
+ fullSection += "\n";
198
+ }
199
+ if (highCognitive.length > 0) {
200
+ fullSection += `### High Cognitive Complexity (> 20)\n\n| File | Cognitive | MI | Cyclomatic | Nesting |\n|------|-----------|-----|------------|--------|\n`;
201
+ for (const f of highCognitive) {
202
+ fullSection += `| ${f.filePath} | ${f.cognitiveComplexity} | ${f.maintainabilityIndex.toFixed(1)} | ${f.cyclomaticComplexity} | ${f.maxNestingDepth} |\n`;
203
+ }
204
+ fullSection += "\n";
205
+ }
206
+ fullSection += `### All Files\n\n| File | MI | Cognitive | Cyclomatic | Nesting | Entropy |\n|------|-----|-----------|------------|---------|--------|\n`;
207
+ for (const f of results.sort((a, b) => a.maintainabilityIndex - b.maintainabilityIndex)) {
208
+ fullSection += `| ${f.filePath} | ${f.maintainabilityIndex.toFixed(1)} | ${f.cognitiveComplexity} | ${f.cyclomaticComplexity} | ${f.maxNestingDepth} | ${f.codeEntropy.toFixed(2)} |\n`;
209
+ }
210
+ fullSection += "\n";
211
+ if (aiSlopIssues.length > 0) {
212
+ fullSection += `### AI Slop Indicators\n\n`;
213
+ for (const issue of aiSlopIssues) {
214
+ fullSection += `${issue}\n`;
215
+ }
216
+ fullSection += "\n";
217
+ }
218
+ fullReport.push(fullSection);
219
+ }
220
+ // Part 4: TODOs
221
+ const todoResult = clients.todo.scanDirectory(targetPath);
222
+ const todoReport = clients.todo.formatResult(todoResult);
223
+ if (todoReport) {
224
+ parts.push(todoReport);
225
+ let fullSection = `## TODOs / Annotations\n\n`;
226
+ if (todoResult.items.length > 0) {
227
+ fullSection += `**${todoResult.items.length} annotation(s) found**\n\n| Type | File | Line | Text |\n|------|------|------|------|\n`;
228
+ for (const item of todoResult.items) {
229
+ fullSection += `| ${item.type} | ${item.file} | ${item.line} | ${item.message} |\n`;
230
+ }
231
+ }
232
+ else {
233
+ fullSection += `No annotations found.\n`;
234
+ }
235
+ fullSection += "\n";
236
+ fullReport.push(fullSection);
237
+ }
238
+ // Part 5: Dead code
239
+ if (clients.knip.isAvailable()) {
240
+ const knipResult = clients.knip.analyze(targetPath);
241
+ const knipReport = clients.knip.formatResult(knipResult);
242
+ if (knipReport) {
243
+ parts.push(knipReport);
244
+ let fullSection = `## Dead Code (Knip)\n\n`;
245
+ if (knipResult.issues.length > 0) {
246
+ fullSection += `**${knipResult.issues.length} issue(s) found**\n\n| Type | Name | File |\n|------|------|------|\n`;
247
+ for (const issue of knipResult.issues) {
248
+ fullSection += `| ${issue.type} | ${issue.name} | ${issue.file ?? ""} |\n`;
249
+ }
250
+ }
251
+ else {
252
+ fullSection += `No dead code issues found.\n`;
253
+ }
254
+ fullSection += "\n";
255
+ fullReport.push(fullSection);
256
+ }
257
+ }
258
+ // Part 6: Duplicate code
259
+ if (clients.jscpd.isAvailable()) {
260
+ const jscpdResult = clients.jscpd.scan(targetPath);
261
+ const jscpdReport = clients.jscpd.formatResult(jscpdResult);
262
+ if (jscpdReport) {
263
+ parts.push(jscpdReport);
264
+ let fullSection = `## Code Duplication (jscpd)\n\n`;
265
+ if (jscpdResult.clones.length > 0) {
266
+ fullSection += `**${jscpdResult.clones.length} duplicate block(s) found** (${jscpdResult.duplicatedLines}/${jscpdResult.totalLines} lines, ${jscpdResult.percentage.toFixed(1)}%)\n\n| File A | Line A | File B | Line B | Lines | Tokens |\n|--------|--------|--------|--------|-------|--------|\n`;
267
+ for (const dup of jscpdResult.clones) {
268
+ fullSection += `| ${dup.fileA} | ${dup.startA} | ${dup.fileB} | ${dup.startB} | ${dup.lines} | ${dup.tokens} |\n`;
269
+ }
270
+ }
271
+ else {
272
+ fullSection += `No duplicate code found.\n`;
273
+ }
274
+ fullSection += "\n";
275
+ fullReport.push(fullSection);
276
+ }
277
+ }
278
+ // Part 7: Type coverage
279
+ if (clients.typeCoverage.isAvailable()) {
280
+ const tcResult = clients.typeCoverage.scan(targetPath);
281
+ const tcReport = clients.typeCoverage.formatResult(tcResult);
282
+ if (tcReport) {
283
+ parts.push(tcReport);
284
+ let fullSection = `## Type Coverage\n\n**${tcResult.percentage.toFixed(1)}% typed** (${tcResult.typed}/${tcResult.total} identifiers)\n\n`;
285
+ if (tcResult.untypedLocations.length > 0) {
286
+ fullSection += `### Untyped Identifiers\n\n| File | Line | Column | Name |\n|------|------|--------|------|\n`;
287
+ for (const u of tcResult.untypedLocations) {
288
+ fullSection += `| ${u.file} | ${u.line} | ${u.column} | ${u.name} |\n`;
289
+ }
290
+ }
291
+ fullSection += "\n";
292
+ fullReport.push(fullSection);
293
+ }
294
+ }
295
+ // Part 8: Circular deps
296
+ if (!pi.getFlag("no-madge") && clients.depChecker.isAvailable()) {
297
+ const { circular } = clients.depChecker.scanProject(targetPath);
298
+ const depReport = clients.depChecker.formatScanResult(circular);
299
+ if (depReport) {
300
+ parts.push(depReport);
301
+ let fullSection = `## Circular Dependencies (Madge)\n\n**${circular.length} circular chain(s) found**\n\n`;
302
+ for (const dep of circular) {
303
+ fullSection += `- ${dep.path.join(" → ")}\n`;
304
+ }
305
+ fullReport.push(`${fullSection}\n`);
306
+ }
307
+ }
308
+ // Part 9: Arch rules
309
+ if (!clients.architect.hasConfig()) {
310
+ clients.architect.loadConfig(process.cwd());
311
+ }
312
+ if (clients.architect.hasConfig()) {
313
+ const archViolations = [];
314
+ const archScanDir = (dir) => {
315
+ for (const entry of nodeFs.readdirSync(dir, { withFileTypes: true })) {
316
+ const full = path.join(dir, entry.name);
317
+ if (entry.isDirectory()) {
318
+ if ([
319
+ "node_modules",
320
+ ".git",
321
+ "dist",
322
+ "build",
323
+ ".next",
324
+ ".pi-lens",
325
+ ].includes(entry.name))
326
+ continue;
327
+ archScanDir(full);
328
+ }
329
+ else if (/\.(ts|tsx|js|jsx|py|go|rs)$/.test(entry.name)) {
330
+ const relPath = path.relative(targetPath, full).replace(/\\/g, "/");
331
+ const content = nodeFs.readFileSync(full, "utf-8");
332
+ const lineCount = content.split("\n").length;
333
+ for (const v of clients.architect.checkFile(relPath, content)) {
334
+ archViolations.push({ file: relPath, message: v.message });
335
+ }
336
+ const sizeV = clients.architect.checkFileSize(relPath, lineCount);
337
+ if (sizeV)
338
+ archViolations.push({ file: relPath, message: sizeV.message });
339
+ }
340
+ }
341
+ };
342
+ archScanDir(targetPath);
343
+ if (archViolations.length > 0) {
344
+ parts.push(`šŸ”“ ${archViolations.length} architectural violation(s) — fix before adding new code`);
345
+ let fullSection = `## Architectural Rules\n\n**${archViolations.length} violation(s) found**\n\n`;
346
+ for (const v of archViolations) {
347
+ fullSection += `- **${v.file}**: ${v.message}\n`;
348
+ }
349
+ fullReport.push(`${fullSection}\n`);
350
+ }
351
+ }
352
+ nodeFs.mkdirSync(reviewDir, { recursive: true });
353
+ const projectName = path.basename(process.cwd());
354
+ const mdReport = `# Code Review: ${projectName}\n\n**Scanned:** ${new Date().toISOString()}\n\n**Path:** \`${targetPath}\`\n\n---\n\n${fullReport.join("\n")}`;
355
+ const reportPath = path.join(reviewDir, `booboo-${timestamp}.md`);
356
+ nodeFs.writeFileSync(reportPath, mdReport, "utf-8");
357
+ if (parts.length === 0) {
358
+ ctx.ui.notify("āœ“ Code review clean — saved to .pi-lens/reviews/", "info");
359
+ }
360
+ else {
361
+ ctx.ui.notify(`${parts.join("\n\n")}\n\nšŸ“„ Full report: ${reportPath}`, "info");
362
+ }
363
+ }