pi-lens
Version:
Real-time code feedback for pi — LSP, linters, formatters, type-checking, structural analysis & booboo
276 lines (275 loc) • 14.1 kB
JavaScript
/**
* Fresh-fetch counterpart to `extractCachedProjectDiagnostics` (./extractors.ts)
* for `lens_diagnostics mode=full` (#585).
*
* `extractCachedProjectDiagnostics` is deliberately cache-only — see its own
* header comment — because historically mode=full had no safe way to trigger
* a scan itself: relaunching knip/jscpd/gitleaks/govulncheck/trivy/dead-code
* concurrently with the session_start background pass over the SAME project
* root could double-spawn a CPU-bound analyzer (the exact TUI-freeze/zombie-
* process pathology `KnipClient.inFlight`'s docstring describes).
*
* That pathology is now closed for every one of these analyzers:
* `KnipClient`, `JscpdClient`, and the `DeadCodeClient`s each carry their own
* `inFlight` de-dupe map, and `GitleaksClient`/`GovulncheckClient`/
* `TrivyClient` share `SecurityScanClient.dedupeScan` (landed in #313, well
* before this issue — verified before writing this module). So mode=full can
* now safely trigger — or, via the de-dupe guard, *join* — a fresh run of
* each analyzer instead of settling for a session_start-only snapshot that
* can be hours stale in a long session.
*
* Mirrors the gating each analyzer already applies at session_start
* (`clients/runtime-session.ts`) — same "not applicable to this project" /
* "not installed" skip conditions — but never skips on a cache hit; it always
* performs (or joins) an actual run. One deliberate exception: gitleaks (#608)
* uses a looser "smart-default" gate here (any tracked git repo) than
* session_start's strict opt-in-config gate, since mode=full is an
* explicitly-requested comprehensive review and gitleaks is cheap/advisory —
* see its own task below. Every fresh result is written back to
* cache via the same `cacheManager.writeCache` session_start/turn_end use, so
* a background pass racing in afterward reads a result at least as fresh as
* its own.
*
* No extra write-ordering guard (`clients/write-ordering-guard.ts`) is
* layered on top of this: an overlapping call to the same analyzer for the
* same root always resolves to the exact same in-flight promise (the de-dupe
* guard above), so concurrent writers here are always writing IDENTICAL
* data — there is no "stale write lands after a fresher one" race to guard
* against. A guard would only earn its keep if two *different* result
* objects for the same key could race; that can't happen while every caller
* for a given root shares one in-flight run.
*
* Does NOT change session_start's or turn_end's own scheduling (both remain
* skip-if-cached) — this module is additive and mode=full-only.
*
* Abort handling: `formatFullMode` (`tools/lens-diagnostics.ts`) already
* threads a combined signal (Escape/turn-abort OR'd with a hard wall-clock
* ceiling, `FULL_SCAN_WALL_CLOCK_MS`) into the LSP sweep and the cheap
* project-runner scan — this module accepts the SAME signal so a `mode=full`
* abort also bounds the fresh-fetch instead of letting it run uncancelled for
* up to trivy's own ~180s ceiling after the rest of the scan already stopped.
* None of the six analyzer clients accept a cancellation token today (checked
* each `analyze()`/`scan()` signature before assuming otherwise — none does),
* so true in-flight cancellation isn't available at the client level. Instead
* this races the overall `Promise.all(tasks)` against the abort signal and
* returns whatever has already settled — the same "partial is OK, a hang is
* not" shape `clients/deadline-utils.ts`'s `withDeadline(..., onTimeout:
* "undefined")` and `clients/lsp/index.ts`'s `runWorkspaceDiagnostics` already
* use. Already-spawned analyzer processes are NOT killed: they keep running in
* the background (bounded by their own `SCAN_TIMEOUT_MS`/`ANALYSIS_TIMEOUT_MS`)
* and still write their result to cache when they finish, so nothing already
* in flight is wasted — the NEXT caller (or a background session_start/
* turn_end pass) benefits from it. Analyzers that hadn't settled yet when the
* abort fired are reported in both `cold` (so they don't silently read as
* "ran clean") and `abortedIds` (so a caller can render a more honest reason
* than "not applicable").
*
* Refs: #585, #313 (the SecurityScanClient de-dupe prerequisite)
*/
import * as fs from "node:fs";
import * as path from "node:path";
import { getKnipIgnorePatterns } from "../file-utils.js";
import { GitleaksClient } from "../gitleaks-client.js";
import { GovulncheckClient } from "../govulncheck-client.js";
import { TrivyClient } from "../trivy-client.js";
import { deadCodeResultToProjectDiagnostics } from "./runner-adapters/dead-code.js";
import { gitleaksResultToProjectDiagnostics } from "./runner-adapters/gitleaks.js";
import { govulncheckResultToProjectDiagnostics } from "./runner-adapters/govulncheck.js";
import { jscpdResultToProjectDiagnostics } from "./runner-adapters/jscpd.js";
import { knipIssuesToProjectDiagnostics } from "./runner-adapters/knip.js";
import { circularDepsToProjectDiagnostics } from "./runner-adapters/madge.js";
import { trivyResultToProjectDiagnostics } from "./runner-adapters/trivy.js";
/** Registry order mirrors `extractors.ts`'s `EXTRACTORS` ids — kept in sync by
* hand since this module intentionally doesn't share that table (additive,
* not a registry restructure — see the module header). */
const ANALYZER_IDS = [
"knip",
"jscpd",
"madge",
"gitleaks",
"govulncheck",
"trivy",
"dead-code",
];
function pushUnique(list, id) {
if (!list.includes(id))
list.push(id);
}
/**
* Trigger (or join, via each client's in-flight de-dupe guard) a fresh run of
* every heavyweight project analyzer and adapt the results to
* `ProjectDiagnostic[]`, mirroring `extractCachedProjectDiagnostics`'s return
* shape. Runs all analyzers in parallel — total wall time is bounded by the
* single slowest one (trivy's own timeout ceiling) rather than their sum.
*
* `signal`, when provided and it fires before every analyzer has settled,
* makes this return immediately with whatever partial results are available
* (see the module header for why this races rather than cancels in-flight
* spawns).
*/
export async function fetchFreshProjectDiagnostics(cacheManager, cwd, clients, signal) {
const analysisRoot = path.resolve(cwd);
const diagnostics = [];
const runners = [];
const cold = [];
const timings = {};
const settledIds = new Set();
function record(id, adapted, elapsedMs) {
timings[id] = (timings[id] ?? 0) + elapsedMs;
if (adapted.length > 0) {
diagnostics.push(...adapted);
pushUnique(runners, id);
}
}
function task(id, run) {
return run().finally(() => settledIds.add(id));
}
const tasks = [
// knip — always applicable to probe (KnipClient.analyze itself no-ops
// when no project root marker is found, matching session_start).
task("knip", async () => {
const startMs = Date.now();
const result = await clients.knipClient.analyze(analysisRoot, getKnipIgnorePatterns());
cacheManager.writeCache("knip", result, analysisRoot, {
scanDurationMs: Date.now() - startMs,
});
record("knip", knipIssuesToProjectDiagnostics(analysisRoot, result.issues ?? []), Date.now() - startMs);
}),
// jscpd — duplicate code detection. Cache key varies with TS-project
// detection, exactly mirroring session_start's own logic.
task("jscpd", async () => {
if (!(await clients.jscpdClient.ensureAvailable())) {
cold.push("jscpd");
return;
}
const isTsProject = fs.existsSync(path.join(analysisRoot, "tsconfig.json"));
const scannerKey = isTsProject ? "jscpd-ts" : "jscpd";
const startMs = Date.now();
const result = await clients.jscpdClient.scan(analysisRoot, undefined, undefined, isTsProject);
cacheManager.writeCache(scannerKey, result, analysisRoot, {
scanDurationMs: Date.now() - startMs,
});
record("jscpd", jscpdResultToProjectDiagnostics(analysisRoot, result), Date.now() - startMs);
}),
// madge — circular-dependency detection.
task("madge", async () => {
if (!(await clients.depChecker.ensureAvailable())) {
cold.push("madge");
return;
}
const startMs = Date.now();
const result = await clients.depChecker.scanProject(analysisRoot);
cacheManager.writeCache("madge", result, analysisRoot, {
scanDurationMs: Date.now() - startMs,
});
record("madge", circularDepsToProjectDiagnostics(analysisRoot, result.circular ?? []), Date.now() - startMs);
}),
// gitleaks — committed-secrets detection. session_start/per-edit stay
// config-gated per #130's strict default (GitleaksClient.hasGitleaksSignal),
// but mode=full is an explicitly-requested comprehensive review — use
// #130's own considered-but-unshipped "smart-default" tier instead (any
// tracked git repo, GitleaksClient.hasGitRepo): gitleaks is cheap (~10MB
// binary, no external DB pull) and findings are advisory-only, so the
// stricter opt-in gate is needlessly conservative for this call. Refs #608
// dogfooding finding that flagged gitleaks/trivy/govulncheck/dead-code as
// "cold" on a project with no explicit gitleaks config.
task("gitleaks", async () => {
if (!GitleaksClient.hasGitRepo(analysisRoot)) {
cold.push("gitleaks");
return;
}
if (!(await clients.gitleaksClient.ensureAvailable())) {
cold.push("gitleaks");
return;
}
const startMs = Date.now();
const result = await clients.gitleaksClient.scan(analysisRoot, {
requireSignal: false,
});
cacheManager.writeCache("gitleaks", result, analysisRoot, {
scanDurationMs: Date.now() - startMs,
});
record("gitleaks", gitleaksResultToProjectDiagnostics(analysisRoot, result), Date.now() - startMs);
}),
// govulncheck — Go module CVE detection. Go-module-gated per #132.
task("govulncheck", async () => {
if (!GovulncheckClient.hasGoModule(analysisRoot)) {
cold.push("govulncheck");
return;
}
if (!(await clients.govulncheckClient.ensureAvailable())) {
cold.push("govulncheck");
return;
}
const startMs = Date.now();
const result = await clients.govulncheckClient.analyze(analysisRoot);
cacheManager.writeCache("govulncheck", result, analysisRoot, {
scanDurationMs: Date.now() - startMs,
});
record("govulncheck", govulncheckResultToProjectDiagnostics(analysisRoot, result), Date.now() - startMs);
}),
// trivy — dependency CVE detection. Explicit opt-in per #131.
task("trivy", async () => {
if (!TrivyClient.shouldScan(analysisRoot)) {
cold.push("trivy");
return;
}
if (!(await clients.trivyClient.ensureAvailable())) {
cold.push("trivy");
return;
}
const startMs = Date.now();
const result = await clients.trivyClient.scan(analysisRoot);
cacheManager.writeCache("trivy", result, analysisRoot, {
scanDurationMs: Date.now() - startMs,
});
record("trivy", trivyResultToProjectDiagnostics(analysisRoot, result), Date.now() - startMs);
}),
// dead-code — cross-file dead-code for non-JS/TS languages (#127).
// Each client self-gates via detect(); only matching-language projects
// incur the whole-tree scan. Run the applicable ones in parallel too.
task("dead-code", async () => {
const applicable = clients.deadCodeClients.filter((c) => c.detect(analysisRoot));
if (applicable.length === 0) {
cold.push("dead-code");
return;
}
await Promise.all(applicable.map(async (client) => {
const cacheKey = `dead-code-${client.id}`;
const startMs = Date.now();
const result = await client.analyze(analysisRoot);
cacheManager.writeCache(cacheKey, result, analysisRoot, {
scanDurationMs: Date.now() - startMs,
});
record("dead-code", deadCodeResultToProjectDiagnostics(analysisRoot, result), Date.now() - startMs);
}));
}),
];
// Swallow any later rejection so an aborted-and-abandoned task can never
// surface as an unhandled rejection once this function has already
// returned partial results below.
const allSettled = Promise.all(tasks)
.then(() => "completed")
.catch(() => "completed");
const outcome = signal
? await Promise.race([
allSettled,
new Promise((resolve) => {
if (signal.aborted) {
resolve("aborted");
return;
}
signal.addEventListener("abort", () => resolve("aborted"), {
once: true,
});
}),
])
: await allSettled;
if (outcome === "aborted") {
const abortedIds = ANALYZER_IDS.filter((id) => !settledIds.has(id));
for (const id of abortedIds)
pushUnique(cold, id);
return { diagnostics, runners, cold, timings, aborted: true, abortedIds };
}
return { diagnostics, runners, cold, timings };
}