UNPKG

pi-lens

Version:

Real-time code feedback for pi — LSP, linters, formatters, type-checking, structural analysis & booboo

757 lines (755 loc) • 27.8 kB
import { createRequire as __pilensCreateRequire } from "node:module"; const require = __pilensCreateRequire(import.meta.url); import { HardFailureStamps } from "./chunk-7U2IDNXN.js"; import { formatToolFailure } from "./chunk-ZMOL6RQC.js"; import { spawnFailedWithNoOutput } from "./chunk-I7SPGOWI.js"; import { createAvailabilityChecker, findManagedNodeToolBinary, getManagedToolEnvironment, resolveAvailableOrInstall } from "./chunk-7FBF7NPR.js"; import "./chunk-HHYAELLJ.js"; import { canonicalDirectory, listNestedLinkedWorktreeRoots } from "./chunk-3I5QFT3N.js"; import "./chunk-B3I2UUT4.js"; import "./chunk-DIULIWKU.js"; import "./chunk-VDBPVIKD.js"; import "./chunk-TGPXLY2J.js"; import "./chunk-QOZXJ4NA.js"; import "./chunk-PAK6IDME.js"; import "./chunk-GUOGVTCQ.js"; import "./chunk-AJVLWNFN.js"; import "./chunk-YGVYKNSN.js"; import "./chunk-LNAKEO63.js"; import "./chunk-Z3CCVDUB.js"; import "./chunk-2BEMIWEW.js"; import { logSessionStart } from "./chunk-OQGFCZJ4.js"; import "./chunk-RUOG5D2C.js"; import { findLocalBinUpwards } from "./chunk-U3VGR4VU.js"; import "./chunk-NTUJEGDZ.js"; import "./chunk-EOKRQ3SA.js"; import { createAvailabilityLatch, describeUnavailability } from "./chunk-WONERJTP.js"; import "./chunk-22AVIGXS.js"; import "./chunk-GVXA6CCE.js"; import "./chunk-6QIUX4EP.js"; import "./chunk-VHQLPIRJ.js"; import { getProjectDataDir, safeSpawnAsync } from "./chunk-VN2AXLEX.js"; import "./chunk-BW5KFIQC.js"; import "./chunk-QYF5S6P6.js"; import "./chunk-5ZEJHV35.js"; import "./chunk-DQ2NZ7C4.js"; import "./chunk-VNMT7C64.js"; import "./chunk-2EECBNC5.js"; import { incrementDegradationCount } from "./chunk-N3YQJI6O.js"; import { createSubsystemLogger } from "./chunk-O6TQT6RI.js"; import "./chunk-5THXD6X5.js"; import "./chunk-UK3CMEAL.js"; import "./chunk-LJDODBBZ.js"; import "./chunk-ABBOA7UD.js"; import { findNearestMarkerRoot, normalizeEphemeralMapKey, normalizeFilePath } from "./chunk-Q5U6FMDI.js"; import "./chunk-NXL4FFQQ.js"; // dist/clients/knip-client.js import * as fs from "node:fs"; import * as path from "node:path"; var EMPTY_RESULT = { success: false, issues: [], unusedExports: [], unusedFiles: [], unusedDeps: [], unlistedDeps: [] }; var ANALYSIS_TIMEOUT_MS = 3e4; function readOverridePinnedPackageNames(targetDir) { const names = /* @__PURE__ */ new Set(); let pkg; try { pkg = JSON.parse(fs.readFileSync(path.join(targetDir, "package.json"), "utf-8")); } catch { return names; } const collectKeys = (value) => { if (!value || typeof value !== "object" || Array.isArray(value)) return; for (const [key, nested] of Object.entries(value)) { names.add(key); collectKeys(nested); } }; collectKeys(pkg.overrides); collectKeys(pkg.resolutions); collectKeys(pkg.pnpm?.overrides); return names; } var KNIP_CONFIG_FILENAMES = [ "knip.json", "knip.jsonc", "knip.ts", "knip.js", "knip.config.ts", "knip.config.js", ".knip.json", ".knip.jsonc", ".knip.ts", ".knip.js" ]; function resolveProjectKnipConfig(targetDir) { for (const name of KNIP_CONFIG_FILENAMES) { if (fs.existsSync(path.join(targetDir, name))) return name; } try { const pkg = JSON.parse(fs.readFileSync(path.join(targetDir, "package.json"), "utf-8")); if (pkg && typeof pkg === "object" && pkg.knip !== void 0) { return "package.json#knip"; } } catch { } return null; } function resolveProjectKnipBinary(targetDir) { return findLocalBinUpwards("knip", targetDir) ?? null; } function classifyKnipBinary(binary, targetDir) { if (binary === null) return "managed-or-path"; const relative2 = path.relative(path.resolve(targetDir), path.resolve(binary)); return relative2 === "" || relative2.startsWith("..") || path.isAbsolute(relative2) ? "global" : "project"; } function readKnipShimVersion(binary) { if (!path.isAbsolute(binary)) return null; const binDir = path.dirname(binary); if (path.basename(binDir) !== ".bin") return null; try { const pkg = JSON.parse(fs.readFileSync(path.join(path.dirname(binDir), "knip", "package.json"), "utf-8")); const version = pkg.version; return typeof version === "string" ? version : null; } catch { return null; } } var NO_NESTED_WORKTREES = { count: 0, contains: () => false }; function nestedWorktreeScope(targetDir) { const prefixes = []; for (const root of listNestedLinkedWorktreeRoots(targetDir)) { prefixes.push(`${normalizeEphemeralMapKey(normalizeFilePath(root))}/`); } if (prefixes.length === 0) return NO_NESTED_WORKTREES; const base = normalizeFilePath(canonicalDirectory(targetDir)); return { count: prefixes.length, contains: (file) => { if (!file) return false; const key = normalizeEphemeralMapKey(`${base}/${file}`); return prefixes.some((prefix) => key.startsWith(prefix)); } }; } var MAX_RECORDED_TOOLCHAINS = 32; var KnipClient = class { knipAvailability = createAvailabilityChecker("knip", ".cmd", ["--version"], { environment: (cwd) => getManagedToolEnvironment("knip", cwd), unclassifiedFailureOutcome: "missing", fastPath: () => findManagedNodeToolBinary("knip") }); /** * Client-side memo. Only a DURABLE verdict is latched — a transient probe * failure expires, so an installed knip becomes available again without a * host restart (#1467). */ availabilityLatch = createAvailabilityLatch(); knipCommand = "knip"; 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 = /* @__PURE__ */ new Map(); /** * Per project root, the last run that died to a timeout or kill (#3872). * Set where the scan SETTLES, so a scan turn_end abandoned at its budget and * that later timed out still leaves the failure the next turn must see: * the turn's own cache row only exists for a scan that settled inside it. */ hardFailures = new HardFailureStamps(); /** * Per project root, how long its last two successful scans took (#3872). * turn_end reads the shorter of the two to stop awaiting a scan that cannot * fit the hook budget: the scan still runs (it re-measures, and warms knip's * own cache), the hook just no longer waits for it. Two samples, because a * session's first scan of a root is cold and its second is not (3.3 s then * 1.3 s on pi-lens itself): one cold sample must not cost the next turn its * advisory. A scan that failed, was killed or never completed is no sample. * Cleared with the other per-session state. */ scanDurations = /* @__PURE__ */ new Map(); /** Last successful result per project and runtime content generation. */ completedByProject = /* @__PURE__ */ new Map(); /** * (project root, binary, config) triples already recorded, so the toolchain * row is written once per distinct resolution instead of once per run. */ recordedToolchains = /* @__PURE__ */ new Set(); constructor(verbose = false) { this.log = verbose ? createSubsystemLogger("knip") : () => { }; } /** Re-arm content-keyed reuse at the session boundary. */ resetSessionState() { this.completedByProject.clear(); this.hardFailures.clear(); this.scanDurations.clear(); } /** * 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. * * The memo returns `null` when the last verdict was transient and its * cooldown has expired, which re-enters the probe. That is the difference * between "knip is missing" (a fact worth caching) and "the probe timed out" * (a moment worth retrying). */ async ensureAvailable() { const memo = this.availabilityLatch.read(); if (memo !== null) return memo; if (this.ensureInFlight) return this.ensureInFlight; this.ensureInFlight = this.doEnsureAvailable(); try { return await this.ensureInFlight; } finally { this.ensureInFlight = null; } } async doEnsureAvailable() { const cwd = process.cwd(); const resolved = await resolveAvailableOrInstall(this.knipAvailability, "knip", cwd); if (resolved !== null) { this.knipCommand = resolved; this.availabilityLatch.noteAvailable(); return true; } const verdict = this.knipAvailability.getVerdict(cwd); this.availabilityLatch.noteUnavailable(verdict.outcome ?? "missing", verdict.cause ?? "not-found"); return false; } /** * The single place a knip-unavailable result is worded. A transient probe * failure must never be reported as "install knip" — knip is on disk. */ unavailableResult() { const verdict = this.knipAvailability.getVerdict(process.cwd()); const transient = verdict.outcome === "transient"; const retryAfterMs = verdict.retryAtMs ? Math.max(0, verdict.retryAtMs - Date.now()) : void 0; return { ...EMPTY_RESULT, failureKind: transient ? "unavailable-transient" : "unavailable-missing", summary: describeUnavailability({ tool: "Knip", installHint: "npm install -D knip", outcome: verdict.outcome, cause: verdict.cause, elapsedMs: verdict.elapsedMs, retryAfterMs }) }; } /** * The summary of this root's last timeout or kill, while it is recent * enough (30 minutes) to keep turn_end from launching another heavyweight * knip (#1467, #3872); `null` when there is none. The root is resolved the * way `analyze` resolves it, so a subdirectory and its project root agree. */ recentHardFailure(cwd) { const key = this.resolveProjectRoot(cwd); if (!key) return null; return this.hardFailures.recent(key); } /** * The shorter of this root's last two successful scans, or `undefined` while * fewer than two settled this session (#3872). The root is resolved the way * `analyze` resolves it. */ scanFloorMs(cwd) { const samples = this.scanDurations.get(this.resolveProjectRoot(cwd) ?? cwd); return samples?.length === 2 ? Math.min(...samples) : void 0; } /** Linked worktrees nested under this root, which knip walks as project files (#3872). */ nestedWorktrees(cwd) { return nestedWorktreeScope(this.resolveProjectRoot(cwd) ?? cwd).count; } /** * 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`. * * Successful memo hits validate package.json and the resolved Knip config * with bounded metadata checks. The two statSync calls replace a 10-23 * second project scan. Source-only external edits remain undetected until * pi observes a write or the session resets. */ async analyze(cwd, _ignore, options = {}) { const targetDir = this.resolveProjectRoot(cwd || process.cwd()); if (!targetDir) { this.log(`No project root found from ${cwd || process.cwd()}; skipping knip`); return { ...EMPTY_RESULT, success: true, summary: "No project root found; knip skipped" }; } const key = path.resolve(targetDir); const completed = this.completedByProject.get(key); if (options.projectSeq !== void 0 && completed?.projectSeq === options.projectSeq && this.matchesMemoSignal(targetDir, completed.signal)) { this.log(`Analysis cache hit for ${key} at projectSeq ${options.projectSeq}`); return { ...completed.result, execution: "cache", analyzed: false }; } if (resolveProjectKnipBinary(targetDir) === null && !await this.ensureAvailable()) { return this.unavailableResult(); } const existing = this.inFlight.get(key); if (existing) { this.log(`Analysis already in flight for ${key}; sharing result`); return existing; } const startedAt = Date.now(); const promise = this.runAnalyze(key).then((result) => { this.hardFailures.settle(key, result); if (result.success) { this.scanDurations.set(key, [ ...(this.scanDurations.get(key) ?? []).slice(-1), Date.now() - startedAt ]); } const executed = { ...result, execution: "executed" }; if (result.success && options.projectSeq !== void 0) { this.completedByProject.set(key, { projectSeq: options.projectSeq, result: executed, signal: this.readMemoSignal(key) }); } return executed; }); const wrapped = promise.finally(() => { if (this.inFlight.get(key) === wrapped) this.inFlight.delete(key); }); this.inFlight.set(key, wrapped); return wrapped; } readMemoFileSignal(filePath) { try { const stat = fs.statSync(filePath); return { path: filePath, mtimeMs: stat.mtimeMs, size: stat.size }; } catch { return null; } } readMemoSignal(targetDir) { const config = resolveProjectKnipConfig(targetDir); return { packageJson: this.readMemoFileSignal(path.join(targetDir, "package.json")), config: config && config !== "package.json#knip" ? this.readMemoFileSignal(path.join(targetDir, config)) : null }; } matchesMemoSignal(targetDir, signal) { const current = { packageJson: this.readMemoFileSignal(path.join(targetDir, "package.json")), config: signal.config ? this.readMemoFileSignal(signal.config.path) : null }; return JSON.stringify(current.packageJson) === JSON.stringify(signal.packageJson) && JSON.stringify(current.config) === JSON.stringify(signal.config); } async runAnalyze(targetDir) { const scannedAt = (/* @__PURE__ */ new Date()).toISOString(); const cacheLocation = path.join(getProjectDataDir(targetDir), "cache", "knip"); try { fs.mkdirSync(cacheLocation, { recursive: true }); } catch (err) { this.log(`Failed to pre-create knip cache dir ${cacheLocation}: ${err}`); } this.pruneStaleGlobCache(cacheLocation); 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 projectBinary = resolveProjectKnipBinary(targetDir); const command = projectBinary ?? this.knipCommand; this.recordToolchain(targetDir, command, classifyKnipBinary(projectBinary, targetDir)); const result = await safeSpawnAsync(command, args, { timeout: ANALYSIS_TIMEOUT_MS, cwd: targetDir, env: await getManagedToolEnvironment("knip", targetDir) }); if (result.error) { this.log(`Analysis error: ${result.error.message}`); return { ...EMPTY_RESULT, summary: `Error: ${result.error.message}` }; } const output = result.stdout || ""; this.log(`Knip output length: ${output.length}, exit status: ${result.status}`); if (output.length < 500) { this.log(`Knip output sample: ${output}`); } if (!output.trim()) { if (spawnFailedWithNoOutput(result, output)) { const reason = formatToolFailure({ tool: "knip", status: result.status, signal: result.signal, stderr: result.stderr, fields: { command, source: classifyKnipBinary(projectBinary, targetDir) } }); this.log(reason); incrementDegradationCount({ kind: "runner-empty-result", subject: "knip", reason }); return { ...EMPTY_RESULT, summary: `Knip exited ${result.status} with no output; run skipped` }; } return { ...EMPTY_RESULT, success: true, summary: "No issues found" }; } return { ...this.dropOverridePinnedDeps(this.parseOutput(output, nestedWorktreeScope(targetDir)), targetDir), scannedAt }; } /** * Record WHICH knip a run used and WHICH config it ran under (#1721). * * The dogfood failure this fixes was invisible from the outside: the lens * reported 62 unused type exports and the project's own `npx knip` reported * none, with nothing in any log to say the two ran different binaries. This * row is that missing evidence — it names the shim, its version, the config * knip will discover, and whether the shim came from the project or from * pi-lens. * * Bounded twice over: one row per distinct (project root, binary, config) * triple, and at most `MAX_RECORDED_TOOLCHAINS` distinct triples per client. * Steady-state cost is therefore one line per project per session, not one * per turn_end. */ recordToolchain(targetDir, command, source) { const config = resolveProjectKnipConfig(targetDir); const key = `${targetDir} ${command} ${config ?? ""}`; if (this.recordedToolchains.has(key)) return; if (this.recordedToolchains.size >= MAX_RECORDED_TOOLCHAINS) return; this.recordedToolchains.add(key); const version = readKnipShimVersion(command); logSessionStart(`knip toolchain ${targetDir}: binary=${command}${version ? ` version=${version}` : ""} source=${source} config=${config ?? "none (knip defaults)"}`); } /** * Delete knip's glob cache (`glob-<version>.cache`) before every run (#1630). * * knip revalidates a cached glob against the mtimes of the directories that * contributed a matched path. Directories that matched nothing are never * recorded, so files later added to them stay invisible and knip keeps * reporting an export as unused after a new consumer imports it. Removing * this one file forces a fresh file walk while the module and plugin caches — * which carry nearly all of the cache's speed — survive. * * Failures are logged and ignored: a glob cache we could not delete degrades * to the pre-#1630 behavior, which is stale but not fatal. */ pruneStaleGlobCache(cacheLocation) { let entries; try { entries = fs.readdirSync(cacheLocation); } catch (err) { this.log(`Failed to read knip cache dir ${cacheLocation}: ${err}`); return; } for (const entry of entries) { if (!entry.startsWith("glob-") || !entry.endsWith(".cache")) continue; try { fs.rmSync(path.join(cacheLocation, entry), { force: true }); this.log(`Pruned knip glob cache ${entry}`); } catch (err) { this.log(`Failed to prune knip glob cache ${entry}: ${err}`); } } } /** * Drop `dependency`/`devDependency` issues for a package that's also * referenced as an npm `overrides` (or Yarn `resolutions` / pnpm * `pnpm.overrides`) key in this project's `package.json` (#968). * * A direct devDependency whose only job is pinning a vulnerable * transitive/peer resolution has no source import — that's WORKING AS * INTENDED, not dead code, and knip has no concept of "this dependency * exists only to satisfy an overrides entry" (it only sees imports). * `overrides`/`resolutions` are the project's own explicit, unambiguous * signal that the package is deliberately present — the same class of * signal `hardcoded-url`'s `SCREAMING_SNAKE_CASE` constant-name carve-out * and `ts-ssrf`'s constant-identifier carve-out lean on elsewhere in this * codebase — so this narrows the finding rather than suppressing * `dependency`/`devDependency` issues wholesale: a devDependency that * ISN'T also an overrides/resolutions key is still reported. */ dropOverridePinnedDeps(result, targetDir) { if (result.unusedDeps.length === 0) return result; const pinned = readOverridePinnedPackageNames(targetDir); if (pinned.size === 0) return result; const isPinnedDepIssue = (issue) => (issue.type === "dependency" || issue.type === "devDependency") && (pinned.has(issue.name) || !!issue.package && pinned.has(issue.package)); const issues = result.issues.filter((issue) => !isPinnedDepIssue(issue)); const unusedDeps = result.unusedDeps.filter((issue) => !isPinnedDepIssue(issue)); return unusedDeps.length === result.unusedDeps.length ? result : { ...result, issues, unusedDeps }; } /** * Find unused exports in a specific file */ async findUnusedExports(filePath) { const result = await this.analyze(path.dirname(filePath)); const basename2 = path.basename(filePath); return result.unusedExports.filter((e) => e.file?.includes(basename2)).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, nested = NO_NESTED_WORKTREES) { try { const data = JSON.parse(output); const issues = []; const unusedExports = []; const unusedFiles = []; const unusedDeps = []; const unlistedDeps = []; const droppedFiles = /* @__PURE__ */ new Set(); const addIssue = (issue) => { if (nested.contains(issue.file)) { droppedFiles.add(issue.file ?? ""); return; } 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); } }; 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); } 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, // #2154: the one knip site that parsed a scan of this root. analyzed: true, issues, unusedExports, unusedFiles, unusedDeps, unlistedDeps, summary: `Found ${issues.length} issues`, ...nested.count > 0 && { nestedWorktrees: nested.count, nestedFilesDropped: droppedFiles.size } }; } catch (err) { void err; this.log("Failed to parse knip JSON output"); return { ...EMPTY_RESULT, summary: "Failed to parse output" }; } } }; 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 += ` \u2014 ${result.unusedExports.length} unused export(s)`; if (result.unusedFiles.length) output += ` \u2014 ${result.unusedFiles.length} unused file(s)`; if (result.unusedDeps.length) output += ` \u2014 ${result.unusedDeps.length} unused dep(s)`; if (result.unlistedDeps.length) output += ` \u2014 ${result.unlistedDeps.length} unlisted dep(s)`; output += ":\n"; 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} `; } if (result.unusedExports.length > maxItems) { output += ` ... and ${result.unusedExports.length - maxItems} more `; } } if (result.unusedFiles.length > 0) { output += "\n Unused files:\n"; for (const issue of result.unusedFiles.slice(0, 10)) { output += ` - ${issue.name} `; } } if (result.unusedDeps.length > 0) { output += "\n Unused dependencies:\n"; for (const issue of result.unusedDeps) { output += ` - ${issue.package || issue.name} `; } } return output; } export { KnipClient, classifyKnipBinary, formatKnipResult, readOverridePinnedPackageNames, resolveProjectKnipBinary, resolveProjectKnipConfig };