pi-lens 1.3.13 → 2.0.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.
package/index.ts CHANGED
@@ -217,12 +217,15 @@ export default function (pi: ExtensionAPI) {
217
217
 
218
218
  pi.registerCommand("lens-booboo", {
219
219
  description:
220
- "Code review: design smells + complexity metrics. Usage: /lens-booboo [path]",
220
+ "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]",
221
221
  handler: async (args, ctx) => {
222
222
  const targetPath = args.trim() || ctx.cwd || process.cwd();
223
- ctx.ui.notify("🔍 Running code review...", "info");
223
+ ctx.ui.notify("🔍 Running full codebase review...", "info");
224
224
 
225
225
  const parts: string[] = [];
226
+ const fullReport: string[] = [];
227
+ const timestamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
228
+ const reviewDir = path.join(process.cwd(), ".pi-lens", "reviews");
226
229
 
227
230
  // Part 1: Design smells via ast-grep
228
231
  if (astGrepClient.isAvailable()) {
@@ -239,6 +242,10 @@ export default function (pi: ExtensionAPI) {
239
242
  "scan",
240
243
  "--config", configPath,
241
244
  "--json",
245
+ "--globs", "!**/*.test.ts",
246
+ "--globs", "!**/*.spec.ts",
247
+ "--globs", "!**/test-utils.ts",
248
+ "--globs", "!**/.pi-lens/**",
242
249
  targetPath,
243
250
  ], {
244
251
  encoding: "utf-8",
@@ -271,6 +278,7 @@ export default function (pi: ExtensionAPI) {
271
278
  }
272
279
 
273
280
  if (issues.length > 0) {
281
+ // UI summary (truncated)
274
282
  let report = `[Design Smells] ${issues.length} issue(s) found:\n`;
275
283
  for (const issue of issues.slice(0, 20)) {
276
284
  report += ` L${issue.line}: ${issue.rule} — ${issue.message}\n`;
@@ -279,6 +287,14 @@ export default function (pi: ExtensionAPI) {
279
287
  report += ` ... and ${issues.length - 20} more\n`;
280
288
  }
281
289
  parts.push(report);
290
+
291
+ // Full report for file
292
+ let fullSection = `## Design Smells\n\n**${issues.length} issue(s) found**\n\n`;
293
+ fullSection += "| Line | Rule | Message |\n|------|------|--------|\n";
294
+ for (const issue of issues) {
295
+ fullSection += `| ${issue.line} | ${issue.rule} | ${issue.message} |\n`;
296
+ }
297
+ fullReport.push(fullSection);
282
298
  }
283
299
  }
284
300
  } catch (err: any) {
@@ -290,6 +306,7 @@ export default function (pi: ExtensionAPI) {
290
306
  if (astGrepClient.isAvailable()) {
291
307
  const similarGroups = await astGrepClient.findSimilarFunctions(targetPath, "typescript");
292
308
  if (similarGroups.length > 0) {
309
+ // UI summary (truncated)
293
310
  let report = `[Similar Functions] ${similarGroups.length} group(s) of structurally similar functions:\n`;
294
311
  for (const group of similarGroups.slice(0, 5)) {
295
312
  report += ` Pattern: ${group.functions.map(f => f.name).join(", ")}\n`;
@@ -301,11 +318,24 @@ export default function (pi: ExtensionAPI) {
301
318
  report += ` ... and ${similarGroups.length - 5} more groups\n`;
302
319
  }
303
320
  parts.push(report);
321
+
322
+ // Full report for file
323
+ let fullSection = `## Similar Functions\n\n**${similarGroups.length} group(s) of structurally similar functions**\n\n`;
324
+ for (const group of similarGroups) {
325
+ fullSection += `### Pattern: ${group.functions.map(f => f.name).join(", ")}\n\n`;
326
+ fullSection += "| Function | File | Line |\n|----------|------|------|\n";
327
+ for (const fn of group.functions) {
328
+ fullSection += `| ${fn.name} | ${fn.file} | ${fn.line} |\n`;
329
+ }
330
+ fullSection += "\n";
331
+ }
332
+ fullReport.push(fullSection);
304
333
  }
305
334
  }
306
335
 
307
- // Part 3: Complexity metrics
336
+ // Part 3: Complexity metrics + AI slop detection
308
337
  const results: import("./clients/complexity-client.js").FileComplexity[] = [];
338
+ const aiSlopIssues: string[] = [];
309
339
 
310
340
  const scanDir = (dir: string) => {
311
341
  if (!require("node:fs").existsSync(dir)) return;
@@ -320,6 +350,17 @@ export default function (pi: ExtensionAPI) {
320
350
  const metrics = complexityClient.analyzeFile(fullPath);
321
351
  if (metrics) {
322
352
  results.push(metrics);
353
+
354
+ // Check AI slop indicators — skip test files (low MI is expected/structural)
355
+ if (!/\.(test|spec)\.[jt]sx?$/.test(entry.name)) {
356
+ const warnings = complexityClient.checkThresholds(metrics);
357
+ if (warnings.length > 0) {
358
+ aiSlopIssues.push(` ${metrics.filePath}:`);
359
+ for (const w of warnings) {
360
+ aiSlopIssues.push(` ⚠ ${w}`);
361
+ }
362
+ }
363
+ }
323
364
  }
324
365
  }
325
366
  }
@@ -332,10 +373,13 @@ export default function (pi: ExtensionAPI) {
332
373
  const avgCognitive = results.reduce((a, b) => a + b.cognitiveComplexity, 0) / results.length;
333
374
  const avgCyclomatic = results.reduce((a, b) => a + b.cyclomaticComplexity, 0) / results.length;
334
375
  const maxNesting = Math.max(...results.map(r => r.maxNestingDepth));
376
+ const maxCognitive = Math.max(...results.map(r => r.cognitiveComplexity));
377
+ const minMI = Math.min(...results.map(r => r.maintainabilityIndex));
335
378
 
336
379
  const lowMI = results.filter(r => r.maintainabilityIndex < 60).sort((a, b) => a.maintainabilityIndex - b.maintainabilityIndex);
337
380
  const highCognitive = results.filter(r => r.cognitiveComplexity > 20).sort((a, b) => b.cognitiveComplexity - a.cognitiveComplexity);
338
381
 
382
+ // UI summary (truncated)
339
383
  let summary = `[Complexity] ${results.length} file(s) scanned\n`;
340
384
  summary += ` Maintainability: ${avgMI.toFixed(1)} avg | Cognitive: ${avgCognitive.toFixed(1)} avg | Max Nesting: ${maxNesting} levels\n`;
341
385
 
@@ -355,17 +399,369 @@ export default function (pi: ExtensionAPI) {
355
399
  if (highCognitive.length > 5) summary += ` ... and ${highCognitive.length - 5} more\n`;
356
400
  }
357
401
 
402
+ // Add AI slop issues
403
+ if (aiSlopIssues.length > 0) {
404
+ summary += `\n[AI Slop Indicators]\n${aiSlopIssues.join("\n")}`;
405
+ }
406
+
358
407
  parts.push(summary);
408
+
409
+ // Full report for file
410
+ let fullSection = `## Complexity Metrics\n\n**${results.length} file(s) scanned**\n\n`;
411
+ fullSection += `### Summary\n\n`;
412
+ fullSection += `| Metric | Value |\n|--------|-------|\n`;
413
+ fullSection += `| Avg Maintainability Index | ${avgMI.toFixed(1)} |\n`;
414
+ fullSection += `| Min Maintainability Index | ${minMI.toFixed(1)} |\n`;
415
+ fullSection += `| Avg Cognitive Complexity | ${avgCognitive.toFixed(1)} |\n`;
416
+ fullSection += `| Max Cognitive Complexity | ${maxCognitive} |\n`;
417
+ fullSection += `| Avg Cyclomatic Complexity | ${avgCyclomatic.toFixed(1)} |\n`;
418
+ fullSection += `| Max Nesting Depth | ${maxNesting} |\n`;
419
+ fullSection += `| Total Files | ${results.length} |\n\n`;
420
+
421
+ if (lowMI.length > 0) {
422
+ fullSection += `### Low Maintainability (MI < 60)\n\n`;
423
+ fullSection += `| File | MI | Cognitive | Cyclomatic | Nesting |\n`;
424
+ fullSection += `|------|-----|-----------|------------|--------|\n`;
425
+ for (const f of lowMI) {
426
+ fullSection += `| ${f.filePath} | ${f.maintainabilityIndex.toFixed(1)} | ${f.cognitiveComplexity} | ${f.cyclomaticComplexity} | ${f.maxNestingDepth} |\n`;
427
+ }
428
+ fullSection += "\n";
429
+ }
430
+
431
+ if (highCognitive.length > 0) {
432
+ fullSection += `### High Cognitive Complexity (> 20)\n\n`;
433
+ fullSection += `| File | Cognitive | MI | Cyclomatic | Nesting |\n`;
434
+ fullSection += `|------|-----------|-----|------------|--------|\n`;
435
+ for (const f of highCognitive) {
436
+ fullSection += `| ${f.filePath} | ${f.cognitiveComplexity} | ${f.maintainabilityIndex.toFixed(1)} | ${f.cyclomaticComplexity} | ${f.maxNestingDepth} |\n`;
437
+ }
438
+ fullSection += "\n";
439
+ }
440
+
441
+ // All files table
442
+ fullSection += `### All Files\n\n`;
443
+ fullSection += `| File | MI | Cognitive | Cyclomatic | Nesting | Entropy |\n`;
444
+ fullSection += `|------|-----|-----------|------------|---------|--------|\n`;
445
+ for (const f of results.sort((a, b) => a.maintainabilityIndex - b.maintainabilityIndex)) {
446
+ fullSection += `| ${f.filePath} | ${f.maintainabilityIndex.toFixed(1)} | ${f.cognitiveComplexity} | ${f.cyclomaticComplexity} | ${f.maxNestingDepth} | ${f.codeEntropy.toFixed(2)} |\n`;
447
+ }
448
+ fullSection += "\n";
449
+
450
+ // AI slop indicators
451
+ if (aiSlopIssues.length > 0) {
452
+ fullSection += `### AI Slop Indicators\n\n`;
453
+ for (const issue of aiSlopIssues) {
454
+ fullSection += `${issue}\n`;
455
+ }
456
+ fullSection += "\n";
457
+ }
458
+
459
+ fullReport.push(fullSection);
359
460
  }
360
461
 
462
+ // Part 4: TODOs scan
463
+ const todoResult = todoScanner.scanDirectory(targetPath);
464
+ const todoReport = todoScanner.formatResult(todoResult);
465
+ if (todoReport) {
466
+ parts.push(todoReport);
467
+ // Full TODO report
468
+ let fullSection = `## TODOs / Annotations\n\n`;
469
+ if (todoResult.items.length > 0) {
470
+ fullSection += `**${todoResult.items.length} annotation(s) found**\n\n`;
471
+ fullSection += `| Type | File | Line | Text |\n`;
472
+ fullSection += `|------|------|------|------|\n`;
473
+ for (const item of todoResult.items) {
474
+ fullSection += `| ${item.type} | ${item.file} | ${item.line} | ${item.message} |\n`;
475
+ }
476
+ } else {
477
+ fullSection += `No annotations found.\n`;
478
+ }
479
+ fullSection += "\n";
480
+ fullReport.push(fullSection);
481
+ }
482
+
483
+ // Part 5: Dead code (knip)
484
+ if (knipClient.isAvailable()) {
485
+ const knipResult = knipClient.analyze(targetPath);
486
+ const knipReport = knipClient.formatResult(knipResult);
487
+ if (knipReport) {
488
+ parts.push(knipReport);
489
+ // Full knip report
490
+ let fullSection = `## Dead Code (Knip)\n\n`;
491
+ if (knipResult.issues.length > 0) {
492
+ fullSection += `**${knipResult.issues.length} issue(s) found**\n\n`;
493
+ fullSection += `| Type | Name | File |\n`;
494
+ fullSection += `|------|------|------|\n`;
495
+ for (const issue of knipResult.issues) {
496
+ fullSection += `| ${issue.type} | ${issue.name} | ${issue.file ?? ""} |\n`;
497
+ }
498
+ } else {
499
+ fullSection += `No dead code issues found.\n`;
500
+ }
501
+ fullSection += "\n";
502
+ fullReport.push(fullSection);
503
+ }
504
+ }
505
+
506
+ // Part 6: Code duplication
507
+ if (jscpdClient.isAvailable()) {
508
+ const jscpdResult = jscpdClient.scan(targetPath);
509
+ const jscpdReport = jscpdClient.formatResult(jscpdResult);
510
+ if (jscpdReport) {
511
+ parts.push(jscpdReport);
512
+ // Full jscpd report
513
+ let fullSection = `## Code Duplication (jscpd)\n\n`;
514
+ if (jscpdResult.clones.length > 0) {
515
+ fullSection += `**${jscpdResult.clones.length} duplicate block(s) found** (${jscpdResult.duplicatedLines}/${jscpdResult.totalLines} lines, ${jscpdResult.percentage.toFixed(1)}%)\n\n`;
516
+ fullSection += `| File A | Line A | File B | Line B | Lines | Tokens |\n`;
517
+ fullSection += `|--------|--------|--------|--------|-------|--------|\n`;
518
+ for (const dup of jscpdResult.clones) {
519
+ fullSection += `| ${dup.fileA} | ${dup.startA} | ${dup.fileB} | ${dup.startB} | ${dup.lines} | ${dup.tokens} |\n`;
520
+ }
521
+ } else {
522
+ fullSection += `No duplicate code found.\n`;
523
+ }
524
+ fullSection += "\n";
525
+ fullReport.push(fullSection);
526
+ }
527
+ }
528
+
529
+ // Part 7: Type coverage
530
+ if (typeCoverageClient.isAvailable()) {
531
+ const tcResult = typeCoverageClient.scan(targetPath);
532
+ const tcReport = typeCoverageClient.formatResult(tcResult);
533
+ if (tcReport) {
534
+ parts.push(tcReport);
535
+ // Full type coverage report
536
+ let fullSection = `## Type Coverage\n\n`;
537
+ fullSection += `**${tcResult.percentage.toFixed(1)}% typed** (${tcResult.typed}/${tcResult.total} identifiers)\n\n`;
538
+ if (tcResult.untypedLocations.length > 0) {
539
+ fullSection += `### Untyped Identifiers\n\n`;
540
+ fullSection += `| File | Line | Column | Name |\n`;
541
+ fullSection += `|------|------|--------|------|\n`;
542
+ for (const u of tcResult.untypedLocations) {
543
+ fullSection += `| ${u.file} | ${u.line} | ${u.column} | ${u.name} |\n`;
544
+ }
545
+ }
546
+ fullSection += "\n";
547
+ fullReport.push(fullSection);
548
+ }
549
+ }
550
+
551
+ // Build and save full markdown report
552
+ const fs = require("node:fs");
553
+ if (!fs.existsSync(reviewDir)) {
554
+ fs.mkdirSync(reviewDir, { recursive: true });
555
+ }
556
+
557
+ const projectName = path.basename(process.cwd());
558
+ let mdReport = `# Code Review: ${projectName}\n\n`;
559
+ mdReport += `**Scanned:** ${new Date().toISOString()}\n\n`;
560
+ mdReport += `**Path:** \`${targetPath}\`\n\n`;
561
+ mdReport += `---\n\n`;
562
+ mdReport += fullReport.join("\n");
563
+
564
+ const reportPath = path.join(reviewDir, `booboo-${timestamp}.md`);
565
+ fs.writeFileSync(reportPath, mdReport, "utf-8");
566
+
361
567
  if (parts.length === 0) {
362
- ctx.ui.notify("✓ Code review clean", "info");
568
+ ctx.ui.notify("✓ Code review clean — saved to .pi-lens/reviews/", "info");
363
569
  } else {
364
- ctx.ui.notify(parts.join("\n\n"), "info");
570
+ ctx.ui.notify(`${parts.join("\n\n")}\n\n📄 Full report: ${reportPath}`, "info");
365
571
  }
366
572
  },
367
573
  });
368
574
 
575
+ pi.registerCommand("lens-metrics", {
576
+ description:
577
+ "Measure complexity metrics for all files and export to report.md. Usage: /lens-metrics [path]",
578
+ handler: async (args, ctx) => {
579
+ const targetPath = args.trim() || ctx.cwd || process.cwd();
580
+ ctx.ui.notify("📊 Measuring code metrics...", "info");
581
+
582
+ const fs = require("node:fs");
583
+ const reviewDir = path.join(process.cwd(), ".pi-lens", "reviews");
584
+ const timestamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
585
+ const projectName = path.basename(process.cwd());
586
+
587
+ const results: import("./clients/complexity-client.js").FileComplexity[] = [];
588
+
589
+ const scanDir = (dir: string) => {
590
+ if (!fs.existsSync(dir)) return;
591
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
592
+
593
+ for (const entry of entries) {
594
+ const fullPath = path.join(dir, entry.name);
595
+ if (entry.isDirectory()) {
596
+ if (["node_modules", ".git", "dist", "build", ".next", ".pi-lens"].includes(entry.name)) continue;
597
+ scanDir(fullPath);
598
+ } else if (complexityClient.isSupportedFile(fullPath)) {
599
+ const metrics = complexityClient.analyzeFile(fullPath);
600
+ if (metrics) {
601
+ results.push(metrics);
602
+ }
603
+ }
604
+ }
605
+ };
606
+
607
+ scanDir(targetPath);
608
+
609
+ if (results.length === 0) {
610
+ ctx.ui.notify("No supported files found to analyze", "warning");
611
+ return;
612
+ }
613
+
614
+ // Calculate aggregates
615
+ const avgMI = results.reduce((a, b) => a + b.maintainabilityIndex, 0) / results.length;
616
+ const avgCognitive = results.reduce((a, b) => a + b.cognitiveComplexity, 0) / results.length;
617
+ const avgCyclomatic = results.reduce((a, b) => a + b.cyclomaticComplexity, 0) / results.length;
618
+ const avgFunctionLength = results.reduce((a, b) => a + b.avgFunctionLength, 0) / results.length;
619
+ const maxNesting = Math.max(...results.map(r => r.maxNestingDepth));
620
+ const maxCognitive = Math.max(...results.map(r => r.cognitiveComplexity));
621
+ const minMI = Math.min(...results.map(r => r.maintainabilityIndex));
622
+ const totalFunctions = results.reduce((a, b) => a + b.functionCount, 0);
623
+ const totalLOC = results.reduce((a, b) => a + b.linesOfCode, 0);
624
+
625
+ // Grade distribution
626
+ const grades = results.map(r => {
627
+ const mi = r.maintainabilityIndex;
628
+ if (mi >= 80) return { letter: "A", color: "🟢" };
629
+ if (mi >= 60) return { letter: "B", color: "🟡" };
630
+ if (mi >= 40) return { letter: "C", color: "🟠" };
631
+ if (mi >= 20) return { letter: "D", color: "🔴" };
632
+ return { letter: "F", color: "⚫" };
633
+ });
634
+
635
+ const gradeCount = { A: 0, B: 0, C: 0, D: 0, F: 0 };
636
+ grades.forEach(g => gradeCount[g.letter as keyof typeof gradeCount]++);
637
+
638
+ // Build report
639
+ let report = `# Code Metrics Report: ${projectName}\n\n`;
640
+ report += `**Generated:** ${new Date().toISOString()}\n\n`;
641
+ report += `**Path:** \`${targetPath}\`\n\n`;
642
+ report += `---\n\n`;
643
+
644
+ // AI slop aggregates
645
+ const totalAISlopWarnings = results.reduce((a, b) => {
646
+ return a + complexityClient.checkThresholds(b).length;
647
+ }, 0);
648
+ const totalEmojiComments = results.reduce((a, b) => a + b.aiCommentPatterns, 0);
649
+ const totalTryCatch = results.reduce((a, b) => a + b.tryCatchCount, 0);
650
+ const totalSingleUse = results.reduce((a, b) => a + b.singleUseFunctions, 0);
651
+ const maxParams = Math.max(...results.map(r => r.maxParamsInFunction));
652
+
653
+ // Summary
654
+ report += `## Summary\n\n`;
655
+ report += `| Metric | Value |\n`;
656
+ report += `|--------|-------|\n`;
657
+ report += `| Files Analyzed | ${results.length} |\n`;
658
+ report += `| Total Functions | ${totalFunctions} |\n`;
659
+ report += `| Total Lines of Code | ${totalLOC.toLocaleString()} |\n`;
660
+ report += `| Avg Maintainability Index | ${avgMI.toFixed(1)} |\n`;
661
+ report += `| Min Maintainability Index | ${minMI.toFixed(1)} |\n`;
662
+ report += `| Avg Cognitive Complexity | ${avgCognitive.toFixed(1)} |\n`;
663
+ report += `| Max Cognitive Complexity | ${maxCognitive} |\n`;
664
+ report += `| Avg Cyclomatic Complexity | ${avgCyclomatic.toFixed(1)} |\n`;
665
+ report += `| Max Nesting Depth | ${maxNesting} |\n`;
666
+ report += `| Avg Function Length | ${avgFunctionLength.toFixed(1)} lines |\n\n`;
667
+
668
+ // AI Slop Summary
669
+ report += `## AI Slop Indicators (Aggregate)\n\n`;
670
+ report += `| Indicator | Count |\n`;
671
+ report += `|-----------|-------|\n`;
672
+ report += `| Total Warnings | ${totalAISlopWarnings} |\n`;
673
+ report += `| Emoji/Boilerplate Comments | ${totalEmojiComments} |\n`;
674
+ report += `| Try/Catch Blocks | ${totalTryCatch} |\n`;
675
+ report += `| Single-Use Helper Functions | ${totalSingleUse} |\n`;
676
+ report += `| Max Function Parameters | ${maxParams} |\n\n`;
677
+
678
+ // Grade distribution
679
+ report += `## Maintainability Grade Distribution\n\n`;
680
+ report += `| Grade | Count | Percentage |\n`;
681
+ report += `|-------|-------|------------|\n`;
682
+ for (const [grade, count] of Object.entries(gradeCount)) {
683
+ const pct = ((count / results.length) * 100).toFixed(1);
684
+ const icon = grade === "A" ? "🟢" : grade === "B" ? "🟡" : grade === "C" ? "🟠" : grade === "D" ? "🔴" : "⚫";
685
+ report += `| ${icon} ${grade} (MI ≥ ${grade === "A" ? 80 : grade === "B" ? 60 : grade === "C" ? 40 : grade === "D" ? 20 : 0}) | ${count} | ${pct}% |\n`;
686
+ }
687
+ report += `\n`;
688
+
689
+ // All files table (sorted by MI ascending)
690
+ report += `## All Files\n\n`;
691
+ report += `| Grade | File | MI | Cognitive | Cyclomatic | Nesting | Functions | LOC | Entropy |\n`;
692
+ report += `|-------|------|-----|-----------|------------|---------|-----------|-----|--------|\n`;
693
+
694
+ const sorted = [...results].sort((a, b) => a.maintainabilityIndex - b.maintainabilityIndex);
695
+ for (const f of sorted) {
696
+ const mi = f.maintainabilityIndex;
697
+ let grade: string;
698
+ if (mi >= 80) grade = "🟢 A";
699
+ else if (mi >= 60) grade = "🟡 B";
700
+ else if (mi >= 40) grade = "🟠 C";
701
+ else if (mi >= 20) grade = "🔴 D";
702
+ else grade = "⚫ F";
703
+
704
+ // Make path relative for readability
705
+ const relPath = path.relative(targetPath, f.filePath);
706
+
707
+ report += `| ${grade} | ${relPath} | ${mi.toFixed(1)} | ${f.cognitiveComplexity} | ${f.cyclomaticComplexity.toFixed(1)} | ${f.maxNestingDepth} | ${f.functionCount} | ${f.linesOfCode} | ${f.codeEntropy.toFixed(2)} |\n`;
708
+ }
709
+ report += `\n`;
710
+
711
+ // Top 10 worst files (actionable)
712
+ report += `## Top 10 Files Needing Attention\n\n`;
713
+ report += `These files have the lowest maintainability scores:\n\n`;
714
+ for (let i = 0; i < Math.min(10, sorted.length); i++) {
715
+ const f = sorted[i];
716
+ const relPath = path.relative(targetPath, f.filePath);
717
+ const warnings: string[] = [];
718
+
719
+ if (f.maintainabilityIndex < 20) warnings.push("🔴 Critical: MI < 20");
720
+ else if (f.maintainabilityIndex < 40) warnings.push("🟠 Low: MI < 40");
721
+ if (f.cognitiveComplexity > 50) warnings.push(`High cognitive (${f.cognitiveComplexity})`);
722
+ if (f.maxNestingDepth > 5) warnings.push(`Deep nesting (${f.maxNestingDepth})`);
723
+ if (f.maxFunctionLength > 50) warnings.push(`Long functions (max ${f.maxFunctionLength})`);
724
+
725
+ // AI slop indicators
726
+ const slopWarnings = complexityClient.checkThresholds(f);
727
+ for (const w of slopWarnings) {
728
+ if (w.includes("AI-style") || w.includes("try/catch") || w.includes("single-use") || w.includes("parameter list")) {
729
+ warnings.push(`🤖 ${w.split(" — ")[0]}`);
730
+ }
731
+ }
732
+
733
+ report += `${i + 1}. **${relPath}** — MI: ${f.maintainabilityIndex.toFixed(1)}\n`;
734
+ if (warnings.length > 0) {
735
+ report += ` - ${warnings.join(", ")}\n`;
736
+ }
737
+ }
738
+ report += `\n`;
739
+
740
+ // Save report
741
+ if (!fs.existsSync(reviewDir)) {
742
+ fs.mkdirSync(reviewDir, { recursive: true });
743
+ }
744
+
745
+ const reportPath = path.join(reviewDir, `metrics-${timestamp}.md`);
746
+ fs.writeFileSync(reportPath, report, "utf-8");
747
+
748
+ // Also save latest.md for easy access
749
+ const latestPath = path.join(reviewDir, "latest.md");
750
+ fs.writeFileSync(latestPath, report, "utf-8");
751
+
752
+ // Console summary
753
+ const summary = [
754
+ `📊 Metrics Report`,
755
+ ` ${results.length} files, ${totalLOC.toLocaleString()} LOC, ${totalFunctions} functions`,
756
+ ` MI: ${avgMI.toFixed(1)} avg (${gradeCount.A}A ${gradeCount.B}B ${gradeCount.C}C ${gradeCount.D}D ${gradeCount.F}F)`,
757
+ ` Cognitive: ${avgCognitive.toFixed(1)} avg, ${maxCognitive} max`,
758
+ `📄 Saved: ${reportPath}`,
759
+ ].join("\n");
760
+
761
+ ctx.ui.notify(summary, "info");
762
+ },
763
+ });
764
+
369
765
  pi.registerCommand("lens-format", {
370
766
  description:
371
767
  "Apply Biome formatting to files. Usage: /lens-format [file-path] or /lens-format --all",
@@ -508,6 +904,10 @@ export default function (pi: ExtensionAPI) {
508
904
  let cachedExports = new Map<string, string>(); // function name -> file path
509
905
  const complexityBaselines: Map<string, import("./clients/complexity-client.js").FileComplexity> = new Map();
510
906
 
907
+ // Delta baselines: store pre-write diagnostics to diff against post-write
908
+ const astGrepBaselines = new Map<string, import("./clients/ast-grep-client.js").AstGrepDiagnostic[]>();
909
+ const biomeBaselines = new Map<string, import("./clients/biome-client.js").BiomeDiagnostic[]>();
910
+
511
911
  // --- Events ---
512
912
 
513
913
  pi.on("session_start", async (_event, ctx) => {
@@ -644,26 +1044,13 @@ export default function (pi: ExtensionAPI) {
644
1044
  }
645
1045
  }
646
1046
 
647
- if (
648
- /\.(ts|tsx|js|jsx)$/.test(filePath) &&
649
- !pi.getFlag("no-biome") &&
650
- biomeClient.isAvailable()
651
- ) {
652
- const diags = biomeClient.checkFile(filePath);
653
- if (diags.length > 0) {
654
- hints.push(
655
- `⚠ Pre-write: file already has ${diags.length} Biome issue(s)`,
656
- );
657
- }
1047
+ // Snapshot baselines for delta mode (no pre-write hints — delta handles it)
1048
+ if (!pi.getFlag("no-ast-grep") && astGrepClient.isAvailable()) {
1049
+ astGrepBaselines.set(filePath, astGrepClient.scanFile(filePath));
658
1050
  }
659
1051
 
660
- if (!pi.getFlag("no-ast-grep") && astGrepClient.isAvailable()) {
661
- const diags = astGrepClient.scanFile(filePath);
662
- if (diags.length > 0) {
663
- hints.push(
664
- `⚠ Pre-write: file already has ${diags.length} structural violations`,
665
- );
666
- }
1052
+ if (!pi.getFlag("no-biome") && biomeClient.isAvailable() && biomeClient.isSupportedFile(filePath)) {
1053
+ biomeBaselines.set(filePath, biomeClient.checkFile(filePath).filter(d => d.category === "lint" || d.severity === "error"));
667
1054
  }
668
1055
 
669
1056
  dbg(` pre-write hints: ${hints.length} — ${hints.join(" | ") || "none"}`);
@@ -778,50 +1165,105 @@ export default function (pi: ExtensionAPI) {
778
1165
  }
779
1166
  }
780
1167
 
781
- // ast-grep structural analysis
782
- const astAvailable = astGrepClient.isAvailable();
783
- dbg(
784
- ` ast-grep available: ${astAvailable}, no-ast-grep: ${pi.getFlag("no-ast-grep")}`,
785
- );
786
- if (!pi.getFlag("no-ast-grep") && astAvailable) {
787
- const astDiags = astGrepClient.scanFile(filePath);
788
- dbg(` ast-grep diags: ${astDiags.length}`);
789
- if (astDiags.length > 0) {
790
- lspOutput += `\n\n${astGrepClient.formatDiagnostics(astDiags)}`;
1168
+ // ast-grep structural analysis — delta mode (only show new violations)
1169
+ if (!pi.getFlag("no-ast-grep") && astGrepClient.isAvailable()) {
1170
+ const after = astGrepClient.scanFile(filePath);
1171
+ const before = astGrepBaselines.get(filePath) ?? [];
1172
+ astGrepBaselines.set(filePath, after);
1173
+
1174
+ // Count by rule before/after
1175
+ const countBefore = new Map<string, number>();
1176
+ const countAfter = new Map<string, number>();
1177
+ for (const d of before) countBefore.set(d.rule, (countBefore.get(d.rule) ?? 0) + 1);
1178
+ for (const d of after) countAfter.set(d.rule, (countAfter.get(d.rule) ?? 0) + 1);
1179
+
1180
+ // Find new/increased rules
1181
+ const newViolations: typeof after = [];
1182
+ for (const d of after) {
1183
+ const ruleBefore = countBefore.get(d.rule) ?? 0;
1184
+ const ruleAfter = countAfter.get(d.rule) ?? 0;
1185
+ if (ruleAfter > ruleBefore && newViolations.filter(x => x.rule === d.rule).length < (ruleAfter - ruleBefore)) {
1186
+ newViolations.push(d);
1187
+ }
1188
+ }
1189
+
1190
+ // Find fixed rules
1191
+ const fixedRules: string[] = [];
1192
+ for (const [rule, n] of countBefore) {
1193
+ const after_n = countAfter.get(rule) ?? 0;
1194
+ if (after_n < n) fixedRules.push(`${rule} (-${n - after_n})`);
1195
+ }
1196
+
1197
+ if (newViolations.length > 0) {
1198
+ lspOutput += `\n\n[ast-grep] +${newViolations.length} new issue(s) introduced:\n`;
1199
+ lspOutput += astGrepClient.formatDiagnostics(newViolations);
1200
+ }
1201
+ if (fixedRules.length > 0) {
1202
+ lspOutput += `\n\n[ast-grep] ✓ Fixed: ${fixedRules.join(", ")}`;
1203
+ }
1204
+ // Show total count quietly so context isn't lost
1205
+ if (after.length > 0 && newViolations.length === 0 && fixedRules.length === 0) {
1206
+ // no change — show nothing
1207
+ } else if (after.length > 0) {
1208
+ lspOutput += `\n (${after.length} total)`;
791
1209
  }
792
1210
  }
793
1211
 
794
- // Biome: lint only (formatting noise filtered out, use /lens-format)
1212
+ // Biome: lint only delta mode
795
1213
  const biomeAvailable = biomeClient.isAvailable();
796
- dbg(
797
- ` biome available: ${biomeAvailable}, supported: ${biomeClient.isSupportedFile(filePath)}, no-biome: ${pi.getFlag("no-biome")}`,
798
- );
799
- if (!pi.getFlag("no-biome") && biomeClient.isSupportedFile(filePath)) {
800
- const biomeDiags = biomeClient.checkFile(filePath);
801
- // Filter out format-only issues (noise for agent, use /lens-format)
802
- const lintDiags = biomeDiags.filter((d) => d.category === "lint" || d.severity === "error");
803
- dbg(` biome diags: ${biomeDiags.length} total, ${lintDiags.length} lint-only`);
804
- if (pi.getFlag("autofix-biome") && lintDiags.length > 0) {
805
- // Always attempt fix let Biome decide what it can do
1214
+ if (!pi.getFlag("no-biome") && biomeAvailable && biomeClient.isSupportedFile(filePath)) {
1215
+ const allDiags = biomeClient.checkFile(filePath);
1216
+ const after = allDiags.filter((d) => d.category === "lint" || d.severity === "error");
1217
+ const before = biomeBaselines.get(filePath) ?? [];
1218
+ biomeBaselines.set(filePath, after);
1219
+
1220
+ // Count by rule before/after
1221
+ const countBefore = new Map<string, number>();
1222
+ const countAfter = new Map<string, number>();
1223
+ const ruleKey = (d: typeof after[0]) => d.rule ?? d.message;
1224
+ for (const d of before) countBefore.set(ruleKey(d), (countBefore.get(ruleKey(d)) ?? 0) + 1);
1225
+ for (const d of after) countAfter.set(ruleKey(d), (countAfter.get(ruleKey(d)) ?? 0) + 1);
1226
+
1227
+ const newDiags: typeof after = [];
1228
+ for (const d of after) {
1229
+ const key = ruleKey(d);
1230
+ const n_before = countBefore.get(key) ?? 0;
1231
+ const n_after = countAfter.get(key) ?? 0;
1232
+ if (n_after > n_before && newDiags.filter(x => ruleKey(x) === key).length < (n_after - n_before)) {
1233
+ newDiags.push(d);
1234
+ }
1235
+ }
1236
+
1237
+ const fixedRules: string[] = [];
1238
+ for (const [rule, n] of countBefore) {
1239
+ const after_n = countAfter.get(rule) ?? 0;
1240
+ if (after_n < n) fixedRules.push(`${rule} (-${n - after_n})`);
1241
+ }
1242
+
1243
+ if (pi.getFlag("autofix-biome") && after.length > 0) {
806
1244
  const fixResult = biomeClient.fixFile(filePath);
807
1245
  if (fixResult.success && fixResult.changed) {
808
1246
  lspOutput += `\n\n[Biome] Auto-fixed ${fixResult.fixed} issue(s) — file updated on disk`;
809
- const remaining = biomeClient.checkFile(filePath);
810
- const remainingLint = remaining.filter((d) => d.category === "lint" || d.severity === "error");
811
- if (remainingLint.length > 0) {
812
- lspOutput += `\n\n${biomeClient.formatDiagnostics(remainingLint, filePath)}`;
813
- } else {
814
- lspOutput += `\n\n[Biome] ✓ All issues resolved`;
815
- }
816
- } else {
817
- // Nothing fixable — show diagnostics as-is
818
- lspOutput += `\n\n${biomeClient.formatDiagnostics(lintDiags, filePath)}`;
1247
+ const remaining = biomeClient.checkFile(filePath).filter((d) => d.category === "lint" || d.severity === "error");
1248
+ if (remaining.length > 0) lspOutput += `\n\n${biomeClient.formatDiagnostics(remaining, filePath)}`;
1249
+ else lspOutput += `\n\n[Biome] ✓ All issues resolved`;
1250
+ } else if (after.length > 0) {
1251
+ lspOutput += `\n\n${biomeClient.formatDiagnostics(after, filePath)}`;
819
1252
  }
820
- } else if (lintDiags.length > 0) {
821
- const fixable = lintDiags.filter((d) => d.fixable);
822
- lspOutput += `\n\n${biomeClient.formatDiagnostics(lintDiags, filePath)}`;
823
- if (fixable.length > 0) {
824
- lspOutput += `\n\n[Biome] ${fixable.length} fixable — enable --autofix-biome flag or run /lens-format`;
1253
+ } else {
1254
+ if (newDiags.length > 0) {
1255
+ const fixable = newDiags.filter((d) => d.fixable);
1256
+ lspOutput += `\n\n[Biome] +${newDiags.length} new issue(s) introduced:\n`;
1257
+ lspOutput += biomeClient.formatDiagnostics(newDiags, filePath);
1258
+ if (fixable.length > 0) lspOutput += `\n\n[Biome] ${fixable.length} fixable — run /lens-format`;
1259
+ }
1260
+ if (fixedRules.length > 0) {
1261
+ lspOutput += `\n\n[Biome] ✓ Fixed: ${fixedRules.join(", ")}`;
1262
+ }
1263
+ if (after.length > 0 && newDiags.length === 0 && fixedRules.length === 0) {
1264
+ // no change — show nothing
1265
+ } else if (after.length > 0) {
1266
+ lspOutput += `\n (${after.length} total)`;
825
1267
  }
826
1268
  }
827
1269
  }