pi-lens
Version:
Real-time code feedback for pi — LSP, linters, formatters, type-checking, structural analysis & booboo
543 lines (542 loc) • 21.5 kB
JavaScript
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");
}