pi-lens
Version:
Real-time code feedback for pi — LSP, linters, formatters, type-checking, structural analysis & booboo
119 lines (118 loc) • 4.88 kB
JavaScript
/**
* Review-loop helpers for the MCP path: forking a `fresh` worker (so an analysis
* reflects the latest build, not the long-lived server's stale image) and
* rebuilding the dist/in-place output so `fresh` picks the change up.
*
* Together these close the "stale-process trap": commit → `pilens_rebuild` →
* `pilens_analyze mode=fresh` measures the just-built code first-hand.
*/
import { spawn } from "node:child_process";
import { pmBinary, resolveNodePackageManager, runScriptArgs, } from "../package-manager.js";
import { safeSpawnAsync } from "../safe-spawn.js";
/**
* Which npm script rebuilds the layout the server is running from. A server at
* `…/dist/mcp/server.js` is the precompiled dist (`build:dist` recreates it); a
* server at `…/mcp/server.js` is the in-place dev build (`build` emits beside the
* sources). The forked worker is always resolved relative to the server, so it
* shares the server's layout — rebuilding that layout updates the worker.
*/
export function resolveRebuildScript(serverFilePath) {
return serverFilePath.replace(/\\/g, "/").includes("/dist/")
? "build:dist"
: "build";
}
/**
* Fork `node <workerPath>` to analyze a file in a fresh process. We spawn node
* directly (no shell) so an interpreter path containing spaces is safe on
* Windows — `safeSpawnAsync`'s shell mode does not escape the command itself.
*/
export function analyzeFileFresh(workerPath, file, cwd, options = {}, timeoutMs = 120_000) {
return new Promise((resolve) => {
const args = [workerPath, `--file=${file}`, `--cwd=${cwd}`];
if (options.flags)
args.push(`--flags=${JSON.stringify(options.flags)}`);
const child = spawn(process.execPath, args, {
stdio: ["ignore", "pipe", "pipe"],
});
let stdout = "";
let stderr = "";
let settled = false;
const finish = (outcome) => {
if (settled)
return;
settled = true;
clearTimeout(timer);
resolve(outcome);
};
const timer = setTimeout(() => {
child.kill();
finish({ error: `fresh analyze timed out after ${timeoutMs}ms` });
}, timeoutMs);
child.stdout.setEncoding("utf8");
child.stdout.on("data", (chunk) => (stdout += chunk));
child.stderr.setEncoding("utf8");
child.stderr.on("data", (chunk) => (stderr += chunk));
child.on("error", (err) => finish({ error: `failed to fork worker: ${err.message}` }));
child.on("close", (code) => {
if (code !== 0) {
finish({
error: `worker exited ${code}: ${stderr.trim() || "(no stderr)"}`,
});
return;
}
try {
finish({ result: JSON.parse(stdout) });
}
catch {
finish({
error: `worker produced invalid JSON (${stderr.trim() || stdout.slice(0, 200)})`,
});
}
});
});
}
/**
* Run `<pm> run <script>` in the pi-lens repo, where `<pm>` is resolved from the
* repo's lockfile / installed managers. Uses `safeSpawnAsync` (Windows
* `.cmd`/shell-aware). `ignoreAmbientSignal` — a rebuild must run to completion.
*/
export async function runRebuild(repoRoot, script, timeoutMs = 300_000) {
const start = Date.now();
const packageManager = await resolveNodePackageManager(repoRoot);
const res = await safeSpawnAsync(pmBinary(packageManager), runScriptArgs(script), {
cwd: repoRoot,
timeout: timeoutMs,
ignoreAmbientSignal: true,
});
const output = `${res.stdout}\n${res.stderr}`.trim();
return {
ok: !res.error && res.status === 0,
script,
packageManager,
durationMs: Date.now() - start,
output: output.slice(-2000),
};
}
/**
* Dedupe project-scan diagnostics (the cheap scanners can emit the same
* file:line:rule twice) and aggregate counts by rule and file — so the
* `pilens_project_scan` tool returns a compact, scannable summary instead of
* dumping ~100 raw objects into the agent's context.
*/
export function summarizeScan(diagnostics) {
const seen = new Set();
const deduped = [];
const byRule = {};
const byFile = {};
for (const diagnostic of diagnostics) {
const ruleId = diagnostic.rule ?? diagnostic.runner ?? diagnostic.tool ?? "unknown";
const key = `${diagnostic.filePath}|${diagnostic.line ?? "?"}|${diagnostic.column ?? "?"}|${ruleId}`;
if (seen.has(key))
continue;
seen.add(key);
deduped.push(diagnostic);
byRule[ruleId] = (byRule[ruleId] ?? 0) + 1;
byFile[diagnostic.filePath] = (byFile[diagnostic.filePath] ?? 0) + 1;
}
return { deduped, byRule, byFile };
}