UNPKG

pi-lens

Version:

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

217 lines (216 loc) • 8.81 kB
import { createHash } from "node:crypto"; import * as fs from "node:fs"; import * as path from "node:path"; import { toRunnerDisplayPath } from "./dispatch/runner-context.js"; import { getProjectDataDir } from "./file-utils.js"; import { normalizeMapKey } from "./path-utils.js"; function normalizeMessage(message) { return message.replace(/\s+/g, " ").trim().toLowerCase(); } function hashText(value, length = 10) { return createHash("sha256").update(value).digest("hex").slice(0, length); } function relativeFile(filePath, cwd) { const rel = path.relative(cwd, filePath).replace(/\\/g, "/"); return rel && !rel.startsWith("..") ? rel : normalizeMapKey(filePath); } function createCodeQualityWarningId(args) { const parts = [ relativeFile(args.filePath, args.cwd), args.tool ?? "", args.rule ?? "", String(args.code ?? ""), normalizeMessage(args.message), String(args.line ?? ""), ]; return `cq:${hashText(parts.join("|"))}`; } function categorize(diagnostic) { const haystack = `${diagnostic.tool} ${diagnostic.rule ?? ""} ${diagnostic.code ?? ""} ${diagnostic.message}`.toLowerCase(); if (haystack.includes("type") || haystack.includes("any")) return "type-safety"; if (haystack.includes("complex") || haystack.includes("fan-out") || haystack.includes("fanout")) return "maintainability"; if (haystack.includes("duplicate") || haystack.includes("similar")) return "duplication"; if (haystack.includes("style") || haystack.includes("format")) return "style"; return "other"; } function lineInModifiedRanges(line, ranges) { if (line === undefined) return true; if (ranges.length === 0) return true; return ranges.some((range) => line >= range.start - 2 && line <= range.end + 2); } export function recordFromCodeQualityDiagnostic(diagnostic, cwd) { if (diagnostic.semantic !== "warning" && diagnostic.semantic !== "none") return undefined; if (diagnostic.severity === "error") return undefined; if (diagnostic.fixable || diagnostic.fixSuggestion || diagnostic.autoFixAvailable) return undefined; const filePath = path.resolve(cwd, diagnostic.filePath); return { id: createCodeQualityWarningId({ cwd, filePath, tool: diagnostic.tool, rule: diagnostic.rule, code: diagnostic.code, message: diagnostic.message, line: diagnostic.line, }), filePath, displayPath: toRunnerDisplayPath(cwd, filePath), line: diagnostic.line, column: diagnostic.column, severity: diagnostic.severity === "hint" ? "hint" : diagnostic.severity === "info" ? "info" : "warning", tool: diagnostic.tool, rule: diagnostic.rule, code: diagnostic.code, message: diagnostic.message, category: categorize(diagnostic), origin: "dispatch", }; } export function buildCodeQualityWarningsReport(args) { const cwd = path.resolve(args.cwd); const maxWarnings = Math.max(1, args.maxWarnings ?? 50); const byId = new Map(); for (const warning of args.warnings) { const ranges = args.modifiedRangesByFile.get(normalizeMapKey(warning.filePath)) ?? []; if (!lineInModifiedRanges(warning.line, ranges)) continue; byId.set(warning.id, warning); } const merged = [...byId.values()] .sort((a, b) => a.displayPath.localeCompare(b.displayPath) || (a.line ?? 0) - (b.line ?? 0) || a.message.localeCompare(b.message)) .slice(0, maxWarnings); const byFile = new Map(); for (const warning of merged) { const arr = byFile.get(warning.filePath) ?? []; arr.push(warning); byFile.set(warning.filePath, arr); } const files = [...byFile.entries()].map(([filePath, warnings]) => ({ filePath, displayPath: toRunnerDisplayPath(cwd, filePath), fileSeq: args.fileSeqByPath?.get(normalizeMapKey(filePath)), warnings, })); const ruleCounts = new Map(); for (const warning of merged) { const rule = warning.rule ?? warning.tool; ruleCounts.set(rule, (ruleCounts.get(rule) ?? 0) + 1); } const topRules = [...ruleCounts.entries()] .map(([rule, count]) => ({ rule, count })) .sort((a, b) => b.count - a.count || a.rule.localeCompare(b.rule)) .slice(0, 8); return { generatedAt: new Date().toISOString(), scope: "turn_delta", sessionId: args.sessionId, turnIndex: args.turnIndex, projectSeqStart: args.projectSeqStart, projectSeqEnd: args.projectSeqEnd, deltaOnly: true, files, summary: { warnings: merged.length, files: files.length, topRules, }, }; } /** * #557 audit (same race class as #555's LSP client fix and #560's * `widget-state.ts` `recordDiagnostics` fix): pi-lens deliberately allows * concurrent pipeline runs for the SAME file across DIFFERENT same-turn * edits (dedupe key is `filePath + contentHash`, not just `filePath` — see * `clients/runtime-tool-result.ts`), so a per-key cache fed directly from * those concurrent pipeline runs needs an ordering guard (`WriteOrderingGuard` * from `clients/write-ordering-guard.ts`) or an older edit's slower pipeline * can silently overwrite a newer edit's fresher write. * * This call site is NOT exposed to that race. Unlike `widget-state.ts`'s * `recordDiagnostics` (called directly from `clients/pipeline.ts`, once per * pipeline run — i.e. potentially several times per turn, out of completion * order), `writeCodeQualityWarningsReport` has exactly one caller: * `handleTurnEnd` in `clients/runtime-turn.ts`, which per-edit pipeline runs * never call directly — they only feed `runtime.recordCodeQualityWarnings` * (an accumulating, order-independent Map). `handleTurnEnd` itself reads that * accumulator once via `runtime.peekCodeQualityWarnings()` and writes the * aggregate report exactly once per turn-end invocation — the same single * sequential turn-end-only shape already confirmed safe for * `writeActionableWarningsReport` in `clients/actionable-warnings.ts`, right * next to this call site in `handleTurnEnd`. No second writer, so no * ordering token to guard against — see * `tests/clients/code-quality-warnings.test.ts`'s * "single sequential caller" test, which pins this invariant so a future * change that adds a second call site has a better chance of being caught. */ export function writeCodeQualityWarningsReport(cacheManager, cwd, report) { cacheManager.writeCache("code-quality-warnings", report, cwd); } export function getCodeQualityWarningsHistoryPath(cwd) { return path.join(getProjectDataDir(cwd), "code-quality-warnings.jsonl"); } export function appendCodeQualityWarningsHistory(cwd, report) { const warnings = report.files.flatMap((file) => file.warnings.map((warning) => ({ timestamp: report.generatedAt, sessionId: report.sessionId, turnIndex: report.turnIndex, projectSeq: report.projectSeqEnd, filePath: warning.filePath, displayPath: warning.displayPath, fileSeq: file.fileSeq, line: warning.line, column: warning.column, severity: warning.severity, tool: warning.tool, rule: warning.rule, code: warning.code, message: warning.message, category: warning.category, warningId: warning.id, }))); if (warnings.length === 0) return; const historyPath = getCodeQualityWarningsHistoryPath(cwd); try { fs.mkdirSync(path.dirname(historyPath), { recursive: true }); fs.appendFileSync(historyPath, `${warnings.map((entry) => JSON.stringify(entry)).join("\n")}\n`, "utf8"); } catch { // Non-fatal — history write failure should never surface to the agent. } } export function formatCodeQualityWarningsAdvisory(report) { if (report.summary.warnings === 0) return undefined; const topRules = report.summary.topRules .slice(0, 3) .map((entry) => `${entry.rule}×${entry.count}`) .join(", "); return [ `Code-quality warnings introduced/touched this turn: ${report.summary.warnings} across ${report.summary.files} file(s).`, topRules ? `Top rules: ${topRules}` : undefined, "Details written to .pi-lens/cache/code-quality-warnings.json", "No action required unless you are already refactoring these areas.", ] .filter(Boolean) .join("\n"); }