pi-lens
Version:
Real-time code feedback for pi — LSP, linters, formatters, type-checking, structural analysis & booboo
1,043 lines (1,042 loc) • 44.5 kB
JavaScript
/**
* Post-write pipeline for pi-lens
*
* Extracted from index.ts tool_result handler.
* Runs sequentially on every file write/edit:
* 1. Secrets scan (blocking — early exit)
* 2. Auto-format (Biome, Prettier, Ruff, gofmt, etc.)
* 3. Auto-fix (Biome --write, Ruff --fix, ESLint --fix)
* 4. LSP file sync (open/update in LSP servers)
* 5. Dispatch lint (type errors, security rules)
* 6. Test runner (run corresponding test file)
* 7. Cascade diagnostics (other files with errors, LSP only)
*/
import * as nodeFs from "node:fs";
import * as path from "node:path";
import { findNearestContaining } from "./path-utils.js";
import { recordFromDispatchDiagnostic, } from "./actionable-warnings.js";
import { recordFromCodeQualityDiagnostic, } from "./code-quality-warnings.js";
import { recordDiagnostics } from "./widget-state.js";
import { getDiagnosticLogger } from "./diagnostic-logger.js";
import { getDiagnosticTracker } from "./diagnostic-tracker.js";
import { computeCascadeForFile, dispatchLintWithResult, } from "./dispatch/integration.js";
import { toRunnerDisplayPath } from "./dispatch/runner-context.js";
import { createAvailabilityChecker, resolveAvailableOrInstall, resolveCommandArgsWithInstallFallback, resolveToolCommand, resolveToolCommandWithInstallFallback, } from "./dispatch/runners/utils/runner-helpers.js";
import { findDetektConfig } from "./dispatch/runners/detekt.js";
import { detectFileKind, getFileKindLabel } from "./file-kinds.js";
import { detectFileChangedAfterCommand, getProjectIgnoreMatcher, isExcludedDirName, } from "./file-utils.js";
import { logLatency } from "./latency-logger.js";
import { emitLensAnalysisComplete } from "./lens-events.js";
import { publishFilesTouched } from "./bus-publish.js";
import { publishDiagnostics, wasPreviouslyReportedDirty, } from "./diagnostics-publish.js";
import { getLSPService } from "./lsp/index.js";
import { clearGraphCache } from "./review-graph/builder.js";
import { RUNTIME_CONFIG } from "./runtime-config.js";
import { getAmbientAbortSignal, safeSpawnAsync } from "./safe-spawn.js";
import { combineAbortSignals } from "./deadline-utils.js";
import { getAutofixPolicyForFile, getPreferredAutofixTools, getRubocopCommand, hasBiomeConfig, hasDetektConfig, hasEslintConfig, hasGolangciConfig, hasKtfmtConfig, hasOxlintConfig, hasRubocopConfig, hasSqlfluffConfig, hasStylelintConfig, } from "./tool-policy.js";
const LSP_MAX_FILE_BYTES = RUNTIME_CONFIG.pipeline.lspMaxFileBytes;
const LSP_MAX_FILE_LINES = RUNTIME_CONFIG.pipeline.lspMaxFileLines;
const LSP_SPAWN_BUDGET_MS = RUNTIME_CONFIG.pipeline.lspSpawnBudgetMs;
const AUTOFIX_CHANGED_FILE_SCAN_LIMIT = 5000;
/**
* Hard ceiling for the pre-dispatch LSP sync (`resyncLspFile`). The sync sends a
* didChange/didOpen; that write can backpressure indefinitely when the language
* server's stdin isn't being drained (a wedged/CPU-bound server), which would
* hang the whole edit with no per-call bound — client acquisition is capped, but
* the notify write is not. So the sync is abandoned after this budget (the edit
* proceeds; the dispatch LSP runner, which has its own 30s cap, still tries).
*/
function lspSyncBudgetMs() {
const raw = Number(process.env.PI_LENS_LSP_SYNC_BUDGET_MS);
return Number.isFinite(raw) && raw > 0 ? raw : 3000;
}
// Scan one directory's entries into `snapshot`, pushing walkable subdirs onto
// `stack`. Extracted from the walk loop to keep each function's cognitive
// complexity low. Excluded/ignored dirs are not descended; ignored/vanished
// files are skipped.
// Files stat'd between event-loop yields. The walk stays on the tool_result
// hot path; yielding every N keeps its longest synchronous stretch well under
// the <50ms hook-burst budget even at the AUTOFIX_CHANGED_FILE_SCAN_LIMIT cap.
const SNAPSHOT_YIELD_EVERY = 500;
// Scan one directory's entries into `snapshot`, pushing walkable subdirs onto
// `stack`. Yields to the event loop every SNAPSHOT_YIELD_EVERY files (shared
// `counter`) so a single huge directory can't hold the loop. Excluded/ignored
// dirs are not descended; ignored/vanished files are skipped.
async function snapshotDirInto(dir, ignoreMatcher, stack, snapshot, counter) {
let entries;
try {
entries = nodeFs.readdirSync(dir, { withFileTypes: true });
}
catch {
return;
}
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
if (!isExcludedDirName(entry.name) &&
!ignoreMatcher.isIgnored(fullPath, true)) {
stack.push(fullPath);
}
continue;
}
if (!entry.isFile())
continue;
if (ignoreMatcher.isIgnored(fullPath, false))
continue;
try {
const stat = nodeFs.statSync(fullPath);
snapshot.set(path.resolve(fullPath), {
mtimeMs: stat.mtimeMs,
size: stat.size,
});
}
catch {
// ignore vanished files
}
if (++counter.n % SNAPSHOT_YIELD_EVERY === 0) {
await new Promise((resolve) => setImmediate(resolve));
}
}
}
// Exported for the event-loop occupancy guard (#361/#368): an O(files) walk on
// the tool_result autofix path, bounded by AUTOFIX_CHANGED_FILE_SCAN_LIMIT and
// chunk-yielding every SNAPSHOT_YIELD_EVERY files so it never blocks the TUI.
export async function snapshotProjectFiles(root) {
const snapshot = new Map();
const projectRoot = path.resolve(root);
const ignoreMatcher = getProjectIgnoreMatcher(projectRoot);
const stack = [projectRoot];
const counter = { n: 0 };
while (stack.length > 0 && snapshot.size < AUTOFIX_CHANGED_FILE_SCAN_LIMIT) {
await snapshotDirInto(stack.pop(), ignoreMatcher, stack, snapshot, counter);
}
return snapshot;
}
async function diffProjectSnapshot(root, before) {
const after = await snapshotProjectFiles(root);
const changed = new Set();
for (const [filePath, next] of after) {
const prev = before.get(filePath);
if (prev?.mtimeMs !== next.mtimeMs || prev?.size !== next.size) {
changed.add(filePath);
}
}
for (const filePath of before.keys()) {
if (!after.has(filePath))
changed.add(filePath);
}
return [...changed].sort((a, b) => a.localeCompare(b));
}
function exceedsLspSyncLimits(_filePath, content) {
const sizeBytes = Buffer.byteLength(content, "utf-8");
if (sizeBytes > LSP_MAX_FILE_BYTES) {
return {
tooLarge: true,
reason: `${Math.round(sizeBytes / 1024)}KB exceeds ${Math.round(LSP_MAX_FILE_BYTES / 1024)}KB`,
};
}
const lineCount = content.split("\n").length;
if (lineCount > LSP_MAX_FILE_LINES) {
return {
tooLarge: true,
reason: `${lineCount} lines exceeds ${LSP_MAX_FILE_LINES}`,
};
}
return { tooLarge: false, reason: "" };
}
function createPhaseTracker(toolName, filePath) {
const phases = [];
return {
start(name) {
phases.push({ name, startTime: Date.now(), ended: false });
},
end(name, metadata) {
const p = phases.find((x) => x.name === name && !x.ended);
if (p) {
p.ended = true;
logLatency({
type: "phase",
toolName,
filePath,
phase: name,
durationMs: Date.now() - p.startTime,
metadata,
});
}
},
};
}
// --- ESLint autofix helpers ---
export { hasEslintConfig, hasRubocopConfig, hasSqlfluffConfig, hasStylelintConfig, };
const _eslintCache = new Map();
/**
* Run eslint --fix on a file. Runs a single spawn and diffs the file before/after,
* same idiom as the other autofix helpers below. Exit code 1 (unfixable problems
* remain) is allowed because fixes may still have been applied; only exit code 2
* (config/fatal error) is treated as failure.
* Returns 1 if the file changed, 0 if ESLint is not configured / not available /
* made no changes.
*/
async function tryEslintFix(filePath, cwd) {
const userHasConfig = hasEslintConfig(cwd);
if (!userHasConfig)
return 0;
const cacheKey = path.resolve(cwd);
let cached = _eslintCache.get(cacheKey);
if (!cached) {
const candidate = resolveToolCommand(cwd, "eslint") ?? "eslint";
const check = await safeSpawnAsync(candidate, ["--version"], {
timeout: 5000,
cwd,
});
cached = {
available: !check.error && check.status === 0,
bin: !check.error && check.status === 0 ? candidate : null,
};
_eslintCache.set(cacheKey, cached);
}
if (!cached.available || !cached.bin)
return 0;
const cmd = cached.bin;
return detectFileChangedAfterCommand(filePath, cmd, ["--fix", "--no-error-on-unmatched-pattern", filePath], cwd, [1]);
}
async function tryStylelintFix(filePath, cwd) {
const cmd = await resolveToolCommandWithInstallFallback(cwd, "stylelint");
if (!cmd)
return 0;
return detectFileChangedAfterCommand(filePath, cmd, ["--fix", "--allow-empty-input", filePath], cwd, [2]);
}
async function trySqlfluffFix(filePath, cwd) {
const cmd = await resolveToolCommandWithInstallFallback(cwd, "sqlfluff");
if (!cmd)
return 0;
const args = ["fix", "--force", filePath];
if (!hasSqlfluffConfig(cwd)) {
args.splice(2, 0, "--dialect", "ansi");
}
return detectFileChangedAfterCommand(filePath, cmd, args, cwd);
}
async function tryRubocopFix(filePath, cwd) {
const resolved = await resolveCommandArgsWithInstallFallback(getRubocopCommand(cwd), "rubocop", cwd, ["--version"], 10000);
if (!resolved)
return 0;
return detectFileChangedAfterCommand(filePath, resolved.cmd, [...resolved.args, "-a", "--no-color", filePath], cwd, [1]);
}
async function tryKtlintFix(filePath, cwd) {
const cmd = await resolveToolCommandWithInstallFallback(cwd, "ktlint");
if (!cmd)
return 0;
return detectFileChangedAfterCommand(filePath, cmd, ["-F", filePath], cwd, [1]);
}
// golangci-lint/detekt/ktfmt have no TOOL_COMMAND_SPEC; resolve via availability
// checkers like their runners do.
const golangciAutofixChecker = createAvailabilityChecker("golangci-lint", ".exe");
const detektAutofixChecker = createAvailabilityChecker("detekt", ".bat");
const ktfmtAutofixChecker = createAvailabilityChecker("ktfmt", ".bat");
async function tryKtfmtFix(filePath, cwd) {
// Config-first: the autofix policy only reaches here when the project opted
// into ktfmt, so resolveAvailableOrInstall honors that gate. ktfmt writes the
// formatted file in place and exits 0; treat any byte change as the fix.
const cmd = await resolveAvailableOrInstall(ktfmtAutofixChecker, "ktfmt", cwd);
if (!cmd)
return 0;
const absPath = path.resolve(cwd, filePath);
return detectFileChangedAfterCommand(filePath, cmd, [absPath], cwd, [0]);
}
async function tryGolangciLintFix(filePath, cwd) {
// Config-first: the autofix policy only reaches here when a .golangci.* config
// exists. resolveAvailableOrInstall honors that gate (won't auto-install a
// config-first tool). golangci-lint exits non-zero when issues remain after
// --fix, so allow its issue-found codes.
const cmd = await resolveAvailableOrInstall(golangciAutofixChecker, "golangci-lint", cwd);
if (!cmd)
return 0;
return detectFileChangedAfterCommand(filePath, cmd, ["run", "--fix", filePath], cwd, [1, 7]);
}
async function tryDetektFix(filePath, cwd) {
const configPath = findDetektConfig(cwd);
if (!configPath)
return 0;
if (!(await detektAutofixChecker.isAvailableAsync(cwd)))
return 0;
const cmd = detektAutofixChecker.getCommand(cwd);
if (!cmd)
return 0;
const absPath = path.resolve(cwd, filePath);
return detectFileChangedAfterCommand(filePath, cmd, ["--auto-correct", "--input", absPath, "--config", configPath], cwd, [1, 2]);
}
async function tryMarkdownlintFix(filePath, cwd) {
const cmd = await resolveToolCommandWithInstallFallback(cwd, "markdownlint");
if (!cmd)
return 0;
// markdownlint-cli2 --fix exits non-zero when unfixable violations remain.
return detectFileChangedAfterCommand(filePath, cmd, ["--fix", filePath], cwd, [1]);
}
async function tryOxlintFix(filePath, cwd) {
const cmd = await resolveToolCommandWithInstallFallback(cwd, "oxlint");
if (!cmd)
return 0;
return detectFileChangedAfterCommand(filePath, cmd, ["--fix", filePath], cwd, [1]);
}
async function tryRustClippyFix(filePath) {
const check = await safeSpawnAsync("cargo", ["--version"], { timeout: 5000 });
if (check.error || check.status !== 0)
return [];
const cargoDir = findNearestContaining(path.dirname(path.resolve(filePath)), [
"Cargo.toml",
]);
if (!cargoDir)
return [];
const before = await snapshotProjectFiles(cargoDir);
const result = await safeSpawnAsync("cargo", ["clippy", "--fix", "--allow-dirty", "--allow-staged", "-q"], { timeout: 30000, cwd: cargoDir });
if (result.error || result.status !== 0)
return [];
return diffProjectSnapshot(cargoDir, before);
}
async function tryDartFix(filePath) {
const check = await safeSpawnAsync("dart", ["--version"], { timeout: 5000 });
if (check.error || check.status !== 0)
return [];
const pubspecDir = findNearestContaining(path.dirname(path.resolve(filePath)), ["pubspec.yaml"]);
if (!pubspecDir)
return [];
const before = await snapshotProjectFiles(pubspecDir);
const result = await safeSpawnAsync("dart", ["fix", "--apply"], {
timeout: 30000,
cwd: pubspecDir,
});
if (result.error || result.status !== 0)
return [];
return diffProjectSnapshot(pubspecDir, before);
}
// --- Pipeline phase helpers ---
export async function runAutofix(filePath, cwd, getFlag, dbg, deps) {
const { biomeClient, ruffClient, fixedThisTurn } = deps;
const noAutofix = getFlag("no-autofix");
let fixedCount = 0;
const autofixTools = [];
const attemptedTools = [];
const changedFiles = new Set();
const markTargetChanged = () => changedFiles.add(path.resolve(filePath));
let needsContentRefresh = false;
if (fixedThisTurn.has(filePath)) {
dbg(`autofix: skipped for ${filePath} (already fixed this turn)`);
return {
fixedCount,
autofixTools,
attemptedTools,
changedFiles: [],
needsContentRefresh,
skipReason: "already_fixed_this_turn",
};
}
if (noAutofix) {
dbg(`autofix: skipped for ${filePath} (--no-autofix)`);
return {
fixedCount,
autofixTools,
attemptedTools,
changedFiles: [],
needsContentRefresh,
skipReason: "disabled_by_flag",
};
}
const autofixContext = {
hasEslintConfig: hasEslintConfig(cwd),
hasStylelintConfig: hasStylelintConfig(cwd),
hasSqlfluffConfig: hasSqlfluffConfig(cwd),
hasRubocopConfig: hasRubocopConfig(cwd),
hasBiomeConfig: hasBiomeConfig(cwd),
hasGolangciConfig: hasGolangciConfig(cwd),
hasDetektConfig: hasDetektConfig(cwd),
hasKtfmtConfig: hasKtfmtConfig(cwd),
hasOxlintConfig: hasOxlintConfig(cwd),
};
const autofixPolicy = getAutofixPolicyForFile(filePath, autofixContext);
const preferredAutofixTools = autofixPolicy?.safe
? getPreferredAutofixTools(filePath, autofixContext)
: [];
dbg(`autofix: policy for ${filePath} -> ${autofixPolicy?.defaultTool ?? "none"} ` +
`(preferred: ${preferredAutofixTools.join(",") || "none"}, gate: ${autofixPolicy?.gate ?? "none"}, safe: ${autofixPolicy?.safe ? "yes" : "no"})`);
if (!autofixPolicy) {
dbg(`autofix: no policy for ${filePath}`);
return {
fixedCount,
autofixTools,
attemptedTools,
changedFiles: [],
needsContentRefresh,
skipReason: "no_policy",
};
}
if (!autofixPolicy.safe || preferredAutofixTools.length === 0) {
dbg(`autofix: no safe preferred tools for ${filePath}`);
return {
fixedCount,
autofixTools,
attemptedTools,
changedFiles: [],
needsContentRefresh,
skipReason: "no_safe_tools",
};
}
for (const toolName of preferredAutofixTools) {
attemptedTools.push(toolName);
if (toolName === "ruff") {
const ruffReady = ruffClient.isPythonFile(filePath)
? await ruffClient.ensureAvailable()
: false;
if (!ruffReady) {
dbg(`autofix: ruff unavailable for ${filePath}`);
continue;
}
const result = await ruffClient.fixFileAsync(filePath);
if (result.success && result.fixed > 0) {
fixedCount += result.fixed;
autofixTools.push(`ruff:${result.fixed}`);
fixedThisTurn.add(filePath);
markTargetChanged();
dbg(`autofix: ruff fixed ${result.fixed} issue(s) in ${filePath}`);
needsContentRefresh = true;
}
continue;
}
if (toolName === "biome") {
const biomeReady = biomeClient.isSupportedFile(filePath)
? await biomeClient.ensureAvailable()
: false;
if (!biomeReady) {
dbg(`autofix: biome unavailable or unsupported for ${filePath}`);
continue;
}
const result = await biomeClient.fixFileAsync(filePath);
if (result.success && result.fixed > 0) {
fixedCount += result.fixed;
autofixTools.push(`biome:${result.fixed}`);
fixedThisTurn.add(filePath);
markTargetChanged();
dbg(`autofix: biome fixed ${result.fixed} issue(s) in ${filePath}`);
needsContentRefresh = true;
}
continue;
}
if (toolName === "eslint") {
const eslintFixed = await tryEslintFix(filePath, cwd);
if (eslintFixed > 0) {
fixedCount += eslintFixed;
autofixTools.push(`eslint:${eslintFixed}`);
fixedThisTurn.add(filePath);
markTargetChanged();
dbg(`autofix: eslint fixed ${eslintFixed} issue(s) in ${filePath}`);
needsContentRefresh = true;
}
continue;
}
if (toolName === "stylelint") {
const stylelintFixed = await tryStylelintFix(filePath, cwd);
if (stylelintFixed > 0) {
fixedCount += stylelintFixed;
autofixTools.push(`stylelint:${stylelintFixed}`);
fixedThisTurn.add(filePath);
markTargetChanged();
dbg(`autofix: stylelint fixed ${stylelintFixed} issue(s) in ${filePath}`);
needsContentRefresh = true;
}
continue;
}
if (toolName === "sqlfluff") {
const sqlfluffFixed = await trySqlfluffFix(filePath, cwd);
if (sqlfluffFixed > 0) {
fixedCount += sqlfluffFixed;
autofixTools.push(`sqlfluff:${sqlfluffFixed}`);
fixedThisTurn.add(filePath);
markTargetChanged();
dbg(`autofix: sqlfluff fixed ${sqlfluffFixed} issue(s) in ${filePath}`);
needsContentRefresh = true;
}
continue;
}
if (toolName === "rubocop") {
const rubocopFixed = await tryRubocopFix(filePath, cwd);
if (rubocopFixed > 0) {
fixedCount += rubocopFixed;
autofixTools.push(`rubocop:${rubocopFixed}`);
fixedThisTurn.add(filePath);
markTargetChanged();
dbg(`autofix: rubocop fixed ${rubocopFixed} issue(s) in ${filePath}`);
needsContentRefresh = true;
}
continue;
}
if (toolName === "ktlint") {
const ktlintFixed = await tryKtlintFix(filePath, cwd);
if (ktlintFixed > 0) {
fixedCount += ktlintFixed;
autofixTools.push(`ktlint:${ktlintFixed}`);
fixedThisTurn.add(filePath);
markTargetChanged();
dbg(`autofix: ktlint fixed ${ktlintFixed} issue(s) in ${filePath}`);
needsContentRefresh = true;
}
continue;
}
if (toolName === "rust-clippy") {
const clippyChangedFiles = await tryRustClippyFix(filePath);
if (clippyChangedFiles.length > 0) {
fixedCount += clippyChangedFiles.length;
autofixTools.push(`rust-clippy:${clippyChangedFiles.length}`);
fixedThisTurn.add(filePath);
for (const changedFile of clippyChangedFiles)
changedFiles.add(changedFile);
dbg(`autofix: rust-clippy changed ${clippyChangedFiles.length} file(s) from ${filePath}`);
needsContentRefresh = true;
}
continue;
}
if (toolName === "dart-analyze") {
const dartChangedFiles = await tryDartFix(filePath);
if (dartChangedFiles.length > 0) {
fixedCount += dartChangedFiles.length;
autofixTools.push(`dart-analyze:${dartChangedFiles.length}`);
fixedThisTurn.add(filePath);
for (const changedFile of dartChangedFiles)
changedFiles.add(changedFile);
dbg(`autofix: dart fix changed ${dartChangedFiles.length} file(s) from ${filePath}`);
needsContentRefresh = true;
}
continue;
}
if (toolName === "golangci-lint") {
const fixed = await tryGolangciLintFix(filePath, cwd);
if (fixed > 0) {
fixedCount += fixed;
autofixTools.push(`golangci-lint:${fixed}`);
fixedThisTurn.add(filePath);
markTargetChanged();
dbg(`autofix: golangci-lint fixed ${filePath}`);
needsContentRefresh = true;
}
continue;
}
if (toolName === "detekt") {
const fixed = await tryDetektFix(filePath, cwd);
if (fixed > 0) {
fixedCount += fixed;
autofixTools.push(`detekt:${fixed}`);
fixedThisTurn.add(filePath);
markTargetChanged();
dbg(`autofix: detekt --auto-correct fixed ${filePath}`);
needsContentRefresh = true;
}
continue;
}
if (toolName === "ktfmt") {
const fixed = await tryKtfmtFix(filePath, cwd);
if (fixed > 0) {
fixedCount += fixed;
autofixTools.push(`ktfmt:${fixed}`);
fixedThisTurn.add(filePath);
markTargetChanged();
dbg(`autofix: ktfmt formatted ${filePath}`);
needsContentRefresh = true;
}
continue;
}
if (toolName === "markdownlint") {
const fixed = await tryMarkdownlintFix(filePath, cwd);
if (fixed > 0) {
fixedCount += fixed;
autofixTools.push(`markdownlint:${fixed}`);
fixedThisTurn.add(filePath);
markTargetChanged();
dbg(`autofix: markdownlint --fix fixed ${filePath}`);
needsContentRefresh = true;
}
continue;
}
if (toolName === "oxlint") {
const fixed = await tryOxlintFix(filePath, cwd);
if (fixed > 0) {
fixedCount += fixed;
autofixTools.push(`oxlint:${fixed}`);
fixedThisTurn.add(filePath);
markTargetChanged();
dbg(`autofix: oxlint --fix fixed ${filePath}`);
needsContentRefresh = true;
}
continue;
}
}
if (attemptedTools.length > 0 && autofixTools.length === 0) {
dbg(`autofix: attempted ${attemptedTools.join(",")} for ${filePath}, but no fixes were applied`);
}
return {
fixedCount,
autofixTools,
attemptedTools,
changedFiles: [...changedFiles],
needsContentRefresh,
};
}
export async function resyncLspFile(filePath, fileContent, needsContentRefresh, lspSyncCompleted, getFlag, dbg) {
if (getFlag("no-lsp"))
return;
if (!needsContentRefresh && lspSyncCompleted)
return;
const limitCheck = exceedsLspSyncLimits(filePath, fileContent);
if (limitCheck.tooLarge)
return;
try {
const lspService = getLSPService();
if (lspService.supportsLSP(filePath)) {
// Push the final post-format/post-fix content through touchFile (not the
// bare openFile) so it registers in the touch-debounce map via
// markTouched. The dispatch-lsp-runner's touchFile fires ~80ms later with
// identical content; registering this push makes its shouldSkipNotify
// return true, so it reuses the diagnostics THIS push triggers instead of
// re-clearing the cache and forcing the server to recompute from scratch —
// the dominant per-edit LSP latency (#203). diagnostics:"none" keeps this
// call non-blocking; the dispatch runner owns the diagnostics wait. We let
// the cache clear (no preserveDiagnostics) so the wait resolves on fresh,
// correctly-positioned diagnostics rather than stale pre-edit ones — the
// didChange triggers a server recompute regardless of cache preservation.
//
// The touch is client-wait-capped, but its didChange/didOpen *write* can
// backpressure forever on a wedged server (stdin not drained), which would
// hang the whole edit with no bound and — until this — no log. Race it
// against a hard budget + the turn's abort signal (Escape): whichever wins,
// the edit proceeds. A wedged server no longer parks the pipeline.
const budgetMs = lspSyncBudgetMs();
const abort = getAmbientAbortSignal();
if (abort?.aborted)
return;
const bail = combineAbortSignals(abort, AbortSignal.timeout(budgetMs));
const startedAt = Date.now();
const touch = lspService
.touchFile(filePath, fileContent, {
diagnostics: "none",
source: "lsp_sync",
clientScope: "primary",
maxClientWaitMs: LSP_SPAWN_BUDGET_MS,
})
.then(() => "done")
.catch((err) => {
dbg(`LSP resync after autofix error: ${err}`);
return "done";
});
const bailed = new Promise((resolve) => {
if (!bail || bail.aborted)
return resolve("bailed");
bail.addEventListener("abort", () => resolve("bailed"), { once: true });
});
const outcome = await Promise.race([touch, bailed]);
if (outcome === "bailed") {
// Abandon the still-pending write; the edit continues. Log it so this
// stall — previously an invisible hang — is queryable in latency.log.
logLatency({
type: "phase",
phase: "lsp_sync_abandoned",
filePath,
durationMs: Date.now() - startedAt,
metadata: {
source: "lsp_sync",
reason: abort?.aborted ? "aborted" : "timeout",
budgetMs,
},
});
dbg(`LSP resync ${abort?.aborted ? "aborted (Escape)" : `timed out after ${budgetMs}ms`}; server slow/wedged for ${filePath}`);
}
}
}
catch (err) {
dbg(`LSP resync after autofix error: ${err}`);
}
}
/** Maps a dispatch `Diagnostic` (clients/dispatch/types.ts) to the #502 bus payload shape. */
function toPilensDiagnosticEntry(d) {
const entry = {
severity: d.severity,
message: d.message,
tool: d.tool,
};
if (d.rule !== undefined)
entry.ruleId = d.rule;
if (d.line !== undefined)
entry.line = d.line;
if (d.column !== undefined)
entry.col = d.column;
if (d.fixable !== undefined)
entry.fixable = d.fixable;
return entry;
}
function buildAllClearOutput(_dispatchResult, elapsed, filePath) {
const kind = detectFileKind(filePath);
const langLabel = kind ? getFileKindLabel(kind) : path.extname(filePath);
const parts = [];
if (kind) {
parts.push(`${langLabel} clean`);
}
parts.push(`${elapsed}ms`);
return `checkmark ${parts.join(" · ")}`.replace("checkmark", "\u2713");
}
export async function runFormatPhase(filePath, getFormatService, dbg) {
let formatChanged = false;
let formattersUsed = [];
const formatFailures = [];
let fileContent;
const formatService = getFormatService();
try {
formatService.recordRead(filePath);
const result = await formatService.formatFile(filePath);
formattersUsed = result.formatters.map((f) => f.name);
if (result.anyChanged) {
formatChanged = true;
dbg("autoformat: " +
result.formatters
.map((f) => f.name + "(" + (f.changed ? "changed" : "unchanged") + ")")
.join(", "));
}
if (!result.allSucceeded) {
const failures = result.formatters.filter((f) => !f.success);
formatFailures.push(...failures.map((f) => `${f.name}: ${f.error ?? "unknown error"}`));
dbg("autoformat: " +
failures
.map((f) => f.name + " failed: " + (f.error ?? "unknown error"))
.join("; "));
}
}
catch (err) {
const message = err instanceof Error ? err.message : String(err);
formatFailures.push(message);
dbg(`autoformat error: ${err}`);
}
try {
fileContent = nodeFs.readFileSync(filePath, "utf-8");
}
catch {
fileContent = undefined;
}
return { formatChanged, formattersUsed, formatFailures, fileContent };
}
/**
* Build the 🔴 STOP blocker output with an inline code snippet for each
* diagnostic so the agent can see the exact line it wrote without re-reading
* the file.
*
* Example:
* L4: 'randomInt' is declared but its value is never read.
* → const randomInt = Math.floor(result);
*/
function buildEnrichedBlockerOutput(blockers, fileContent) {
const fileLines = fileContent.split("\n");
const MAX_SNIPPET = 120; // chars — keep it tight in context
let out = `\n\n🔴 STOP — ${blockers.length} issue(s) must be fixed:\n`;
const shown = blockers.slice(0, 10);
for (const d of shown) {
const lineNo = d.line ?? 1;
const nodeCtx = d.astNodeType ? ` (${d.astNodeType})` : "";
out += ` L${lineNo}: ${d.message}${nodeCtx}\n`;
// Prefer the exact matched node text (tree-sitter); fall back to the
// full source line (LSP / other runners).
const snippet = d.matchedText
? d.matchedText.trim().split("\n")[0]?.slice(0, MAX_SNIPPET)
: fileLines[lineNo - 1]?.trim().slice(0, MAX_SNIPPET);
if (snippet)
out += ` → ${snippet}\n`;
if (d.fixSuggestion)
out += ` 💡 ${d.fixSuggestion}\n`;
}
if (blockers.length > 10) {
out += ` ... and ${blockers.length - 10} more\n`;
}
return out;
}
// --- Main Pipeline ---
export async function runPipeline(ctx, deps) {
const { filePath, cwd, toolName, getFlag, dbg } = ctx;
const { getFormatService } = deps;
const phase = createPhaseTracker(toolName, filePath);
const pipelineStart = Date.now();
clearGraphCache();
phase.start("total");
// --- 1. Read file content ---
phase.start("read_file");
let fileContent;
try {
fileContent = nodeFs.readFileSync(filePath, "utf-8");
}
catch {
// File may not exist (e.g., deleted)
}
phase.end("read_file");
// --- 2. Auto-format ---
phase.start("format");
let formatChanged = false;
let formattersUsed = [];
let formatFailures = [];
const piChangedFiles = new Set();
const autoformatDisabled = !!getFlag("no-autoformat");
const immediateFormat = !!getFlag("immediate-format");
const formatDeferred = !autoformatDisabled && !immediateFormat && !!fileContent;
if (!autoformatDisabled && immediateFormat && fileContent) {
const formatResult = await runFormatPhase(filePath, getFormatService, dbg);
formatChanged = formatResult.formatChanged;
formattersUsed = formatResult.formattersUsed;
formatFailures = formatResult.formatFailures;
fileContent = formatResult.fileContent;
if (formatChanged) {
const absPath = path.resolve(filePath);
piChangedFiles.add(absPath);
publishFilesTouched({
reason: "format",
paths: [absPath],
cwd,
dbg,
fixes: formattersUsed.map((tool) => ({
path: absPath,
tool,
kind: "format",
})),
});
}
}
else if (formatDeferred) {
dbg(`autoformat: deferred until agent_end for ${filePath}`);
}
phase.end("format", {
formattersUsed,
formatChanged,
deferred: formatDeferred,
});
// --- 3. Auto-fix ---
phase.start("autofix");
const { fixedCount, autofixTools, attemptedTools, changedFiles: autofixChangedFiles, needsContentRefresh: fixRefresh, skipReason: autofixSkipReason, } = await runAutofix(filePath, cwd, getFlag, dbg, deps);
for (const changedFile of autofixChangedFiles) {
piChangedFiles.add(path.resolve(changedFile));
}
if (autofixChangedFiles.length > 0) {
// autofixTools entries are "tool:count" (e.g. "ruff:3"); runAutofix runs
// against ONE target filePath per call, so every tool that fired in this
// batch applies to every file the batch changed — best-effort attribution
// (not per-file precision) since the underlying tool runners don't report
// per-changed-file breakdown. Good enough for the "was this hunk
// mechanical" use case the fix-provenance field exists for.
const absChangedFiles = autofixChangedFiles.map((f) => path.resolve(f));
const toolNames = autofixTools.map((t) => t.split(":")[0]);
publishFilesTouched({
reason: "autofix",
paths: absChangedFiles,
cwd,
dbg,
fixes: absChangedFiles.flatMap((p) => toolNames.map((tool) => ({ path: p, tool, kind: "autofix" }))),
});
}
if (fixRefresh) {
try {
fileContent = nodeFs.readFileSync(filePath, "utf-8");
}
catch {
fileContent = undefined;
}
}
phase.end("autofix", {
fixedCount,
tools: autofixTools,
attemptedTools,
skipReason: autofixSkipReason,
});
// --- 4. LSP file sync ---
// Sync once with final post-format/post-fix content so dispatch and cascade
// diagnostics do not observe stale pre-format text.
phase.start("lsp_sync");
let lspSyncCompleted = false;
if (fileContent) {
await resyncLspFile(filePath, fileContent, true, false, getFlag, dbg);
lspSyncCompleted = true;
}
phase.end("lsp_sync", { completed: lspSyncCompleted, finalContent: true });
// --- 5. Dispatch lint ---
phase.start("dispatch_lint");
dbg(`dispatch: running lint tools for ${filePath}`);
const piApi = {
getFlag: getFlag,
};
const dispatchResult = await dispatchLintWithResult(filePath, cwd, piApi, ctx.modifiedRanges, {
model: ctx.telemetry?.model ?? "unknown",
sessionId: ctx.telemetry?.sessionId ?? "unknown",
turnIndex: ctx.telemetry?.turnIndex ?? 0,
writeIndex: ctx.telemetry?.writeIndex ?? 0,
});
recordDiagnostics(filePath, dispatchResult.diagnostics, ctx.telemetry?.writeIndex);
// #502: emit the write batch's FINAL diagnostic state immediately after
// recordDiagnostics commits it — this call site runs after format,
// autofix, and dispatch have all completed for this batch (see the phase
// order above), so dispatchResult.diagnostics IS the latest post-batch
// picture, not an intermediate runner result. Full-replace semantics: we
// always pass the complete current set for this file, including an
// explicit `[]` on the transition from a previously-reported-dirty state
// to clean (see clients/diagnostics-publish.ts for the contract).
{
const absPath = path.resolve(filePath);
const wasDirty = wasPreviouslyReportedDirty(absPath);
if (dispatchResult.diagnostics.length > 0 || wasDirty) {
publishDiagnostics({
cwd,
files: [
{
path: absPath,
diagnostics: dispatchResult.diagnostics.map(toPilensDiagnosticEntry),
},
],
dbg,
});
}
}
const hasBlockers = dispatchResult.hasBlockers;
const actionableWarnings = dispatchResult.warnings
.map((diagnostic) => recordFromDispatchDiagnostic(diagnostic, cwd))
.filter((warning) => Boolean(warning));
const codeQualityWarnings = dispatchResult.warnings
.map((diagnostic) => recordFromCodeQualityDiagnostic(diagnostic, cwd))
.filter((warning) => Boolean(warning));
if (dispatchResult.diagnostics.length > 0) {
const logger = getDiagnosticLogger();
const tracker = getDiagnosticTracker();
tracker.trackShown(dispatchResult.diagnostics);
const toKey = (d) => [
d.tool || "",
d.id || "",
d.rule || "",
d.filePath || "",
d.line || 0,
d.column || 0,
].join("|");
const inlineKeys = new Set([...dispatchResult.blockers, ...dispatchResult.fixed].map(toKey));
for (const d of dispatchResult.diagnostics) {
logger.logCaught(d, {
model: ctx.telemetry?.model ?? "unknown",
sessionId: ctx.telemetry?.sessionId ?? "unknown",
turnIndex: ctx.telemetry?.turnIndex ?? 0,
writeIndex: ctx.telemetry?.writeIndex ?? 0,
}, inlineKeys.has(toKey(d)));
}
}
if (fixedCount > 0)
getDiagnosticTracker().trackAutoFixed(fixedCount);
if (dispatchResult.resolvedCount > 0)
getDiagnosticTracker().trackAgentFixed(dispatchResult.resolvedCount);
let output = "";
if (dispatchResult.hasBlockers && fileContent) {
// Enrich blocker output with a code snippet so the agent can see the
// exact line it wrote that caused each violation — no re-read needed.
output += buildEnrichedBlockerOutput(dispatchResult.blockers, fileContent);
// Append fixed/coverage parts from the original output (slice off the
// blocker section we're replacing).
const rest = dispatchResult.output.slice(dispatchResult.blockerOutput.length);
if (rest)
output += rest;
}
else if (dispatchResult.output) {
output += `\n\n${dispatchResult.output}`;
}
if (fixedCount > 0) {
const detail = autofixTools.length > 0 ? ` (${autofixTools.join(", ")})` : "";
output += `\n\n✅ Auto-fixed ${fixedCount} issue(s)${detail}`;
}
if (formatFailures.length > 0) {
const details = formatFailures.slice(0, 3).join("; ");
const suffix = formatFailures.length > 3
? `; ... and ${formatFailures.length - 3} more`
: "";
output += `\n\n⚠️ Auto-format failed: ${details}${suffix}`;
}
if (formatChanged || fixedCount > 0) {
const changedList = [...piChangedFiles].map((changedFile) => toRunnerDisplayPath(cwd, changedFile));
const topFiles = changedList
.slice(0, 8)
.map((f) => " - " + f)
.join("\n");
const overflow = changedList.length > 8
? "\n - ... and " + (changedList.length - 8) + " more"
: "";
const fileList = changedList.length
? "\nModified files:\n" + topFiles + overflow
: "";
output += `\n\n⚠️ **File was modified by auto-format/fix. You MUST re-read modified file(s) before making any further edits — the content on disk has changed (whitespace, indentation, quotes, or code). Editing from memory will produce mismatches.**${fileList}`;
}
phase.end("dispatch_lint", {
hasOutput: !!dispatchResult.output,
diagnosticCount: dispatchResult.diagnostics.length,
});
// --- 6. Cascade diagnostics (LSP only) ---
// Kicked off UNAWAITED so the graph rebuild + neighbor LSP pulls run
// concurrently after the edit returns rather than blocking it (#450). The
// result is never shown inline — settled (bounded) and surfaced at turn_end.
// The stored promise must never reject: an unhandled rejection is fatal, so a
// failing compute resolves to an "error" skip-run instead.
const cascadePromise = getFlag("no-lsp")
? undefined
: computeCascadeForFile(filePath, cwd, {
hasBlockers,
dbg,
turnSeq: ctx.telemetry?.turnIndex,
writeSeq: ctx.telemetry?.writeIndex,
seqState: ctx.seqState,
fileContent,
wordIndex: ctx.wordIndex,
onWordIndexUpdated: ctx.onWordIndexUpdated,
}).catch((err) => {
dbg(`cascade compute failed for ${filePath}: ${err}`);
return {
filePath,
result: undefined,
neighborCount: 0,
diagnosticCount: 0,
skipReason: "error",
};
});
// --- Final timing + all-clear ---
const elapsed = Date.now() - pipelineStart;
if (!output) {
output = buildAllClearOutput(dispatchResult, elapsed, filePath);
}
phase.end("total", { hasOutput: !!output });
const fileModified = formatChanged || fixedCount > 0;
const changedFiles = [...piChangedFiles];
emitLensAnalysisComplete({
cwd,
filePath,
toolName,
model: ctx.telemetry?.model ?? "unknown",
sessionId: ctx.telemetry?.sessionId ?? "unknown",
turnIndex: ctx.telemetry?.turnIndex ?? 0,
writeIndex: ctx.telemetry?.writeIndex ?? 0,
diagnostics: dispatchResult.diagnostics,
blockers: dispatchResult.blockers,
warnings: dispatchResult.warnings,
fixed: dispatchResult.fixed,
resolvedCount: dispatchResult.resolvedCount,
hasBlockers,
fileModified,
changedFiles,
durationMs: elapsed,
});
return {
output,
hasBlockers,
cascadePromise,
isError: false,
fileModified,
changedFiles,
inlineBlockerSummary: dispatchResult.hasBlockers
? dispatchResult.blockerOutput.trim() || undefined
: undefined,
actionableWarnings,
codeQualityWarnings,
diagnostics: dispatchResult.diagnostics,
formattersUsed,
fixedCount,
autofixTools,
};
}