import fs from "node:fs/promises"; import path from "node:path"; import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent"; import { Box, Spacer, Text } from "@earendil-works/pi-tui"; import { loadCodeQualityConfig } from "../_shared/config"; import { isPathIncluded, matchesAnyGlob } from "../_shared/glob"; import { findProjectRoot, normalizeRelativePath, resolveCommand, toAbsolutePath } from "../_shared/paths"; import { commandOutput, formatCommandIssue, formatDiagnosticOutput, formatWarnings, hasIssueOutput, joinSections } from "../_shared/output"; import { isExecutableAvailable, runCommand } from "../_shared/runner"; import type { CodeQualityToolConfig, CommandConfig } from "../_shared/types"; const DEFAULT_MAX_FILE_SIZE_BYTES = 2 * 1024 * 1024; interface MatchedTool { tool: CodeQualityToolConfig; root: string; relFile: string; } interface ActionSummary { line: string; diagnostics: boolean; } function customMessageText(content: unknown, details: unknown): string { const summary = (details as { summary?: unknown } | undefined)?.summary; if (typeof summary === "string") return summary; if (typeof content === "string") return content; if (!Array.isArray(content)) return ""; return content .filter((item): item is { type: string; text: string } => item?.type === "text" && typeof item.text === "string") .map((item) => item.text) .join("\n"); } function registerDiagnosticsRenderer(pi: ExtensionAPI): void { pi.registerMessageRenderer("pi-code-quality", (message, _options, theme) => { const text = customMessageText(message.content, message.details); const box = new Box(1, 1, (value) => theme.bg("toolSuccessBg", value)); box.addChild(new Text(theme.fg("toolTitle", theme.bold("code_quality")), 0, 0)); if (text.trim()) { box.addChild(new Spacer(1)); box.addChild(new Text(theme.fg("toolOutput", text), 0, 0)); } return box; }); } function getEventPath(input: Record): string | undefined { return typeof input.path === "string" && input.path.trim() ? input.path : undefined; } async function fileSizeAllowed(file: string, limit: number): Promise { const stat = await fs.stat(file); return stat.size <= limit; } async function hashFile(file: string): Promise { try { return await fs.readFile(file, "utf8"); } catch { return ""; } } function isDiagnosticExitCode(command: CommandConfig, code: number): boolean { const diagnosticExitCodes = command.diagnosticExitCodes ?? [1]; return diagnosticExitCodes.includes(code); } function couldMatchBeforeRoot(file: string, cwd: string, include?: string[], exclude?: string[]): boolean { const candidates = [...new Set([normalizeRelativePath(path.relative(cwd, file)), path.basename(file)])]; const included = !include || include.length === 0 || candidates.some((candidate) => matchesAnyGlob(include, candidate)); if (!included) return false; return !candidates.some((candidate) => matchesAnyGlob(exclude, candidate)); } async function matchTools(ctx: ExtensionContext, tools: CodeQualityToolConfig[], file: string): Promise<{ matches: MatchedTool[]; warnings: string[] }> { const warnings: string[] = []; const matches: MatchedTool[] = []; for (const tool of tools) { if (tool.enabled === false) continue; if (!couldMatchBeforeRoot(file, ctx.cwd, tool.include, tool.exclude)) continue; const root = findProjectRoot(file, tool.rootMarkers, ctx.cwd); if (!root) { warnings.push(`${tool.id}: root markers not found (${(tool.rootMarkers ?? []).join(", ") || "none"})`); continue; } const relFile = normalizeRelativePath(path.relative(root, file)); if (relFile.startsWith("..") || path.isAbsolute(relFile)) continue; if (!isPathIncluded(relFile, tool.include, tool.exclude)) continue; matches.push({ tool, root, relFile }); } return { matches, warnings }; } async function runFormatter(options: { pi: ExtensionAPI; ctx: ExtensionContext; tool: CodeQualityToolConfig; root: string; file: string; workspace: string; }): Promise { if (!options.tool.formatter) return undefined; const command = resolveCommand(`${options.tool.id} formatter`, options.tool.formatter, { workspace: options.workspace, root: options.root, file: options.file, }); if (!isExecutableAvailable(command.bin)) { return { line: `⚠️ ${options.tool.id}: formatter binary not found: ${command.bin}`, diagnostics: false }; } const before = await hashFile(options.file); const result = await runCommand(options.pi, command, options.ctx.signal); if (result.code !== 0) { return { line: formatCommandIssue(options.tool.id, "formatter", result), diagnostics: false }; } const after = await hashFile(options.file); const relFile = command.placeholders.relFile; return { line: before === after ? `✅ ${options.tool.id}: formatter checked ${relFile}` : `✅ ${options.tool.id}: formatted ${relFile}`, diagnostics: false, }; } async function runLinter(options: { pi: ExtensionAPI; ctx: ExtensionContext; tool: CodeQualityToolConfig; root: string; file: string; workspace: string; }): Promise { if (!options.tool.linter) return undefined; const command = resolveCommand(`${options.tool.id} linter`, options.tool.linter, { workspace: options.workspace, root: options.root, file: options.file, }); if (!isExecutableAvailable(command.bin)) { return { line: `⚠️ ${options.tool.id}: linter binary not found: ${command.bin}`, diagnostics: false }; } const result = await runCommand(options.pi, command, options.ctx.signal); if (result.code === 0) { return { line: `✅ ${options.tool.id}: no issues in ${command.placeholders.relDir}`, diagnostics: false }; } if (isDiagnosticExitCode(options.tool.linter, result.code)) { return { line: formatDiagnosticOutput(options.tool.id, commandOutput(result)), diagnostics: true }; } return { line: formatCommandIssue(options.tool.id, "linter", result), diagnostics: false }; } async function runTool(options: { pi: ExtensionAPI; ctx: ExtensionContext; match: MatchedTool; file: string; workspace: string; }): Promise { const { tool, root } = options.match; const maxFileSizeBytes = tool.maxFileSizeBytes ?? DEFAULT_MAX_FILE_SIZE_BYTES; if (!(await fileSizeAllowed(options.file, maxFileSizeBytes))) { return [`⚠️ ${tool.id}: skipped ${options.match.relFile}; file exceeds maxFileSizeBytes (${maxFileSizeBytes})`]; } const lines: string[] = []; const formatter = await runFormatter({ ...options, tool, root }); if (formatter) lines.push(formatter.line); const linter = await runLinter({ ...options, tool, root }); if (linter) lines.push(linter.line); if (!formatter && !linter) { lines.push(`⚠️ ${tool.id}: no formatter or linter configured`); } return lines; } class PerFileQueue { private readonly queues = new Map>(); run(file: string, task: () => Promise): Promise { const previous = this.queues.get(file) ?? Promise.resolve(""); const next = previous.catch(() => "").then(task); let tracked: Promise; tracked = next.finally(() => { if (this.queues.get(file) === tracked) this.queues.delete(file); }); this.queues.set(file, tracked); return next; } } async function buildCodeQualitySummary(pi: ExtensionAPI, ctx: ExtensionContext, inputPath: string): Promise { const absoluteFile = toAbsolutePath(inputPath, ctx.cwd); const loaded = await loadCodeQualityConfig(ctx); const warnings = [...loaded.warnings]; const workspace = loaded.layers.find((layer) => layer.scope === "project") ? path.dirname(loaded.layers.find((layer) => layer.scope === "project")!.dir) : ctx.cwd; if (loaded.items.length === 0) { return formatWarnings("Code quality", warnings); } const { matches, warnings: matchWarnings } = await matchTools(ctx, loaded.items, absoluteFile); warnings.push(...matchWarnings); if (matches.length === 0) { return formatWarnings("Code quality", warnings); } const lines: string[] = []; for (const match of matches) { try { lines.push(...(await runTool({ pi, ctx, match, file: absoluteFile, workspace }))); } catch (error) { lines.push(`⚠️ ${match.tool.id}: ${(error as Error).message}`); } } return [formatWarnings("Code quality", warnings), joinSections("Code quality", lines)].filter(Boolean).join("\n\n"); } export default function piCodeQualityExtension(pi: ExtensionAPI) { const queue = new PerFileQueue(); registerDiagnosticsRenderer(pi); pi.on("tool_result", async (event, ctx) => { if (event.toolName !== "write" && event.toolName !== "edit") return undefined; if (event.isError) return undefined; const inputPath = getEventPath(event.input); if (!inputPath) return undefined; const absoluteFile = toAbsolutePath(inputPath, ctx.cwd); const summary = await queue.run(absoluteFile, () => buildCodeQualitySummary(pi, ctx, absoluteFile)); if (!summary.trim()) return undefined; if (hasIssueOutput(summary)) { pi.sendMessage({ customType: "pi-code-quality", content: "", display: true, details: { path: absoluteFile, summary, toolCallId: event.toolCallId }, }, { deliverAs: "steer" }); } return { content: [...event.content, { type: "text", text: summary }], }; }); }