pi-lens 3.8.39 → 3.8.41
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +84 -5
- package/README.md +37 -1
- package/clients/biome-client.ts +5 -4
- package/clients/cache/rule-cache.ts +1 -1
- package/clients/complexity-client.ts +1 -1
- package/clients/dependency-checker.ts +1 -1
- package/clients/dispatch/diagnostic-taxonomy.ts +13 -1
- package/clients/dispatch/dispatcher.ts +9 -0
- package/clients/dispatch/fact-scheduler.ts +1 -1
- package/clients/dispatch/integration.ts +58 -3
- package/clients/dispatch/runners/index.ts +2 -0
- package/clients/dispatch/runners/semgrep.ts +269 -0
- package/clients/dispatch/runners/shellcheck.ts +2 -8
- package/clients/dispatch/runners/tree-sitter.ts +32 -11
- package/clients/dispatch/tool-profile.ts +1 -0
- package/clients/format-service.ts +10 -0
- package/clients/formatters.ts +22 -8
- package/clients/installer/index.ts +3 -3
- package/clients/knip-client.ts +360 -362
- package/clients/lsp/aggregation.ts +91 -0
- package/clients/lsp/client.ts +91 -38
- package/clients/lsp/index.ts +88 -72
- package/clients/lsp/launch.ts +107 -34
- package/clients/lsp/server-strategies.ts +71 -0
- package/clients/lsp/server.ts +76 -57
- package/clients/path-utils.ts +17 -0
- package/clients/pipeline.ts +23 -5
- package/clients/production-readiness.ts +2 -2
- package/clients/read-guard-logger.ts +41 -1
- package/clients/read-guard-tool-lines.ts +17 -4
- package/clients/read-guard.ts +95 -46
- package/clients/runtime-agent-end.ts +3 -0
- package/clients/runtime-session.ts +5 -0
- package/clients/runtime-tool-result.ts +48 -1
- package/clients/runtime-turn.ts +48 -4
- package/clients/sanitize.ts +1 -1
- package/clients/semgrep-config.ts +213 -0
- package/clients/tool-policy.ts +1982 -1936
- package/clients/tree-sitter-client.ts +1 -1
- package/clients/widget-state.ts +283 -0
- package/commands/booboo.ts +34 -2
- package/index.ts +231 -17
- package/package.json +3 -2
- package/rules/rule-catalog.json +25 -1
- package/rules/tree-sitter-queries/cobol/lock-table-cobol.yml +35 -0
- package/rules/tree-sitter-queries/cpp/unnecessary-bit-ops.yml +58 -0
- package/rules/tree-sitter-queries/java/infinite-loop.yml +58 -0
- package/rules/tree-sitter-queries/java/infinite-recursion.yml +58 -0
- package/rules/tree-sitter-queries/java/mockito-initialized.yml +66 -0
- package/rules/tree-sitter-queries/java/name-capitalization-conflict.yml +54 -0
- package/rules/tree-sitter-queries/java/no-octal-values.yml +48 -0
- package/rules/tree-sitter-queries/java/resources-closed.yml +57 -0
- package/rules/tree-sitter-queries/java/short-circuit-logic.yml +57 -0
- package/rules/tree-sitter-queries/java/tests-include-assertions.yml +60 -0
- package/rules/tree-sitter-queries/java/unnecessary-bit-ops-java.yml +57 -0
- package/rules/tree-sitter-queries/javascript/switch-case-termination-js.yml +64 -0
- package/rules/tree-sitter-queries/plsql/lock-table.yml +42 -0
- package/rules/tree-sitter-queries/plsql/nchar-nvarchar2-bytes.yml +54 -0
- package/rules/tree-sitter-queries/python/no-super-torchscript.yml +52 -0
- package/rules/tree-sitter-queries/typescript/default-not-last.yml +54 -0
- package/rules/tree-sitter-queries/typescript/duplicate-function-arg.yml +51 -0
- package/rules/tree-sitter-queries/typescript/empty-switch-case.yml +54 -0
- package/rules/tree-sitter-queries/typescript/infinite-loop.yml +55 -0
- package/rules/tree-sitter-queries/typescript/self-assignment.yml +46 -0
- package/rules/tree-sitter-queries/typescript/switch-case-termination.yml +64 -0
package/clients/knip-client.ts
CHANGED
|
@@ -1,362 +1,360 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Knip Client for pi-local
|
|
3
|
-
*
|
|
4
|
-
* Detects unused exports, files, dependencies, and more.
|
|
5
|
-
* Essential for safe refactoring — I need to know what's dead code
|
|
6
|
-
* before I can clean it up.
|
|
7
|
-
*
|
|
8
|
-
* Requires: npm install -D knip
|
|
9
|
-
* Docs: https://knip.dev/
|
|
10
|
-
*/
|
|
11
|
-
|
|
12
|
-
import * as fs from "node:fs";
|
|
13
|
-
import * as path from "node:path";
|
|
14
|
-
import { safeSpawn, safeSpawnAsync } from "./safe-spawn.js";
|
|
15
|
-
|
|
16
|
-
// --- Types ---
|
|
17
|
-
|
|
18
|
-
export interface KnipIssue {
|
|
19
|
-
type: "export" | "file" | "dependency" | "devDependency" | "unlisted" | "bin";
|
|
20
|
-
name: string;
|
|
21
|
-
file?: string;
|
|
22
|
-
line?: number;
|
|
23
|
-
package?: string;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
export interface KnipResult {
|
|
27
|
-
success: boolean;
|
|
28
|
-
issues: KnipIssue[];
|
|
29
|
-
unusedExports: KnipIssue[];
|
|
30
|
-
unusedFiles: KnipIssue[];
|
|
31
|
-
unusedDeps: KnipIssue[];
|
|
32
|
-
unlistedDeps: KnipIssue[];
|
|
33
|
-
summary: string;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
// --- Client ---
|
|
37
|
-
|
|
38
|
-
export class KnipClient {
|
|
39
|
-
private knipAvailable: boolean | null = null;
|
|
40
|
-
private log: (msg: string) => void;
|
|
41
|
-
|
|
42
|
-
constructor(verbose = false) {
|
|
43
|
-
this.log = verbose
|
|
44
|
-
? (msg: string) => console.error(`[knip] ${msg}`)
|
|
45
|
-
: () => {};
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
private resolveProjectRoot(startDir: string): string {
|
|
49
|
-
let current = path.resolve(startDir);
|
|
50
|
-
while (true) {
|
|
51
|
-
const markers = [
|
|
52
|
-
"package.json",
|
|
53
|
-
"knip.json",
|
|
54
|
-
"knip.ts",
|
|
55
|
-
"knip.config.js",
|
|
56
|
-
"knip.config.ts",
|
|
57
|
-
];
|
|
58
|
-
if (markers.some((m) => fs.existsSync(path.join(current, m)))) {
|
|
59
|
-
return current;
|
|
60
|
-
}
|
|
61
|
-
const parent = path.dirname(current);
|
|
62
|
-
if (parent === current) return path.resolve(startDir);
|
|
63
|
-
current = parent;
|
|
64
|
-
}
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
/**
|
|
68
|
-
* Check if knip CLI is available, auto-install if not
|
|
69
|
-
*/
|
|
70
|
-
async ensureAvailable(): Promise<boolean> {
|
|
71
|
-
// Fast path: already checked
|
|
72
|
-
if (this.knipAvailable !== null) return this.knipAvailable;
|
|
73
|
-
|
|
74
|
-
// Check if available in PATH (fast)
|
|
75
|
-
const pathResult = await safeSpawnAsync("knip", ["--version"], {
|
|
76
|
-
timeout: 5000,
|
|
77
|
-
});
|
|
78
|
-
if (!pathResult.error && pathResult.status === 0) {
|
|
79
|
-
this.knipAvailable = true;
|
|
80
|
-
this.log("Knip found in PATH");
|
|
81
|
-
return true;
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
// Auto-install via pi-lens installer
|
|
85
|
-
this.log("Knip not found, attempting auto-install...");
|
|
86
|
-
const { ensureTool } = await import("./installer/index.js");
|
|
87
|
-
const installedPath = await ensureTool("knip");
|
|
88
|
-
|
|
89
|
-
if (installedPath) {
|
|
90
|
-
this.knipAvailable = true;
|
|
91
|
-
this.log(`Knip auto-installed: ${installedPath}`);
|
|
92
|
-
return true;
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
this.knipAvailable = false;
|
|
96
|
-
return false;
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
/**
|
|
100
|
-
* Check if knip CLI is available (legacy sync method)
|
|
101
|
-
* Prefer ensureAvailable() for auto-install behavior
|
|
102
|
-
*/
|
|
103
|
-
isAvailable(): boolean {
|
|
104
|
-
if (this.knipAvailable !== null) return this.knipAvailable;
|
|
105
|
-
|
|
106
|
-
const result = safeSpawn("npx", ["knip", "--version"], {
|
|
107
|
-
timeout: 10000,
|
|
108
|
-
});
|
|
109
|
-
|
|
110
|
-
this.knipAvailable = !result.error && result.status === 0;
|
|
111
|
-
if (this.knipAvailable) {
|
|
112
|
-
this.log(`Knip available`);
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
return this.knipAvailable;
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
/**
|
|
119
|
-
* Run knip analysis on the project
|
|
120
|
-
*/
|
|
121
|
-
analyze(cwd?: string, ignore?: string[]): KnipResult {
|
|
122
|
-
if (!this.isAvailable()) {
|
|
123
|
-
return {
|
|
124
|
-
success: false,
|
|
125
|
-
issues: [],
|
|
126
|
-
unusedExports: [],
|
|
127
|
-
unusedFiles: [],
|
|
128
|
-
unusedDeps: [],
|
|
129
|
-
unlistedDeps: [],
|
|
130
|
-
summary: "Knip not available. Install with: npm install -D knip",
|
|
131
|
-
};
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
const targetDir = this.resolveProjectRoot(cwd || process.cwd());
|
|
135
|
-
|
|
136
|
-
try {
|
|
137
|
-
const args = [
|
|
138
|
-
"knip",
|
|
139
|
-
"--reporter=json",
|
|
140
|
-
"--include",
|
|
141
|
-
"files,exports,types,dependencies,unlisted",
|
|
142
|
-
];
|
|
143
|
-
if (ignore && ignore.length > 0) {
|
|
144
|
-
args.push("--ignore", ignore.join(","));
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
const result = safeSpawn("npx", args, {
|
|
148
|
-
timeout: 30000,
|
|
149
|
-
cwd: targetDir,
|
|
150
|
-
});
|
|
151
|
-
|
|
152
|
-
// Knip exits 0 on success (even with issues), 1 on errors
|
|
153
|
-
const output = result.stdout || "";
|
|
154
|
-
this.log(`Knip output length: ${output.length}`);
|
|
155
|
-
if (output.length < 500) {
|
|
156
|
-
this.log(`Knip output sample: ${output}`);
|
|
157
|
-
}
|
|
158
|
-
if (!output.trim()) {
|
|
159
|
-
return {
|
|
160
|
-
success: true,
|
|
161
|
-
issues: [],
|
|
162
|
-
unusedExports: [],
|
|
163
|
-
unusedFiles: [],
|
|
164
|
-
unusedDeps: [],
|
|
165
|
-
unlistedDeps: [],
|
|
166
|
-
summary: "No issues found",
|
|
167
|
-
};
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
return this.parseOutput(output);
|
|
171
|
-
} catch (err: any) {
|
|
172
|
-
this.log(`Analysis error: ${err.message}`);
|
|
173
|
-
return {
|
|
174
|
-
success: false,
|
|
175
|
-
issues: [],
|
|
176
|
-
unusedExports: [],
|
|
177
|
-
unusedFiles: [],
|
|
178
|
-
unusedDeps: [],
|
|
179
|
-
unlistedDeps: [],
|
|
180
|
-
summary: `Error: ${err.message}`,
|
|
181
|
-
};
|
|
182
|
-
}
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
/**
|
|
186
|
-
* Find unused exports in a specific file
|
|
187
|
-
*/
|
|
188
|
-
findUnusedExports(filePath: string): string[] {
|
|
189
|
-
const result = this.analyze(
|
|
190
|
-
this.resolveProjectRoot(path.dirname(filePath)),
|
|
191
|
-
);
|
|
192
|
-
const basename = path.basename(filePath);
|
|
193
|
-
|
|
194
|
-
return result.unusedExports
|
|
195
|
-
.filter((e) => e.file?.includes(basename))
|
|
196
|
-
.map((e) => e.name);
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
/**
|
|
200
|
-
* Format results for LLM consumption
|
|
201
|
-
*/
|
|
202
|
-
formatResult(result: KnipResult, maxItems = 20): string {
|
|
203
|
-
if (!result.success) return `[Knip] ${result.summary}`;
|
|
204
|
-
if (result.issues.length === 0) return "";
|
|
205
|
-
|
|
206
|
-
let output = `[Knip] ${result.issues.length} issue(s)`;
|
|
207
|
-
if (result.unusedExports.length)
|
|
208
|
-
output += ` — ${result.unusedExports.length} unused export(s)`;
|
|
209
|
-
if (result.unusedFiles.length)
|
|
210
|
-
output += ` — ${result.unusedFiles.length} unused file(s)`;
|
|
211
|
-
if (result.unusedDeps.length)
|
|
212
|
-
output += ` — ${result.unusedDeps.length} unused dep(s)`;
|
|
213
|
-
if (result.unlistedDeps.length)
|
|
214
|
-
output += ` — ${result.unlistedDeps.length} unlisted dep(s)`;
|
|
215
|
-
output += ":\n";
|
|
216
|
-
|
|
217
|
-
// Show unused exports first (most useful for refactoring)
|
|
218
|
-
if (result.unusedExports.length > 0) {
|
|
219
|
-
output += "\n Unused exports:\n";
|
|
220
|
-
for (const issue of result.unusedExports.slice(0, maxItems)) {
|
|
221
|
-
const loc = issue.file ? ` (${path.basename(issue.file)})` : "";
|
|
222
|
-
output += ` - ${issue.name}${loc}\n`;
|
|
223
|
-
}
|
|
224
|
-
if (result.unusedExports.length > maxItems) {
|
|
225
|
-
output += ` ... and ${result.unusedExports.length - maxItems} more\n`;
|
|
226
|
-
}
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
// Show unused files
|
|
230
|
-
if (result.unusedFiles.length > 0) {
|
|
231
|
-
output += "\n Unused files:\n";
|
|
232
|
-
for (const issue of result.unusedFiles.slice(0, 10)) {
|
|
233
|
-
output += ` - ${issue.name}\n`;
|
|
234
|
-
}
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
// Show unused deps (might be worth removing)
|
|
238
|
-
if (result.unusedDeps.length > 0) {
|
|
239
|
-
output += "\n Unused dependencies:\n";
|
|
240
|
-
for (const issue of result.unusedDeps) {
|
|
241
|
-
output += ` - ${issue.package || issue.name}\n`;
|
|
242
|
-
}
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
return output;
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
// --- Internal ---
|
|
249
|
-
|
|
250
|
-
private parseOutput(output: string): KnipResult {
|
|
251
|
-
try {
|
|
252
|
-
const data = JSON.parse(output);
|
|
253
|
-
const issues: KnipIssue[] = [];
|
|
254
|
-
const unusedExports: KnipIssue[] = [];
|
|
255
|
-
const unusedFiles: KnipIssue[] = [];
|
|
256
|
-
const unusedDeps: KnipIssue[] = [];
|
|
257
|
-
const unlistedDeps: KnipIssue[] = [];
|
|
258
|
-
|
|
259
|
-
const addIssue = (issue: KnipIssue) => {
|
|
260
|
-
issues.push(issue);
|
|
261
|
-
if (issue.type === "export") unusedExports.push(issue);
|
|
262
|
-
if (issue.type === "file") unusedFiles.push(issue);
|
|
263
|
-
if (issue.type === "dependency" || issue.type === "devDependency") {
|
|
264
|
-
unusedDeps.push(issue);
|
|
265
|
-
}
|
|
266
|
-
if (issue.type === "unlisted" || issue.type === "bin") {
|
|
267
|
-
unlistedDeps.push(issue);
|
|
268
|
-
}
|
|
269
|
-
};
|
|
270
|
-
|
|
271
|
-
// Knip JSON format (grouped): { issues: [ { file, exports:[], files:[], dependencies:[], ... } ] }
|
|
272
|
-
const fileEntries: any[] = Array.isArray(data?.issues) ? data.issues : [];
|
|
273
|
-
|
|
274
|
-
for (const entry of fileEntries) {
|
|
275
|
-
const file: string = entry.file ?? "";
|
|
276
|
-
|
|
277
|
-
const push = (
|
|
278
|
-
arr: any[],
|
|
279
|
-
type: KnipIssue["type"],
|
|
280
|
-
_target: KnipIssue[],
|
|
281
|
-
) => {
|
|
282
|
-
for (const item of arr) {
|
|
283
|
-
addIssue({
|
|
284
|
-
type,
|
|
285
|
-
name: item.name ?? item.symbol ?? String(item),
|
|
286
|
-
file,
|
|
287
|
-
line: item.line,
|
|
288
|
-
package: item.package,
|
|
289
|
-
});
|
|
290
|
-
}
|
|
291
|
-
};
|
|
292
|
-
|
|
293
|
-
push(entry.exports ?? [], "export", unusedExports);
|
|
294
|
-
push(entry.types ?? [], "export", unusedExports);
|
|
295
|
-
push(entry.files ?? [], "file", unusedFiles);
|
|
296
|
-
push(entry.dependencies ?? [], "dependency", unusedDeps);
|
|
297
|
-
push(entry.devDependencies ?? [], "devDependency", unusedDeps);
|
|
298
|
-
push(entry.unlisted ?? [], "unlisted", unlistedDeps);
|
|
299
|
-
push(entry.binaries ?? [], "bin", unlistedDeps);
|
|
300
|
-
}
|
|
301
|
-
|
|
302
|
-
// Fallback format: flat list of issue objects
|
|
303
|
-
if (issues.length === 0 && Array.isArray(data)) {
|
|
304
|
-
for (const item of data) {
|
|
305
|
-
if (!item || typeof item !== "object") continue;
|
|
306
|
-
const rawType = String(
|
|
307
|
-
item.type ?? item.issueType ?? item.kind ?? "file",
|
|
308
|
-
).toLowerCase();
|
|
309
|
-
const type: KnipIssue["type"] =
|
|
310
|
-
rawType === "export" || rawType === "exports"
|
|
311
|
-
? "export"
|
|
312
|
-
: rawType === "dependency"
|
|
313
|
-
? "dependency"
|
|
314
|
-
: rawType === "devdependency"
|
|
315
|
-
? "devDependency"
|
|
316
|
-
: rawType === "unlisted"
|
|
317
|
-
? "unlisted"
|
|
318
|
-
: rawType === "bin" || rawType === "binaries"
|
|
319
|
-
? "bin"
|
|
320
|
-
:
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
item.
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
}
|
|
362
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Knip Client for pi-local
|
|
3
|
+
*
|
|
4
|
+
* Detects unused exports, files, dependencies, and more.
|
|
5
|
+
* Essential for safe refactoring — I need to know what's dead code
|
|
6
|
+
* before I can clean it up.
|
|
7
|
+
*
|
|
8
|
+
* Requires: npm install -D knip
|
|
9
|
+
* Docs: https://knip.dev/
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import * as fs from "node:fs";
|
|
13
|
+
import * as path from "node:path";
|
|
14
|
+
import { safeSpawn, safeSpawnAsync } from "./safe-spawn.js";
|
|
15
|
+
|
|
16
|
+
// --- Types ---
|
|
17
|
+
|
|
18
|
+
export interface KnipIssue {
|
|
19
|
+
type: "export" | "file" | "dependency" | "devDependency" | "unlisted" | "bin";
|
|
20
|
+
name: string;
|
|
21
|
+
file?: string;
|
|
22
|
+
line?: number;
|
|
23
|
+
package?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface KnipResult {
|
|
27
|
+
success: boolean;
|
|
28
|
+
issues: KnipIssue[];
|
|
29
|
+
unusedExports: KnipIssue[];
|
|
30
|
+
unusedFiles: KnipIssue[];
|
|
31
|
+
unusedDeps: KnipIssue[];
|
|
32
|
+
unlistedDeps: KnipIssue[];
|
|
33
|
+
summary: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// --- Client ---
|
|
37
|
+
|
|
38
|
+
export class KnipClient {
|
|
39
|
+
private knipAvailable: boolean | null = null;
|
|
40
|
+
private log: (msg: string) => void;
|
|
41
|
+
|
|
42
|
+
constructor(verbose = false) {
|
|
43
|
+
this.log = verbose
|
|
44
|
+
? (msg: string) => console.error(`[knip] ${msg}`)
|
|
45
|
+
: () => {};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
private resolveProjectRoot(startDir: string): string {
|
|
49
|
+
let current = path.resolve(startDir);
|
|
50
|
+
while (true) {
|
|
51
|
+
const markers = [
|
|
52
|
+
"package.json",
|
|
53
|
+
"knip.json",
|
|
54
|
+
"knip.ts",
|
|
55
|
+
"knip.config.js",
|
|
56
|
+
"knip.config.ts",
|
|
57
|
+
];
|
|
58
|
+
if (markers.some((m) => fs.existsSync(path.join(current, m)))) {
|
|
59
|
+
return current;
|
|
60
|
+
}
|
|
61
|
+
const parent = path.dirname(current);
|
|
62
|
+
if (parent === current) return path.resolve(startDir);
|
|
63
|
+
current = parent;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Check if knip CLI is available, auto-install if not
|
|
69
|
+
*/
|
|
70
|
+
async ensureAvailable(): Promise<boolean> {
|
|
71
|
+
// Fast path: already checked
|
|
72
|
+
if (this.knipAvailable !== null) return this.knipAvailable;
|
|
73
|
+
|
|
74
|
+
// Check if available in PATH (fast)
|
|
75
|
+
const pathResult = await safeSpawnAsync("knip", ["--version"], {
|
|
76
|
+
timeout: 5000,
|
|
77
|
+
});
|
|
78
|
+
if (!pathResult.error && pathResult.status === 0) {
|
|
79
|
+
this.knipAvailable = true;
|
|
80
|
+
this.log("Knip found in PATH");
|
|
81
|
+
return true;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Auto-install via pi-lens installer
|
|
85
|
+
this.log("Knip not found, attempting auto-install...");
|
|
86
|
+
const { ensureTool } = await import("./installer/index.js");
|
|
87
|
+
const installedPath = await ensureTool("knip");
|
|
88
|
+
|
|
89
|
+
if (installedPath) {
|
|
90
|
+
this.knipAvailable = true;
|
|
91
|
+
this.log(`Knip auto-installed: ${installedPath}`);
|
|
92
|
+
return true;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
this.knipAvailable = false;
|
|
96
|
+
return false;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Check if knip CLI is available (legacy sync method)
|
|
101
|
+
* Prefer ensureAvailable() for auto-install behavior
|
|
102
|
+
*/
|
|
103
|
+
isAvailable(): boolean {
|
|
104
|
+
if (this.knipAvailable !== null) return this.knipAvailable;
|
|
105
|
+
|
|
106
|
+
const result = safeSpawn("npx", ["knip", "--version"], {
|
|
107
|
+
timeout: 10000,
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
this.knipAvailable = !result.error && result.status === 0;
|
|
111
|
+
if (this.knipAvailable) {
|
|
112
|
+
this.log(`Knip available`);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return this.knipAvailable;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Run knip analysis on the project
|
|
120
|
+
*/
|
|
121
|
+
analyze(cwd?: string, ignore?: string[]): KnipResult {
|
|
122
|
+
if (!this.isAvailable()) {
|
|
123
|
+
return {
|
|
124
|
+
success: false,
|
|
125
|
+
issues: [],
|
|
126
|
+
unusedExports: [],
|
|
127
|
+
unusedFiles: [],
|
|
128
|
+
unusedDeps: [],
|
|
129
|
+
unlistedDeps: [],
|
|
130
|
+
summary: "Knip not available. Install with: npm install -D knip",
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const targetDir = this.resolveProjectRoot(cwd || process.cwd());
|
|
135
|
+
|
|
136
|
+
try {
|
|
137
|
+
const args = [
|
|
138
|
+
"knip",
|
|
139
|
+
"--reporter=json",
|
|
140
|
+
"--include",
|
|
141
|
+
"files,exports,types,dependencies,unlisted",
|
|
142
|
+
];
|
|
143
|
+
if (ignore && ignore.length > 0) {
|
|
144
|
+
args.push("--ignore", ignore.join(","));
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const result = safeSpawn("npx", args, {
|
|
148
|
+
timeout: 30000,
|
|
149
|
+
cwd: targetDir,
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
// Knip exits 0 on success (even with issues), 1 on errors
|
|
153
|
+
const output = result.stdout || "";
|
|
154
|
+
this.log(`Knip output length: ${output.length}`);
|
|
155
|
+
if (output.length < 500) {
|
|
156
|
+
this.log(`Knip output sample: ${output}`);
|
|
157
|
+
}
|
|
158
|
+
if (!output.trim()) {
|
|
159
|
+
return {
|
|
160
|
+
success: true,
|
|
161
|
+
issues: [],
|
|
162
|
+
unusedExports: [],
|
|
163
|
+
unusedFiles: [],
|
|
164
|
+
unusedDeps: [],
|
|
165
|
+
unlistedDeps: [],
|
|
166
|
+
summary: "No issues found",
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return this.parseOutput(output);
|
|
171
|
+
} catch (err: any) {
|
|
172
|
+
this.log(`Analysis error: ${err.message}`);
|
|
173
|
+
return {
|
|
174
|
+
success: false,
|
|
175
|
+
issues: [],
|
|
176
|
+
unusedExports: [],
|
|
177
|
+
unusedFiles: [],
|
|
178
|
+
unusedDeps: [],
|
|
179
|
+
unlistedDeps: [],
|
|
180
|
+
summary: `Error: ${err.message}`,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Find unused exports in a specific file
|
|
187
|
+
*/
|
|
188
|
+
findUnusedExports(filePath: string): string[] {
|
|
189
|
+
const result = this.analyze(
|
|
190
|
+
this.resolveProjectRoot(path.dirname(filePath)),
|
|
191
|
+
);
|
|
192
|
+
const basename = path.basename(filePath);
|
|
193
|
+
|
|
194
|
+
return result.unusedExports
|
|
195
|
+
.filter((e) => e.file?.includes(basename))
|
|
196
|
+
.map((e) => e.name);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Format results for LLM consumption
|
|
201
|
+
*/
|
|
202
|
+
formatResult(result: KnipResult, maxItems = 20): string {
|
|
203
|
+
if (!result.success) return `[Knip] ${result.summary}`;
|
|
204
|
+
if (result.issues.length === 0) return "";
|
|
205
|
+
|
|
206
|
+
let output = `[Knip] ${result.issues.length} issue(s)`;
|
|
207
|
+
if (result.unusedExports.length)
|
|
208
|
+
output += ` — ${result.unusedExports.length} unused export(s)`;
|
|
209
|
+
if (result.unusedFiles.length)
|
|
210
|
+
output += ` — ${result.unusedFiles.length} unused file(s)`;
|
|
211
|
+
if (result.unusedDeps.length)
|
|
212
|
+
output += ` — ${result.unusedDeps.length} unused dep(s)`;
|
|
213
|
+
if (result.unlistedDeps.length)
|
|
214
|
+
output += ` — ${result.unlistedDeps.length} unlisted dep(s)`;
|
|
215
|
+
output += ":\n";
|
|
216
|
+
|
|
217
|
+
// Show unused exports first (most useful for refactoring)
|
|
218
|
+
if (result.unusedExports.length > 0) {
|
|
219
|
+
output += "\n Unused exports:\n";
|
|
220
|
+
for (const issue of result.unusedExports.slice(0, maxItems)) {
|
|
221
|
+
const loc = issue.file ? ` (${path.basename(issue.file)})` : "";
|
|
222
|
+
output += ` - ${issue.name}${loc}\n`;
|
|
223
|
+
}
|
|
224
|
+
if (result.unusedExports.length > maxItems) {
|
|
225
|
+
output += ` ... and ${result.unusedExports.length - maxItems} more\n`;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Show unused files
|
|
230
|
+
if (result.unusedFiles.length > 0) {
|
|
231
|
+
output += "\n Unused files:\n";
|
|
232
|
+
for (const issue of result.unusedFiles.slice(0, 10)) {
|
|
233
|
+
output += ` - ${issue.name}\n`;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Show unused deps (might be worth removing)
|
|
238
|
+
if (result.unusedDeps.length > 0) {
|
|
239
|
+
output += "\n Unused dependencies:\n";
|
|
240
|
+
for (const issue of result.unusedDeps) {
|
|
241
|
+
output += ` - ${issue.package || issue.name}\n`;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
return output;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// --- Internal ---
|
|
249
|
+
|
|
250
|
+
private parseOutput(output: string): KnipResult {
|
|
251
|
+
try {
|
|
252
|
+
const data = JSON.parse(output);
|
|
253
|
+
const issues: KnipIssue[] = [];
|
|
254
|
+
const unusedExports: KnipIssue[] = [];
|
|
255
|
+
const unusedFiles: KnipIssue[] = [];
|
|
256
|
+
const unusedDeps: KnipIssue[] = [];
|
|
257
|
+
const unlistedDeps: KnipIssue[] = [];
|
|
258
|
+
|
|
259
|
+
const addIssue = (issue: KnipIssue) => {
|
|
260
|
+
issues.push(issue);
|
|
261
|
+
if (issue.type === "export") unusedExports.push(issue);
|
|
262
|
+
if (issue.type === "file") unusedFiles.push(issue);
|
|
263
|
+
if (issue.type === "dependency" || issue.type === "devDependency") {
|
|
264
|
+
unusedDeps.push(issue);
|
|
265
|
+
}
|
|
266
|
+
if (issue.type === "unlisted" || issue.type === "bin") {
|
|
267
|
+
unlistedDeps.push(issue);
|
|
268
|
+
}
|
|
269
|
+
};
|
|
270
|
+
|
|
271
|
+
// Knip JSON format (grouped): { issues: [ { file, exports:[], files:[], dependencies:[], ... } ] }
|
|
272
|
+
const fileEntries: any[] = Array.isArray(data?.issues) ? data.issues : [];
|
|
273
|
+
|
|
274
|
+
for (const entry of fileEntries) {
|
|
275
|
+
const file: string = entry.file ?? "";
|
|
276
|
+
|
|
277
|
+
const push = (
|
|
278
|
+
arr: any[],
|
|
279
|
+
type: KnipIssue["type"],
|
|
280
|
+
_target: KnipIssue[],
|
|
281
|
+
) => {
|
|
282
|
+
for (const item of arr) {
|
|
283
|
+
addIssue({
|
|
284
|
+
type,
|
|
285
|
+
name: item.name ?? item.symbol ?? String(item),
|
|
286
|
+
file,
|
|
287
|
+
line: item.line,
|
|
288
|
+
package: item.package,
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
};
|
|
292
|
+
|
|
293
|
+
push(entry.exports ?? [], "export", unusedExports);
|
|
294
|
+
push(entry.types ?? [], "export", unusedExports);
|
|
295
|
+
push(entry.files ?? [], "file", unusedFiles);
|
|
296
|
+
push(entry.dependencies ?? [], "dependency", unusedDeps);
|
|
297
|
+
push(entry.devDependencies ?? [], "devDependency", unusedDeps);
|
|
298
|
+
push(entry.unlisted ?? [], "unlisted", unlistedDeps);
|
|
299
|
+
push(entry.binaries ?? [], "bin", unlistedDeps);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// Fallback format: flat list of issue objects
|
|
303
|
+
if (issues.length === 0 && Array.isArray(data)) {
|
|
304
|
+
for (const item of data) {
|
|
305
|
+
if (!item || typeof item !== "object") continue;
|
|
306
|
+
const rawType = String(
|
|
307
|
+
item.type ?? item.issueType ?? item.kind ?? "file",
|
|
308
|
+
).toLowerCase();
|
|
309
|
+
const type: KnipIssue["type"] =
|
|
310
|
+
rawType === "export" || rawType === "exports"
|
|
311
|
+
? "export"
|
|
312
|
+
: rawType === "dependency"
|
|
313
|
+
? "dependency"
|
|
314
|
+
: rawType === "devdependency"
|
|
315
|
+
? "devDependency"
|
|
316
|
+
: rawType === "unlisted"
|
|
317
|
+
? "unlisted"
|
|
318
|
+
: rawType === "bin" || rawType === "binaries"
|
|
319
|
+
? "bin"
|
|
320
|
+
: "file";
|
|
321
|
+
addIssue({
|
|
322
|
+
type,
|
|
323
|
+
name: String(
|
|
324
|
+
item.name ??
|
|
325
|
+
item.symbol ??
|
|
326
|
+
item.package ??
|
|
327
|
+
item.message ??
|
|
328
|
+
"unknown",
|
|
329
|
+
),
|
|
330
|
+
file: item.file ?? item.path ?? item.location?.file,
|
|
331
|
+
line: item.line ?? item.location?.line,
|
|
332
|
+
package: item.package,
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
return {
|
|
338
|
+
success: true,
|
|
339
|
+
issues,
|
|
340
|
+
unusedExports,
|
|
341
|
+
unusedFiles,
|
|
342
|
+
unusedDeps,
|
|
343
|
+
unlistedDeps,
|
|
344
|
+
summary: `Found ${issues.length} issues`,
|
|
345
|
+
};
|
|
346
|
+
} catch (err) {
|
|
347
|
+
void err;
|
|
348
|
+
this.log("Failed to parse knip JSON output");
|
|
349
|
+
return {
|
|
350
|
+
success: false,
|
|
351
|
+
issues: [],
|
|
352
|
+
unusedExports: [],
|
|
353
|
+
unusedFiles: [],
|
|
354
|
+
unusedDeps: [],
|
|
355
|
+
unlistedDeps: [],
|
|
356
|
+
summary: "Failed to parse output",
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
}
|