pi-lens
Version:
Real-time code feedback for pi — LSP, linters, formatters, type-checking, structural analysis & booboo
287 lines (286 loc) • 11.1 kB
JavaScript
/**
* Cross-file dead-code detection for non-JS/TS ecosystems (#127).
*
* Knip (clients/knip-client.ts) gives JS/TS projects project-wide unused
* exports/files/deps at session_start. Per-file dispatch linters can't do that
* for other languages — "this exported function is unused anywhere" needs a
* whole-project scan. This module is the per-language harness that closes the
* gap, paralleling KnipClient's lifecycle (detect → ensureAvailable → analyze,
* cached at session_start, surfaced as a turn_end advisory).
*
* Phase 1 ships Python via `vulture`. Future phases add Go/Rust/etc. by
* implementing DeadCodeClient and adding to getDeadCodeClients().
*/
import * as path from "node:path";
import { findNearestMarkerRoot } from "./path-utils.js";
import { safeSpawnAsync } from "./safe-spawn.js";
function emptyResult(language) {
return {
success: false,
language,
unusedExports: [],
unusedFiles: [],
unusedDeps: [],
unlistedDeps: [],
};
}
const ANALYSIS_TIMEOUT_MS = 30_000;
// Directories never worth scanning for the user's own dead code.
const VULTURE_EXCLUDES = [
"*/.venv/*",
"*/venv/*",
"*/.tox/*",
"*/build/*",
"*/dist/*",
"*/node_modules/*",
"*/.git/*",
"*/site-packages/*",
"*/__pycache__/*",
"*/.eggs/*",
];
// vulture line: `path/to/file.py:12: unused function 'foo' (60% confidence)`
const VULTURE_LINE = /^(.*?):(\d+): unused (\w[\w ]*?) '([^']+)' \((\d+)% confidence\)\s*$/;
/**
* Parse vulture's text output into normalized issues. Pure (no spawn/fs) so the
* parser is unit-testable against captured output. Unrecognized lines (banner,
* `unreachable code` without a quoted name) are ignored. `root` makes file
* paths project-relative when possible.
*/
export function parseVultureOutput(output, root) {
const issues = [];
for (const raw of output.split(/\r?\n/)) {
const m = raw.match(VULTURE_LINE);
if (!m)
continue;
const [, file, line, kind, name, confidence] = m;
// All map to the "export" bucket (a defined symbol used nowhere); the
// `kind` preserves function/class/import/etc. for display.
let rel = file;
try {
rel = path.relative(root, file) || file;
}
catch {
rel = file;
}
issues.push({
category: "export",
kind: kind.trim(),
name,
file: rel,
line: Number.parseInt(line, 10),
confidence: Number.parseInt(confidence, 10),
});
}
return issues;
}
/**
* Python dead-code via vulture (https://github.com/jendrikseipp/vulture).
*
* vulture finds unused functions, classes, methods, imports, variables and
* attributes by static analysis of the whole tree. It has no JSON reporter, so
* we parse its stable one-line-per-finding text output. It exits 1 when it
* FINDS dead code (linter convention), so a non-zero exit with parseable
* output is success, not failure.
*/
export class PythonDeadCodeClient {
id = "python";
language = "Python";
available = null;
resolved = null;
ensureInFlight = null;
inFlight = new Map();
log;
constructor(verbose = false) {
this.log = verbose
? (msg) => console.error(`[dead-code:python] ${msg}`)
: () => { };
}
get minConfidence() {
const raw = Number.parseInt(process.env.PI_LENS_VULTURE_MIN_CONFIDENCE ?? "60", 10);
return Number.isFinite(raw) ? Math.min(100, Math.max(0, raw)) : 60;
}
detect(cwd) {
return this.resolveProjectRoot(cwd) !== null;
}
/**
* Nearest dir with a Python project marker, never at/above $HOME and never
* escaping a VCS boundary — same containment rules as KnipClient so a scan
* launched from a bare cwd can't recurse the whole home tree (#250/#296).
* Delegates to the shared path-utils helper (refs #625) rather than
* hand-rolling the climb; only the marker list differs from KnipClient's.
*/
resolveProjectRoot(startDir, homeDirOverride) {
return findNearestMarkerRoot(startDir, [
"pyproject.toml",
"setup.py",
"setup.cfg",
"requirements.txt",
"Pipfile",
"tox.ini",
], { boundaries: [".git", ".hg", ".svn"], homeDir: homeDirOverride });
}
async ensureAvailable() {
if (this.available !== null)
return this.available;
if (this.ensureInFlight)
return this.ensureInFlight;
this.ensureInFlight = this.doEnsureAvailable();
try {
return await this.ensureInFlight;
}
finally {
this.ensureInFlight = null;
}
}
async doEnsureAvailable() {
// Presence-gated, NOT auto-installed. vulture is a pure-Python package
// with no standalone binary, so "auto-install" would mean `pip install`
// into whatever Python environment happens to be active — wrong and
// intrusive for uv / poetry / conda / pipx users. So we only use vulture
// when the user already has it, probing both the `vulture` console script
// and `python -m vulture` (the script dir is frequently not on PATH even
// when the package is installed). Mirrors govulncheck's no-install gating.
const candidates = [
{ cmd: "vulture", prefix: [] },
{ cmd: "python", prefix: ["-m", "vulture"] },
{ cmd: "python3", prefix: ["-m", "vulture"] },
];
for (const c of candidates) {
const probe = await safeSpawnAsync(c.cmd, [...c.prefix, "--version"], {
timeout: 5000,
});
if (!probe.error && probe.status === 0) {
this.resolved = c;
this.available = true;
this.log(`vulture found: ${[c.cmd, ...c.prefix].join(" ")}`);
return true;
}
}
this.available = false;
this.log("vulture not installed; skipping (no auto-install)");
return false;
}
async analyze(cwd) {
const root = this.resolveProjectRoot(cwd || process.cwd());
if (!root) {
return {
...emptyResult(this.language),
success: true,
summary: "No Python project root found; vulture skipped",
};
}
if (!(await this.ensureAvailable())) {
return {
...emptyResult(this.language),
success: true,
summary: "vulture not installed; skipped. Install vulture (pip/uv/pipx) to enable Python dead-code detection.",
};
}
const key = path.resolve(root);
const existing = this.inFlight.get(key);
if (existing)
return existing;
const promise = this.runAnalyze(key).finally(() => this.inFlight.delete(key));
this.inFlight.set(key, promise);
return promise;
}
async runAnalyze(root) {
const startMs = Date.now();
const invocation = this.resolved ?? { cmd: "vulture", prefix: [] };
const args = [
...invocation.prefix,
".",
`--min-confidence=${this.minConfidence}`,
`--exclude=${VULTURE_EXCLUDES.join(",")}`,
];
const result = await safeSpawnAsync(invocation.cmd, args, {
timeout: ANALYSIS_TIMEOUT_MS,
cwd: root,
});
const durationMs = Date.now() - startMs;
// Spawn-level failure (ENOENT, timeout) — not a "found issues" exit.
if (result.error) {
this.log(`scan error: ${result.error.message}`);
return {
...emptyResult(this.language),
summary: `Error: ${result.error.message}`,
durationMs,
};
}
// vulture writes parse/usage errors to stderr and exits 1 with no
// stdout findings; distinguish that from "found dead code" (exit 1 WITH
// findings on stdout).
const output = result.stdout || "";
if (!output.trim()) {
const stderr = (result.stderr || "").trim();
if (result.status !== 0 && stderr) {
return {
...emptyResult(this.language),
summary: `vulture error: ${stderr.split("\n")[0]}`,
durationMs,
};
}
return {
...emptyResult(this.language),
success: true,
summary: "No dead code found",
durationMs,
};
}
return { ...this.parseOutput(output, root), durationMs };
}
parseOutput(output, root) {
const unusedExports = parseVultureOutput(output, root);
const total = unusedExports.length;
return {
...emptyResult(this.language),
success: true,
unusedExports,
summary: total === 0
? "No dead code found"
: `Found ${total} unused Python symbol(s)`,
};
}
}
/**
* The dead-code clients to run at session_start. Each is offered every project;
* the orchestrator calls detect() to decide which actually apply (polyglot
* repos may run several). Phase 1: Python only.
*/
export function getDeadCodeClients(verbose = false) {
return [new PythonDeadCodeClient(verbose)];
}
/** Total issue count across all buckets — convenience for advisories/telemetry. */
export function deadCodeIssueCount(result) {
return (result.unusedExports.length +
result.unusedFiles.length +
result.unusedDeps.length +
result.unlistedDeps.length);
}
/**
* Format cached dead-code results into one turn_end advisory, or "" when there
* is nothing to report. Merges multiple languages (polyglot repos) under one
* `[Dead code]` heading so it reads consistently next to the Knip advisory.
* Advisory-only: these are project-wide signals, never blockers.
*/
export function formatDeadCodeAdvisory(results, maxPerLang = 10) {
const withFindings = results.filter((r) => r.success && deadCodeIssueCount(r) > 0);
if (withFindings.length === 0)
return "";
const lines = [];
for (const r of withFindings) {
const count = deadCodeIssueCount(r);
lines.push(`${r.language}: ${count} unused symbol(s)`);
for (const issue of r.unusedExports.slice(0, maxPerLang)) {
const loc = issue.file
? ` (${issue.file}${issue.line ? `:${issue.line}` : ""})`
: "";
lines.push(` - unused ${issue.kind} '${issue.name}'${loc}`);
}
if (r.unusedExports.length > maxPerLang) {
lines.push(` … and ${r.unusedExports.length - maxPerLang} more`);
}
}
return ("💀 [Dead code] project-wide unused symbols — verify before removing:\n " +
lines.join("\n "));
}