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