pi-lens 1.1.1 → 1.1.2

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.
@@ -31,6 +31,49 @@ export interface TodoScanResult {
31
31
  export class TodoScanner {
32
32
  private readonly pattern = /\b(TODO|FIXME|HACK|XXX|NOTE|DEPRECATED|BUG)\b\s*[\(:]?\s*(.+)/gi;
33
33
 
34
+ /**
35
+ * Check if a match position is inside a comment context.
36
+ * Handles: // line comments, star-slash block comments, * JSDoc lines, # Python comments
37
+ */
38
+ private isInComment(line: string, matchIndex: number): boolean {
39
+ const trimmed = line.trimStart();
40
+
41
+ // Line starts with comment markers — entire line is a comment
42
+ if (/^\/\/|^\/\*|^\*|^#/.test(trimmed)) return true;
43
+
44
+ // Check if there's a // before the match position (not inside a string)
45
+ const beforeMatch = line.slice(0, matchIndex);
46
+ const lineCommentPos = beforeMatch.lastIndexOf("//");
47
+ if (lineCommentPos !== -1) {
48
+ // Count quotes before // to see if it's inside a string
49
+ const beforeComment = beforeMatch.slice(0, lineCommentPos);
50
+ const singleQuotes = (beforeComment.match(/'/g) || []).length;
51
+ const doubleQuotes = (beforeComment.match(/"/g) || []).length;
52
+ const backticks = (beforeComment.match(/`/g) || []).length;
53
+ if (singleQuotes % 2 === 0 && doubleQuotes % 2 === 0 && backticks % 2 === 0) {
54
+ return true;
55
+ }
56
+ }
57
+
58
+ // Check for /* ... */ block comment before match
59
+ const blockOpen = beforeMatch.lastIndexOf("/*");
60
+ const blockClose = beforeMatch.lastIndexOf("*/");
61
+ if (blockOpen !== -1 && blockClose < blockOpen) return true;
62
+
63
+ // Check for # comment (Python)
64
+ const hashPos = beforeMatch.lastIndexOf("#");
65
+ if (hashPos !== -1) {
66
+ const beforeHash = beforeMatch.slice(0, hashPos);
67
+ const singleQuotes = (beforeHash.match(/'/g) || []).length;
68
+ const doubleQuotes = (beforeHash.match(/"/g) || []).length;
69
+ if (singleQuotes % 2 === 0 && doubleQuotes % 2 === 0) {
70
+ return true;
71
+ }
72
+ }
73
+
74
+ return false;
75
+ }
76
+
34
77
  /**
35
78
  * Scan a single file for TODOs
36
79
  */
@@ -47,6 +90,9 @@ export class TodoScanner {
47
90
  const matches = line.matchAll(this.pattern);
48
91
 
49
92
  for (const match of matches) {
93
+ // Skip matches that aren't inside comments
94
+ if (!this.isInComment(line, match.index ?? 0)) continue;
95
+
50
96
  const type = match[1].toUpperCase() as TodoItem["type"];
51
97
  const message = (match[2] || "").trim().replace(/\s*\*\/\s*$/, ""); // Strip closing comment
52
98
 
@@ -82,6 +128,8 @@ export class TodoScanner {
82
128
  if (["node_modules", ".git", "dist", "build", ".next", "coverage"].includes(entry.name)) continue;
83
129
  scan(fullPath);
84
130
  } else if (extensions.some(ext => entry.name.endsWith(ext))) {
131
+ // Skip this scanner file — its own type literals and regex cause false positives
132
+ if (entry.name === "todo-scanner.ts" || entry.name === "todo-scanner.js") continue;
85
133
  items.push(...this.scanFile(fullPath));
86
134
  }
87
135
  }
package/index.ts CHANGED
@@ -25,453 +25,518 @@
25
25
  * - pip: ruff
26
26
  */
27
27
 
28
- import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
28
+ import * as nodeFs from "node:fs";
29
+ import * as os from "node:os";
30
+ import * as path from "node:path";
31
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
29
32
  import { isToolCallEventType } from "@mariozechner/pi-coding-agent";
30
-
31
- import { TypeScriptClient } from "./clients/typescript-client.js";
32
33
  import { AstGrepClient } from "./clients/ast-grep-client.js";
33
- import { RuffClient } from "./clients/ruff-client.js";
34
34
  import { BiomeClient } from "./clients/biome-client.js";
35
+ import { DependencyChecker } from "./clients/dependency-checker.js";
36
+ import { JscpdClient } from "./clients/jscpd-client.js";
35
37
  import { KnipClient } from "./clients/knip-client.js";
38
+ import { RuffClient } from "./clients/ruff-client.js";
36
39
  import { TodoScanner } from "./clients/todo-scanner.js";
37
- import { JscpdClient } from "./clients/jscpd-client.js";
38
40
  import { TypeCoverageClient } from "./clients/type-coverage-client.js";
39
- import { DependencyChecker } from "./clients/dependency-checker.js";
40
- import * as path from "node:path";
41
- import * as nodeFs from "node:fs";
41
+ import { TypeScriptClient } from "./clients/typescript-client.js";
42
42
 
43
- const DEBUG_LOG = "C:/Users/R3LiC/Desktop/pi-lens-debug.log";
43
+ const DEBUG_LOG = path.join(os.homedir(), "pi-lens-debug.log");
44
44
  function dbg(msg: string) {
45
- const line = `[${new Date().toISOString()}] ${msg}\n`;
46
- try { nodeFs.appendFileSync(DEBUG_LOG, line); } catch (e) { console.error("[pi-lens-debug] write failed:", e); }
45
+ const line = `[${new Date().toISOString()}] ${msg}\n`;
46
+ try {
47
+ nodeFs.appendFileSync(DEBUG_LOG, line);
48
+ } catch (e) {
49
+ console.error("[pi-lens-debug] write failed:", e);
50
+ }
47
51
  }
48
52
 
49
53
  // --- State ---
50
54
 
51
- let verbose = false;
55
+ let _verbose = false;
52
56
 
53
57
  function log(msg: string) {
54
- console.log(`[pi-lens] ${msg}`);
58
+ console.log(`[pi-lens] ${msg}`);
55
59
  }
56
60
 
57
61
  // --- Extension ---
58
62
 
59
63
  export default function (pi: ExtensionAPI) {
60
- log("Extension loaded");
61
-
62
- const tsClient = new TypeScriptClient();
63
- const astGrepClient = new AstGrepClient();
64
- const ruffClient = new RuffClient();
65
- const biomeClient = new BiomeClient();
66
- const knipClient = new KnipClient();
67
- const todoScanner = new TodoScanner();
68
- const jscpdClient = new JscpdClient();
69
- const typeCoverageClient = new TypeCoverageClient();
70
- const depChecker = new DependencyChecker();
71
-
72
- // --- Flags ---
73
-
74
- pi.registerFlag("lens-verbose", {
75
- description: "Enable verbose pi-lens logging",
76
- type: "boolean",
77
- default: false,
78
- });
79
-
80
- pi.registerFlag("no-biome", {
81
- description: "Disable Biome linting/formatting",
82
- type: "boolean",
83
- default: false,
84
- });
85
-
86
- pi.registerFlag("no-ast-grep", {
87
- description: "Disable ast-grep structural analysis",
88
- type: "boolean",
89
- default: false,
90
- });
91
-
92
- pi.registerFlag("no-ruff", {
93
- description: "Disable Ruff Python linting",
94
- type: "boolean",
95
- default: false,
96
- });
97
-
98
- pi.registerFlag("no-lsp", {
99
- description: "Disable TypeScript LSP",
100
- type: "boolean",
101
- default: false,
102
- });
103
-
104
- pi.registerFlag("no-madge", {
105
- description: "Disable circular dependency checking via madge",
106
- type: "boolean",
107
- default: false,
108
- });
109
-
110
- pi.registerFlag("autofix-biome", {
111
- description: "Auto-fix Biome lint/format issues on write (applies --write --unsafe)",
112
- type: "boolean",
113
- default: true,
114
- });
115
-
116
- pi.registerFlag("autofix-ruff", {
117
- description: "Auto-fix Ruff lint/format issues on write",
118
- type: "boolean",
119
- default: true,
120
- });
121
-
122
- // --- Commands ---
123
-
124
- pi.registerCommand("find-todos", {
125
- description: "Scan for TODO/FIXME/HACK annotations. Usage: /find-todos [path]",
126
- handler: async (args, ctx) => {
127
- const targetPath = args.trim() || ctx.cwd || process.cwd();
128
- ctx.ui.notify("🔍 Scanning for TODOs...", "info");
129
-
130
- const result = todoScanner.scanDirectory(targetPath);
131
- const report = todoScanner.formatResult(result);
132
-
133
- if (report) {
134
- ctx.ui.notify(report, "info");
135
- } else {
136
- ctx.ui.notify("✓ No TODOs found", "info");
137
- }
138
- },
139
- });
140
-
141
- pi.registerCommand("dead-code", {
142
- description: "Check for unused exports, files, and dependencies",
143
- handler: async (args, ctx) => {
144
- if (!knipClient.isAvailable()) {
145
- ctx.ui.notify("Knip not installed. Run: npm install -D knip", "error");
146
- return;
147
- }
148
-
149
- ctx.ui.notify("🔍 Analyzing for dead code...", "info");
150
- const result = knipClient.analyze(args.trim() || ctx.cwd);
151
- const report = knipClient.formatResult(result);
152
-
153
- if (report) {
154
- ctx.ui.notify(report, "info");
155
- } else {
156
- ctx.ui.notify("✓ No dead code found", "info");
157
- }
158
- },
159
- });
160
-
161
- pi.registerCommand("check-deps", {
162
- description: "Check for circular dependencies in the project",
163
- handler: async (args, ctx) => {
164
- if (!depChecker.isAvailable()) {
165
- ctx.ui.notify("Madge not installed. Run: npm install -D madge", "error");
166
- return;
167
- }
168
-
169
- ctx.ui.notify("🔍 Scanning dependencies...", "info");
170
- const { circular } = depChecker.scanProject(args.trim() || ctx.cwd);
171
- const report = depChecker.formatScanResult(circular);
172
-
173
- if (report) {
174
- ctx.ui.notify(report, "warning");
175
- } else {
176
- ctx.ui.notify("✓ No circular dependencies found", "info");
177
- }
178
- },
179
- });
180
-
181
- pi.registerCommand("format", {
182
- description: "Apply Biome formatting to files. Usage: /format [file-path] or /format --all",
183
- handler: async (args, ctx) => {
184
- if (!biomeClient.isAvailable()) {
185
- ctx.ui.notify("Biome not installed. Run: npm install -D @biomejs/biome", "error");
186
- return;
187
- }
188
-
189
- const arg = args.trim();
190
-
191
- if (!arg || arg === "--all") {
192
- ctx.ui.notify("🔍 Formatting all files...", "info");
193
-
194
- let formatted = 0;
195
- let skipped = 0;
196
-
197
- const formatDir = (dir: string) => {
198
- if (!require("node:fs").existsSync(dir)) return;
199
- const entries = require("node:fs").readdirSync(dir, { withFileTypes: true });
200
-
201
- for (const entry of entries) {
202
- const fullPath = path.join(dir, entry.name);
203
- if (entry.isDirectory()) {
204
- if (["node_modules", ".git", "dist", "build", ".next"].includes(entry.name)) continue;
205
- formatDir(fullPath);
206
- } else if (/\.(ts|tsx|js|jsx|json|css)$/.test(entry.name)) {
207
- const result = biomeClient.formatFile(fullPath);
208
- if (result.changed) formatted++;
209
- else if (result.success) skipped++;
210
- }
211
- }
212
- };
213
-
214
- formatDir(ctx.cwd || process.cwd());
215
- ctx.ui.notify(`✓ Formatted ${formatted} file(s), ${skipped} already clean`, "info");
216
- return;
217
- }
218
-
219
- const filePath = path.resolve(arg);
220
- const result = biomeClient.formatFile(filePath);
221
-
222
- if (result.success && result.changed) {
223
- ctx.ui.notify(`✓ Formatted ${path.basename(filePath)}`, "info");
224
- } else if (result.success) {
225
- ctx.ui.notify(`✓ ${path.basename(filePath)} already clean`, "info");
226
- } else {
227
- ctx.ui.notify(`⚠️ Format failed: ${result.error}`, "error");
228
- }
229
- },
230
- });
231
-
232
- // Delivered once into the first tool_result of the session, then cleared
233
- let sessionSummary: string | null = null;
234
-
235
- // --- Events ---
236
-
237
- pi.on("session_start", async (_event, ctx) => {
238
- verbose = !!pi.getFlag("lens-verbose");
239
-
240
- // Log available tools
241
- const tools: string[] = [];
242
- tools.push("TypeScript LSP"); // Always available
243
- if (biomeClient.isAvailable()) tools.push("Biome");
244
- if (astGrepClient.isAvailable()) tools.push("ast-grep");
245
- if (ruffClient.isAvailable()) tools.push("Ruff");
246
- if (knipClient.isAvailable()) tools.push("Knip");
247
- if (depChecker.isAvailable()) tools.push("Madge");
248
- if (jscpdClient.isAvailable()) tools.push("jscpd");
249
- if (typeCoverageClient.isAvailable()) tools.push("type-coverage");
250
-
251
- log(`Active tools: ${tools.join(", ")}`);
252
-
253
- const cwd = ctx.cwd ?? process.cwd();
254
- const parts: string[] = [];
255
-
256
- // TODO/FIXME scan fast, no deps
257
- const todoResult = todoScanner.scanDirectory(cwd);
258
- const todoReport = todoScanner.formatResult(todoResult);
259
- if (todoReport) parts.push(todoReport);
260
-
261
- // Dead code scan — only if knip is available
262
- if (knipClient.isAvailable()) {
263
- const knipResult = knipClient.analyze(cwd);
264
- const knipReport = knipClient.formatResult(knipResult);
265
- if (knipReport) parts.push(knipReport);
266
- }
267
-
268
- // Duplicate code detection
269
- if (jscpdClient.isAvailable()) {
270
- const jscpdResult = jscpdClient.scan(cwd);
271
- const jscpdReport = jscpdClient.formatResult(jscpdResult);
272
- if (jscpdReport) parts.push(jscpdReport);
273
- }
274
-
275
- // TypeScript type coverage
276
- if (typeCoverageClient.isAvailable()) {
277
- const tcResult = typeCoverageClient.scan(cwd);
278
- const tcReport = typeCoverageClient.formatResult(tcResult);
279
- if (tcReport) parts.push(tcReport);
280
- }
281
-
282
- if (parts.length > 0) {
283
- sessionSummary = `[Session Start]\n${parts.join("\n\n")}`;
284
- }
285
- });
286
-
287
- // --- Pre-write proactive hints ---
288
- // Stored during tool_call, prepended to tool_result output so the agent sees them.
289
- const preWriteHints = new Map<string, string>();
290
-
291
- pi.on("tool_call", async (event, _ctx) => {
292
- const filePath = isToolCallEventType("write", event)
293
- ? (event.input as { path: string }).path
294
- : isToolCallEventType("edit", event)
295
- ? (event.input as { path: string }).path
296
- : undefined;
297
-
298
- if (!filePath) return;
299
-
300
- const fs = require("node:fs") as typeof import("node:fs");
301
- dbg(`tool_call fired for: ${filePath} (exists: ${fs.existsSync(filePath)})`);
302
- if (!fs.existsSync(filePath)) return;
303
-
304
- const hints: string[] = [];
305
-
306
- if (/\.(ts|tsx|js|jsx)$/.test(filePath) && !pi.getFlag("no-lsp")) {
307
- tsClient.updateFile(filePath, fs.readFileSync(filePath, "utf-8"));
308
- const diags = tsClient.getDiagnostics(filePath);
309
- if (diags.length > 0) {
310
- hints.push(`⚠ Pre-write: file already has ${diags.length} TypeScript error(s) — fix before adding more`);
311
- }
312
- }
313
-
314
- if (/\.(ts|tsx|js|jsx)$/.test(filePath) && !pi.getFlag("no-biome") && biomeClient.isAvailable()) {
315
- const diags = biomeClient.checkFile(filePath);
316
- if (diags.length > 0) {
317
- hints.push(`⚠ Pre-write: file already has ${diags.length} Biome issue(s)`);
318
- }
319
- }
320
-
321
- if (!pi.getFlag("no-ast-grep") && astGrepClient.isAvailable()) {
322
- const diags = astGrepClient.scanFile(filePath);
323
- if (diags.length > 0) {
324
- hints.push(`⚠ Pre-write: file already has ${diags.length} structural violations`);
325
- }
326
- }
327
-
328
- dbg(` pre-write hints: ${hints.length} ${hints.join(" | ") || "none"}`);
329
- if (hints.length > 0) {
330
- preWriteHints.set(filePath, hints.join("\n"));
331
- }
332
- });
333
-
334
- // Real-time feedback on file writes/edits
335
- pi.on("tool_result", async (event) => {
336
- if (event.toolName !== "write" && event.toolName !== "edit") return;
337
-
338
- const filePath = (event.input as { path?: string }).path;
339
- if (!filePath) return;
340
-
341
- dbg(`tool_result fired for: ${filePath}`);
342
- dbg(` cwd: ${process.cwd()}`);
343
- dbg(` __dirname: ${typeof __dirname !== "undefined" ? __dirname : "undefined"}`);
344
-
345
- // Deliver session-start summary (TODOs, dead code) once into the first tool_result
346
- const sessionDump = sessionSummary;
347
- sessionSummary = null;
348
-
349
- // Prepend any pre-write hints collected during tool_call
350
- const preHint = preWriteHints.get(filePath);
351
- preWriteHints.delete(filePath);
352
-
353
- let lspOutput = sessionDump ? `\n\n${sessionDump}` : "";
354
- if (preHint) lspOutput += `\n\n${preHint}`;
355
-
356
- // TypeScript LSP diagnostics
357
- if (!pi.getFlag("no-lsp") && tsClient.isTypeScriptFile(filePath)) {
358
- const fs = require("node:fs");
359
- if (fs.existsSync(filePath)) {
360
- tsClient.updateFile(filePath, fs.readFileSync(filePath, "utf-8"));
361
- }
362
-
363
- const diags = tsClient.getDiagnostics(filePath);
364
- if (diags.length > 0) {
365
- lspOutput += `\n\n[TypeScript] ${diags.length} issue(s):\n`;
366
- for (const d of diags.slice(0, 10)) {
367
- const label = d.severity === 2 ? "Warning" : "Error";
368
- lspOutput += ` [${label}] L${d.range.start.line + 1}: ${d.message}\n`;
369
- }
370
- }
371
- }
372
-
373
- // Python — Ruff linting + formatting
374
- if (!pi.getFlag("no-ruff") && ruffClient.isPythonFile(filePath)) {
375
- const diags = ruffClient.checkFile(filePath);
376
- const fmtReport = ruffClient.checkFormatting(filePath);
377
- const fixable = diags.filter(d => d.fixable);
378
- const hasFormatIssues = !!fmtReport;
379
-
380
- if (pi.getFlag("autofix-ruff")) {
381
- // Apply fixes then re-check to show what remains
382
- let fixed = 0;
383
- let formatted = false;
384
- if (fixable.length > 0) {
385
- const fixResult = ruffClient.fixFile(filePath);
386
- if (fixResult.success && fixResult.changed) fixed = fixResult.fixed ?? fixable.length;
387
- }
388
- const fmtResult = ruffClient.formatFile(filePath);
389
- if (fmtResult.success && fmtResult.changed) formatted = true;
390
-
391
- if (fixed > 0 || formatted) {
392
- lspOutput += `\n\n[Ruff] Auto-fixed: ${fixed} lint issue(s)${formatted ? ", reformatted" : ""} — file updated on disk`;
393
- // Re-check remaining issues
394
- const remaining = ruffClient.checkFile(filePath);
395
- const remainingFmt = ruffClient.checkFormatting(filePath);
396
- if (remaining.length > 0 || remainingFmt) {
397
- lspOutput += `\n\n${ruffClient.formatDiagnostics(remaining)}`;
398
- if (remainingFmt) lspOutput += `\n\n${remainingFmt}`;
399
- } else {
400
- lspOutput += `\n\n[Ruff] ✓ All issues resolved`;
401
- }
402
- } else {
403
- if (diags.length > 0) lspOutput += `\n\n${ruffClient.formatDiagnostics(diags)}`;
404
- if (fmtReport) lspOutput += `\n\n${fmtReport}`;
405
- }
406
- } else {
407
- if (diags.length > 0) lspOutput += `\n\n${ruffClient.formatDiagnostics(diags)}`;
408
- if (fmtReport) lspOutput += `\n\n${fmtReport}`;
409
- if (fixable.length > 0 || hasFormatIssues) {
410
- lspOutput += `\n\n[Ruff] ${fixable.length} fixable — enable --autofix-ruff flag to auto-fix`;
411
- }
412
- }
413
- }
414
-
415
- // ast-grep structural analysis
416
- const astAvailable = astGrepClient.isAvailable();
417
- dbg(` ast-grep available: ${astAvailable}, no-ast-grep: ${pi.getFlag("no-ast-grep")}`);
418
- if (!pi.getFlag("no-ast-grep") && astAvailable) {
419
- const astDiags = astGrepClient.scanFile(filePath);
420
- dbg(` ast-grep diags: ${astDiags.length}`);
421
- if (astDiags.length > 0) {
422
- lspOutput += `\n\n${astGrepClient.formatDiagnostics(astDiags)}`;
423
- }
424
- }
425
-
426
- // Biome: lint + format check
427
- const biomeAvailable = biomeClient.isAvailable();
428
- dbg(` biome available: ${biomeAvailable}, supported: ${biomeClient.isSupportedFile(filePath)}, no-biome: ${pi.getFlag("no-biome")}`);
429
- if (!pi.getFlag("no-biome") && biomeClient.isSupportedFile(filePath)) {
430
- const biomeDiags = biomeClient.checkFile(filePath);
431
- dbg(` biome diags: ${biomeDiags.length}`);
432
- if (pi.getFlag("autofix-biome") && biomeDiags.length > 0) {
433
- // Always attempt fix — let Biome decide what it can do
434
- const fixResult = biomeClient.fixFile(filePath);
435
- if (fixResult.success && fixResult.changed) {
436
- lspOutput += `\n\n[Biome] Auto-fixed ${fixResult.fixed} issue(s) — file updated on disk`;
437
- const remaining = biomeClient.checkFile(filePath);
438
- if (remaining.length > 0) {
439
- lspOutput += `\n\n${biomeClient.formatDiagnostics(remaining, filePath)}`;
440
- } else {
441
- lspOutput += `\n\n[Biome] All issues resolved`;
442
- }
443
- } else {
444
- // Nothing fixable — show diagnostics as-is
445
- lspOutput += `\n\n${biomeClient.formatDiagnostics(biomeDiags, filePath)}`;
446
- }
447
- } else if (biomeDiags.length > 0) {
448
- const fixable = biomeDiags.filter(d => d.fixable);
449
- lspOutput += `\n\n${biomeClient.formatDiagnostics(biomeDiags, filePath)}`;
450
- if (fixable.length > 0) {
451
- lspOutput += `\n\n[Biome] ${fixable.length} fixable — enable --autofix-biome flag or run /format`;
452
- }
453
- }
454
- }
455
-
456
- // Circular dependency check (cached, only when imports change)
457
- if (!pi.getFlag("no-madge") && depChecker.isAvailable() && /\.(ts|tsx|js|jsx)$/.test(filePath)) {
458
- const depResult = depChecker.checkFile(filePath);
459
- if (depResult.hasCircular && depResult.circular.length > 0) {
460
- const circularDeps = depResult.circular
461
- .map(d => d.path)
462
- .flat()
463
- .filter((p: string) => !filePath.endsWith(require("node:path").basename(p)));
464
- const uniqueDeps = [...new Set(circularDeps)];
465
- if (uniqueDeps.length > 0) {
466
- lspOutput += `\n\n${depChecker.formatWarning(filePath, uniqueDeps)}`;
467
- }
468
- }
469
- }
470
-
471
- if (!lspOutput) return;
472
-
473
- return {
474
- content: [...event.content, { type: "text" as const, text: lspOutput }],
475
- };
476
- });
64
+ log("Extension loaded");
65
+
66
+ const tsClient = new TypeScriptClient();
67
+ const astGrepClient = new AstGrepClient();
68
+ const ruffClient = new RuffClient();
69
+ const biomeClient = new BiomeClient();
70
+ const knipClient = new KnipClient();
71
+ const todoScanner = new TodoScanner();
72
+ const jscpdClient = new JscpdClient();
73
+ const typeCoverageClient = new TypeCoverageClient();
74
+ const depChecker = new DependencyChecker();
75
+
76
+ // --- Flags ---
77
+
78
+ pi.registerFlag("lens-verbose", {
79
+ description: "Enable verbose pi-lens logging",
80
+ type: "boolean",
81
+ default: false,
82
+ });
83
+
84
+ pi.registerFlag("no-biome", {
85
+ description: "Disable Biome linting/formatting",
86
+ type: "boolean",
87
+ default: false,
88
+ });
89
+
90
+ pi.registerFlag("no-ast-grep", {
91
+ description: "Disable ast-grep structural analysis",
92
+ type: "boolean",
93
+ default: false,
94
+ });
95
+
96
+ pi.registerFlag("no-ruff", {
97
+ description: "Disable Ruff Python linting",
98
+ type: "boolean",
99
+ default: false,
100
+ });
101
+
102
+ pi.registerFlag("no-lsp", {
103
+ description: "Disable TypeScript LSP",
104
+ type: "boolean",
105
+ default: false,
106
+ });
107
+
108
+ pi.registerFlag("no-madge", {
109
+ description: "Disable circular dependency checking via madge",
110
+ type: "boolean",
111
+ default: false,
112
+ });
113
+
114
+ pi.registerFlag("autofix-biome", {
115
+ description:
116
+ "Auto-fix Biome lint/format issues on write (applies --write --unsafe)",
117
+ type: "boolean",
118
+ default: true,
119
+ });
120
+
121
+ pi.registerFlag("autofix-ruff", {
122
+ description: "Auto-fix Ruff lint/format issues on write",
123
+ type: "boolean",
124
+ default: true,
125
+ });
126
+
127
+ // --- Commands ---
128
+
129
+ pi.registerCommand("find-todos", {
130
+ description:
131
+ "Scan for TODO/FIXME/HACK annotations. Usage: /find-todos [path]",
132
+ handler: async (args, ctx) => {
133
+ const targetPath = args.trim() || ctx.cwd || process.cwd();
134
+ ctx.ui.notify("🔍 Scanning for TODOs...", "info");
135
+
136
+ const result = todoScanner.scanDirectory(targetPath);
137
+ const report = todoScanner.formatResult(result);
138
+
139
+ if (report) {
140
+ ctx.ui.notify(report, "info");
141
+ } else {
142
+ ctx.ui.notify("✓ No TODOs found", "info");
143
+ }
144
+ },
145
+ });
146
+
147
+ pi.registerCommand("dead-code", {
148
+ description: "Check for unused exports, files, and dependencies",
149
+ handler: async (args, ctx) => {
150
+ if (!knipClient.isAvailable()) {
151
+ ctx.ui.notify("Knip not installed. Run: npm install -D knip", "error");
152
+ return;
153
+ }
154
+
155
+ ctx.ui.notify("🔍 Analyzing for dead code...", "info");
156
+ const result = knipClient.analyze(args.trim() || ctx.cwd);
157
+ const report = knipClient.formatResult(result);
158
+
159
+ if (report) {
160
+ ctx.ui.notify(report, "info");
161
+ } else {
162
+ ctx.ui.notify("✓ No dead code found", "info");
163
+ }
164
+ },
165
+ });
166
+
167
+ pi.registerCommand("check-deps", {
168
+ description: "Check for circular dependencies in the project",
169
+ handler: async (args, ctx) => {
170
+ if (!depChecker.isAvailable()) {
171
+ ctx.ui.notify(
172
+ "Madge not installed. Run: npm install -D madge",
173
+ "error",
174
+ );
175
+ return;
176
+ }
177
+
178
+ ctx.ui.notify("🔍 Scanning dependencies...", "info");
179
+ const { circular } = depChecker.scanProject(args.trim() || ctx.cwd);
180
+ const report = depChecker.formatScanResult(circular);
181
+
182
+ if (report) {
183
+ ctx.ui.notify(report, "warning");
184
+ } else {
185
+ ctx.ui.notify("✓ No circular dependencies found", "info");
186
+ }
187
+ },
188
+ });
189
+
190
+ pi.registerCommand("format", {
191
+ description:
192
+ "Apply Biome formatting to files. Usage: /format [file-path] or /format --all",
193
+ handler: async (args, ctx) => {
194
+ if (!biomeClient.isAvailable()) {
195
+ ctx.ui.notify(
196
+ "Biome not installed. Run: npm install -D @biomejs/biome",
197
+ "error",
198
+ );
199
+ return;
200
+ }
201
+
202
+ const arg = args.trim();
203
+
204
+ if (!arg || arg === "--all") {
205
+ ctx.ui.notify("🔍 Formatting all files...", "info");
206
+
207
+ let formatted = 0;
208
+ let skipped = 0;
209
+
210
+ const formatDir = (dir: string) => {
211
+ if (!require("node:fs").existsSync(dir)) return;
212
+ const entries = require("node:fs").readdirSync(dir, {
213
+ withFileTypes: true,
214
+ });
215
+
216
+ for (const entry of entries) {
217
+ const fullPath = path.join(dir, entry.name);
218
+ if (entry.isDirectory()) {
219
+ if (
220
+ ["node_modules", ".git", "dist", "build", ".next"].includes(
221
+ entry.name,
222
+ )
223
+ )
224
+ continue;
225
+ formatDir(fullPath);
226
+ } else if (/\.(ts|tsx|js|jsx|json|css)$/.test(entry.name)) {
227
+ const result = biomeClient.formatFile(fullPath);
228
+ if (result.changed) formatted++;
229
+ else if (result.success) skipped++;
230
+ }
231
+ }
232
+ };
233
+
234
+ formatDir(ctx.cwd || process.cwd());
235
+ ctx.ui.notify(
236
+ `✓ Formatted ${formatted} file(s), ${skipped} already clean`,
237
+ "info",
238
+ );
239
+ return;
240
+ }
241
+
242
+ const filePath = path.resolve(arg);
243
+ const result = biomeClient.formatFile(filePath);
244
+
245
+ if (result.success && result.changed) {
246
+ ctx.ui.notify(`✓ Formatted ${path.basename(filePath)}`, "info");
247
+ } else if (result.success) {
248
+ ctx.ui.notify(`✓ ${path.basename(filePath)} already clean`, "info");
249
+ } else {
250
+ ctx.ui.notify(`⚠️ Format failed: ${result.error}`, "error");
251
+ }
252
+ },
253
+ });
254
+
255
+ // Delivered once into the first tool_result of the session, then cleared
256
+ let sessionSummary: string | null = null;
257
+
258
+ // --- Events ---
259
+
260
+ pi.on("session_start", async (_event, ctx) => {
261
+ _verbose = !!pi.getFlag("lens-verbose");
262
+ dbg("session_start fired");
263
+
264
+ // Log available tools
265
+ const tools: string[] = [];
266
+ tools.push("TypeScript LSP"); // Always available
267
+ if (biomeClient.isAvailable()) tools.push("Biome");
268
+ if (astGrepClient.isAvailable()) tools.push("ast-grep");
269
+ if (ruffClient.isAvailable()) tools.push("Ruff");
270
+ if (knipClient.isAvailable()) tools.push("Knip");
271
+ if (depChecker.isAvailable()) tools.push("Madge");
272
+ if (jscpdClient.isAvailable()) tools.push("jscpd");
273
+ if (typeCoverageClient.isAvailable()) tools.push("type-coverage");
274
+
275
+ log(`Active tools: ${tools.join(", ")}`);
276
+ dbg(`session_start tools: ${tools.join(", ")}`);
277
+
278
+ const cwd = ctx.cwd ?? process.cwd();
279
+ dbg(`session_start cwd: ${cwd}`);
280
+ const parts: string[] = [];
281
+
282
+ // TODO/FIXME scan — fast, no deps
283
+ const todoResult = todoScanner.scanDirectory(cwd);
284
+ const todoReport = todoScanner.formatResult(todoResult);
285
+ dbg(`session_start TODO scan: ${todoResult.items.length} items`);
286
+ if (todoReport) parts.push(todoReport);
287
+
288
+ // Dead code scan — only if knip is available
289
+ if (knipClient.isAvailable()) {
290
+ const knipResult = knipClient.analyze(cwd);
291
+ const knipReport = knipClient.formatResult(knipResult);
292
+ dbg(`session_start Knip scan done`);
293
+ if (knipReport) parts.push(knipReport);
294
+ } else {
295
+ dbg(`session_start Knip: not available`);
296
+ }
297
+
298
+ // Duplicate code detection
299
+ if (jscpdClient.isAvailable()) {
300
+ const jscpdResult = jscpdClient.scan(cwd);
301
+ const jscpdReport = jscpdClient.formatResult(jscpdResult);
302
+ dbg(`session_start jscpd scan done`);
303
+ if (jscpdReport) parts.push(jscpdReport);
304
+ } else {
305
+ dbg(`session_start jscpd: not available`);
306
+ }
307
+
308
+ // TypeScript type coverage
309
+ if (typeCoverageClient.isAvailable()) {
310
+ const tcResult = typeCoverageClient.scan(cwd);
311
+ const tcReport = typeCoverageClient.formatResult(tcResult);
312
+ dbg(`session_start type-coverage scan done`);
313
+ if (tcReport) parts.push(tcReport);
314
+ } else {
315
+ dbg(`session_start type-coverage: not available`);
316
+ }
317
+
318
+ if (parts.length > 0) {
319
+ sessionSummary = `[Session Start]\n${parts.join("\n\n")}`;
320
+ dbg(`session_start summary queued (${parts.length} parts)`);
321
+ } else {
322
+ dbg(`session_start: no parts, no summary`);
323
+ }
324
+ });
325
+
326
+ // --- Pre-write proactive hints ---
327
+ // Stored during tool_call, prepended to tool_result output so the agent sees them.
328
+ const preWriteHints = new Map<string, string>();
329
+
330
+ pi.on("tool_call", async (event, _ctx) => {
331
+ const filePath = isToolCallEventType("write", event)
332
+ ? (event.input as { path: string }).path
333
+ : isToolCallEventType("edit", event)
334
+ ? (event.input as { path: string }).path
335
+ : undefined;
336
+
337
+ if (!filePath) return;
338
+
339
+ const fs = require("node:fs") as typeof import("node:fs");
340
+ dbg(
341
+ `tool_call fired for: ${filePath} (exists: ${fs.existsSync(filePath)})`,
342
+ );
343
+ if (!fs.existsSync(filePath)) return;
344
+
345
+ const hints: string[] = [];
346
+
347
+ if (/\.(ts|tsx|js|jsx)$/.test(filePath) && !pi.getFlag("no-lsp")) {
348
+ tsClient.updateFile(filePath, fs.readFileSync(filePath, "utf-8"));
349
+ const diags = tsClient.getDiagnostics(filePath);
350
+ if (diags.length > 0) {
351
+ hints.push(
352
+ `⚠ Pre-write: file already has ${diags.length} TypeScript error(s) — fix before adding more`,
353
+ );
354
+ }
355
+ }
356
+
357
+ if (
358
+ /\.(ts|tsx|js|jsx)$/.test(filePath) &&
359
+ !pi.getFlag("no-biome") &&
360
+ biomeClient.isAvailable()
361
+ ) {
362
+ const diags = biomeClient.checkFile(filePath);
363
+ if (diags.length > 0) {
364
+ hints.push(
365
+ `⚠ Pre-write: file already has ${diags.length} Biome issue(s)`,
366
+ );
367
+ }
368
+ }
369
+
370
+ if (!pi.getFlag("no-ast-grep") && astGrepClient.isAvailable()) {
371
+ const diags = astGrepClient.scanFile(filePath);
372
+ if (diags.length > 0) {
373
+ hints.push(
374
+ `⚠ Pre-write: file already has ${diags.length} structural violations`,
375
+ );
376
+ }
377
+ }
378
+
379
+ dbg(` pre-write hints: ${hints.length} — ${hints.join(" | ") || "none"}`);
380
+ if (hints.length > 0) {
381
+ preWriteHints.set(filePath, hints.join("\n"));
382
+ }
383
+ });
384
+
385
+ // Real-time feedback on file writes/edits
386
+ pi.on("tool_result", async (event) => {
387
+ if (event.toolName !== "write" && event.toolName !== "edit") return;
388
+
389
+ const filePath = (event.input as { path?: string }).path;
390
+ if (!filePath) return;
391
+
392
+ dbg(`tool_result fired for: ${filePath}`);
393
+ dbg(` cwd: ${process.cwd()}`);
394
+ dbg(
395
+ ` __dirname: ${typeof __dirname !== "undefined" ? __dirname : "undefined"}`,
396
+ );
397
+
398
+ // Deliver session-start summary (TODOs, dead code) once into the first tool_result
399
+ const sessionDump = sessionSummary;
400
+ sessionSummary = null;
401
+
402
+ // Prepend any pre-write hints collected during tool_call
403
+ const preHint = preWriteHints.get(filePath);
404
+ preWriteHints.delete(filePath);
405
+
406
+ let lspOutput = sessionDump ? `\n\n${sessionDump}` : "";
407
+ if (preHint) lspOutput += `\n\n${preHint}`;
408
+
409
+ // TypeScript LSP diagnostics
410
+ if (!pi.getFlag("no-lsp") && tsClient.isTypeScriptFile(filePath)) {
411
+ const fs = require("node:fs");
412
+ if (fs.existsSync(filePath)) {
413
+ tsClient.updateFile(filePath, fs.readFileSync(filePath, "utf-8"));
414
+ }
415
+
416
+ const diags = tsClient.getDiagnostics(filePath);
417
+ if (diags.length > 0) {
418
+ lspOutput += `\n\n[TypeScript] ${diags.length} issue(s):\n`;
419
+ for (const d of diags.slice(0, 10)) {
420
+ const label = d.severity === 2 ? "Warning" : "Error";
421
+ lspOutput += ` [${label}] L${d.range.start.line + 1}: ${d.message}\n`;
422
+ }
423
+ }
424
+ }
425
+
426
+ // Python — Ruff linting + formatting
427
+ if (!pi.getFlag("no-ruff") && ruffClient.isPythonFile(filePath)) {
428
+ const diags = ruffClient.checkFile(filePath);
429
+ const fmtReport = ruffClient.checkFormatting(filePath);
430
+ const fixable = diags.filter((d) => d.fixable);
431
+ const hasFormatIssues = !!fmtReport;
432
+
433
+ if (pi.getFlag("autofix-ruff")) {
434
+ // Apply fixes then re-check to show what remains
435
+ let fixed = 0;
436
+ let formatted = false;
437
+ if (fixable.length > 0) {
438
+ const fixResult = ruffClient.fixFile(filePath);
439
+ if (fixResult.success && fixResult.changed)
440
+ fixed = fixResult.fixed ?? fixable.length;
441
+ }
442
+ const fmtResult = ruffClient.formatFile(filePath);
443
+ if (fmtResult.success && fmtResult.changed) formatted = true;
444
+
445
+ if (fixed > 0 || formatted) {
446
+ lspOutput += `\n\n[Ruff] Auto-fixed: ${fixed} lint issue(s)${formatted ? ", reformatted" : ""} — file updated on disk`;
447
+ // Re-check remaining issues
448
+ const remaining = ruffClient.checkFile(filePath);
449
+ const remainingFmt = ruffClient.checkFormatting(filePath);
450
+ if (remaining.length > 0 || remainingFmt) {
451
+ lspOutput += `\n\n${ruffClient.formatDiagnostics(remaining)}`;
452
+ if (remainingFmt) lspOutput += `\n\n${remainingFmt}`;
453
+ } else {
454
+ lspOutput += `\n\n[Ruff] All issues resolved`;
455
+ }
456
+ } else {
457
+ if (diags.length > 0)
458
+ lspOutput += `\n\n${ruffClient.formatDiagnostics(diags)}`;
459
+ if (fmtReport) lspOutput += `\n\n${fmtReport}`;
460
+ }
461
+ } else {
462
+ if (diags.length > 0)
463
+ lspOutput += `\n\n${ruffClient.formatDiagnostics(diags)}`;
464
+ if (fmtReport) lspOutput += `\n\n${fmtReport}`;
465
+ if (fixable.length > 0 || hasFormatIssues) {
466
+ lspOutput += `\n\n[Ruff] ${fixable.length} fixable — enable --autofix-ruff flag to auto-fix`;
467
+ }
468
+ }
469
+ }
470
+
471
+ // ast-grep structural analysis
472
+ const astAvailable = astGrepClient.isAvailable();
473
+ dbg(
474
+ ` ast-grep available: ${astAvailable}, no-ast-grep: ${pi.getFlag("no-ast-grep")}`,
475
+ );
476
+ if (!pi.getFlag("no-ast-grep") && astAvailable) {
477
+ const astDiags = astGrepClient.scanFile(filePath);
478
+ dbg(` ast-grep diags: ${astDiags.length}`);
479
+ if (astDiags.length > 0) {
480
+ lspOutput += `\n\n${astGrepClient.formatDiagnostics(astDiags)}`;
481
+ }
482
+ }
483
+
484
+ // Biome: lint + format check
485
+ const biomeAvailable = biomeClient.isAvailable();
486
+ dbg(
487
+ ` biome available: ${biomeAvailable}, supported: ${biomeClient.isSupportedFile(filePath)}, no-biome: ${pi.getFlag("no-biome")}`,
488
+ );
489
+ if (!pi.getFlag("no-biome") && biomeClient.isSupportedFile(filePath)) {
490
+ const biomeDiags = biomeClient.checkFile(filePath);
491
+ dbg(` biome diags: ${biomeDiags.length}`);
492
+ if (pi.getFlag("autofix-biome") && biomeDiags.length > 0) {
493
+ // Always attempt fix — let Biome decide what it can do
494
+ const fixResult = biomeClient.fixFile(filePath);
495
+ if (fixResult.success && fixResult.changed) {
496
+ lspOutput += `\n\n[Biome] Auto-fixed ${fixResult.fixed} issue(s) — file updated on disk`;
497
+ const remaining = biomeClient.checkFile(filePath);
498
+ if (remaining.length > 0) {
499
+ lspOutput += `\n\n${biomeClient.formatDiagnostics(remaining, filePath)}`;
500
+ } else {
501
+ lspOutput += `\n\n[Biome] ✓ All issues resolved`;
502
+ }
503
+ } else {
504
+ // Nothing fixable — show diagnostics as-is
505
+ lspOutput += `\n\n${biomeClient.formatDiagnostics(biomeDiags, filePath)}`;
506
+ }
507
+ } else if (biomeDiags.length > 0) {
508
+ const fixable = biomeDiags.filter((d) => d.fixable);
509
+ lspOutput += `\n\n${biomeClient.formatDiagnostics(biomeDiags, filePath)}`;
510
+ if (fixable.length > 0) {
511
+ lspOutput += `\n\n[Biome] ${fixable.length} fixable — enable --autofix-biome flag or run /format`;
512
+ }
513
+ }
514
+ }
515
+
516
+ // Circular dependency check (cached, only when imports change)
517
+ if (
518
+ !pi.getFlag("no-madge") &&
519
+ depChecker.isAvailable() &&
520
+ /\.(ts|tsx|js|jsx)$/.test(filePath)
521
+ ) {
522
+ const depResult = depChecker.checkFile(filePath);
523
+ if (depResult.hasCircular && depResult.circular.length > 0) {
524
+ const circularDeps = depResult.circular
525
+ .flatMap((d) => d.path)
526
+ .filter(
527
+ (p: string) => !filePath.endsWith(require("node:path").basename(p)),
528
+ );
529
+ const uniqueDeps = [...new Set(circularDeps)];
530
+ if (uniqueDeps.length > 0) {
531
+ lspOutput += `\n\n${depChecker.formatWarning(filePath, uniqueDeps)}`;
532
+ }
533
+ }
534
+ }
535
+
536
+ if (!lspOutput) return;
537
+
538
+ return {
539
+ content: [...event.content, { type: "text" as const, text: lspOutput }],
540
+ };
541
+ });
477
542
  }
package/package.json CHANGED
@@ -1,50 +1,50 @@
1
1
  {
2
- "name": "pi-lens",
3
- "version": "1.1.1",
4
- "description": "Real-time code feedback for pi — TypeScript LSP, Biome, ast-grep, Ruff, TODO scanner, dead code, duplicate detection, type coverage",
5
- "main": "index.ts",
6
- "scripts": {
7
- "build": "tsc",
8
- "watch": "tsc --watch"
9
- },
10
- "keywords": [
11
- "pi",
12
- "pi-extension",
13
- "pi-package",
14
- "linter",
15
- "biome",
16
- "ast-grep",
17
- "ruff",
18
- "typescript",
19
- "code-quality",
20
- "feedback",
21
- "type-coverage",
22
- "jscpd",
23
- "knip"
24
- ],
25
- "author": "R3LiC",
26
- "license": "MIT",
27
- "files": [
28
- "index.ts",
29
- "clients/",
30
- "rules/",
31
- "tsconfig.json",
32
- "README.md"
33
- ],
34
- "peerDependencies": {
35
- "@mariozechner/pi-coding-agent": "*"
36
- },
37
- "dependencies": {
38
- "vscode-languageserver-protocol": "^3.17.5",
39
- "vscode-languageserver-types": "^3.17.5"
40
- },
41
- "devDependencies": {
42
- "@types/node": "^22.10.5",
43
- "typescript": "^5.0.0"
44
- },
45
- "pi": {
46
- "extensions": [
47
- "./index.ts"
48
- ]
49
- }
2
+ "name": "pi-lens",
3
+ "version": "1.1.2",
4
+ "description": "Real-time code feedback for pi — TypeScript LSP, Biome, ast-grep, Ruff, TODO scanner, dead code, duplicate detection, type coverage",
5
+ "main": "index.ts",
6
+ "scripts": {
7
+ "build": "tsc",
8
+ "watch": "tsc --watch"
9
+ },
10
+ "keywords": [
11
+ "pi",
12
+ "pi-extension",
13
+ "pi-package",
14
+ "linter",
15
+ "biome",
16
+ "ast-grep",
17
+ "ruff",
18
+ "typescript",
19
+ "code-quality",
20
+ "feedback",
21
+ "type-coverage",
22
+ "jscpd",
23
+ "knip"
24
+ ],
25
+ "author": "R3LiC",
26
+ "license": "MIT",
27
+ "files": [
28
+ "index.ts",
29
+ "clients/",
30
+ "rules/",
31
+ "tsconfig.json",
32
+ "README.md"
33
+ ],
34
+ "peerDependencies": {
35
+ "@mariozechner/pi-coding-agent": "*"
36
+ },
37
+ "dependencies": {
38
+ "vscode-languageserver-protocol": "^3.17.5",
39
+ "vscode-languageserver-types": "^3.17.5"
40
+ },
41
+ "devDependencies": {
42
+ "@types/node": "^22.10.5",
43
+ "typescript": "^5.0.0"
44
+ },
45
+ "pi": {
46
+ "extensions": [
47
+ "./index.ts"
48
+ ]
49
+ }
50
50
  }