pi-lens
Version:
Real-time code feedback for pi — LSP, linters, formatters, type-checking, structural analysis & booboo
389 lines (388 loc) • 16.3 kB
JavaScript
/**
* Knip Client for pi-local
*
* Detects unused exports, files, dependencies, and more.
* Essential for safe refactoring — I need to know what's dead code
* before I can clean it up.
*
* Requires: npm install -D knip
* Docs: https://knip.dev/
*/
import * as fs from "node:fs";
import * as path from "node:path";
import { getProjectDataDir } from "./file-utils.js";
import { findNearestMarkerRoot } from "./path-utils.js";
import { safeSpawnAsync } from "./safe-spawn.js";
const EMPTY_RESULT = {
success: false,
issues: [],
unusedExports: [],
unusedFiles: [],
unusedDeps: [],
unlistedDeps: [],
};
const ANALYSIS_TIMEOUT_MS = 30_000;
// --- Client ---
export class KnipClient {
knipAvailable = null;
ensureInFlight = null;
log;
/**
* De-dupe concurrent `analyze()` calls against the same project root.
*
* Without this guard, two back-to-back turn_end events (or a turn_end
* firing while the session_start scan is still in flight) can each spawn
* a fresh `knip` process over the same tree. Two concurrent knip
* runs are CPU-bound and cause the exact pathology we're fixing: load
* averages >5, TUI freezes, and zombie processes reparented to init
* after pi exits mid-scan.
*
* Key: canonicalised project root (not the caller's cwd). Value is the
* in-flight promise; completing clears the slot.
*/
inFlight = new Map();
constructor(verbose = false) {
this.log = verbose
? (msg) => console.error(`[knip] ${msg}`)
: () => { };
}
/**
* Find the nearest directory with a project/knip config marker.
*
* Returns `null` when no marker is found up to the filesystem root.
* Callers MUST treat a null return as "no project here, skip knip" —
* previously this fell back to `startDir`, which on a bare cwd like
* `/home/v` caused knip to recurse through every project and balloon
* memory/CPU.
*
* Delegates to the shared path-utils helper (refs #625) — never treats a
* package/knip config at or above $HOME as the project (escapes the
* workspace, #296/#250), and never walks past a `.git`/`.hg`/`.svn`
* boundary to pick up an unrelated parent's package.json (Unity/non-JS
* repos often have no package.json at their own root).
*/
resolveProjectRoot(startDir, homeDirOverride) {
return findNearestMarkerRoot(startDir, ["package.json", "knip.json", "knip.ts", "knip.config.js", "knip.config.ts"], { boundaries: [".git", ".hg", ".svn"], homeDir: homeDirOverride });
}
/**
* Check if knip CLI is available, auto-install if not
*/
async ensureAvailable() {
// Fast path: already checked
if (this.knipAvailable !== null)
return this.knipAvailable;
if (this.ensureInFlight)
return this.ensureInFlight;
this.ensureInFlight = this.doEnsureAvailable();
try {
return await this.ensureInFlight;
}
finally {
this.ensureInFlight = null;
}
}
async doEnsureAvailable() {
// Check if available in PATH (fast)
const pathResult = await safeSpawnAsync("knip", ["--version"], {
timeout: 5000,
});
if (!pathResult.error && pathResult.status === 0) {
this.knipAvailable = true;
this.log("Knip found in PATH");
return true;
}
// Auto-install via pi-lens installer
this.log("Knip not found, attempting auto-install...");
const { ensureTool } = await import("./installer/index.js");
const installedPath = await ensureTool("knip");
if (installedPath) {
this.knipAvailable = true;
this.log(`Knip auto-installed: ${installedPath}`);
return true;
}
this.knipAvailable = false;
return false;
}
/**
* Run knip analysis on the project.
*
* Async (uses `safeSpawnAsync`) so it never blocks the event loop —
* knip scans on large monorepos can take tens of seconds, and the
* previous `spawnSync` implementation froze the TUI for the entire
* duration.
*
* Re-entrancy safe: concurrent calls resolving to the same project
* root share a single knip process via `inFlight`.
*/
async analyze(cwd, _ignore) {
const targetDir = this.resolveProjectRoot(cwd || process.cwd());
if (!targetDir) {
// No package.json / knip config anywhere up the tree. Running knip
// from an arbitrary cwd (e.g. $HOME) has no defined meaning and in
// practice walks huge irrelevant trees — bail early.
this.log(`No project root found from ${cwd || process.cwd()}; skipping knip`);
return {
...EMPTY_RESULT,
success: true,
summary: "No project root found; knip skipped",
};
}
if (!(await this.ensureAvailable())) {
return {
...EMPTY_RESULT,
summary: "Knip not available. Install with: npm install -D knip",
};
}
const key = path.resolve(targetDir);
const existing = this.inFlight.get(key);
if (existing) {
this.log(`Analysis already in flight for ${key}; sharing result`);
return existing;
}
const promise = this.runAnalyze(key).finally(() => {
this.inFlight.delete(key);
});
this.inFlight.set(key, promise);
return promise;
}
async runAnalyze(targetDir) {
// Cache dir is routed through pi-lens's project-data-dir convention (NOT
// knip's own default `./node_modules/.cache/knip`) so it lives alongside
// every other project cache (see cache-manager.ts, call-graph.ts) and is
// covered by the existing `.pi-lens/` gitignore entry.
//
// Caveat (per knip's docs): a cached run does NOT pick up newly-added
// `.gitignore` files automatically — the cache must be deleted to detect
// them. Not auto-handled here; this is a documented tradeoff, not a bug.
const cacheLocation = path.join(getProjectDataDir(targetDir), "cache", "knip");
// knip (verified against 6.26.0) silently fails to persist the cache when
// `--cache-location` points at a directory that doesn't exist yet: its
// internal auto-mkdir throws ENOENT (swallowed internally, debug-logged
// only) on Windows, so the very first run — and every run after, since the
// dir never gets created — degrades to an uncached scan with no error
// surfaced. Pre-creating the dir avoids that path entirely; matches the
// mkdirSync-before-spawn convention call-graph.ts already uses for its
// cache file's parent dir.
try {
fs.mkdirSync(cacheLocation, { recursive: true });
}
catch (err) {
this.log(`Failed to pre-create knip cache dir ${cacheLocation}: ${err}`);
}
const args = [
"--reporter=json",
"--include",
// enumMembers surfaces unused enum members — finer-grained than
// file-level exports. (knip 6.x has NO `classMembers` issue type; passing
// it makes knip exit 2 with zero output, silently disabling the scan —
// verified against knip 6.20. Valid member-level type here is enumMembers.)
"files,exports,types,dependencies,unlisted,enumMembers",
"--cache",
"--cache-location",
cacheLocation,
];
const result = await safeSpawnAsync("knip", args, {
timeout: ANALYSIS_TIMEOUT_MS,
cwd: targetDir,
env: await this.getKnipEnvironment(targetDir),
});
if (result.error) {
this.log(`Analysis error: ${result.error.message}`);
return {
...EMPTY_RESULT,
summary: `Error: ${result.error.message}`,
};
}
// Knip exits 0 on success (even with issues), 1 on errors
const output = result.stdout || "";
this.log(`Knip output length: ${output.length}`);
if (output.length < 500) {
this.log(`Knip output sample: ${output}`);
}
if (!output.trim()) {
return {
...EMPTY_RESULT,
success: true,
summary: "No issues found",
};
}
return this.parseOutput(output);
}
async getKnipEnvironment(targetDir) {
const { getToolEnvironment } = await import("./installer/index.js");
const env = await getToolEnvironment();
const separator = process.platform === "win32" ? ";" : ":";
const currentPath = env.PATH || env.Path || process.env.PATH || "";
const localBin = path.join(targetDir, "node_modules", ".bin");
const augmentedPath = `${localBin}${separator}${currentPath}`;
return {
...env,
PATH: augmentedPath,
...(process.platform === "win32" ? { Path: augmentedPath } : {}),
};
}
/**
* Find unused exports in a specific file
*/
async findUnusedExports(filePath) {
const result = await this.analyze(path.dirname(filePath));
const basename = path.basename(filePath);
return result.unusedExports
.filter((e) => e.file?.includes(basename))
.map((e) => e.name);
}
/**
* Format results for LLM consumption. Delegates to the pure
* `formatKnipResult` so callers (e.g. turn-end) can format without a live
* client instance.
*/
formatResult(result, maxItems = 20) {
return formatKnipResult(result, maxItems);
}
// --- Internal ---
parseOutput(output) {
try {
const data = JSON.parse(output);
const issues = [];
const unusedExports = [];
const unusedFiles = [];
const unusedDeps = [];
const unlistedDeps = [];
const addIssue = (issue) => {
issues.push(issue);
if (issue.type === "export" || issue.type === "enumMember") {
unusedExports.push(issue);
}
if (issue.type === "file")
unusedFiles.push(issue);
if (issue.type === "dependency" || issue.type === "devDependency") {
unusedDeps.push(issue);
}
if (issue.type === "unlisted" || issue.type === "bin") {
unlistedDeps.push(issue);
}
};
// Knip JSON format (grouped): { issues: [ { file, exports:[], files:[], dependencies:[], ... } ] }
const fileEntries = Array.isArray(data?.issues) ? data.issues : [];
for (const entry of fileEntries) {
const file = entry.file ?? "";
const push = (arr, type, _target) => {
for (const item of arr) {
addIssue({
type,
name: item.name ?? item.symbol ?? String(item),
file,
line: item.line,
package: item.package,
});
}
};
push(entry.exports ?? [], "export", unusedExports);
push(entry.types ?? [], "export", unusedExports);
push(entry.enumMembers ?? [], "enumMember", unusedExports);
push(entry.files ?? [], "file", unusedFiles);
push(entry.dependencies ?? [], "dependency", unusedDeps);
push(entry.devDependencies ?? [], "devDependency", unusedDeps);
push(entry.unlisted ?? [], "unlisted", unlistedDeps);
push(entry.binaries ?? [], "bin", unlistedDeps);
}
// Fallback format: flat list of issue objects
if (issues.length === 0 && Array.isArray(data)) {
for (const item of data) {
if (!item || typeof item !== "object")
continue;
const rawType = String(item.type ?? item.issueType ?? item.kind ?? "file").toLowerCase();
const type = rawType === "export" || rawType === "exports"
? "export"
: rawType === "dependency"
? "dependency"
: rawType === "devdependency"
? "devDependency"
: rawType === "unlisted"
? "unlisted"
: rawType === "bin" || rawType === "binaries"
? "bin"
: "file";
addIssue({
type,
name: String(item.name ??
item.symbol ??
item.package ??
item.message ??
"unknown"),
file: item.file ?? item.path ?? item.location?.file,
line: item.line ?? item.location?.line,
package: item.package,
});
}
}
return {
success: true,
issues,
unusedExports,
unusedFiles,
unusedDeps,
unlistedDeps,
summary: `Found ${issues.length} issues`,
};
}
catch (err) {
void err;
this.log("Failed to parse knip JSON output");
return {
...EMPTY_RESULT,
summary: "Failed to parse output",
};
}
}
}
/**
* Format a KnipResult for the agent (the FULL dead-code picture: all unused
* exports/members, files, and deps — not a delta). Pure: no client instance or
* `this`, so turn-end can surface findings without depending on the injected
* client exposing the method. Returns "" when there is nothing to report.
* Unlisted deps are intentionally omitted here — they're surfaced as a
* delta-gated blocker (newly broken imports), not as cleanup advice.
*/
export function formatKnipResult(result, maxItems = 20) {
if (!result.success)
return `[Knip] ${result.summary}`;
if (result.issues.length === 0)
return "";
let output = `[Knip] ${result.issues.length} issue(s)`;
if (result.unusedExports.length)
output += ` — ${result.unusedExports.length} unused export(s)`;
if (result.unusedFiles.length)
output += ` — ${result.unusedFiles.length} unused file(s)`;
if (result.unusedDeps.length)
output += ` — ${result.unusedDeps.length} unused dep(s)`;
if (result.unlistedDeps.length)
output += ` — ${result.unlistedDeps.length} unlisted dep(s)`;
output += ":\n";
// Show unused exports first (most useful for refactoring)
if (result.unusedExports.length > 0) {
output += "\n Unused exports:\n";
for (const issue of result.unusedExports.slice(0, maxItems)) {
const loc = issue.file ? ` (${path.basename(issue.file)})` : "";
output += ` - ${issue.name}${loc}\n`;
}
if (result.unusedExports.length > maxItems) {
output += ` ... and ${result.unusedExports.length - maxItems} more\n`;
}
}
// Show unused files
if (result.unusedFiles.length > 0) {
output += "\n Unused files:\n";
for (const issue of result.unusedFiles.slice(0, 10)) {
output += ` - ${issue.name}\n`;
}
}
// Show unused deps (might be worth removing)
if (result.unusedDeps.length > 0) {
output += "\n Unused dependencies:\n";
for (const issue of result.unusedDeps) {
output += ` - ${issue.package || issue.name}\n`;
}
}
return output;
}