pi-lens
Version:
Real-time code feedback for pi — LSP, linters, formatters, type-checking, structural analysis & booboo
816 lines (815 loc) • 40 kB
JavaScript
import * as path from "node:path";
import { appendActionableWarningsHistory, buildActionableWarningsReport, formatActionableWarningsAdvisory, writeActionableWarningsReport, } from "./actionable-warnings.js";
import { logActionableWarningsEvent } from "./actionable-warnings-logger.js";
import { appendCodeQualityWarningsHistory, buildCodeQualityWarningsReport, formatCodeQualityWarningsAdvisory, writeCodeQualityWarningsReport, } from "./code-quality-warnings.js";
import { logCascade } from "./cascade-logger.js";
import { normalizeMapKey } from "./path-utils.js";
import { resolveRunnerPath, toRunnerDisplayPath, } from "./dispatch/runner-context.js";
import { getKnipIgnorePatterns } from "./file-utils.js";
import { dedupeSecretFindings, fromAstGrepWarnings, fromGitleaks, fromTrivySecrets, isSecretWarning, secretLocationKey, } from "./secret-findings.js";
import { formatDeadCodeAdvisory } from "./dead-code-client.js";
import { PROJECT_DIAGNOSTICS_CACHE_VERSION, writeProjectDiagnosticsDeltaReport, } from "./project-diagnostics/cache.js";
import { knipIssuesToProjectDiagnostics } from "./project-diagnostics/runner-adapters/knip.js";
import { logLatency } from "./latency-logger.js";
import { updateHeartbeat } from "./instance-registry.js";
import { emitLensTurnFindings } from "./lens-events.js";
import { RUNTIME_CONFIG } from "./runtime-config.js";
// LSP idle reset scheduling — prevents thrashing by delaying shutdown
let lspIdleResetTimeout = null;
function emitIdleResetReporterWarning(reportErr) {
try {
process.emitWarning(`pi-lens LSP idle reset error reporter failed: ${reportErr}`, { code: "PI_LENS_LSP_IDLE_RESET_REPORTER_FAILED" });
}
catch {
// Preserve the detached-timer invariant: this path must never crash.
void reportErr;
}
}
function reportIdleResetError(onError, err) {
try {
onError?.(err);
}
catch (reportErr) {
emitIdleResetReporterWarning(reportErr);
}
}
function scheduleLSPIdleReset(resetFn, delayMs, options = {}) {
// Clear any pending reset to avoid multiple timers
if (lspIdleResetTimeout) {
clearTimeout(lspIdleResetTimeout);
}
lspIdleResetTimeout = setTimeout(() => {
lspIdleResetTimeout = null;
try {
if (options.isCurrentSession && !options.isCurrentSession()) {
return;
}
resetFn();
}
catch (err) {
// Detached timers run outside a pi event boundary. They must never crash
// the extension process (for example if a host UI object was invalidated
// by session replacement before the timer fired).
reportIdleResetError(options.onError, err);
}
}, delayMs);
// unref so this timer does not prevent the process from exiting naturally
// (critical for subagent / --mode json -p usage where the process should
// exit after completing its work, not wait 240 seconds for this to fire)
lspIdleResetTimeout.unref();
}
export function cancelLSPIdleReset() {
if (lspIdleResetTimeout) {
clearTimeout(lspIdleResetTimeout);
lspIdleResetTimeout = null;
}
}
// Bounded wait for the turn's deferred cascade computes (#450) to settle before
// they are merged below. A late compute is carried over to the next turn_end.
function cascadeSettleWaitMs() {
const raw = Number(process.env.PI_LENS_CASCADE_SETTLE_WAIT_MS);
return Number.isFinite(raw) && raw >= 0 ? raw : 5000;
}
function capTurnEndMessage(content) {
const maxLines = RUNTIME_CONFIG.turnEnd.maxLines;
const maxChars = RUNTIME_CONFIG.turnEnd.maxChars;
let out = content;
const lines = out.split("\n");
if (lines.length > maxLines) {
out = `${lines.slice(0, maxLines).join("\n")}\n... (truncated)`;
}
if (out.length > maxChars) {
out = `${out.slice(0, maxChars)}\n... (truncated)`;
}
return out;
}
export async function handleTurnEnd(deps) {
const { ctxCwd, getFlag, dbg, runtime, cacheManager, knipClient, deadCodeClients, depChecker, testRunnerClient, resetLSPService, resetFormatService, } = deps;
// #449 slice 1: piggyback the instance-registry heartbeat on this existing
// per-turn touchpoint rather than adding a new timer/interval. Cheap (reads
// process.memoryUsage().rss, one read-modify-write of instances.json) and
// fire-and-forget — the kill-switch check + no-op behavior live inside
// updateHeartbeat itself, so this call site doesn't need to know about it.
//
// #620: intentionally RSS-only here — CPU%/LSP-child sampling (which shells
// out to `pidusage`, and a full CIM query on Windows for a spawn's process
// tree) is left to the quiet-window "instance_registry_heartbeat" task
// (clients/quiet-window.ts's `buildHeartbeatResourcePatch`), which fires on
// the idle `agent_settled` window rather than every single turn end. Every
// turn end is a much hotter path than an idle window, and the issue's own
// guardrail is not to let the measurement itself become a new source of
// per-turn overhead worth investigating.
void updateHeartbeat().catch(() => {
// best-effort observability — never fail turn_end over this
});
const cwd = ctxCwd ?? process.cwd();
let turnState = cacheManager.readTurnState(cwd);
// Evict turn state written by a previous session — it carries stale file
// ranges that no longer reflect the current editing context.
if (turnState.sessionId &&
turnState.sessionId !== runtime.telemetrySessionId) {
dbg(`turn_end: evicting stale turn state (session ${turnState.sessionId} ≠ current ${runtime.telemetrySessionId})`);
cacheManager.clearTurnState(cwd);
turnState = cacheManager.readTurnState(cwd);
}
const files = Object.keys(turnState.files);
if (files.length === 0) {
dbg("turn_end: no modified files, scheduling LSP idle reset (240s)");
if (!getFlag("no-lsp")) {
const sessionGeneration = runtime.sessionGeneration;
scheduleLSPIdleReset(resetLSPService, 240_000, {
isCurrentSession: () => runtime.isCurrentSession(sessionGeneration),
onError: (err) => dbg(`lsp idle reset failed: ${err}`),
});
}
resetFormatService();
return;
}
// Cancel any pending idle reset since we're actively working
if (lspIdleResetTimeout) {
cancelLSPIdleReset();
dbg("turn_end: cancelled pending LSP idle reset (active editing)");
}
dbg(`turn_end: ${files.length} file(s) modified, cycles: ${turnState.turnCycles}/${turnState.maxCycles}`);
if (cacheManager.isMaxCyclesExceeded(cwd)) {
dbg("turn_end: max cycles exceeded, clearing state and forcing through");
cacheManager.clearTurnState(cwd);
runtime.fixedThisTurn.clear();
resetFormatService();
return;
}
const turnEndStart = Date.now();
const blockerParts = [];
const advisoryParts = [];
const projectDiagnosticsDelta = [];
const projectDiagnosticsSources = new Set();
// Re-surface inline blockers from this turn that the agent didn't fix.
// These were shown inline during write/edit but the agent moved on without resolving them.
const unresolvedBlockers = runtime.consumeInlineBlockers();
for (const { filePath: bPath, summary } of unresolvedBlockers) {
const displayPath = toRunnerDisplayPath(cwd, bPath);
blockerParts.push(`Unresolved from this turn — ${displayPath}:\n${summary}`);
}
// Drain the deferred cascade computes kicked off this turn (#450). They ran
// concurrently off the write hot path; wait a bounded time for them here so
// their runs are available to the merge below. A compute still in flight at
// the cap is carried over to the next turn_end (never dropped).
const cascadeSettleStart = Date.now();
const { settled, timedOut } = await runtime.settleCascadeRuns(cascadeSettleWaitMs());
logLatency({
type: "phase",
toolName: "turn_end",
filePath: cwd,
phase: "cascade_settle_wait",
durationMs: Date.now() - cascadeSettleStart,
metadata: { settled, timedOut },
});
// Merge accumulated cascade results from all pipeline runs this turn.
// Two-pass dedup:
// 1. Primary-level: dedup by primary file (last writer wins).
// 2. Neighbor-level: each neighbor is claimed by the latest cascade result
// that covers it — suppresses stale neighbor state from earlier writes.
const t0 = Date.now();
const cascadeRuns = runtime.consumeCascadeRuns();
const cascadeResults = cascadeRuns.flatMap((r) => r.result ? [r.result] : []);
if (cascadeResults.length > 0) {
const seen = new Map();
for (const result of cascadeResults) {
seen.set(normalizeMapKey(result.filePath), result);
}
// Iterate in reverse so the latest result claims each neighbor first.
const neighborOwner = new Map();
for (const result of [...seen.values()].reverse()) {
const pk = normalizeMapKey(result.filePath);
for (const n of result.neighbors) {
const nk = normalizeMapKey(n.filePath);
if (!neighborOwner.has(nk))
neighborOwner.set(nk, pk);
}
}
const parts = [];
for (const result of seen.values()) {
const pk = normalizeMapKey(result.filePath);
const ownsAny = result.neighbors.some((n) => neighborOwner.get(normalizeMapKey(n.filePath)) === pk);
if (ownsAny && result.formatted)
parts.push(result.formatted);
}
// Suggest tests for cascade neighbors (files with diagnostics)
const neighborFilesWithErrors = cascadeResults
.flatMap((r) => r.neighbors)
.filter((n) => n.diagnostics.length > 0)
.map((n) => n.filePath);
const uniqueNeighborFiles = [...new Set(neighborFilesWithErrors)];
if (uniqueNeighborFiles.length > 0 &&
typeof testRunnerClient.suggestTestFiles === "function") {
const testSuggestions = testRunnerClient.suggestTestFiles(uniqueNeighborFiles, cwd);
if (testSuggestions.length > 0) {
const testLines = testSuggestions
.slice(0, 5)
.map((s) => ` ${toRunnerDisplayPath(cwd, s.testFile)} (${s.runner})`);
let testSection = `🧪 Likely tests for affected neighbors:\n${testLines.join("\n")}`;
if (testSuggestions.length > 5) {
testSection += `\n ... and ${testSuggestions.length - 5} more`;
}
parts.push(testSection);
}
}
if (parts.length > 0)
blockerParts.push(parts.join("\n\n"));
logCascade({
phase: "cascade_turn_end",
filePath: files[0] ?? cwd,
neighborCount: cascadeResults.reduce((s, r) => s + r.neighbors.length, 0),
diagnosticCount: cascadeResults.reduce((s, r) => s + r.neighbors.reduce((ns, n) => ns + n.diagnostics.length, 0), 0),
metadata: {
fileCount: cascadeResults.length,
mergedResults: seen.size,
},
});
}
const cascadeSkipped = {
blockers: 0,
non_code: 0,
no_neighbors: 0,
clean: 0,
error: 0,
};
for (const r of cascadeRuns) {
if (r.skipReason)
cascadeSkipped[r.skipReason] = (cascadeSkipped[r.skipReason] ?? 0) + 1;
}
logLatency({
type: "phase",
toolName: "turn_end",
filePath: cwd,
phase: "cascade_merge",
durationMs: Date.now() - t0,
metadata: {
runsTotal: cascadeRuns.length,
resultCount: cascadeResults.length,
neighborCount: cascadeRuns.reduce((s, r) => s + r.neighborCount, 0),
diagnosticCount: cascadeRuns.reduce((s, r) => s + r.diagnosticCount, 0),
skipped: cascadeSkipped,
},
});
const t2 = Date.now();
let knipMeta = {};
if (runtime.isStartupScanInFlight("knip")) {
dbg("turn_end: skipping knip (startup scan still in flight)");
knipMeta = { skipped: true };
}
else {
// Let KnipClient resolve/validate a real JS project root before probing or
// auto-installing knip. Non-JS repos (for example Unity projects) should not
// run tool checks every turn. Also back off after a timeout/kill so every
// agent turn does not spend 30s launching another heavyweight knip process.
const prevKnip = cacheManager.readCache("knip", cwd);
const previousFailedHard = prevKnip &&
!prevKnip.data.success &&
/(timed out|killed|SIGTERM|SIGKILL|SIGABRT)/i.test(prevKnip.data.summary);
if (previousFailedHard) {
dbg(`turn_end: skipping knip after recent failure: ${prevKnip.data.summary}`);
knipMeta = { skipped: true, reason: prevKnip.data.summary };
}
else {
const knipResult = await knipClient.analyze(cwd, getKnipIgnorePatterns());
cacheManager.writeCache("knip", knipResult, cwd);
knipMeta = {
success: knipResult.success,
totalIssues: knipResult.issues.length,
newIssues: 0,
blockerIssues: 0,
...(!knipResult.success && { reason: knipResult.summary }),
};
if (knipResult.success && knipResult.issues.length > 0) {
const issueKey = (i) => `${i.type}:${i.file ?? ""}:${i.name}:${i.line ?? 0}:${i.package ?? ""}`;
const prevKeys = new Set((prevKnip?.data?.issues ?? []).map(issueKey));
const modifiedSet = new Set(files.map((f) => resolveRunnerPath(cwd, f)));
const newIssues = knipResult.issues.filter((issue) => {
if (prevKeys.has(issueKey(issue)))
return false;
if (!issue.file)
return false;
const abs = resolveRunnerPath(cwd, issue.file);
return modifiedSet.has(abs);
});
knipMeta.newIssues = newIssues.length;
if (newIssues.length > 0) {
projectDiagnosticsDelta.push(...knipIssuesToProjectDiagnostics(cwd, newIssues));
projectDiagnosticsSources.add("knip");
}
const blockerIssues = newIssues.filter((i) => i.type === "unlisted" || i.type === "bin");
knipMeta.blockerIssues = blockerIssues.length;
if (blockerIssues.length > 0) {
let report = "🔴 New unresolved imports/deps in modified code (Knip):\n";
let firstPath = null;
for (const issue of blockerIssues.slice(0, 5)) {
const display = issue.file
? toRunnerDisplayPath(cwd, issue.file)
: "(unknown)";
if (!firstPath && display !== "(unknown)")
firstPath = display;
report += ` ${display}${issue.line ? `:${issue.line}` : ""} — ${issue.type}: ${issue.name}\n`;
}
if (firstPath) {
report += ` First location: ${firstPath}\n`;
}
blockerParts.push(report);
}
// Turn-end injects only this turn's HIGH-CONFIDENCE, ATTRIBUTABLE
// delta: symbols in files the agent just edited that became unused
// (weren't flagged in the previous scan) — low-volume and actionable
// now. The FULL project-wide dead-code picture is deliberately NOT
// injected per turn (hundreds of mostly-pre-existing findings would
// drown the blockers and burn context every turn); it's available
// on demand via lens_diagnostics. The delta also feeds the session-slop
// record (`projectDiagnosticsDelta`) above.
const unusedExportDelta = newIssues.filter((i) => i.type === "export" || i.type === "enumMember");
if (unusedExportDelta.length > 0) {
let report = "⚠️ Newly unused exports in files you edited — check if callers need updating (Knip):\n";
for (const issue of unusedExportDelta.slice(0, 5)) {
const display = issue.file
? toRunnerDisplayPath(cwd, issue.file)
: "(unknown)";
report += ` ${display}${issue.line ? `:${issue.line}` : ""} — ${issue.name}\n`;
}
advisoryParts.push(report);
}
}
}
}
logLatency({
type: "phase",
toolName: "turn_end",
filePath: cwd,
phase: "knip",
durationMs: Date.now() - t2,
metadata: knipMeta,
});
// govulncheck — surface session_start-cached Go CVE findings as advisory.
// No per-turn re-run in this slice; the cache refreshes at next session_start.
const govCacheEntry = cacheManager.readCache("govulncheck", cwd);
if (govCacheEntry?.data?.findings?.length) {
const findings = govCacheEntry.data.findings.slice(0, 5);
let report = "🛡️ Go CVEs reachable from this code (govulncheck) — upgrade where possible:\n";
for (const f of findings) {
const callSite = f.trace.find((t) => t.filename);
const where = callSite?.filename
? `${toRunnerDisplayPath(cwd, callSite.filename)}${callSite.line ? `:${callSite.line}` : ""}`
: (f.module ?? f.packageName ?? "(module)");
const fix = f.fixedVersion
? ` — upgrade to ${f.fixedVersion} or later`
: " — no fix yet, track upstream";
report += ` ${f.osv} (${where})${fix}\n`;
}
if (govCacheEntry.data.findings.length > findings.length) {
report += ` … and ${govCacheEntry.data.findings.length - findings.length} more\n`;
}
advisoryParts.push(report);
}
const trivyCacheEntry = cacheManager.readCache("trivy", cwd);
// Secrets — UNIFIED surfacing (#131 Mode 3). gitleaks, trivy secret, and the
// ast-grep hardcoded-secret rules can each flag the SAME line with different
// rule ids, which the rule-keyed diagnostic dedup can't collapse. Collapse by
// location so a committed/hardcoded secret is reported ONCE (with combined
// provenance) — a blocker, since credentials need rotation before merge.
const gitleaksData = cacheManager.readCache("gitleaks", cwd)?.data;
const astSecretWarnings = runtime
.peekActionableWarnings()
.filter(isSecretWarning);
const sessionSecrets = dedupeSecretFindings([
...fromGitleaks(gitleaksData?.findings ?? []),
...fromTrivySecrets(trivyCacheEntry?.data?.secrets ?? []),
]);
// Locations already surfaced as session-scan secret blockers — used to enrich
// provenance where ast-grep agrees and to suppress the duplicate ast-grep copy
// from the actionable-warnings advisory below.
const secretBlockedLocations = new Set(sessionSecrets.map((f) => secretLocationKey(f.file, f.line)));
if (sessionSecrets.length) {
// Fold in ast-grep provenance ONLY where it coincides with a session
// secret — don't promote ast-grep-only findings out of their advisory tier.
const enriched = dedupeSecretFindings([
...sessionSecrets,
...fromAstGrepWarnings(astSecretWarnings).filter((a) => secretBlockedLocations.has(secretLocationKey(a.file, a.line))),
]);
const shown = enriched.slice(0, 5);
let report = "🔴 STOP — hardcoded secrets detected. Rotate the credentials and remove them from source:\n";
for (const f of shown) {
const where = `${toRunnerDisplayPath(cwd, f.file)}:${f.line}`;
report += ` ${where} — ${f.rule} [${f.sources.join(" + ")}]${f.description ? `: ${f.description}` : ""}\n`;
}
if (enriched.length > shown.length) {
report += ` … and ${enriched.length - shown.length} more\n`;
}
blockerParts.push(report);
}
// trivy — surface session_start-cached dependency CVEs (#131, Phase 1).
// CRITICAL is a blocker (a known-exploitable CVE in a shipped dep is real
// production risk); HIGH/MEDIUM/LOW are advisory. The agent gets the upgrade
// target as a hint and decides — we never auto-edit lockfiles.
if (trivyCacheEntry?.data?.findings?.length) {
const all = trivyCacheEntry.data.findings;
const critical = all.filter((f) => f.severity === "CRITICAL");
const advisory = all.filter((f) => f.severity !== "CRITICAL");
const fmt = (f) => {
const pkg = f.installedVersion
? `${f.pkgName}@${f.installedVersion}`
: f.pkgName;
const fix = f.fixedVersion
? ` — upgrade to ${f.fixedVersion} or later`
: " — no fix yet, track upstream";
return ` ${f.vulnerabilityId} (${pkg})${fix}\n`;
};
if (critical.length) {
const shown = critical.slice(0, 5);
let report = "🔴 STOP — CRITICAL dependency CVEs (trivy). Upgrade before shipping:\n";
for (const f of shown)
report += fmt(f);
if (critical.length > shown.length) {
report += ` … and ${critical.length - shown.length} more\n`;
}
blockerParts.push(report);
}
if (advisory.length) {
const shown = advisory.slice(0, 5);
let report = "🛡️ Dependency CVEs (trivy) — upgrade where possible:\n";
for (const f of shown)
report += fmt(f);
if (advisory.length > shown.length) {
report += ` … and ${advisory.length - shown.length} more\n`;
}
advisoryParts.push(report);
}
}
// trivy — dependency license risk (#131 Mode 4). Advisory only: a copyleft /
// restricted license in a proprietary tree is a compliance signal, not a
// build break. Surfaced from the same cached `trivy fs` pass.
const licenses = trivyCacheEntry?.data?.licenses ?? [];
if (licenses.length) {
const shown = licenses.slice(0, 5);
let report = "📜 Dependency license risk (trivy) — review for compliance:\n";
for (const l of shown) {
const cat = l.category ? `, ${l.category}` : "";
report += ` ${l.pkgName} — ${l.license} (${l.severity}${cat})\n`;
}
if (licenses.length > shown.length) {
report += ` … and ${licenses.length - shown.length} more\n`;
}
advisoryParts.push(report);
}
const t3 = Date.now();
if (await depChecker.ensureAvailable()) {
const madgeFiles = cacheManager.getFilesForMadge(cwd);
if (madgeFiles.length > 0) {
dbg(`turn_end: madge checking ${madgeFiles.length} file(s) for circular deps`);
for (const file of madgeFiles) {
const absPath = path.resolve(cwd, file);
const depResult = await depChecker.checkFile(absPath, cwd);
if (depResult.localSkips && depResult.localSkips > 0) {
// Not silent: a skipped LOCAL import means madge couldn't resolve
// it into the graph, so a cycle through it would be missed.
dbg(`turn_end: madge skipped ${depResult.localSkips} local file(s) resolving ${file} — possible silent cycle-miss`);
}
if (depResult.hasCircular && depResult.circular.length > 0) {
// Whole-project circular deps are surfaced in lens_diagnostics via the
// session-start `madge` cache + extractor; this per-file turn-end pass
// only logs (blockers-only mode suppresses circular-dep notes).
dbg(`turn_end: circular dependency note for ${file} (suppressed in blockers-only mode)`);
}
}
}
}
logLatency({
type: "phase",
toolName: "turn_end",
filePath: cwd,
phase: "madge",
durationMs: Date.now() - t3,
});
// --- Test runner: fire once per turn after all edits are done ---
// Runs for each unique test target across modified files; results appear
// in the next turn's context injection alongside jscpd/madge findings.
if (!getFlag("no-tests") && files.length > 0) {
const seen = new Set();
const targets = [];
// #628: also target the test companions of this turn's cascade neighbors
// (files that import an edited file) — a neighbor's own tests can break
// even though the neighbor's source wasn't touched. Reuses `cascadeResults`,
// already computed above (from the same #450 deferred-cascade drain) for the
// LSP cascade-diagnostics merge — no second reverse-dependency walk, and the
// neighbor set inherits whatever budget the cascade compute already applied
// (CASCADE_NEIGHBOUR_BUDGET), so this can't turn into unbounded per-edit work.
const candidates = [];
const seenCandidateKeys = new Set();
for (const file of files) {
const abs = resolveRunnerPath(cwd, file);
const key = normalizeMapKey(abs);
if (seenCandidateKeys.has(key))
continue;
seenCandidateKeys.add(key);
candidates.push({ display: file, abs, isNeighbor: false });
}
for (const result of cascadeResults) {
for (const neighbor of result.neighbors) {
const abs = path.isAbsolute(neighbor.filePath)
? neighbor.filePath
: resolveRunnerPath(cwd, neighbor.filePath);
const key = normalizeMapKey(abs);
if (seenCandidateKeys.has(key))
continue;
seenCandidateKeys.add(key);
candidates.push({ display: neighbor.filePath, abs, isNeighbor: true });
}
}
for (const { display, abs, isNeighbor } of candidates) {
const target = testRunnerClient.getTestRunTarget(abs, cwd);
if (target && !seen.has(target.testFile)) {
seen.add(target.testFile);
targets.push(target);
dbg(`turn_end: ${display} → test ${target.runner} ${path.relative(cwd, target.testFile)} (${target.strategy}${isNeighbor ? ", cascade-neighbor" : ""})`);
}
else if (!target) {
dbg(`turn_end: ${display} → no test file found${isNeighbor ? " (cascade-neighbor)" : ""}`);
}
}
if (targets.length > 0) {
dbg(`turn_end: firing ${targets.length} test target(s) async (non-blocking)`);
const firedAtTurn = runtime.turnIndex;
Promise.allSettled(targets.map((t) => testRunnerClient.runTestFileAsync(t.testFile, cwd, t.runner, t.config)))
.then((results) => {
// #628: the turn advancing while tests ran no longer means the
// results are thrown away — a late result is still real
// information about what's currently broken. It's tagged `stale`
// so a downstream consumer can distinguish it from a result that
// arrived in time, but it's cached either way.
const stale = runtime.turnIndex !== firedAtTurn;
const failures = [];
const resultValues = [];
for (const r of results) {
if (r.status === "rejected") {
dbg(`turn_end: test run rejected — ${r.reason}`);
continue;
}
resultValues.push(r.value);
const { file, runner, passed, failed, duration, error } = r.value;
const shortFile = path.basename(file);
const summary = error && passed === 0 && failed === 0
? `error: ${error}`
: `${failed > 0 ? "FAIL" : "PASS"} ${passed}p/${failed}f (${duration}ms)`;
dbg(`turn_end: ${stale ? "[stale] " : ""}test ${runner} ${shortFile} → ${summary}`);
if (failed > 0) {
const formatted = testRunnerClient.formatResult(r.value);
if (formatted)
failures.push(formatted);
}
}
if (failures.length > 0) {
const content = stale
? `[from a prior turn — the edit that triggered this run had already been superseded by the time results came back]\n\n${failures.join("\n\n")}`
: failures.join("\n\n");
cacheManager.writeCache("test-runner-findings", { content, stale, results: resultValues }, cwd);
dbg(`turn_end: ${failures.length} test failure(s) cached for next context injection${stale ? " (stale — turn advanced while tests ran)" : ""}`);
}
else if (results.length > 0) {
dbg(`turn_end: all tests passed${stale ? " (stale — turn advanced while tests ran)" : ""}`);
}
})
.catch(() => { });
}
}
if (runtime.errorDebtBaseline && files.length > 0) {
dbg("turn_end: marking error debt check for next session");
cacheManager.writeCache("errorDebt", {
pendingCheck: true,
baselineTestsPassed: runtime.errorDebtBaseline.testsPassed,
}, cwd);
}
// Session summaries are intentionally suppressed at turn_end to avoid
// distracting the agent with non-blocking telemetry.
if (projectDiagnosticsDelta.length > 0) {
writeProjectDiagnosticsDeltaReport(cwd, {
version: PROJECT_DIAGNOSTICS_CACHE_VERSION,
cwd,
generatedAt: new Date().toISOString(),
sessionId: runtime.telemetrySessionId,
turnIndex: runtime.turnIndex,
projectSeqStart: runtime.turnStartProjectSeq,
projectSeqEnd: runtime.projectSeq,
diagnostics: projectDiagnosticsDelta,
sources: [...projectDiagnosticsSources].sort((a, b) => a.localeCompare(b)),
});
}
const t4 = Date.now();
const modifiedRangesByFile = new Map(Object.entries(turnState.files).map(([file, state]) => [
normalizeMapKey(resolveRunnerPath(cwd, file)),
state.modifiedRanges,
]));
const getFileSeq = runtime.getFileSeq;
const fileSeqByPath = new Map();
if (getFileSeq) {
for (const file of files) {
const filePath = normalizeMapKey(resolveRunnerPath(cwd, file));
fileSeqByPath.set(filePath, getFileSeq.call(runtime, filePath));
}
}
if (getFlag("lens-actionable-warnings")) {
try {
const report = await buildActionableWarningsReport({
cwd,
sessionId: runtime.telemetrySessionId,
turnIndex: runtime.turnIndex,
files,
modifiedRangesByFile,
// Suppress the ast-grep secret advisory at any location already
// surfaced in the unified secrets blocker above (#131 Mode 3) — the
// secret is reported once, not twice.
dispatchWarnings: runtime
.peekActionableWarnings()
.filter((w) => !(isSecretWarning(w) &&
typeof w.line === "number" &&
secretBlockedLocations.has(secretLocationKey(w.filePath, w.line)))),
includeLspCodeActions: !!getFlag("lens-actionable-warning-actions"),
projectSeqStart: runtime.turnStartProjectSeq,
projectSeqEnd: runtime.projectSeq,
fileSeqByPath,
deltaOnly: !getFlag("lens-actionable-warning-all"),
dbg,
});
writeActionableWarningsReport(cacheManager, cwd, report);
appendActionableWarningsHistory(cwd, report);
const advisory = formatActionableWarningsAdvisory(report);
if (advisory)
advisoryParts.push(advisory);
logActionableWarningsEvent({
event: advisory ? "advisory_injected" : "advisory_skipped",
sessionId: runtime.telemetrySessionId,
metadata: {
turnIndex: runtime.turnIndex,
unsuppressed: report.summary.unsuppressed,
},
});
logLatency({
type: "phase",
toolName: "turn_end",
filePath: cwd,
phase: "actionable_warnings_report",
durationMs: Date.now() - t4,
metadata: report.summary,
});
}
catch (err) {
dbg(`turn_end: actionable warning report failed: ${err}`);
logLatency({
type: "phase",
toolName: "turn_end",
filePath: cwd,
phase: "actionable_warnings_report",
durationMs: Date.now() - t4,
metadata: {
failed: true,
error: err instanceof Error ? err.message : String(err),
},
});
}
}
// Call-graph impact analysis — surface WillBreak/MayBreak callers for modified symbols
if (runtime.callGraph && files.length > 0) {
try {
const { impact, formatImpact } = await import("./call-graph.js");
const impactLines = [];
for (const filePath of files.slice(0, 5)) {
// Find callee keys for this file in the call graph
const fileCallerKeys = [...runtime.callGraph.callers.keys()].filter((k) => k.startsWith(`${filePath}:`));
for (const calleeKey of fileCallerKeys.slice(0, 3)) {
const results = impact(runtime.callGraph, calleeKey);
if (results.length > 0) {
const summary = formatImpact(results, cwd);
if (summary)
impactLines.push(` ${calleeKey.split(":").pop()}: ${summary}`);
}
}
}
if (impactLines.length > 0) {
advisoryParts.push(`📊 Call-graph impact (changed symbols have callers):\n${impactLines.join("\n")}`);
}
}
catch {
// Non-fatal — call graph is best-effort
}
}
const t5 = Date.now();
try {
const qualityReport = buildCodeQualityWarningsReport({
cwd,
sessionId: runtime.telemetrySessionId,
turnIndex: runtime.turnIndex,
warnings: runtime.peekCodeQualityWarnings(),
modifiedRangesByFile,
projectSeqStart: runtime.turnStartProjectSeq,
projectSeqEnd: runtime.projectSeq,
fileSeqByPath,
});
writeCodeQualityWarningsReport(cacheManager, cwd, qualityReport);
appendCodeQualityWarningsHistory(cwd, qualityReport);
const advisory = formatCodeQualityWarningsAdvisory(qualityReport);
if (advisory)
advisoryParts.push(advisory);
logLatency({
type: "phase",
toolName: "turn_end",
filePath: cwd,
phase: "code_quality_warnings_report",
durationMs: Date.now() - t5,
metadata: qualityReport.summary,
});
}
catch (err) {
dbg(`turn_end: code quality warning report failed: ${err}`);
logLatency({
type: "phase",
toolName: "turn_end",
filePath: cwd,
phase: "code_quality_warnings_report",
durationMs: Date.now() - t5,
metadata: {
failed: true,
error: err instanceof Error ? err.message : String(err),
},
});
}
// Cross-file dead-code (#127): surface the cached session_start scan as an
// advisory (project-wide unused symbols are slow to compute, so we read the
// cache rather than re-scanning every turn — the analogue of knip's cache
// for non-JS/TS languages). Merged across languages for polyglot repos.
try {
const deadCodeResults = [];
for (const client of deadCodeClients) {
if (!client.detect(cwd))
continue;
const cached = cacheManager.readCache(`dead-code-${client.id}`, cwd);
if (cached?.data)
deadCodeResults.push(cached.data);
}
const advisory = formatDeadCodeAdvisory(deadCodeResults);
if (advisory)
advisoryParts.push(advisory);
}
catch (err) {
dbg(`turn_end: dead-code advisory failed: ${err}`);
}
cacheManager.incrementTurnCycle(cwd);
const labeledAdvisoryParts = advisoryParts.map((p) => `ℹ️ Advisory — no action required this turn:\n${p}`);
const findingParts = [...blockerParts, ...labeledAdvisoryParts];
if (findingParts.length > 0) {
dbg(`turn_end: ${blockerParts.length} blocker section(s), ${advisoryParts.length} advisory section(s) found, persisting for next context`);
const content = capTurnEndMessage(findingParts.join("\n\n"));
const signature = `${files
.slice()
.sort((a, b) => a.localeCompare(b))
.join("|")}::${content}`;
const last = cacheManager.readCache("turn-end-findings-last", cwd);
if (last?.data?.signature === signature &&
last?.data?.sessionId === runtime.telemetrySessionId) {
dbg("turn_end: duplicate findings detected (same session), suppressing re-prompt");
cacheManager.clearTurnState(cwd);
runtime.fixedThisTurn.clear();
resetFormatService();
return;
}
cacheManager.writeCache("turn-end-findings", { content }, cwd);
cacheManager.writeCache("turn-end-findings-last", {
signature,
sessionId: runtime.telemetrySessionId,
projectSeqStart: runtime.turnStartProjectSeq,
projectSeqEnd: runtime.projectSeq,
}, cwd);
emitLensTurnFindings({
cwd,
filePaths: files.map((file) => resolveRunnerPath(cwd, file)),
sessionId: runtime.telemetrySessionId,
turnIndex: runtime.turnIndex,
blockerSections: blockerParts.length,
advisorySections: advisoryParts.length,
content,
});
}
if (blockerParts.length === 0) {
cacheManager.clearTurnState(cwd);
}
runtime.fixedThisTurn.clear();
runtime.clearActionableWarnings();
runtime.clearCodeQualityWarnings();
logLatency({
type: "tool_result",
toolName: "turn_end",
filePath: cwd,
durationMs: Date.now() - turnEndStart,
result: blockerParts.length > 0 ? "blockers_found" : "clean",
metadata: {
fileCount: files.length,
blockerSections: blockerParts.length,
advisorySections: advisoryParts.length,
},
});
resetFormatService();
}