UNPKG

pi-lens

Version:

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

543 lines (542 loc) • 21.5 kB
import { createHash } from "node:crypto"; import * as fs from "node:fs"; import * as path from "node:path"; import { applyWorkspaceEdit } from "./lsp/edits.js"; import { getLSPService } from "./lsp/index.js"; import { normalizeMapKey } from "./path-utils.js"; import { toRunnerDisplayPath } from "./dispatch/runner-context.js"; import { logActionableWarningsEvent } from "./actionable-warnings-logger.js"; import { getProjectDataDir } from "./file-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); } export function createActionableWarningId(args) { const parts = [ relativeFile(args.filePath, args.cwd), args.tool ?? "", args.source ?? "", String(args.code ?? ""), args.rule ?? "", normalizeMessage(args.message), String(args.line ?? ""), ]; return `aw:${hashText(parts.join("|"))}`; } function actionSafety(action) { const kind = action.kind ?? ""; if (!kind.startsWith("quickfix")) return { eligible: false, reason: "not_quickfix" }; if (!action.isPreferred) return { eligible: false, reason: "not_preferred" }; if (!action.edit) return { eligible: false, reason: "no_edit" }; if (action.command) return { eligible: false, reason: "has_command" }; return { eligible: true }; } function serializeAction(action) { const safety = actionSafety(action); return { title: action.title, kind: action.kind, isPreferred: action.isPreferred, hasEdit: Boolean(action.edit), hasCommand: Boolean(action.command), autoFixEligible: safety.eligible, skipReason: safety.reason, }; } function readSuppressionState(cwd) { const statePath = path.join(getProjectDataDir(cwd), "cache", "actionable-warning-state.json"); try { const parsed = JSON.parse(fs.readFileSync(statePath, "utf-8")); return parsed && typeof parsed === "object" ? parsed : {}; } catch { return {}; } } function updateWarningState(cwd, warnings) { const statePath = path.join(getProjectDataDir(cwd), "cache", "actionable-warning-state.json"); const now = new Date().toISOString(); const state = readSuppressionState(cwd); state.warnings ??= {}; for (const warning of warnings) { const existing = state.warnings[warning.id] ?? {}; state.warnings[warning.id] = { ...existing, status: existing.status ?? "active", firstSeenAt: existing.firstSeenAt ?? now, lastSeenAt: now, seenCount: (existing.seenCount ?? 0) + 1, }; } fs.mkdirSync(path.dirname(statePath), { recursive: true }); fs.writeFileSync(statePath, JSON.stringify(state, null, 2)); } function suppressionFor(cwd, id) { const entry = readSuppressionState(cwd).warnings?.[id]; return { suppressed: entry?.status === "suppressed", reason: entry?.reason, }; } export function recordFromDispatchDiagnostic(diagnostic, cwd) { if (diagnostic.semantic !== "warning" || diagnostic.severity !== "warning") return undefined; if (!diagnostic.fixable && !diagnostic.fixSuggestion) return undefined; const filePath = path.resolve(cwd, diagnostic.filePath); const id = createActionableWarningId({ cwd, filePath, tool: diagnostic.tool, code: diagnostic.code, rule: diagnostic.rule, message: diagnostic.message, line: diagnostic.line, }); const suppression = suppressionFor(cwd, id); return { id, filePath, displayPath: toRunnerDisplayPath(cwd, filePath), line: diagnostic.line, column: diagnostic.column, severity: "warning", tool: diagnostic.tool, code: diagnostic.code, rule: diagnostic.rule, message: diagnostic.message, fixSuggestion: diagnostic.fixSuggestion, fixKind: diagnostic.fixKind, autoFixAvailable: diagnostic.autoFixAvailable, actions: [], suppressed: suppression.suppressed, suppressionReason: suppression.reason, origin: "dispatch", }; } 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); } function recordFromLspDiagnostic(diag, filePath, cwd) { const line = diag.range.start.line + 1; const column = diag.range.start.character + 1; const source = diag.source ?? "lsp"; const code = diag.code === undefined ? undefined : String(diag.code); const id = createActionableWarningId({ cwd, filePath, tool: "lsp", source, code, message: diag.message, line, }); const suppression = suppressionFor(cwd, id); return { id, filePath, displayPath: toRunnerDisplayPath(cwd, filePath), line, column, severity: "warning", tool: "lsp", source, code, rule: code ? `${source}:${code}` : source, message: diag.message, actions: [], suppressed: suppression.suppressed, suppressionReason: suppression.reason, origin: "lsp", }; } function mergeWarnings(records) { const byId = new Map(); for (const record of records) { const existing = byId.get(record.id); if (!existing) { byId.set(record.id, { ...record, actions: [...record.actions] }); continue; } existing.origin = existing.origin === record.origin ? existing.origin : "merged"; existing.fixSuggestion ??= record.fixSuggestion; existing.fixKind ??= record.fixKind; existing.autoFixAvailable ||= record.autoFixAvailable; const seenActions = new Set(existing.actions.map((a) => `${a.kind ?? ""}|${a.title}`)); for (const action of record.actions) { const key = `${action.kind ?? ""}|${action.title}`; if (!seenActions.has(key)) { existing.actions.push(action); seenActions.add(key); } } } return [...byId.values()].sort((a, b) => a.displayPath.localeCompare(b.displayPath) || (a.line ?? 0) - (b.line ?? 0)); } export async function buildActionableWarningsReport(args) { const cwd = path.resolve(args.cwd); const records = [...args.dispatchWarnings]; const lspService = getLSPService(); logActionableWarningsEvent({ event: "report_started", sessionId: args.sessionId, metadata: { turnIndex: args.turnIndex, filesCount: args.files.length, dispatchWarningsCount: args.dispatchWarnings.length, deltaOnly: args.deltaOnly !== false, includeLspCodeActions: args.includeLspCodeActions, }, }); if (args.includeLspCodeActions) { for (const file of args.files) { const filePath = path.resolve(cwd, file); if (!lspService.supportsLSP(filePath)) { logActionableWarningsEvent({ event: "lsp_file_skipped", sessionId: args.sessionId, filePath, metadata: { reason: "no_lsp_support" }, }); continue; } // Reuse the cache primed by the dispatch pipeline's touchFile earlier in // this turn — but only when it is verified current. A second open+wait // here costs ~1 s/file with the LSP cold, so we pass the hash of the // current file bytes: getLastKnownDiagnostics returns the entry only if // it was primed for the SAME content, so a previous turn's diagnostics // are never served as current. On any miss (no entry, content drift, or // an entry written without content) we fall through to a fresh read. let diags; let lspSource = "cache"; const currentContent = fs.existsSync(filePath) ? fs.readFileSync(filePath, "utf-8") : undefined; const contentHash = currentContent !== undefined ? createHash("sha256").update(currentContent).digest("hex") : undefined; const cached = contentHash !== undefined ? lspService.getLastKnownDiagnostics(filePath, contentHash) : undefined; if (cached !== undefined) { diags = cached; } else { try { if (currentContent) await lspService.openFile(filePath, currentContent); diags = await lspService.getDiagnostics(filePath); lspSource = "fresh"; } catch (err) { args.dbg?.(`actionable_warnings: LSP diagnostics failed for ${filePath}: ${err}`); logActionableWarningsEvent({ event: "lsp_file_skipped", sessionId: args.sessionId, filePath, metadata: { reason: "lsp_error", error: String(err) }, }); continue; } } const ranges = args.modifiedRangesByFile.get(normalizeMapKey(filePath)) ?? []; const diagsWarning = diags.filter((d) => d.severity === 2); let deltaFiltered = 0; let enriched = 0; for (const diag of diagsWarning) { const line = diag.range.start.line + 1; if (args.deltaOnly !== false && !lineInModifiedRanges(line, ranges)) { deltaFiltered++; continue; } const record = recordFromLspDiagnostic(diag, filePath, cwd); try { const actions = await lspService.codeAction(filePath, diag.range.start.line, diag.range.start.character, diag.range.end.line, diag.range.end.character); record.actions = actions.map(serializeAction).slice(0, 5); } catch (err) { args.dbg?.(`actionable_warnings: LSP codeAction failed for ${filePath}: ${err}`); } if (record.actions.length > 0) { records.push(record); enriched++; } } logActionableWarningsEvent({ event: "lsp_file_checked", sessionId: args.sessionId, filePath, metadata: { diagsTotal: diags.length, diagsWarning: diagsWarning.length, deltaFiltered, enriched, modifiedRangesCount: ranges.length, lspSource, }, }); } } const merged = mergeWarnings(records); updateWarningState(cwd, merged); 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 allActions = merged.flatMap((warning) => warning.actions); const summary = { warnings: merged.length, unsuppressed: merged.filter((warning) => !warning.suppressed).length, suppressed: merged.filter((warning) => warning.suppressed).length, files: files.length, actions: allActions.length, autoFixEligible: allActions.filter((action) => action.autoFixEligible) .length, }; logActionableWarningsEvent({ event: "report_complete", sessionId: args.sessionId, metadata: { turnIndex: args.turnIndex, summary }, }); return { generatedAt: new Date().toISOString(), scope: "turn_delta", sessionId: args.sessionId, turnIndex: args.turnIndex, projectSeqStart: args.projectSeqStart, projectSeqEnd: args.projectSeqEnd, deltaOnly: args.deltaOnly !== false, includeLspCodeActions: args.includeLspCodeActions, files, summary, }; } export function writeActionableWarningsReport(cacheManager, cwd, report) { cacheManager.writeCache("actionable-warnings", report, cwd); } export function getActionableWarningsHistoryPath(cwd) { return path.join(getProjectDataDir(cwd), "actionable-warnings.jsonl"); } /** * Append every actionable warning from this turn to the project's rolling * NDJSON history. Mirrors `appendCodeQualityWarningsHistory` so the two * advisory families have the same shape of cross-turn persistence: * * - One line per warning (not per turn). * - Carries the stable `aw:<hash>` id so callers can correlate the same * warning across turns / sessions. * - Captures suppression state at write time so historical analyses can * reconstruct what the agent actually saw. * - Captures action counts (and autoFixEligible counts) — the LSP code- * action enrichment is the actionable-warnings-only signal; preserving * it lets later analyses ask "which warnings ship with an autofix?". * * Skips the write entirely when no warnings exist — matching the code- * quality history's no-op-on-empty behaviour and keeping the file from * accumulating 0-warning noise. */ export function appendActionableWarningsHistory(cwd, report) { const entries = []; for (const file of report.files) { for (const warning of file.warnings) { entries.push({ 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, source: warning.source, rule: warning.rule, code: warning.code, message: warning.message, fixKind: warning.fixKind, autoFixAvailable: warning.autoFixAvailable, actionCount: warning.actions.length, autoFixEligibleActionCount: warning.actions.filter((action) => action.autoFixEligible).length, suppressed: warning.suppressed, suppressionReason: warning.suppressionReason, origin: warning.origin, warningId: warning.id, }); } } if (entries.length === 0) return; const historyPath = getActionableWarningsHistoryPath(cwd); try { fs.mkdirSync(path.dirname(historyPath), { recursive: true }); fs.appendFileSync(historyPath, `${entries.map((entry) => JSON.stringify(entry)).join("\n")}\n`, "utf8"); } catch { // Non-fatal — history write failure must never surface to the agent. } } export function checkActionableWarningsReportFresh(args) { const reportProjectSeqEnd = args.report.projectSeqEnd; if (typeof reportProjectSeqEnd !== "number") { return { fresh: false, reason: "missing_project_seq", currentProjectSeq: args.currentProjectSeq, }; } if (reportProjectSeqEnd !== args.currentProjectSeq) { return { fresh: false, reason: "project_seq_mismatch", reportProjectSeqEnd, currentProjectSeq: args.currentProjectSeq, }; } if (args.getFileSeq) { for (const file of args.report.files) { if (typeof file.fileSeq !== "number") continue; const currentFileSeq = args.getFileSeq(file.filePath); if (currentFileSeq !== file.fileSeq) { return { fresh: false, reason: "file_seq_mismatch", reportProjectSeqEnd, currentProjectSeq: args.currentProjectSeq, filePath: file.filePath, reportFileSeq: file.fileSeq, currentFileSeq, }; } } } return { fresh: true, reportProjectSeqEnd, currentProjectSeq: args.currentProjectSeq, }; } export async function applyConservativeActionableWarningFixes(args) { const summary = { considered: 0, applied: 0, changedFiles: [], skipped: [], }; const changedFiles = new Set(); const lspService = getLSPService(); const maxFixes = Math.max(0, args.maxFixes ?? 5); for (const file of args.report.files) { if (summary.applied >= maxFixes) break; for (const warning of file.warnings) { if (summary.applied >= maxFixes) break; if (warning.suppressed) continue; const eligibleActions = warning.actions.filter((action) => action.autoFixEligible); if (eligibleActions.length !== 1) { if (eligibleActions.length > 1) summary.skipped.push({ id: warning.id, reason: "multiple_eligible_actions", }); continue; } summary.considered++; if (!warning.line || !warning.column) { summary.skipped.push({ id: warning.id, reason: "missing_position" }); continue; } if (!lspService.supportsLSP(warning.filePath)) { summary.skipped.push({ id: warning.id, reason: "no_lsp" }); continue; } try { const content = fs.existsSync(warning.filePath) ? fs.readFileSync(warning.filePath, "utf-8") : undefined; if (content) await lspService.openFile(warning.filePath, content); const line = warning.line - 1; const character = warning.column - 1; const actions = await lspService.codeAction(warning.filePath, line, character, line, character); const title = eligibleActions[0]?.title; const selected = actions.find((action) => action.title === title); if (!selected) { summary.skipped.push({ id: warning.id, reason: "action_not_found" }); continue; } const safety = actionSafety(selected); if (!safety.eligible) { summary.skipped.push({ id: warning.id, reason: safety.reason ?? "not_safe", }); continue; } const edit = selected.edit; const applied = await applyWorkspaceEdit(edit, args.cwd); for (const changedFile of applied.files) changedFiles.add(changedFile); summary.applied++; } catch (err) { const message = err instanceof Error ? err.message : String(err); args.dbg?.(`actionable_warnings_autofix failed for ${warning.id}: ${message}`); summary.skipped.push({ id: warning.id, reason: "apply_failed" }); } } } summary.changedFiles = [...changedFiles]; return summary; } export function formatActionableWarningsAdvisory(report) { if (report.summary.unsuppressed === 0) return undefined; const files = report.files.filter((file) => file.warnings.some((warning) => !warning.suppressed)); const fileList = files .slice(0, 5) .map((file) => ` ${file.displayPath}: ${file.warnings.filter((warning) => !warning.suppressed).length}`) .join("\n"); const more = files.length > 5 ? `\n ... and ${files.length - 5} more file(s)` : ""; const safe = report.summary.autoFixEligible > 0 ? ` ${report.summary.autoFixEligible} appear to have conservative preferred quickfixes.` : ""; return [ `🟡 Fixable warnings introduced this turn: ${report.summary.unsuppressed}.${safe}`, `Details written to .pi-lens/cache/actionable-warnings.json`, fileList ? `Files:\n${fileList}${more}` : undefined, "If continuing in these files, read that JSON and resolve warnings that are safe and relevant. Do not apply broad refactors unless requested.", ] .filter(Boolean) .join("\n"); }