pi-lens 2.0.7 → 2.0.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/clients/ast-grep-client.test.ts +146 -116
  2. package/clients/ast-grep-client.ts +645 -551
  3. package/clients/biome-client.test.ts +154 -137
  4. package/clients/biome-client.ts +397 -337
  5. package/clients/complexity-client.test.ts +188 -200
  6. package/clients/complexity-client.ts +815 -667
  7. package/clients/dependency-checker.test.ts +55 -55
  8. package/clients/dependency-checker.ts +358 -333
  9. package/clients/go-client.test.ts +121 -111
  10. package/clients/go-client.ts +218 -216
  11. package/clients/jscpd-client.test.ts +132 -132
  12. package/clients/jscpd-client.ts +155 -118
  13. package/clients/knip-client.test.ts +123 -133
  14. package/clients/knip-client.ts +231 -218
  15. package/clients/metrics-client.test.ts +171 -167
  16. package/clients/metrics-client.ts +283 -252
  17. package/clients/ruff-client.test.ts +128 -117
  18. package/clients/ruff-client.ts +300 -269
  19. package/clients/rust-client.test.ts +104 -85
  20. package/clients/rust-client.ts +241 -234
  21. package/clients/subprocess-client.ts +1 -1
  22. package/clients/test-runner-client.test.ts +248 -215
  23. package/clients/test-runner-client.ts +728 -608
  24. package/clients/test-utils.ts +10 -3
  25. package/clients/todo-scanner.test.ts +288 -202
  26. package/clients/todo-scanner.ts +225 -187
  27. package/clients/type-coverage-client.test.ts +119 -119
  28. package/clients/type-coverage-client.ts +142 -115
  29. package/clients/types.ts +28 -28
  30. package/clients/typescript-client.test.ts +99 -93
  31. package/clients/typescript-client.ts +527 -502
  32. package/index.ts +662 -212
  33. package/package.json +1 -1
  34. package/tsconfig.json +12 -12
package/index.ts CHANGED
@@ -10,9 +10,9 @@
10
10
  * Proactive hints before write/edit:
11
11
  * - Warns when target file already has existing violations
12
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)
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
16
  *
17
17
  * On-demand commands:
18
18
  * - /lens-format - Apply Biome formatting
@@ -28,21 +28,21 @@
28
28
  import * as nodeFs from "node:fs";
29
29
  import * as os from "node:os";
30
30
  import * as path from "node:path";
31
- import { Type } from "@sinclair/typebox";
32
31
  import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
33
32
  import { isToolCallEventType } from "@mariozechner/pi-coding-agent";
33
+ import { Type } from "@sinclair/typebox";
34
34
  import { AstGrepClient } from "./clients/ast-grep-client.js";
35
35
  import { BiomeClient } from "./clients/biome-client.js";
36
+ import { ComplexityClient } from "./clients/complexity-client.js";
36
37
  import { DependencyChecker } from "./clients/dependency-checker.js";
38
+ import { GoClient } from "./clients/go-client.js";
37
39
  import { JscpdClient } from "./clients/jscpd-client.js";
38
40
  import { KnipClient } from "./clients/knip-client.js";
39
- import { RuffClient } from "./clients/ruff-client.js";
40
- import { TodoScanner } from "./clients/todo-scanner.js";
41
- import { ComplexityClient } from "./clients/complexity-client.js";
42
- import { GoClient } from "./clients/go-client.js";
43
41
  import { MetricsClient } from "./clients/metrics-client.js";
42
+ import { RuffClient } from "./clients/ruff-client.js";
44
43
  import { RustClient } from "./clients/rust-client.js";
45
44
  import { TestRunnerClient } from "./clients/test-runner-client.js";
45
+ import { TodoScanner } from "./clients/todo-scanner.js";
46
46
  import { TypeCoverageClient } from "./clients/type-coverage-client.js";
47
47
  import { TypeScriptClient } from "./clients/typescript-client.js";
48
48
 
@@ -67,7 +67,6 @@ function log(msg: string) {
67
67
  // --- Extension ---
68
68
 
69
69
  export default function (pi: ExtensionAPI) {
70
-
71
70
  const tsClient = new TypeScriptClient();
72
71
  const astGrepClient = new AstGrepClient();
73
72
  const ruffClient = new RuffClient();
@@ -173,7 +172,8 @@ export default function (pi: ExtensionAPI) {
173
172
  });
174
173
 
175
174
  pi.registerCommand("lens-dead-code", {
176
- description: "Check for unused exports, files, and dependencies. Usage: /lens-dead-code [path]",
175
+ description:
176
+ "Check for unused exports, files, and dependencies. Usage: /lens-dead-code [path]",
177
177
  handler: async (args, ctx) => {
178
178
  if (!knipClient.isAvailable()) {
179
179
  ctx.ui.notify("Knip not installed. Run: npm install -D knip", "error");
@@ -224,7 +224,10 @@ export default function (pi: ExtensionAPI) {
224
224
 
225
225
  const parts: string[] = [];
226
226
  const fullReport: string[] = [];
227
- const timestamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
227
+ const timestamp = new Date()
228
+ .toISOString()
229
+ .replace(/[:.]/g, "-")
230
+ .slice(0, 19);
228
231
  const reviewDir = path.join(process.cwd(), ".pi-lens", "reviews");
229
232
 
230
233
  // Part 1: Design smells via ast-grep
@@ -237,46 +240,70 @@ export default function (pi: ExtensionAPI) {
237
240
  );
238
241
 
239
242
  try {
240
- const result = require("node:child_process").spawnSync("npx", [
241
- "sg",
242
- "scan",
243
- "--config", configPath,
244
- "--json",
245
- "--globs", "!**/*.test.ts",
246
- "--globs", "!**/*.spec.ts",
247
- "--globs", "!**/test-utils.ts",
248
- "--globs", "!**/.pi-lens/**",
249
- targetPath,
250
- ], {
251
- encoding: "utf-8",
252
- timeout: 30000,
253
- shell: true,
254
- maxBuffer: 32 * 1024 * 1024, // 32MB
255
- });
243
+ const result = require("node:child_process").spawnSync(
244
+ "npx",
245
+ [
246
+ "sg",
247
+ "scan",
248
+ "--config",
249
+ configPath,
250
+ "--json",
251
+ "--globs",
252
+ "!**/*.test.ts",
253
+ "--globs",
254
+ "!**/*.spec.ts",
255
+ "--globs",
256
+ "!**/test-utils.ts",
257
+ "--globs",
258
+ "!**/.pi-lens/**",
259
+ targetPath,
260
+ ],
261
+ {
262
+ encoding: "utf-8",
263
+ timeout: 30000,
264
+ shell: true,
265
+ maxBuffer: 32 * 1024 * 1024, // 32MB
266
+ },
267
+ );
256
268
 
257
269
  const output = result.stdout || result.stderr || "";
258
270
  if (output.trim() && result.status !== undefined) {
259
- let issues: Array<{line: number; rule: string; message: string}> = [];
271
+ const issues: Array<{
272
+ line: number;
273
+ rule: string;
274
+ message: string;
275
+ }> = [];
260
276
 
261
277
  // ast-grep outputs either a JSON array or NDJSON (one object per line)
262
278
  // biome-ignore lint/suspicious/noExplicitAny: ast-grep JSON output is untyped
263
279
  const parseItems = (raw: string): Record<string, any>[] => {
264
280
  const trimmed = raw.trim();
265
281
  if (trimmed.startsWith("[")) {
266
- try { return JSON.parse(trimmed); } catch { return []; }
282
+ try {
283
+ return JSON.parse(trimmed);
284
+ } catch {
285
+ return [];
286
+ }
267
287
  }
268
288
  return raw.split("\n").flatMap((l: string) => {
269
- try { return [JSON.parse(l)]; } catch { return []; }
289
+ try {
290
+ return [JSON.parse(l)];
291
+ } catch {
292
+ return [];
293
+ }
270
294
  });
271
295
  };
272
296
 
273
297
  for (const item of parseItems(output)) {
274
- const ruleId = item.ruleId || item.rule?.title || item.name || "unknown";
298
+ const ruleId =
299
+ item.ruleId || item.rule?.title || item.name || "unknown";
275
300
  const ruleDesc = astGrepClient.getRuleDescription?.(ruleId);
276
301
  const message = ruleDesc?.message || item.message || ruleId;
277
- const lineNum = item.labels?.[0]?.range?.start?.line ||
302
+ const lineNum =
303
+ item.labels?.[0]?.range?.start?.line ||
278
304
  item.spans?.[0]?.range?.start?.line ||
279
- item.range?.start?.line || 0;
305
+ item.range?.start?.line ||
306
+ 0;
280
307
 
281
308
  issues.push({
282
309
  line: lineNum + 1,
@@ -298,26 +325,30 @@ export default function (pi: ExtensionAPI) {
298
325
 
299
326
  // Full report for file
300
327
  let fullSection = `## ast-grep (Structural Issues)\n\n**${issues.length} issue(s) found**\n\n`;
301
- fullSection += "| Line | Rule | Message |\n|------|------|--------|\n";
328
+ fullSection +=
329
+ "| Line | Rule | Message |\n|------|------|--------|\n";
302
330
  for (const issue of issues) {
303
331
  fullSection += `| ${issue.line} | ${issue.rule} | ${issue.message} |\n`;
304
332
  }
305
333
  fullReport.push(fullSection);
306
334
  }
307
335
  }
308
- } catch (err: any) {
336
+ } catch (_err: any) {
309
337
  // ast-grep scan failed, skip
310
338
  }
311
339
  }
312
340
 
313
341
  // Part 2: Similar functions (advanced duplicate detection)
314
342
  if (astGrepClient.isAvailable()) {
315
- const similarGroups = await astGrepClient.findSimilarFunctions(targetPath, "typescript");
343
+ const similarGroups = await astGrepClient.findSimilarFunctions(
344
+ targetPath,
345
+ "typescript",
346
+ );
316
347
  if (similarGroups.length > 0) {
317
348
  // UI summary (truncated)
318
349
  let report = `[Similar Functions] ${similarGroups.length} group(s) of structurally similar functions:\n`;
319
350
  for (const group of similarGroups.slice(0, 5)) {
320
- report += ` Pattern: ${group.functions.map(f => f.name).join(", ")}\n`;
351
+ report += ` Pattern: ${group.functions.map((f) => f.name).join(", ")}\n`;
321
352
  for (const fn of group.functions) {
322
353
  report += ` ${fn.name} (${path.basename(fn.file)}:${fn.line})\n`;
323
354
  }
@@ -330,8 +361,9 @@ export default function (pi: ExtensionAPI) {
330
361
  // Full report for file
331
362
  let fullSection = `## Similar Functions\n\n**${similarGroups.length} group(s) of structurally similar functions**\n\n`;
332
363
  for (const group of similarGroups) {
333
- fullSection += `### Pattern: ${group.functions.map(f => f.name).join(", ")}\n\n`;
334
- fullSection += "| Function | File | Line |\n|----------|------|------|\n";
364
+ fullSection += `### Pattern: ${group.functions.map((f) => f.name).join(", ")}\n\n`;
365
+ fullSection +=
366
+ "| Function | File | Line |\n|----------|------|------|\n";
335
367
  for (const fn of group.functions) {
336
368
  fullSection += `| ${fn.name} | ${fn.file} | ${fn.line} |\n`;
337
369
  }
@@ -342,17 +374,30 @@ export default function (pi: ExtensionAPI) {
342
374
  }
343
375
 
344
376
  // Part 3: Complexity metrics + AI slop detection
345
- const results: import("./clients/complexity-client.js").FileComplexity[] = [];
377
+ const results: import("./clients/complexity-client.js").FileComplexity[] =
378
+ [];
346
379
  const aiSlopIssues: string[] = [];
347
380
 
348
381
  const scanDir = (dir: string) => {
349
382
  if (!require("node:fs").existsSync(dir)) return;
350
- const entries = require("node:fs").readdirSync(dir, { withFileTypes: true });
383
+ const entries = require("node:fs").readdirSync(dir, {
384
+ withFileTypes: true,
385
+ });
351
386
 
352
387
  for (const entry of entries) {
353
388
  const fullPath = path.join(dir, entry.name);
354
389
  if (entry.isDirectory()) {
355
- if (["node_modules", ".git", "dist", "build", ".next", ".pi-lens"].includes(entry.name)) continue;
390
+ if (
391
+ [
392
+ "node_modules",
393
+ ".git",
394
+ "dist",
395
+ "build",
396
+ ".next",
397
+ ".pi-lens",
398
+ ].includes(entry.name)
399
+ )
400
+ continue;
356
401
  scanDir(fullPath);
357
402
  } else if (complexityClient.isSupportedFile(fullPath)) {
358
403
  const metrics = complexityClient.analyzeFile(fullPath);
@@ -377,15 +422,27 @@ export default function (pi: ExtensionAPI) {
377
422
  scanDir(targetPath);
378
423
 
379
424
  if (results.length > 0) {
380
- const avgMI = results.reduce((a, b) => a + b.maintainabilityIndex, 0) / results.length;
381
- const avgCognitive = results.reduce((a, b) => a + b.cognitiveComplexity, 0) / results.length;
382
- const avgCyclomatic = results.reduce((a, b) => a + b.cyclomaticComplexity, 0) / results.length;
383
- const maxNesting = Math.max(...results.map(r => r.maxNestingDepth));
384
- const maxCognitive = Math.max(...results.map(r => r.cognitiveComplexity));
385
- const minMI = Math.min(...results.map(r => r.maintainabilityIndex));
425
+ const avgMI =
426
+ results.reduce((a, b) => a + b.maintainabilityIndex, 0) /
427
+ results.length;
428
+ const avgCognitive =
429
+ results.reduce((a, b) => a + b.cognitiveComplexity, 0) /
430
+ results.length;
431
+ const avgCyclomatic =
432
+ results.reduce((a, b) => a + b.cyclomaticComplexity, 0) /
433
+ results.length;
434
+ const maxNesting = Math.max(...results.map((r) => r.maxNestingDepth));
435
+ const maxCognitive = Math.max(
436
+ ...results.map((r) => r.cognitiveComplexity),
437
+ );
438
+ const minMI = Math.min(...results.map((r) => r.maintainabilityIndex));
386
439
 
387
- const lowMI = results.filter(r => r.maintainabilityIndex < 60).sort((a, b) => a.maintainabilityIndex - b.maintainabilityIndex);
388
- const highCognitive = results.filter(r => r.cognitiveComplexity > 20).sort((a, b) => b.cognitiveComplexity - a.cognitiveComplexity);
440
+ const lowMI = results
441
+ .filter((r) => r.maintainabilityIndex < 60)
442
+ .sort((a, b) => a.maintainabilityIndex - b.maintainabilityIndex);
443
+ const highCognitive = results
444
+ .filter((r) => r.cognitiveComplexity > 20)
445
+ .sort((a, b) => b.cognitiveComplexity - a.cognitiveComplexity);
389
446
 
390
447
  // UI summary (truncated)
391
448
  let summary = `[Complexity] ${results.length} file(s) scanned\n`;
@@ -396,7 +453,8 @@ export default function (pi: ExtensionAPI) {
396
453
  for (const f of lowMI.slice(0, 5)) {
397
454
  summary += ` ✗ ${f.filePath}: MI ${f.maintainabilityIndex.toFixed(1)}\n`;
398
455
  }
399
- if (lowMI.length > 5) summary += ` ... and ${lowMI.length - 5} more\n`;
456
+ if (lowMI.length > 5)
457
+ summary += ` ... and ${lowMI.length - 5} more\n`;
400
458
  }
401
459
 
402
460
  if (highCognitive.length > 0) {
@@ -404,7 +462,8 @@ export default function (pi: ExtensionAPI) {
404
462
  for (const f of highCognitive.slice(0, 5)) {
405
463
  summary += ` ⚠ ${f.filePath}: ${f.cognitiveComplexity}\n`;
406
464
  }
407
- if (highCognitive.length > 5) summary += ` ... and ${highCognitive.length - 5} more\n`;
465
+ if (highCognitive.length > 5)
466
+ summary += ` ... and ${highCognitive.length - 5} more\n`;
408
467
  }
409
468
 
410
469
  // Add AI slop issues
@@ -450,7 +509,9 @@ export default function (pi: ExtensionAPI) {
450
509
  fullSection += `### All Files\n\n`;
451
510
  fullSection += `| File | MI | Cognitive | Cyclomatic | Nesting | Entropy |\n`;
452
511
  fullSection += `|------|-----|-----------|------------|---------|--------|\n`;
453
- for (const f of results.sort((a, b) => a.maintainabilityIndex - b.maintainabilityIndex)) {
512
+ for (const f of results.sort(
513
+ (a, b) => a.maintainabilityIndex - b.maintainabilityIndex,
514
+ )) {
454
515
  fullSection += `| ${f.filePath} | ${f.maintainabilityIndex.toFixed(1)} | ${f.cognitiveComplexity} | ${f.cyclomaticComplexity} | ${f.maxNestingDepth} | ${f.codeEntropy.toFixed(2)} |\n`;
455
516
  }
456
517
  fullSection += "\n";
@@ -573,34 +634,82 @@ export default function (pi: ExtensionAPI) {
573
634
  fs.writeFileSync(reportPath, mdReport, "utf-8");
574
635
 
575
636
  if (parts.length === 0) {
576
- ctx.ui.notify("✓ Code review clean — saved to .pi-lens/reviews/", "info");
637
+ ctx.ui.notify(
638
+ "✓ Code review clean — saved to .pi-lens/reviews/",
639
+ "info",
640
+ );
577
641
  } else {
578
- ctx.ui.notify(`${parts.join("\n\n")}\n\n📄 Full report: ${reportPath}`, "info");
642
+ ctx.ui.notify(
643
+ `${parts.join("\n\n")}\n\n📄 Full report: ${reportPath}`,
644
+ "info",
645
+ );
579
646
  }
580
647
  },
581
648
  });
582
649
 
583
650
  // --- 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." },
651
+ const RULE_ACTIONS: Record<
652
+ string,
653
+ { type: "biome" | "agent" | "skip"; note: string }
654
+ > = {
655
+ "no-var": { type: "biome", note: "auto-fixed by Biome --write" },
656
+ "prefer-template": { type: "biome", note: "auto-fixed by Biome --write" },
657
+ "no-useless-concat": { type: "biome", note: "auto-fixed by Biome --write" },
658
+ "no-lonely-if": { type: "biome", note: "auto-fixed by Biome --write" },
659
+ "prefer-const": { type: "biome", note: "auto-fixed by Biome --write" },
660
+ "empty-catch": {
661
+ type: "agent",
662
+ note: "Add this.log('Error: ' + err.message) to the catch block",
663
+ },
664
+ "silent-failure": {
665
+ type: "agent",
666
+ note: "Add this.log('Error: ' + err.message) or rethrow",
667
+ },
668
+ "no-console-log": {
669
+ type: "agent",
670
+ note: "Remove or replace with class logger method",
671
+ },
672
+ "no-debugger": { type: "agent", note: "Remove the debugger statement" },
673
+ "no-return-await": {
674
+ type: "agent",
675
+ note: "Remove the unnecessary `return await`",
676
+ },
677
+ "nested-ternary": {
678
+ type: "agent",
679
+ note: "Extract to if/else or a named variable",
680
+ },
681
+ "no-throw-string": {
682
+ type: "agent",
683
+ note: "Wrap in `new Error(...)` instead of throwing a string",
684
+ },
685
+ "no-star-imports": {
686
+ type: "skip",
687
+ note: "Requires knowing which exports are actually used.",
688
+ },
689
+ "no-as-any": {
690
+ type: "skip",
691
+ note: "Replacing `as any` requires knowing the correct type.",
692
+ },
693
+ "no-non-null-assertion": {
694
+ type: "skip",
695
+ note: "Each `!` needs nullability analysis in context.",
696
+ },
697
+ "large-class": {
698
+ type: "skip",
699
+ note: "Splitting a class requires architectural decisions.",
700
+ },
701
+ "long-method": {
702
+ type: "skip",
703
+ note: "Extraction requires understanding the function's purpose.",
704
+ },
705
+ "long-parameter-list": {
706
+ type: "skip",
707
+ note: "Redesigning the signature requires an API decision.",
708
+ },
709
+ "no-shadow": {
710
+ type: "skip",
711
+ note: "Renaming requires understanding all variable scopes.",
712
+ },
604
713
  };
605
714
 
606
715
  pi.registerCommand("lens-booboo-fix", {
@@ -609,10 +718,16 @@ export default function (pi: ExtensionAPI) {
609
718
  handler: async (args, ctx) => {
610
719
  const targetPath = args.trim() || ctx.cwd || process.cwd();
611
720
  const fs = require("node:fs") as typeof import("node:fs");
612
- const sessionFile = path.join(process.cwd(), ".pi-lens", "fix-session.json");
721
+ const sessionFile = path.join(
722
+ process.cwd(),
723
+ ".pi-lens",
724
+ "fix-session.json",
725
+ );
613
726
  const configPath = path.join(
614
727
  typeof __dirname !== "undefined" ? __dirname : ".",
615
- "rules", "ast-grep-rules", ".sgconfig.yml",
728
+ "rules",
729
+ "ast-grep-rules",
730
+ ".sgconfig.yml",
616
731
  );
617
732
 
618
733
  ctx.ui.notify("🔧 Running booboo fix loop...", "info");
@@ -620,13 +735,24 @@ export default function (pi: ExtensionAPI) {
620
735
  const MAX_ITERATIONS = 3;
621
736
 
622
737
  // Load session state
623
- let session: { iteration: number; counts: Record<string, number> } = { iteration: 0, counts: {} };
624
- try { if (fs.existsSync(sessionFile)) session = JSON.parse(fs.readFileSync(sessionFile, "utf-8")); } catch (e) { dbg(`fix-session load failed: ${e}`); }
738
+ let session: { iteration: number; counts: Record<string, number> } = {
739
+ iteration: 0,
740
+ counts: {},
741
+ };
742
+ try {
743
+ if (fs.existsSync(sessionFile))
744
+ session = JSON.parse(fs.readFileSync(sessionFile, "utf-8"));
745
+ } catch (e) {
746
+ dbg(`fix-session load failed: ${e}`);
747
+ }
625
748
  session.iteration++;
626
749
 
627
750
  // Hard stop at max iterations
628
751
  if (session.iteration > MAX_ITERATIONS) {
629
- ctx.ui.notify(`⛔ Max iterations (${MAX_ITERATIONS}) reached. Run /lens-booboo for full remaining report, or delete .pi-lens/fix-session.json to reset.`, "warning");
752
+ ctx.ui.notify(
753
+ `⛔ Max iterations (${MAX_ITERATIONS}) reached. Run /lens-booboo for full remaining report, or delete .pi-lens/fix-session.json to reset.`,
754
+ "warning",
755
+ );
630
756
  return;
631
757
  }
632
758
  const prevCounts = { ...session.counts };
@@ -634,42 +760,100 @@ export default function (pi: ExtensionAPI) {
634
760
  // --- Step 1: Auto-fix with Biome ---
635
761
  let biomeRan = false;
636
762
  if (!pi.getFlag("no-biome") && biomeClient.isAvailable()) {
637
- require("node:child_process").spawnSync("npx", [
638
- "@biomejs/biome", "check", "--write", "--unsafe", targetPath,
639
- ], { encoding: "utf-8", timeout: 30000, shell: true });
763
+ require("node:child_process").spawnSync(
764
+ "npx",
765
+ ["@biomejs/biome", "check", "--write", "--unsafe", targetPath],
766
+ { encoding: "utf-8", timeout: 30000, shell: true },
767
+ );
640
768
  biomeRan = true;
641
769
  }
642
770
 
643
771
  // --- Step 2: Auto-fix with Ruff ---
644
772
  let ruffRan = false;
645
773
  if (!pi.getFlag("no-ruff") && ruffClient.isAvailable()) {
646
- require("node:child_process").spawnSync("ruff", ["check", "--fix", targetPath], { encoding: "utf-8", timeout: 15000, shell: true });
647
- require("node:child_process").spawnSync("ruff", ["format", targetPath], { encoding: "utf-8", timeout: 15000, shell: true });
774
+ require("node:child_process").spawnSync(
775
+ "ruff",
776
+ ["check", "--fix", targetPath],
777
+ { encoding: "utf-8", timeout: 15000, shell: true },
778
+ );
779
+ require("node:child_process").spawnSync(
780
+ "ruff",
781
+ ["format", targetPath],
782
+ { encoding: "utf-8", timeout: 15000, shell: true },
783
+ );
648
784
  ruffRan = true;
649
785
  }
650
786
 
651
787
  // --- Step 3: ast-grep scan (after auto-fix) ---
652
- type AstIssue = { rule: string; file: string; line: number; message: string };
788
+ type AstIssue = {
789
+ rule: string;
790
+ file: string;
791
+ line: number;
792
+ message: string;
793
+ };
653
794
  const astIssues: AstIssue[] = [];
654
795
  if (astGrepClient.isAvailable()) {
655
- const result = require("node:child_process").spawnSync("npx", [
656
- "sg", "scan", "--config", configPath, "--json",
657
- "--globs", "!**/*.test.ts", "--globs", "!**/*.spec.ts",
658
- "--globs", "!**/test-utils.ts", "--globs", "!**/.pi-lens/**",
659
- targetPath,
660
- ], { encoding: "utf-8", timeout: 30000, shell: true, maxBuffer: 32 * 1024 * 1024 });
796
+ const result = require("node:child_process").spawnSync(
797
+ "npx",
798
+ [
799
+ "sg",
800
+ "scan",
801
+ "--config",
802
+ configPath,
803
+ "--json",
804
+ "--globs",
805
+ "!**/*.test.ts",
806
+ "--globs",
807
+ "!**/*.spec.ts",
808
+ "--globs",
809
+ "!**/test-utils.ts",
810
+ "--globs",
811
+ "!**/.pi-lens/**",
812
+ targetPath,
813
+ ],
814
+ {
815
+ encoding: "utf-8",
816
+ timeout: 30000,
817
+ shell: true,
818
+ maxBuffer: 32 * 1024 * 1024,
819
+ },
820
+ );
661
821
 
662
822
  const raw = result.stdout?.trim() ?? "";
663
823
  // biome-ignore lint/suspicious/noExplicitAny: ast-grep JSON output is untyped
664
824
  const items: Record<string, any>[] = raw.startsWith("[")
665
- ? (() => { try { return JSON.parse(raw); } catch (e) { dbg(`ast-grep parse failed: ${e}`); return []; } })()
666
- : raw.split("\n").flatMap((l: string) => { try { return [JSON.parse(l)]; } catch { return []; } });
825
+ ? (() => {
826
+ try {
827
+ return JSON.parse(raw);
828
+ } catch (e) {
829
+ dbg(`ast-grep parse failed: ${e}`);
830
+ return [];
831
+ }
832
+ })()
833
+ : raw.split("\n").flatMap((l: string) => {
834
+ try {
835
+ return [JSON.parse(l)];
836
+ } catch {
837
+ return [];
838
+ }
839
+ });
667
840
 
668
841
  for (const item of items) {
669
- const rule = item.ruleId || item.rule?.title || item.name || "unknown";
670
- const line = (item.labels?.[0]?.range?.start?.line ?? item.range?.start?.line ?? 0) + 1;
671
- const relFile = path.relative(targetPath, item.file ?? "").replace(/\\/g, "/");
672
- astIssues.push({ rule, file: relFile, line, message: item.message ?? rule });
842
+ const rule =
843
+ item.ruleId || item.rule?.title || item.name || "unknown";
844
+ const line =
845
+ (item.labels?.[0]?.range?.start?.line ??
846
+ item.range?.start?.line ??
847
+ 0) + 1;
848
+ const relFile = path
849
+ .relative(targetPath, item.file ?? "")
850
+ .replace(/\\/g, "/");
851
+ astIssues.push({
852
+ rule,
853
+ file: relFile,
854
+ line,
855
+ message: item.message ?? rule,
856
+ });
673
857
  }
674
858
  }
675
859
 
@@ -680,16 +864,38 @@ export default function (pi: ExtensionAPI) {
680
864
  for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
681
865
  const fullPath = path.join(dir, entry.name);
682
866
  if (entry.isDirectory()) {
683
- if (["node_modules", ".git", "dist", "build", ".next", ".pi-lens"].includes(entry.name)) continue;
867
+ if (
868
+ [
869
+ "node_modules",
870
+ ".git",
871
+ "dist",
872
+ "build",
873
+ ".next",
874
+ ".pi-lens",
875
+ ].includes(entry.name)
876
+ )
877
+ continue;
684
878
  slopScanDir(fullPath);
685
- } else if (complexityClient.isSupportedFile(fullPath) && !/\.(test|spec)\.[jt]sx?$/.test(entry.name)) {
879
+ } else if (
880
+ complexityClient.isSupportedFile(fullPath) &&
881
+ !/\.(test|spec)\.[jt]sx?$/.test(entry.name)
882
+ ) {
686
883
  const metrics = complexityClient.analyzeFile(fullPath);
687
884
  if (metrics) {
688
- const warnings = complexityClient.checkThresholds(metrics).filter(w =>
689
- w.includes("AI-style") || w.includes("try/catch") || w.includes("single-use") || w.includes("Excessive comments")
690
- );
885
+ const warnings = complexityClient
886
+ .checkThresholds(metrics)
887
+ .filter(
888
+ (w) =>
889
+ w.includes("AI-style") ||
890
+ w.includes("try/catch") ||
891
+ w.includes("single-use") ||
892
+ w.includes("Excessive comments"),
893
+ );
691
894
  if (warnings.length > 0) {
692
- slopFiles.push({ file: path.relative(targetPath, fullPath).replace(/\\/g, "/"), warnings });
895
+ slopFiles.push({
896
+ file: path.relative(targetPath, fullPath).replace(/\\/g, "/"),
897
+ warnings,
898
+ });
693
899
  }
694
900
  }
695
901
  }
@@ -698,13 +904,27 @@ export default function (pi: ExtensionAPI) {
698
904
  slopScanDir(targetPath);
699
905
 
700
906
  // --- Step 5: Remaining Biome lint ---
701
- const remainingBiome: Array<{ file: string; line: number; rule: string; message: string; fixable: boolean }> = [];
907
+ const remainingBiome: Array<{
908
+ file: string;
909
+ line: number;
910
+ rule: string;
911
+ message: string;
912
+ fixable: boolean;
913
+ }> = [];
702
914
  if (!pi.getFlag("no-biome") && biomeClient.isAvailable()) {
703
915
  // Sample a few key files for remaining lint (not whole project — Biome just ran)
704
916
  // Just report counts from a quick scan
705
- const checkResult = require("node:child_process").spawnSync("npx", [
706
- "@biomejs/biome", "check", "--reporter=json", "--max-diagnostics=50", targetPath,
707
- ], { encoding: "utf-8", timeout: 20000, shell: true });
917
+ const checkResult = require("node:child_process").spawnSync(
918
+ "npx",
919
+ [
920
+ "@biomejs/biome",
921
+ "check",
922
+ "--reporter=json",
923
+ "--max-diagnostics=50",
924
+ targetPath,
925
+ ],
926
+ { encoding: "utf-8", timeout: 20000, shell: true },
927
+ );
708
928
  try {
709
929
  const data = JSON.parse(checkResult.stdout ?? "{}");
710
930
  for (const diag of (data.diagnostics ?? []).slice(0, 20)) {
@@ -715,10 +935,15 @@ export default function (pi: ExtensionAPI) {
715
935
  const fixable = diag.tags?.includes("fixable") ?? false;
716
936
  remainingBiome.push({
717
937
  file: path.relative(targetPath, filePath).replace(/\\/g, "/"),
718
- line: line + 1, rule, message: diag.message ?? rule, fixable,
938
+ line: line + 1,
939
+ rule,
940
+ message: diag.message ?? rule,
941
+ fixable,
719
942
  });
720
943
  }
721
- } catch (e) { dbg(`biome lint parse failed: ${e}`); }
944
+ } catch (e) {
945
+ dbg(`biome lint parse failed: ${e}`);
946
+ }
722
947
  }
723
948
 
724
949
  // --- Categorize ast-grep issues ---
@@ -754,7 +979,8 @@ export default function (pi: ExtensionAPI) {
754
979
  fs.writeFileSync(sessionFile, JSON.stringify(session, null, 2), "utf-8");
755
980
 
756
981
  // --- Check if done ---
757
- const totalFixable = agentTasks.length + remainingBiome.length + slopFiles.length;
982
+ const totalFixable =
983
+ agentTasks.length + remainingBiome.length + slopFiles.length;
758
984
  if (totalFixable === 0) {
759
985
  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.`;
760
986
  ctx.ui.notify(msg, "info");
@@ -768,18 +994,25 @@ export default function (pi: ExtensionAPI) {
768
994
  const prevTotal = Object.values(prevCounts).reduce((a, b) => a + b, 0);
769
995
  const currTotal = totalFixable;
770
996
  const fixed = prevTotal - currTotal;
771
- deltaLine = fixed > 0 ? `✅ Fixed ${fixed} issues since last iteration.` : `⚠️ No change since last iteration — check if fixes were applied.`;
997
+ deltaLine =
998
+ fixed > 0
999
+ ? `✅ Fixed ${fixed} issues since last iteration.`
1000
+ : `⚠️ No change since last iteration — check if fixes were applied.`;
772
1001
  }
773
1002
 
774
1003
  // --- Build the fix plan message ---
775
1004
  const lines: string[] = [];
776
- lines.push(`📋 BOOBOO FIX PLAN — Iteration ${session.iteration}/${MAX_ITERATIONS} (${totalFixable} fixable items remaining)`);
1005
+ lines.push(
1006
+ `📋 BOOBOO FIX PLAN — Iteration ${session.iteration}/${MAX_ITERATIONS} (${totalFixable} fixable items remaining)`,
1007
+ );
777
1008
  if (deltaLine) lines.push(deltaLine);
778
1009
  lines.push("");
779
1010
 
780
1011
  // Auto-fix note
781
1012
  if (biomeRan || ruffRan) {
782
- lines.push(`⚡ Auto-fixed: ${[biomeRan && "Biome --write --unsafe", ruffRan && "Ruff --fix + format"].filter(Boolean).join(", ")} already ran.`);
1013
+ lines.push(
1014
+ `⚡ Auto-fixed: ${[biomeRan && "Biome --write --unsafe", ruffRan && "Ruff --fix + format"].filter(Boolean).join(", ")} already ran.`,
1015
+ );
783
1016
  lines.push("");
784
1017
  }
785
1018
 
@@ -801,19 +1034,23 @@ export default function (pi: ExtensionAPI) {
801
1034
  for (const issue of issues.slice(0, 15)) {
802
1035
  lines.push(` - \`${issue.file}:${issue.line}\``);
803
1036
  }
804
- if (issues.length > 15) lines.push(` ... and ${issues.length - 15} more`);
1037
+ if (issues.length > 15)
1038
+ lines.push(` ... and ${issues.length - 15} more`);
805
1039
  lines.push("");
806
1040
  }
807
1041
  }
808
1042
 
809
1043
  // Remaining Biome lint
810
1044
  if (remainingBiome.length > 0) {
811
- lines.push(`## 🟠 Remaining Biome lint [${remainingBiome.length} items]`);
1045
+ lines.push(
1046
+ `## 🟠 Remaining Biome lint [${remainingBiome.length} items]`,
1047
+ );
812
1048
  lines.push("→ Fix each manually or run `/lens-format` from the UI:");
813
1049
  for (const d of remainingBiome.slice(0, 10)) {
814
1050
  lines.push(` - \`${d.file}:${d.line}\` [${d.rule}] ${d.message}`);
815
1051
  }
816
- if (remainingBiome.length > 10) lines.push(` ... and ${remainingBiome.length - 10} more`);
1052
+ if (remainingBiome.length > 10)
1053
+ lines.push(` ... and ${remainingBiome.length - 10} more`);
817
1054
  lines.push("");
818
1055
  }
819
1056
 
@@ -821,15 +1058,20 @@ export default function (pi: ExtensionAPI) {
821
1058
  if (slopFiles.length > 0) {
822
1059
  lines.push(`## 🤖 AI Slop indicators [${slopFiles.length} files]`);
823
1060
  for (const { file, warnings } of slopFiles.slice(0, 10)) {
824
- lines.push(` - \`${file}\`: ${warnings.map(w => w.split(" — ")[0]).join(", ")}`);
1061
+ lines.push(
1062
+ ` - \`${file}\`: ${warnings.map((w) => w.split(" — ")[0]).join(", ")}`,
1063
+ );
825
1064
  }
826
- if (slopFiles.length > 10) lines.push(` ... and ${slopFiles.length - 10} more`);
1065
+ if (slopFiles.length > 10)
1066
+ lines.push(` ... and ${slopFiles.length - 10} more`);
827
1067
  lines.push("");
828
1068
  }
829
1069
 
830
1070
  // Skips
831
1071
  if (skipRules.size > 0) {
832
- lines.push(`## ⏭️ Skip [${[...skipRules.values()].reduce((a, b) => a + b.count, 0)} items — architectural]`);
1072
+ lines.push(
1073
+ `## ⏭️ Skip [${[...skipRules.values()].reduce((a, b) => a + b.count, 0)} items — architectural]`,
1074
+ );
833
1075
  for (const [rule, { note, count }] of skipRules) {
834
1076
  lines.push(` - **${rule}** (${count}): ${note}`);
835
1077
  }
@@ -837,18 +1079,28 @@ export default function (pi: ExtensionAPI) {
837
1079
  }
838
1080
 
839
1081
  lines.push("---");
840
- lines.push("Fix the items above, then run `/lens-booboo-fix` again for the next iteration.");
841
- lines.push("If an item in '🔨 Fix these' is not safe to fix, skip it with one sentence why.");
1082
+ lines.push(
1083
+ "Fix the items above, then run `/lens-booboo-fix` again for the next iteration.",
1084
+ );
1085
+ lines.push(
1086
+ "If an item in '🔨 Fix these' is not safe to fix, skip it with one sentence why.",
1087
+ );
842
1088
 
843
1089
  const fixPlan = lines.join("\n");
844
1090
 
845
1091
  // Save plan for reference
846
1092
  const planPath = path.join(process.cwd(), ".pi-lens", "fix-plan.md");
847
- fs.writeFileSync(planPath, `# Fix Plan — Iteration ${session.iteration}\n\n${fixPlan}`, "utf-8");
1093
+ fs.writeFileSync(
1094
+ planPath,
1095
+ `# Fix Plan — Iteration ${session.iteration}\n\n${fixPlan}`,
1096
+ "utf-8",
1097
+ );
848
1098
 
849
1099
  // Notify and inject into conversation
850
1100
  ctx.ui.notify(`📄 Fix plan saved: ${planPath}`, "info");
851
- pi.sendUserMessage(fixPlan);
1101
+ // Use steer delivery — agent is busy processing this tool call
1102
+ // steer interrupts mid-processing with the next fix plan
1103
+ pi.sendUserMessage(fixPlan, { deliverAs: "steer" });
852
1104
  },
853
1105
  });
854
1106
 
@@ -861,10 +1113,14 @@ export default function (pi: ExtensionAPI) {
861
1113
 
862
1114
  const fs = require("node:fs");
863
1115
  const reviewDir = path.join(process.cwd(), ".pi-lens", "reviews");
864
- const timestamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
1116
+ const timestamp = new Date()
1117
+ .toISOString()
1118
+ .replace(/[:.]/g, "-")
1119
+ .slice(0, 19);
865
1120
  const projectName = path.basename(process.cwd());
866
1121
 
867
- const results: import("./clients/complexity-client.js").FileComplexity[] = [];
1122
+ const results: import("./clients/complexity-client.js").FileComplexity[] =
1123
+ [];
868
1124
 
869
1125
  const scanDir = (dir: string) => {
870
1126
  if (!fs.existsSync(dir)) return;
@@ -873,7 +1129,17 @@ export default function (pi: ExtensionAPI) {
873
1129
  for (const entry of entries) {
874
1130
  const fullPath = path.join(dir, entry.name);
875
1131
  if (entry.isDirectory()) {
876
- if (["node_modules", ".git", "dist", "build", ".next", ".pi-lens"].includes(entry.name)) continue;
1132
+ if (
1133
+ [
1134
+ "node_modules",
1135
+ ".git",
1136
+ "dist",
1137
+ "build",
1138
+ ".next",
1139
+ ".pi-lens",
1140
+ ].includes(entry.name)
1141
+ )
1142
+ continue;
877
1143
  scanDir(fullPath);
878
1144
  } else if (complexityClient.isSupportedFile(fullPath)) {
879
1145
  const metrics = complexityClient.analyzeFile(fullPath);
@@ -892,18 +1158,26 @@ export default function (pi: ExtensionAPI) {
892
1158
  }
893
1159
 
894
1160
  // Calculate aggregates
895
- const avgMI = results.reduce((a, b) => a + b.maintainabilityIndex, 0) / results.length;
896
- const avgCognitive = results.reduce((a, b) => a + b.cognitiveComplexity, 0) / results.length;
897
- const avgCyclomatic = results.reduce((a, b) => a + b.cyclomaticComplexity, 0) / results.length;
898
- const avgFunctionLength = results.reduce((a, b) => a + b.avgFunctionLength, 0) / results.length;
899
- const maxNesting = Math.max(...results.map(r => r.maxNestingDepth));
900
- const maxCognitive = Math.max(...results.map(r => r.cognitiveComplexity));
901
- const minMI = Math.min(...results.map(r => r.maintainabilityIndex));
1161
+ const avgMI =
1162
+ results.reduce((a, b) => a + b.maintainabilityIndex, 0) /
1163
+ results.length;
1164
+ const avgCognitive =
1165
+ results.reduce((a, b) => a + b.cognitiveComplexity, 0) / results.length;
1166
+ const avgCyclomatic =
1167
+ results.reduce((a, b) => a + b.cyclomaticComplexity, 0) /
1168
+ results.length;
1169
+ const avgFunctionLength =
1170
+ results.reduce((a, b) => a + b.avgFunctionLength, 0) / results.length;
1171
+ const maxNesting = Math.max(...results.map((r) => r.maxNestingDepth));
1172
+ const maxCognitive = Math.max(
1173
+ ...results.map((r) => r.cognitiveComplexity),
1174
+ );
1175
+ const minMI = Math.min(...results.map((r) => r.maintainabilityIndex));
902
1176
  const totalFunctions = results.reduce((a, b) => a + b.functionCount, 0);
903
1177
  const totalLOC = results.reduce((a, b) => a + b.linesOfCode, 0);
904
1178
 
905
1179
  // Grade distribution
906
- const grades = results.map(r => {
1180
+ const grades = results.map((r) => {
907
1181
  const mi = r.maintainabilityIndex;
908
1182
  if (mi >= 80) return { letter: "A", color: "🟢" };
909
1183
  if (mi >= 60) return { letter: "B", color: "🟡" };
@@ -913,7 +1187,7 @@ export default function (pi: ExtensionAPI) {
913
1187
  });
914
1188
 
915
1189
  const gradeCount = { A: 0, B: 0, C: 0, D: 0, F: 0 };
916
- grades.forEach(g => gradeCount[g.letter as keyof typeof gradeCount]++);
1190
+ grades.forEach((g) => gradeCount[g.letter as keyof typeof gradeCount]++);
917
1191
 
918
1192
  // Build report
919
1193
  let report = `# Code Metrics Report: ${projectName}\n\n`;
@@ -925,10 +1199,16 @@ export default function (pi: ExtensionAPI) {
925
1199
  const totalAISlopWarnings = results.reduce((a, b) => {
926
1200
  return a + complexityClient.checkThresholds(b).length;
927
1201
  }, 0);
928
- const totalEmojiComments = results.reduce((a, b) => a + b.aiCommentPatterns, 0);
1202
+ const totalEmojiComments = results.reduce(
1203
+ (a, b) => a + b.aiCommentPatterns,
1204
+ 0,
1205
+ );
929
1206
  const totalTryCatch = results.reduce((a, b) => a + b.tryCatchCount, 0);
930
- const totalSingleUse = results.reduce((a, b) => a + b.singleUseFunctions, 0);
931
- const maxParams = Math.max(...results.map(r => r.maxParamsInFunction));
1207
+ const totalSingleUse = results.reduce(
1208
+ (a, b) => a + b.singleUseFunctions,
1209
+ 0,
1210
+ );
1211
+ const maxParams = Math.max(...results.map((r) => r.maxParamsInFunction));
932
1212
 
933
1213
  // Summary
934
1214
  report += `## Summary\n\n`;
@@ -961,8 +1241,11 @@ export default function (pi: ExtensionAPI) {
961
1241
  report += `|-------|-------|------------|\n`;
962
1242
  for (const [grade, count] of Object.entries(gradeCount)) {
963
1243
  const pct = ((count / results.length) * 100).toFixed(1);
964
- const icon = grade === "A" ? "🟢" : grade === "B" ? "🟡" : grade === "C" ? "🟠" : grade === "D" ? "🔴" : "⚫";
965
- report += `| ${icon} ${grade} (MI ≥ ${grade === "A" ? 80 : grade === "B" ? 60 : grade === "C" ? 40 : grade === "D" ? 20 : 0}) | ${count} | ${pct}% |\n`;
1244
+ const gradeIcons: Record<string, string> = { A: "🟢", B: "🟡", C: "🟠", D: "🔴" };
1245
+ const gradeThresholds: Record<string, number> = { A: 80, B: 60, C: 40, D: 20 };
1246
+ const icon = gradeIcons[grade] ?? "⚫";
1247
+ const threshold = gradeThresholds[grade] ?? 0;
1248
+ report += `| ${icon} ${grade} (MI ≥ ${threshold}) | ${count} | ${pct}% |\n`;
966
1249
  }
967
1250
  report += `\n`;
968
1251
 
@@ -971,7 +1254,9 @@ export default function (pi: ExtensionAPI) {
971
1254
  report += `| Grade | File | MI | Cognitive | Cyclomatic | Nesting | Functions | LOC | Entropy |\n`;
972
1255
  report += `|-------|------|-----|-----------|------------|---------|-----------|-----|--------|\n`;
973
1256
 
974
- const sorted = [...results].sort((a, b) => a.maintainabilityIndex - b.maintainabilityIndex);
1257
+ const sorted = [...results].sort(
1258
+ (a, b) => a.maintainabilityIndex - b.maintainabilityIndex,
1259
+ );
975
1260
  for (const f of sorted) {
976
1261
  const mi = f.maintainabilityIndex;
977
1262
  let grade: string;
@@ -998,14 +1283,22 @@ export default function (pi: ExtensionAPI) {
998
1283
 
999
1284
  if (f.maintainabilityIndex < 20) warnings.push("🔴 Critical: MI < 20");
1000
1285
  else if (f.maintainabilityIndex < 40) warnings.push("🟠 Low: MI < 40");
1001
- if (f.cognitiveComplexity > 50) warnings.push(`High cognitive (${f.cognitiveComplexity})`);
1002
- if (f.maxNestingDepth > 5) warnings.push(`Deep nesting (${f.maxNestingDepth})`);
1003
- if (f.maxFunctionLength > 50) warnings.push(`Long functions (max ${f.maxFunctionLength})`);
1286
+ if (f.cognitiveComplexity > 50)
1287
+ warnings.push(`High cognitive (${f.cognitiveComplexity})`);
1288
+ if (f.maxNestingDepth > 5)
1289
+ warnings.push(`Deep nesting (${f.maxNestingDepth})`);
1290
+ if (f.maxFunctionLength > 50)
1291
+ warnings.push(`Long functions (max ${f.maxFunctionLength})`);
1004
1292
 
1005
1293
  // AI slop indicators
1006
1294
  const slopWarnings = complexityClient.checkThresholds(f);
1007
1295
  for (const w of slopWarnings) {
1008
- if (w.includes("AI-style") || w.includes("try/catch") || w.includes("single-use") || w.includes("parameter list")) {
1296
+ if (
1297
+ w.includes("AI-style") ||
1298
+ w.includes("try/catch") ||
1299
+ w.includes("single-use") ||
1300
+ w.includes("parameter list")
1301
+ ) {
1009
1302
  warnings.push(`🤖 ${w.split(" — ")[0]}`);
1010
1303
  }
1011
1304
  }
@@ -1110,83 +1403,186 @@ export default function (pi: ExtensionAPI) {
1110
1403
  // --- Tools ---
1111
1404
 
1112
1405
  const LANGUAGES = [
1113
- "c", "cpp", "csharp", "css", "dart", "elixir", "go", "haskell", "html",
1114
- "java", "javascript", "json", "kotlin", "lua", "php", "python", "ruby",
1115
- "rust", "scala", "sql", "swift", "tsx", "typescript", "yaml",
1406
+ "c",
1407
+ "cpp",
1408
+ "csharp",
1409
+ "css",
1410
+ "dart",
1411
+ "elixir",
1412
+ "go",
1413
+ "haskell",
1414
+ "html",
1415
+ "java",
1416
+ "javascript",
1417
+ "json",
1418
+ "kotlin",
1419
+ "lua",
1420
+ "php",
1421
+ "python",
1422
+ "ruby",
1423
+ "rust",
1424
+ "scala",
1425
+ "sql",
1426
+ "swift",
1427
+ "tsx",
1428
+ "typescript",
1429
+ "yaml",
1116
1430
  ] as const;
1117
1431
 
1118
1432
  pi.registerTool({
1119
1433
  name: "ast_grep_search",
1120
1434
  label: "AST Search",
1121
- description: "Search code using AST-aware pattern matching. IMPORTANT: Use specific AST patterns, NOT text search. Examples:\n- Find function: 'function $NAME() { $$$BODY }'\n- Find call: 'fetchMetrics($ARGS)'\n- Find import: 'import { $NAMES } from \"$PATH\"'\n- Generic identifier (broad): 'fetchMetrics'\n\nAlways prefer specific patterns with context over bare identifiers. Use 'paths' to scope to specific files/folders.",
1435
+ description:
1436
+ "Search code using AST-aware pattern matching. IMPORTANT: Use specific AST patterns, NOT text search. Examples:\n- Find function: 'function $NAME() { $$$BODY }'\n- Find call: 'fetchMetrics($ARGS)'\n- Find import: 'import { $NAMES } from \"$PATH\"'\n- Generic identifier (broad): 'fetchMetrics'\n\nAlways prefer specific patterns with context over bare identifiers. Use 'paths' to scope to specific files/folders.",
1122
1437
  promptSnippet: "Use ast_grep_search for AST-aware code search",
1123
1438
  parameters: Type.Object({
1124
- pattern: Type.String({ description: "AST pattern (use function/class/call context, not text)" }),
1125
- lang: Type.Union(LANGUAGES.map((l) => Type.Literal(l)), { description: "Target language" }),
1126
- paths: Type.Optional(Type.Array(Type.String(), { description: "Specific files/folders to search" })),
1439
+ pattern: Type.String({
1440
+ description: "AST pattern (use function/class/call context, not text)",
1441
+ }),
1442
+ lang: Type.Union(
1443
+ LANGUAGES.map((l) => Type.Literal(l)),
1444
+ { description: "Target language" },
1445
+ ),
1446
+ paths: Type.Optional(
1447
+ Type.Array(Type.String(), {
1448
+ description: "Specific files/folders to search",
1449
+ }),
1450
+ ),
1127
1451
  }),
1128
1452
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
1129
1453
  if (!astGrepClient.isAvailable()) {
1130
- return { content: [{ type: "text", text: "ast-grep CLI not found. Install: npm i -D @ast-grep/cli" }], isError: true, details: {} };
1454
+ return {
1455
+ content: [
1456
+ {
1457
+ type: "text",
1458
+ text: "ast-grep CLI not found. Install: npm i -D @ast-grep/cli",
1459
+ },
1460
+ ],
1461
+ isError: true,
1462
+ details: {},
1463
+ };
1131
1464
  }
1132
1465
 
1133
- const { pattern, lang, paths } = params as { pattern: string; lang: string; paths?: string[] };
1466
+ const { pattern, lang, paths } = params as {
1467
+ pattern: string;
1468
+ lang: string;
1469
+ paths?: string[];
1470
+ };
1134
1471
  const searchPaths = paths?.length ? paths : [ctx.cwd || "."];
1135
1472
  const result = await astGrepClient.search(pattern, lang, searchPaths);
1136
1473
 
1137
1474
  if (result.error) {
1138
- return { content: [{ type: "text", text: `Error: ${result.error}` }], isError: true, details: {} };
1475
+ return {
1476
+ content: [{ type: "text", text: `Error: ${result.error}` }],
1477
+ isError: true,
1478
+ details: {},
1479
+ };
1139
1480
  }
1140
1481
 
1141
1482
  const output = astGrepClient.formatMatches(result.matches);
1142
- return { content: [{ type: "text", text: output }], details: { matchCount: result.matches.length } };
1483
+ return {
1484
+ content: [{ type: "text", text: output }],
1485
+ details: { matchCount: result.matches.length },
1486
+ };
1143
1487
  },
1144
1488
  });
1145
1489
 
1146
1490
  pi.registerTool({
1147
1491
  name: "ast_grep_replace",
1148
1492
  label: "AST Replace",
1149
- description: "Replace code using AST-aware pattern matching. IMPORTANT: Use specific AST patterns, not text. Dry-run by default (use apply=true to apply).\n\nExamples:\n- pattern='console.log($MSG)' rewrite='logger.info($MSG)'\n- pattern='var $X' rewrite='let $X'\n- pattern='function $NAME() { }' rewrite='' (delete)\n\nAlways use 'paths' to scope to specific files/folders. Dry-run first to preview changes.",
1493
+ description:
1494
+ "Replace code using AST-aware pattern matching. IMPORTANT: Use specific AST patterns, not text. Dry-run by default (use apply=true to apply).\n\nExamples:\n- pattern='console.log($MSG)' rewrite='logger.info($MSG)'\n- pattern='var $X' rewrite='let $X'\n- pattern='function $NAME() { }' rewrite='' (delete)\n\nAlways use 'paths' to scope to specific files/folders. Dry-run first to preview changes.",
1150
1495
  promptSnippet: "Use ast_grep_replace for AST-aware find-and-replace",
1151
1496
  parameters: Type.Object({
1152
- pattern: Type.String({ description: "AST pattern to match (be specific with context)" }),
1153
- rewrite: Type.String({ description: "Replacement using meta-variables from pattern" }),
1154
- lang: Type.Union(LANGUAGES.map((l) => Type.Literal(l)), { description: "Target language" }),
1155
- paths: Type.Optional(Type.Array(Type.String(), { description: "Specific files/folders" })),
1156
- apply: Type.Optional(Type.Boolean({ description: "Apply changes (default: false)" })),
1497
+ pattern: Type.String({
1498
+ description: "AST pattern to match (be specific with context)",
1499
+ }),
1500
+ rewrite: Type.String({
1501
+ description: "Replacement using meta-variables from pattern",
1502
+ }),
1503
+ lang: Type.Union(
1504
+ LANGUAGES.map((l) => Type.Literal(l)),
1505
+ { description: "Target language" },
1506
+ ),
1507
+ paths: Type.Optional(
1508
+ Type.Array(Type.String(), { description: "Specific files/folders" }),
1509
+ ),
1510
+ apply: Type.Optional(
1511
+ Type.Boolean({ description: "Apply changes (default: false)" }),
1512
+ ),
1157
1513
  }),
1158
1514
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
1159
1515
  if (!astGrepClient.isAvailable()) {
1160
- return { content: [{ type: "text", text: "ast-grep CLI not found. Install: npm i -D @ast-grep/cli" }], isError: true, details: {} };
1516
+ return {
1517
+ content: [
1518
+ {
1519
+ type: "text",
1520
+ text: "ast-grep CLI not found. Install: npm i -D @ast-grep/cli",
1521
+ },
1522
+ ],
1523
+ isError: true,
1524
+ details: {},
1525
+ };
1161
1526
  }
1162
1527
 
1163
- const { pattern, rewrite, lang, paths, apply } = params as { pattern: string; rewrite: string; lang: string; paths?: string[]; apply?: boolean };
1528
+ const { pattern, rewrite, lang, paths, apply } = params as {
1529
+ pattern: string;
1530
+ rewrite: string;
1531
+ lang: string;
1532
+ paths?: string[];
1533
+ apply?: boolean;
1534
+ };
1164
1535
  const searchPaths = paths?.length ? paths : [ctx.cwd || "."];
1165
- const result = await astGrepClient.replace(pattern, rewrite, lang, searchPaths, apply ?? false);
1536
+ const result = await astGrepClient.replace(
1537
+ pattern,
1538
+ rewrite,
1539
+ lang,
1540
+ searchPaths,
1541
+ apply ?? false,
1542
+ );
1166
1543
 
1167
1544
  if (result.error) {
1168
- return { content: [{ type: "text", text: `Error: ${result.error}` }], isError: true, details: {} };
1545
+ return {
1546
+ content: [{ type: "text", text: `Error: ${result.error}` }],
1547
+ isError: true,
1548
+ details: {},
1549
+ };
1169
1550
  }
1170
1551
 
1171
1552
  const isDryRun = !apply;
1172
1553
  let output = astGrepClient.formatMatches(result.matches, isDryRun);
1173
- if (isDryRun && result.matches.length > 0) output += "\n\n(Dry run - use apply=true to apply)";
1174
- if (apply && result.matches.length > 0) output = `Applied ${result.matches.length} replacements:\n${output}`;
1175
-
1176
- return { content: [{ type: "text", text: output }], details: { matchCount: result.matches.length, applied: apply ?? false } };
1554
+ if (isDryRun && result.matches.length > 0)
1555
+ output += "\n\n(Dry run - use apply=true to apply)";
1556
+ if (apply && result.matches.length > 0)
1557
+ output = `Applied ${result.matches.length} replacements:\n${output}`;
1558
+
1559
+ return {
1560
+ content: [{ type: "text", text: output }],
1561
+ details: { matchCount: result.matches.length, applied: apply ?? false },
1562
+ };
1177
1563
  },
1178
1564
  });
1179
1565
 
1180
1566
  // Delivered once into the first tool_result of the session, then cleared
1181
1567
  let sessionSummary: string | null = null;
1182
1568
  let sessionMetricsShown = false;
1183
- let cachedJscpdClones: import("./clients/jscpd-client.js").DuplicateClone[] = [];
1184
- let cachedExports = new Map<string, string>(); // function name -> file path
1185
- const complexityBaselines: Map<string, import("./clients/complexity-client.js").FileComplexity> = new Map();
1569
+ let cachedJscpdClones: import("./clients/jscpd-client.js").DuplicateClone[] =
1570
+ [];
1571
+ const cachedExports = new Map<string, string>(); // function name -> file path
1572
+ const complexityBaselines: Map<
1573
+ string,
1574
+ import("./clients/complexity-client.js").FileComplexity
1575
+ > = new Map();
1186
1576
 
1187
1577
  // Delta baselines: store pre-write diagnostics to diff against post-write
1188
- const astGrepBaselines = new Map<string, import("./clients/ast-grep-client.js").AstGrepDiagnostic[]>();
1189
- const biomeBaselines = new Map<string, import("./clients/biome-client.js").BiomeDiagnostic[]>();
1578
+ const astGrepBaselines = new Map<
1579
+ string,
1580
+ import("./clients/ast-grep-client.js").AstGrepDiagnostic[]
1581
+ >();
1582
+ const biomeBaselines = new Map<
1583
+ string,
1584
+ import("./clients/biome-client.js").BiomeDiagnostic[]
1585
+ >();
1190
1586
 
1191
1587
  // --- Events ---
1192
1588
 
@@ -1287,11 +1683,9 @@ export default function (pi: ExtensionAPI) {
1287
1683
  const preWriteHints = new Map<string, string>();
1288
1684
 
1289
1685
  pi.on("tool_call", async (event, _ctx) => {
1290
- const filePath = isToolCallEventType("write", event)
1686
+ const filePath = (isToolCallEventType("write", event) || isToolCallEventType("edit", event))
1291
1687
  ? (event.input as { path: string }).path
1292
- : isToolCallEventType("edit", event)
1293
- ? (event.input as { path: string }).path
1294
- : undefined;
1688
+ : undefined;
1295
1689
 
1296
1690
  if (!filePath) return;
1297
1691
 
@@ -1305,7 +1699,10 @@ export default function (pi: ExtensionAPI) {
1305
1699
  metricsClient.recordBaseline(filePath);
1306
1700
 
1307
1701
  // Record complexity baseline for TS/JS files
1308
- if (complexityClient.isSupportedFile(filePath) && !complexityBaselines.has(filePath)) {
1702
+ if (
1703
+ complexityClient.isSupportedFile(filePath) &&
1704
+ !complexityBaselines.has(filePath)
1705
+ ) {
1309
1706
  const baseline = complexityClient.analyzeFile(filePath);
1310
1707
  if (baseline) {
1311
1708
  complexityBaselines.set(filePath, baseline);
@@ -1329,8 +1726,17 @@ export default function (pi: ExtensionAPI) {
1329
1726
  astGrepBaselines.set(filePath, astGrepClient.scanFile(filePath));
1330
1727
  }
1331
1728
 
1332
- if (!pi.getFlag("no-biome") && biomeClient.isAvailable() && biomeClient.isSupportedFile(filePath)) {
1333
- biomeBaselines.set(filePath, biomeClient.checkFile(filePath).filter(d => d.category === "lint" || d.severity === "error"));
1729
+ if (
1730
+ !pi.getFlag("no-biome") &&
1731
+ biomeClient.isAvailable() &&
1732
+ biomeClient.isSupportedFile(filePath)
1733
+ ) {
1734
+ biomeBaselines.set(
1735
+ filePath,
1736
+ biomeClient
1737
+ .checkFile(filePath)
1738
+ .filter((d) => d.category === "lint" || d.severity === "error"),
1739
+ );
1334
1740
  }
1335
1741
 
1336
1742
  dbg(` pre-write hints: ${hints.length} — ${hints.join(" | ") || "none"}`);
@@ -1379,8 +1785,12 @@ export default function (pi: ExtensionAPI) {
1379
1785
  const diags = tsClient.getDiagnostics(filePath);
1380
1786
  if (diags.length > 0) {
1381
1787
  // Separate unused imports (TS6133, TS6196) from other diagnostics
1382
- const unusedImports = diags.filter(d => d.code === 6133 || d.code === 6196);
1383
- const otherDiags = diags.filter(d => d.code !== 6133 && d.code !== 6196);
1788
+ const unusedImports = diags.filter(
1789
+ (d) => d.code === 6133 || d.code === 6196,
1790
+ );
1791
+ const otherDiags = diags.filter(
1792
+ (d) => d.code !== 6133 && d.code !== 6196,
1793
+ );
1384
1794
 
1385
1795
  if (unusedImports.length > 0) {
1386
1796
  lspOutput += `\n\n🧹 Remove ${unusedImports.length} unused import(s) — they are dead code:\n`;
@@ -1390,8 +1800,8 @@ export default function (pi: ExtensionAPI) {
1390
1800
  }
1391
1801
 
1392
1802
  if (otherDiags.length > 0) {
1393
- const errors = otherDiags.filter(d => d.severity !== 2);
1394
- const warnings = otherDiags.filter(d => d.severity === 2);
1803
+ const errors = otherDiags.filter((d) => d.severity !== 2);
1804
+ const warnings = otherDiags.filter((d) => d.severity === 2);
1395
1805
  if (errors.length > 0) {
1396
1806
  lspOutput += `\n\n🔴 Fix ${errors.length} TypeScript error(s) — these must be resolved:\n`;
1397
1807
  for (const d of errors.slice(0, 10)) {
@@ -1462,15 +1872,21 @@ export default function (pi: ExtensionAPI) {
1462
1872
  // Count by rule before/after
1463
1873
  const countBefore = new Map<string, number>();
1464
1874
  const countAfter = new Map<string, number>();
1465
- for (const d of before) countBefore.set(d.rule, (countBefore.get(d.rule) ?? 0) + 1);
1466
- for (const d of after) countAfter.set(d.rule, (countAfter.get(d.rule) ?? 0) + 1);
1875
+ for (const d of before)
1876
+ countBefore.set(d.rule, (countBefore.get(d.rule) ?? 0) + 1);
1877
+ for (const d of after)
1878
+ countAfter.set(d.rule, (countAfter.get(d.rule) ?? 0) + 1);
1467
1879
 
1468
1880
  // Find new/increased rules
1469
1881
  const newViolations: typeof after = [];
1470
1882
  for (const d of after) {
1471
1883
  const ruleBefore = countBefore.get(d.rule) ?? 0;
1472
1884
  const ruleAfter = countAfter.get(d.rule) ?? 0;
1473
- if (ruleAfter > ruleBefore && newViolations.filter(x => x.rule === d.rule).length < (ruleAfter - ruleBefore)) {
1885
+ if (
1886
+ ruleAfter > ruleBefore &&
1887
+ newViolations.filter((x) => x.rule === d.rule).length <
1888
+ ruleAfter - ruleBefore
1889
+ ) {
1474
1890
  newViolations.push(d);
1475
1891
  }
1476
1892
  }
@@ -1483,10 +1899,11 @@ export default function (pi: ExtensionAPI) {
1483
1899
  }
1484
1900
 
1485
1901
  if (newViolations.length > 0) {
1486
- const hasFixable = newViolations.some(v => v.fix);
1902
+ const hasFixable = newViolations.some((v) => v.fix);
1487
1903
  lspOutput += `\n\n🔴 You introduced ${newViolations.length} new structural violation(s) — fix before moving on:\n`;
1488
1904
  lspOutput += astGrepClient.formatDiagnostics(newViolations);
1489
- if (hasFixable) lspOutput += `\n Some are fixable — check the → hints above`;
1905
+ if (hasFixable)
1906
+ lspOutput += `\n Some are fixable — check the → hints above`;
1490
1907
  }
1491
1908
  if (fixedRules.length > 0) {
1492
1909
  lspOutput += `\n\n✅ ast-grep: fixed ${fixedRules.join(", ")}`;
@@ -1498,25 +1915,36 @@ export default function (pi: ExtensionAPI) {
1498
1915
 
1499
1916
  // Biome: lint only — delta mode
1500
1917
  const biomeAvailable = biomeClient.isAvailable();
1501
- if (!pi.getFlag("no-biome") && biomeAvailable && biomeClient.isSupportedFile(filePath)) {
1918
+ if (
1919
+ !pi.getFlag("no-biome") &&
1920
+ biomeAvailable &&
1921
+ biomeClient.isSupportedFile(filePath)
1922
+ ) {
1502
1923
  const allDiags = biomeClient.checkFile(filePath);
1503
- const after = allDiags.filter((d) => d.category === "lint" || d.severity === "error");
1924
+ const after = allDiags.filter(
1925
+ (d) => d.category === "lint" || d.severity === "error",
1926
+ );
1504
1927
  const before = biomeBaselines.get(filePath) ?? [];
1505
1928
  biomeBaselines.set(filePath, after);
1506
1929
 
1507
1930
  // Count by rule before/after
1508
1931
  const countBefore = new Map<string, number>();
1509
1932
  const countAfter = new Map<string, number>();
1510
- const ruleKey = (d: typeof after[0]) => d.rule ?? d.message;
1511
- for (const d of before) countBefore.set(ruleKey(d), (countBefore.get(ruleKey(d)) ?? 0) + 1);
1512
- for (const d of after) countAfter.set(ruleKey(d), (countAfter.get(ruleKey(d)) ?? 0) + 1);
1933
+ const ruleKey = (d: (typeof after)[0]) => d.rule ?? d.message;
1934
+ for (const d of before)
1935
+ countBefore.set(ruleKey(d), (countBefore.get(ruleKey(d)) ?? 0) + 1);
1936
+ for (const d of after)
1937
+ countAfter.set(ruleKey(d), (countAfter.get(ruleKey(d)) ?? 0) + 1);
1513
1938
 
1514
1939
  const newDiags: typeof after = [];
1515
1940
  for (const d of after) {
1516
1941
  const key = ruleKey(d);
1517
1942
  const n_before = countBefore.get(key) ?? 0;
1518
1943
  const n_after = countAfter.get(key) ?? 0;
1519
- if (n_after > n_before && newDiags.filter(x => ruleKey(x) === key).length < (n_after - n_before)) {
1944
+ if (
1945
+ n_after > n_before &&
1946
+ newDiags.filter((x) => ruleKey(x) === key).length < n_after - n_before
1947
+ ) {
1520
1948
  newDiags.push(d);
1521
1949
  }
1522
1950
  }
@@ -1531,8 +1959,11 @@ export default function (pi: ExtensionAPI) {
1531
1959
  const fixResult = biomeClient.fixFile(filePath);
1532
1960
  if (fixResult.success && fixResult.changed) {
1533
1961
  lspOutput += `\n\n[Biome] Auto-fixed ${fixResult.fixed} issue(s) — file updated on disk`;
1534
- const remaining = biomeClient.checkFile(filePath).filter((d) => d.category === "lint" || d.severity === "error");
1535
- if (remaining.length > 0) lspOutput += `\n\n${biomeClient.formatDiagnostics(remaining, filePath)}`;
1962
+ const remaining = biomeClient
1963
+ .checkFile(filePath)
1964
+ .filter((d) => d.category === "lint" || d.severity === "error");
1965
+ if (remaining.length > 0)
1966
+ lspOutput += `\n\n${biomeClient.formatDiagnostics(remaining, filePath)}`;
1536
1967
  else lspOutput += `\n\n[Biome] ✓ All issues resolved`;
1537
1968
  } else if (after.length > 0) {
1538
1969
  lspOutput += `\n\n${biomeClient.formatDiagnostics(after, filePath)}`;
@@ -1542,7 +1973,8 @@ export default function (pi: ExtensionAPI) {
1542
1973
  const fixable = newDiags.filter((d) => d.fixable);
1543
1974
  lspOutput += `\n\n🟠 You introduced ${newDiags.length} new Biome violation(s) — fix before moving on:\n`;
1544
1975
  lspOutput += biomeClient.formatDiagnostics(newDiags, filePath);
1545
- if (fixable.length > 0) lspOutput += `\n → Auto-fixable: \`npx @biomejs/biome check --write ${path.basename(filePath)}\``;
1976
+ if (fixable.length > 0)
1977
+ lspOutput += `\n → Auto-fixable: \`npx @biomejs/biome check --write ${path.basename(filePath)}\``;
1546
1978
  }
1547
1979
  if (fixedRules.length > 0) {
1548
1980
  lspOutput += `\n\n✅ Biome: fixed ${fixedRules.join(", ")}`;
@@ -1554,7 +1986,11 @@ export default function (pi: ExtensionAPI) {
1554
1986
  }
1555
1987
 
1556
1988
  // Go — go vet diagnostics
1557
- if (!pi.getFlag("no-go") && goClient.isGoFile(filePath) && goClient.isGoAvailable()) {
1989
+ if (
1990
+ !pi.getFlag("no-go") &&
1991
+ goClient.isGoFile(filePath) &&
1992
+ goClient.isGoAvailable()
1993
+ ) {
1558
1994
  const goDiags = goClient.checkFile(filePath);
1559
1995
  if (goDiags.length > 0) {
1560
1996
  lspOutput += `\n\n${goClient.formatDiagnostics(goDiags)}`;
@@ -1562,7 +1998,11 @@ export default function (pi: ExtensionAPI) {
1562
1998
  }
1563
1999
 
1564
2000
  // Rust — cargo check diagnostics
1565
- if (!pi.getFlag("no-rust") && rustClient.isRustFile(filePath) && rustClient.isAvailable()) {
2001
+ if (
2002
+ !pi.getFlag("no-rust") &&
2003
+ rustClient.isRustFile(filePath) &&
2004
+ rustClient.isAvailable()
2005
+ ) {
1566
2006
  const cwd = process.cwd();
1567
2007
  const rustDiags = rustClient.checkFile(filePath, cwd);
1568
2008
  if (rustDiags.length > 0) {
@@ -1596,7 +2036,9 @@ export default function (pi: ExtensionAPI) {
1596
2036
  dbg(` test runner: detected runner: ${detected?.runner || "none"}`);
1597
2037
  if (detected) {
1598
2038
  const testInfo = testRunnerClient.findTestFile(filePath, cwd);
1599
- dbg(` test runner: testInfo: ${testInfo ? testInfo.testFile : "none"}`);
2039
+ dbg(
2040
+ ` test runner: testInfo: ${testInfo ? testInfo.testFile : "none"}`,
2041
+ );
1600
2042
  if (testInfo) {
1601
2043
  dbg(` test file found: ${testInfo.testFile} (${testInfo.runner})`);
1602
2044
  const testResult = testRunnerClient.runTestFile(
@@ -1618,16 +2060,18 @@ export default function (pi: ExtensionAPI) {
1618
2060
  // Check for code duplication involving the edited file
1619
2061
  if (cachedJscpdClones.length > 0) {
1620
2062
  const fileClones = cachedJscpdClones.filter(
1621
- clone => path.resolve(clone.fileA) === path.resolve(filePath) ||
1622
- path.resolve(clone.fileB) === path.resolve(filePath)
2063
+ (clone) =>
2064
+ path.resolve(clone.fileA) === path.resolve(filePath) ||
2065
+ path.resolve(clone.fileB) === path.resolve(filePath),
1623
2066
  );
1624
2067
  if (fileClones.length > 0) {
1625
2068
  dbg(` jscpd: ${fileClones.length} duplicate(s) involving ${filePath}`);
1626
2069
  let dupReport = `🟠 This file has ${fileClones.length} duplicate block(s) — extract to shared utilities:\n`;
1627
2070
  for (const clone of fileClones.slice(0, 3)) {
1628
- const other = path.resolve(clone.fileA) === path.resolve(filePath)
1629
- ? `${path.basename(clone.fileB)}:${clone.startB}`
1630
- : `${path.basename(clone.fileA)}:${clone.startA}`;
2071
+ const other =
2072
+ path.resolve(clone.fileA) === path.resolve(filePath)
2073
+ ? `${path.basename(clone.fileB)}:${clone.startB}`
2074
+ : `${path.basename(clone.fileA)}:${clone.startA}`;
1631
2075
  dupReport += ` ${clone.lines} lines duplicated with ${other}\n`;
1632
2076
  }
1633
2077
  if (fileClones.length > 3) {
@@ -1640,12 +2084,18 @@ export default function (pi: ExtensionAPI) {
1640
2084
  // Check for duplicate exports (function already exists elsewhere)
1641
2085
  if (cachedExports.size > 0 && /\.(ts|tsx|js|jsx)$/.test(filePath)) {
1642
2086
  try {
1643
- const newExports = await astGrepClient.scanExports(filePath, "typescript");
2087
+ const newExports = await astGrepClient.scanExports(
2088
+ filePath,
2089
+ "typescript",
2090
+ );
1644
2091
  const dupes: string[] = [];
1645
- for (const [name, file] of newExports) {
2092
+ for (const [name, _file] of newExports) {
1646
2093
  if (cachedExports.has(name)) {
1647
2094
  const existingFile = cachedExports.get(name);
1648
- if (existingFile && path.resolve(existingFile) !== path.resolve(filePath)) {
2095
+ if (
2096
+ existingFile &&
2097
+ path.resolve(existingFile) !== path.resolve(filePath)
2098
+ ) {
1649
2099
  dupes.push(`${name} (already in ${path.basename(existingFile)})`);
1650
2100
  }
1651
2101
  }