UNPKG

pi-lens

Version:

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

259 lines (258 loc) • 9.93 kB
import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; import { safeSpawnAsync } from "../../safe-spawn.js"; import { findCompiledClassesDir } from "../../tool-policy.js"; import { PRIORITY } from "../priorities.js"; import { createAvailabilityChecker, resolveAvailableOrInstall, } from "./utils/runner-helpers.js"; // SpotBugs is a JVM tool installed via the archive strategy (#133); java gates // it (no JRE → skip). It analyzes the *bytecode tree*, not the edited source. const java = createAvailabilityChecker("java", ".exe", ["-version"]); const spotbugs = createAvailabilityChecker("spotbugs", ".bat", ["-version"]); const MAX_DIAGNOSTICS = 50; const MAX_XML_BYTES = 4 * 1024 * 1024; const scanCache = new Map(); /** Reset the SpotBugs scan cache (tests). */ export function _resetSpotbugsCacheForTests() { scanCache.clear(); } /** * Cheap signature of the compiled-classes tree: count + newest mtime of all * `.class` files. Changes iff the user rebuilt, which is exactly when SpotBugs * should re-run. */ function classesSignature(classesDir) { let count = 0; let newest = 0; const stack = [classesDir]; // Bounded walk — large class trees are fine (count is cheap), but cap depth // of the stack defensively. while (stack.length > 0) { const dir = stack.pop(); if (!dir) break; let entries; try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { continue; } for (const entry of entries) { const full = path.join(dir, entry.name); if (entry.isDirectory()) { stack.push(full); } else if (entry.isFile() && entry.name.endsWith(".class")) { count++; try { const m = fs.statSync(full).mtimeMs; if (m > newest) newest = m; } catch { } } } } return `${count}:${newest}`; } // SpotBugs bug categories → pi-lens defect taxonomy (per #133). function mapCategory(category) { switch (category.toUpperCase()) { case "CORRECTNESS": case "MT_CORRECTNESS": return "correctness"; case "SECURITY": return "safety"; case "PERFORMANCE": case "BAD_PRACTICE": case "STYLE": case "I18N": case "EXPERIMENTAL": return "style"; default: // Unmapped SpotBugs categories are still bug patterns — treat as correctness. return "correctness"; } } function mapSeverity(priority) { switch (priority) { case "1": return "error"; case "3": return "info"; default: return "warning"; } } function attr(attrs, name) { // Bounded, non-backtracking: a single negated-char class, no nesting (S5852-safe). const m = new RegExp(`\\b${name}="([^"]*)"`).exec(attrs); return m ? m[1] : undefined; } function firstSentence(text) { const trimmed = text.replace(/\s+/g, " ").trim(); const dot = trimmed.indexOf(". "); return (dot > 0 ? trimmed.slice(0, dot + 1) : trimmed).slice(0, 300); } /** * Parse SpotBugs `-xml:withMessages` output. Hand-rolled (no XML dep, zero-dep * ethos) but bounded: input capped, lazy `<BugInstance>…</BugInstance>` blocks * (no nested quantifiers), diagnostics capped. */ export function parseSpotbugsXml(raw) { const xml = raw.length > MAX_XML_BYTES ? raw.slice(0, MAX_XML_BYTES) : raw; const diagnostics = []; const blockRe = /<BugInstance\b([^>]*)>([\s\S]*?)<\/BugInstance>/g; let block; let index = 0; // biome-ignore lint/suspicious/noAssignInExpressions: standard regex-exec loop while ((block = blockRe.exec(xml)) !== null) { if (diagnostics.length >= MAX_DIAGNOSTICS) break; const headAttrs = block[1]; const body = block[2]; const type = attr(headAttrs, "type") || "SPOTBUGS"; const priority = attr(headAttrs, "priority") || "2"; const category = attr(headAttrs, "category") || "CORRECTNESS"; // A BugInstance has several SourceLines: class span, method span, and the // primary (the actual defect location, marked primary="true"). Prefer the // primary; otherwise the last one (most specific), else the first. let filePath; let line = 1; const slRe = /<SourceLine\b([^>]*?)\/?>/g; let sl; // biome-ignore lint/suspicious/noAssignInExpressions: standard regex-exec loop while ((sl = slRe.exec(body)) !== null) { const slAttrs = sl[1]; const start = attr(slAttrs, "start"); const sourcepath = attr(slAttrs, "sourcepath"); const sourcefile = attr(slAttrs, "sourcefile"); if (!start || !(sourcepath || sourcefile)) continue; filePath = sourcepath || sourcefile; line = Number(start) || 1; if (attr(slAttrs, "primary") === "true") break; // the defect location } if (!filePath) continue; // no source mapping — skip (can't surface usefully) const longMsgMatch = /<LongMessage>([\s\S]*?)<\/LongMessage>/.exec(body); const shortMsgMatch = /<ShortMessage>([\s\S]*?)<\/ShortMessage>/.exec(body); const longMsg = longMsgMatch ? decodeXmlEntities(longMsgMatch[1].trim()) : ""; const shortMsg = shortMsgMatch ? decodeXmlEntities(shortMsgMatch[1].trim()) : ""; const message = shortMsg || longMsg || type; const fixSuggestion = longMsg ? firstSentence(longMsg) : undefined; diagnostics.push({ id: `spotbugs:${type}:${path.basename(filePath)}:${line}:${index}`, message: `[${type}] ${message}`, filePath, line, column: 1, severity: mapSeverity(priority), // Bug patterns are advisory by default (not always exploitable); a user // can promote to blocking via config. So semantic stays "warning". semantic: "warning", tool: "spotbugs", rule: type, defectClass: mapCategory(category), fixable: false, autoFixAvailable: false, fixKind: fixSuggestion ? "suggestion" : undefined, fixSuggestion, }); index++; } return diagnostics; } function decodeXmlEntities(s) { return s .replace(/&lt;/g, "<") .replace(/&gt;/g, ">") .replace(/&quot;/g, '"') .replace(/&apos;/g, "'") .replace(/&amp;/g, "&"); } /** * SpotBugs bytecode bug-pattern analyzer for Java + Kotlin (#133). * * Opt-in (lens-spotbugs flag) and gated via the withSpotbugsGroup dispatch * group, which only adds it when a Java build descriptor + compiled .class dir * exist. Operates on the compiled tree, NOT the edited source, and only * re-invokes after a rebuild (mtime-cached). */ const spotbugsRunner = { id: "spotbugs", appliesTo: ["java", "kotlin"], priority: PRIORITY.DEEP_LANGUAGE_ANALYSIS, enabledByDefault: false, async run(ctx) { const cwd = ctx.cwd || process.cwd(); const classesDir = findCompiledClassesDir(cwd); if (!classesDir) { ctx.log?.("spotbugs: Java project detected but no compiled .class files found — run `mvn compile` / `gradle build` first"); return { status: "skipped", diagnostics: [], semantic: "none" }; } // JRE required — skip silently if absent (SpotBugs needs JDK 11+). if (!(await java.isAvailableAsync(cwd))) { return { status: "skipped", diagnostics: [], semantic: "none" }; } // Cache gate: re-run only when the .class tree changed since last scan. const signature = classesSignature(classesDir); const cached = scanCache.get(classesDir); if (cached && cached.signature === signature) { return { status: "succeeded", diagnostics: cached.diagnostics, semantic: cached.diagnostics.length > 0 ? "warning" : "none", }; } const cmd = await resolveAvailableOrInstall(spotbugs, "spotbugs", cwd); if (!cmd) return { status: "skipped", diagnostics: [], semantic: "none" }; const tmpXml = path.join(os.tmpdir(), `pi-lens-spotbugs-${process.pid}-${Date.now()}.xml`); const result = await safeSpawnAsync(cmd, [ "-textui", "-xml:withMessages", "-low", "-longBugCodes", "-output", tmpXml, classesDir, ], { cwd, timeout: 120000 }); let xml = ""; try { xml = fs.readFileSync(tmpXml, "utf8"); } catch { // No output file — if the run errored, surface a soft failure. if (result.error || (result.status ?? 0) > 1) { return { status: "failed", diagnostics: [], semantic: "none", rawOutput: (result.stderr || "").slice(0, 500), }; } return { status: "succeeded", diagnostics: [], semantic: "none" }; } finally { try { fs.rmSync(tmpXml, { force: true }); } catch { } } const diagnostics = parseSpotbugsXml(xml); scanCache.set(classesDir, { signature, diagnostics }); return { status: "succeeded", diagnostics, semantic: diagnostics.length > 0 ? "warning" : "none", }; }, }; export default spotbugsRunner;