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.
- package/clients/ast-grep-client.test.ts +146 -116
- package/clients/ast-grep-client.ts +645 -551
- package/clients/biome-client.test.ts +154 -137
- package/clients/biome-client.ts +397 -337
- package/clients/complexity-client.test.ts +188 -200
- package/clients/complexity-client.ts +815 -667
- package/clients/dependency-checker.test.ts +55 -55
- package/clients/dependency-checker.ts +358 -333
- package/clients/go-client.test.ts +121 -111
- package/clients/go-client.ts +218 -216
- package/clients/jscpd-client.test.ts +132 -132
- package/clients/jscpd-client.ts +155 -118
- package/clients/knip-client.test.ts +123 -133
- package/clients/knip-client.ts +231 -218
- package/clients/metrics-client.test.ts +171 -167
- package/clients/metrics-client.ts +283 -252
- package/clients/ruff-client.test.ts +128 -117
- package/clients/ruff-client.ts +300 -269
- package/clients/rust-client.test.ts +104 -85
- package/clients/rust-client.ts +241 -234
- package/clients/subprocess-client.ts +1 -1
- package/clients/test-runner-client.test.ts +248 -215
- package/clients/test-runner-client.ts +728 -608
- package/clients/test-utils.ts +10 -3
- package/clients/todo-scanner.test.ts +288 -202
- package/clients/todo-scanner.ts +225 -187
- package/clients/type-coverage-client.test.ts +119 -119
- package/clients/type-coverage-client.ts +142 -115
- package/clients/types.ts +28 -28
- package/clients/typescript-client.test.ts +99 -93
- package/clients/typescript-client.ts +527 -502
- package/index.ts +662 -212
- package/package.json +1 -1
- 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
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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:
|
|
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()
|
|
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(
|
|
241
|
-
"
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
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
|
-
|
|
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 {
|
|
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 {
|
|
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 =
|
|
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 =
|
|
302
|
+
const lineNum =
|
|
303
|
+
item.labels?.[0]?.range?.start?.line ||
|
|
278
304
|
item.spans?.[0]?.range?.start?.line ||
|
|
279
|
-
item.range?.start?.line ||
|
|
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 +=
|
|
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 (
|
|
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(
|
|
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 +=
|
|
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, {
|
|
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 (
|
|
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 =
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
const
|
|
384
|
-
|
|
385
|
-
|
|
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
|
|
388
|
-
|
|
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)
|
|
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)
|
|
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(
|
|
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(
|
|
637
|
+
ctx.ui.notify(
|
|
638
|
+
"✓ Code review clean — saved to .pi-lens/reviews/",
|
|
639
|
+
"info",
|
|
640
|
+
);
|
|
577
641
|
} else {
|
|
578
|
-
ctx.ui.notify(
|
|
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<
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
"no-
|
|
589
|
-
"prefer-
|
|
590
|
-
"
|
|
591
|
-
"
|
|
592
|
-
"
|
|
593
|
-
"
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
"
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
"
|
|
602
|
-
|
|
603
|
-
|
|
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(
|
|
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",
|
|
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> } = {
|
|
624
|
-
|
|
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(
|
|
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(
|
|
638
|
-
"
|
|
639
|
-
|
|
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(
|
|
647
|
-
|
|
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 = {
|
|
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(
|
|
656
|
-
"
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
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
|
-
? (() => {
|
|
666
|
-
|
|
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 =
|
|
670
|
-
|
|
671
|
-
const
|
|
672
|
-
|
|
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 (
|
|
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 (
|
|
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
|
|
689
|
-
|
|
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({
|
|
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<{
|
|
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(
|
|
706
|
-
"
|
|
707
|
-
|
|
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,
|
|
938
|
+
line: line + 1,
|
|
939
|
+
rule,
|
|
940
|
+
message: diag.message ?? rule,
|
|
941
|
+
fixable,
|
|
719
942
|
});
|
|
720
943
|
}
|
|
721
|
-
} catch (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 =
|
|
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 =
|
|
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(
|
|
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(
|
|
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)
|
|
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(
|
|
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)
|
|
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(
|
|
1061
|
+
lines.push(
|
|
1062
|
+
` - \`${file}\`: ${warnings.map((w) => w.split(" — ")[0]).join(", ")}`,
|
|
1063
|
+
);
|
|
825
1064
|
}
|
|
826
|
-
if (slopFiles.length > 10)
|
|
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(
|
|
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(
|
|
841
|
-
|
|
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(
|
|
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
|
-
|
|
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()
|
|
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 (
|
|
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 =
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
const
|
|
899
|
-
|
|
900
|
-
const
|
|
901
|
-
|
|
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(
|
|
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(
|
|
931
|
-
|
|
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
|
|
965
|
-
|
|
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(
|
|
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)
|
|
1002
|
-
|
|
1003
|
-
if (f.
|
|
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 (
|
|
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",
|
|
1114
|
-
"
|
|
1115
|
-
"
|
|
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:
|
|
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({
|
|
1125
|
-
|
|
1126
|
-
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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:
|
|
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({
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
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 {
|
|
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 {
|
|
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(
|
|
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 {
|
|
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)
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
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
|
-
|
|
1185
|
-
const
|
|
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<
|
|
1189
|
-
|
|
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
|
-
:
|
|
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 (
|
|
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 (
|
|
1333
|
-
|
|
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(
|
|
1383
|
-
|
|
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)
|
|
1466
|
-
|
|
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 (
|
|
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)
|
|
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 (
|
|
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(
|
|
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)
|
|
1512
|
-
|
|
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 (
|
|
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
|
|
1535
|
-
|
|
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)
|
|
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 (
|
|
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 (
|
|
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(
|
|
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
|
-
|
|
1622
|
-
|
|
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 =
|
|
1629
|
-
|
|
1630
|
-
|
|
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(
|
|
2087
|
+
const newExports = await astGrepClient.scanExports(
|
|
2088
|
+
filePath,
|
|
2089
|
+
"typescript",
|
|
2090
|
+
);
|
|
1644
2091
|
const dupes: string[] = [];
|
|
1645
|
-
for (const [name,
|
|
2092
|
+
for (const [name, _file] of newExports) {
|
|
1646
2093
|
if (cachedExports.has(name)) {
|
|
1647
2094
|
const existingFile = cachedExports.get(name);
|
|
1648
|
-
if (
|
|
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
|
}
|