/** * auto-fix — end-of-turn formatter/linter dispatcher. * * Collects every file written during a turn (via `edit` / `write` tool * results and `context-guard:file-modified` events), then on `agent_end` * dispatches each file to a language-appropriate fixer command (eslint, * black, prettier, etc.). Fixes are applied silently; the user is only * notified of the final summary. * * Config resolution order (first hit wins): * 1. PI_AUTO_FIX=0 → extension is disabled entirely * 2. ~/.pi/agent/auto-fix.json * 3. built-in defaults (see DEFAULT_FIXERS below) */ import type { ExtensionAPI, ExtensionContext, } from "@earendil-works/pi-coding-agent"; import { spawn } from "node:child_process"; import { existsSync, readFileSync, statSync } from "node:fs"; import { homedir, tmpdir } from "node:os"; import { dirname, extname, isAbsolute, relative, resolve } from "node:path"; // --------------------------------------------------------------------------- // Config // --------------------------------------------------------------------------- interface FixerRule { /** File extensions this rule matches (include leading dot, lowercase). */ extensions: string[]; /** Shell command; `{files}` is replaced with space-separated, quoted paths. */ command: string; /** Optional human-readable label used in notifications. */ label?: string; } interface Config { enabled: boolean; fixers: FixerRule[]; /** Glob-ish substring ignore patterns applied to the relative path. */ ignore: string[]; /** Per-fixer timeout in ms. */ timeoutMs: number; /** Max parallel fixer invocations. */ concurrency: number; } const DEFAULT_FIXERS: FixerRule[] = [ { label: "eslint", extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"], command: "npx eslint --fix --no-error-on-unmatched-pattern {files}", }, { label: "ruff", extensions: [".py"], command: "uvx ruff check --fix --quiet {files} && uvx ruff format --quiet {files}", }, { label: "prettier", extensions: [".json", ".md", ".yml", ".yaml", ".css", ".scss", ".html"], command: "npx prettier --write --log-level=warn {files}", }, ]; const DEFAULT_CONFIG: Config = { enabled: true, fixers: DEFAULT_FIXERS, ignore: ["node_modules/", "dist/", "build/", ".git/", ".next/", "coverage/"], timeoutMs: 60_000, concurrency: 3, }; const CONFIG_PATH = `${homedir()}/.pi/agent/auto-fix.json`; function loadConfig(): Config { if (process.env.PI_AUTO_FIX === "0") { return { ...DEFAULT_CONFIG, enabled: false }; } if (!existsSync(CONFIG_PATH)) return DEFAULT_CONFIG; try { const parsed = JSON.parse( readFileSync(CONFIG_PATH, "utf-8"), ) as Partial; return { enabled: parsed.enabled ?? DEFAULT_CONFIG.enabled, fixers: parsed.fixers ?? DEFAULT_CONFIG.fixers, ignore: parsed.ignore ?? DEFAULT_CONFIG.ignore, timeoutMs: parsed.timeoutMs ?? DEFAULT_CONFIG.timeoutMs, concurrency: parsed.concurrency ?? DEFAULT_CONFIG.concurrency, }; } catch { return DEFAULT_CONFIG; } } // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- function shellQuote(s: string): string { if (process.platform === "win32") { return `"${s.replace(/"/g, '\\"')}"`; } return `'${s.replace(/'/g, "'\\''")}'`; } function matchFixer( absPath: string, fixers: FixerRule[], ): FixerRule | undefined { const ext = extname(absPath).toLowerCase(); if (!ext) return undefined; return fixers.find((f) => f.extensions.includes(ext)); } function isIgnored(relPath: string, ignore: string[]): boolean { return ignore.some((p) => relPath.includes(p)); } function mtimeSafe(path: string): number { try { return statSync(path).mtimeMs; } catch { return -1; } } /** * Walk up from `startDir` until a directory containing `node_modules/.bin/` * is found. Returns `{ binPath, projectRoot }` or undefined. */ function findLocalBin( startDir: string, tool: string, ): { binPath: string; projectRoot: string } | undefined { let dir = startDir; while (true) { const candidates = process.platform === "win32" ? [ `${dir}/node_modules/.bin/${tool}.cmd`, `${dir}/node_modules/.bin/${tool}.exe`, `${dir}/node_modules/.bin/${tool}.bat`, `${dir}/node_modules/.bin/${tool}`, ] : [`${dir}/node_modules/.bin/${tool}`]; const binPath = candidates.find((candidate) => existsSync(candidate)); if (binPath) return { binPath, projectRoot: dir }; const parent = dirname(dir); if (parent === dir) return undefined; dir = parent; } } /** * Walk up from `startDir` until a `package.json` is found. */ function findProjectRoot(startDir: string): string | undefined { let dir = startDir; while (true) { if (existsSync(`${dir}/package.json`)) return dir; const parent = dirname(dir); if (parent === dir) return undefined; dir = parent; } } const ESLINT_FLAT_CONFIG_FILES = [ "eslint.config.mjs", "eslint.config.js", "eslint.config.cjs", "eslint.config.ts", "eslint.config.mts", "eslint.config.cts", ]; const ESLINT_LEGACY_CONFIG_FILES = [ ".eslintrc.cjs", ".eslintrc.mjs", ".eslintrc.js", ".eslintrc.json", ".eslintrc.yaml", ".eslintrc.yml", ".eslintrc", ]; function detectEslintConfigType(projectRoot: string): "flat" | "legacy" | "none" { for (const f of ESLINT_FLAT_CONFIG_FILES) { if (existsSync(`${projectRoot}/${f}`)) return "flat"; } for (const f of ESLINT_LEGACY_CONFIG_FILES) { if (existsSync(`${projectRoot}/${f}`)) return "legacy"; } return "none"; } function getEslintVersion(projectRoot: string): number | null { const pkgPath = `${projectRoot}/node_modules/eslint/package.json`; if (!existsSync(pkgPath)) return null; try { const pkg = JSON.parse(readFileSync(pkgPath, "utf-8")); const major = parseInt(pkg.version.split(".")[0], 10); return isNaN(major) ? null : major; } catch { return null; } } interface SpawnTarget { command: string | null; spawnCwd: string; env?: Record; } /** * ESLint-specific resolver that avoids version / config mismatches. * * Logic: * 1. Local ESLint installed? * a. v9+ + legacy config → use local + ESLINT_USE_FLAT_CONFIG=false * b. v8 + flat config → skip (incompatible) * c. otherwise → use local binary directly * 2. No local ESLint? * a. legacy config → `npx --yes eslint@8` (pin v8) * b. flat config → `npx --yes eslint@9` (pin v9+) * c. no config → skip */ function resolveEslintCommand( commandTemplate: string, files: string[], cwd: string, ): SpawnTarget { const firstFile = files[0]; const fileDir = firstFile ? dirname(firstFile) : cwd; const projectRoot = findProjectRoot(fileDir) ?? cwd; const local = findLocalBin(fileDir, "eslint"); const configType = detectEslintConfigType(projectRoot); const filesArg = files.map(shellQuote).join(" "); // Strip the binary invocation and {files} placeholder to keep only flags. const extraArgs = commandTemplate .replace(/^npx\s+/, "") .replace(/^(?:[.\\/][\S]+[\\/])?eslint(?:\.exe)?\b\s*/, "") .replace("{files}", "") .trim(); // CASE 1: Local ESLint installed — honour project's pinned version. if (local) { const version = getEslintVersion(local.projectRoot); // ESLint v9+ on a legacy-config project: force legacy mode so the v9 // binary can still read .eslintrc.* files. if (version !== null && version >= 9 && configType === "legacy") { return { command: `${shellQuote(local.binPath)} ${extraArgs} ${filesArg}`.trim(), spawnCwd: local.projectRoot, env: { ESLINT_USE_FLAT_CONFIG: "false" }, }; } // ESLint v8 on a flat-config project: incompatible — skip. if (version !== null && version < 9 && configType === "flat") { return { command: null, spawnCwd: local.projectRoot }; } return { command: `${shellQuote(local.binPath)} ${extraArgs} ${filesArg}`.trim(), spawnCwd: local.projectRoot, }; } // CASE 2: No local ESLint — pin a version compatible with the project's // config format so we never install latest v9 on a legacy-config repo. if (configType === "legacy") { return { command: `npx --yes eslint@8 ${extraArgs} ${filesArg}`.trim(), spawnCwd: projectRoot, }; } if (configType === "flat") { return { command: `npx --yes eslint@9 ${extraArgs} ${filesArg}`.trim(), spawnCwd: projectRoot, }; } // CASE 3: No ESLint config found — skip to avoid injecting unwanted rules. return { command: null, spawnCwd: projectRoot }; } /** * If `command` starts with `npx `, attempt to resolve a project-local * binary by walking up from the first file. On hit, rewrite the command to * exec the local bin directly and return the project root as cwd. On miss, * fall back to the OS temp directory so npx can fetch the latest from the * registry without being delegated through pnpm. */ function resolveSpawnTarget( command: string, files: string[], cwd: string, ): SpawnTarget { const npxMatch = command.match(/^npx\s+(\S+)/); if (!npxMatch) return { command, spawnCwd: cwd }; const tool = npxMatch[1]; const firstFile = files[0]; if (firstFile) { const local = findLocalBin(dirname(firstFile), tool); if (local) { return { command: command.replace(/^npx\s+\S+/, shellQuote(local.binPath)), spawnCwd: local.projectRoot, }; } } // No local install — neutral cwd avoids pnpm delegating npx. return { command, spawnCwd: tmpdir() }; } async function runFixer( rule: FixerRule, files: string[], cwd: string, timeoutMs: number, ): Promise<{ ok: boolean; stderr: string }> { const filesArg = files.map(shellQuote).join(" "); let resolved: SpawnTarget; if (rule.label === "eslint") { // ESLint gets version-aware command resolution so we don't install a // global v9 on a legacy v8-configured project (or vice-versa). resolved = resolveEslintCommand(rule.command, files, cwd); } else { const rawCommand = rule.command.includes("{files}") ? rule.command.replace("{files}", filesArg) : `${rule.command} ${filesArg}`; // Prefer the project's locally installed binary when available so we // honor the pinned version + config. Only fall back to neutral-cwd npx // when no local install is found, which sidesteps pnpm's delegation of // npx without auto-install. resolved = resolveSpawnTarget(rawCommand, files, cwd); } const command = resolved.command; if (command === null) { return { ok: true, stderr: `Skipped ${rule.label ?? rule.command}: no compatible target found.`, }; } return new Promise((resolvePromise) => { const child = spawn(command, [], { cwd: resolved.spawnCwd, shell: true, env: { ...process.env, ...resolved.env }, }); let stderr = ""; const timer = setTimeout(() => child.kill("SIGTERM"), timeoutMs); child.stderr?.on("data", (chunk: Buffer) => { stderr += chunk.toString(); }); child.on("error", () => { clearTimeout(timer); resolvePromise({ ok: false, stderr }); }); child.on("close", (code: number | null) => { clearTimeout(timer); resolvePromise({ ok: code === 0, stderr }); }); }); } async function runWithConcurrency( items: T[], limit: number, worker: (item: T) => Promise, ): Promise { const queue = [...items]; const workers = Array.from( { length: Math.min(limit, queue.length) }, async () => { while (queue.length) { const item = queue.shift(); if (item === undefined) return; await worker(item); } }, ); await Promise.all(workers); } // --------------------------------------------------------------------------- // Extension // --------------------------------------------------------------------------- export default function (pi: ExtensionAPI): void { const cfg = loadConfig(); if (!cfg.enabled) return; /** Absolute paths written during the current turn. */ const pending = new Set(); function collect(rawPath: string, cwd: string): void { if (!rawPath) return; const absolutePath = isAbsolute(rawPath) ? rawPath : resolve(cwd, rawPath); const rel = relative(cwd, absolutePath); if (rel.startsWith("..")) return; // outside cwd — skip if (isIgnored(rel, cfg.ignore)) return; pending.add(absolutePath); } // Reset between agent runs (turn boundary for pi's purposes). pi.on("agent_start", () => { pending.clear(); }); // Collector 1: direct edit/write tool results. pi.on("tool_result", async (event, ctx) => { if (event.isError) return; if (event.toolName !== "edit" && event.toolName !== "write") return; const rawPath = (event.input as { path?: string }).path; if (rawPath) collect(rawPath, ctx.cwd); }); // End-of-turn flush. pi.on("agent_end", async (_event, ctx) => { if (!pending.size) return; const paths = [...pending]; pending.clear(); await flush(paths, ctx); }); async function flush(paths: string[], ctx: ExtensionContext): Promise { // Filter to existing files and group by fixer rule. const groups = new Map(); for (const p of paths) { if (!existsSync(p)) continue; const rule = matchFixer(p, cfg.fixers); if (!rule) continue; const bucket = groups.get(rule) ?? []; bucket.push(p); groups.set(rule, bucket); } if (!groups.size) return; let changed = 0; const failures: string[] = []; const jobs = [...groups.entries()]; await runWithConcurrency(jobs, cfg.concurrency, async ([rule, files]) => { // Snapshot mtimes *before* running this fixer group. const groupBefore = new Map(files.map((p) => [p, mtimeSafe(p)])); const result = await runFixer(rule, files, ctx.cwd, cfg.timeoutMs); // Count how many files this fixer actually changed. const groupChanged = files.filter( (p) => mtimeSafe(p) !== groupBefore.get(p), ).length; changed += groupChanged; // Only count as failure if the tool failed AND no files changed. // Some tools (eslint --fix) exit non-zero even when they fix things. if (!result.ok && groupChanged === 0) { failures.push( `${rule.label ?? rule.command.split(" ")[0]} (${files.length} file${files.length === 1 ? "" : "s"})`, ); } // Re-emit file-modified for anything the fixer actually rewrote so // context-guard evicts its stale read cache. for (const p of files) { if (mtimeSafe(p) !== groupBefore.get(p)) { pi.events.emit("context-guard:file-modified", { path: p }); } } }); const total = [...groups.values()].reduce((n, b) => n + b.length, 0); if (changed > 0 || failures.length) { const parts: string[] = [ `auto-fix: ${changed}/${total} file${total === 1 ? "" : "s"} updated`, ]; if (failures.length) parts.push(`failed: ${failures.join(", ")}`); ctx.ui.notify( `[auto-fix] ${parts.join(" — ")}`, failures.length ? "warning" : "info", ); } } }