pi-lens 2.0.5 → 2.0.6

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 (2) hide show
  1. package/index.ts +264 -0
  2. package/package.json +1 -1
package/index.ts CHANGED
@@ -580,6 +580,270 @@ export default function (pi: ExtensionAPI) {
580
580
  },
581
581
  });
582
582
 
583
+ // --- Rule action map for lens-booboo-fix ---
584
+ const RULE_ACTIONS: Record<string, { type: "biome" | "agent" | "skip"; note: string }> = {
585
+ "no-var": { type: "biome", note: "auto-fixed by Biome --write" },
586
+ "prefer-template": { type: "biome", note: "auto-fixed by Biome --write" },
587
+ "no-useless-concat": { type: "biome", note: "auto-fixed by Biome --write" },
588
+ "no-lonely-if": { type: "biome", note: "auto-fixed by Biome --write" },
589
+ "prefer-const": { type: "biome", note: "auto-fixed by Biome --write" },
590
+ "empty-catch": { type: "agent", note: "Add this.log('Error: ' + err.message) to the catch block" },
591
+ "silent-failure": { type: "agent", note: "Add this.log('Error: ' + err.message) or rethrow" },
592
+ "no-console-log": { type: "agent", note: "Remove or replace with class logger method" },
593
+ "no-debugger": { type: "agent", note: "Remove the debugger statement" },
594
+ "no-return-await": { type: "agent", note: "Remove the unnecessary `return await`" },
595
+ "nested-ternary": { type: "agent", note: "Extract to if/else or a named variable" },
596
+ "no-throw-string": { type: "agent", note: "Wrap in `new Error(...)` instead of throwing a string" },
597
+ "no-star-imports": { type: "skip", note: "Requires knowing which exports are actually used." },
598
+ "no-as-any": { type: "skip", note: "Replacing `as any` requires knowing the correct type." },
599
+ "no-non-null-assertion": { type: "skip", note: "Each `!` needs nullability analysis in context." },
600
+ "large-class": { type: "skip", note: "Splitting a class requires architectural decisions." },
601
+ "long-method": { type: "skip", note: "Extraction requires understanding the function's purpose." },
602
+ "long-parameter-list": { type: "skip", note: "Redesigning the signature requires an API decision." },
603
+ "no-shadow": { type: "skip", note: "Renaming requires understanding all variable scopes." },
604
+ };
605
+
606
+ pi.registerCommand("lens-booboo-fix", {
607
+ description:
608
+ "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]",
609
+ handler: async (args, ctx) => {
610
+ const targetPath = args.trim() || ctx.cwd || process.cwd();
611
+ const fs = require("node:fs") as typeof import("node:fs");
612
+ const sessionFile = path.join(process.cwd(), ".pi-lens", "fix-session.json");
613
+ const configPath = path.join(
614
+ typeof __dirname !== "undefined" ? __dirname : ".",
615
+ "rules", "ast-grep-rules", ".sgconfig.yml",
616
+ );
617
+
618
+ ctx.ui.notify("🔧 Running booboo fix loop...", "info");
619
+
620
+ // Load session state
621
+ let session: { iteration: number; counts: Record<string, number> } = { iteration: 0, counts: {} };
622
+ try { if (fs.existsSync(sessionFile)) session = JSON.parse(fs.readFileSync(sessionFile, "utf-8")); } catch (e) { dbg(`fix-session load failed: ${e}`); }
623
+ session.iteration++;
624
+ const prevCounts = { ...session.counts };
625
+
626
+ // --- Step 1: Auto-fix with Biome ---
627
+ let biomeRan = false;
628
+ if (!pi.getFlag("no-biome") && biomeClient.isAvailable()) {
629
+ require("node:child_process").spawnSync("npx", [
630
+ "@biomejs/biome", "check", "--write", "--unsafe", targetPath,
631
+ ], { encoding: "utf-8", timeout: 30000, shell: true });
632
+ biomeRan = true;
633
+ }
634
+
635
+ // --- Step 2: Auto-fix with Ruff ---
636
+ let ruffRan = false;
637
+ if (!pi.getFlag("no-ruff") && ruffClient.isAvailable()) {
638
+ require("node:child_process").spawnSync("ruff", ["check", "--fix", targetPath], { encoding: "utf-8", timeout: 15000, shell: true });
639
+ require("node:child_process").spawnSync("ruff", ["format", targetPath], { encoding: "utf-8", timeout: 15000, shell: true });
640
+ ruffRan = true;
641
+ }
642
+
643
+ // --- Step 3: ast-grep scan (after auto-fix) ---
644
+ type AstIssue = { rule: string; file: string; line: number; message: string };
645
+ const astIssues: AstIssue[] = [];
646
+ if (astGrepClient.isAvailable()) {
647
+ const result = require("node:child_process").spawnSync("npx", [
648
+ "sg", "scan", "--config", configPath, "--json",
649
+ "--globs", "!**/*.test.ts", "--globs", "!**/*.spec.ts",
650
+ "--globs", "!**/test-utils.ts", "--globs", "!**/.pi-lens/**",
651
+ targetPath,
652
+ ], { encoding: "utf-8", timeout: 30000, shell: true, maxBuffer: 32 * 1024 * 1024 });
653
+
654
+ const raw = result.stdout?.trim() ?? "";
655
+ // biome-ignore lint/suspicious/noExplicitAny: ast-grep JSON output is untyped
656
+ const items: Record<string, any>[] = raw.startsWith("[")
657
+ ? (() => { try { return JSON.parse(raw); } catch (e) { dbg(`ast-grep parse failed: ${e}`); return []; } })()
658
+ : raw.split("\n").flatMap((l: string) => { try { return [JSON.parse(l)]; } catch { return []; } });
659
+
660
+ for (const item of items) {
661
+ const rule = item.ruleId || item.rule?.title || item.name || "unknown";
662
+ const line = (item.labels?.[0]?.range?.start?.line ?? item.range?.start?.line ?? 0) + 1;
663
+ const relFile = path.relative(targetPath, item.file ?? "").replace(/\\/g, "/");
664
+ astIssues.push({ rule, file: relFile, line, message: item.message ?? rule });
665
+ }
666
+ }
667
+
668
+ // --- Step 4: AI slop from complexity scan ---
669
+ const slopFiles: Array<{ file: string; warnings: string[] }> = [];
670
+ const slopScanDir = (dir: string) => {
671
+ if (!fs.existsSync(dir)) return;
672
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
673
+ const fullPath = path.join(dir, entry.name);
674
+ if (entry.isDirectory()) {
675
+ if (["node_modules", ".git", "dist", "build", ".next", ".pi-lens"].includes(entry.name)) continue;
676
+ slopScanDir(fullPath);
677
+ } else if (complexityClient.isSupportedFile(fullPath) && !/\.(test|spec)\.[jt]sx?$/.test(entry.name)) {
678
+ const metrics = complexityClient.analyzeFile(fullPath);
679
+ if (metrics) {
680
+ const warnings = complexityClient.checkThresholds(metrics).filter(w =>
681
+ w.includes("AI-style") || w.includes("try/catch") || w.includes("single-use") || w.includes("Excessive comments")
682
+ );
683
+ if (warnings.length > 0) {
684
+ slopFiles.push({ file: path.relative(targetPath, fullPath).replace(/\\/g, "/"), warnings });
685
+ }
686
+ }
687
+ }
688
+ }
689
+ };
690
+ slopScanDir(targetPath);
691
+
692
+ // --- Step 5: Remaining Biome lint ---
693
+ const remainingBiome: Array<{ file: string; line: number; rule: string; message: string; fixable: boolean }> = [];
694
+ if (!pi.getFlag("no-biome") && biomeClient.isAvailable()) {
695
+ // Sample a few key files for remaining lint (not whole project — Biome just ran)
696
+ // Just report counts from a quick scan
697
+ const checkResult = require("node:child_process").spawnSync("npx", [
698
+ "@biomejs/biome", "check", "--reporter=json", "--max-diagnostics=50", targetPath,
699
+ ], { encoding: "utf-8", timeout: 20000, shell: true });
700
+ try {
701
+ const data = JSON.parse(checkResult.stdout ?? "{}");
702
+ for (const diag of (data.diagnostics ?? []).slice(0, 20)) {
703
+ if (!diag.category?.startsWith("lint/")) continue;
704
+ const filePath = diag.location?.path?.file ?? "";
705
+ const line = diag.location?.span?.start?.line ?? 0;
706
+ const rule = diag.category ?? "lint";
707
+ const fixable = diag.tags?.includes("fixable") ?? false;
708
+ remainingBiome.push({
709
+ file: path.relative(targetPath, filePath).replace(/\\/g, "/"),
710
+ line: line + 1, rule, message: diag.message ?? rule, fixable,
711
+ });
712
+ }
713
+ } catch (e) { dbg(`biome lint parse failed: ${e}`); }
714
+ }
715
+
716
+ // --- Categorize ast-grep issues ---
717
+ const agentTasks: AstIssue[] = [];
718
+ const skipRules = new Map<string, { note: string; count: number }>();
719
+
720
+ // Group by rule
721
+ const byRule = new Map<string, AstIssue[]>();
722
+ for (const issue of astIssues) {
723
+ const list = byRule.get(issue.rule) ?? [];
724
+ list.push(issue);
725
+ byRule.set(issue.rule, list);
726
+ }
727
+
728
+ for (const [rule, issues] of byRule) {
729
+ const action = RULE_ACTIONS[rule];
730
+ if (!action || action.type === "agent") {
731
+ agentTasks.push(...issues);
732
+ } else if (action.type === "skip") {
733
+ skipRules.set(rule, { note: action.note, count: issues.length });
734
+ }
735
+ // biome type → already handled by Step 1
736
+ }
737
+
738
+ // --- Update session counts ---
739
+ const currentCounts = {
740
+ agent_ast: agentTasks.length,
741
+ biome_lint: remainingBiome.length,
742
+ slop_files: slopFiles.length,
743
+ };
744
+ session.counts = currentCounts;
745
+ fs.mkdirSync(path.dirname(sessionFile), { recursive: true });
746
+ fs.writeFileSync(sessionFile, JSON.stringify(session, null, 2), "utf-8");
747
+
748
+ // --- Check if done ---
749
+ const totalFixable = agentTasks.length + remainingBiome.length + slopFiles.length;
750
+ if (totalFixable === 0) {
751
+ 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.`;
752
+ ctx.ui.notify(msg, "info");
753
+ fs.unlinkSync(sessionFile);
754
+ return;
755
+ }
756
+
757
+ // --- Build delta line ---
758
+ let deltaLine = "";
759
+ if (session.iteration > 1 && Object.keys(prevCounts).length > 0) {
760
+ const prevTotal = Object.values(prevCounts).reduce((a, b) => a + b, 0);
761
+ const currTotal = totalFixable;
762
+ const fixed = prevTotal - currTotal;
763
+ deltaLine = fixed > 0 ? `✅ Fixed ${fixed} issues since last iteration.` : `⚠️ No change since last iteration — check if fixes were applied.`;
764
+ }
765
+
766
+ // --- Build the fix plan message ---
767
+ const lines: string[] = [];
768
+ lines.push(`📋 BOOBOO FIX PLAN — Iteration ${session.iteration} (${totalFixable} fixable items remaining)`);
769
+ if (deltaLine) lines.push(deltaLine);
770
+ lines.push("");
771
+
772
+ // Auto-fix note
773
+ if (biomeRan || ruffRan) {
774
+ lines.push(`⚡ Auto-fixed: ${[biomeRan && "Biome --write --unsafe", ruffRan && "Ruff --fix + format"].filter(Boolean).join(", ")} already ran.`);
775
+ lines.push("");
776
+ }
777
+
778
+ // Agent tasks — grouped by rule
779
+ if (agentTasks.length > 0) {
780
+ lines.push(`## 🔨 Fix these [${agentTasks.length} items]`);
781
+ lines.push("");
782
+ const groupedAgent = new Map<string, AstIssue[]>();
783
+ for (const t of agentTasks) {
784
+ const g = groupedAgent.get(t.rule) ?? [];
785
+ g.push(t);
786
+ groupedAgent.set(t.rule, g);
787
+ }
788
+ for (const [rule, issues] of groupedAgent) {
789
+ const action = RULE_ACTIONS[rule];
790
+ const note = action?.note ?? "Fix this violation";
791
+ lines.push(`### ${rule} (${issues.length})`);
792
+ lines.push(`→ ${note}`);
793
+ for (const issue of issues.slice(0, 15)) {
794
+ lines.push(` - \`${issue.file}:${issue.line}\``);
795
+ }
796
+ if (issues.length > 15) lines.push(` ... and ${issues.length - 15} more`);
797
+ lines.push("");
798
+ }
799
+ }
800
+
801
+ // Remaining Biome lint
802
+ if (remainingBiome.length > 0) {
803
+ lines.push(`## 🟠 Remaining Biome lint [${remainingBiome.length} items]`);
804
+ lines.push("→ Fix each manually or run `/lens-format` from the UI:");
805
+ for (const d of remainingBiome.slice(0, 10)) {
806
+ lines.push(` - \`${d.file}:${d.line}\` [${d.rule}] ${d.message}`);
807
+ }
808
+ if (remainingBiome.length > 10) lines.push(` ... and ${remainingBiome.length - 10} more`);
809
+ lines.push("");
810
+ }
811
+
812
+ // AI slop
813
+ if (slopFiles.length > 0) {
814
+ lines.push(`## 🤖 AI Slop indicators [${slopFiles.length} files]`);
815
+ for (const { file, warnings } of slopFiles.slice(0, 10)) {
816
+ lines.push(` - \`${file}\`: ${warnings.map(w => w.split(" — ")[0]).join(", ")}`);
817
+ }
818
+ if (slopFiles.length > 10) lines.push(` ... and ${slopFiles.length - 10} more`);
819
+ lines.push("");
820
+ }
821
+
822
+ // Skips
823
+ if (skipRules.size > 0) {
824
+ lines.push(`## ⏭️ Skip [${[...skipRules.values()].reduce((a, b) => a + b.count, 0)} items — architectural]`);
825
+ for (const [rule, { note, count }] of skipRules) {
826
+ lines.push(` - **${rule}** (${count}): ${note}`);
827
+ }
828
+ lines.push("");
829
+ }
830
+
831
+ lines.push("---");
832
+ lines.push("Fix the items above, then run `/lens-booboo-fix` again for the next iteration.");
833
+ lines.push("If an item in '🔨 Fix these' is not safe to fix, skip it with one sentence why.");
834
+
835
+ const fixPlan = lines.join("\n");
836
+
837
+ // Save plan for reference
838
+ const planPath = path.join(process.cwd(), ".pi-lens", "fix-plan.md");
839
+ fs.writeFileSync(planPath, `# Fix Plan — Iteration ${session.iteration}\n\n${fixPlan}`, "utf-8");
840
+
841
+ // Notify and inject into conversation
842
+ ctx.ui.notify(`📄 Fix plan saved: ${planPath}`, "info");
843
+ pi.sendUserMessage(fixPlan);
844
+ },
845
+ });
846
+
583
847
  pi.registerCommand("lens-metrics", {
584
848
  description:
585
849
  "Measure complexity metrics for all files and export to report.md. Usage: /lens-metrics [path]",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-lens",
3
- "version": "2.0.5",
3
+ "version": "2.0.6",
4
4
  "description": "Real-time code feedback for pi — TypeScript LSP, Biome, ast-grep, Ruff, TODO scanner, dead code, duplicate detection, type coverage",
5
5
  "repository": {
6
6
  "type": "git",