pi-lens 1.3.4 → 1.3.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (4) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/README.md +7 -7
  3. package/index.ts +102 -197
  4. 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-fixes on every write by default |
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
- | `/find-todos [path]` | Scan for TODO/FIXME/HACK annotations |
72
- | `/dead-code` | Find unused exports/files/dependencies (requires knip) |
73
- | `/check-deps` | Circular dependency scan (requires madge) |
74
- | `/format [file|--all]` | Apply Biome formatting |
75
- | `/design-review [path]` | Analyze files for design smells (long methods, large classes, etc.) |
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
 
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
- * - /find-todos - Scan for TODO/FIXME/HACK annotations
20
- * - /dead-code - Find unused exports/dependencies (requires knip)
21
- * - /check-deps - Full circular dependency scan (requires madge)
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("find-todos", {
157
+ pi.registerCommand("lens-todos", {
158
158
  description:
159
- "Scan for TODO/FIXME/HACK annotations. Usage: /find-todos [path]",
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("check-deps", {
196
- description: "Check for circular dependencies in the project",
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("design-review", {
218
+ pi.registerCommand("lens-review", {
219
219
  description:
220
- "Analyze files for design smells (long methods, large classes, deep nesting). Usage: /design-review [path]",
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("🔍 Analyzing design smells...", "info");
223
+ ctx.ui.notify("🔍 Running code review...", "info");
232
224
 
233
- const configPath = path.join(
234
- typeof __dirname !== "undefined" ? __dirname : ".",
235
- "rules",
236
- "ast-grep-rules",
237
- ".sgconfig.yml",
238
- );
225
+ const parts: string[] = [];
239
226
 
240
- try {
241
- const result = require("node:child_process").spawnSync("npx", [
242
- "sg",
243
- "scan",
244
- "--config", configPath,
245
- "--json",
246
- targetPath,
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
- let issues: Array<{line: number; rule: string; message: string}> = [];
260
- const lines = output.split("\n").filter((l: string) => l.trim());
261
-
262
- for (const line of lines) {
263
- try {
264
- const item = JSON.parse(line);
265
- const ruleId = item.ruleId || item.name || "unknown";
266
- const ruleDesc = astGrepClient.getRuleDescription?.(ruleId);
267
- const message = ruleDesc?.message || item.message || ruleId;
268
- const lineNum = item.labels?.[0]?.range?.start?.line ||
269
- item.spans?.[0]?.range?.start?.line || 0;
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
- if (issues.length === 0) {
282
- ctx.ui.notify("✓ No design smells found", "info");
283
- return;
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
- let report = `[Design Review] ${issues.length} design smell(s) found:\n`;
287
- for (const issue of issues.slice(0, 20)) {
288
- report += ` L${issue.line}: ${issue.rule} ${issue.message}\n`;
289
- }
290
- if (issues.length > 20) {
291
- report += ` ... and ${issues.length - 20} more\n`;
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
288
 
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
-
306
- const startTime = Date.now();
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 === 0) {
331
- ctx.ui.notify("✓ No TS/JS files found", "info");
332
- return;
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
- // Calculate aggregates
336
- const avgMI = results.reduce((a, b) => a + b.maintainabilityIndex, 0) / results.length;
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
- if (highCognitive.length > 0) {
377
- report += `## High Cognitive Complexity (> 20)\n\n`;
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
- if (highNesting.length > 0) {
387
- report += `## Deep Nesting (> 5 levels)\n\n`;
388
- report += `| File | Max Nesting |\n`;
389
- report += `|------|-------------|\n`;
390
- for (const f of highNesting) {
391
- report += `| ${f.filePath} | ${f.maxNestingDepth} levels |\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`;
392
330
  }
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
331
 
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`;
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`;
428
338
  }
429
- if (lowMI.length > 5) summary += ` ... and ${lowMI.length - 5} more\n`;
430
- }
431
339
 
432
- if (highCognitive.length > 0) {
433
- summary += `\n── High Cognitive Complexity (> 20) ──\n`;
434
- for (const f of highCognitive.slice(0, 5)) {
435
- summary += ` ⚠ ${f.filePath}: ${f.cognitiveComplexity}\n`;
436
- }
437
- if (highCognitive.length > 5) summary += ` ... and ${highCognitive.length - 5} more\n`;
340
+ parts.push(summary);
438
341
  }
439
342
 
440
- summary += `\n📁 Full report saved to: .pi-lens/metrics/latest.md`;
441
-
442
- ctx.ui.notify(summary, "info");
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(
@@ -871,7 +776,7 @@ export default function (pi: ExtensionAPI) {
871
776
  const fixable = biomeDiags.filter((d) => d.fixable);
872
777
  lspOutput += `\n\n${biomeClient.formatDiagnostics(biomeDiags, filePath)}`;
873
778
  if (fixable.length > 0) {
874
- lspOutput += `\n\n[Biome] ${fixable.length} fixable — enable --autofix-biome flag or run /format`;
779
+ lspOutput += `\n\n[Biome] ${fixable.length} fixable — enable --autofix-biome flag or run /lens-format`;
875
780
  }
876
781
  }
877
782
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-lens",
3
- "version": "1.3.4",
3
+ "version": "1.3.5",
4
4
  "description": "Real-time code feedback for pi — TypeScript LSP, Biome, ast-grep, Ruff, TODO scanner, dead code, duplicate detection, type coverage",
5
5
  "repository": {
6
6
  "type": "git",