UNPKG

pi-lens

Version:

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

280 lines (279 loc) • 13.7 kB
/** * Host-neutral analysis facade for the MCP path. * * This is the heart of the "real review loop": it runs the *same* per-edit * dispatch pipeline pi-lens runs inside pi (`dispatchLintWithResult`) on a file, * and returns a structured, JSON-serializable result — diagnostics plus the * latency record for that dispatch, in the same schema pi writes to latency.log. * * Because the only host coupling is `getFlag` (see host-shim), this runs with no * pi process: an MCP server (or a `fresh` worker importing the freshly-built * dist) can drive it directly, letting Claude observe a commit's real behavioral * + perf impact first-hand rather than inferring it from pasted logs. */ import * as fs from "node:fs"; import * as path from "node:path"; import { CacheManager } from "../cache-manager.js"; import { CASCADE_GRAPH_KINDS, dispatchLintWithResult, getLatencyReports, } from "../dispatch/integration.js"; import { FactStore } from "../dispatch/fact-store.js"; import { detectFileKind } from "../file-kinds.js"; import { getDiagnosticTracker } from "../diagnostic-tracker.js"; import { getLSPService } from "../lsp/index.js"; import { loadProjectSnapshot } from "../project-snapshot.js"; import { buildOrUpdateGraph } from "../review-graph/service.js"; import { recordDiagnostics } from "../widget-state.js"; import { deserializeWordIndex, removeWordIndexDocument, scheduleWordIndexPersist, updateWordIndexDocument, WORD_INDEX_MAX_BYTES, } from "../word-index.js"; import { createMcpHost } from "./host-shim.js"; // #536: module-scoped FactStore for the warm-analyze graph seam, mirroring the // per-edit cascade path's own module-level `sessionFacts` singleton // (clients/dispatch/integration.ts) — buildOrUpdateGraph's incremental/cached // tiers key off a stable FactStore instance across calls, so a fresh FactStore // per call would defeat that reuse. Scoped separately from integration.ts's // singleton since this file has no dependency on that module's internal state. const warmGraphFacts = new FactStore(); // #536 rider (issue body: "when #348 phase 2 lands, the word-index per-edit // update should ride the SAME seam so both indexes stay warm together"): // MCP has no RuntimeCoordinator/`runtime.wordIndex` to hold a live index the // way pi's per-edit seam does (clients/dispatch/integration.ts's // `updateWordIndexForCascade`, called from clients/runtime-tool-result.ts with // `runtime.wordIndex`) — this process-scoped Map is the MCP-side equivalent: a // per-cwd live WordIndex, loaded once from the persisted snapshot and mutated // in place thereafter, mirroring `runtime.wordIndex`'s lifecycle for a process // that has no other place to hold it. `undefined` cached value = "checked, // nothing usable" (index missing or pre-phase-2/no-forward-map), distinct from // "never checked" (key absent) — avoids re-attempting a snapshot load with no // forward index on every single analyze call. const warmWordIndexes = new Map(); /** * Look up (loading from the persisted snapshot on first use per cwd) the warm * in-memory word index this analyze facade keeps mutated in place. Exported so * `symbolSearch()` (clients/lens-engine.ts) can prefer this live copy over a * fresh disk read when one exists for the cwd — otherwise a query immediately * following a warm `pilens_analyze` call in the SAME process would read a * stale on-disk snapshot until the debounced persist (default 1500ms) flushes. */ export function getOrLoadWarmWordIndex(cwd) { const key = path.resolve(cwd); if (warmWordIndexes.has(key)) return warmWordIndexes.get(key); const snapshot = loadProjectSnapshot(key); const index = deserializeWordIndex(snapshot?.wordIndex) ?? undefined; // Same phase-2 rule as updateWordIndexForCascade: no forward index ⇒ no // incremental primitive available, so don't cache it as "usable" — this // call site's whole point is the incremental single-doc update. const usable = index?.forward ? index : undefined; warmWordIndexes.set(key, usable); return usable; } /** * Test-only reset — the module-level warm cache otherwise survives across * unrelated test cases in the same vitest worker. */ export function _resetWarmWordIndexCacheForTests() { warmWordIndexes.clear(); } // Generous warm-up budgets: a cold language server needs to spawn AND publish // diagnostics. The per-edit dispatch runner caps these tightly (spawn budget + // 2500ms) for latency; a review tool prioritises completeness, so we pre-warm // with room to spare, then the measured dispatch reads the warm cache. // Bounded so a cold analysis can't hang: enough for fast servers (pyright, // rust-analyzer, gopls) and a warm typescript-language-server, but NOT enough to // fully load a large TS project from cold — that exceeds any per-call budget and // is the persistent warm server's job (see the `lsp` honesty signal + Tier 2). const WARMUP_CLIENT_WAIT_MS = 10_000; const WARMUP_DIAGNOSTICS_WAIT_MS = 6_000; function toMcpDiagnostic(diagnostic) { return { line: diagnostic.line, column: diagnostic.column, severity: diagnostic.severity, semantic: diagnostic.semantic, tool: diagnostic.tool, rule: diagnostic.rule, code: diagnostic.code, message: diagnostic.message, fixable: diagnostic.fixable, fixSuggestion: diagnostic.fixSuggestion, }; } /** * Pre-warm the LSP for a file: spawn the server and wait for it to publish * diagnostics, so the subsequent dispatch reads a warm cache instead of a cold * (empty) one. Best-effort — failures never block the analysis. */ async function warmLspForFile(absPath, host) { if (host.getFlag("no-lsp")) return; const lspService = getLSPService(); if (!lspService.supportsLSP(absPath)) return; let content; try { content = fs.readFileSync(absPath, "utf8"); } catch { return; } try { await lspService.touchFile(absPath, content, { diagnostics: "document", collectDiagnostics: true, clientScope: "primary", maxClientWaitMs: WARMUP_CLIENT_WAIT_MS, maxDiagnosticsWaitMs: WARMUP_DIAGNOSTICS_WAIT_MS, source: "mcp-warmup", }); } catch { // Best-effort warm-up; the dispatch runner still tries on its own. } } /** * Run the dispatch pipeline on `filePath` and return a structured result. * * Unlike pi's per-edit path this defaults to the *full* analysis (warnings + * structural smells, not just blocking errors), pre-warms the LSP so a cold * server doesn't under-report, records into the session diagnostic state so the * query tools compose, and runs delta-free so a repeated analysis of an * unchanged file is a consistent full snapshot rather than "new issues only". * * The latency report is matched against the dispatches appended *during this * call* (we snapshot the report count first), so concurrent callers don't pick * up each other's timings. */ export async function analyzeFile(filePath, cwd, options = {}) { const absPath = path.isAbsolute(filePath) ? filePath : path.resolve(cwd, filePath); // no-delta by default → a full snapshot every call (not delta-filtered); // caller flags win over the default. const host = createMcpHost({ "no-delta": true, ...(options.flags ?? {}) }); if (options.warmLsp !== false) { await warmLspForFile(absPath, host); } const reportsBefore = getLatencyReports().length; const start = Date.now(); const result = await dispatchLintWithResult(absPath, cwd, host, undefined, undefined, { blockingOnly: options.blockingOnly ?? false, }); const durationMs = Date.now() - start; if (options.record !== false) { // Mirror pipeline.ts's recording so pilens_diagnostics (mode=all) and // pilens_health see what this analysis found. recordDiagnostics(absPath, result.diagnostics); if (result.diagnostics.length > 0) { getDiagnosticTracker().trackShown(result.diagnostics); } } if (options.registerTurnState) { // Full-file range, importsChanged=true (conservative → dep/knip re-check // broadly). No sessionId — leaving it unset avoids turn_end's stale-session // eviction. Best-effort. try { const lineCount = fs.readFileSync(absPath, "utf8").split("\n").length; new CacheManager().addModifiedRange(absPath, { start: 1, end: lineCount }, true, cwd); } catch { // unreadable — skip turn-state registration } } if (options.updateGraph && !result.hasBlockers) { // #536: maintain the review graph on a successful warm analysis — the same // call pi's per-edit cascade path makes (computeCascadeForFile), gated the // same way (CASCADE_GRAPH_KINDS: only languages the graph actually models, // and skipped when the file has blockers, matching the cascade path's own // "primary_has_blockers" skip). buildOrUpdateGraph owns its own debounced // persist + seq machinery internally — this is the ONLY call needed; no // separate persist/flush step. Best-effort: a graph build failure must // never fail the analysis itself (the diagnostics are already computed). const fileKind = detectFileKind(absPath); if (fileKind && CASCADE_GRAPH_KINDS.has(fileKind)) { try { await buildOrUpdateGraph(cwd, [absPath], warmGraphFacts); } catch { // Best-effort — the graph update is additive; a failure here must not // surface as an analyze failure. } } // #536 rider: ride the SAME seam for the word index (#348 phase 2's // per-edit primitive), mirroring pi's `updateWordIndexForCascade` // (clients/dispatch/integration.ts) rule-for-rule rather than reusing it // directly — that function is module-private and reads its file-content // argument from the pipeline's already-read buffer, whereas this seam // reads the file itself (no pipeline hook here). Same rules: a cached // index with no `forward` map (or none loaded) ⇒ no-op (no incremental // primitive available — the eventual full rebuild covers it); an // oversized file is REMOVED, never partially indexed; the update is // synchronous (no interleaving hazard — MCP is single-process, same as // pi); a successful update schedules the SAME debounced persist // (`scheduleWordIndexPersist`, `PI_LENS_WORD_INDEX_PERSIST_DEBOUNCE_MS`) // pi's path uses — no second persist mechanism. // // Key shape: `path.resolve(absPath)`, matching the build path's own keys // (collectWordIndexDocs → collectSourceFilesAsync), NOT normalizeMapKey — // see updateWordIndexForCascade's doc comment for why a mismatched key // silently orphans a duplicate entry instead of replacing it. const warmIndex = getOrLoadWarmWordIndex(cwd); if (warmIndex) { try { const content = fs.readFileSync(absPath, "utf8"); const byteLength = Buffer.byteLength(content, "utf-8"); if (byteLength > WORD_INDEX_MAX_BYTES) { removeWordIndexDocument(warmIndex, absPath); } else { updateWordIndexDocument(warmIndex, { path: absPath, content }); } scheduleWordIndexPersist(cwd, warmIndex); } catch { // unreadable/deleted, or an update failure — best-effort, same as the // graph update above. } } } // dispatchForFile appended a latency report during the call above. Match the // newly-added report for this exact path; fall back to the most recent new // report if the path normalization differs. const newReports = getLatencyReports().slice(reportsBefore); const latencyReport = newReports.find((report) => path.resolve(report.filePath) === absPath) ?? newReports[newReports.length - 1]; const lspRunner = latencyReport?.runners.find((runner) => runner.runnerId === "lsp"); const lsp = lspRunner ? { ran: lspRunner.status !== "skipped" && lspRunner.status !== "when_skipped", status: lspRunner.status, diagnosticCount: lspRunner.diagnosticCount, durationMs: lspRunner.durationMs, } : undefined; return { filePath: absPath, cwd, fileKind: latencyReport?.fileKind, durationMs, hasBlockers: result.hasBlockers, counts: { diagnostics: result.diagnostics.length, blockers: result.blockers.length, warnings: result.warnings.length, fixed: result.fixed.length, }, lsp, diagnostics: result.diagnostics.map(toMcpDiagnostic), latency: latencyReport ? { totalDurationMs: latencyReport.totalDurationMs, stoppedEarly: latencyReport.stoppedEarly, runners: latencyReport.runners.map((runner) => ({ runnerId: runner.runnerId, durationMs: runner.durationMs, status: runner.status, diagnosticCount: runner.diagnosticCount, })), } : undefined, }; }