UNPKG

pi-lens

Version:

Real-time code feedback for pi — LSP, linters, formatters, type-checking, structural analysis & booboo

549 lines (548 loc) • 21.1 kB
/** * AstGrep Client for pi-lens * * Structural code analysis using ast-grep CLI. * Scans files against YAML rule definitions. * * Requires: npm install -D @ast-grep/cli * Rules: ./rules/ directory */ import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; import { AstGrepRuleManager } from "./ast-grep-rule-manager.js"; import { resolvePackagePath } from "./package-root.js"; import { SgRunner } from "./sg-runner.js"; // --- Client --- function extractDebugAst(raw) { const lines = raw.split(/\r?\n/); const start = lines.findIndex((line) => /^Debug (?:A|C)ST:/.test(line.trim())); if (start < 0) return undefined; const out = []; for (const line of lines.slice(start + 1)) { if (!line.trim()) break; out.push(line); } return out.length > 0 ? out.join("\n") : undefined; } function lineStartOffsets(source) { const offsets = [0]; for (let index = 0; index < source.length; index++) { if (source.charCodeAt(index) === 10) offsets.push(index + 1); } return offsets; } function snippetForRange(source, offsets, startLine0, startCol0, endLine0, endCol0) { const start = (offsets[startLine0] ?? 0) + startCol0; const end = (offsets[endLine0] ?? source.length) + endCol0; const text = source.slice(start, end).replace(/\s+/g, " ").trim(); return text.length > 80 ? `${text.slice(0, 77)}...` : text; } const MAX_VALIDATE_PATTERN_CHARS = 20_000; const MAX_VALIDATE_RULE_CHARS = 200_000; const VALIDATION_SNIPPETS = { bash: { ext: "sh", source: "echo pi_lens_validate\n" }, c: { ext: "c", source: "int main(void) { return 0; }\n" }, cpp: { ext: "cpp", source: "int main() { return 0; }\n" }, csharp: { ext: "cs", source: "class C { static void Main() {} }\n" }, css: { ext: "css", source: ".pi-lens { color: black; }\n" }, go: { ext: "go", source: "package main\nfunc main() {}\n" }, html: { ext: "html", source: "<main>pi-lens</main>\n" }, java: { ext: "java", source: "class Main { void run() {} }\n" }, javascript: { ext: "js", source: "const piLensValidate = 1;\n" }, json: { ext: "json", source: "{\"piLensValidate\":true}\n" }, kotlin: { ext: "kt", source: "fun main() {}\n" }, lua: { ext: "lua", source: "local pi_lens_validate = 1\n" }, php: { ext: "php", source: "<?php $piLensValidate = 1;\n" }, python: { ext: "py", source: "pi_lens_validate = 1\n" }, ruby: { ext: "rb", source: "pi_lens_validate = 1\n" }, rust: { ext: "rs", source: "fn main() {}\n" }, tsx: { ext: "tsx", source: "export function App() { return <div />; }\n" }, typescript: { ext: "ts", source: "const piLensValidate = 1;\n" }, yaml: { ext: "yaml", source: "piLensValidate: true\n" }, }; function validationSnippetFor(language) { const key = language.toLowerCase().replace(/^"|"$/g, ""); return VALIDATION_SNIPPETS[key] ?? { ext: key.replace(/[^a-z0-9_-]/gi, "") || "txt", source: "pi_lens_validate\n" }; } function validateInputShape(value, maxChars, label) { if (value.includes("\0")) return `${label} contains NUL bytes`; if (value.length > maxChars) { return `${label} is too large (${value.length} chars, max ${maxChars})`; } return undefined; } function stderrHasError(stderr) { return stderr.split(/\r?\n/).some((line) => /^\s*(error|Error):/.test(line)); } function formatDebugAst(tree, source) { const offsets = lineStartOffsets(source); return tree .split(/\r\n|\n/) .map((line) => { const match = /^([ \t]*)([^ \t(][^(]*)? \((\d+),(\d+)\)-\((\d+),(\d+)\)$/.exec(line); if (!match) return line; const [, indent = "", label = "", startLine, startCol, endLine, endCol] = match; const sl = Number(startLine); const sc = Number(startCol); const el = Number(endLine); const ec = Number(endCol); const snippet = snippetForRange(source, offsets, sl, sc, el, ec); return `${indent}${label} [${sl + 1},${sc + 1}] - [${el + 1},${ec + 1}] ${JSON.stringify(snippet)}`; }) .join("\n"); } export class AstGrepClient { ruleDir; log; ruleManager; runner; constructor(ruleDir, verbose = false) { const projectRuleDir = path.join(process.cwd(), "rules"); this.ruleDir = ruleDir || (fs.existsSync(projectRuleDir) ? projectRuleDir : resolvePackagePath(import.meta.url, "rules")); this.log = verbose ? (msg) => console.error(`[ast-grep] ${msg}`) : () => { }; this.ruleManager = new AstGrepRuleManager(this.ruleDir, this.log); this.runner = new SgRunner(verbose); } /** * Check if ast-grep CLI is available, auto-install if not */ ensureAvailable() { return this.runner.ensureAvailable(); } /** * Replace using a raw YAML rule that includes a `fix:` field (Phase 3/4 of #125). * Dry-run returns matches for preview; apply writes fixes to disk. */ async replaceWithRule(ruleYaml, paths, apply) { const allMatches = []; for (const scanPath of paths) { if (apply) { // Stale-preview check: dry-run first const preCheck = await this.runner.tempScanAsync(scanPath, "agent-rule", ruleYaml); if (preCheck.length === 0) { return { matches: [], totalMatches: 0, applied: false, stalePreview: true, }; } } const result = await this.runner.tempScanWithFixAsync(scanPath, "agent-rule", ruleYaml, apply); if (result.error) { return { matches: allMatches, totalMatches: allMatches.length, applied: false, error: result.error, }; } allMatches.push(...result.matches); } return { matches: allMatches, totalMatches: allMatches.length, applied: apply, }; } /** * Search using a raw YAML rule (Phase 4 of #125). * Routes through sg scan --config rather than sg run -p. * Each path is scanned independently; results are merged. */ async searchWithRule(ruleYaml, paths) { const allMatches = []; for (const scanPath of paths) { try { const results = await this.runner.tempScanAsync(scanPath, "agent-rule", ruleYaml); allMatches.push(...results); } catch (err) { return { matches: allMatches, totalMatches: allMatches.length, error: String(err), }; } } return { matches: allMatches, totalMatches: allMatches.length }; } /** * Dump the parsed tree-sitter AST for a snippet using ast-grep CLI. */ async dumpAst(source, lang, options = {}) { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-lens-ast-dump-")); const tmpFile = path.join(tmpDir, `snippet.${lang.replace(/[^a-z0-9_-]/gi, "") || "txt"}`); try { fs.writeFileSync(tmpFile, source, "utf-8"); const mode = options.includeAnonymous ? "cst" : "ast"; const result = await this.runner.execRaw([ "run", "--lang", lang, "-p", source, `--debug-query=${mode}`, tmpFile, ]); const raw = result.stderr || result.stdout; const tree = extractDebugAst(raw); if (tree) return { output: formatDebugAst(tree, source) }; return { error: result.error || result.stderr.trim() || result.stdout.trim() || `ast-grep did not return a debug AST for language ${lang}`, }; } finally { fs.rmSync(tmpDir, { recursive: true, force: true }); } } async validatePattern(pattern, lang, options) { const shapeError = validateInputShape(pattern, MAX_VALIDATE_PATTERN_CHARS, "pattern"); if (shapeError) return { valid: false, error: shapeError }; const snippet = validationSnippetFor(lang); const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-lens-sg-validate-")); const tmpFile = path.join(tmpDir, `snippet.${snippet.ext}`); try { fs.writeFileSync(tmpFile, snippet.source, "utf-8"); const args = ["run", "-p", pattern, "--lang", lang, "--json=compact"]; if (options?.selector) args.push("--selector", options.selector); if (options?.strictness) args.push("--strictness", options.strictness); args.push(tmpFile); const result = await this.runner.execRaw(args); const stderr = result.stderr.trim(); const stdout = result.stdout.trim(); if (result.error) return { valid: false, error: result.error }; if (stderrHasError(stderr)) return { valid: false, error: stderr }; const warning = stderr || stdout || undefined; return { valid: true, ...(warning ? { warning } : {}), }; } finally { try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch { // Best-effort cleanup; never mask the validation result. } } } async validateRule(ruleYaml) { const shapeError = validateInputShape(ruleYaml, MAX_VALIDATE_RULE_CHARS, "rule"); if (shapeError) return { valid: false, error: shapeError }; const language = /^\s*language:\s*([^\s#]+)/im.exec(ruleYaml)?.[1] ?? "typescript"; const snippet = validationSnippetFor(language); const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-lens-sg-rule-")); try { fs.writeFileSync(path.join(tmpDir, `snippet.${snippet.ext}`), snippet.source, "utf-8"); await this.runner.tempScanAsync(tmpDir, "agent-rule", ruleYaml, 10000); return { valid: true }; } catch (err) { return { valid: false, error: String(err) }; } finally { try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch { // Best-effort cleanup; never mask the validation result. } } } /** * Syntax-only code outline via `ast-grep outline` (#311) — symbols, imports, * exports, and members for file or directory input. Raw, fast, no index/LSP; * complements module_report (which adds the cached graph's who-uses-this, * complexity, and blast radius). Returns parsed JSON; args go through * `execRaw` (execFile-style, no shell), so no interpolation risk. */ async outline(paths, options = {}) { if (paths.length === 0) return { error: "no paths provided" }; const args = ["outline", "--json=compact", "--color", "never"]; if (options.lang) args.push("--lang", options.lang); if (options.items) args.push("--items", options.items); if (options.view) args.push("--view", options.view); if (options.types?.length) args.push("--type", options.types.join(",")); if (options.match) args.push("--match", options.match); if (options.pubMembers) args.push("--pub-members"); for (const glob of options.globs ?? []) args.push("--globs", glob); args.push(...paths); const result = await this.runner.execRaw(args); const raw = (result.stdout ?? "").trim(); if (!raw) { return { error: result.error || result.stderr?.trim() || "ast-grep outline returned no output", }; } try { return { output: JSON.parse(raw) }; } catch (err) { return { error: `failed to parse ast-grep outline JSON: ${err instanceof Error ? err.message : String(err)}`, }; } } /** * Search for AST patterns in files */ async search(pattern, lang, paths, options) { const args = ["run", "-p", pattern, "--lang", lang, "--json=compact"]; if (options?.selector) { args.push("--selector", options.selector); } if (options?.context !== undefined) { args.push("--context", String(options.context)); } if (options?.strictness) { args.push("--strictness", options.strictness); } args.push(...paths); const result = await this.runner.exec(args); return { matches: result.matches, totalMatches: result.totalMatches, truncated: result.truncated, error: result.error, }; } /** * Search and replace AST patterns */ async replace(pattern, rewrite, lang, paths, apply = false, options) { const baseArgs = ["run", "-p", pattern, "-r", rewrite, "--lang", lang]; if (options?.strictness) { baseArgs.push("--strictness", options.strictness); } if (!apply) { // Dry-run: --json=compact shows what would change without writing const result = await this.runner.exec([ ...baseArgs, "--json=compact", ...paths, ]); return { matches: result.matches, totalMatches: result.totalMatches, truncated: result.truncated, applied: false, error: result.error, }; } // Stale-preview check: re-run dry-run before writing. // If the pattern no longer matches, the files changed since the preview. const preCheck = await this.runner.exec([ ...baseArgs, "--json=compact", ...paths, ]); if (preCheck.error) { return { matches: [], totalMatches: 0, truncated: false, applied: false, error: preCheck.error, }; } if (preCheck.matches.length === 0) { return { matches: [], totalMatches: 0, truncated: false, applied: false, stalePreview: true, }; } // Apply: --update-all writes the files. We do NOT recount afterwards — // the original pattern no longer matches post-rewrite, and searching for // the rewrite as a pattern is unreliable (multi-line rewrites and // metavariable substitutions don't round-trip into a valid search // pattern, yielding a false "0 matches" even on a successful apply). // preCheck above already captured exactly what matched and was rewritten. const applyResult = await this.runner.exec([ ...baseArgs, "--update-all", ...paths, ]); if (applyResult.error) { return { matches: [], totalMatches: 0, truncated: false, applied: false, error: applyResult.error, }; } return { matches: preCheck.matches, totalMatches: preCheck.totalMatches, truncated: preCheck.truncated, applied: true, error: undefined, }; } /** * Run a one-off scan with a temporary rule and configuration */ async runTempScanAsync(dir, ruleId, ruleYaml, timeout = 30000) { if (!(await this.ensureAvailable())) return []; return this.runner.tempScanAsync(dir, ruleId, ruleYaml, timeout); } /** * Find similar functions by comparing normalized AST structure */ async findSimilarFunctions(dir, lang = "typescript") { const ruleYaml = `id: find-functions language: ${lang} rule: kind: function_declaration severity: info message: found `; const matches = await this.runTempScanAsync(dir, "find-functions", ruleYaml); if (matches.length === 0) return []; return this.groupSimilarFunctions(matches); } groupSimilarFunctions(matches) { const grouped = new Map(); for (const item of matches) { const name = this.extractFunctionName(item.text); if (!name) continue; const signature = this.normalizeFunction(item.text); const line = (item.range?.start?.line || item.labels?.[0]?.range?.start?.line || 0) + 1; const group = grouped.get(signature) ?? []; group.push({ name, file: item.file, line }); grouped.set(signature, group); } return Array.from(grouped.entries()) .filter(([_, functions]) => functions.length > 1) .map(([pattern, functions]) => ({ pattern, functions })); } /** * Extract function name from match text */ extractFunctionName(text) { return text.match(/function\s+(\w+)/)?.[1] ?? null; } normalizeFunction(text) { const normalizedText = text .replace(/function\s+\w+/, "function FN") .replace(/\bconst\b|\blet\b|\bvar\b/g, "VAR") .replace(/["'].*?["']/g, "STR") .replace(/`[^`]*`/g, "TMPL") .replace(/\b\d+\b/g, "NUM") .replace(/\btrue\b|\bfalse\b/g, "BOOL") .replace(/\/\/.*/g, "") .replace(/\/\*[\s\S]*?\*\//g, "") .replace(/\s+/g, " ") .trim(); // Extract just the body structure const bodyMatch = normalizedText.match(/\{(.*)\}/); const body = bodyMatch ? bodyMatch[1].trim() : normalizedText; // Use first 200 chars as signature return body.slice(0, 200); } /** * Scan for exported function names in a directory */ async scanExports(dir, lang = "typescript") { const exports = new Map(); const ruleYaml = `id: find-functions language: ${lang} rule: kind: function_declaration severity: info message: found `; const matches = await this.runTempScanAsync(dir, "find-functions", ruleYaml, 15000); this.log(`scanExports output length: ${matches.length}`); for (const item of matches) { const text = item.text || ""; const nameMatch = text.match(/function\s+(\w+)/); if (nameMatch?.[1]) { this.log(`scanExports found: ${nameMatch[1]} in ${item.file}`); exports.set(nameMatch[1], item.file); } } return exports; } formatMatches(matches, isDryRun = false, showModeIndicator = false) { return this.runner.formatMatches(matches, isDryRun, 50, showModeIndicator); } /** * Format diagnostics for LLM consumption */ formatDiagnostics(diags) { if (diags.length === 0) return ""; const errors = diags.filter((d) => d.severity === "error"); const warnings = diags.filter((d) => d.severity === "warning"); const hints = diags.filter((d) => d.severity === "hint"); let output = `[ast-grep] ${diags.length} structural issue(s)`; if (errors.length) output += ` — ${errors.length} error(s)`; if (warnings.length) output += ` — ${warnings.length} warning(s)`; if (hints.length) output += ` — ${hints.length} hint(s)`; output += ":\n"; for (const d of diags.slice(0, 10)) { const loc = d.line === d.endLine ? `L${d.line}` : `L${d.line}-${d.endLine}`; const ruleInfo = d.ruleDescription ? `${d.rule}: ${d.ruleDescription.message}` : d.rule; const fix = d.fix || d.ruleDescription?.note ? " [fixable]" : ""; output += ` ${ruleInfo} (${loc})${fix}\n`; if (d.ruleDescription?.note) { const shortNote = d.ruleDescription.note.split(/\r?\n/)[0]; output += ` → ${shortNote}\n`; } } if (diags.length > 10) { output += ` ... and ${diags.length - 10} more\n`; } return output; } getRuleDescription(ruleId) { return this.ruleManager.loadRuleDescriptions().get(ruleId); } }