pi-lens 1.3.4 → 1.3.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +14 -0
- package/README.md +7 -7
- package/clients/ast-grep-client.ts +72 -0
- package/index.ts +155 -207
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,20 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to pi-lens will be documented in this file.
|
|
4
4
|
|
|
5
|
+
## [1.5.0] - 2026-03-23
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
- **Real-time jscpd duplicate detection**: Code duplication is now detected on every write. Duplicates involving the edited file are shown to the agent in real-time.
|
|
9
|
+
- **`/lens-review` command**: Combined code review: design smells + complexity metrics in one command.
|
|
10
|
+
|
|
11
|
+
### Changed
|
|
12
|
+
- **Consistent command prefix**: All commands now start with `lens-`.
|
|
13
|
+
- `/find-todos` → `/lens-todos`
|
|
14
|
+
- `/dead-code` → `/lens-dead-code`
|
|
15
|
+
- `/check-deps` → `/lens-deps`
|
|
16
|
+
- `/format` → `/lens-format`
|
|
17
|
+
- `/design-review` + `/lens-metrics` → `/lens-review`
|
|
18
|
+
|
|
5
19
|
## [1.4.0] - 2026-03-23
|
|
6
20
|
|
|
7
21
|
### Added
|
package/README.md
CHANGED
|
@@ -12,10 +12,11 @@ Real-time code quality feedback for [pi](https://github.com/mariozechner/pi-codi
|
|
|
12
12
|
|---|---|
|
|
13
13
|
| **TypeScript LSP** | Type errors and warnings, using the project's `tsconfig.json` (walks up from the file to find it; falls back to `ES2020 + DOM` defaults) |
|
|
14
14
|
| **ast-grep** | 60+ structural rules: `no-var`, `no-eval`, `no-debugger`, `no-as-any`, `prefer-template`, `no-throw-string`, `no-hardcoded-secrets`, `no-return-await`, nested ternaries, strict equality, and more |
|
|
15
|
-
| **Biome** | Lint + format for JS/TS/JSX/TSX/CSS/JSON. Auto-
|
|
15
|
+
| **Biome** | Lint + format for JS/TS/JSX/TSX/CSS/JSON. Auto-fix disabled by default, use `/lens-format` to apply |
|
|
16
16
|
| **Ruff** | Lint + format for Python. Auto-fixes on every write by default |
|
|
17
17
|
| **Test Runner** | Runs corresponding test file when you edit source code (vitest, jest, pytest). Silent if no test file exists. |
|
|
18
18
|
| **Complexity Metrics** | AST-based analysis: Maintainability Index, Cyclomatic/Cognitive Complexity, Halstead Volume, nesting depth, function length. |
|
|
19
|
+
| **jscpd** | Code duplication detection. Warns when editing a file that has duplicates with other files in the project. |
|
|
19
20
|
|
|
20
21
|
### Pre-write hints
|
|
21
22
|
|
|
@@ -68,12 +69,11 @@ Example:
|
|
|
68
69
|
|
|
69
70
|
| Command | Description |
|
|
70
71
|
|---|---|
|
|
71
|
-
| `/
|
|
72
|
-
| `/dead-code` | Find unused exports/files/dependencies (requires knip) |
|
|
73
|
-
| `/
|
|
74
|
-
| `/format [file
|
|
75
|
-
| `/
|
|
76
|
-
| `/lens-metrics [path]` | Full project complexity scan (Maintainability Index, Cognitive/Cyclomatic Complexity, Halstead Volume) |
|
|
72
|
+
| `/lens-todos [path]` | Scan for TODO/FIXME/HACK annotations |
|
|
73
|
+
| `/lens-dead-code` | Find unused exports/files/dependencies (requires knip) |
|
|
74
|
+
| `/lens-deps` | Circular dependency scan (requires madge) |
|
|
75
|
+
| `/lens-format [file\|--all]` | Apply Biome formatting |
|
|
76
|
+
| `/lens-review [path]` | Code review: design smells + complexity metrics |
|
|
77
77
|
|
|
78
78
|
### On-demand tools
|
|
79
79
|
|
|
@@ -200,6 +200,78 @@ export class AstGrepClient {
|
|
|
200
200
|
return { matches: result.matches, applied: apply, error: result.error };
|
|
201
201
|
}
|
|
202
202
|
|
|
203
|
+
/**
|
|
204
|
+
* Scan for exported function names in a directory
|
|
205
|
+
*/
|
|
206
|
+
async scanExports(dir: string, lang: string = "typescript"): Promise<Map<string, string>> {
|
|
207
|
+
console.log(`[scanExports] Starting scan of ${dir}`);
|
|
208
|
+
const exports = new Map<string, string>();
|
|
209
|
+
|
|
210
|
+
if (!this.isAvailable()) {
|
|
211
|
+
console.log(`[scanExports] ast-grep not available`);
|
|
212
|
+
return exports;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const tmpDir = require("node:os").tmpdir();
|
|
216
|
+
const ts = Date.now();
|
|
217
|
+
const ruleDir = require("node:path").join(tmpDir, `pi-lens-exports-${ts}`);
|
|
218
|
+
const rulesSubdir = require("node:path").join(ruleDir, "rules");
|
|
219
|
+
const ruleFile = require("node:path").join(rulesSubdir, "find-functions.yml");
|
|
220
|
+
const configFile = require("node:path").join(ruleDir, ".sgconfig.yml");
|
|
221
|
+
|
|
222
|
+
require("node:fs").mkdirSync(rulesSubdir, { recursive: true });
|
|
223
|
+
|
|
224
|
+
require("node:fs").writeFileSync(configFile, `ruleDirs:\n - ./rules\n`);
|
|
225
|
+
require("node:fs").writeFileSync(ruleFile, `id: find-functions
|
|
226
|
+
language: ${lang}
|
|
227
|
+
rule:
|
|
228
|
+
kind: function_declaration
|
|
229
|
+
severity: info
|
|
230
|
+
message: found
|
|
231
|
+
`);
|
|
232
|
+
|
|
233
|
+
try {
|
|
234
|
+
const result = spawnSync("npx", [
|
|
235
|
+
"sg", "scan",
|
|
236
|
+
"--config", configFile,
|
|
237
|
+
"--json",
|
|
238
|
+
dir,
|
|
239
|
+
], {
|
|
240
|
+
encoding: "utf-8",
|
|
241
|
+
timeout: 15000,
|
|
242
|
+
shell: true,
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
console.log(`[scanExports] status: ${result.status}, stdout length: ${result.stdout?.length || 0}`);
|
|
246
|
+
|
|
247
|
+
const output = result.stdout || result.stderr || "";
|
|
248
|
+
this.log(`scanExports output length: ${output.length}`);
|
|
249
|
+
if (output.trim()) {
|
|
250
|
+
try {
|
|
251
|
+
const items = JSON.parse(output);
|
|
252
|
+
const matches = Array.isArray(items) ? items : [items];
|
|
253
|
+
console.log(`[scanExports] parsed ${matches.length} matches`);
|
|
254
|
+
for (const item of matches) {
|
|
255
|
+
const text = item.text || "";
|
|
256
|
+
const nameMatch = text.match(/function\s+(\w+)/);
|
|
257
|
+
if (nameMatch && nameMatch[1]) {
|
|
258
|
+
this.log(`scanExports found: ${nameMatch[1]} in ${item.file}`);
|
|
259
|
+
exports.set(nameMatch[1], item.file);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
} catch (e) {
|
|
263
|
+
this.log(`scanExports parse error: ${e}`);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
} catch (err: any) {
|
|
267
|
+
this.log(`scanExports error: ${err.message}`);
|
|
268
|
+
} finally {
|
|
269
|
+
try { require("node:fs").rmSync(ruleDir, { recursive: true, force: true }); } catch {}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
return exports;
|
|
273
|
+
}
|
|
274
|
+
|
|
203
275
|
private runSg(args: string[]): Promise<{ matches: AstGrepMatch[]; error?: string }> {
|
|
204
276
|
return new Promise((resolve) => {
|
|
205
277
|
const proc = spawn("npx", ["sg", ...args], { stdio: ["ignore", "pipe", "pipe"], shell: true });
|
package/index.ts
CHANGED
|
@@ -11,14 +11,14 @@
|
|
|
11
11
|
* - Warns when target file already has existing violations
|
|
12
12
|
*
|
|
13
13
|
* Auto-fix on write (enable with --autofix-ruff flag, Biome auto-fix disabled by default):
|
|
14
|
-
* - Biome: feedback only by default, use /format to apply fixes
|
|
14
|
+
* - Biome: feedback only by default, use /lens-format to apply fixes
|
|
15
15
|
* - Ruff: applies --fix + format (lint + format fixes)
|
|
16
16
|
*
|
|
17
17
|
* On-demand commands:
|
|
18
|
-
* - /format - Apply Biome formatting
|
|
19
|
-
* - /
|
|
20
|
-
* - /dead-code - Find unused exports/dependencies (requires knip)
|
|
21
|
-
* - /
|
|
18
|
+
* - /lens-format - Apply Biome formatting
|
|
19
|
+
* - /lens-todos - Scan for TODO/FIXME/HACK annotations
|
|
20
|
+
* - /lens-dead-code - Find unused exports/dependencies (requires knip)
|
|
21
|
+
* - /lens-deps - Full circular dependency scan (requires madge)
|
|
22
22
|
*
|
|
23
23
|
* External dependencies:
|
|
24
24
|
* - npm: @biomejs/biome, @ast-grep/cli, knip, madge
|
|
@@ -154,9 +154,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
154
154
|
|
|
155
155
|
// --- Commands ---
|
|
156
156
|
|
|
157
|
-
pi.registerCommand("
|
|
157
|
+
pi.registerCommand("lens-todos", {
|
|
158
158
|
description:
|
|
159
|
-
"Scan for TODO/FIXME/HACK annotations. Usage: /
|
|
159
|
+
"Scan for TODO/FIXME/HACK annotations. Usage: /lens-todos [path]",
|
|
160
160
|
handler: async (args, ctx) => {
|
|
161
161
|
const targetPath = args.trim() || ctx.cwd || process.cwd();
|
|
162
162
|
ctx.ui.notify("🔍 Scanning for TODOs...", "info");
|
|
@@ -172,8 +172,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
172
172
|
},
|
|
173
173
|
});
|
|
174
174
|
|
|
175
|
-
pi.registerCommand("dead-code", {
|
|
176
|
-
description: "Check for unused exports, files, and dependencies",
|
|
175
|
+
pi.registerCommand("lens-dead-code", {
|
|
176
|
+
description: "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");
|
|
@@ -192,8 +192,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
192
192
|
},
|
|
193
193
|
});
|
|
194
194
|
|
|
195
|
-
pi.registerCommand("
|
|
196
|
-
description: "Check for circular dependencies
|
|
195
|
+
pi.registerCommand("lens-deps", {
|
|
196
|
+
description: "Check for circular dependencies. Usage: /lens-deps [path]",
|
|
197
197
|
handler: async (args, ctx) => {
|
|
198
198
|
if (!depChecker.isAvailable()) {
|
|
199
199
|
ctx.ui.notify(
|
|
@@ -215,95 +215,78 @@ export default function (pi: ExtensionAPI) {
|
|
|
215
215
|
},
|
|
216
216
|
});
|
|
217
217
|
|
|
218
|
-
pi.registerCommand("
|
|
218
|
+
pi.registerCommand("lens-review", {
|
|
219
219
|
description:
|
|
220
|
-
"
|
|
220
|
+
"Code review: design smells + complexity metrics. Usage: /lens-review [path]",
|
|
221
221
|
handler: async (args, ctx) => {
|
|
222
|
-
if (!astGrepClient.isAvailable()) {
|
|
223
|
-
ctx.ui.notify(
|
|
224
|
-
"ast-grep not installed. Run: npm i -D @ast-grep/cli",
|
|
225
|
-
"error",
|
|
226
|
-
);
|
|
227
|
-
return;
|
|
228
|
-
}
|
|
229
|
-
|
|
230
222
|
const targetPath = args.trim() || ctx.cwd || process.cwd();
|
|
231
|
-
ctx.ui.notify("🔍
|
|
223
|
+
ctx.ui.notify("🔍 Running code review...", "info");
|
|
232
224
|
|
|
233
|
-
const
|
|
234
|
-
typeof __dirname !== "undefined" ? __dirname : ".",
|
|
235
|
-
"rules",
|
|
236
|
-
"ast-grep-rules",
|
|
237
|
-
".sgconfig.yml",
|
|
238
|
-
);
|
|
225
|
+
const parts: string[] = [];
|
|
239
226
|
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
"
|
|
244
|
-
"
|
|
245
|
-
"
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
encoding: "utf-8",
|
|
249
|
-
timeout: 30000,
|
|
250
|
-
shell: true,
|
|
251
|
-
});
|
|
252
|
-
|
|
253
|
-
const output = result.stdout || result.stderr || "";
|
|
254
|
-
if (!output.trim() || result.status !== 1) {
|
|
255
|
-
ctx.ui.notify("✓ No design smells found", "info");
|
|
256
|
-
return;
|
|
257
|
-
}
|
|
227
|
+
// Part 1: Design smells via ast-grep
|
|
228
|
+
if (astGrepClient.isAvailable()) {
|
|
229
|
+
const configPath = path.join(
|
|
230
|
+
typeof __dirname !== "undefined" ? __dirname : ".",
|
|
231
|
+
"rules",
|
|
232
|
+
"ast-grep-rules",
|
|
233
|
+
".sgconfig.yml",
|
|
234
|
+
);
|
|
258
235
|
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
issues.push({
|
|
272
|
-
line: lineNum + 1,
|
|
273
|
-
rule: ruleId,
|
|
274
|
-
message: message,
|
|
275
|
-
});
|
|
276
|
-
} catch {
|
|
277
|
-
// Skip unparseable lines
|
|
278
|
-
}
|
|
279
|
-
}
|
|
236
|
+
try {
|
|
237
|
+
const result = require("node:child_process").spawnSync("npx", [
|
|
238
|
+
"sg",
|
|
239
|
+
"scan",
|
|
240
|
+
"--config", configPath,
|
|
241
|
+
"--json",
|
|
242
|
+
targetPath,
|
|
243
|
+
], {
|
|
244
|
+
encoding: "utf-8",
|
|
245
|
+
timeout: 30000,
|
|
246
|
+
shell: true,
|
|
247
|
+
});
|
|
280
248
|
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
249
|
+
const output = result.stdout || result.stderr || "";
|
|
250
|
+
if (output.trim() && result.status === 1) {
|
|
251
|
+
let issues: Array<{line: number; rule: string; message: string}> = [];
|
|
252
|
+
const lines = output.split("\n").filter((l: string) => l.trim());
|
|
253
|
+
|
|
254
|
+
for (const line of lines) {
|
|
255
|
+
try {
|
|
256
|
+
const item = JSON.parse(line);
|
|
257
|
+
const ruleId = item.ruleId || item.name || "unknown";
|
|
258
|
+
const ruleDesc = astGrepClient.getRuleDescription?.(ruleId);
|
|
259
|
+
const message = ruleDesc?.message || item.message || ruleId;
|
|
260
|
+
const lineNum = item.labels?.[0]?.range?.start?.line ||
|
|
261
|
+
item.spans?.[0]?.range?.start?.line || 0;
|
|
262
|
+
|
|
263
|
+
issues.push({
|
|
264
|
+
line: lineNum + 1,
|
|
265
|
+
rule: ruleId,
|
|
266
|
+
message: message,
|
|
267
|
+
});
|
|
268
|
+
} catch {
|
|
269
|
+
// Skip unparseable lines
|
|
270
|
+
}
|
|
271
|
+
}
|
|
285
272
|
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
273
|
+
if (issues.length > 0) {
|
|
274
|
+
let report = `[Design Smells] ${issues.length} issue(s) found:\n`;
|
|
275
|
+
for (const issue of issues.slice(0, 20)) {
|
|
276
|
+
report += ` L${issue.line}: ${issue.rule} — ${issue.message}\n`;
|
|
277
|
+
}
|
|
278
|
+
if (issues.length > 20) {
|
|
279
|
+
report += ` ... and ${issues.length - 20} more\n`;
|
|
280
|
+
}
|
|
281
|
+
parts.push(report);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
} catch (err: any) {
|
|
285
|
+
// ast-grep scan failed, skip
|
|
292
286
|
}
|
|
293
|
-
ctx.ui.notify(report, "info");
|
|
294
|
-
} catch (err: any) {
|
|
295
|
-
ctx.ui.notify(`Design review failed: ${err.message}`, "error");
|
|
296
287
|
}
|
|
297
|
-
},
|
|
298
|
-
});
|
|
299
|
-
|
|
300
|
-
pi.registerCommand("lens-metrics", {
|
|
301
|
-
description: "Scan project for complexity metrics (maintainability, cognitive complexity, etc.). Usage: /lens-metrics [path]",
|
|
302
|
-
handler: async (args, ctx) => {
|
|
303
|
-
const targetPath = args.trim() || ctx.cwd || process.cwd();
|
|
304
|
-
ctx.ui.notify("🔍 Scanning project metrics...", "info");
|
|
305
288
|
|
|
306
|
-
|
|
289
|
+
// Part 2: Complexity metrics
|
|
307
290
|
const results: import("./clients/complexity-client.js").FileComplexity[] = [];
|
|
308
291
|
|
|
309
292
|
const scanDir = (dir: string) => {
|
|
@@ -325,127 +308,49 @@ export default function (pi: ExtensionAPI) {
|
|
|
325
308
|
};
|
|
326
309
|
|
|
327
310
|
scanDir(targetPath);
|
|
328
|
-
const duration = Date.now() - startTime;
|
|
329
311
|
|
|
330
|
-
if (results.length
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
312
|
+
if (results.length > 0) {
|
|
313
|
+
const avgMI = results.reduce((a, b) => a + b.maintainabilityIndex, 0) / results.length;
|
|
314
|
+
const avgCognitive = results.reduce((a, b) => a + b.cognitiveComplexity, 0) / results.length;
|
|
315
|
+
const avgCyclomatic = results.reduce((a, b) => a + b.cyclomaticComplexity, 0) / results.length;
|
|
316
|
+
const maxNesting = Math.max(...results.map(r => r.maxNestingDepth));
|
|
334
317
|
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
const avgCognitive = results.reduce((a, b) => a + b.cognitiveComplexity, 0) / results.length;
|
|
338
|
-
const avgCyclomatic = results.reduce((a, b) => a + b.cyclomaticComplexity, 0) / results.length;
|
|
339
|
-
const maxNesting = Math.max(...results.map(r => r.maxNestingDepth));
|
|
340
|
-
const avgHalstead = results.reduce((a, b) => a + b.halsteadVolume, 0) / results.length;
|
|
341
|
-
|
|
342
|
-
// Find problem files
|
|
343
|
-
const lowMI = results.filter(r => r.maintainabilityIndex < 60).sort((a, b) => a.maintainabilityIndex - b.maintainabilityIndex);
|
|
344
|
-
const highCognitive = results.filter(r => r.cognitiveComplexity > 20).sort((a, b) => b.cognitiveComplexity - a.cognitiveComplexity);
|
|
345
|
-
const highNesting = results.filter(r => r.maxNestingDepth > 5).sort((a, b) => b.maxNestingDepth - a.maxNestingDepth);
|
|
346
|
-
|
|
347
|
-
// Build markdown report
|
|
348
|
-
const now = new Date();
|
|
349
|
-
const timestamp = now.toISOString().replace(/[:.]/g, "-").slice(0, 19);
|
|
350
|
-
const dateStr = now.toISOString().slice(0, 10);
|
|
351
|
-
const timeStr = now.toISOString().slice(11, 19);
|
|
352
|
-
|
|
353
|
-
let report = `# Lens Metrics Report\n\n`;
|
|
354
|
-
report += `**Date:** ${dateStr} ${timeStr}\n`;
|
|
355
|
-
report += `**Files scanned:** ${results.length}\n`;
|
|
356
|
-
report += `**Scan time:** ${duration}ms\n\n`;
|
|
357
|
-
report += `## Aggregate\n\n`;
|
|
358
|
-
report += `| Metric | Value |\n`;
|
|
359
|
-
report += `|--------|-------|\n`;
|
|
360
|
-
report += `| Maintainability Index | ${avgMI.toFixed(1)} (avg) |\n`;
|
|
361
|
-
report += `| Cognitive Complexity | ${avgCognitive.toFixed(1)} (avg) |\n`;
|
|
362
|
-
report += `| Cyclomatic Complexity | ${avgCyclomatic.toFixed(1)} (avg) |\n`;
|
|
363
|
-
report += `| Halstead Volume | ${avgHalstead.toFixed(1)} (avg) |\n`;
|
|
364
|
-
report += `| Max Nesting Depth | ${maxNesting} levels |\n\n`;
|
|
365
|
-
|
|
366
|
-
if (lowMI.length > 0) {
|
|
367
|
-
report += `## Low Maintainability (MI < 60)\n\n`;
|
|
368
|
-
report += `| File | MI |\n`;
|
|
369
|
-
report += `|------|-----|\n`;
|
|
370
|
-
for (const f of lowMI) {
|
|
371
|
-
report += `| ${f.filePath} | ${f.maintainabilityIndex.toFixed(1)} |\n`;
|
|
372
|
-
}
|
|
373
|
-
report += `\n`;
|
|
374
|
-
}
|
|
318
|
+
const lowMI = results.filter(r => r.maintainabilityIndex < 60).sort((a, b) => a.maintainabilityIndex - b.maintainabilityIndex);
|
|
319
|
+
const highCognitive = results.filter(r => r.cognitiveComplexity > 20).sort((a, b) => b.cognitiveComplexity - a.cognitiveComplexity);
|
|
375
320
|
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
report += `| File | Cognitive | Cyclomatic | Max Nesting |\n`;
|
|
379
|
-
report += `|------|-----------|------------|-------------|\n`;
|
|
380
|
-
for (const f of highCognitive) {
|
|
381
|
-
report += `| ${f.filePath} | ${f.cognitiveComplexity} | ${f.cyclomaticComplexity} | ${f.maxNestingDepth} |\n`;
|
|
382
|
-
}
|
|
383
|
-
report += `\n`;
|
|
384
|
-
}
|
|
321
|
+
let summary = `[Complexity] ${results.length} file(s) scanned\n`;
|
|
322
|
+
summary += ` Maintainability: ${avgMI.toFixed(1)} avg | Cognitive: ${avgCognitive.toFixed(1)} avg | Max Nesting: ${maxNesting} levels\n`;
|
|
385
323
|
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
}
|
|
393
|
-
report += `\n`;
|
|
394
|
-
}
|
|
395
|
-
|
|
396
|
-
// All files sorted by MI
|
|
397
|
-
report += `## All Files\n\n`;
|
|
398
|
-
report += `| File | MI | Cognitive | Cyclomatic | Nesting | Halstead |\n`;
|
|
399
|
-
report += `|------|-----|-----------|------------|---------|----------|\n`;
|
|
400
|
-
for (const f of results.sort((a, b) => a.maintainabilityIndex - b.maintainabilityIndex)) {
|
|
401
|
-
report += `| ${f.filePath} | ${f.maintainabilityIndex.toFixed(1)} | ${f.cognitiveComplexity} | ${f.cyclomaticComplexity} | ${f.maxNestingDepth} | ${f.halsteadVolume.toFixed(0)} |\n`;
|
|
402
|
-
}
|
|
403
|
-
|
|
404
|
-
// Save report
|
|
405
|
-
const fs = require("node:fs");
|
|
406
|
-
const metricsDir = path.join(targetPath, ".pi-lens", "metrics");
|
|
407
|
-
if (!fs.existsSync(metricsDir)) {
|
|
408
|
-
fs.mkdirSync(metricsDir, { recursive: true });
|
|
409
|
-
}
|
|
410
|
-
const timestampedPath = path.join(metricsDir, `metrics-${timestamp}.md`);
|
|
411
|
-
const latestPath = path.join(metricsDir, "latest.md");
|
|
412
|
-
fs.writeFileSync(timestampedPath, report);
|
|
413
|
-
fs.writeFileSync(latestPath, report);
|
|
414
|
-
|
|
415
|
-
// Build summary for UI (shorter)
|
|
416
|
-
let summary = `[Lens Metrics] ${results.length} file(s) scanned in ${duration}ms\n\n`;
|
|
417
|
-
summary += `── Aggregate ──\n`;
|
|
418
|
-
summary += ` Maintainability Index: ${avgMI.toFixed(1)} (avg)\n`;
|
|
419
|
-
summary += ` Cognitive Complexity: ${avgCognitive.toFixed(1)} (avg)\n`;
|
|
420
|
-
summary += ` Cyclomatic Complexity: ${avgCyclomatic.toFixed(1)} (avg)\n`;
|
|
421
|
-
summary += ` Halstead Volume: ${avgHalstead.toFixed(1)} (avg)\n`;
|
|
422
|
-
summary += ` Max Nesting Depth: ${maxNesting} levels\n`;
|
|
423
|
-
|
|
424
|
-
if (lowMI.length > 0) {
|
|
425
|
-
summary += `\n── Low Maintainability (MI < 60) ──\n`;
|
|
426
|
-
for (const f of lowMI.slice(0, 5)) {
|
|
427
|
-
summary += ` ✗ ${f.filePath}: MI ${f.maintainabilityIndex.toFixed(1)}\n`;
|
|
324
|
+
if (lowMI.length > 0) {
|
|
325
|
+
summary += `\n Low Maintainability (MI < 60):\n`;
|
|
326
|
+
for (const f of lowMI.slice(0, 5)) {
|
|
327
|
+
summary += ` ✗ ${f.filePath}: MI ${f.maintainabilityIndex.toFixed(1)}\n`;
|
|
328
|
+
}
|
|
329
|
+
if (lowMI.length > 5) summary += ` ... and ${lowMI.length - 5} more\n`;
|
|
428
330
|
}
|
|
429
|
-
if (lowMI.length > 5) summary += ` ... and ${lowMI.length - 5} more\n`;
|
|
430
|
-
}
|
|
431
331
|
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
332
|
+
if (highCognitive.length > 0) {
|
|
333
|
+
summary += `\n High Cognitive Complexity (> 20):\n`;
|
|
334
|
+
for (const f of highCognitive.slice(0, 5)) {
|
|
335
|
+
summary += ` ⚠ ${f.filePath}: ${f.cognitiveComplexity}\n`;
|
|
336
|
+
}
|
|
337
|
+
if (highCognitive.length > 5) summary += ` ... and ${highCognitive.length - 5} more\n`;
|
|
436
338
|
}
|
|
437
|
-
if (highCognitive.length > 5) summary += ` ... and ${highCognitive.length - 5} more\n`;
|
|
438
|
-
}
|
|
439
339
|
|
|
440
|
-
|
|
340
|
+
parts.push(summary);
|
|
341
|
+
}
|
|
441
342
|
|
|
442
|
-
|
|
343
|
+
if (parts.length === 0) {
|
|
344
|
+
ctx.ui.notify("✓ Code review clean", "info");
|
|
345
|
+
} else {
|
|
346
|
+
ctx.ui.notify(parts.join("\n\n"), "info");
|
|
347
|
+
}
|
|
443
348
|
},
|
|
444
349
|
});
|
|
445
350
|
|
|
446
|
-
pi.registerCommand("format", {
|
|
351
|
+
pi.registerCommand("lens-format", {
|
|
447
352
|
description:
|
|
448
|
-
"Apply Biome formatting to files. Usage: /format [file-path] or /format --all",
|
|
353
|
+
"Apply Biome formatting to files. Usage: /lens-format [file-path] or /lens-format --all",
|
|
449
354
|
handler: async (args, ctx) => {
|
|
450
355
|
if (!biomeClient.isAvailable()) {
|
|
451
356
|
ctx.ui.notify(
|
|
@@ -582,6 +487,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
582
487
|
let sessionSummary: string | null = null;
|
|
583
488
|
let sessionMetricsShown = false;
|
|
584
489
|
let cachedJscpdClones: import("./clients/jscpd-client.js").DuplicateClone[] = [];
|
|
490
|
+
let cachedExports = new Map<string, string>(); // function name -> file path
|
|
585
491
|
const complexityBaselines: Map<string, import("./clients/complexity-client.js").FileComplexity> = new Map();
|
|
586
492
|
|
|
587
493
|
// --- Events ---
|
|
@@ -661,6 +567,15 @@ export default function (pi: ExtensionAPI) {
|
|
|
661
567
|
dbg(`session_start type-coverage: not available`);
|
|
662
568
|
}
|
|
663
569
|
|
|
570
|
+
// Scan for exported functions (cached for duplicate detection on write)
|
|
571
|
+
if (astGrepClient.isAvailable()) {
|
|
572
|
+
const exports = await astGrepClient.scanExports(cwd, "typescript");
|
|
573
|
+
dbg(`session_start exports scan: ${exports.size} functions found`);
|
|
574
|
+
for (const [name, file] of exports) {
|
|
575
|
+
cachedExports.set(name, file);
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
|
|
664
579
|
if (parts.length > 0) {
|
|
665
580
|
sessionSummary = `[Session Start]\n${parts.join("\n\n")}`;
|
|
666
581
|
dbg(`session_start summary queued (${parts.length} parts)`);
|
|
@@ -844,34 +759,37 @@ export default function (pi: ExtensionAPI) {
|
|
|
844
759
|
}
|
|
845
760
|
}
|
|
846
761
|
|
|
847
|
-
// Biome: lint
|
|
762
|
+
// Biome: lint only (formatting noise filtered out, use /lens-format)
|
|
848
763
|
const biomeAvailable = biomeClient.isAvailable();
|
|
849
764
|
dbg(
|
|
850
765
|
` biome available: ${biomeAvailable}, supported: ${biomeClient.isSupportedFile(filePath)}, no-biome: ${pi.getFlag("no-biome")}`,
|
|
851
766
|
);
|
|
852
767
|
if (!pi.getFlag("no-biome") && biomeClient.isSupportedFile(filePath)) {
|
|
853
768
|
const biomeDiags = biomeClient.checkFile(filePath);
|
|
854
|
-
|
|
855
|
-
|
|
769
|
+
// Filter out format-only issues (noise for agent, use /lens-format)
|
|
770
|
+
const lintDiags = biomeDiags.filter((d) => d.category === "lint" || d.severity === "error");
|
|
771
|
+
dbg(` biome diags: ${biomeDiags.length} total, ${lintDiags.length} lint-only`);
|
|
772
|
+
if (pi.getFlag("autofix-biome") && lintDiags.length > 0) {
|
|
856
773
|
// Always attempt fix — let Biome decide what it can do
|
|
857
774
|
const fixResult = biomeClient.fixFile(filePath);
|
|
858
775
|
if (fixResult.success && fixResult.changed) {
|
|
859
776
|
lspOutput += `\n\n[Biome] Auto-fixed ${fixResult.fixed} issue(s) — file updated on disk`;
|
|
860
777
|
const remaining = biomeClient.checkFile(filePath);
|
|
861
|
-
|
|
862
|
-
|
|
778
|
+
const remainingLint = remaining.filter((d) => d.category === "lint" || d.severity === "error");
|
|
779
|
+
if (remainingLint.length > 0) {
|
|
780
|
+
lspOutput += `\n\n${biomeClient.formatDiagnostics(remainingLint, filePath)}`;
|
|
863
781
|
} else {
|
|
864
782
|
lspOutput += `\n\n[Biome] ✓ All issues resolved`;
|
|
865
783
|
}
|
|
866
784
|
} else {
|
|
867
785
|
// Nothing fixable — show diagnostics as-is
|
|
868
|
-
lspOutput += `\n\n${biomeClient.formatDiagnostics(
|
|
786
|
+
lspOutput += `\n\n${biomeClient.formatDiagnostics(lintDiags, filePath)}`;
|
|
869
787
|
}
|
|
870
|
-
} else if (
|
|
871
|
-
const fixable =
|
|
872
|
-
lspOutput += `\n\n${biomeClient.formatDiagnostics(
|
|
788
|
+
} else if (lintDiags.length > 0) {
|
|
789
|
+
const fixable = lintDiags.filter((d) => d.fixable);
|
|
790
|
+
lspOutput += `\n\n${biomeClient.formatDiagnostics(lintDiags, filePath)}`;
|
|
873
791
|
if (fixable.length > 0) {
|
|
874
|
-
lspOutput += `\n\n[Biome] ${fixable.length} fixable — enable --autofix-biome flag or run /format`;
|
|
792
|
+
lspOutput += `\n\n[Biome] ${fixable.length} fixable — enable --autofix-biome flag or run /lens-format`;
|
|
875
793
|
}
|
|
876
794
|
}
|
|
877
795
|
}
|
|
@@ -942,6 +860,36 @@ export default function (pi: ExtensionAPI) {
|
|
|
942
860
|
}
|
|
943
861
|
}
|
|
944
862
|
|
|
863
|
+
// Check for duplicate exports (function already exists elsewhere)
|
|
864
|
+
if (cachedExports.size > 0 && /\.(ts|tsx|js|jsx)$/.test(filePath)) {
|
|
865
|
+
try {
|
|
866
|
+
const newExports = await astGrepClient.scanExports(filePath, "typescript");
|
|
867
|
+
const dupes: string[] = [];
|
|
868
|
+
for (const [name, file] of newExports) {
|
|
869
|
+
if (cachedExports.has(name)) {
|
|
870
|
+
const existingFile = cachedExports.get(name);
|
|
871
|
+
if (existingFile && path.resolve(existingFile) !== path.resolve(filePath)) {
|
|
872
|
+
dupes.push(`${name} (already in ${path.basename(existingFile)})`);
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
if (dupes.length > 0) {
|
|
877
|
+
dbg(` duplicate exports: ${dupes.length} found`);
|
|
878
|
+
let exportReport = `[Duplicate Exports] ${dupes.length} function(s) already exist:\n`;
|
|
879
|
+
for (const dupe of dupes.slice(0, 5)) {
|
|
880
|
+
exportReport += ` ${dupe}\n`;
|
|
881
|
+
}
|
|
882
|
+
lspOutput += `\n\n${exportReport}`;
|
|
883
|
+
}
|
|
884
|
+
// Update cache with new exports
|
|
885
|
+
for (const [name, file] of newExports) {
|
|
886
|
+
cachedExports.set(name, file);
|
|
887
|
+
}
|
|
888
|
+
} catch {
|
|
889
|
+
// ast-grep not available, skip
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
|
|
945
893
|
// Silent metrics summary (appended to first tool_result after files are touched)
|
|
946
894
|
const metricsSummary = metricsClient.formatSessionSummary();
|
|
947
895
|
if (metricsSummary) {
|
package/package.json
CHANGED