pi-lens 1.1.1 → 1.2.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/README.md CHANGED
@@ -69,7 +69,16 @@ Example:
69
69
  | `/find-todos [path]` | Scan for TODO/FIXME/HACK annotations |
70
70
  | `/dead-code` | Find unused exports/files/dependencies (requires knip) |
71
71
  | `/check-deps` | Circular dependency scan (requires madge) |
72
- | `/format [file\|--all]` | Apply Biome formatting |
72
+ | `/format [file|--all]` | Apply Biome formatting |
73
+
74
+ ### On-demand tools
75
+
76
+ | Tool | Description |
77
+ |---|---|
78
+ | **`ast_grep_search`** | Search code patterns using AST-aware matching. Supports meta-variables: `$VAR` (single node), `$$$` (multiple). Example: `console.log($MSG)` |
79
+ | **`ast_grep_replace`** | Replace code patterns with AST-aware rewriting. Dry-run by default, use `apply=true` to apply changes. Example: `pattern='console.log($MSG)' rewrite='logger.info($MSG)'` |
80
+
81
+ Supported languages: c, cpp, csharp, css, dart, elixir, go, haskell, html, java, javascript, json, kotlin, lua, php, python, ruby, rust, scala, sql, swift, tsx, typescript, yaml
73
82
 
74
83
  ---
75
84
 
@@ -110,6 +119,8 @@ pip install ruff
110
119
 
111
120
  Rules live in `rules/ast-grep-rules/rules/`. All rules are YAML files you can edit or extend.
112
121
 
122
+ Each rule includes a `message` and `note` that are shown in diagnostics, so the agent understands why something violated a rule and how to fix it.
123
+
113
124
  **Security**
114
125
  `no-eval`, `no-implied-eval`, `no-hardcoded-secrets`, `no-insecure-randomness`, `no-open-redirect`, `no-sql-in-code`, `no-inner-html`, `no-dangerously-set-inner-html`, `no-javascript-url`
115
126
 
@@ -8,12 +8,26 @@
8
8
  * Rules: ./rules/ directory
9
9
  */
10
10
 
11
- import { spawnSync } from "node:child_process";
11
+ import { spawn, spawnSync } from "node:child_process";
12
12
  import * as path from "node:path";
13
13
  import * as fs from "node:fs";
14
14
 
15
15
  // --- Types ---
16
16
 
17
+ export interface RuleDescription {
18
+ id: string;
19
+ message: string;
20
+ note?: string;
21
+ severity: "error" | "warning" | "info" | "hint";
22
+ }
23
+
24
+ export interface AstGrepMatch {
25
+ file: string;
26
+ range: { start: { line: number; column: number }; end: { line: number; column: number } };
27
+ text: string;
28
+ replacement?: string;
29
+ }
30
+
17
31
  export interface AstGrepDiagnostic {
18
32
  line: number;
19
33
  column: number;
@@ -22,6 +36,7 @@ export interface AstGrepDiagnostic {
22
36
  severity: "error" | "warning" | "info" | "hint";
23
37
  message: string;
24
38
  rule: string;
39
+ ruleDescription?: RuleDescription;
25
40
  file: string;
26
41
  fix?: string;
27
42
  }
@@ -61,17 +76,89 @@ export class AstGrepClient {
61
76
  private available: boolean | null = null;
62
77
  private ruleDir: string;
63
78
  private log: (msg: string) => void;
79
+ private ruleDescriptions: Map<string, RuleDescription> | null = null;
64
80
 
65
81
  constructor(ruleDir?: string, verbose = false) {
66
82
  this.ruleDir = ruleDir || path.join(typeof __dirname !== "undefined" ? __dirname : ".", "..", "rules");
67
83
  this.log = verbose
68
84
  ? (msg: string) => console.log(`[ast-grep] ${msg}`)
69
85
  : () => {};
86
+ }
87
+
88
+ /**
89
+ * Load rule descriptions from YAML files
90
+ */
91
+ private loadRuleDescriptions(): Map<string, RuleDescription> {
92
+ if (this.ruleDescriptions !== null) return this.ruleDescriptions;
93
+
94
+ const descriptions = new Map<string, RuleDescription>();
95
+
96
+ // Find the rules directory - check more specific paths first
97
+ const possiblePaths = [
98
+ path.join(this.ruleDir, "ast-grep-rules", "rules"),
99
+ path.join(this.ruleDir, "rules"),
100
+ this.ruleDir,
101
+ ];
102
+
103
+ let rulesPath = possiblePaths.find(p => fs.existsSync(p));
104
+
105
+ if (!rulesPath) {
106
+ this.log(`Rule descriptions: no rules directory found in ${possiblePaths.join(", ")}`);
107
+ this.ruleDescriptions = descriptions;
108
+ return descriptions;
109
+ }
110
+
70
111
  try {
71
- const nodeFs2 = require("node:fs") as typeof import("node:fs");
72
- nodeFs2.appendFileSync("C:/Users/R3LiC/Desktop/pi-lens-debug.log",
73
- `[${new Date().toISOString()}] AstGrepClient constructed, __dirname=${typeof __dirname !== "undefined" ? __dirname : "undefined"}, ruleDir=${this.ruleDir}\n`);
74
- } catch {}
112
+ const files = fs.readdirSync(rulesPath).filter(f => f.endsWith(".yml"));
113
+ this.log(`Loaded ${files.length} rule descriptions from ${rulesPath}`);
114
+ for (const file of files) {
115
+ const filePath = path.join(rulesPath, file);
116
+ const content = fs.readFileSync(filePath, "utf-8");
117
+ const rule = this.parseRuleYaml(content);
118
+ if (rule) {
119
+ descriptions.set(rule.id, rule);
120
+ }
121
+ }
122
+ } catch (err: any) {
123
+ this.log(`Failed to load rule descriptions: ${err.message}`);
124
+ }
125
+
126
+ this.ruleDescriptions = descriptions;
127
+ return descriptions;
128
+ }
129
+
130
+ /**
131
+ * Simple YAML parser for rule descriptions
132
+ */
133
+ private parseRuleYaml(content: string): RuleDescription | null {
134
+ const result: Partial<RuleDescription> = {};
135
+
136
+ // Extract id
137
+ const idMatch = content.match(/^id:\s*(.+)$/m);
138
+ if (idMatch) result.id = idMatch[1].trim();
139
+
140
+ // Extract message (handle quoted strings)
141
+ const msgMatch = content.match(/^message:\s*"([^"]+)"/m) || content.match(/^message:\s*'([^']+)'/m) || content.match(/^message:\s*(.+)$/m);
142
+ if (msgMatch) result.message = (msgMatch[3] || msgMatch[2] || msgMatch[1]).trim();
143
+
144
+ // Extract note (multiline, indented lines)
145
+ const noteMatch = content.match(/^note:\s*\|([\s\S]*?)(?=^\w|\n\n|\nrule:)/m);
146
+ if (noteMatch) {
147
+ result.note = noteMatch[1]
148
+ .split("\n")
149
+ .map(line => line.trim())
150
+ .filter(line => line.length > 0)
151
+ .join(" ");
152
+ }
153
+
154
+ // Extract severity
155
+ const sevMatch = content.match(/^severity:\s*(.+)$/m);
156
+ if (sevMatch) result.severity = this.mapSeverity(sevMatch[1].trim());
157
+
158
+ if (result.id && result.message) {
159
+ return result as RuleDescription;
160
+ }
161
+ return null;
75
162
  }
76
163
 
77
164
  /**
@@ -94,6 +181,72 @@ export class AstGrepClient {
94
181
  return this.available;
95
182
  }
96
183
 
184
+ /**
185
+ * Search for AST patterns in files
186
+ */
187
+ async search(pattern: string, lang: string, paths: string[]): Promise<{ matches: AstGrepMatch[]; error?: string }> {
188
+ return this.runSg(["run", "-p", pattern, "--lang", lang, "--json=compact", ...paths]);
189
+ }
190
+
191
+ /**
192
+ * Search and replace AST patterns
193
+ */
194
+ async replace(pattern: string, rewrite: string, lang: string, paths: string[], apply = false): Promise<{ matches: AstGrepMatch[]; applied: boolean; error?: string }> {
195
+ const args = ["run", "-p", pattern, "-r", rewrite, "--lang", lang, "--json=compact"];
196
+ if (apply) args.push("--update-all");
197
+ args.push(...paths);
198
+
199
+ const result = await this.runSg(args);
200
+ return { matches: result.matches, applied: apply, error: result.error };
201
+ }
202
+
203
+ private runSg(args: string[]): Promise<{ matches: AstGrepMatch[]; error?: string }> {
204
+ return new Promise((resolve) => {
205
+ const proc = spawn("npx", ["sg", ...args], { stdio: ["ignore", "pipe", "pipe"], shell: true });
206
+ let stdout = "";
207
+ let stderr = "";
208
+
209
+ proc.stdout.on("data", (data: Buffer) => (stdout += data.toString()));
210
+ proc.stderr.on("data", (data: Buffer) => (stderr += data.toString()));
211
+
212
+ proc.on("error", (err: Error) => {
213
+ if (err.message.includes("ENOENT")) {
214
+ resolve({ matches: [], error: "ast-grep CLI not found. Install: npm i -D @ast-grep/cli" });
215
+ } else {
216
+ resolve({ matches: [], error: err.message });
217
+ }
218
+ });
219
+
220
+ proc.on("close", (code: number | null) => {
221
+ if (code !== 0 && !stdout.trim()) {
222
+ resolve({ matches: [], error: stderr.includes("No files found") ? undefined : stderr.trim() || `Exit code ${code}` });
223
+ return;
224
+ }
225
+ if (!stdout.trim()) { resolve({ matches: [] }); return; }
226
+ try {
227
+ const parsed = JSON.parse(stdout);
228
+ const matches = Array.isArray(parsed) ? parsed : [parsed];
229
+ resolve({ matches });
230
+ } catch {
231
+ resolve({ matches: [], error: "Failed to parse output" });
232
+ }
233
+ });
234
+ });
235
+ }
236
+
237
+ formatMatches(matches: AstGrepMatch[], isDryRun = false): string {
238
+ if (matches.length === 0) return "No matches found";
239
+ const MAX = 50;
240
+ const shown = matches.slice(0, MAX);
241
+ const lines = shown.map((m) => {
242
+ const loc = `${m.file}:${m.range.start.line + 1}:${m.range.start.column + 1}`;
243
+ const text = m.text.length > 100 ? m.text.slice(0, 100) + "..." : m.text;
244
+ return isDryRun && m.replacement ? `${loc}\n - ${text}\n + ${m.replacement}` : `${loc}: ${text}`;
245
+ });
246
+ if (matches.length > MAX) lines.unshift(`Found ${matches.length} matches (showing first ${MAX}):`);
247
+ return lines.join("\n");
248
+ }
249
+
97
250
  /**
98
251
  * Scan a file against all rules
99
252
  */
@@ -137,22 +290,33 @@ export class AstGrepClient {
137
290
 
138
291
  const errors = diags.filter(d => d.severity === "error");
139
292
  const warnings = diags.filter(d => d.severity === "warning");
293
+ const hints = diags.filter(d => d.severity === "hint");
140
294
 
141
295
  let output = `[ast-grep] ${diags.length} structural issue(s)`;
142
296
  if (errors.length) output += ` — ${errors.length} error(s)`;
143
297
  if (warnings.length) output += ` — ${warnings.length} warning(s)`;
298
+ if (hints.length) output += ` — ${hints.length} hint(s)`;
144
299
  output += ":\n";
145
300
 
146
- for (const d of diags.slice(0, 15)) {
301
+ for (const d of diags.slice(0, 10)) {
147
302
  const loc = d.line === d.endLine
148
303
  ? `L${d.line}`
149
304
  : `L${d.line}-${d.endLine}`;
150
- const fix = d.fix ? " [fixable]" : "";
151
- output += ` [${d.rule}] ${loc} ${d.message}${fix}\n`;
305
+ const ruleInfo = d.ruleDescription
306
+ ? `${d.rule}: ${d.ruleDescription.message}`
307
+ : d.rule;
308
+ const fix = d.fix || d.ruleDescription?.note ? " [fixable]" : "";
309
+ output += ` ${ruleInfo} (${loc})${fix}\n`;
310
+
311
+ // Include note for errors to provide fix guidance
312
+ if (d.severity === "error" && d.ruleDescription?.note) {
313
+ const shortNote = d.ruleDescription.note.split("\n")[0];
314
+ output += ` → ${shortNote}\n`;
315
+ }
152
316
  }
153
317
 
154
- if (diags.length > 15) {
155
- output += ` ... and ${diags.length - 15} more\n`;
318
+ if (diags.length > 10) {
319
+ output += ` ... and ${diags.length - 10} more\n`;
156
320
  }
157
321
 
158
322
  return output;
@@ -214,6 +378,7 @@ export class AstGrepClient {
214
378
  severity: this.mapSeverity(item.severity),
215
379
  message: item.message || "Unknown issue",
216
380
  rule: item.ruleId || "unknown",
381
+ ruleDescription: this.getRuleDescription(item.ruleId || "unknown"),
217
382
  file: filePath,
218
383
  };
219
384
  }
@@ -229,6 +394,7 @@ export class AstGrepClient {
229
394
  const start = span.range?.start || { line: 0, column: 0 };
230
395
  const end = span.range?.end || start;
231
396
 
397
+ const ruleId = item.name || item.ruleId || "unknown";
232
398
  return {
233
399
  line: start.line + 1,
234
400
  column: start.column,
@@ -236,7 +402,8 @@ export class AstGrepClient {
236
402
  endColumn: end.column,
237
403
  severity: this.mapSeverity(item.severity || item.Severity || "warning"),
238
404
  message: item.Message?.text || item.message || "Unknown issue",
239
- rule: item.name || item.ruleId || "unknown",
405
+ rule: ruleId,
406
+ ruleDescription: this.getRuleDescription(ruleId),
240
407
  file: filePath,
241
408
  };
242
409
  }
@@ -244,6 +411,11 @@ export class AstGrepClient {
244
411
  return null;
245
412
  }
246
413
 
414
+ private getRuleDescription(ruleId: string): RuleDescription | undefined {
415
+ const descriptions = this.loadRuleDescriptions();
416
+ return descriptions.get(ruleId);
417
+ }
418
+
247
419
  private mapSeverity(severity: string): AstGrepDiagnostic["severity"] {
248
420
  const lower = severity.toLowerCase();
249
421
  if (lower === "error") return "error";
@@ -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
  }