pi-lens
Version:
Real-time code feedback for pi — LSP, linters, formatters, type-checking, structural analysis & booboo
106 lines (105 loc) • 4.36 kB
JavaScript
/**
* pi-lens-analyze — standalone per-file analysis for the *push* half of the
* mirror: a Claude Code PostToolUse hook (matched to Edit|Write) that fires
* pi-lens automatically after every edit, the way pi's per-edit pipeline does.
* Also usable as a plain CLI for testing/debugging.
*
* Reuses the Tier 1 `analyzeFile` facade. Defaults to `no-lsp` so inline
* feedback is FAST (cold LSP would cost ~5s per edit and under-report anyway —
* pull `pilens_analyze` against the warm MCP server for the type-check). The
* fast runners (tree-sitter structural, ast-grep security, biome/ruff/oxlint
* lint, complexity) are complete even in a cold process.
*
* Input: `--file=<path>` (+ optional `--cwd=`), or — when no `--file` and stdin
* is piped — a Claude Code PostToolUse JSON payload on stdin (`tool_input.path`/
* `file_path` + `cwd`). Output: a concise report on stdout; with `--hook`, a
* PostToolUse JSON envelope that injects the report as context. Exit 0 always
* (advisory — never blocks the edit).
*/
import * as path from "node:path";
import { requestWarmAnalyze } from "../clients/mcp/ipc.js";
console.log = (...args) => console.error(...args);
function argVal(name) {
const prefix = `--${name}=`;
const found = process.argv.find((value) => value.startsWith(prefix));
return found ? found.slice(prefix.length) : undefined;
}
async function readStdin() {
const chunks = [];
for await (const chunk of process.stdin)
chunks.push(chunk);
return Buffer.concat(chunks).toString("utf8");
}
function formatReport(result, cwd) {
const rel = path.relative(cwd, result.filePath) || result.filePath;
const lines = [
`🔎 pi-lens: ${rel} — ${result.counts.blockers} blocking, ${result.counts.warnings} warning(s)`,
];
for (const d of result.diagnostics.slice(0, 30)) {
const marker = d.semantic === "blocking" ? "🔴" : "⚠";
const label = d.rule ?? d.tool;
lines.push(` ${marker} L${d.line ?? "?"} ${label}: ${d.message}`);
}
if (result.diagnostics.length > 30) {
lines.push(` … ${result.diagnostics.length - 30} more`);
}
if (result.lsp && result.lsp.status === "skipped") {
lines.push(" (LSP type-check skipped — run pilens_analyze on the warm MCP server for type errors)");
}
return lines.join("\n");
}
async function resolveTarget() {
let file = argVal("file");
let cwd = argVal("cwd") ?? process.cwd();
if (!file && !process.stdin.isTTY) {
try {
const data = JSON.parse(await readStdin());
file = data.tool_input?.file_path ?? data.tool_input?.path;
cwd = data.cwd ?? cwd;
}
catch {
// not a JSON payload — nothing to analyze
}
}
return { file, cwd };
}
async function main() {
const hookMode = process.argv.includes("--hook");
const withLsp = process.argv.includes("--lsp");
const { file, cwd } = await resolveTarget();
if (!file)
process.exit(0); // nothing to analyze — stay silent
// Warm path first: if the MCP server is up for this workspace, it analyzes in
// its warm process (LSP-COMPLETE) and we never load the dispatch graph here.
// Falls back to a cold, no-LSP local run when no server is reachable.
let result = await requestWarmAnalyze(cwd, file);
if (!result) {
const { analyzeFile } = await import("../clients/mcp/analyze.js");
result = await analyzeFile(file, cwd, {
flags: withLsp ? {} : { "no-lsp": true },
record: false,
// Edit-detection path (PostToolUse) — mark the file for pilens_turn_end.
registerTurnState: true,
});
}
if (result.counts.diagnostics === 0)
process.exit(0); // clean → no noise
const report = formatReport(result, cwd);
if (hookMode) {
process.stdout.write(JSON.stringify({
hookSpecificOutput: {
hookEventName: "PostToolUse",
additionalContext: report,
},
}));
}
else {
process.stdout.write(`${report}\n`);
}
process.exit(0);
}
main().catch((err) => {
process.stderr.write(`pi-lens-analyze failed: ${err.message}\n`);
process.exit(0); // advisory — never break the edit flow
});