pi-lens 2.0.37 → 2.0.39

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 (46) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/README.md +1 -1
  3. package/clients/architect-client.js +50 -20
  4. package/clients/architect-client.ts +61 -22
  5. package/clients/ast-grep-client.js +32 -127
  6. package/clients/ast-grep-client.test.js +2 -14
  7. package/clients/ast-grep-client.test.ts +2 -16
  8. package/clients/ast-grep-client.ts +34 -150
  9. package/clients/auto-loop.js +117 -0
  10. package/clients/auto-loop.ts +171 -0
  11. package/clients/biome-client.js +25 -22
  12. package/clients/biome-client.ts +28 -25
  13. package/clients/complexity-client.js +111 -74
  14. package/clients/complexity-client.ts +149 -105
  15. package/clients/dependency-checker.js +16 -30
  16. package/clients/dependency-checker.ts +15 -35
  17. package/clients/fix-scanners.js +195 -0
  18. package/clients/fix-scanners.ts +297 -0
  19. package/clients/interviewer-templates.js +75 -0
  20. package/clients/interviewer-templates.ts +90 -0
  21. package/clients/interviewer.js +73 -101
  22. package/clients/interviewer.ts +195 -140
  23. package/clients/knip-client.js +12 -4
  24. package/clients/knip-client.ts +21 -16
  25. package/clients/metrics-history.js +215 -0
  26. package/clients/metrics-history.ts +300 -0
  27. package/clients/scan-architectural-debt.js +62 -72
  28. package/clients/scan-architectural-debt.ts +79 -64
  29. package/clients/scan-utils.js +98 -0
  30. package/clients/scan-utils.ts +112 -0
  31. package/clients/sg-runner.js +138 -0
  32. package/clients/sg-runner.ts +168 -0
  33. package/clients/subprocess-client.js +0 -37
  34. package/clients/subprocess-client.ts +0 -60
  35. package/clients/ts-service.ts +1 -7
  36. package/clients/type-safety-client.js +3 -8
  37. package/clients/type-safety-client.ts +10 -14
  38. package/clients/typescript-client.js +55 -56
  39. package/clients/typescript-client.ts +94 -52
  40. package/default-architect.yaml +87 -0
  41. package/index.ts +143 -1165
  42. package/package.json +2 -1
  43. package/rules/ast-grep-rules/rules/large-class.yml +6 -2
  44. package/rules/ast-grep-rules/rules/long-method.yml +1 -1
  45. package/rules/ast-grep-rules/rules/no-single-char-var.yml +2 -2
  46. package/rules/ast-grep-rules/rules/switch-without-default.yml +0 -12
package/index.ts CHANGED
@@ -1,54 +1,39 @@
1
- /**
2
- * pi-lens - Real-time code feedback for pi
3
- *
4
- * Provides real-time diagnostics on every write/edit:
5
- * - TypeScript/JavaScript: Biome (lint+format) + TypeScript LSP (type checking)
6
- * - Python: Ruff (lint+format)
7
- * - All languages: ast-grep (63 structural rules)
8
- * - JavaScript/TypeScript: Dependency checker (circular deps)
9
- *
10
- * Proactive hints before write/edit:
11
- * - Warns when target file already has existing violations
12
- *
13
- * Auto-fix on write (enable with --autofix-ruff flag, Biome auto-fix disabled by default):
14
- * - Biome: feedback only by default, use /lens-format to apply fixes
15
- * - Ruff: applies --fix + format (lint + format fixes)
16
- *
17
- * On-demand commands:
18
- * - /lens-format - Apply Biome formatting
19
- *
20
- * External dependencies:
21
- * - npm: @biomejs/biome, @ast-grep/cli, knip, madge
22
- * - pip: ruff
23
- */
24
-
25
1
  import * as nodeFs from "node:fs";
26
2
  import * as os from "node:os";
27
3
  import * as path from "node:path";
28
4
  import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
29
5
  import { isToolCallEventType } from "@mariozechner/pi-coding-agent";
30
6
  import { Type } from "@sinclair/typebox";
7
+ import { ArchitectClient } from "./clients/architect-client.js";
31
8
  import { AstGrepClient } from "./clients/ast-grep-client.js";
32
9
  import { BiomeClient } from "./clients/biome-client.js";
33
10
  import { ComplexityClient } from "./clients/complexity-client.js";
34
- import {
35
- TypeSafetyClient,
36
- getTypeSafetyClient,
37
- } from "./clients/type-safety-client.js";
38
- import { ArchitectClient } from "./clients/architect-client.js";
39
11
  import { DependencyChecker } from "./clients/dependency-checker.js";
40
12
  import { GoClient } from "./clients/go-client.js";
13
+ import { buildInterviewer } from "./clients/interviewer.js";
41
14
  import { JscpdClient } from "./clients/jscpd-client.js";
42
15
  import { KnipClient } from "./clients/knip-client.js";
43
16
  import { MetricsClient } from "./clients/metrics-client.js";
17
+ import { captureSnapshots, getTrendSummary, formatTrendCell } from "./clients/metrics-history.js";
44
18
  import { RuffClient } from "./clients/ruff-client.js";
45
19
  import { RustClient } from "./clients/rust-client.js";
20
+ import { getSourceFiles } from "./clients/scan-utils.js";
46
21
  import { TestRunnerClient } from "./clients/test-runner-client.js";
47
22
  import { TodoScanner } from "./clients/todo-scanner.js";
48
23
  import { TypeCoverageClient } from "./clients/type-coverage-client.js";
24
+ import { TypeSafetyClient } from "./clients/type-safety-client.js";
49
25
  import { TypeScriptClient } from "./clients/typescript-client.js";
50
- import { buildInterviewer, type InterviewOption } from "./clients/interviewer.js";
51
- import { scanSkipViolations, scanComplexityMetrics, scanArchitectViolations, scoreFiles, extractCodeSnippet, type SkipIssue, type FileMetrics } from "./clients/scan-architectural-debt.js";
26
+
27
+ import { handleBooboo } from "./commands/booboo.js";
28
+ import { handleFix } from "./commands/fix.js";
29
+ import { handleRefactor, initRefactorLoop } from "./commands/refactor.js";
30
+
31
+ const _getExtensionDir = () => {
32
+ if (typeof __dirname !== "undefined") {
33
+ return __dirname;
34
+ }
35
+ return ".";
36
+ };
52
37
 
53
38
  const DEBUG_LOG = path.join(os.homedir(), "pi-lens-debug.log");
54
39
  function dbg(msg: string) {
@@ -89,6 +74,9 @@ export default function (pi: ExtensionAPI) {
89
74
  const goClient = new GoClient();
90
75
  const rustClient = new RustClient();
91
76
 
77
+ // --- Initialize auto-loops (must be early for event handlers) ---
78
+ initRefactorLoop(pi);
79
+
92
80
  // --- Flags ---
93
81
 
94
82
  pi.registerFlag("lens-verbose", {
@@ -163,503 +151,22 @@ export default function (pi: ExtensionAPI) {
163
151
  pi.registerCommand("lens-booboo", {
164
152
  description:
165
153
  "Full codebase review: design smells, complexity, AI slop detection, TODOs, dead code, duplicates, type coverage. Results saved to .pi-lens/reviews/. Usage: /lens-booboo [path]",
166
- handler: async (args, ctx) => {
167
- const targetPath = args.trim() || ctx.cwd || process.cwd();
168
- ctx.ui.notify("šŸ” Running full codebase review...", "info");
169
-
170
- const parts: string[] = [];
171
- const fullReport: string[] = [];
172
- const timestamp = new Date()
173
- .toISOString()
174
- .replace(/[:.]/g, "-")
175
- .slice(0, 19);
176
- const reviewDir = path.join(process.cwd(), ".pi-lens", "reviews");
177
-
178
- // Part 1: Design smells via ast-grep
179
- if (astGrepClient.isAvailable()) {
180
- const configPath = path.join(
181
- typeof __dirname !== "undefined" ? __dirname : ".",
182
- "rules",
183
- "ast-grep-rules",
184
- ".sgconfig.yml",
185
- );
186
-
187
- try {
188
- const result = require("node:child_process").spawnSync(
189
- "npx",
190
- [
191
- "sg",
192
- "scan",
193
- "--config",
194
- configPath,
195
- "--json",
196
- "--globs",
197
- "!**/*.test.ts",
198
- "--globs",
199
- "!**/*.spec.ts",
200
- "--globs",
201
- "!**/test-utils.ts",
202
- "--globs",
203
- "!**/.pi-lens/**",
204
- targetPath,
205
- ],
206
- {
207
- encoding: "utf-8",
208
- timeout: 30000,
209
- shell: true,
210
- maxBuffer: 32 * 1024 * 1024, // 32MB
211
- },
212
- );
213
-
214
- const output = result.stdout || result.stderr || "";
215
- if (output.trim() && result.status !== undefined) {
216
- const issues: Array<{
217
- line: number;
218
- rule: string;
219
- message: string;
220
- }> = [];
221
-
222
- // ast-grep outputs either a JSON array or NDJSON (one object per line)
223
- // biome-ignore lint/suspicious/noExplicitAny: ast-grep JSON output is untyped
224
- const parseItems = (raw: string): Record<string, any>[] => {
225
- const trimmed = raw.trim();
226
- if (trimmed.startsWith("[")) {
227
- try {
228
- return JSON.parse(trimmed);
229
- } catch (err) {
230
- void err;
231
- return [];
232
- }
233
- }
234
- return raw.split("\n").flatMap((l: string) => {
235
- try {
236
- return [JSON.parse(l)];
237
- } catch (err) {
238
- void err;
239
- return [];
240
- }
241
- });
242
- };
243
-
244
- for (const item of parseItems(output)) {
245
- const ruleId =
246
- item.ruleId || item.rule?.title || item.name || "unknown";
247
- const ruleDesc = astGrepClient.getRuleDescription?.(ruleId);
248
- const message = ruleDesc?.message || item.message || ruleId;
249
- const lineNum =
250
- item.labels?.[0]?.range?.start?.line ||
251
- item.spans?.[0]?.range?.start?.line ||
252
- item.range?.start?.line ||
253
- 0;
254
-
255
- issues.push({
256
- line: lineNum + 1,
257
- rule: ruleId,
258
- message: message,
259
- });
260
- }
261
-
262
- if (issues.length > 0) {
263
- // UI summary (truncated)
264
- let report = `[ast-grep] ${issues.length} issue(s) found:\n`;
265
- for (const issue of issues.slice(0, 20)) {
266
- report += ` L${issue.line}: ${issue.rule} — ${issue.message}\n`;
267
- }
268
- if (issues.length > 20) {
269
- report += ` ... and ${issues.length - 20} more\n`;
270
- }
271
- parts.push(report);
272
-
273
- // Full report for file
274
- let fullSection = `## ast-grep (Structural Issues)\n\n**${issues.length} issue(s) found**\n\n`;
275
- fullSection +=
276
- "| Line | Rule | Message |\n|------|------|--------|\n";
277
- for (const issue of issues) {
278
- fullSection += `| ${issue.line} | ${issue.rule} | ${issue.message} |\n`;
279
- }
280
- fullReport.push(fullSection);
281
- }
282
- }
283
- } catch (_err: any) {
284
- dbg(`ast-grep scan failed: ${_err.message}`);
285
- }
286
- }
287
-
288
- // Part 2: Similar functions (advanced duplicate detection)
289
- if (astGrepClient.isAvailable()) {
290
- const similarGroups = await astGrepClient.findSimilarFunctions(
291
- targetPath,
292
- "typescript",
293
- );
294
- if (similarGroups.length > 0) {
295
- // UI summary (truncated)
296
- let report = `[Similar Functions] ${similarGroups.length} group(s) of structurally similar functions:\n`;
297
- for (const group of similarGroups.slice(0, 5)) {
298
- report += ` Pattern: ${group.functions.map((f) => f.name).join(", ")}\n`;
299
- for (const fn of group.functions) {
300
- report += ` ${fn.name} (${path.basename(fn.file)}:${fn.line})\n`;
301
- }
302
- }
303
- if (similarGroups.length > 5) {
304
- report += ` ... and ${similarGroups.length - 5} more groups\n`;
305
- }
306
- parts.push(report);
307
-
308
- // Full report for file
309
- let fullSection = `## Similar Functions\n\n**${similarGroups.length} group(s) of structurally similar functions**\n\n`;
310
- for (const group of similarGroups) {
311
- fullSection += `### Pattern: ${group.functions.map((f) => f.name).join(", ")}\n\n`;
312
- fullSection +=
313
- "| Function | File | Line |\n|----------|------|------|\n";
314
- for (const fn of group.functions) {
315
- fullSection += `| ${fn.name} | ${fn.file} | ${fn.line} |\n`;
316
- }
317
- fullSection += "\n";
318
- }
319
- fullReport.push(fullSection);
320
- }
321
- }
322
-
323
- // Part 3: Complexity metrics + AI slop detection
324
- const results: import("./clients/complexity-client.js").FileComplexity[] =
325
- [];
326
- const aiSlopIssues: string[] = [];
327
-
328
- const scanDir = (dir: string) => {
329
- let entries: nodeFs.Dirent[];
330
- try {
331
- entries = nodeFs.readdirSync(dir, { withFileTypes: true });
332
- } catch {
333
- return;
334
- }
335
-
336
- for (const entry of entries) {
337
- const fullPath = path.join(dir, entry.name);
338
- if (
339
- entry.isDirectory() &&
340
- ![
341
- "node_modules",
342
- ".git",
343
- "dist",
344
- "build",
345
- ".next",
346
- ".pi-lens",
347
- ].includes(entry.name)
348
- ) {
349
- scanDir(fullPath);
350
- } else if (complexityClient.isSupportedFile(fullPath)) {
351
- const metrics = complexityClient.analyzeFile(fullPath);
352
- if (metrics) {
353
- results.push(metrics);
354
-
355
- // Check AI slop indicators — skip test files (low MI is expected/structural)
356
- if (!/\.(test|spec)\.[jt]sx?$/.test(entry.name)) {
357
- const warnings = complexityClient.checkThresholds(metrics);
358
- if (warnings.length > 0) {
359
- aiSlopIssues.push(` ${metrics.filePath}:`);
360
- for (const w of warnings) {
361
- aiSlopIssues.push(` ⚠ ${w}`);
362
- }
363
- }
364
- }
365
- }
366
- }
367
- }
368
- };
369
-
370
- scanDir(targetPath);
371
-
372
- if (results.length > 0) {
373
- const avgMI =
374
- results.reduce((a, b) => a + b.maintainabilityIndex, 0) /
375
- results.length;
376
- const avgCognitive =
377
- results.reduce((a, b) => a + b.cognitiveComplexity, 0) /
378
- results.length;
379
- const avgCyclomatic =
380
- results.reduce((a, b) => a + b.cyclomaticComplexity, 0) /
381
- results.length;
382
- const maxNesting = Math.max(...results.map((r) => r.maxNestingDepth));
383
- const maxCognitive = Math.max(
384
- ...results.map((r) => r.cognitiveComplexity),
385
- );
386
- const minMI = Math.min(...results.map((r) => r.maintainabilityIndex));
387
-
388
- const lowMI = results
389
- .filter((r) => r.maintainabilityIndex < 60)
390
- .sort((a, b) => a.maintainabilityIndex - b.maintainabilityIndex);
391
- const highCognitive = results
392
- .filter((r) => r.cognitiveComplexity > 20)
393
- .sort((a, b) => b.cognitiveComplexity - a.cognitiveComplexity);
394
-
395
- // UI summary (truncated)
396
- let summary = `[Complexity] ${results.length} file(s) scanned\n`;
397
- summary += ` Maintainability: ${avgMI.toFixed(1)} avg | Cognitive: ${avgCognitive.toFixed(1)} avg | Max Nesting: ${maxNesting} levels\n`;
398
-
399
- if (lowMI.length > 0) {
400
- summary += `\n Low Maintainability (MI < 60):\n`;
401
- for (const f of lowMI.slice(0, 5)) {
402
- summary += ` āœ— ${f.filePath}: MI ${f.maintainabilityIndex.toFixed(1)}\n`;
403
- }
404
- if (lowMI.length > 5)
405
- summary += ` ... and ${lowMI.length - 5} more\n`;
406
- }
407
-
408
- if (highCognitive.length > 0) {
409
- summary += `\n High Cognitive Complexity (> 20):\n`;
410
- for (const f of highCognitive.slice(0, 5)) {
411
- summary += ` ⚠ ${f.filePath}: ${f.cognitiveComplexity}\n`;
412
- }
413
- if (highCognitive.length > 5)
414
- summary += ` ... and ${highCognitive.length - 5} more\n`;
415
- }
416
-
417
- // Add AI slop issues
418
- if (aiSlopIssues.length > 0) {
419
- summary += `\n[AI Slop Indicators]\n${aiSlopIssues.join("\n")}`;
420
- }
421
-
422
- parts.push(summary);
423
-
424
- // Full report for file
425
- let fullSection = `## Complexity Metrics\n\n**${results.length} file(s) scanned**\n\n`;
426
- fullSection += `### Summary\n\n`;
427
- fullSection += `| Metric | Value |\n|--------|-------|\n`;
428
- fullSection += `| Avg Maintainability Index | ${avgMI.toFixed(1)} |\n`;
429
- fullSection += `| Min Maintainability Index | ${minMI.toFixed(1)} |\n`;
430
- fullSection += `| Avg Cognitive Complexity | ${avgCognitive.toFixed(1)} |\n`;
431
- fullSection += `| Max Cognitive Complexity | ${maxCognitive} |\n`;
432
- fullSection += `| Avg Cyclomatic Complexity | ${avgCyclomatic.toFixed(1)} |\n`;
433
- fullSection += `| Max Nesting Depth | ${maxNesting} |\n`;
434
- fullSection += `| Total Files | ${results.length} |\n\n`;
435
-
436
- if (lowMI.length > 0) {
437
- fullSection += `### Low Maintainability (MI < 60)\n\n`;
438
- fullSection += `| File | MI | Cognitive | Cyclomatic | Nesting |\n`;
439
- fullSection += `|------|-----|-----------|------------|--------|\n`;
440
- for (const f of lowMI) {
441
- fullSection += `| ${f.filePath} | ${f.maintainabilityIndex.toFixed(1)} | ${f.cognitiveComplexity} | ${f.cyclomaticComplexity} | ${f.maxNestingDepth} |\n`;
442
- }
443
- fullSection += "\n";
444
- }
445
-
446
- if (highCognitive.length > 0) {
447
- fullSection += `### High Cognitive Complexity (> 20)\n\n`;
448
- fullSection += `| File | Cognitive | MI | Cyclomatic | Nesting |\n`;
449
- fullSection += `|------|-----------|-----|------------|--------|\n`;
450
- for (const f of highCognitive) {
451
- fullSection += `| ${f.filePath} | ${f.cognitiveComplexity} | ${f.maintainabilityIndex.toFixed(1)} | ${f.cyclomaticComplexity} | ${f.maxNestingDepth} |\n`;
452
- }
453
- fullSection += "\n";
454
- }
455
-
456
- // All files table
457
- fullSection += `### All Files\n\n`;
458
- fullSection += `| File | MI | Cognitive | Cyclomatic | Nesting | Entropy |\n`;
459
- fullSection += `|------|-----|-----------|------------|---------|--------|\n`;
460
- for (const f of results.sort(
461
- (a, b) => a.maintainabilityIndex - b.maintainabilityIndex,
462
- )) {
463
- fullSection += `| ${f.filePath} | ${f.maintainabilityIndex.toFixed(1)} | ${f.cognitiveComplexity} | ${f.cyclomaticComplexity} | ${f.maxNestingDepth} | ${f.codeEntropy.toFixed(2)} |\n`;
464
- }
465
- fullSection += "\n";
466
-
467
- // AI slop indicators
468
- if (aiSlopIssues.length > 0) {
469
- fullSection += `### AI Slop Indicators\n\n`;
470
- for (const issue of aiSlopIssues) {
471
- fullSection += `${issue}\n`;
472
- }
473
- fullSection += "\n";
474
- }
475
-
476
- fullReport.push(fullSection);
477
- }
478
-
479
- // Part 4: TODOs scan
480
- const todoResult = todoScanner.scanDirectory(targetPath);
481
- const todoReport = todoScanner.formatResult(todoResult);
482
- if (todoReport) {
483
- parts.push(todoReport);
484
- // Full TODO report
485
- let fullSection = `## TODOs / Annotations\n\n`;
486
- if (todoResult.items.length > 0) {
487
- fullSection += `**${todoResult.items.length} annotation(s) found**\n\n`;
488
- fullSection += `| Type | File | Line | Text |\n`;
489
- fullSection += `|------|------|------|------|\n`;
490
- for (const item of todoResult.items) {
491
- fullSection += `| ${item.type} | ${item.file} | ${item.line} | ${item.message} |\n`;
492
- }
493
- } else {
494
- fullSection += `No annotations found.\n`;
495
- }
496
- fullSection += "\n";
497
- fullReport.push(fullSection);
498
- }
499
-
500
- // Part 5: Dead code (knip)
501
- if (knipClient.isAvailable()) {
502
- const knipResult = knipClient.analyze(targetPath);
503
- const knipReport = knipClient.formatResult(knipResult);
504
- if (knipReport) {
505
- parts.push(knipReport);
506
- // Full knip report
507
- let fullSection = `## Dead Code (Knip)\n\n`;
508
- if (knipResult.issues.length > 0) {
509
- fullSection += `**${knipResult.issues.length} issue(s) found**\n\n`;
510
- fullSection += `| Type | Name | File |\n`;
511
- fullSection += `|------|------|------|\n`;
512
- for (const issue of knipResult.issues) {
513
- fullSection += `| ${issue.type} | ${issue.name} | ${issue.file ?? ""} |\n`;
514
- }
515
- } else {
516
- fullSection += `No dead code issues found.\n`;
517
- }
518
- fullSection += "\n";
519
- fullReport.push(fullSection);
520
- }
521
- }
522
-
523
- // Part 6: Code duplication
524
- if (jscpdClient.isAvailable()) {
525
- const jscpdResult = jscpdClient.scan(targetPath);
526
- const jscpdReport = jscpdClient.formatResult(jscpdResult);
527
- if (jscpdReport) {
528
- parts.push(jscpdReport);
529
- // Full jscpd report
530
- let fullSection = `## Code Duplication (jscpd)\n\n`;
531
- if (jscpdResult.clones.length > 0) {
532
- fullSection += `**${jscpdResult.clones.length} duplicate block(s) found** (${jscpdResult.duplicatedLines}/${jscpdResult.totalLines} lines, ${jscpdResult.percentage.toFixed(1)}%)\n\n`;
533
- fullSection += `| File A | Line A | File B | Line B | Lines | Tokens |\n`;
534
- fullSection += `|--------|--------|--------|--------|-------|--------|\n`;
535
- for (const dup of jscpdResult.clones) {
536
- fullSection += `| ${dup.fileA} | ${dup.startA} | ${dup.fileB} | ${dup.startB} | ${dup.lines} | ${dup.tokens} |\n`;
537
- }
538
- } else {
539
- fullSection += `No duplicate code found.\n`;
540
- }
541
- fullSection += "\n";
542
- fullReport.push(fullSection);
543
- }
544
- }
545
-
546
- // Part 7: Type coverage
547
- if (typeCoverageClient.isAvailable()) {
548
- const tcResult = typeCoverageClient.scan(targetPath);
549
- const tcReport = typeCoverageClient.formatResult(tcResult);
550
- if (tcReport) {
551
- parts.push(tcReport);
552
- // Full type coverage report
553
- let fullSection = `## Type Coverage\n\n`;
554
- fullSection += `**${tcResult.percentage.toFixed(1)}% typed** (${tcResult.typed}/${tcResult.total} identifiers)\n\n`;
555
- if (tcResult.untypedLocations.length > 0) {
556
- fullSection += `### Untyped Identifiers\n\n`;
557
- fullSection += `| File | Line | Column | Name |\n`;
558
- fullSection += `|------|------|--------|------|\n`;
559
- for (const u of tcResult.untypedLocations) {
560
- fullSection += `| ${u.file} | ${u.line} | ${u.column} | ${u.name} |\n`;
561
- }
562
- }
563
- fullSection += "\n";
564
- fullReport.push(fullSection);
565
- }
566
- }
567
-
568
- // Part 8: Circular dependencies
569
- if (!pi.getFlag("no-madge") && depChecker.isAvailable()) {
570
- const { circular } = depChecker.scanProject(targetPath);
571
- const depReport = depChecker.formatScanResult(circular);
572
- if (depReport) {
573
- parts.push(depReport);
574
- let fullSection = `## Circular Dependencies (Madge)\n\n`;
575
- fullSection += `**${circular.length} circular chain(s) found**\n\n`;
576
- for (const dep of circular) {
577
- fullSection += `- ${dep.path.join(" → ")}\n`;
578
- }
579
- fullSection += "\n";
580
- fullReport.push(fullSection);
581
- }
582
- }
583
-
584
- // Part 9: Architectural Rules
585
- // Reload config in case it was added after session start
586
- if (!architectClient.hasConfig()) {
587
- architectClient.loadConfig(projectRoot);
588
- }
589
- if (architectClient.hasConfig()) {
590
- const archViolations: Array<{ file: string; message: string }> = [];
591
- const archScanDir = (dir: string) => {
592
- for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
593
- const full = path.join(dir, entry.name);
594
- if (entry.isDirectory()) {
595
- if (
596
- ["node_modules", ".git", "dist", "build", ".next", ".pi-lens"].includes(
597
- entry.name,
598
- )
599
- )
600
- continue;
601
- archScanDir(full);
602
- } else if (/\.(ts|tsx|js|jsx|py|go|rs)$/.test(entry.name)) {
603
- const relPath = path.relative(targetPath, full).replace(/\\/g, "/");
604
- const content = fs.readFileSync(full, "utf-8");
605
- const lineCount = content.split("\n").length;
606
-
607
- // Check pattern violations
608
- const violations = architectClient.checkFile(relPath, content);
609
- for (const v of violations) {
610
- archViolations.push({ file: relPath, message: v.message });
611
- }
612
-
613
- // Check file size
614
- const sizeV = architectClient.checkFileSize(relPath, lineCount);
615
- if (sizeV) {
616
- archViolations.push({ file: relPath, message: sizeV.message });
617
- }
618
- }
619
- }
620
- };
621
- archScanDir(targetPath);
622
-
623
- if (archViolations.length > 0) {
624
- parts.push(
625
- `šŸ”“ ${archViolations.length} architectural violation(s) — fix before adding new code`,
626
- );
627
- let fullSection = `## Architectural Rules\n\n`;
628
- fullSection += `**${archViolations.length} violation(s) found**\n\n`;
629
- for (const v of archViolations) {
630
- fullSection += `- **${v.file}**: ${v.message}\n`;
631
- }
632
- fullSection += "\n";
633
- fullReport.push(fullSection);
634
- }
635
- }
636
-
637
- // Build and save full markdown report
638
- const fs = require("node:fs");
639
- fs.mkdirSync(reviewDir, { recursive: true });
640
-
641
- const projectName = path.basename(process.cwd());
642
- let mdReport = `# Code Review: ${projectName}\n\n`;
643
- mdReport += `**Scanned:** ${new Date().toISOString()}\n\n`;
644
- mdReport += `**Path:** \`${targetPath}\`\n\n`;
645
- mdReport += `---\n\n`;
646
- mdReport += fullReport.join("\n");
647
-
648
- const reportPath = path.join(reviewDir, `booboo-${timestamp}.md`);
649
- fs.writeFileSync(reportPath, mdReport, "utf-8");
650
-
651
- if (parts.length === 0) {
652
- ctx.ui.notify(
653
- "āœ“ Code review clean — saved to .pi-lens/reviews/",
654
- "info",
655
- );
656
- } else {
657
- ctx.ui.notify(
658
- `${parts.join("\n\n")}\n\nšŸ“„ Full report: ${reportPath}`,
659
- "info",
660
- );
661
- }
662
- },
154
+ handler: (args, ctx) =>
155
+ handleBooboo(
156
+ args,
157
+ ctx,
158
+ {
159
+ astGrep: astGrepClient,
160
+ complexity: complexityClient,
161
+ todo: todoScanner,
162
+ knip: knipClient,
163
+ jscpd: jscpdClient,
164
+ typeCoverage: typeCoverageClient,
165
+ depChecker: depChecker,
166
+ architect: architectClient,
167
+ },
168
+ pi,
169
+ ),
663
170
  });
664
171
 
665
172
  // --- Rule action map for lens-booboo-fix ---
@@ -754,569 +261,41 @@ export default function (pi: ExtensionAPI) {
754
261
 
755
262
  pi.registerCommand("lens-booboo-fix", {
756
263
  description:
757
- "Iterative fix loop: auto-fixes Biome/Ruff, then generates a per-issue plan for agent to execute. Run repeatedly until clean. Usage: /lens-booboo-fix [path]",
758
- handler: async (args, ctx) => {
759
- const targetPath = args.trim() || ctx.cwd || process.cwd();
760
- const fs = require("node:fs") as typeof import("node:fs");
761
- const sessionFile = path.join(
762
- process.cwd(),
763
- ".pi-lens",
764
- "fix-session.json",
765
- );
766
- const configPath = path.join(
767
- typeof __dirname !== "undefined" ? __dirname : ".",
768
- "rules",
769
- "ast-grep-rules",
770
- ".sgconfig.yml",
771
- );
772
-
773
- ctx.ui.notify("šŸ”§ Running booboo fix loop...", "info");
774
-
775
- const MAX_ITERATIONS = 3;
776
-
777
- // Detect TypeScript project — exclude compiled .js output from scans
778
- const isTsProject = fs.existsSync(path.join(targetPath, "tsconfig.json"));
779
- dbg(`booboo-fix: isTsProject=${isTsProject}`);
780
-
781
- // Load session state
782
- let session: { iteration: number; counts: Record<string, number> } = {
783
- iteration: 0,
784
- counts: {},
785
- };
786
- try {
787
- session = JSON.parse(fs.readFileSync(sessionFile, "utf-8"));
788
- } catch (e) {
789
- dbg(`fix-session load failed: ${e}`);
790
- }
791
- session.iteration++;
792
-
793
- // Hard stop at max iterations — auto-reset for next run
794
- if (session.iteration > MAX_ITERATIONS) {
795
- try {
796
- fs.unlinkSync(sessionFile);
797
- } catch {
798
- void 0;
799
- }
800
- ctx.ui.notify(
801
- `ā›” Max iterations (${MAX_ITERATIONS}) reached. Session reset — run /lens-booboo-fix again for a fresh loop, or /lens-booboo for a full report.`,
802
- "warning",
803
- );
804
- return;
805
- }
806
- const prevCounts = { ...session.counts };
807
-
808
- // --- Step 1: Auto-fix with Biome + Ruff ---
809
- let biomeRan = false;
810
- if (!pi.getFlag("no-biome") && biomeClient.isAvailable()) {
811
- require("node:child_process").spawnSync(
812
- "npx",
813
- ["@biomejs/biome", "check", "--write", "--unsafe", targetPath],
814
- { encoding: "utf-8", timeout: 30000, shell: true },
815
- );
816
- biomeRan = true;
817
- }
818
- let ruffRan = false;
819
- if (!pi.getFlag("no-ruff") && ruffClient.isAvailable()) {
820
- require("node:child_process").spawnSync(
821
- "ruff",
822
- ["check", "--fix", targetPath],
823
- { encoding: "utf-8", timeout: 15000, shell: true },
824
- );
825
- require("node:child_process").spawnSync(
826
- "ruff",
827
- ["format", targetPath],
828
- { encoding: "utf-8", timeout: 15000, shell: true },
829
- );
830
- ruffRan = true;
831
- }
832
-
833
- // --- Step 2: Duplicate code (jscpd) ---
834
- type JscpdClone = {
835
- fileA: string;
836
- fileB: string;
837
- startA: number;
838
- startB: number;
839
- lines: number;
840
- };
841
- const dupClones: JscpdClone[] = [];
842
- if (jscpdClient.isAvailable()) {
843
- const jscpdResult = jscpdClient.scan(targetPath);
844
- // Only within-file duplicates are mechanically fixable
845
- const clones = jscpdResult.clones.filter((c) => {
846
- if (isTsProject && (c.fileA.endsWith(".js") || c.fileB.endsWith(".js"))) return false;
847
- return path.resolve(c.fileA) === path.resolve(c.fileB);
848
- });
849
- dupClones.push(...clones);
850
- dbg(
851
- `booboo-fix jscpd: ${dupClones.length} within-file clone(s) from ${jscpdResult.clones.length} total`,
852
- );
853
- }
854
-
855
- // --- Step 3: Dead code (knip) ---
856
- type KnipIssue = { type: string; name: string; file?: string };
857
- const deadCodeIssues: KnipIssue[] = [];
858
- if (knipClient.isAvailable()) {
859
- const knipResult = knipClient.analyze(targetPath);
860
- deadCodeIssues.push(...knipResult.issues);
861
- dbg(`booboo-fix knip: ${deadCodeIssues.length} issue(s)`);
862
- }
863
-
864
- // --- Step 4: ast-grep scan (on surviving code) ---
865
- type AstIssue = {
866
- rule: string;
867
- file: string;
868
- line: number;
869
- message: string;
870
- };
871
- const astIssues: AstIssue[] = [];
872
- if (astGrepClient.isAvailable()) {
873
- const result = require("node:child_process").spawnSync(
874
- "npx",
875
- [
876
- "sg",
877
- "scan",
878
- "--config",
879
- configPath,
880
- "--json",
881
- "--globs",
882
- "!**/*.test.ts",
883
- "--globs",
884
- "!**/*.spec.ts",
885
- "--globs",
886
- "!**/test-utils.ts",
887
- "--globs",
888
- "!**/.pi-lens/**",
889
- ...(isTsProject ? ["--globs", "!**/*.js"] : []),
890
- targetPath,
891
- ],
892
- {
893
- encoding: "utf-8",
894
- timeout: 30000,
895
- shell: true,
896
- maxBuffer: 32 * 1024 * 1024,
897
- },
898
- );
899
-
900
- const raw = result.stdout?.trim() ?? "";
901
- // biome-ignore lint/suspicious/noExplicitAny: ast-grep JSON output is untyped
902
- const items: Record<string, any>[] = raw.startsWith("[")
903
- ? (() => {
904
- try {
905
- return JSON.parse(raw);
906
- } catch (e) {
907
- dbg(`ast-grep parse failed: ${e}`);
908
- return [];
909
- }
910
- })()
911
- : raw.split("\n").flatMap((l: string) => {
912
- try {
913
- return [JSON.parse(l)];
914
- } catch (err) {
915
- void err;
916
- return [];
917
- }
918
- });
919
-
920
- for (const item of items) {
921
- const rule =
922
- item.ruleId || item.rule?.title || item.name || "unknown";
923
- const line =
924
- (item.labels?.[0]?.range?.start?.line ??
925
- item.range?.start?.line ??
926
- 0) + 1;
927
- const relFile = path
928
- .relative(targetPath, item.file ?? "")
929
- .replace(/\\/g, "/");
930
- astIssues.push({
931
- rule,
932
- file: relFile,
933
- line,
934
- message: item.message ?? rule,
935
- });
936
- }
937
- }
938
-
939
- // --- Step 5: AI slop from complexity scan ---
940
- const slopFiles: Array<{ file: string; warnings: string[] }> = [];
941
- const slopScanDir = (dir: string) => {
942
- if (!fs.existsSync(dir)) return;
943
- for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
944
- const fullPath = path.join(dir, entry.name);
945
- if (entry.isDirectory()) {
946
- if (
947
- [
948
- "node_modules",
949
- ".git",
950
- "dist",
951
- "build",
952
- ".next",
953
- ".pi-lens",
954
- ].includes(entry.name)
955
- )
956
- continue;
957
- slopScanDir(fullPath);
958
- } else if (
959
- complexityClient.isSupportedFile(fullPath) &&
960
- !/\.(test|spec)\.[jt]sx?$/.test(entry.name) &&
961
- !(isTsProject && /\.js$/.test(entry.name))
962
- ) {
963
- const metrics = complexityClient.analyzeFile(fullPath);
964
- if (metrics) {
965
- const warnings = complexityClient
966
- .checkThresholds(metrics)
967
- .filter(
968
- (w) =>
969
- w.includes("AI-style") ||
970
- w.includes("try/catch") ||
971
- w.includes("single-use") ||
972
- w.includes("Excessive comments"),
973
- );
974
- // Only flag files with 2+ signals — single-issue flags are noise
975
- if (warnings.length >= 2) {
976
- slopFiles.push({
977
- file: path.relative(targetPath, fullPath).replace(/\\/g, "/"),
978
- warnings,
979
- });
980
- }
981
- }
982
- }
983
- }
984
- };
985
- slopScanDir(targetPath);
986
-
987
- // --- Step 6: Remaining Biome lint (unfixable by --unsafe) ---
988
- const remainingBiome: Array<{
989
- file: string;
990
- line: number;
991
- rule: string;
992
- message: string;
993
- }> = [];
994
- if (!pi.getFlag("no-biome") && biomeClient.isAvailable()) {
995
- const checkResult = require("node:child_process").spawnSync(
996
- "npx",
997
- [
998
- "@biomejs/biome",
999
- "check",
1000
- "--reporter=json",
1001
- "--max-diagnostics=50",
1002
- targetPath,
1003
- ],
1004
- { encoding: "utf-8", timeout: 20000, shell: true },
1005
- );
1006
- try {
1007
- const data = JSON.parse(checkResult.stdout ?? "{}");
1008
- for (const diag of (data.diagnostics ?? []).slice(0, 20)) {
1009
- if (!diag.category?.startsWith("lint/")) continue;
1010
- const filePath = diag.location?.path?.file ?? "";
1011
- const line = diag.location?.span?.start?.line ?? 0;
1012
- const rule = diag.category ?? "lint";
1013
- remainingBiome.push({
1014
- file: path.relative(targetPath, filePath).replace(/\\/g, "/"),
1015
- line: line + 1,
1016
- rule,
1017
- message: diag.message ?? rule,
1018
- });
1019
- }
1020
- } catch (e) {
1021
- dbg(`biome lint parse failed: ${e}`);
1022
- }
1023
- }
1024
-
1025
- // --- Categorize ast-grep issues ---
1026
- const agentTasks: AstIssue[] = [];
1027
- const skipRules = new Map<string, { note: string; count: number }>();
1028
-
1029
- const byRule = new Map<string, AstIssue[]>();
1030
- for (const issue of astIssues) {
1031
- const list = byRule.get(issue.rule) ?? [];
1032
- list.push(issue);
1033
- byRule.set(issue.rule, list);
1034
- }
1035
- for (const [rule, issues] of byRule) {
1036
- const action = RULE_ACTIONS[rule];
1037
- if (!action || action.type === "agent") {
1038
- agentTasks.push(...issues);
1039
- } else if (action.type === "skip") {
1040
- skipRules.set(rule, { note: action.note, count: issues.length });
1041
- }
1042
- }
1043
-
1044
- // --- Update session counts ---
1045
- const currentCounts = {
1046
- duplicates: dupClones.length,
1047
- dead_code: deadCodeIssues.length,
1048
- agent_ast: agentTasks.length,
1049
- biome_lint: remainingBiome.length,
1050
- slop_files: slopFiles.length,
1051
- };
1052
- session.counts = currentCounts;
1053
- fs.mkdirSync(path.dirname(sessionFile), { recursive: true });
1054
- fs.writeFileSync(sessionFile, JSON.stringify(session, null, 2), "utf-8");
1055
-
1056
- // --- Check if done ---
1057
- const totalFixable =
1058
- dupClones.length +
1059
- deadCodeIssues.length +
1060
- agentTasks.length +
1061
- remainingBiome.length +
1062
- slopFiles.length;
1063
- if (totalFixable === 0) {
1064
- const msg = `āœ… BOOBOO FIX LOOP COMPLETE — No more fixable issues found after ${session.iteration} iteration(s).\n\nRemaining skipped items are architectural — see /lens-booboo for full report.`;
1065
- ctx.ui.notify(msg, "info");
1066
- try {
1067
- fs.unlinkSync(sessionFile);
1068
- } catch {
1069
- void 0;
1070
- }
1071
- return;
1072
- }
1073
-
1074
- // --- Build delta line ---
1075
- let deltaLine = "";
1076
- if (session.iteration > 1 && Object.keys(prevCounts).length > 0) {
1077
- const prevTotal = Object.values(prevCounts).reduce((a, b) => a + b, 0);
1078
- const fixed = prevTotal - totalFixable;
1079
- deltaLine =
1080
- fixed > 0
1081
- ? `āœ… Fixed ${fixed} issues since last iteration.`
1082
- : `āš ļø No change since last iteration — check if fixes were applied.`;
1083
- }
1084
-
1085
- // --- Build the fix plan message ---
1086
- const lines: string[] = [];
1087
- lines.push(
1088
- `šŸ“‹ BOOBOO FIX PLAN — Iteration ${session.iteration}/${MAX_ITERATIONS} (${totalFixable} fixable items remaining)`,
1089
- );
1090
- if (deltaLine) lines.push(deltaLine);
1091
- lines.push("");
1092
-
1093
- if (biomeRan || ruffRan) {
1094
- lines.push(
1095
- `⚔ Auto-fixed: ${[biomeRan && "Biome --write --unsafe", ruffRan && "Ruff --fix + format"].filter(Boolean).join(", ")} already ran.`,
1096
- );
1097
- lines.push("");
1098
- }
1099
-
1100
- // Duplicate code — fix first
1101
- if (dupClones.length > 0) {
1102
- lines.push(
1103
- `## šŸ” Duplicate code [${dupClones.length} block(s)] — fix first`,
1104
- );
1105
- lines.push(
1106
- "→ Extract duplicated blocks into shared utilities before fixing violations in them.",
1107
- );
1108
- for (const clone of dupClones.slice(0, 10)) {
1109
- const relA = path
1110
- .relative(targetPath, clone.fileA)
1111
- .replace(/\\/g, "/");
1112
- const relB = path
1113
- .relative(targetPath, clone.fileB)
1114
- .replace(/\\/g, "/");
1115
- lines.push(
1116
- ` - ${clone.lines} lines: \`${relA}:${clone.startA}\` ↔ \`${relB}:${clone.startB}\``,
1117
- );
1118
- }
1119
- if (dupClones.length > 10)
1120
- lines.push(` ... and ${dupClones.length - 10} more`);
1121
- lines.push("");
1122
- }
1123
-
1124
- // Dead code — delete before fixing violations in it
1125
- if (deadCodeIssues.length > 0) {
1126
- lines.push(
1127
- `## šŸ—‘ļø Dead code [${deadCodeIssues.length} item(s)] — delete before fixing violations`,
1128
- );
1129
- lines.push(
1130
- "→ Remove unused exports/files — no point fixing violations in code you're about to delete.",
1131
- );
1132
- for (const issue of deadCodeIssues.slice(0, 10)) {
1133
- lines.push(
1134
- ` - [${issue.type}] \`${issue.name}\`${issue.file ? ` in ${issue.file}` : ""}`,
1135
- );
1136
- }
1137
- if (deadCodeIssues.length > 10)
1138
- lines.push(` ... and ${deadCodeIssues.length - 10} more`);
1139
- lines.push("");
1140
- }
1141
-
1142
- // Agent tasks — ast-grep violations on surviving code
1143
- if (agentTasks.length > 0) {
1144
- lines.push(`## šŸ”Ø Fix these [${agentTasks.length} items]`);
1145
- lines.push("");
1146
- const groupedAgent = new Map<string, AstIssue[]>();
1147
- for (const t of agentTasks) {
1148
- const g = groupedAgent.get(t.rule) ?? [];
1149
- g.push(t);
1150
- groupedAgent.set(t.rule, g);
1151
- }
1152
- for (const [rule, issues] of groupedAgent) {
1153
- const action = RULE_ACTIONS[rule];
1154
- const note = action?.note ?? "Fix this violation";
1155
- lines.push(`### ${rule} (${issues.length})`);
1156
- lines.push(`→ ${note}`);
1157
- for (const issue of issues.slice(0, 15)) {
1158
- lines.push(` - \`${issue.file}:${issue.line}\``);
1159
- }
1160
- if (issues.length > 15)
1161
- lines.push(` ... and ${issues.length - 15} more`);
1162
- lines.push("");
1163
- }
1164
- }
1165
-
1166
- // Remaining Biome lint — couldn't be auto-fixed even with --unsafe
1167
- if (remainingBiome.length > 0) {
1168
- lines.push(
1169
- `## 🟠 Remaining Biome lint [${remainingBiome.length} items]`,
1170
- );
1171
- lines.push(
1172
- "→ These couldn't be auto-fixed by Biome --unsafe. Fix each one manually:",
1173
- );
1174
- for (const d of remainingBiome.slice(0, 10)) {
1175
- lines.push(` - \`${d.file}:${d.line}\` [${d.rule}] ${d.message}`);
1176
- }
1177
- if (remainingBiome.length > 10)
1178
- lines.push(` ... and ${remainingBiome.length - 10} more`);
1179
- lines.push("");
1180
- }
1181
-
1182
- // AI slop
1183
- if (slopFiles.length > 0) {
1184
- lines.push(`## šŸ¤– AI Slop indicators [${slopFiles.length} files]`);
1185
- for (const { file, warnings } of slopFiles.slice(0, 10)) {
1186
- lines.push(
1187
- ` - \`${file}\`: ${warnings.map((w) => w.split(" — ")[0]).join(", ")}`,
1188
- );
1189
- }
1190
- if (slopFiles.length > 10)
1191
- lines.push(` ... and ${slopFiles.length - 10} more`);
1192
- lines.push("");
1193
- }
1194
-
1195
- // Skips
1196
- if (skipRules.size > 0) {
1197
- lines.push(
1198
- `## ā­ļø Skip [${[...skipRules.values()].reduce((a, b) => a + b.count, 0)} items — architectural]`,
1199
- );
1200
- for (const [rule, { note, count }] of skipRules) {
1201
- lines.push(` - **${rule}** (${count}): ${note}`);
1202
- }
1203
- lines.push("");
1204
- }
1205
-
1206
- lines.push("---");
1207
- lines.push(
1208
- "Fix the items above in order, then run `/lens-booboo-fix` again for the next iteration.",
1209
- );
1210
- lines.push(
1211
- "If an item is not safe to fix, skip it with one sentence why.",
1212
- );
1213
-
1214
- const fixPlan = lines.join("\n");
1215
-
1216
- // Save plan for reference
1217
- const planPath = path.join(process.cwd(), ".pi-lens", "fix-plan.md");
1218
- fs.writeFileSync(
1219
- planPath,
1220
- `# Fix Plan — Iteration ${session.iteration}\n\n${fixPlan}`,
1221
- "utf-8",
1222
- );
1223
-
1224
- // Notify and inject into conversation
1225
- ctx.ui.notify(`šŸ“„ Fix plan saved: ${planPath}`, "info");
1226
- // Use steer delivery — agent is busy processing this tool call
1227
- // steer interrupts mid-processing with the next fix plan
1228
- pi.sendUserMessage(fixPlan, { deliverAs: "steer" });
1229
- },
264
+ "Iterative fix loop: auto-fixes Biome/Ruff, then generates a per-issue plan for agent to execute. Run repeatedly until clean. Usage: /lens-booboo-fix [path] [--reset]",
265
+ handler: (args, ctx) =>
266
+ handleFix(
267
+ args,
268
+ ctx,
269
+ {
270
+ tsClient,
271
+ astGrep: astGrepClient,
272
+ ruff: ruffClient,
273
+ biome: biomeClient,
274
+ knip: knipClient,
275
+ jscpd: jscpdClient,
276
+ complexity: complexityClient,
277
+ },
278
+ pi,
279
+ RULE_ACTIONS,
280
+ ),
1230
281
  });
1231
282
 
1232
283
  pi.registerCommand("lens-booboo-refactor", {
1233
284
  description:
1234
285
  "Interactive architectural refactor: scans for worst offender, opens a browser interview with options + recommendation, then steers the agent with your decision. Usage: /lens-booboo-refactor [path]",
1235
- handler: async (args, ctx) => {
1236
- const targetPath = args.trim() || ctx.cwd || process.cwd();
1237
- const fs = require("node:fs") as typeof import("node:fs");
1238
- ctx.ui.notify("šŸ—ļø Scanning for architectural debt...", "info");
1239
-
1240
- // --- Scan for architectural debt (shared module) ---
1241
- const configPath = path.join(
1242
- typeof __dirname !== "undefined" ? __dirname : ".",
1243
- "rules", "ast-grep-rules", ".sgconfig.yml",
1244
- );
1245
- const isTsProject = fs.existsSync(path.join(targetPath, "tsconfig.json"));
1246
-
1247
- const skipByFile = scanSkipViolations(astGrepClient, configPath, targetPath, isTsProject, SKIP_RULES, RULE_ACTIONS);
1248
- const metricsByFile = scanComplexityMetrics(complexityClient, targetPath, isTsProject);
1249
- const architectViolations = architectClient.hasConfig()
1250
- ? scanArchitectViolations(architectClient, targetPath)
1251
- : new Map<string, string[]>();
1252
- const scored = scoreFiles(skipByFile, metricsByFile, architectViolations);
1253
-
1254
- if (scored.length === 0) {
1255
- ctx.ui.notify("āœ… No architectural debt found — codebase is clean.", "info");
1256
- return;
1257
- }
1258
-
1259
- const { file: worstFile, score } = scored[0];
1260
- const relFile = path.relative(targetPath, worstFile).replace(/\\/g, "/");
1261
- const issues = skipByFile.get(worstFile) ?? [];
1262
- const metrics = metricsByFile.get(worstFile);
1263
- const archIssues = architectViolations.get(worstFile) ?? [];
1264
-
1265
- const snippetResult = issues.length > 0 ? extractCodeSnippet(worstFile, issues[0].line) : null;
1266
- const snippet = snippetResult?.snippet ?? "";
1267
- const snippetStart = snippetResult?.start ?? 1;
1268
- const snippetEnd = snippetResult?.end ?? 1;
1269
-
1270
- // --- Steer agent (agent generates options + calls interviewer tool) ---
1271
- const ruleGroups = new Map<string, number>();
1272
- for (const i of issues)
1273
- ruleGroups.set(i.rule, (ruleGroups.get(i.rule) ?? 0) + 1);
1274
-
1275
- const issuesSummary = [...ruleGroups.entries()]
1276
- .map(
1277
- ([r, n]) =>
1278
- `- \`${r}\` (Ɨ${n})${RULE_ACTIONS[r] ? ` — ${RULE_ACTIONS[r].note}` : ""}`,
1279
- )
1280
- .join("\n");
1281
- const archSummary = archIssues.length > 0
1282
- ? archIssues.map((m) => `- ${m}`).join("\n")
1283
- : "None";
1284
- const metricsSummary = metrics
1285
- ? `MI: ${metrics.mi.toFixed(1)}, Cognitive: ${metrics.cognitive}, Nesting: ${metrics.nesting}`
1286
- : "";
1287
-
1288
- const steer = [
1289
- `šŸ—ļø BOOBOO REFACTOR — worst offender identified`,
1290
- "",
1291
- `**File**: \`${relFile}\` (debt score: ${score})`,
1292
- "",
1293
- metrics ? `**Complexity**: ${metricsSummary}` : "",
1294
- "",
1295
- issues.length > 0 ? `**Violations**:\n${issuesSummary}` : "",
1296
- archIssues.length > 0 ? `**Architectural rules violated**:\n${archSummary}` : "",
1297
- "",
1298
- `**Code** (\`${relFile}\` lines ${snippetStart}–${snippetEnd}):`,
1299
- "```typescript",
1300
- snippet,
1301
- "```",
1302
- "",
1303
- "**Your job**:",
1304
- "1. Analyze this code — what's the most impactful refactoring for this file?",
1305
- "2. Build 3-5 refactoring options. For each, explain *why* it helps and *what* you'd change. Mark one as recommended.",
1306
- "3. For each option, estimate the impact: linesReduced (number), miProjection (e.g. '3.5 → 8'), cognitiveProjection (e.g. '1533 → 1400').",
1307
- "4. Include an option to skip to the next worst offender.",
1308
- "5. Call the `interviewer` tool with:",
1309
- " - `question`: what you're asking the user",
1310
- " - `options`: array of { value, label, context, recommended, impact: { linesReduced, miProjection, cognitiveProjection } }",
1311
- "6. The user picks an option or types a free-text response in the browser form.",
1312
- "7. Implement the refactoring. After changes, run `git diff HEAD~1` to capture what was changed.",
1313
- "8. Run a complexity scan on the changed file(s) to compute the metrics delta (before vs after MI, cognitive).",
1314
- "9. Call the `interviewer` tool AGAIN with confirmationMode=true. The plan should contain: what was changed (summary + diff lines), how metrics evolved, and a free-chat option for refinements.",
1315
- "10. If the user describes changes: make further edits, re-run the scan, call interviewer again with an updated report. Repeat until satisfied.",
1316
- ].join("\n");
1317
-
1318
- pi.sendUserMessage(steer, { deliverAs: "steer" });
1319
- },
286
+ handler: (args, ctx) =>
287
+ handleRefactor(
288
+ args,
289
+ ctx,
290
+ {
291
+ astGrep: astGrepClient,
292
+ complexity: complexityClient,
293
+ architect: architectClient,
294
+ },
295
+ pi,
296
+ SKIP_RULES,
297
+ RULE_ACTIONS,
298
+ ),
1320
299
  });
1321
300
 
1322
301
  pi.registerCommand("lens-metrics", {
@@ -1326,7 +305,6 @@ export default function (pi: ExtensionAPI) {
1326
305
  const targetPath = args.trim() || ctx.cwd || process.cwd();
1327
306
  ctx.ui.notify("šŸ“Š Measuring code metrics...", "info");
1328
307
 
1329
- const fs = require("node:fs");
1330
308
  const reviewDir = path.join(process.cwd(), ".pi-lens", "reviews");
1331
309
  const timestamp = new Date()
1332
310
  .toISOString()
@@ -1337,38 +315,18 @@ export default function (pi: ExtensionAPI) {
1337
315
  const results: import("./clients/complexity-client.js").FileComplexity[] =
1338
316
  [];
1339
317
 
1340
- const scanDir = (dir: string) => {
1341
- let entries: nodeFs.Dirent[];
1342
- try {
1343
- entries = nodeFs.readdirSync(dir, { withFileTypes: true });
1344
- } catch {
1345
- return;
1346
- }
1347
-
1348
- for (const entry of entries) {
1349
- const fullPath = path.join(dir, entry.name);
1350
- if (
1351
- entry.isDirectory() &&
1352
- ![
1353
- "node_modules",
1354
- ".git",
1355
- "dist",
1356
- "build",
1357
- ".next",
1358
- ".pi-lens",
1359
- ].includes(entry.name)
1360
- ) {
1361
- scanDir(fullPath);
1362
- } else if (complexityClient.isSupportedFile(fullPath)) {
1363
- const metrics = complexityClient.analyzeFile(fullPath);
1364
- if (metrics) {
1365
- results.push(metrics);
1366
- }
318
+ const isTsProject = nodeFs.existsSync(
319
+ path.join(targetPath, "tsconfig.json"),
320
+ );
321
+ const files = getSourceFiles(targetPath, isTsProject);
322
+ for (const fullPath of files) {
323
+ if (complexityClient.isSupportedFile(fullPath)) {
324
+ const metrics = complexityClient.analyzeFile(fullPath);
325
+ if (metrics) {
326
+ results.push(metrics);
1367
327
  }
1368
328
  }
1369
- };
1370
-
1371
- scanDir(targetPath);
329
+ }
1372
330
 
1373
331
  if (results.length === 0) {
1374
332
  ctx.ui.notify("No supported files found to analyze", "warning");
@@ -1405,7 +363,22 @@ export default function (pi: ExtensionAPI) {
1405
363
  });
1406
364
 
1407
365
  const gradeCount = { A: 0, B: 0, C: 0, D: 0, F: 0 };
1408
- grades.forEach((g) => gradeCount[g.letter as keyof typeof gradeCount]++);
366
+ for (const g of grades) {
367
+ gradeCount[g.letter as keyof typeof gradeCount]++;
368
+ }
369
+
370
+ // Capture snapshots for history tracking
371
+ const history = captureSnapshots(
372
+ results.map((r) => ({
373
+ filePath: r.filePath,
374
+ metrics: {
375
+ maintainabilityIndex: r.maintainabilityIndex,
376
+ cognitiveComplexity: r.cognitiveComplexity,
377
+ maxNestingDepth: r.maxNestingDepth,
378
+ linesOfCode: r.linesOfCode,
379
+ },
380
+ })),
381
+ );
1409
382
 
1410
383
  // Build report
1411
384
  let report = `# Code Metrics Report: ${projectName}\n\n`;
@@ -1479,8 +452,8 @@ export default function (pi: ExtensionAPI) {
1479
452
 
1480
453
  // All files table (sorted by MI ascending)
1481
454
  report += `## All Files\n\n`;
1482
- report += `| Grade | File | MI | Cognitive | Cyclomatic | Nesting | Functions | LOC | Entropy |\n`;
1483
- report += `|-------|------|-----|-----------|------------|---------|-----------|-----|--------|\n`;
455
+ report += `| Grade | File | MI | Cognitive | Nesting | LOC | Trend |\n`;
456
+ report += `|-------|------|-----|-----------|---------|-----|-------|\n`;
1484
457
 
1485
458
  const sorted = [...results].sort(
1486
459
  (a, b) => a.maintainabilityIndex - b.maintainabilityIndex,
@@ -1496,11 +469,30 @@ export default function (pi: ExtensionAPI) {
1496
469
 
1497
470
  // Make path relative for readability
1498
471
  const relPath = path.relative(targetPath, f.filePath);
472
+ const trendCell = formatTrendCell(f.filePath, history);
1499
473
 
1500
- report += `| ${grade} | ${relPath} | ${mi.toFixed(1)} | ${f.cognitiveComplexity} | ${f.cyclomaticComplexity.toFixed(1)} | ${f.maxNestingDepth} | ${f.functionCount} | ${f.linesOfCode} | ${f.codeEntropy.toFixed(2)} |\n`;
474
+ report += `| ${grade} | ${relPath} | ${mi.toFixed(1)} | ${f.cognitiveComplexity} | ${f.maxNestingDepth} | ${f.linesOfCode} | ${trendCell} |\n`;
1501
475
  }
1502
476
  report += `\n`;
1503
477
 
478
+ // Trend Summary
479
+ const trendSummary = getTrendSummary(history);
480
+ report += `## Trend Summary\n\n`;
481
+ report += `| Trend | Count |\n`;
482
+ report += `|-------|-------|\n`;
483
+ report += `| šŸ“ˆ Improving | ${trendSummary.improving} |\n`;
484
+ report += `| āž”ļø Stable | ${trendSummary.stable} |\n`;
485
+ report += `| šŸ“‰ Regressing | ${trendSummary.regressing} |\n\n`;
486
+
487
+ if (trendSummary.worstRegressions.length > 0) {
488
+ report += `### Top Regressions\n\n`;
489
+ report += `Files with largest MI decline since last scan:\n\n`;
490
+ for (const r of trendSummary.worstRegressions) {
491
+ report += `- **${r.file}**: MI ${r.miDelta > 0 ? "+" : ""}${r.miDelta}\n`;
492
+ }
493
+ report += `\n`;
494
+ }
495
+
1504
496
  // Top 10 worst files (actionable)
1505
497
  report += `## Top 10 Files Needing Attention\n\n`;
1506
498
  report += `These files have the lowest maintainability scores:\n\n`;
@@ -1539,14 +531,14 @@ export default function (pi: ExtensionAPI) {
1539
531
  report += `\n`;
1540
532
 
1541
533
  // Save report
1542
- fs.mkdirSync(reviewDir, { recursive: true });
534
+ nodeFs.mkdirSync(reviewDir, { recursive: true });
1543
535
 
1544
536
  const reportPath = path.join(reviewDir, `metrics-${timestamp}.md`);
1545
- fs.writeFileSync(reportPath, report, "utf-8");
537
+ nodeFs.writeFileSync(reportPath, report, "utf-8");
1546
538
 
1547
539
  // Also save latest.md for easy access
1548
540
  const latestPath = path.join(reviewDir, "latest.md");
1549
- fs.writeFileSync(latestPath, report, "utf-8");
541
+ nodeFs.writeFileSync(latestPath, report, "utf-8");
1550
542
 
1551
543
  // Console summary
1552
544
  const summary = [
@@ -1581,32 +573,19 @@ export default function (pi: ExtensionAPI) {
1581
573
  let formatted = 0;
1582
574
  let skipped = 0;
1583
575
 
1584
- const formatDir = (dir: string) => {
1585
- let entries: nodeFs.Dirent[];
1586
- try {
1587
- entries = nodeFs.readdirSync(dir, { withFileTypes: true });
1588
- } catch {
1589
- return;
1590
- }
576
+ const targetPath = ctx.cwd || process.cwd();
577
+ const isTsProject = nodeFs.existsSync(
578
+ path.join(targetPath, "tsconfig.json"),
579
+ );
580
+ const files = getSourceFiles(targetPath, isTsProject);
1591
581
 
1592
- for (const entry of entries) {
1593
- const fullPath = path.join(dir, entry.name);
1594
- if (
1595
- entry.isDirectory() &&
1596
- !["node_modules", ".git", "dist", "build", ".next"].includes(
1597
- entry.name,
1598
- )
1599
- ) {
1600
- formatDir(fullPath);
1601
- } else if (/\.(ts|tsx|js|jsx|json|css)$/.test(entry.name)) {
1602
- const result = biomeClient.formatFile(fullPath);
1603
- if (result.changed) formatted++;
1604
- else if (result.success) skipped++;
1605
- }
582
+ for (const fullPath of files) {
583
+ if (/\.(ts|tsx|js|jsx|json|css)$/.test(fullPath)) {
584
+ const result = biomeClient.formatFile(fullPath);
585
+ if (result.changed) formatted++;
586
+ else if (result.success) skipped++;
1606
587
  }
1607
- };
1608
-
1609
- formatDir(ctx.cwd || process.cwd());
588
+ }
1610
589
  ctx.ui.notify(
1611
590
  `āœ“ Formatted ${formatted} file(s), ${skipped} already clean`,
1612
591
  "info",
@@ -1656,10 +635,9 @@ export default function (pi: ExtensionAPI) {
1656
635
  "yaml",
1657
636
  ] as const;
1658
637
 
1659
- // --- Interviewer tool (browser-based interview with diff confirmation) ---
638
+ // --- Interviewer tool (browser-based interview with diff confirmation) ---
1660
639
  buildInterviewer(pi, dbg);
1661
640
 
1662
-
1663
641
  pi.registerTool({
1664
642
  name: "ast_grep_search",
1665
643
  label: "AST Search",
@@ -1919,11 +897,10 @@ export default function (pi: ExtensionAPI) {
1919
897
 
1920
898
  if (!filePath) return;
1921
899
 
1922
- const fs = require("node:fs") as typeof import("node:fs");
1923
900
  dbg(
1924
- `tool_call fired for: ${filePath} (exists: ${fs.existsSync(filePath)})`,
901
+ `tool_call fired for: ${filePath} (exists: ${nodeFs.existsSync(filePath)})`,
1925
902
  );
1926
- if (!fs.existsSync(filePath)) return;
903
+ if (!nodeFs.existsSync(filePath)) return;
1927
904
 
1928
905
  // Record complexity baseline for TS/JS files
1929
906
  if (
@@ -1939,7 +916,7 @@ export default function (pi: ExtensionAPI) {
1939
916
  const hints: string[] = [];
1940
917
 
1941
918
  if (/\.(ts|tsx|js|jsx)$/.test(filePath) && !pi.getFlag("no-lsp")) {
1942
- tsClient.updateFile(filePath, fs.readFileSync(filePath, "utf-8"));
919
+ tsClient.updateFile(filePath, nodeFs.readFileSync(filePath, "utf-8"));
1943
920
  const diags = tsClient.getDiagnostics(filePath);
1944
921
  if (diags.length > 0) {
1945
922
  hints.push(
@@ -2012,9 +989,9 @@ export default function (pi: ExtensionAPI) {
2012
989
  preWriteHints.delete(filePath);
2013
990
 
2014
991
  // Record write for metrics (silent tracking)
2015
- const fs = require("node:fs") as typeof import("node:fs");
992
+
2016
993
  try {
2017
- const content = fs.readFileSync(filePath, "utf-8");
994
+ const content = nodeFs.readFileSync(filePath, "utf-8");
2018
995
  metricsClient.recordWrite(filePath, content);
2019
996
  } catch (err) {
2020
997
  void err;
@@ -2025,7 +1002,7 @@ export default function (pi: ExtensionAPI) {
2025
1002
  // TypeScript LSP diagnostics
2026
1003
  if (!pi.getFlag("no-lsp") && tsClient.isTypeScriptFile(filePath)) {
2027
1004
  try {
2028
- tsClient.updateFile(filePath, fs.readFileSync(filePath, "utf-8"));
1005
+ tsClient.updateFile(filePath, nodeFs.readFileSync(filePath, "utf-8"));
2029
1006
  } catch (err) {
2030
1007
  void err;
2031
1008
  }
@@ -2134,24 +1111,27 @@ export default function (pi: ExtensionAPI) {
2134
1111
  }
2135
1112
 
2136
1113
  // Architectural rule validation (post-write)
2137
- if (architectClient.hasConfig() && fs.existsSync(filePath)) {
1114
+ if (architectClient.hasConfig() && nodeFs.existsSync(filePath)) {
2138
1115
  const relPath = path.relative(projectRoot, filePath).replace(/\\/g, "/");
2139
- const content = fs.readFileSync(filePath, "utf-8");
1116
+ const content = nodeFs.readFileSync(filePath, "utf-8");
2140
1117
  const lineCount = content.split("\n").length;
2141
1118
 
2142
1119
  // Check for violations
2143
1120
  const violations = architectClient.checkFile(relPath, content);
2144
1121
  if (violations.length > 0) {
2145
- lspOutput += `\n\nšŸ”“ Architectural violation(s) in ${relPath}:\n`;
1122
+ lspOutput += `\n\nšŸ”“ STOP — ${violations.length} architectural violation(s). Fix before continuing:\n`;
2146
1123
  for (const v of violations) {
2147
- lspOutput += ` → ${v.message}\n`;
1124
+ const lineStr = v.line ? `L${v.line}: ` : "";
1125
+ lspOutput += ` ${lineStr}${v.message}\n`;
2148
1126
  }
1127
+ lspOutput += ` → Refactor the code to comply with the project's architectural rules.\n`;
2149
1128
  }
2150
1129
 
2151
1130
  // Check file size limit — hard stop, file is too large to reason about
2152
1131
  const sizeViolation = architectClient.checkFileSize(relPath, lineCount);
2153
1132
  if (sizeViolation) {
2154
- lspOutput += `\n\nšŸ”“ STOP — ${sizeViolation.message}\n`;
1133
+ lspOutput += `\n\nšŸ”“ STOP — Architectural Limit Exceeded:\n`;
1134
+ lspOutput += ` ${sizeViolation.message}\n`;
2155
1135
  lspOutput += ` → Split into smaller, focused modules before adding more code.\n`;
2156
1136
  }
2157
1137
  }
@@ -2422,9 +1402,7 @@ export default function (pi: ExtensionAPI) {
2422
1402
  if (depResult.hasCircular && depResult.circular.length > 0) {
2423
1403
  const circularDeps = depResult.circular
2424
1404
  .flatMap((d) => d.path)
2425
- .filter(
2426
- (p: string) => !filePath.endsWith(require("node:path").basename(p)),
2427
- );
1405
+ .filter((p: string) => !filePath.endsWith(path.basename(p)));
2428
1406
  const uniqueDeps = [...new Set(circularDeps)];
2429
1407
  if (uniqueDeps.length > 0) {
2430
1408
  lspOutput += `\n\n${depChecker.formatWarning(filePath, uniqueDeps)}`;