pi-lens 1.1.2 → 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";
package/index.ts CHANGED
@@ -28,6 +28,7 @@
28
28
  import * as nodeFs from "node:fs";
29
29
  import * as os from "node:os";
30
30
  import * as path from "node:path";
31
+ import { Type } from "@sinclair/typebox";
31
32
  import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
32
33
  import { isToolCallEventType } from "@mariozechner/pi-coding-agent";
33
34
  import { AstGrepClient } from "./clients/ast-grep-client.js";
@@ -55,13 +56,12 @@ function dbg(msg: string) {
55
56
  let _verbose = false;
56
57
 
57
58
  function log(msg: string) {
58
- console.log(`[pi-lens] ${msg}`);
59
+ if (_verbose) console.log(`[pi-lens] ${msg}`);
59
60
  }
60
61
 
61
62
  // --- Extension ---
62
63
 
63
64
  export default function (pi: ExtensionAPI) {
64
- log("Extension loaded");
65
65
 
66
66
  const tsClient = new TypeScriptClient();
67
67
  const astGrepClient = new AstGrepClient();
@@ -252,6 +252,74 @@ export default function (pi: ExtensionAPI) {
252
252
  },
253
253
  });
254
254
 
255
+ // --- Tools ---
256
+
257
+ const LANGUAGES = [
258
+ "c", "cpp", "csharp", "css", "dart", "elixir", "go", "haskell", "html",
259
+ "java", "javascript", "json", "kotlin", "lua", "php", "python", "ruby",
260
+ "rust", "scala", "sql", "swift", "tsx", "typescript", "yaml",
261
+ ] as const;
262
+
263
+ pi.registerTool({
264
+ name: "ast_grep_search",
265
+ label: "AST Search",
266
+ description: "Search code patterns using AST-aware matching. Use meta-variables: $VAR (single node), $$$ (multiple). Examples: 'console.log($MSG)', 'def $FUNC($$$):'",
267
+ parameters: Type.Object({
268
+ pattern: Type.String({ description: "AST pattern with meta-variables" }),
269
+ lang: Type.Union(LANGUAGES.map((l) => Type.Literal(l)), { description: "Target language" }),
270
+ paths: Type.Optional(Type.Array(Type.String(), { description: "Paths to search (default: .)" })),
271
+ }),
272
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
273
+ if (!astGrepClient.isAvailable()) {
274
+ return { content: [{ type: "text", text: "ast-grep CLI not found. Install: npm i -D @ast-grep/cli" }], isError: true, details: {} };
275
+ }
276
+
277
+ const { pattern, lang, paths } = params as { pattern: string; lang: string; paths?: string[] };
278
+ const searchPaths = paths?.length ? paths : [ctx.cwd || "."];
279
+ const result = await astGrepClient.search(pattern, lang, searchPaths);
280
+
281
+ if (result.error) {
282
+ return { content: [{ type: "text", text: `Error: ${result.error}` }], isError: true, details: {} };
283
+ }
284
+
285
+ const output = astGrepClient.formatMatches(result.matches);
286
+ return { content: [{ type: "text", text: output }], details: { matchCount: result.matches.length } };
287
+ },
288
+ });
289
+
290
+ pi.registerTool({
291
+ name: "ast_grep_replace",
292
+ label: "AST Replace",
293
+ description: "Replace code patterns with AST-aware rewriting. Dry-run by default (preview changes). Use apply=true to apply. Example: pattern='console.log($MSG)' rewrite='logger.info($MSG)'",
294
+ parameters: Type.Object({
295
+ pattern: Type.String({ description: "AST pattern to match" }),
296
+ rewrite: Type.String({ description: "Replacement pattern" }),
297
+ lang: Type.Union(LANGUAGES.map((l) => Type.Literal(l)), { description: "Target language" }),
298
+ paths: Type.Optional(Type.Array(Type.String(), { description: "Paths to search (default: .)" })),
299
+ apply: Type.Optional(Type.Boolean({ description: "Apply changes (default: false)" })),
300
+ }),
301
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
302
+ if (!astGrepClient.isAvailable()) {
303
+ return { content: [{ type: "text", text: "ast-grep CLI not found. Install: npm i -D @ast-grep/cli" }], isError: true, details: {} };
304
+ }
305
+
306
+ const { pattern, rewrite, lang, paths, apply } = params as { pattern: string; rewrite: string; lang: string; paths?: string[]; apply?: boolean };
307
+ const searchPaths = paths?.length ? paths : [ctx.cwd || "."];
308
+ const result = await astGrepClient.replace(pattern, rewrite, lang, searchPaths, apply ?? false);
309
+
310
+ if (result.error) {
311
+ return { content: [{ type: "text", text: `Error: ${result.error}` }], isError: true, details: {} };
312
+ }
313
+
314
+ const isDryRun = !apply;
315
+ let output = astGrepClient.formatMatches(result.matches, isDryRun);
316
+ if (isDryRun && result.matches.length > 0) output += "\n\n(Dry run - use apply=true to apply)";
317
+ if (apply && result.matches.length > 0) output = `Applied ${result.matches.length} replacements:\n${output}`;
318
+
319
+ return { content: [{ type: "text", text: output }], details: { matchCount: result.matches.length, applied: apply ?? false } };
320
+ },
321
+ });
322
+
255
323
  // Delivered once into the first tool_result of the session, then cleared
256
324
  let sessionSummary: string | null = null;
257
325
 
package/package.json CHANGED
@@ -1,7 +1,8 @@
1
1
  {
2
2
  "name": "pi-lens",
3
- "version": "1.1.2",
3
+ "version": "1.2.0",
4
4
  "description": "Real-time code feedback for pi — TypeScript LSP, Biome, ast-grep, Ruff, TODO scanner, dead code, duplicate detection, type coverage",
5
+ "repository": "github:apmantza/pi-lens",
5
6
  "main": "index.ts",
6
7
  "scripts": {
7
8
  "build": "tsc",