UNPKG

pi-lens

Version:

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

179 lines (178 loc) • 6.72 kB
/** * Oxlint runner for dispatch system * * Fast JavaScript/TypeScript linter written in Rust. * Drop-in replacement for ESLint with better performance. * * Requires: oxlint (npm install -g oxlint) */ import * as fs from "node:fs"; import * as path from "node:path"; import { walkUpDirs } from "../../path-utils.js"; import { safeSpawnAsync } from "../../safe-spawn.js"; import { getJstsLintPolicyForCwd, hasVitePlusConfig, } from "../../tool-policy.js"; import { PRIORITY } from "../priorities.js"; import { resolveToolCommand, resolveToolCommandWithInstallFallback, } from "./utils/runner-helpers.js"; function resolveLocalVp(cwd) { const isWin = process.platform === "win32"; for (const dir of walkUpDirs(cwd)) { const candidates = isWin ? [ path.join(dir, "node_modules", ".bin", "vp.cmd"), path.join(dir, "node_modules", ".bin", "vp"), ] : [path.join(dir, "node_modules", ".bin", "vp")]; for (const candidate of candidates) { if (fs.existsSync(candidate)) return candidate; } } return null; } async function resolveVitePlusCommand(cwd) { const local = resolveLocalVp(cwd); if (local) return local; const version = await safeSpawnAsync("vp", ["--version"], { timeout: 5000, cwd, }); return !version.error && version.status === 0 ? "vp" : null; } const oxlintRunner = { id: "oxlint", appliesTo: ["jsts"], priority: PRIORITY.LINT_SECONDARY, enabledByDefault: true, skipTestFiles: true, async run(ctx) { const cwd = ctx.cwd || process.cwd(); const policy = getJstsLintPolicyForCwd(cwd); if (!policy.preferredRunners.includes("oxlint")) { return { status: "skipped", diagnostics: [], semantic: "none" }; } let cmd = null; let args; if (hasVitePlusConfig(cwd)) { cmd = await resolveVitePlusCommand(cwd); } if (cmd) { args = ["lint", "--format", "json", ctx.filePath]; } else { // Use ctx.hasTool for async availability check — avoids the synchronous // spawnSync probe that blocks the event loop on first call per cwd. // FactStore caches the result for the session so subsequent writes are free. const oxlintCmd = resolveToolCommand(cwd, "oxlint") ?? "oxlint"; cmd = (await ctx.hasTool(oxlintCmd)) ? oxlintCmd : await resolveToolCommandWithInstallFallback(cwd, "oxlint"); args = ["--format", "json", ctx.filePath]; } if (!cmd) { return { status: "skipped", diagnostics: [], semantic: "none" }; } // Run oxlint (or Vite+'s vp lint wrapper) on the file. const result = await safeSpawnAsync(cmd, args, { timeout: 30000, }); // Oxlint returns non-zero when issues found if (result.status === 0) { return { status: "succeeded", diagnostics: [], semantic: "none" }; } // Parse JSON output. Fall back to the unix-format parser if JSON parsing // fails (older oxlint versions, malformed stderr noise, etc.) — keeps the // runner producing diagnostics even when the structured-fix metadata is // unavailable. const stdout = result.stdout ?? ""; const stderr = result.stderr ?? ""; let diagnostics = parseOxlintJson(stdout, ctx.filePath); if (diagnostics.length === 0 && stdout.length > 0) { diagnostics = parseOxlintUnix(stdout + stderr, ctx.filePath); } if (diagnostics.length === 0) { return { status: "succeeded", diagnostics: [], semantic: "none" }; } const hasBlocking = diagnostics.some((d) => d.semantic === "blocking"); return { status: "failed", diagnostics, semantic: hasBlocking ? "blocking" : "warning", }; }, }; // Oxlint codes look like "eslint(no-debugger)" or "oxc(approx-constant)". // Strip the plugin prefix so the rule lines up with what users expect. // indexOf-based extraction avoids a regex hot-spot Sonar flagged for // potential super-linear backtracking on adversarial inputs. function extractOxlintRule(code) { if (!code) return "unknown"; const open = code.indexOf("("); if (open === -1) return code; const close = code.indexOf(")", open + 1); if (close === -1 || close === open + 1) return code; return code.slice(open + 1, close); } function parseOxlintJson(raw, filePath) { const trimmed = raw.trim(); if (!trimmed.startsWith("{")) return []; let parsed; try { parsed = JSON.parse(trimmed); } catch { return []; } const diagnostics = []; for (const d of parsed.diagnostics ?? []) { const rule = extractOxlintRule(d.code); const label = d.labels?.[0]?.span; const lineNum = label?.line ?? 1; const colNum = label?.column ?? 1; const severity = d.severity === "error" ? "error" : "warning"; const help = d.help?.trim(); diagnostics.push({ id: `oxlint-${rule}-${lineNum}`, message: `${d.message ?? "oxlint issue"} (${rule})`, filePath, line: lineNum, column: colNum, severity, semantic: severity === "error" ? "blocking" : "warning", tool: "oxlint", rule, // Oxlint's help text is rule-specific guidance ("Remove the debugger // statement", "Consider removing this declaration"). Surface it as a // fix suggestion so the warning becomes actionable instead of falling // silently into code-quality. fixSuggestion: help && help.length > 0 ? help : undefined, }); } return diagnostics; } function parseOxlintUnix(raw, filePath) { const diagnostics = []; for (const line of raw.split("\n")) { // Parse: file:line:column: message (rule) const match = line.match(/^(.+):(\d+):(\d+):\s*(.+?)\s*\(([^)]+)\)$/); if (match) { const [, _file, lineStr, _col, message, rule] = match; diagnostics.push({ id: `oxlint-${rule}-${lineStr}`, message: `${message} (${rule})`, filePath, line: parseInt(lineStr, 10), severity: "warning", semantic: "warning", tool: "oxlint", rule, }); } } return diagnostics; } export default oxlintRunner;