UNPKG

pi-lens

Version:

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

278 lines (277 loc) • 13 kB
import * as nodeFs from "node:fs"; import * as path from "node:path"; import { applyConservativeActionableWarningFixes, checkActionableWarningsReportFresh, } from "./actionable-warnings.js"; import { logLatency } from "./latency-logger.js"; import { resyncLspFile, runFormatPhase } from "./pipeline.js"; import { publishFilesTouched } from "./bus-publish.js"; import { appendProjectChange, } from "./project-changes.js"; function recordProjectChange(args) { const bump = args.runtime.bumpFileSeq; if (!bump) return; const { projectSeq, fileSeq } = bump.call(args.runtime, args.filePath); try { appendProjectChange(args.cwd, { seq: projectSeq, timestamp: new Date().toISOString(), sessionId: args.runtime.telemetrySessionId, turnIndex: args.runtime.turnIndex, source: args.source, filePath: path.resolve(args.filePath), fileSeq, }); } catch (err) { args.dbg(`project change log append failed for ${args.filePath}: ${err}`); } } export async function handleAgentEnd({ ctxCwd, getFlag, notify, dbg, runtime, cacheManager, getFormatService, }) { const records = runtime.consumeDeferredFormatFiles(); const actionableAutofixEnabled = !!getFlag("lens-actionable-warning-autofix"); if (records.length === 0 && !actionableAutofixEnabled) return undefined; const startedAt = Date.now(); const summary = { queued: records.length, formatted: 0, changed: [], failed: [], skipped: [], }; // #502 fix provenance: per-path {tool, kind} entries accumulated across the // deferred-format loop below, passed as `fixes` on the batch // publishFilesTouched call so consumers can tell "pi-lens formatted this" // from an agent edit. const deferredFormatFixes = []; dbg(`agent_end deferred_format: ${records.length} file(s)`); logLatency({ type: "phase", toolName: "agent_end", filePath: ctxCwd ?? runtime.projectRoot, phase: "agent_end_deferred_format_start", durationMs: 0, metadata: { fileCount: records.length }, }); const autoformatDisabled = !!getFlag("no-autoformat"); if (autoformatDisabled) { for (const record of records) { summary.skipped.push({ filePath: record.filePath, reason: "no-autoformat", }); } } if (!autoformatDisabled) { // Run all formatter subprocesses concurrently — no shared state touched here. // bumpFileSeq / cacheManager mutations happen in the sequential pass below. const outcomes = await Promise.all(records.map(async (record) => { const fileStart = Date.now(); const filePath = path.resolve(record.filePath); if (!nodeFs.existsSync(filePath)) { dbg(`agent_end deferred_format skipped missing file: ${filePath}`); return { kind: "skipped", filePath, reason: "missing" }; } try { const result = await runFormatPhase(filePath, getFormatService, dbg); return { kind: "done", record, filePath, result, fileStart }; } catch (err) { const message = err instanceof Error ? err.message : String(err); dbg(`agent_end deferred_format failed for ${filePath}: ${message}`); return { kind: "failed", filePath, message, fileStart }; } })); // Process results sequentially — bumpFileSeq and cacheManager mutations // must stay ordered to avoid sequence number races. for (const outcome of outcomes) { if (outcome.kind === "skipped") { summary.skipped.push({ filePath: outcome.filePath, reason: outcome.reason, }); continue; } if (outcome.kind === "failed") { summary.failed.push({ filePath: outcome.filePath, errors: [outcome.message], }); continue; } const { record, filePath, result, fileStart } = outcome; summary.formatted++; if (result.formatFailures.length > 0) { summary.failed.push({ filePath, errors: result.formatFailures }); } if (result.formatChanged) { summary.changed.push(filePath); for (const tool of result.formattersUsed) { deferredFormatFixes.push({ path: filePath, tool, kind: "format" }); } // turnStateCwd is required on DeferredFormatRecord (PR #114) — the // previous fallback chain through ctxCwd / projectRoot / record.cwd // could silently regress the monorepo cwd-mismatch fix from PR #105. const bookkeepingCwd = record.turnStateCwd; recordProjectChange({ runtime, cwd: bookkeepingCwd, filePath, source: "format", dbg, }); if (!getFlag("no-read-guard")) { runtime.readGuard.recordWritten(filePath); } try { const content = nodeFs.readFileSync(filePath, "utf-8"); const lineCount = content.split("\n").length; const hasImports = /^import\s/m.test(content); cacheManager.addModifiedRange(filePath, { start: 1, end: lineCount }, hasImports, bookkeepingCwd); } catch (err) { dbg(`agent_end deferred_format modified-range tracking failed for ${filePath}: ${err}`); } // #484: opt-in per-turn summary — deferred format is the OTHER // half of the format signal (immediate-mode is recorded at the // runtime-tool-result.ts seam); same result.formattersUsed the // latency phase below already logs, no new plumbing. if (getFlag("lens-turn-summary")) { for (const tool of result.formattersUsed) { runtime.turnSummary.recordFormat(filePath, { tool }); } } } if (result.fileContent) { await resyncLspFile(filePath, result.fileContent, true, false, getFlag, dbg); } dbg(`agent_end deferred_format file ${filePath}: changed=${result.formatChanged} duration=${Date.now() - fileStart}ms`); logLatency({ type: "phase", toolName: "agent_end", filePath, phase: "deferred_format_file", durationMs: Date.now() - fileStart, metadata: { changed: result.formatChanged, formattersUsed: result.formattersUsed, failureCount: result.formatFailures.length, }, }); } if (summary.changed.length > 0) { publishFilesTouched({ reason: "format", paths: summary.changed, cwd: ctxCwd ?? runtime.projectRoot, dbg, fixes: deferredFormatFixes, }); } } if (actionableAutofixEnabled) { const actionReport = cacheManager.readCache("actionable-warnings", ctxCwd ?? runtime.projectRoot, 10 * 60_000); if (!actionReport?.data) { dbg("agent_end actionable_warnings_autofix: cache missing or expired, skipping fixes"); } else { const freshness = checkActionableWarningsReportFresh({ report: actionReport.data, currentProjectSeq: runtime.projectSeq, getFileSeq: (filePath) => runtime.getFileSeq(filePath), }); if (!freshness.fresh) { dbg(`agent_end actionable_warnings_autofix: stale report (${freshness.reason}; reportProjectSeqEnd=${freshness.reportProjectSeqEnd ?? "missing"}; currentProjectSeq=${freshness.currentProjectSeq}${freshness.filePath ? `; file=${freshness.filePath}; reportFileSeq=${freshness.reportFileSeq}; currentFileSeq=${freshness.currentFileSeq}` : ""}), skipping fixes`); } else { const fixStart = Date.now(); const fixSummary = await applyConservativeActionableWarningFixes({ cwd: ctxCwd ?? runtime.projectRoot, report: actionReport.data, dbg, }); for (const changedFile of fixSummary.changedFiles) { if (!nodeFs.existsSync(changedFile)) continue; recordProjectChange({ runtime, cwd: ctxCwd ?? runtime.projectRoot, filePath: changedFile, source: "autofix", dbg, }); if (!getFlag("no-read-guard")) runtime.readGuard.recordWritten(changedFile); try { const content = nodeFs.readFileSync(changedFile, "utf-8"); cacheManager.addModifiedRange(changedFile, { start: 1, end: content.split("\n").length }, /^import\s/m.test(content), ctxCwd ?? runtime.projectRoot); } catch (err) { dbg(`agent_end actionable warning changed-file tracking failed for ${changedFile}: ${err}`); } } if (fixSummary.changedFiles.length > 0) { publishFilesTouched({ reason: "autofix", paths: fixSummary.changedFiles, cwd: ctxCwd ?? runtime.projectRoot, dbg, fixes: fixSummary.changedFiles.map((changedFile) => ({ path: changedFile, tool: "lsp-quickfix", kind: "autofix", })), }); if (getFlag("lens-turn-summary")) { for (const changedFile of fixSummary.changedFiles) { runtime.turnSummary.recordAutofix(changedFile, { tool: "lsp-quickfix", }); } } } logLatency({ type: "phase", toolName: "agent_end", filePath: ctxCwd ?? runtime.projectRoot, phase: "actionable_warnings_autofix", durationMs: Date.now() - fixStart, metadata: { considered: fixSummary.considered, applied: fixSummary.applied, changedFiles: fixSummary.changedFiles.length, skipped: fixSummary.skipped.length, }, }); if (fixSummary.applied > 0) { notify(`pi-lens applied ${fixSummary.applied} conservative LSP warning quickfix(es)`, "info"); } } } } logLatency({ type: "tool_result", toolName: "agent_end", filePath: ctxCwd ?? runtime.projectRoot, durationMs: Date.now() - startedAt, result: "deferred_format_complete", metadata: { queued: summary.queued, formatted: summary.formatted, changed: summary.changed.length, failed: summary.failed.length, skipped: summary.skipped.length, }, }); dbg(`agent_end deferred_format complete: formatted=${summary.formatted} changed=${summary.changed.length} failed=${summary.failed.length} skipped=${summary.skipped.length}`); if (summary.failed.length > 0) { notify(`pi-lens deferred format: ${summary.changed.length} changed, ${summary.failed.length} failed`, "warning"); } else if (summary.changed.length > 0 && !getFlag("lens-turn-summary")) { // The info-level success toast is redundant once the turn-summary entry // (#484) is opted in — it would repeat the same "N reformatted" fact the // transcript entry already carries. Failures above stay untouched either way. const names = summary.changed.map((f) => path.basename(f)).join(", "); notify(`pi-lens deferred format applied to ${summary.changed.length} file(s): ${names}`, "info"); } return summary; }